diff --git a/crates/ai/src/cli_agents/adapters.rs b/crates/ai/src/cli_agents/adapters.rs index d8693d7f..a54d0eb2 100644 --- a/crates/ai/src/cli_agents/adapters.rs +++ b/crates/ai/src/cli_agents/adapters.rs @@ -66,6 +66,11 @@ pub trait AgentAdapter: Send + Sync { fn answers_questions(&self) -> bool { false } + /// The message goes in on stdin, not in argv: a long one passes Windows' command-line limit + /// ("The filename or extension is too long", os error 206, #402). + fn prompt_on_stdin(&self) -> bool { + false + } /// The process for a local run of `program` (an adapter may clean its environment). fn process(&self, program: &str) -> Process { Process::new(program) @@ -169,7 +174,6 @@ const CHANGING_TOOLS: &[&str] = fn question_args(mut a: Vec, cx: &ArgsCx) -> Vec { a.extend([ "-p".into(), - cx.prompt.to_string(), "--output-format".into(), "stream-json".into(), "--verbose".into(), @@ -201,6 +205,10 @@ impl AgentAdapter for ClaudeCode { true } + fn prompt_on_stdin(&self) -> bool { + true + } + fn check_tool_server(&self, stop: &CancelToken) -> Result<(), ToolError> { cli_can_start(craft_anything_platform::self_cli_path().as_deref(), stop) } @@ -211,15 +219,7 @@ impl AgentAdapter for ClaudeCode { return question_args(a, cx); } let mcp_json = json!({"mcpServers": {MCP_NAME: {"command": cx.mcp.command, "args": cx.mcp.args}}}).to_string(); - a.extend([ - "-p".into(), - cx.prompt.to_string(), - "--output-format".into(), - "stream-json".into(), - "--verbose".into(), - "--append-system-prompt".into(), - cx.preamble.to_string(), - ]); + a.extend(["-p".into(), "--output-format".into(), "stream-json".into(), "--verbose".into(), "--append-system-prompt".into(), cx.preamble.to_string()]); // Opus 5.5 everywhere unless config.toml names another model on purpose; the effort // goes in through the environment (see `run_cli`). a.extend(["--model".into(), cx.options.cli_model.clone().unwrap_or_else(|| crate::anthropic::DEFAULT_MODEL.into())]); diff --git a/crates/ai/src/cli_agents/mod.rs b/crates/ai/src/cli_agents/mod.rs index 98810126..f02f88f3 100644 --- a/crates/ai/src/cli_agents/mod.rs +++ b/crates/ai/src/cli_agents/mod.rs @@ -551,6 +551,9 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses if let Some(p) = spec.project.as_ref().filter(|_| remote.is_none()) { proc = proc.cwd(p); } + if adapter.prompt_on_stdin() { + proc = proc.stdin_bytes(text); + } let result = proc.run(); let p = parser.lock().unwrap_or_else(PoisonError::into_inner); if let Some(id) = &p.session_id { @@ -789,7 +792,8 @@ mod tests { fn claude_args() { let a = build_args(adapter_for("claude-code").as_ref(), &BackendOptions::default(), "-what?", "PRE", &mcp(), Some("sess-1"), Access::Analyse); assert_eq!(a[0], "-p"); - assert_eq!(a[1], " -what?"); + // #402: the message goes in on stdin, so a long one can't pass Windows' command-line limit. + assert!(adapter_for("claude-code").prompt_on_stdin() && !a.iter().any(|x| x.contains("what?")), "{a:?}"); let pos = |f: &str| a.iter().position(|x| x == f).unwrap(); assert_eq!(a[pos("--output-format") + 1], "stream-json"); assert!(a.contains(&"--verbose".to_string())); @@ -801,7 +805,7 @@ mod tests { assert_eq!(b[b.iter().position(|x| x == "--model").unwrap() + 1], "opus"); let cfg: Value = serde_json::from_str(&a[pos("--mcp-config") + 1]).unwrap(); assert_eq!(cfg["mcpServers"]["craft-anything"]["args"][0], "mcp"); - // variadic flags come last so the prompt is never swallowed + // variadic flags come last: nothing after them is read as one of their values assert!(pos("--mcp-config") > pos("--resume")); } @@ -1080,6 +1084,32 @@ mod tests { assert!(text.contains("Request"), "preamble is passed too"); } + /// #402: a 34,641-character message in argv passed Windows' command-line limit (os error 206); + /// Claude Code reads it from stdin instead. + #[cfg(unix)] + #[test] + fn claude_code_reads_a_long_message_on_stdin() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("claude"); + let reply = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"read %s bytes"}]}}\n"#; + std::fs::write(&program, format!("#!/bin/sh\nn=$(wc -c)\nprintf '{reply}' $n\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = + BackendConfig { name: "Claude".into(), kind: BackendKind::AgentCli, model: "claude-code".into(), base_url: None, ssh_host: None, enabled: true }; + let options = BackendOptions { command: Some(program.display().to_string()), ..Default::default() }; + let mut c = CliConversation::new(CliSpec::new(config, options, false, None)); + c.send(&"a".repeat(40_000)); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let mut all = Vec::new(); + while std::time::Instant::now() < deadline && !all.iter().any(|e| matches!(e, AgentEvent::TurnDone { .. })) { + all.extend(c.poll()); + std::thread::sleep(Duration::from_millis(10)); + } + let text: String = all.iter().filter_map(|e| if let AgentEvent::TextDelta { text } = e { Some(text.clone()) } else { None }).collect(); + assert_eq!(text, "read 40000 bytes", "{all:?}"); + } + /// #91: a Mac app with no CLI beside it reached Claude Code, which stopped with a stack trace. #[test] fn a_missing_or_broken_helper_is_said_plainly() { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7b9a4b06..9efb769e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -555,6 +555,9 @@ Owner: open · Status: in progress manifest) skip the approval policy; `maki acp` takes no tool flags to stop it (0.7.0). Start it in `app/` (paths in the prompt relative to that), or ask Maki for a way to make those writes ask; part of the approval decision above +- [ ] Codex, Gemini CLI, OpenCode, Hermes, Maki and the plain adapter still take the message in + argv, so a long one can pass Windows' command-line limit as Claude Code's did (#402); hand + each its message on stdin or in a file where its CLI reads one ### R2.3 Website coverage and a better visual metric Owner: open · Status: done diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 88c387f4..a5364fd0 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -143,6 +143,11 @@ external tool a live test that checks it really produced output, not just that i It happened again with Explorer: project folders are kept as `D:/Projects/…\name`, and "Open folder" opened Documents. Paths for the system's programs go through `platform::native_path`. +**A message on the command line.** Claude Code was handed the whole task as `-p `; a +34,641-character brief passed Windows' 32,767-character command-line limit, so the build never +started ("The filename or extension is too long", os error 206, #402). Give a program anything +that can grow (messages, briefs, lists of files) on stdin or in a file, never in argv. + **Console windows popping up over the app.** Only the detached launcher hid the console; the supervised runner didn't, so Ghidra's `.bat` and PowerShell each opened a console window during a real Deep-mode run (the CLI never shows it: it already has a console). Every child started from