[bug] opencode-cli provider broken on Windows: spawn EINVAL, argv too long, missing executable extension
Environment
- OS: Windows 11 (also reproducible on Windows 10)
- Node: 22.6+ (tested on 24.16.0)
- SwarmClaw: 1.9.40
- Session config:
provider: opencode-cli, model: minimax/MiniMax-M3
- opencode CLI installed via
npm i -g opencode-ai
Reproduction
- Install SwarmClaw and configure a session with
provider: opencode-cli.
- Open the chat UI at
http://localhost:3456 and send any message (e.g. "hello").
- Observe the response.
Observed
The error changes as you patch each layer — four separate Windows-only bugs are stacked:
Bug 1 — Error: OpenCode CLI exited with code 1: 命令行太长
- File:
src/lib/providers/opencode-cli.ts:69
- Code:
shell: process.platform === 'win32'
- Cause: On Windows,
shell: true makes Node concatenate args into a single string passed to cmd /c. SwarmClaw injects the full agent system prompt (typically 5–25 KB) into the prompt argument via args = ['run', prompt, '--format', 'json', …]. That blows past the Windows 8192-byte CreateProcess command-line limit, and cmd.exe refuses with "命令行太长".
Bug 2 — Error: spawn EINVAL (path resolution)
- File:
src/lib/server/session-tools/context.ts:174-199 (findBinaryOnPath)
- Code:
const resolved = (probe.stdout || '').trim() || null
- Cause: On Windows,
where <name> returns one path per line. For npm-installed binaries it returns multiple matches (the POSIX-shell shim <name>, <name>.cmd, <name>.ps1). .trim() only strips the outer whitespace, so findBinaryOnPath returns the literal string "C:\…\npm\opencode\r\nC:\…\npm\opencode.cmd". spawn() then chokes on the embedded \r\n with EINVAL.
- Same bug affects every CLI provider (
claude-cli, codex-cli, gemini-cli, copilot-cli, droid-cli, cursor-cli, qwen-cli, goose), not just opencode-cli.
Bug 3 — Error: spawn EINVAL (binary is a .ps1 / .cmd / extension-less shim)
- File:
src/lib/providers/opencode-cli.ts:62-70
- Cause: With
shell: false, CreateProcess cannot execute .ps1 or .cmd files directly, and the npm opencode extension-less shim (#!/bin/sh) is not a Windows executable either. After Bug 2 is fixed, resolveCliBinary('opencode') returns the first where match (C:\…\npm\opencode — the shim) and spawn fails with EINVAL.
Bug 4 (residual) — Process closed: code=0 events=0 response=0chars
Even after all of the above are fixed, opencode.exe (Go runtime) reads an anonymous-pipe stdin from Node's spawn as EOF and exits immediately with code 0, producing zero output. PowerShell Start-Process -RedirectStandardInput <file> works correctly because it hands the child a real file handle. The workaround in the patch is to write the prompt to a temp file and pass its file descriptor as stdio[0].
Proposed fix
Three files changed. Diff summary below.
1. src/lib/server/session-tools/context.ts — split where output
const probe = isWindows
? spawnSync('where', [binaryName], { encoding: 'utf-8', timeout: 2000, stdio: 'pipe' })
: spawnSync(process.env.SHELL || '/bin/bash', ['-lc', `command -v ${binaryName} 2>/dev/null`], { encoding: 'utf-8', timeout: 2000 })
- const resolved = (probe.stdout || '').trim() || null
+ // `where` (Windows) and `command -v` (POSIX) can both emit multiple paths
+ // separated by newlines. Trim each line, take the first non-empty match —
+ // a multi-line blob like "a\r\nb" would later crash Node spawn with EINVAL
+ // when it tries to launch a path that contains an embedded newline.
+ const lines = (probe.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
+ const resolved = lines[0] || null
2. src/lib/providers/opencode-cli.ts — drop shell: true, resolve the real .exe, deliver prompt via stdin
-import { spawn } from 'child_process'
+import { spawn, spawnSync } from 'child_process'
+import fs from 'fs'
+import path from 'path'
+import os from 'os'
import type { StreamChatOptions } from './index'
-export const OPENCODE_CLI_STDIO: ['ignore', 'pipe', 'pipe'] = ['ignore', 'pipe', 'pipe']
+// stdin is writable so the prompt can be piped in (avoids the Windows
+// CreateProcess / cmd.exe argv length limit of ~8 KB on win32).
+export const OPENCODE_CLI_STDIO: ['pipe', 'pipe', 'pipe'] = ['pipe', 'pipe', 'pipe']
…
- const args = ['run', prompt, '--format', 'json']
+ const args: string[] = ['run', '--format', 'json']
if (session.opencodeSessionId) args.push('--session', session.opencodeSessionId)
if (session.model) args.push('--model', session.model)
if (imagePath) args.push('--file', imagePath)
+
+ // On Windows, the npm-installed opencode wrapper resolves to either
+ // opencode.ps1 (PowerShell) or opencode.cmd (cmd batch). Neither can be
+ // launched by CreateProcess with shell:false. Resolve the underlying
+ // opencode.exe from the package's bin/ directory instead — that is the
+ // real native binary and avoids spawning an extra interpreter process.
+ let execBinary = binary
+ if (process.platform === 'win32') {
+ // The PATH lookup also returns a POSIX-shell shim with no extension
+ // (npm installs `<name>` as a #!/bin/sh stub on Windows). CreateProcess
+ // can't run those, so re-query `where` and pick the first match with a
+ // recognised executable extension.
+ const looksExecutable = /\.(exe|cmd|bat|ps1)$/i.test(binary)
+ if (!looksExecutable) {
+ try {
+ const probe = spawnSync('where', ['opencode'], { encoding: 'utf-8', timeout: 2000 })
+ const candidates = (probe.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
+ for (const c of candidates) {
+ if (/\.(exe|cmd|bat|ps1)$/i.test(c) && fs.existsSync(c)) {
+ log.info('opencode-cli', `Skipped non-executable wrapper, using: ${c}`)
+ execBinary = c
+ break
+ }
+ }
+ } catch {
+ // Fall through to the original binary.
+ }
+ }
+ if (!/\.exe$/i.test(execBinary)) {
+ const exeCandidate = path.join(path.dirname(execBinary), 'node_modules', 'opencode-ai', 'bin', 'opencode.exe')
+ try {
+ const resolved = fs.realpathSync(exeCandidate)
+ if (fs.existsSync(resolved)) {
+ log.info('opencode-cli', `Using opencode.exe directly: ${resolved}`)
+ execBinary = resolved
+ }
+ } catch {
+ // Fall through to whatever we have.
+ }
+ }
+ }
…
- const proc = spawn(binary, args, {
+ // opencode.exe on Windows reads the prompt from stdin, but if stdin is an
+ // anonymous pipe handle it exits immediately without producing output.
+ // Writing the prompt to a temp file and handing the file descriptor to
+ // CreateProcess instead makes the CLI behave correctly while still avoiding
+ // the cmd.exe ~8 KB argv length limit.
+ let stdinFd: number | null = null
+ let stdinFile: string | null = null
+ const stdio: ['pipe' | number, 'pipe', 'pipe'] = ['pipe', 'pipe', 'pipe']
+ if (!session.opencodeSessionId && prompt.length > 0) {
+ stdinFile = path.join(os.tmpdir(), `swarmclaw-opencode-${process.pid}-${Date.now()}.txt`)
+ fs.writeFileSync(stdinFile, prompt, 'utf8')
+ stdinFd = fs.openSync(stdinFile, 'r')
+ stdio[0] = stdinFd
+ }
+
+ const proc = spawn(execBinary, args, {
cwd,
env,
- // stdin must be closed: OpenCode CLI can wait forever on a connected pipe
- // even when the prompt is passed via argv.
- stdio: OPENCODE_CLI_STDIO,
+ stdio,
timeout: processTimeoutMs,
- shell: process.platform === 'win32',
})
-
- // Deliver the prompt via stdin. When resuming an existing session we send
- // EOF immediately so the CLI does not block waiting on an attached pipe.
- try {
- if (session.opencodeSessionId) {
- proc.stdin!.end()
- } else {
- proc.stdin!.write(prompt)
- proc.stdin!.end()
- }
- } catch (e) {
- log.warn('opencode-cli', `stdin write failed: ${(e as Error).message}`)
- proc.kill()
- }
+
+ if (stdio[0] === 'pipe') {
+ try { proc.stdin!.end() } catch { /* already closed */ }
+ }
+
+ proc.once('close', () => {
+ if (stdinFd !== null) { try { fs.closeSync(stdinFd) } catch { /* already closed */ } }
+ if (stdinFile) { try { fs.unlinkSync(stdinFile) } catch { /* best-effort */ } }
+ })
Known limitation (separate issue)
After all four fixes above are applied, the provider spawns opencode.exe successfully (process exits with code=0), but the child produces zero JSON events (events=0 response=0chars). The same prompt delivered via PowerShell Start-Process -RedirectStandardInput <file> works correctly (returns "Hello!" in ~10 s).
This points to opencode.exe (Go runtime on Windows) not consuming anonymous-pipe stdin from Node's spawn the same way it consumes a real file handle. The temp-file + file-descriptor workaround in the patch above makes the prompt reach the child but does not make the child emit output. The opencode CLI itself needs to be fixed for full Node.js interop; this PR only resolves the SwarmClaw-side Windows bugs so the failure mode is no longer masked by EINVAL / "命令行太长".
Verification log
After applying the patch against SwarmClaw 1.9.40 on Windows 11 / Node 24.16:
[INFO] [opencode-cli] Skipped non-executable wrapper, using: C:\…\npm\opencode.cmd
[INFO] [opencode-cli] Using opencode.exe directly: C:\…\npm\node_modules\opencode-ai\bin\opencode.exe
[INFO] [opencode-cli] Spawning: C:\…\opencode.exe | {"args":["run","--format","json","--model","minimax/MiniMax-M3"],"promptLength":21367,"promptViaStdin":true,…}
[INFO] [opencode-cli] Process spawned: pid=67012
[INFO] [opencode-cli] Process closed: code=0 signal=null events=0 response=0chars
[INFO] [session-run] Run finished … | {"status":"completed","persisted":false,"hasText":false,"error":null}
[bug] opencode-cli provider broken on Windows: spawn EINVAL, argv too long, missing executable extension
Environment
provider: opencode-cli,model: minimax/MiniMax-M3npm i -g opencode-aiReproduction
provider: opencode-cli.http://localhost:3456and send any message (e.g. "hello").Observed
The error changes as you patch each layer — four separate Windows-only bugs are stacked:
Bug 1 —
Error: OpenCode CLI exited with code 1: 命令行太长src/lib/providers/opencode-cli.ts:69shell: process.platform === 'win32'shell: truemakes Node concatenateargsinto a single string passed tocmd /c. SwarmClaw injects the full agent system prompt (typically 5–25 KB) into the prompt argument viaargs = ['run', prompt, '--format', 'json', …]. That blows past the Windows 8192-byteCreateProcesscommand-line limit, andcmd.exerefuses with "命令行太长".Bug 2 —
Error: spawn EINVAL(path resolution)src/lib/server/session-tools/context.ts:174-199(findBinaryOnPath)const resolved = (probe.stdout || '').trim() || nullwhere <name>returns one path per line. For npm-installed binaries it returns multiple matches (the POSIX-shell shim<name>,<name>.cmd,<name>.ps1)..trim()only strips the outer whitespace, sofindBinaryOnPathreturns the literal string"C:\…\npm\opencode\r\nC:\…\npm\opencode.cmd".spawn()then chokes on the embedded\r\nwithEINVAL.claude-cli,codex-cli,gemini-cli,copilot-cli,droid-cli,cursor-cli,qwen-cli,goose), not justopencode-cli.Bug 3 —
Error: spawn EINVAL(binary is a.ps1/.cmd/ extension-less shim)src/lib/providers/opencode-cli.ts:62-70shell: false,CreateProcesscannot execute.ps1or.cmdfiles directly, and the npmopencodeextension-less shim (#!/bin/sh) is not a Windows executable either. After Bug 2 is fixed,resolveCliBinary('opencode')returns the firstwherematch (C:\…\npm\opencode— the shim) andspawnfails withEINVAL.Bug 4 (residual) —
Process closed: code=0 events=0 response=0charsEven after all of the above are fixed,
opencode.exe(Go runtime) reads an anonymous-pipe stdin from Node'sspawnas EOF and exits immediately with code 0, producing zero output. PowerShellStart-Process -RedirectStandardInput <file>works correctly because it hands the child a real file handle. The workaround in the patch is to write the prompt to a temp file and pass its file descriptor asstdio[0].Proposed fix
Three files changed. Diff summary below.
1.
src/lib/server/session-tools/context.ts— splitwhereoutputconst probe = isWindows ? spawnSync('where', [binaryName], { encoding: 'utf-8', timeout: 2000, stdio: 'pipe' }) : spawnSync(process.env.SHELL || '/bin/bash', ['-lc', `command -v ${binaryName} 2>/dev/null`], { encoding: 'utf-8', timeout: 2000 }) - const resolved = (probe.stdout || '').trim() || null + // `where` (Windows) and `command -v` (POSIX) can both emit multiple paths + // separated by newlines. Trim each line, take the first non-empty match — + // a multi-line blob like "a\r\nb" would later crash Node spawn with EINVAL + // when it tries to launch a path that contains an embedded newline. + const lines = (probe.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean) + const resolved = lines[0] || null2.
src/lib/providers/opencode-cli.ts— dropshell: true, resolve the real.exe, deliver prompt via stdinKnown limitation (separate issue)
After all four fixes above are applied, the provider spawns
opencode.exesuccessfully (process exits withcode=0), but the child produces zero JSON events (events=0 response=0chars). The same prompt delivered via PowerShellStart-Process -RedirectStandardInput <file>works correctly (returns "Hello!" in ~10 s).This points to
opencode.exe(Go runtime on Windows) not consuming anonymous-pipe stdin from Node'sspawnthe same way it consumes a real file handle. The temp-file + file-descriptor workaround in the patch above makes the prompt reach the child but does not make the child emit output. The opencode CLI itself needs to be fixed for full Node.js interop; this PR only resolves the SwarmClaw-side Windows bugs so the failure mode is no longer masked byEINVAL/ "命令行太长".Verification log
After applying the patch against SwarmClaw
1.9.40on Windows 11 / Node 24.16: