1
+//! Headless one-shot mode: `sigit run`.
2
+//!
3
+//! Runs a single task non-interactively and exits: the prompt arrives as a
4
+//! CLI flag, progress is emitted as JSONL events on stdout (one object per
5
+//! line), and logs stay on stderr. Built for cloud runners (siGit Code Cloud
6
+//! Agent) and scripting, where nobody is present to answer a permission
7
+//! prompt — on `Decision::Ask` the tool is declined with a pointer to
8
+//! `SIGIT_PERMISSIONS=allow` instead of blocking.
9
+//!
10
+//! The tool loop mirrors the ACP prompt handler (`handle_prompt` in
11
+//! `main.rs`): permission gate → execute → feed results back, with
12
+//! auto-compaction between rounds and a forced text reply on the final
13
+//! round. Keep the two in sync when changing loop semantics.
14
+
15
+use std::io::Write as _;
16
+use std::path::PathBuf;
17
+use std::sync::Arc;
18
+
19
+use serde_json::json;
20
+
21
+use crate::backend::{self, InferenceBackend, OpenAiBackend, ToolResult, ToolSpec};
22
+use crate::{permissions, provider, tools};
23
+
24
+/// Headless runs default to a higher round cap than interactive prompts: an
25
+/// autonomous task routinely needs long edit/build/test chains and there is
26
+/// no user present to re-prompt a stopped run.
27
+const DEFAULT_MAX_ROUNDS: usize = 40;
28
+
29
+/// Cap on `arguments`/`output` strings embedded in JSONL events. Full outputs
30
+/// still reach the model; events only need enough for a live transcript.
31
+const EVENT_FIELD_MAX_CHARS: usize = 4_000;
32
+
33
+const USAGE: &str = "usage: sigit run [--prompt <text> | --prompt-file <path>] \
34
+ [--cwd <dir>] [--max-rounds <n>] [--output jsonl|text]";
35
+
36
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37
+pub enum OutputMode {
38
+ Jsonl,
39
+ Text,
40
+}
41
+
42
+#[derive(Debug)]
43
+pub struct HeadlessOptions {
44
+ pub prompt: String,
45
+ pub cwd: PathBuf,
46
+ pub max_rounds: usize,
47
+ pub output: OutputMode,
48
+}
49
+
50
+/// The value following a flag, or a usage error naming the flag.
51
+fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
52
+ args.next()
53
+ .ok_or_else(|| format!("{flag} needs a value\n{USAGE}"))
54
+}
55
+
56
+/// Parse `sigit run` arguments (everything after the subcommand).
57
+pub fn parse_args(mut args: impl Iterator<Item = String>) -> Result<HeadlessOptions, String> {
58
+ let mut prompt: Option<String> = None;
59
+ let mut cwd: Option<PathBuf> = None;
60
+ let mut max_rounds = DEFAULT_MAX_ROUNDS;
61
+ let mut output = OutputMode::Jsonl;
62
+
63
+ while let Some(flag) = args.next() {
64
+ match flag.as_str() {
65
+ "--prompt" => {
66
+ let value = next_value(&mut args, "--prompt")?;
67
+ if prompt.is_some() {
68
+ return Err(format!("give --prompt or --prompt-file once\n{USAGE}"));
69
+ }
70
+ prompt = Some(value);
71
+ }
72
+ "--prompt-file" => {
73
+ let path = next_value(&mut args, "--prompt-file")?;
74
+ if prompt.is_some() {
75
+ return Err(format!("give --prompt or --prompt-file once\n{USAGE}"));
76
+ }
77
+ let text = std::fs::read_to_string(&path)
78
+ .map_err(|error| format!("cannot read --prompt-file {path}: {error}"))?;
79
+ prompt = Some(text);
80
+ }
81
+ "--cwd" => {
82
+ cwd = Some(PathBuf::from(next_value(&mut args, "--cwd")?));
83
+ }
84
+ "--max-rounds" => {
85
+ max_rounds = next_value(&mut args, "--max-rounds")?
86
+ .parse::<usize>()
87
+ .ok()
88
+ .filter(|n| *n > 0)
89
+ .ok_or_else(|| format!("--max-rounds needs a positive integer\n{USAGE}"))?;
90
+ }
91
+ "--output" => {
92
+ output = match next_value(&mut args, "--output")?.as_str() {
93
+ "jsonl" => OutputMode::Jsonl,
94
+ "text" => OutputMode::Text,
95
+ other => return Err(format!("unknown --output {other}\n{USAGE}")),
96
+ };
97
+ }
98
+ other => return Err(format!("unknown argument {other}\n{USAGE}")),
99
+ }
100
+ }
101
+
102
+ let prompt = prompt
103
+ .map(|text| text.trim().to_string())
104
+ .filter(|text| !text.is_empty())
105
+ .ok_or_else(|| format!("a non-empty --prompt or --prompt-file is required\n{USAGE}"))?;
106
+
107
+ let cwd = cwd.unwrap_or_else(|| PathBuf::from("."));
108
+ let cwd = cwd
109
+ .canonicalize()
110
+ .map_err(|error| format!("--cwd {}: {error}", cwd.display()))?;
111
+
112
+ Ok(HeadlessOptions {
113
+ prompt,
114
+ cwd,
115
+ max_rounds,
116
+ output,
117
+ })
118
+}
119
+
120
+/// Entry point for `sigit run`. Never returns on failure paths — exits the
121
+/// process with 0 (run completed), 1 (run failed), or 2 (usage/config error).
122
+pub async fn run(args: impl Iterator<Item = String>) -> anyhow::Result<()> {
123
+ let options = match parse_args(args) {
124
+ Ok(options) => options,
125
+ Err(message) => {
126
+ eprintln!("sigit run: {message}");
127
+ std::process::exit(2);
128
+ }
129
+ };
130
+
131
+ // Provider: the explicit override (env / providers.toml) first — this is
132
+ // how a cloud runner injects a per-run endpoint and token — then the
133
+ // signed-in cloud as a convenience. Never fall back to on-device: a
134
+ // headless host should not silently download a multi-GB model.
135
+ let Some(config) =
136
+ provider::active_provider().or_else(|| provider::cloud_tier_provider("large"))
137
+ else {
138
+ eprintln!(
139
+ "sigit run: no inference provider configured. Set OPENAI_BASE_URL and \
140
+ OPENAI_API_KEY (and optionally SIGIT_MODEL), configure providers.toml, \
141
+ or sign in with `sigit login`."
142
+ );
143
+ std::process::exit(2);
144
+ };
145
+
146
+ if let Err(error) = std::env::set_current_dir(&options.cwd) {
147
+ eprintln!("sigit run: cannot enter {}: {error}", options.cwd.display());
148
+ std::process::exit(2);
149
+ }
150
+
151
+ let system_prompt = format!(
152
+ "{}\n\n{}",
153
+ crate::system_prompt_for_model(true),
154
+ crate::session_context_message(&options.cwd)
155
+ );
156
+ let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
157
+ config.base_url.clone(),
158
+ config.api_key.clone(),
159
+ config.model.clone(),
160
+ Some(system_prompt),
161
+ ));
162
+ crate::register_subagent_factory_for(&config);
163
+ let tools = crate::agent_tools_as_specs();
164
+
165
+ let emitter = Emitter {
166
+ mode: options.output,
167
+ };
168
+ emitter.event(json!({
169
+ "type": "run_started",
170
+ "cwd": options.cwd.display().to_string(),
171
+ "model": config.model,
172
+ "max_rounds": options.max_rounds,
173
+ }));
174
+
175
+ let rounds = match drive_loop(&backend, &tools, &options, &emitter).await {
176
+ Ok((summary, rounds)) => {
177
+ emitter.event(json!({
178
+ "type": "result",
179
+ "status": "completed",
180
+ "summary": summary,
181
+ "rounds": rounds,
182
+ }));
183
+ rounds
184
+ }
185
+ Err((error, rounds)) => {
186
+ emitter.event(json!({
187
+ "type": "result",
188
+ "status": "failed",
189
+ "error": error,
190
+ "rounds": rounds,
191
+ }));
192
+ std::process::exit(1);
193
+ }
194
+ };
195
+ log::info!("headless run completed after {rounds} tool round(s)");
196
+ Ok(())
197
+}
198
+
199
+/// The tool loop. Returns `(summary, rounds)` or `(error, rounds)`.
200
+///
201
+/// Keep in sync with `handle_prompt` in `main.rs`: same permission gate, same
202
+/// auto-compaction trigger, same force-text final round.
203
+async fn drive_loop(
204
+ backend: &Arc<dyn InferenceBackend>,
205
+ tools: &[ToolSpec],
206
+ options: &HeadlessOptions,
207
+ emitter: &Emitter,
208
+) -> Result<(String, usize), (String, usize)> {
209
+ // Permission decisions are per-session state; a headless process is one
210
+ // session. There are no grants to accumulate (nobody can answer "always
211
+ // allow"), the id only namespaces the lookup.
212
+ let session = format!("headless-{}", std::process::id());
213
+
214
+ let mut result = backend
215
+ .send_message_with_tools(&options.prompt, tools, None)
216
+ .await
217
+ .map_err(|error| (format!("inference failed: {error}"), 0))?;
218
+ emitter.turn_text(&result.text);
219
+
220
+ let mut round = 0usize;
221
+
222
+ while !result.tool_calls.is_empty() && round < options.max_rounds {
223
+ round += 1;
224
+
225
+ // Auto-compaction: long tool runs grow history fast; fold it into a
226
+ // summary before the next round rather than blowing the window.
227
+ let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
228
+ if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
229
+ match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
230
+ Ok(()) => {
231
+ let after = backend::estimate_tokens(&backend.history_snapshot().await);
232
+ emitter.event(json!({
233
+ "type": "compaction",
234
+ "approx_tokens_before": estimate,
235
+ "approx_tokens_after": after,
236
+ }));
237
+ }
238
+ Err(error) => log::warn!("headless compaction failed: {error}"),
239
+ }
240
+ }
241
+
242
+ let mut tool_results = Vec::new();
243
+ for call in &result.tool_calls {
244
+ emitter.tool_call(call);
245
+ let (output, denied) = match permissions::decision_for(&session, &call.name) {
246
+ permissions::Decision::Allow => (
247
+ tools::execute_tool(&call.name, &call.arguments).await,
248
+ false,
249
+ ),
250
+ permissions::Decision::Deny(reason) => {
251
+ log::info!("headless: {} denied by policy", call.name);
252
+ (reason, true)
253
+ }
254
+ permissions::Decision::Ask => {
255
+ log::info!("headless: {} needs approval, declining", call.name);
256
+ (
257
+ format!(
258
+ "`{}` was not executed: headless mode cannot prompt for \
259
+ permission. Run with SIGIT_PERMISSIONS=allow to auto-approve \
260
+ mutating tools, or grant this tool in settings.toml.",
261
+ call.name
262
+ ),
263
+ true,
264
+ )
265
+ }
266
+ };
267
+ emitter.tool_result(call, &output, denied);
268
+ tool_results.push(ToolResult {
269
+ tool_call_id: call.id.clone(),
270
+ content: output,
271
+ });
272
+ }
273
+
274
+ let next_tools = if round < options.max_rounds {
275
+ Some(tools)
276
+ } else {
277
+ None // last round: force a text reply
278
+ };
279
+ result = backend
280
+ .send_tool_results(tool_results, next_tools, None)
281
+ .await
282
+ .map_err(|error| (format!("inference failed: {error}"), round))?;
283
+ emitter.turn_text(&result.text);
284
+ }
285
+
286
+ let (_think, visible) = crate::chat::strip_think_blocks(&result.text);
287
+ let summary = if visible.trim().is_empty() {
288
+ "The run finished without a final summary.".to_string()
289
+ } else {
290
+ visible.trim().to_string()
291
+ };
292
+ Ok((summary, round))
293
+}
294
+
295
+// ── Event output ─────────────────────────────────────────────────────────────
296
+
297
+struct Emitter {
298
+ mode: OutputMode,
299
+}
300
+
301
+impl Emitter {
302
+ /// Write one event. JSONL mode prints the object as-is; text mode renders
303
+ /// a human-oriented line per event kind.
304
+ fn event(&self, event: serde_json::Value) {
305
+ match self.mode {
306
+ OutputMode::Jsonl => {
307
+ let mut stdout = std::io::stdout().lock();
308
+ let _ = writeln!(stdout, "{event}");
309
+ let _ = stdout.flush();
310
+ }
311
+ OutputMode::Text => {
312
+ let line = match event["type"].as_str() {
313
+ Some("run_started") => format!(
314
+ "▶ run started in {} (model {})",
315
+ event["cwd"].as_str().unwrap_or("?"),
316
+ event["model"].as_str().unwrap_or("?"),
317
+ ),
318
+ Some("turn_text") => event["text"].as_str().unwrap_or_default().to_string(),
319
+ Some("tool_call") => format!(
320
+ "→ {}({})",
321
+ event["name"].as_str().unwrap_or("?"),
322
+ event["arguments"].as_str().unwrap_or_default(),
323
+ ),
324
+ Some("tool_result") => format!(
325
+ "← {} ({} chars{})",
326
+ event["name"].as_str().unwrap_or("?"),
327
+ event["output_chars"].as_u64().unwrap_or(0),
328
+ if event["denied"].as_bool().unwrap_or(false) {
329
+ ", denied"
330
+ } else {
331
+ ""
332
+ },
333
+ ),
334
+ Some("compaction") => "… compacted conversation history".to_string(),
335
+ Some("result") => match event["status"].as_str() {
336
+ Some("completed") => format!(
337
+ "✔ completed\n{}",
338
+ event["summary"].as_str().unwrap_or_default()
339
+ ),
340
+ _ => format!("✘ failed: {}", event["error"].as_str().unwrap_or("?")),
341
+ },
342
+ _ => event.to_string(),
343
+ };
344
+ if !line.is_empty() {
345
+ let mut stdout = std::io::stdout().lock();
346
+ let _ = writeln!(stdout, "{line}");
347
+ let _ = stdout.flush();
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ /// Emit the visible part of an assistant turn, skipping empty turns.
354
+ fn turn_text(&self, raw: &str) {
355
+ let (_think, visible) = crate::chat::strip_think_blocks(raw);
356
+ let visible = visible.trim();
357
+ if visible.is_empty() {
358
+ return;
359
+ }
360
+ let (text, truncated) = clip(visible, EVENT_FIELD_MAX_CHARS);
361
+ let mut event = json!({ "type": "turn_text", "text": text });
362
+ if truncated {
363
+ event["truncated"] = json!(true);
364
+ }
365
+ self.event(event);
366
+ }
367
+
368
+ fn tool_call(&self, call: &backend::ToolCall) {
369
+ let (arguments, truncated) = clip(&call.arguments, EVENT_FIELD_MAX_CHARS);
370
+ let mut event = json!({
371
+ "type": "tool_call",
372
+ "id": call.id,
373
+ "name": call.name,
374
+ "arguments": arguments,
375
+ });
376
+ if truncated {
377
+ event["truncated"] = json!(true);
378
+ }
379
+ self.event(event);
380
+ }
381
+
382
+ fn tool_result(&self, call: &backend::ToolCall, output: &str, denied: bool) {
383
+ let (clipped, truncated) = clip(output, EVENT_FIELD_MAX_CHARS);
384
+ let mut event = json!({
385
+ "type": "tool_result",
386
+ "id": call.id,
387
+ "name": call.name,
388
+ "output_chars": output.chars().count(),
389
+ "output": clipped,
390
+ "denied": denied,
391
+ });
392
+ if truncated {
393
+ event["truncated"] = json!(true);
394
+ }
395
+ self.event(event);
396
+ }
397
+}
398
+
399
+/// Truncate to `max` characters (not bytes — always on a char boundary).
400
+fn clip(text: &str, max: usize) -> (String, bool) {
401
+ if text.chars().count() <= max {
402
+ (text.to_string(), false)
403
+ } else {
404
+ (text.chars().take(max).collect(), true)
405
+ }
406
+}
407
+
408
+#[cfg(test)]
409
+mod tests {
410
+ use super::*;
411
+
412
+ fn args(list: &[&str]) -> impl Iterator<Item = String> {
413
+ list.iter()
414
+ .map(|s| s.to_string())
415
+ .collect::<Vec<_>>()
416
+ .into_iter()
417
+ }
418
+
419
+ #[test]
420
+ fn parses_prompt_and_defaults() {
421
+ let options = parse_args(args(&["--prompt", "fix the tests"])).expect("parses");
422
+ assert_eq!(options.prompt, "fix the tests");
423
+ assert_eq!(options.max_rounds, DEFAULT_MAX_ROUNDS);
424
+ assert_eq!(options.output, OutputMode::Jsonl);
425
+ assert!(options.cwd.is_absolute());
426
+ }
427
+
428
+ #[test]
429
+ fn requires_a_prompt() {
430
+ let error = parse_args(args(&[])).expect_err("missing prompt");
431
+ assert!(error.contains("--prompt"));
432
+ }
433
+
434
+ #[test]
435
+ fn rejects_empty_prompt() {
436
+ let error = parse_args(args(&["--prompt", " "])).expect_err("blank prompt");
437
+ assert!(error.contains("non-empty"));
438
+ }
439
+
440
+ #[test]
441
+ fn rejects_prompt_and_prompt_file_together() {
442
+ let file = std::env::temp_dir().join(format!("sigit-prompt-{}.txt", std::process::id()));
443
+ std::fs::write(&file, "task").unwrap();
444
+ let error = parse_args(args(&[
445
+ "--prompt",
446
+ "one",
447
+ "--prompt-file",
448
+ file.to_str().unwrap(),
449
+ ]))
450
+ .expect_err("both prompt flags");
451
+ assert!(error.contains("once"));
452
+ std::fs::remove_file(&file).ok();
453
+ }
454
+
455
+ #[test]
456
+ fn reads_prompt_file() {
457
+ let file = std::env::temp_dir().join(format!("sigit-promptf-{}.txt", std::process::id()));
458
+ std::fs::write(&file, "task from file\n").unwrap();
459
+ let options = parse_args(args(&["--prompt-file", file.to_str().unwrap()])).expect("parses");
460
+ assert_eq!(options.prompt, "task from file");
461
+ std::fs::remove_file(&file).ok();
462
+ }
463
+
464
+ #[test]
465
+ fn rejects_bad_flags_and_values() {
466
+ assert!(parse_args(args(&["--prompt", "x", "--max-rounds", "0"])).is_err());
467
+ assert!(parse_args(args(&["--prompt", "x", "--max-rounds", "abc"])).is_err());
468
+ assert!(parse_args(args(&["--prompt", "x", "--output", "yaml"])).is_err());
469
+ assert!(parse_args(args(&["--bogus"])).is_err());
470
+ assert!(parse_args(args(&["--prompt", "x", "--cwd", "/definitely/not/a/dir"])).is_err());
471
+ }
472
+
473
+ #[test]
474
+ fn parses_overrides() {
475
+ let options = parse_args(args(&[
476
+ "--prompt",
477
+ "x",
478
+ "--max-rounds",
479
+ "7",
480
+ "--output",
481
+ "text",
482
+ ]))
483
+ .expect("parses");
484
+ assert_eq!(options.max_rounds, 7);
485
+ assert_eq!(options.output, OutputMode::Text);
486
+ }
487
+
488
+ #[test]
489
+ fn clip_is_char_boundary_safe() {
490
+ let (out, truncated) = clip("héllo wörld", 5);
491
+ assert_eq!(out, "héllo");
492
+ assert!(truncated);
493
+ let (out, truncated) = clip("short", 10);
494
+ assert_eq!(out, "short");
495
+ assert!(!truncated);
496
+ }
497
+}