1 //! Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and exits.
2 //!
3 //! This is the entry point for CI, scripts, cron, and the Cloud Agent sandbox
4 //! runner: no client, no TTY, just plain stdio. Assistant text streams to
5 //! stdout as it is generated (`--quiet` restricts stdout to the final message);
6 //! logs and tool progress go to stderr.
7 //!
8 //! Cross-platform by design — unlike the ratatui TUI this is NOT gated on
9 //! `#[cfg(unix)]`, so Windows gets it too.
10 //!
11 //! Permission model: nobody is around to answer `ask`, so `ask` collapses to a
12 //! denial telling the model the tool was not pre-approved (mentioning
13 //! `--allow-tool`). `--allow-tool <name>` pre-grants a tool for the run's
14 //! session; `--deny-tool <name>` blocks a tool even if settings would allow it.
15 //! `SIGIT_PERMISSIONS=allow` remains the blunt instrument.
16 //!
17 //! Exit codes: 0 — turn completed; 1 — inference/tool-loop or backend
18 //! resolution error; 2 — bad invocation (handled by the caller in `main`).
19
20 use std::collections::HashSet;
21 use std::io::Write;
22 use std::path::PathBuf;
23 use std::sync::Arc;
24
25 use crate::backend::{
26 self, InferenceBackend, OpenAiBackend, ToolResult as BackendToolResult, TurnResult,
27 };
28 use crate::{permissions, provider, session_store, settings, tools};
29
30 /// Session id for headless runs: permission grants and the saved conversation
31 /// live under this key, so a follow-up feature can resume it.
32 pub const HEADLESS_SESSION: &str = "headless";
33
34 pub const USAGE: &str = "Usage: sigit -p \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
35 [--quiet] [--allow-tool <name>]... [--deny-tool <name>]...";
36
37 /// Parsed `sigit -p` invocation.
38 #[derive(Debug, Clone, PartialEq, Eq)]
39 pub struct HeadlessConfig {
40 pub prompt: String,
41 /// Working directory to enter before anything loads (instruction files and
42 /// project-local MCP config then resolve from it).
43 pub cwd: Option<PathBuf>,
44 /// Extra project roots beyond `cwd`, the headless counterpart of a
45 /// multi-root project in the editor (see `workspace.rs`).
46 pub add_dirs: Vec<PathBuf>,
47 /// Print only the final assistant message to stdout (no streaming).
48 pub quiet: bool,
49 /// Tools pre-approved for the run (fed to `permissions::grant_for_session`).
50 pub allow_tools: Vec<String>,
51 /// Tools blocked for the run, overriding even settings-level allow.
52 pub deny_tools: Vec<String>,
53 }
54
55 /// Parse the process arguments (without argv[0]) for headless mode.
56 ///
57 /// Returns `Ok(None)` when `-p`/`--prompt` is absent — the invocation is not
58 /// headless and falls through to the TTY/ACP dispatch. Once `-p` is present,
59 /// every remaining argument must be a recognized flag (position-insensitive);
60 /// anything else is a usage error the caller reports on stderr with exit 2.
61 pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
62 if !args.iter().any(|arg| arg == "-p" || arg == "--prompt") {
63 return Ok(None);
64 }
65
66 let mut prompt: Option<String> = None;
67 let mut cwd: Option<PathBuf> = None;
68 let mut add_dirs: Vec<PathBuf> = Vec::new();
69 let mut quiet = false;
70 let mut allow_tools: Vec<String> = Vec::new();
71 let mut deny_tools: Vec<String> = Vec::new();
72
73 let mut iter = args.iter();
74 while let Some(arg) = iter.next() {
75 match arg.as_str() {
76 "-p" | "--prompt" => {
77 let value = iter
78 .next()
79 .ok_or_else(|| format!("{arg} requires a prompt argument"))?;
80 if prompt.is_some() {
81 return Err(format!("{arg} was given more than once"));
82 }
83 prompt = Some(value.clone());
84 }
85 "--cwd" => {
86 let value = iter
87 .next()
88 .ok_or_else(|| "--cwd requires a directory argument".to_string())?;
89 cwd = Some(PathBuf::from(value));
90 }
91 "--add-dir" => {
92 let value = iter
93 .next()
94 .ok_or_else(|| "--add-dir requires a directory argument".to_string())?;
95 add_dirs.push(PathBuf::from(value));
96 }
97 "--quiet" => quiet = true,
98 "--allow-tool" => {
99 let value = iter
100 .next()
101 .ok_or_else(|| "--allow-tool requires a tool name".to_string())?;
102 allow_tools.push(value.clone());
103 }
104 "--deny-tool" => {
105 let value = iter
106 .next()
107 .ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
108 deny_tools.push(value.clone());
109 }
110 other => return Err(format!("unknown argument: {other}")),
111 }
112 }
113
114 // Unreachable in practice (the pre-check saw a `-p` token), unless that
115 // token was consumed as another flag's value — still a usage error.
116 let prompt = prompt.ok_or_else(|| "missing -p/--prompt".to_string())?;
117 if prompt.trim().is_empty() {
118 return Err("the prompt must not be empty".to_string());
119 }
120
121 Ok(Some(HeadlessConfig {
122 prompt,
123 cwd,
124 add_dirs,
125 quiet,
126 allow_tools,
127 deny_tools,
128 }))
129 }
130
131 /// Denial fed to the model when a tool at `ask` level fires in a headless run.
132 fn not_preapproved_denial(tool_name: &str) -> String {
133 format!(
134 "`{tool_name}` was not executed: this is a non-interactive headless run and \
135 nobody can answer a permission prompt, so tools at the `ask` level are denied \
136 unless pre-approved. The user can re-run with `--allow-tool {tool_name}` to \
137 approve it (or set SIGIT_PERMISSIONS=allow). Do not retry the same call; \
138 continue without this tool or report what remains to be done."
139 )
140 }
141
142 /// Denial fed to the model when a tool was blocked with `--deny-tool`.
143 fn deny_flag_denial(tool_name: &str) -> String {
144 format!(
145 "`{tool_name}` is blocked for this headless run (--deny-tool). Do not retry it; \
146 continue without this tool or report what remains to be done."
147 )
148 }
149
150 /// Run one headless prompt to completion. Returns the process exit code.
151 ///
152 /// The caller (`main`) has already applied `--cwd` and `--add-dir`, initialized
153 /// logging to stderr, set up the model cache, and run MCP discovery.
154 pub async fn run(config: HeadlessConfig) -> i32 {
155 // Fresh permission state for the run, then apply the flag grants.
156 permissions::reset_session(HEADLESS_SESSION);
157 for tool in &config.allow_tools {
158 // No call arguments at grant time: the empty string records the bare
159 // tool name, granting the whole tool — the --allow-tool contract.
160 permissions::grant_for_session(HEADLESS_SESSION, tool, "");
161 }
162 let denied: HashSet<&str> = config.deny_tools.iter().map(String::as_str).collect();
163
164 // Backend resolution mirrors the ACP server: the explicit provider override
165 // first, else the signed-in cloud tier when local inference is off (what
166 // `apply_startup_inference_mode` does at every ACP session entry). There is
167 // never an implicit on-device load — a fresh process has no model in memory
168 // and headless mode must not silently download gigabytes.
169 let provider_cfg = provider::active_provider().or_else(|| {
170 if settings::local_inference_enabled() {
171 None
172 } else {
173 provider::cloud_tier_provider("balanced")
174 }
175 });
176 let Some(cfg) = provider_cfg else {
177 eprintln!(
178 "sigit: headless mode needs a remote inference provider — running on-device \
179 would require loading (and possibly downloading) a local model, which \
180 headless mode never does implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY \
181 (or configure providers.toml), or sign in with `sigit login` and turn local \
182 inference off."
183 );
184 return 1;
185 };
186
187 log::info!(
188 "headless: using {} (model {}) at {}",
189 cfg.display_name,
190 cfg.model,
191 cfg.base_url
192 );
193 crate::register_subagent_factory_for(&cfg);
194
195 // Same always-on project context the other surfaces inject: cwd guidance
196 // plus AGENTS.md / CLAUDE.md instruction files.
197 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
198 let mut system_prompt = crate::system_prompt_for_model(true).to_string();
199 system_prompt.push_str("\n\n");
200 system_prompt.push_str(&crate::session_context_message(
201 &cwd,
202 &crate::workspace::additional_roots(),
203 ));
204
205 let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
206 cfg.base_url,
207 cfg.api_key,
208 cfg.model,
209 Some(system_prompt),
210 ));
211
212 let outcome = run_prompt(backend.as_ref(), &config, &denied).await;
213
214 // Persist the conversation like the other surfaces, so a follow-up feature
215 // can resume it. Saved even on error: a partial transcript beats none.
216 let snapshot = backend.history_snapshot().await;
217 if let Err(error) = session_store::save(HEADLESS_SESSION, &snapshot) {
218 log::warn!("headless: session save failed: {error}");
219 }
220
221 match outcome {
222 Ok(()) => 0,
223 Err(error) => {
224 eprintln!("sigit: {error}");
225 1
226 }
227 }
228 }
229
230 /// The turn loop: send the prompt, execute tool calls under the permission
231 /// policy, feed results back, repeat up to `MAX_TOOL_ROUNDS` — the same shape
232 /// as the ACP `handle_prompt`, minus the ACP notifications.
233 async fn run_prompt(
234 backend: &dyn InferenceBackend,
235 config: &HeadlessConfig,
236 denied: &HashSet<&str>,
237 ) -> Result<(), backend::BackendError> {
238 let tools = crate::agent_tools_as_specs();
239
240 // Token sink: assistant text streams through this while a turn runs; the
241 // drain loop forwards the visible portion to stdout live. In quiet mode no
242 // sink is passed and only the final message is printed.
243 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
244 let sink_opt = if config.quiet { None } else { Some(&sink) };
245 let mut reply = crate::StreamedReply::default();
246
247 let mut result = drain_to_stdout(
248 backend.send_message_with_tools(&config.prompt, &tools, sink_opt),
249 &mut sink_rx,
250 &mut reply,
251 )
252 .await?;
253
254 let mut round = 0;
255
256 while !result.tool_calls.is_empty() && round < crate::MAX_TOOL_ROUNDS {
257 round += 1;
258
259 // Auto-compaction: long tool runs grow history fast; fold it into a
260 // summary before the next round rather than blowing the window.
261 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
262 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
263 log::info!(
264 "headless: history ≈{estimate} tokens exceeds budget {} — compacting",
265 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
266 );
267 if let Err(error) = backend.compact_history(backend::COMPACT_KEEP_LAST).await {
268 log::warn!("headless: compaction failed: {error}");
269 }
270 }
271
272 let mut tool_results = Vec::new();
273
274 for tc in &result.tool_calls {
275 let args_preview: String = tc.arguments.chars().take(120).collect();
276 eprintln!("→ {}({args_preview})", tc.name);
277
278 // The headless deny set outranks everything, including a
279 // settings-level allow and a --allow-tool grant for the same name.
280 let output = if denied.contains(tc.name.as_str()) {
281 log::info!("headless: {} blocked by --deny-tool", tc.name);
282 deny_flag_denial(&tc.name)
283 } else {
284 match permissions::decision_for(HEADLESS_SESSION, &tc.name, &tc.arguments) {
285 permissions::Decision::Allow => {
286 tools::execute_tool(&tc.name, &tc.arguments).await
287 }
288 permissions::Decision::Deny(reason) => {
289 log::info!("headless: {} denied by policy", tc.name);
290 reason
291 }
292 // Nobody can answer an interactive prompt here: ask
293 // collapses to a denial pointing at --allow-tool.
294 permissions::Decision::Ask => {
295 log::info!("headless: {} not pre-approved — denied", tc.name);
296 not_preapproved_denial(&tc.name)
297 }
298 }
299 };
300
301 tool_results.push(BackendToolResult {
302 tool_call_id: tc.id.clone(),
303 content: output,
304 });
305 }
306
307 let next_tools = if round < crate::MAX_TOOL_ROUNDS {
308 Some(tools.as_slice())
309 } else {
310 None // last round: force text
311 };
312
313 // Whatever this round says starts a new paragraph rather than
314 // continuing the sentence the tool calls interrupted.
315 reply.interrupt();
316
317 result = drain_to_stdout(
318 backend.send_tool_results(tool_results, next_tools, sink_opt),
319 &mut sink_rx,
320 &mut reply,
321 )
322 .await?;
323 }
324
325 // Final text: in quiet mode nothing streamed, so print the final assistant
326 // message now; otherwise print only what streaming did not already cover.
327 let (_think, final_visible) = crate::chat::strip_think_blocks(result.text.trim());
328 let mut stdout = std::io::stdout();
329 if config.quiet {
330 if !final_visible.is_empty() {
331 let _ = writeln!(stdout, "{final_visible}");
332 }
333 } else if reply.streamed_any {
334 // The reply is already on stdout; end the line for the shell.
335 let _ = writeln!(stdout);
336 } else if !final_visible.is_empty() {
337 let _ = writeln!(stdout, "{final_visible}");
338 }
339 let _ = stdout.flush();
340
341 log::info!("headless: prompt complete — {round} tool round(s)");
342 Ok(())
343 }
344
345 /// Run one inference turn while forwarding streamed tokens to stdout as they
346 /// arrive — the stdio counterpart of `SiGitAgent::drain_turn`.
347 async fn drain_to_stdout<F>(
348 fut: F,
349 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
350 reply: &mut crate::StreamedReply,
351 ) -> Result<TurnResult, backend::BackendError>
352 where
353 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
354 {
355 tokio::pin!(fut);
356 let result = loop {
357 tokio::select! {
358 done = &mut fut => break done,
359 Some(piece) = sink_rx.recv() => {
360 emit_visible_chunk(&piece, reply);
361 }
362 }
363 };
364 // Flush tokens that landed between the last poll and the future resolving.
365 while let Ok(piece) = sink_rx.try_recv() {
366 emit_visible_chunk(&piece, reply);
367 }
368 result
369 }
370
371 /// Fold a streamed fragment into `reply` and print whatever it newly reveals —
372 /// the stdio counterpart of `SiGitAgent::emit_visible_chunk`.
373 fn emit_visible_chunk(piece: &str, reply: &mut crate::StreamedReply) {
374 if let Some(extra) = reply.push(piece) {
375 print!("{extra}");
376 let _ = std::io::stdout().flush();
377 }
378 }
379
380 #[cfg(test)]
381 mod tests {
382 use super::*;
383
384 fn args(list: &[&str]) -> Vec<String> {
385 list.iter().map(|s| s.to_string()).collect()
386 }
387
388 #[test]
389 fn absent_prompt_flag_is_not_headless() {
390 assert_eq!(parse_args(&args(&[])), Ok(None));
391 assert_eq!(parse_args(&args(&["login"])), Ok(None));
392 // Flags alone don't trigger headless mode; only -p/--prompt does.
393 assert_eq!(parse_args(&args(&["--quiet"])), Ok(None));
394 }
395
396 #[test]
397 fn parses_minimal_invocation() {
398 let config = parse_args(&args(&["-p", "do the thing"])).unwrap().unwrap();
399 assert_eq!(config.prompt, "do the thing");
400 assert_eq!(config.cwd, None);
401 assert!(!config.quiet);
402 assert!(config.allow_tools.is_empty());
403 assert!(config.deny_tools.is_empty());
404 }
405
406 #[test]
407 fn long_form_prompt_flag_works() {
408 let config = parse_args(&args(&["--prompt", "hello"])).unwrap().unwrap();
409 assert_eq!(config.prompt, "hello");
410 }
411
412 #[test]
413 fn parses_all_flags_position_insensitively() {
414 let config = parse_args(&args(&[
415 "--quiet",
416 "--allow-tool",
417 "run_command",
418 "--cwd",
419 "/tmp/project",
420 "-p",
421 "build it",
422 "--deny-tool",
423 "delete_file",
424 "--allow-tool",
425 "edit_file",
426 ]))
427 .unwrap()
428 .unwrap();
429 assert_eq!(config.prompt, "build it");
430 assert_eq!(config.cwd, Some(PathBuf::from("/tmp/project")));
431 assert!(config.quiet);
432 assert_eq!(config.allow_tools, vec!["run_command", "edit_file"]);
433 assert_eq!(config.deny_tools, vec!["delete_file"]);
434 }
435
436 #[test]
437 fn add_dir_is_repeatable_and_keeps_its_order() {
438 let config = parse_args(&args(&[
439 "-p",
440 "look around",
441 "--add-dir",
442 "/tmp/lib",
443 "--add-dir",
444 "/tmp/docs",
445 ]))
446 .unwrap()
447 .unwrap();
448 assert_eq!(
449 config.add_dirs,
450 vec![PathBuf::from("/tmp/lib"), PathBuf::from("/tmp/docs")]
451 );
452 }
453
454 #[test]
455 fn add_dir_without_a_value_is_a_usage_error() {
456 assert!(parse_args(&args(&["-p", "x", "--add-dir"])).is_err());
457 }
458
459 #[test]
460 fn prompt_value_may_look_like_a_flag() {
461 // -p consumes the next argument verbatim.
462 let config = parse_args(&args(&["-p", "--quiet"])).unwrap().unwrap();
463 assert_eq!(config.prompt, "--quiet");
464 assert!(!config.quiet);
465 }
466
467 #[test]
468 fn unknown_flag_is_a_usage_error() {
469 let error = parse_args(&args(&["-p", "x", "--frobnicate"])).unwrap_err();
470 assert!(error.contains("--frobnicate"), "{error}");
471 }
472
473 #[test]
474 fn missing_values_are_usage_errors() {
475 assert!(parse_args(&args(&["-p"])).is_err());
476 assert!(parse_args(&args(&["-p", "x", "--cwd"])).is_err());
477 assert!(parse_args(&args(&["-p", "x", "--allow-tool"])).is_err());
478 assert!(parse_args(&args(&["-p", "x", "--deny-tool"])).is_err());
479 }
480
481 #[test]
482 fn duplicate_prompt_is_a_usage_error() {
483 assert!(parse_args(&args(&["-p", "a", "--prompt", "b"])).is_err());
484 }
485
486 #[test]
487 fn empty_prompt_is_a_usage_error() {
488 assert!(parse_args(&args(&["-p", " "])).is_err());
489 }
490 }