fix: deliver prompt via stdin for non-Claude agents to avoid spawn ENAMETOOLONG on Windows (#15)

The composed prompt (system instructions + skill body + cwd hint + file
listing + user message) can easily exceed Windows' CreateProcess limit of
~32 KB when passed as a CLI argument via -p <string>.  This causes
spawn ENAMETOOLONG whenever Gemini CLI (or Codex, OpenCode, Cursor
Agent, Qwen) is selected on Windows — even for short user messages,
because the skill / design-system system prompt is folded in.

Fix: add promptViaStdin: true to every plain-text agent definition.
The daemon's /api/chat handler checks this flag, opens stdin as a pipe,
writes the composed prompt to it and closes the stream.  Claude Code is
unaffected — it still uses the -p argv path and a separate stream-json
parser.

docs/agent-adapters.md: update §5.5 Gemini CLI to document the stdin
delivery strategy, and update the Windows open-question note to reflect
the fix.

Co-authored-by: KNIGHTABDO <abdessamad.aabida-etu@etu.univh2c.ma>
Co-authored-by: pftom <1043269994@qq.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: lefarcen <20859779+lefarcen@users.noreply.github.com>
This commit is contained in:
KNIGHTABDO 2026-04-29 10:10:20 +01:00 committed by GitHub
parent 6625674925
commit 623444fe48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 19 deletions

View file

@ -154,9 +154,12 @@ export const AGENT_DEFS = [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
// Keep Codex in workspace-write sandbox while avoiding interactive
// permission prompts in terminal-less web UI.
// Prompt delivered via stdin (`codex exec -`) to avoid Windows
// `spawn ENAMETOOLONG` — CreateProcess caps argv at ~32 KB and the
// composed prompt easily exceeds that. `--full-auto` keeps Codex in
// its workspace-write sandbox while skipping interactive permission
// prompts in the no-TTY web UI.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['exec', '--full-auto'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
@ -166,9 +169,10 @@ export const AGENT_DEFS = [
// is exposed as `model_reasoning_effort`.
args.push('-c', `model_reasoning_effort="${options.reasoning}"`);
}
args.push(prompt);
args.push('-');
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
},
{
@ -181,14 +185,18 @@ export const AGENT_DEFS = [
{ id: 'gemini-2.5-pro', label: 'gemini-2.5-pro' },
{ id: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
// Gemini reads from stdin when `-p` is omitted and stdin is a pipe.
// Passing the full composed prompt as a CLI arg causes ENAMETOOLONG on
// Windows (CreateProcess limit ~32 KB) for any non-trivial prompt.
// `--yolo` skips interactive approval prompts in the no-TTY web UI.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['--yolo'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push('-p', prompt);
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
},
{
@ -208,14 +216,17 @@ export const AGENT_DEFS = [
{ id: 'openai/gpt-5', label: 'openai/gpt-5' },
{ id: 'google/gemini-2.5-pro', label: 'google/gemini-2.5-pro' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
// Prompt delivered via stdin (`opencode run -`) to avoid Windows
// `spawn ENAMETOOLONG` for large composed prompts.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['run'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push(prompt);
args.push('-');
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
},
{
@ -242,14 +253,18 @@ export const AGENT_DEFS = [
{ id: 'sonnet-4-thinking', label: 'sonnet-4-thinking' },
{ id: 'gpt-5', label: 'gpt-5' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
// Prompt delivered via stdin (`cursor-agent -`) to avoid Windows
// `spawn ENAMETOOLONG` for large composed prompts. `--force` skips
// interactive approval prompts in the no-TTY web UI.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['--force'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push('-p', prompt);
args.push('-');
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
},
{
@ -262,15 +277,18 @@ export const AGENT_DEFS = [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen3-coder-flash', label: 'qwen3-coder-flash' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
// Qwen Code is a Gemini-CLI fork and supports the same `--yolo` mode.
// Prompt delivered via stdin (`qwen -`) to avoid Windows
// `spawn ENAMETOOLONG` for large composed prompts. Qwen Code is a
// Gemini-CLI fork and supports the same `--yolo` non-interactive mode.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['--yolo'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push('-p', prompt);
args.push('-');
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
},
{
@ -322,7 +340,7 @@ export const AGENT_DEFS = [
},
];
function resolveOnPath(bin) {
export function resolveOnPath(bin) {
const exts =
process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
@ -412,6 +430,7 @@ function stripFns(def) {
return rest;
}
export async function detectAgents() {
const results = await Promise.all(AGENT_DEFS.map(probe));
// Refresh the validation cache from whatever we just surfaced to the UI

View file

@ -1010,12 +1010,29 @@ export async function startServer({ port = 7456 } = {}) {
let child;
try {
// When the agent definition sets `promptViaStdin`, pipe the composed
// prompt through stdin instead of embedding it in argv. Bypasses the
// OS command-line length limit (Windows CreateProcess caps at ~32 KB)
// which causes `spawn ENAMETOOLONG` for any non-trivial prompt.
const stdinMode = def.promptViaStdin ? 'pipe' : 'ignore';
child = spawn(resolvedBin, args, {
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
stdio: [stdinMode, 'pipe', 'pipe'],
cwd: cwd || undefined,
shell: useShell,
});
if (def.promptViaStdin && child.stdin) {
// EPIPE from a fast-exiting CLI (bad auth, missing model, exit on
// launch) would otherwise surface as an unhandled stream error and
// crash the daemon. Swallow it — the regular exit/close handlers
// below already route the underlying failure to SSE via stderr.
child.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') {
send('error', { message: `stdin: ${err.message}` });
}
});
child.stdin.end(composed, 'utf8');
}
} catch (err) {
send('error', { message: `spawn failed: ${err.message}` });
return res.end();

View file

@ -155,11 +155,19 @@ The adapter declares which strategy to use via `capabilities().nativeSkillLoadin
### 5.5 Gemini CLI
- Invocation: `gemini --prompt "<prompt>" --cwd <dir>`.
- Streaming: yes, but less structured tool events. Expect fewer surgical-edit capabilities.
- Invocation: `gemini` with the composed prompt delivered via **stdin** (no `-p` flag).
Gemini CLI enters headless mode automatically when stdin is a pipe and no `-p` flag is
supplied — verified with `gemini@0.1.x`.
- Streaming: yes, plain text to stdout.
- Skill loading: prompt injection only.
- Surgical edits: regenerate whole file.
- **Gotcha:** Gemini's tool-use format is distinct; we translate our file-write tool to its `file_tool` equivalent.
- **Gotcha — `spawn ENAMETOOLONG` on Windows:** Passing the full composed prompt as a
`-p <string>` CLI argument hits Windows' `CreateProcess` hard limit of ~32 KB for the
entire command line. The fix is to set `promptViaStdin: true` in the agent definition
and write the prompt to `child.stdin` after spawning. The daemon's `/api/chat` handler
checks this flag and opens stdin as a pipe instead of `'ignore'`.
- **Gotcha:** Gemini's tool-use format is distinct; we translate our file-write tool to its
`file_tool` equivalent when that feature is implemented.
### 5.6 OpenCode / OpenClaw
@ -263,5 +271,8 @@ Each adapter is a separate module so community contributions can add new ones wi
- **Nested agents.** What if Claude Code's agent itself spawns a subagent? We receive events from the outer process only. v1 policy: surface only top-level events; summarize subagent activity as "sub-task" placeholder.
- **Billing awareness.** Some agents bill per message, some per token. OD doesn't track cost in MVP; v1 adds an optional "usage" event from adapters that expose it.
- **Windows support.** PATH scanning and `spawn` semantics differ on Windows. v1 targets macOS and Linux; Windows is best-effort.
- **Windows support.** PATH scanning and `spawn` semantics differ on Windows. v1 targets
macOS and Linux; Windows is best-effort. Known issue fixed: `spawn ENAMETOOLONG` when
running Gemini CLI (and other plain-text agents) on Windows — resolved by routing the
composed prompt through stdin instead of as a CLI argument (see §5.5).
- **Docker-contained agents.** Some users run Claude Code in a container. Adapter needs a "remote" mode — probably same interface but talks over SSH. Phase 2+.