Skip to content

[bug] opencode-cli provider broken on Windows: spawn EINVAL, argv too long, missing executable extension #139

Description

@chanxiusun

[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

  1. Install SwarmClaw and configure a session with provider: opencode-cli.
  2. Open the chat UI at http://localhost:3456 and send any message (e.g. "hello").
  3. 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}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions