Add durable headless siGit Code sessions
Add the `sigit run` command with resumable sessions, JSONL event output, workspace metadata, and scoped tool permissions while retaining legacy `-p` compatibility. Co-Authored-By: siGit Code <noreply@sigit.si>
Seto Elkahfi committed
Sep 25, 2026 at 01:16 UTC
53a9198751aa22c848d0c7e70b8afcb6355079da
6 files changed
+498
-40
.agents/AGENTS.md
+5
@@ -259,6 +259,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
259
any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell.
260
- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
261
`tokio::select!` to multiplex terminal events with streaming tokens.
262
+- **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
263
+ clients. The legacy `-p` form reaches the same parser. Every new run gets a UUID session id;
264
+ `--resume <id>` restores that session through `session_store`, and `--output jsonl` emits a
265
+ structured session/delta/tool/result stream while logs stay on stderr. Headless permission
266
+ prompts collapse to denial unless the tool was pre-approved with `--allow-tool`.
267
- **`src/session_store.rs`** — durable conversations: one JSON-lines history file per session at
268
`$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`, written atomically, restorable into either backend.
269
Each save also writes a `<id>.meta.json` sidecar naming the session's `cwd` and the extra roots
README.md
+30
-1
@@ -8,7 +8,8 @@
8
<a href="https://github.com/getsigit/sigit/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-235843?style=flat-square&labelColor=17211D" alt="License"></a>
9
</p>
10
11
-siGit Code is a local coding agent. It runs on your machine, not someone else's. No API keys, no cloud round-trips, no subscription.
11
+siGit Code is the local agent runtime for **siGit Factory**. It runs on your machine by default,
12
+with optional cloud inference and remote automation when you choose them.
13
14
Its home is [code.sigit.si](https://code.sigit.si). You can run it yourself, as below, or use the hosted version (siGit Code Cloud) there if you would rather not run a model locally. [sigit.si](https://sigit.si) is Git hosting built for AI workflows.
15
@@ -135,6 +136,34 @@ Run `sigit` in a terminal and you get the same model and system prompt as the ed
136
137
Terminal mode currently needs Unix terminal behavior, so it works on macOS and Linux only.
138
139
+## Headless and CI mode
140
+
141
+Use `sigit run` to execute the same agent from scripts, CI, or another siGit Factory client:
142
+
143
+```sh
144
+sigit run "Review this repository and run the focused tests" --cwd .
145
+```
146
+
147
+Every run receives a durable session ID. The ID is printed to stderr in text mode and included
148
+in every event in JSONL mode. Resume the same conversation from a later command—or import it in
149
+an ACP client—by passing that ID:
150
+
151
+```sh
152
+sigit run "Continue with the next issue" --resume <session-id>
153
+```
154
+
155
+For automation, JSONL output provides session, assistant-delta, tool-call, tool-result, result,
156
+and error events on stdout. Logs remain on stderr.
157
+
158
+```sh
159
+sigit run "Run the checks" --output jsonl --allow-tool run_command
160
+```
161
+
162
+Mutating tools still follow siGit's permission policy. Because a headless run cannot answer an
163
+interactive permission prompt, approve only the tools the run needs with repeatable
164
+`--allow-tool` flags. The legacy `sigit -p "<prompt>"` form remains supported. See the
165
+[headless execution reference](docs/headless.md) for the event contract and exit codes.
166
+
167
## Platform support
168
169
| Platform | Architecture |
docs/headless.md
new
+59
@@ -0,0 +1,59 @@
1
+# Headless execution
2
+
3
+`sigit run` exposes the siGit Code agent runtime to scripts, CI, and siGit Factory clients.
4
+It uses the same provider selection, project instructions, skills, tools, MCP servers, and
5
+permission policy as the interactive and ACP surfaces.
6
+
7
+## Run and resume
8
+
9
+```sh
10
+sigit run "Review this repository" --cwd /path/to/repository
11
+```
12
+
13
+A new run receives a UUID session ID and saves its conversation under the siGit configuration
14
+directory. Text mode prints the ID to stderr as `Session: <id>`. Resume it later with:
15
+
16
+```sh
17
+sigit run "Continue with the fixes" --resume <id>
18
+```
19
+
20
+The saved metadata includes the primary working directory, additional project roots, and model.
21
+This makes the same session discoverable through ACP `session/list` and loadable by an editor.
22
+
23
+Use repeatable `--add-dir <path>` flags for multi-root workspaces. Mutating tools that would ask
24
+for interactive permission are denied in headless mode unless granted with
25
+`--allow-tool <name>`. A repeatable `--deny-tool <name>` takes precedence over grants and
26
+settings.
27
+
28
+## JSONL output
29
+
30
+Pass `--output jsonl` for a machine-readable stdout stream:
31
+
32
+```sh
33
+sigit run "Run the focused tests" --output jsonl --allow-tool run_command
34
+```
35
+
36
+Each line is one JSON object. Every event includes `type` and `session_id`.
37
+
38
+| Type | Additional fields | Meaning |
39
+|---|---|---|
40
+| `session` | `resumed` | Identifies the run before inference starts. |
41
+| `assistant_delta` | `text` | One visible streamed response fragment. |
42
+| `tool_call` | `tool_call_id`, `name`, `arguments` | A tool requested by the model. `arguments` is the tool's JSON string. |
43
+| `tool_result` | `tool_call_id`, `name`, `content` | The executed result or permission denial returned to the model. |
44
+| `result` | `text`, `tool_rounds` | The completed turn and its final visible text. |
45
+| `error` | `message` | A provider, session, or inference failure. |
46
+
47
+Logs and diagnostics stay on stderr. `--quiet` applies only to text output and cannot be combined
48
+with JSONL output.
49
+
50
+## Exit codes
51
+
52
+| Code | Meaning |
53
+|---|---|
54
+| `0` | The turn completed, including turns where a tool was denied by policy. |
55
+| `1` | Provider resolution, session restoration, inference, or tool-loop failure. |
56
+| `2` | Invalid command-line arguments or working directories. |
57
+
58
+The earlier `sigit -p "<prompt>"` syntax remains an alias for headless execution and accepts the
59
+same flags.
src/headless.rs
+234
-31
@@ -1,4 +1,4 @@
1
-//! Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and exits.
1
+//! Headless programmatic mode: `sigit run "<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
@@ -27,17 +27,27 @@ use crate::backend::{
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";
30
+pub const USAGE: &str = "Usage: sigit run \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
31
+ [--output text|jsonl] [--quiet] [--resume <session-id>] \
32
+ [--allow-tool <name>]... [--deny-tool <name>]...\n \
33
+ sigit -p \"<prompt>\" [same options]";
34
34
-pub const USAGE: &str = "Usage: sigit -p \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
35
- [--quiet] [--allow-tool <name>]... [--deny-tool <name>]...";
35
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36
+pub enum OutputFormat {
37
+ #[default]
38
+ Text,
39
+ Jsonl,
40
+}
41
37
-/// Parsed `sigit -p` invocation.
42
+/// Parsed `sigit run` or legacy `sigit -p` invocation.
43
#[derive(Debug, Clone, PartialEq, Eq)]
44
pub struct HeadlessConfig {
45
pub prompt: String,
46
+ /// Durable session id. A fresh UUID is generated unless `--resume` names
47
+ /// an existing session.
48
+ pub session_id: String,
49
+ /// Restore the saved conversation before sending `prompt`.
50
+ pub resume: bool,
51
/// Working directory to enter before anything loads (instruction files and
52
/// project-local MCP config then resolve from it).
53
pub cwd: Option<PathBuf>,
@@ -46,6 +56,8 @@ pub struct HeadlessConfig {
56
pub add_dirs: Vec<PathBuf>,
57
/// Print only the final assistant message to stdout (no streaming).
58
pub quiet: bool,
59
+ /// Machine-readable event output for CI and factory clients.
60
+ pub output: OutputFormat,
61
/// Tools pre-approved for the run (fed to `permissions::grant_for_session`).
62
pub allow_tools: Vec<String>,
63
/// Tools blocked for the run, overriding even settings-level allow.
@@ -54,12 +66,14 @@ pub struct HeadlessConfig {
66
67
/// Parse the process arguments (without argv[0]) for headless mode.
68
///
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,
69
+/// Returns `Ok(None)` when neither `run` nor `-p`/`--prompt` is present — the
70
+/// invocation is not headless and falls through to the TTY/ACP dispatch. Once
71
+/// headless mode is selected,
72
/// every remaining argument must be a recognized flag (position-insensitive);
73
/// anything else is a usage error the caller reports on stderr with exit 2.
74
pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
62
- if !args.iter().any(|arg| arg == "-p" || arg == "--prompt") {
75
+ let run_command = args.first().is_some_and(|arg| arg == "run");
76
+ if !run_command && !args.iter().any(|arg| arg == "-p" || arg == "--prompt") {
77
return Ok(None);
78
}
79
@@ -67,10 +81,12 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
81
let mut cwd: Option<PathBuf> = None;
82
let mut add_dirs: Vec<PathBuf> = Vec::new();
83
let mut quiet = false;
84
+ let mut output = OutputFormat::Text;
85
+ let mut resume_session: Option<String> = None;
86
let mut allow_tools: Vec<String> = Vec::new();
87
let mut deny_tools: Vec<String> = Vec::new();
88
73
- let mut iter = args.iter();
89
+ let mut iter = args.iter().skip(usize::from(run_command));
90
while let Some(arg) = iter.next() {
91
match arg.as_str() {
92
"-p" | "--prompt" => {
@@ -95,6 +111,26 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
111
add_dirs.push(PathBuf::from(value));
112
}
113
"--quiet" => quiet = true,
114
+ "--output" => {
115
+ let value = iter
116
+ .next()
117
+ .ok_or_else(|| "--output requires text or jsonl".to_string())?;
118
+ output = match value.as_str() {
119
+ "text" => OutputFormat::Text,
120
+ "jsonl" => OutputFormat::Jsonl,
121
+ _ => return Err("--output must be text or jsonl".to_string()),
122
+ };
123
+ }
124
+ "--resume" => {
125
+ let value = iter
126
+ .next()
127
+ .ok_or_else(|| "--resume requires a session id".to_string())?;
128
+ if resume_session.is_some() {
129
+ return Err("--resume was given more than once".to_string());
130
+ }
131
+ validate_session_id(value)?;
132
+ resume_session = Some(value.clone());
133
+ }
134
"--allow-tool" => {
135
let value = iter
136
.next()
@@ -107,27 +143,50 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
143
.ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
144
deny_tools.push(value.clone());
145
}
146
+ other if run_command && prompt.is_none() => prompt = Some(other.to_string()),
147
other => return Err(format!("unknown argument: {other}")),
148
}
149
}
150
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.
151
+ // Reaching this without a prompt means `run` had no positional prompt, or
152
+ // a legacy prompt flag was consumed as another flag's value.
153
let prompt = prompt.ok_or_else(|| "missing -p/--prompt".to_string())?;
154
if prompt.trim().is_empty() {
155
return Err("the prompt must not be empty".to_string());
156
}
157
+ if quiet && output == OutputFormat::Jsonl {
158
+ return Err("--quiet cannot be combined with --output jsonl".to_string());
159
+ }
160
+
161
+ let resume = resume_session.is_some();
162
+ let session_id = resume_session.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
163
164
Ok(Some(HeadlessConfig {
165
prompt,
166
+ session_id,
167
+ resume,
168
cwd,
169
add_dirs,
170
quiet,
171
+ output,
172
allow_tools,
173
deny_tools,
174
}))
175
}
176
177
+fn validate_session_id(session_id: &str) -> Result<(), String> {
178
+ if session_id.is_empty()
179
+ || !session_id.chars().all(|character| {
180
+ character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
181
+ })
182
+ {
183
+ return Err(
184
+ "session ids may contain only ASCII letters, numbers, '.', '_', and '-'".to_string(),
185
+ );
186
+ }
187
+ Ok(())
188
+}
189
+
190
/// Denial fed to the model when a tool at `ask` level fires in a headless run.
191
fn not_preapproved_denial(tool_name: &str) -> String {
192
format!(
@@ -153,11 +212,12 @@ fn deny_flag_denial(tool_name: &str) -> String {
212
/// logging to stderr, set up the model cache, and run MCP discovery.
213
pub async fn run(config: HeadlessConfig) -> i32 {
214
// Fresh permission state for the run, then apply the flag grants.
156
- permissions::reset_session(HEADLESS_SESSION);
215
+ permissions::reset_session(&config.session_id);
216
+ tools::set_active_session(Some(&config.session_id));
217
for tool in &config.allow_tools {
218
// No call arguments at grant time: the empty string records the bare
219
// tool name, granting the whole tool — the --allow-tool contract.
160
- permissions::grant_for_session(HEADLESS_SESSION, tool, "");
220
+ permissions::grant_for_session(&config.session_id, tool, "");
221
}
222
let denied: HashSet<&str> = config.deny_tools.iter().map(String::as_str).collect();
223
@@ -174,12 +234,12 @@ pub async fn run(config: HeadlessConfig) -> i32 {
234
}
235
});
236
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."
237
+ emit_error(
238
+ &config,
239
+ "headless mode needs a remote inference provider — running on-device would require \
240
+ loading (and possibly downloading) a local model, which headless mode never does \
241
+ implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY (or configure providers.toml), \
242
+ or sign in with `sigit login` and turn local inference off.",
243
);
244
return 1;
245
};
@@ -205,28 +265,76 @@ pub async fn run(config: HeadlessConfig) -> i32 {
265
let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
266
cfg.base_url,
267
cfg.api_key,
208
- cfg.model,
268
+ cfg.model.clone(),
269
Some(system_prompt),
270
));
271
272
+ if config.resume {
273
+ let Some(history) = session_store::load(&config.session_id) else {
274
+ emit_error(&config, "saved session not found");
275
+ return 1;
276
+ };
277
+ backend::adopt_carryover(backend.as_ref(), backend::carryover_history(history)).await;
278
+ }
279
+
280
+ emit_event(
281
+ &config,
282
+ serde_json::json!({
283
+ "type": "session",
284
+ "session_id": config.session_id,
285
+ "resumed": config.resume,
286
+ }),
287
+ );
288
+ if config.output == OutputFormat::Text {
289
+ eprintln!("Session: {}", config.session_id);
290
+ }
291
+
292
let outcome = run_prompt(backend.as_ref(), &config, &denied).await;
293
294
// Persist the conversation like the other surfaces, so a follow-up feature
295
// can resume it. Saved even on error: a partial transcript beats none.
296
let snapshot = backend.history_snapshot().await;
217
- if let Err(error) = session_store::save(HEADLESS_SESSION, &snapshot) {
297
+ if let Err(error) = session_store::save(&config.session_id, &snapshot) {
298
log::warn!("headless: session save failed: {error}");
299
}
300
+ session_store::save_meta(
301
+ &config.session_id,
302
+ &cwd,
303
+ &crate::workspace::additional_roots(),
304
+ Some(&cfg.model),
305
+ );
306
307
match outcome {
308
Ok(()) => 0,
309
Err(error) => {
224
- eprintln!("sigit: {error}");
310
+ emit_error(&config, &error.to_string());
311
1
312
}
313
}
314
}
315
316
+fn emit_event(config: &HeadlessConfig, event: serde_json::Value) {
317
+ if config.output == OutputFormat::Jsonl {
318
+ println!("{event}");
319
+ let _ = std::io::stdout().flush();
320
+ }
321
+}
322
+
323
+fn emit_error(config: &HeadlessConfig, message: &str) {
324
+ if config.output == OutputFormat::Jsonl {
325
+ emit_event(
326
+ config,
327
+ serde_json::json!({
328
+ "type": "error",
329
+ "session_id": config.session_id,
330
+ "message": message,
331
+ }),
332
+ );
333
+ } else {
334
+ eprintln!("sigit: {message}");
335
+ }
336
+}
337
+
338
/// The turn loop: send the prompt, execute tool calls under the permission
339
/// policy, feed results back, repeat up to `MAX_TOOL_ROUNDS` — the same shape
340
/// as the ACP `handle_prompt`, minus the ACP notifications.
@@ -248,6 +356,7 @@ async fn run_prompt(
356
backend.send_message_with_tools(&config.prompt, &tools, sink_opt),
357
&mut sink_rx,
358
&mut reply,
359
+ config,
360
)
361
.await?;
362
@@ -273,7 +382,20 @@ async fn run_prompt(
382
383
for tc in &result.tool_calls {
384
let args_preview: String = tc.arguments.chars().take(120).collect();
276
- eprintln!("→ {}({args_preview})", tc.name);
385
+ if config.output == OutputFormat::Jsonl {
386
+ emit_event(
387
+ config,
388
+ serde_json::json!({
389
+ "type": "tool_call",
390
+ "session_id": config.session_id,
391
+ "tool_call_id": tc.id,
392
+ "name": tc.name,
393
+ "arguments": tc.arguments,
394
+ }),
395
+ );
396
+ } else {
397
+ eprintln!("→ {}({args_preview})", tc.name);
398
+ }
399
400
// The headless deny set outranks everything, including a
401
// settings-level allow and a --allow-tool grant for the same name.
@@ -281,7 +403,7 @@ async fn run_prompt(
403
log::info!("headless: {} blocked by --deny-tool", tc.name);
404
deny_flag_denial(&tc.name)
405
} else {
284
- match permissions::decision_for(HEADLESS_SESSION, &tc.name, &tc.arguments) {
406
+ match permissions::decision_for(&config.session_id, &tc.name, &tc.arguments) {
407
permissions::Decision::Allow => {
408
tools::execute_tool(&tc.name, &tc.arguments).await
409
}
@@ -298,6 +420,17 @@ async fn run_prompt(
420
}
421
};
422
423
+ emit_event(
424
+ config,
425
+ serde_json::json!({
426
+ "type": "tool_result",
427
+ "session_id": config.session_id,
428
+ "tool_call_id": tc.id,
429
+ "name": tc.name,
430
+ "content": output,
431
+ }),
432
+ );
433
+
434
tool_results.push(BackendToolResult {
435
tool_call_id: tc.id.clone(),
436
content: output,
@@ -314,6 +447,7 @@ async fn run_prompt(
447
backend.send_tool_results(tool_results, &tools, allow_tool_calls, sink_opt),
448
&mut sink_rx,
449
&mut reply,
450
+ config,
451
)
452
.await?;
453
}
@@ -322,7 +456,17 @@ async fn run_prompt(
456
// message now; otherwise print only what streaming did not already cover.
457
let (_think, final_visible) = crate::chat::strip_think_blocks(result.text.trim());
458
let mut stdout = std::io::stdout();
325
- if config.quiet {
459
+ if config.output == OutputFormat::Jsonl {
460
+ emit_event(
461
+ config,
462
+ serde_json::json!({
463
+ "type": "result",
464
+ "session_id": config.session_id,
465
+ "text": final_visible,
466
+ "tool_rounds": round,
467
+ }),
468
+ );
469
+ } else if config.quiet {
470
if !final_visible.is_empty() {
471
let _ = writeln!(stdout, "{final_visible}");
472
}
@@ -344,6 +488,7 @@ async fn drain_to_stdout<F>(
488
fut: F,
489
sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
490
reply: &mut crate::StreamedReply,
491
+ config: &HeadlessConfig,
492
) -> Result<TurnResult, backend::BackendError>
493
where
494
F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
@@ -353,22 +498,33 @@ where
498
tokio::select! {
499
done = &mut fut => break done,
500
Some(piece) = sink_rx.recv() => {
356
- emit_visible_chunk(&piece, reply);
501
+ emit_visible_chunk(&piece, reply, config);
502
}
503
}
504
};
505
// Flush tokens that landed between the last poll and the future resolving.
506
while let Ok(piece) = sink_rx.try_recv() {
362
- emit_visible_chunk(&piece, reply);
507
+ emit_visible_chunk(&piece, reply, config);
508
}
509
result
510
}
511
512
/// Fold a streamed fragment into `reply` and print whatever it newly reveals —
513
/// the stdio counterpart of `SiGitAgent::emit_visible_chunk`.
369
-fn emit_visible_chunk(piece: &str, reply: &mut crate::StreamedReply) {
514
+fn emit_visible_chunk(piece: &str, reply: &mut crate::StreamedReply, config: &HeadlessConfig) {
515
if let Some(extra) = reply.push(piece) {
371
- print!("{extra}");
516
+ if config.output == OutputFormat::Jsonl {
517
+ emit_event(
518
+ config,
519
+ serde_json::json!({
520
+ "type": "assistant_delta",
521
+ "session_id": config.session_id,
522
+ "text": extra,
523
+ }),
524
+ );
525
+ } else {
526
+ print!("{extra}");
527
+ }
528
let _ = std::io::stdout().flush();
529
}
530
}
@@ -393,12 +549,24 @@ mod tests {
549
fn parses_minimal_invocation() {
550
let config = parse_args(&args(&["-p", "do the thing"])).unwrap().unwrap();
551
assert_eq!(config.prompt, "do the thing");
552
+ assert!(!config.session_id.is_empty());
553
+ assert!(!config.resume);
554
assert_eq!(config.cwd, None);
555
assert!(!config.quiet);
556
+ assert_eq!(config.output, OutputFormat::Text);
557
assert!(config.allow_tools.is_empty());
558
assert!(config.deny_tools.is_empty());
559
}
560
561
+ #[test]
562
+ fn run_subcommand_accepts_a_positional_prompt() {
563
+ let config = parse_args(&args(&["run", "do the thing"]))
564
+ .unwrap()
565
+ .unwrap();
566
+ assert_eq!(config.prompt, "do the thing");
567
+ assert!(!config.resume);
568
+ }
569
+
570
#[test]
571
fn long_form_prompt_flag_works() {
572
let config = parse_args(&args(&["--prompt", "hello"])).unwrap().unwrap();
@@ -417,6 +585,8 @@ mod tests {
585
"build it",
586
"--deny-tool",
587
"delete_file",
588
+ "--output",
589
+ "text",
590
"--allow-tool",
591
"edit_file",
592
]))
@@ -425,10 +595,41 @@ mod tests {
595
assert_eq!(config.prompt, "build it");
596
assert_eq!(config.cwd, Some(PathBuf::from("/tmp/project")));
597
assert!(config.quiet);
598
+ assert_eq!(config.output, OutputFormat::Text);
599
assert_eq!(config.allow_tools, vec!["run_command", "edit_file"]);
600
assert_eq!(config.deny_tools, vec!["delete_file"]);
601
}
602
603
+ #[test]
604
+ fn resume_reuses_the_named_session() {
605
+ let config = parse_args(&args(&[
606
+ "run",
607
+ "continue",
608
+ "--resume",
609
+ "session-123",
610
+ "--output",
611
+ "jsonl",
612
+ ]))
613
+ .unwrap()
614
+ .unwrap();
615
+ assert_eq!(config.session_id, "session-123");
616
+ assert!(config.resume);
617
+ assert_eq!(config.output, OutputFormat::Jsonl);
618
+ }
619
+
620
+ #[test]
621
+ fn resume_rejects_unsafe_session_ids() {
622
+ let error = parse_args(&args(&["run", "continue", "--resume", "../escape"])).unwrap_err();
623
+ assert!(error.contains("session ids"), "{error}");
624
+ }
625
+
626
+ #[test]
627
+ fn quiet_and_jsonl_are_mutually_exclusive() {
628
+ let error =
629
+ parse_args(&args(&["run", "work", "--quiet", "--output", "jsonl"])).unwrap_err();
630
+ assert!(error.contains("cannot be combined"), "{error}");
631
+ }
632
+
633
#[test]
634
fn add_dir_is_repeatable_and_keeps_its_order() {
635
let config = parse_args(&args(&[
@@ -472,6 +673,8 @@ mod tests {
673
assert!(parse_args(&args(&["-p", "x", "--cwd"])).is_err());
674
assert!(parse_args(&args(&["-p", "x", "--allow-tool"])).is_err());
675
assert!(parse_args(&args(&["-p", "x", "--deny-tool"])).is_err());
676
+ assert!(parse_args(&args(&["run", "x", "--output"])).is_err());
677
+ assert!(parse_args(&args(&["run", "x", "--resume"])).is_err());
678
}
679
680
#[test]
src/main.rs
+4
-4
@@ -4525,10 +4525,10 @@ async fn main() -> anyhow::Result<()> {
4525
}
4526
}
4527
4528
- // Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and
4529
- // exits. Plain stdio and cross-platform (unlike the Unix-only TUI), so it
4530
- // is dispatched here, before the TTY/ACP split, like the account
4531
- // subcommands above.
4528
+ // Headless programmatic mode: `sigit run "<prompt>"` (or the legacy
4529
+ // `sigit -p "<prompt>"`) runs one prompt and exits. Plain stdio and
4530
+ // cross-platform (unlike the Unix-only TUI), so it is dispatched here,
4531
+ // before the TTY/ACP split, like the account subcommands above.
4532
let cli_args: Vec<String> = std::env::args().skip(1).collect();
4533
// Xcode's custom-agent UI launches ACP agents with an explicit `--acp`
4534
// argument. Honor it even when Xcode gives the child a terminal-like stdin.
tests/headless_mode.rs
+166
-4
@@ -1,8 +1,8 @@
1
-//! End-to-end headless (`sigit -p`) runs against the real binary.
1
+//! End-to-end headless (`sigit run`, with legacy `sigit -p`) runs against the real binary.
2
//!
3
//! Same harness idea as `tests/acp_permissions.rs`: a scripted OpenAI-compatible
4
//! SSE endpoint stands in for the model via the `OPENAI_BASE_URL` override, and
5
-//! the BUILT binary is driven like CI would drive it — one `-p` invocation, then
5
+//! the BUILT binary is driven like CI would drive it — one headless invocation, then
6
//! assertions on the exit code, stdout, and what the endpoint saw.
7
//!
8
//! 1. With `--allow-tool run_command`, the scripted tool call executes and its
@@ -175,6 +175,14 @@ fn stdout_of(output: &Output) -> String {
175
String::from_utf8_lossy(&output.stdout).to_string()
176
}
177
178
+fn session_id_from(output: &Output) -> String {
179
+ String::from_utf8_lossy(&output.stderr)
180
+ .lines()
181
+ .find_map(|line| line.strip_prefix("Session: "))
182
+ .expect("headless text output should report its session id")
183
+ .to_string()
184
+}
185
+
186
// ── The runs ────────────────────────────────────────────────────────────────
187
188
#[test]
@@ -242,15 +250,169 @@ fn allowed_tool_runs_and_result_reaches_the_endpoint() {
250
"the command's output should reach the endpoint: {result}"
251
);
252
245
- // The conversation is saved under the "headless" session id for resume.
253
+ // The conversation and ACP-importable metadata are saved under the run's
254
+ // unique session id.
255
+ let session_id = session_id_from(&output);
256
assert!(
257
scratch
258
.config_dir
259
.join("sessions")
250
- .join("headless.jsonl")
260
+ .join(format!("{session_id}.jsonl"))
261
.is_file(),
262
"headless session must be persisted"
263
);
264
+ assert!(
265
+ scratch
266
+ .config_dir
267
+ .join("sessions")
268
+ .join(format!("{session_id}.meta.json"))
269
+ .is_file(),
270
+ "headless session metadata must be persisted"
271
+ );
272
+}
273
+
274
+#[test]
275
+fn run_subcommand_emits_structured_jsonl_events() {
276
+ let endpoint = start_fake_endpoint(vec![
277
+ sse_tool_call("call_1", "run_command", r#"{"command":"echo structured"}"#),
278
+ sse_text("structured result"),
279
+ ]);
280
+ let scratch = scratch("jsonl");
281
+ let cwd = scratch.cwd.to_str().unwrap().to_string();
282
+
283
+ let output = run_headless(
284
+ endpoint.port,
285
+ &scratch,
286
+ &[
287
+ "run",
288
+ "exercise the event stream",
289
+ "--output",
290
+ "jsonl",
291
+ "--allow-tool",
292
+ "run_command",
293
+ "--cwd",
294
+ &cwd,
295
+ ],
296
+ );
297
+
298
+ assert_eq!(
299
+ output.status.code(),
300
+ Some(0),
301
+ "stderr: {}",
302
+ String::from_utf8_lossy(&output.stderr)
303
+ );
304
+ let events: Vec<Value> = stdout_of(&output)
305
+ .lines()
306
+ .map(|line| serde_json::from_str(line).expect("every stdout line must be JSON"))
307
+ .collect();
308
+ let session_id = events[0]["session_id"].as_str().expect("session id");
309
+ assert_eq!(events[0]["type"], "session");
310
+ assert!(events.iter().all(|event| event["session_id"] == session_id));
311
+ assert!(events.iter().any(|event| event["type"] == "tool_call"));
312
+ assert!(events.iter().any(|event| {
313
+ event["type"] == "tool_result"
314
+ && event["content"]
315
+ .as_str()
316
+ .is_some_and(|content| content.contains("structured"))
317
+ }));
318
+ assert!(
319
+ events
320
+ .iter()
321
+ .any(|event| { event["type"] == "result" && event["text"] == "structured result" })
322
+ );
323
+}
324
+
325
+#[test]
326
+fn resume_continues_the_saved_conversation() {
327
+ let endpoint = start_fake_endpoint(vec![sse_text("first answer"), sse_text("second answer")]);
328
+ let scratch = scratch("resume");
329
+ let cwd = scratch.cwd.to_str().unwrap().to_string();
330
+
331
+ let first = run_headless(
332
+ endpoint.port,
333
+ &scratch,
334
+ &["run", "first question", "--cwd", &cwd],
335
+ );
336
+ assert_eq!(
337
+ first.status.code(),
338
+ Some(0),
339
+ "stderr: {}",
340
+ String::from_utf8_lossy(&first.stderr)
341
+ );
342
+ let session_id = session_id_from(&first);
343
+
344
+ let second = run_headless(
345
+ endpoint.port,
346
+ &scratch,
347
+ &[
348
+ "run",
349
+ "second question",
350
+ "--resume",
351
+ &session_id,
352
+ "--cwd",
353
+ &cwd,
354
+ ],
355
+ );
356
+ assert_eq!(
357
+ second.status.code(),
358
+ Some(0),
359
+ "stderr: {}",
360
+ String::from_utf8_lossy(&second.stderr)
361
+ );
362
+ assert_eq!(session_id_from(&second), session_id);
363
+ assert!(stdout_of(&second).contains("second answer"));
364
+
365
+ let requests = endpoint.requests.lock().unwrap();
366
+ assert_eq!(requests.len(), 2);
367
+ let resumed_messages = requests[1]["messages"].as_array().expect("messages");
368
+ assert!(
369
+ resumed_messages
370
+ .iter()
371
+ .any(|message| { message["role"] == "user" && message["content"] == "first question" })
372
+ );
373
+ assert!(
374
+ resumed_messages.iter().any(|message| {
375
+ message["role"] == "assistant" && message["content"] == "first answer"
376
+ })
377
+ );
378
+ assert!(
379
+ resumed_messages.iter().any(|message| {
380
+ message["role"] == "user" && message["content"] == "second question"
381
+ })
382
+ );
383
+}
384
+
385
+#[test]
386
+fn missing_resume_session_is_a_structured_error() {
387
+ let endpoint = start_fake_endpoint(vec![]);
388
+ let scratch = scratch("missing-resume");
389
+ let cwd = scratch.cwd.to_str().unwrap().to_string();
390
+
391
+ let output = run_headless(
392
+ endpoint.port,
393
+ &scratch,
394
+ &[
395
+ "run",
396
+ "continue",
397
+ "--resume",
398
+ "missing-session",
399
+ "--output",
400
+ "jsonl",
401
+ "--cwd",
402
+ &cwd,
403
+ ],
404
+ );
405
+
406
+ assert_eq!(output.status.code(), Some(1));
407
+ let events: Vec<Value> = stdout_of(&output)
408
+ .lines()
409
+ .map(|line| serde_json::from_str(line).expect("error output must be JSONL"))
410
+ .collect();
411
+ assert_eq!(events.len(), 1);
412
+ assert_eq!(events[0]["type"], "error");
413
+ assert_eq!(events[0]["session_id"], "missing-session");
414
+ assert_eq!(events[0]["message"], "saved session not found");
415
+ assert!(endpoint.requests.lock().unwrap().is_empty());
416
}
417
418
#[test]