1use std::{
2 collections::{HashSet, VecDeque},
3 future::Future,
4 io::{self, Read as IoRead, Write as IoWrite},
5 path::{Path, PathBuf},
6 time::Duration,
7};
8
9use anyhow::{Context as _, anyhow};
10use clap::{ArgGroup, Args, ValueEnum};
11use cookie_agent_engine::{Engine, EngineError, events::OutputMessage};
12use cookie_agent_protocol::{
13 AgentId, ApprovalRespondParams, ApprovalStatus, ApprovalUserDecision, AvailableModelDescriptor,
14 ClientResponseId, ClientRunId, EventPayload, EventSubscriptionMessage, ModelKey,
15 ModelSelection, OutputDelta, OutputGap, OutputStream, PermissionAction, PermissionEffect,
16 PermissionMode, RunId, RunSelection, RunStartParams, SessionId, SessionMeta, SessionOrigin,
17 StoredEvent, ToolCallId, UsageRollup, VariantId, WildcardPattern,
18};
19use serde::Serialize;
20use tokio::sync::mpsc;
21use uuid::Uuid;
22
23pub const EXIT_FAILURE: i32 = 1;
24pub const EXIT_PERMISSION: i32 = 3;
25pub const EXIT_CANCELLED: i32 = 4;
26pub const EXIT_ENVIRONMENT: i32 = 5;
27
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
29pub enum OutputMode {
30 #[default]
31 Text,
32 Json,
33 None,
34}
35
36#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
37pub enum PermissionModeArg {
38 #[default]
39 #[value(alias = "auto_approve")]
40 AutoApprove,
41 #[value(alias = "auto_approve_n")]
42 AutoApproveN,
43 #[value(alias = "auto_approve_y")]
44 AutoApproveY,
45 Ask,
46 Yolo,
47}
48
49impl From<PermissionModeArg> for PermissionMode {
50 fn from(value: PermissionModeArg) -> Self {
51 match value {
52 PermissionModeArg::AutoApprove => Self::AutoApprove,
53 PermissionModeArg::AutoApproveN => Self::AutoApproveN,
54 PermissionModeArg::AutoApproveY => Self::AutoApproveY,
55 PermissionModeArg::Ask => Self::Ask,
56 PermissionModeArg::Yolo => Self::Yolo,
57 }
58 }
59}
60
61#[derive(Clone, Debug, Eq, Hash, PartialEq)]
62pub enum AllowedTool {
63 Read,
64 Write,
65 Bash,
66 Delegate,
67 Mcp,
68 Plugin,
69 PluginPermission(String),
70 Skill(String),
71}
72
73impl AllowedTool {
74 const fn action(&self) -> PermissionAction {
75 match self {
76 Self::Read => PermissionAction::Read,
77 Self::Write => PermissionAction::Write,
78 Self::Bash => PermissionAction::Bash,
79 Self::Delegate => PermissionAction::Delegate,
80 Self::Mcp => PermissionAction::Mcp,
81 Self::Plugin | Self::PluginPermission(_) => PermissionAction::Plugin,
82 Self::Skill(_) => PermissionAction::Skill,
83 }
84 }
85
86 fn resource(&self) -> &str {
87 match self {
88 Self::Skill(name) | Self::PluginPermission(name) => name,
89 _ => "*",
90 }
91 }
92}
93
94impl std::str::FromStr for AllowedTool {
95 type Err = String;
96
97 fn from_str(value: &str) -> Result<Self, Self::Err> {
98 match value {
99 "read" => Ok(Self::Read),
100 "write" => Ok(Self::Write),
101 "bash" => Ok(Self::Bash),
102 "delegate" => Ok(Self::Delegate),
103 "mcp" => Ok(Self::Mcp),
104 "plugin" => Ok(Self::Plugin),
105 _ => value
106 .strip_prefix("skill:")
107 .filter(|name| !name.is_empty())
108 .map(|name| Self::Skill(name.to_owned()))
109 .or_else(|| {
110 value
111 .strip_prefix("plugin:")
112 .filter(|name| !name.is_empty())
113 .map(|name| Self::PluginPermission(name.to_owned()))
114 })
115 .ok_or_else(|| {
116 "expected read, write, bash, delegate, mcp, plugin, plugin:<name>, or skill:<name>"
117 .into()
118 }),
119 }
120 }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq, Args)]
124#[command(group(
125 ArgGroup::new("prompt_source")
126 .required(true)
127 .multiple(false)
128))]
129pub struct RunArgs {
130 #[arg(value_name = "PROMPT", group = "prompt_source")]
132 pub positional_prompt: Option<String>,
133 #[arg(short = 'p', long, value_name = "PROMPT", group = "prompt_source")]
135 pub prompt: Option<String>,
136 #[arg(short = 'f', long, value_name = "PATH", group = "prompt_source")]
138 pub prompt_file: Option<PathBuf>,
139 #[arg(short = 'a', long)]
141 pub agent: Option<AgentId>,
142 #[arg(long)]
144 pub preset: Option<String>,
145 #[arg(short = 'm', long)]
147 pub model: Option<ModelKey>,
148 #[arg(long)]
150 pub variant: Option<String>,
151 #[arg(long, value_enum, default_value_t)]
153 pub permission_mode: PermissionModeArg,
154 #[arg(long, value_delimiter = ',')]
156 pub allowed_tools: Vec<AllowedTool>,
157 #[arg(long)]
159 pub skill: Option<String>,
160 #[arg(long, requires = "skill")]
162 pub skill_args: Option<String>,
163 #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..))]
165 pub max_turns: u32,
166 #[arg(long, default_value_t = 600, value_parser = clap::value_parser!(u64).range(1..))]
168 pub timeout: u64,
169 #[arg(long)]
171 pub resume_session: Option<SessionId>,
172 #[arg(long)]
174 pub data_dir: Option<PathBuf>,
175 #[arg(short = 'o', long, value_enum)]
177 pub output: Option<OutputMode>,
178 #[arg(long)]
180 pub output_file: Option<PathBuf>,
181 #[arg(long)]
183 pub verbose: bool,
184 #[arg(long, conflicts_with = "output")]
186 pub json: bool,
187}
188
189impl RunArgs {
190 pub fn output_mode(&self) -> OutputMode {
191 if self.json {
192 OutputMode::Json
193 } else {
194 self.output.unwrap_or_default()
195 }
196 }
197
198 pub fn validate_cli(&self) -> Result<(), &'static str> {
199 if self.output_mode() == OutputMode::None && self.output_file.is_some() {
200 return Err("--output-file cannot be used with '--output none'");
201 }
202 Ok(())
203 }
204}
205
206#[derive(Clone, Copy, Debug, Serialize)]
207#[serde(rename_all = "snake_case")]
208enum TerminalStatus {
209 Completed,
210 Failed,
211 Cancelled,
212 Interrupted,
213}
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
216#[serde(rename_all = "snake_case")]
217enum CancellationCause {
218 Permission,
219 User,
220}
221
222#[derive(Serialize)]
223struct JsonEvent<'a> {
224 r#type: &'static str,
225 event: &'a StoredEvent,
226}
227
228#[derive(Serialize)]
229struct JsonToolOutput<'a> {
230 r#type: &'static str,
231 call_id: ToolCallId,
232 stream: OutputStream,
233 byte_offset: u64,
234 data: &'a str,
235}
236
237#[derive(Serialize)]
238struct JsonToolOutputGap {
239 r#type: &'static str,
240 call_id: ToolCallId,
241 stream: OutputStream,
242 next_offset: u64,
243}
244
245#[derive(Serialize)]
246struct JsonSummary {
247 r#type: &'static str,
248 session_id: SessionId,
249 run_id: RunId,
250 status: TerminalStatus,
251 exit_code: i32,
252 turns: u32,
253 approval_rejections: u32,
254 event_recoveries: u32,
255 cancellation: Option<CancellationCause>,
256 final_text: Option<String>,
257 error: Option<String>,
258 usage: UsageRollup,
259}
260
261struct TerminalOutcome {
262 status: TerminalStatus,
263 final_text: Option<String>,
264 error: Option<String>,
265}
266
267struct TerminalResult {
268 outcome: TerminalOutcome,
269 exit_code: i32,
270}
271
272struct RunDisplay<'a> {
273 args: &'a RunArgs,
274 mode: OutputMode,
275 session_id: SessionId,
276}
277
278struct DriverState {
279 cursor: u64,
280 turns: u32,
281 approval_rejections: u32,
282 event_recoveries: u32,
283 rejected: HashSet<cookie_agent_protocol::ApprovalId>,
284 cancellation_requested: bool,
285 cancellation: Option<CancellationCause>,
286 tool_outputs: Vec<mpsc::Receiver<OutputMessage>>,
287}
288
289impl DriverState {
290 fn new(cursor: u64) -> Self {
291 Self {
292 cursor,
293 turns: 0,
294 approval_rejections: 0,
295 event_recoveries: 0,
296 rejected: HashSet::new(),
297 cancellation_requested: false,
298 cancellation: None,
299 tool_outputs: Vec::new(),
300 }
301 }
302}
303
304pub async fn execute(engine: &Engine, args: RunArgs) -> i32 {
305 let mut stdin = io::stdin().lock();
306 let mut stdout = io::stdout().lock();
307 let mut stderr = io::stderr().lock();
308 execute_with_io(engine, args, &mut stdin, &mut stdout, &mut stderr, async {
309 let _ = tokio::signal::ctrl_c().await;
310 })
311 .await
312}
313
314pub async fn execute_with_io<F>(
315 engine: &Engine,
316 args: RunArgs,
317 stdin: &mut dyn IoRead,
318 stdout: &mut dyn IoWrite,
319 stderr: &mut dyn IoWrite,
320 interrupt: F,
321) -> i32
322where
323 F: Future<Output = ()>,
324{
325 if let Err(error) = args.validate_cli() {
326 let _ = writeln!(stderr, "cookie run: {error}");
327 return EXIT_ENVIRONMENT;
328 }
329 let prompt = match resolve_prompt(&args, stdin) {
330 Ok(prompt) => prompt,
331 Err(error) => {
332 let _ = writeln!(
333 stderr,
334 "cookie run: {}",
335 cookie_agent_protocol::diagnostics::detail(&format!("{error:#}"))
336 );
337 return EXIT_ENVIRONMENT;
338 }
339 };
340 let mode = args.output_mode();
341 let mut file = match args.output_file.as_ref() {
342 Some(path) => match std::fs::File::create(path) {
343 Ok(file) => Some(io::BufWriter::new(file)),
344 Err(error) => {
345 let _ = writeln!(stderr, "cookie run: open output file: {error}");
346 return EXIT_ENVIRONMENT;
347 }
348 },
349 None => None,
350 };
351 let mut sink = io::sink();
352 let output: &mut dyn IoWrite = if mode == OutputMode::None {
353 &mut sink
354 } else if let Some(file) = file.as_mut() {
355 file
356 } else {
357 stdout
358 };
359
360 let prepared = match prepare_run(engine, &args, prompt).await {
361 Ok(prepared) => prepared,
362 Err(error) => {
363 let _ = writeln!(stderr, "cookie run: {:#}", error.error);
364 return error.exit_code;
365 }
366 };
367 progress(
368 &args,
369 stderr,
370 &format!(
371 "session {} run {} started",
372 prepared.session_id, prepared.run_id
373 ),
374 );
375
376 let outcome = drive_run(engine, &args, mode, output, stderr, prepared, interrupt).await;
377 match outcome {
378 Ok((session_id, run_id, state, terminal)) => {
379 let exit_code = terminal_exit(&terminal, state.cancellation);
380 if let Err(error) = write_terminal(
381 engine,
382 output,
383 mode,
384 session_id,
385 run_id,
386 &state,
387 TerminalResult {
388 outcome: terminal,
389 exit_code,
390 },
391 ) {
392 let _ = writeln!(stderr, "cookie run: write terminal output: {error:#}");
393 return EXIT_ENVIRONMENT;
394 }
395 progress(&args, stderr, &format!("run {run_id} finished"));
396 exit_code
397 }
398 Err(error) => {
399 let _ = writeln!(
400 stderr,
401 "cookie run: headless driver failed: {}",
402 cookie_agent_protocol::diagnostics::detail(&format!("{error:#}"))
403 );
404 EXIT_FAILURE
405 }
406 }
407}
408
409struct PreparedRun {
410 session_id: SessionId,
411 root_session_id: SessionId,
412 run_id: RunId,
413 receiver: mpsc::Receiver<EventSubscriptionMessage>,
414 replay: VecDeque<StoredEvent>,
415 cursor: u64,
416}
417
418struct PrepareError {
419 error: anyhow::Error,
420 exit_code: i32,
421}
422
423impl PrepareError {
424 fn handled(reason: String) -> Self {
425 Self {
426 error: anyhow!(reason).context("input handled by plugin; no run started"),
427 exit_code: 0,
428 }
429 }
430
431 fn setup(error: impl Into<anyhow::Error>) -> Self {
432 Self {
433 error: error.into(),
434 exit_code: EXIT_ENVIRONMENT,
435 }
436 }
437
438 fn run(error: impl Into<anyhow::Error>) -> Self {
439 Self {
440 error: error.into(),
441 exit_code: EXIT_FAILURE,
442 }
443 }
444}
445
446async fn prepare_run(
447 engine: &Engine,
448 args: &RunArgs,
449 prompt: String,
450) -> Result<PreparedRun, PrepareError> {
451 let snapshot = engine
452 .runtime_snapshot()
453 .map_err(|error| {
454 PrepareError::setup(anyhow!(error).context("load runtime snapshot for headless run"))
455 })?
456 .snapshot;
457 let resumed = match args.resume_session {
458 Some(session_id) => Some(engine.resume(session_id).await.map_err(|error| {
459 PrepareError::setup(anyhow!(error).context("resume headless session"))
460 })?),
461 None => None,
462 };
463 let remembered = resumed
464 .as_ref()
465 .map(|session| engine.session_model_selection(session.session_id))
466 .transpose()
467 .map_err(|error| PrepareError::setup(anyhow!(error)))?;
468 let selection = resolve_selection(
469 &snapshot.agents,
470 &snapshot.models,
471 resumed.as_ref(),
472 remembered.as_ref(),
473 args,
474 )
475 .map_err(PrepareError::setup)?;
476 let session = match resumed {
477 Some(session) => session,
478 None => engine.create_session(selection.clone()).map_err(|error| {
479 PrepareError::setup(anyhow!(error).context("create headless session"))
480 })?,
481 };
482 let root_session_id = session_root_id(&session);
483 engine
484 .set_permission_mode(session.session_id, args.permission_mode.into())
485 .map_err(|error| {
486 PrepareError::setup(anyhow!(error).context("set headless permission mode"))
487 })?;
488 apply_allowed_tools(engine, session.session_id, &args.allowed_tools)
489 .await
490 .map_err(PrepareError::setup)?;
491 let prompt = args.skill.as_ref().map_or(prompt.clone(), |skill| {
492 cookie_agent_protocol::encode_skill_submission_with_prompt(
493 skill,
494 args.skill_args.as_deref().unwrap_or_default(),
495 Some(&prompt),
496 )
497 });
498 let cursor = engine
499 .get_session(session.session_id)
500 .map_err(|error| PrepareError::setup(anyhow!(error).context("reload headless session")))?
501 .last_event_seq;
502 let (replay, receiver) = engine
503 .subscribe(session.session_id, Some(cursor))
504 .await
505 .map_err(|error| {
506 PrepareError::setup(anyhow!(error).context("subscribe to headless session events"))
507 })?;
508 let run_id = engine
509 .start_run(
510 RunStartParams {
511 reset_fallback: args.model.is_some()
512 || args.variant.is_some()
513 || args.agent.is_some()
514 || args.preset.is_some(),
515 session_id: session.session_id,
516 client_run_id: ClientRunId::new(Uuid::now_v7().to_string())
517 .expect("UUID is a valid client run ID"),
518 selection,
519 input: prompt,
520 },
521 headless_origin(),
522 )
523 .await
524 .map_err(|error| match error {
525 EngineError::InputHandled(reason) => PrepareError::handled(reason),
526 error => PrepareError::run(anyhow!(error).context("start headless run")),
527 })?
528 .run_id;
529 Ok(PreparedRun {
530 session_id: session.session_id,
531 root_session_id,
532 run_id,
533 receiver,
534 replay: replay.events.into(),
535 cursor,
536 })
537}
538
539fn session_root_id(session: &SessionMeta) -> SessionId {
540 match &session.origin {
541 SessionOrigin::Root => session.session_id,
542 SessionOrigin::Delegated {
543 root_session_id, ..
544 } => *root_session_id,
545 }
546}
547
548fn resolve_prompt(args: &RunArgs, stdin: &mut dyn IoRead) -> anyhow::Result<String> {
549 match (
550 args.positional_prompt.as_deref(),
551 args.prompt.as_deref(),
552 args.prompt_file.as_deref(),
553 ) {
554 (Some(prompt), None, None) | (None, Some(prompt), None) => {
555 if prompt == "-" {
556 } else {
558 return Ok(prompt.to_owned());
559 }
560 }
561 (None, None, Some(path)) if path != Path::new("-") => {
562 return std::fs::read_to_string(path)
563 .with_context(|| format!("read prompt file {path:?}"));
564 }
565 (None, None, Some(_)) => {}
566 _ => return Err(anyhow!("exactly one prompt source is required")),
567 }
568 let mut prompt = String::new();
569 stdin
570 .read_to_string(&mut prompt)
571 .context("read prompt from standard input")?;
572 Ok(prompt)
573}
574
575fn resolve_selection(
576 agents: &[cookie_agent_protocol::AgentDescriptor],
577 models: &[AvailableModelDescriptor],
578 resumed: Option<&SessionMeta>,
579 remembered: Option<&RunSelection>,
580 args: &RunArgs,
581) -> anyhow::Result<RunSelection> {
582 let previous = remembered.or_else(|| resumed.map(|session| &session.creation_selection));
583 let root_session = resumed.is_none_or(|session| matches!(session.origin, SessionOrigin::Root));
584 if !root_session && args.agent.is_some() {
585 return Err(anyhow!("--agent cannot override a delegated session"));
586 }
587 let preset_exists = |name: &str| {
588 agents
589 .iter()
590 .any(|agent| agent.preset.as_deref() == Some(name))
591 };
592 let preset = match args.preset.clone() {
595 Some(name) if !preset_exists(&name) => {
596 return Err(anyhow!("agent preset `{name}` is not available"));
597 }
598 Some(name) => Some(name),
599 None => previous
600 .and_then(|selection| selection.preset.clone())
601 .filter(|name| preset_exists(name)),
602 };
603 let find = |agent_id: &AgentId| {
604 agents.iter().find(|agent| {
605 agent.id == *agent_id
606 && agent.preset == preset
607 && (agent.runnable_as_root || !root_session)
608 })
609 };
610 let default_agent = || {
611 agents
612 .iter()
613 .filter(|agent| agent.runnable_as_root && agent.preset == preset)
614 .find(|agent| agent.id.as_str() == "primary")
615 .or_else(|| {
616 agents
617 .iter()
618 .find(|agent| agent.runnable_as_root && agent.preset == preset)
619 })
620 };
621 let agent = match &args.agent {
622 Some(agent_id) => find(agent_id),
623 None => previous
624 .and_then(|selection| find(&selection.agent))
625 .or_else(default_agent),
626 }
627 .ok_or_else(|| anyhow!("selected agent is not available for this session"))?;
628
629 let base_model = if let Some(model) = &args.model {
630 let descriptor = model_descriptor(models, model)
631 .ok_or_else(|| anyhow!("model `{model}` is not available"))?;
632 ModelSelection {
633 model: model.clone(),
634 variant: descriptor.default_variant.clone(),
635 }
636 } else if args.agent.is_none()
637 && args.preset.is_none()
638 && let Some(previous) = previous
639 && previous.agent == agent.id
640 && selection_is_live(models, &previous.model)
641 {
642 previous.model.clone()
643 } else {
644 agent
645 .resolved_fallback
646 .iter()
647 .find(|selection| selection_is_live(models, selection))
648 .cloned()
649 .or_else(|| models.first().map(default_model_selection))
650 .ok_or_else(|| anyhow!("no live model is available"))?
651 };
652 let mut model = base_model;
653 if let Some(variant) = args.variant.as_deref() {
654 model.variant = if variant == "base" {
655 None
656 } else {
657 Some(variant.parse::<VariantId>().context("parse --variant")?)
658 };
659 }
660 if !selection_is_live(models, &model) {
661 return Err(anyhow!("selected model and variant are not available"));
662 }
663 Ok(RunSelection {
664 agent: agent.id.clone(),
665 model,
666 preset,
667 })
668}
669
670fn model_descriptor<'a>(
671 models: &'a [AvailableModelDescriptor],
672 key: &ModelKey,
673) -> Option<&'a AvailableModelDescriptor> {
674 models.iter().find(|model| &model.key == key)
675}
676
677fn default_model_selection(descriptor: &AvailableModelDescriptor) -> ModelSelection {
678 ModelSelection {
679 model: descriptor.key.clone(),
680 variant: descriptor.default_variant.clone(),
681 }
682}
683
684fn variant_is_valid(descriptor: &AvailableModelDescriptor, variant: Option<&VariantId>) -> bool {
685 variant.is_none_or(|variant| {
686 descriptor
687 .variants
688 .iter()
689 .any(|candidate| candidate.id == *variant)
690 })
691}
692
693fn selection_is_live(models: &[AvailableModelDescriptor], selection: &ModelSelection) -> bool {
694 model_descriptor(models, &selection.model)
695 .is_some_and(|descriptor| variant_is_valid(descriptor, selection.variant.as_ref()))
696}
697
698async fn apply_allowed_tools(
699 engine: &Engine,
700 session_id: SessionId,
701 allowed: &[AllowedTool],
702) -> anyhow::Result<()> {
703 let rules = allowed
704 .iter()
705 .map(|allowed| (allowed.action(), allowed.resource().to_owned()))
706 .collect::<HashSet<_>>();
707 for (action, resource) in rules {
708 engine
709 .set_session_permission(
710 session_id,
711 action,
712 WildcardPattern::new(resource).context("parse allowed-tools resource")?,
713 PermissionEffect::Allow,
714 headless_origin(),
715 )
716 .await
717 .context("apply headless allowed-tools policy")?;
718 }
719 Ok(())
720}
721
722fn headless_origin() -> cookie_agent_protocol::EventOrigin {
723 cookie_agent_protocol::EventOrigin::new("client:headless")
724 .expect("static event origin is valid")
725}
726
727async fn drive_run<F>(
728 engine: &Engine,
729 args: &RunArgs,
730 mode: OutputMode,
731 output: &mut dyn IoWrite,
732 stderr: &mut dyn IoWrite,
733 mut prepared: PreparedRun,
734 interrupt: F,
735) -> anyhow::Result<(SessionId, RunId, DriverState, TerminalOutcome)>
736where
737 F: Future<Output = ()>,
738{
739 let mut state = DriverState::new(prepared.cursor);
740 let mut approvals = tokio::time::interval(Duration::from_millis(25));
741 approvals.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
742 let timeout = tokio::time::sleep(Duration::from_secs(args.timeout));
743 tokio::pin!(timeout);
744 tokio::pin!(interrupt);
745 let display = RunDisplay {
746 args,
747 mode,
748 session_id: prepared.session_id,
749 };
750
751 loop {
752 drain_tool_output(&mut state, mode, args.verbose, output)?;
753 if let Some(event) = prepared.replay.pop_front() {
754 match accept_event(&mut state, event) {
755 AcceptedEvent::Duplicate => continue,
756 AcceptedEvent::Event(event) => {
757 if let Some(terminal) = process_event(
758 engine,
759 &display,
760 output,
761 stderr,
762 prepared.run_id,
763 &mut state,
764 *event,
765 )
766 .await?
767 {
768 drain_tool_output(&mut state, mode, args.verbose, output)?;
769 return Ok((prepared.session_id, prepared.run_id, state, terminal));
770 }
771 continue;
772 }
773 }
774 }
775
776 tokio::select! {
777 message = prepared.receiver.recv() => {
778 match message {
779 Some(EventSubscriptionMessage::Event { event }) => {
780 prepared.replay.push_back(*event);
781 }
782 Some(EventSubscriptionMessage::Gap { .. }) | None => {
783 state.event_recoveries = state.event_recoveries.saturating_add(1);
784 recover_events(engine, &mut prepared, state.cursor).await?;
785 }
786 }
787 }
788 _ = approvals.tick(), if !state.cancellation_requested => {
789 if reject_escalated(engine, prepared.root_session_id, &mut state).await? {
790 progress(args, stderr, "escalated approval rejected; cancelling run");
791 request_cancel(
792 engine,
793 prepared.run_id,
794 CancellationCause::Permission,
795 &mut state,
796 ).await?;
797 }
798 }
799 () = &mut timeout, if !state.cancellation_requested => {
800 progress(args, stderr, "timeout reached; cancelling run");
801 request_cancel(
802 engine,
803 prepared.run_id,
804 CancellationCause::User,
805 &mut state,
806 ).await?;
807 }
808 () = &mut interrupt, if !state.cancellation_requested => {
809 progress(args, stderr, "interrupt received; cancelling run");
810 request_cancel(
811 engine,
812 prepared.run_id,
813 CancellationCause::User,
814 &mut state,
815 ).await?;
816 }
817 }
818 }
819}
820
821enum AcceptedEvent {
822 Duplicate,
823 Event(Box<StoredEvent>),
824}
825
826fn accept_event(state: &mut DriverState, event: StoredEvent) -> AcceptedEvent {
827 if event.seq <= state.cursor {
828 AcceptedEvent::Duplicate
829 } else {
830 state.cursor = event.seq;
831 AcceptedEvent::Event(Box::new(event))
832 }
833}
834
835async fn recover_events(
836 engine: &Engine,
837 prepared: &mut PreparedRun,
838 cursor: u64,
839) -> anyhow::Result<()> {
840 let (replay, receiver) = engine
841 .subscribe(prepared.session_id, Some(cursor))
842 .await
843 .context("recover headless event subscription")?;
844 prepared.receiver = receiver;
845 prepared.replay = replay.events.into();
846 Ok(())
847}
848
849fn event_belongs_to_run(event: &StoredEvent, session_id: SessionId, run_id: RunId) -> bool {
850 let session_diagnostic =
851 event.run_id.is_none() && matches!(&event.payload, EventPayload::PluginDiagnostic { .. });
852 event.session_id == session_id && (event.run_id == Some(run_id) || session_diagnostic)
853}
854
855async fn process_event(
856 engine: &Engine,
857 display: &RunDisplay<'_>,
858 output: &mut dyn IoWrite,
859 stderr: &mut dyn IoWrite,
860 active_run_id: RunId,
861 state: &mut DriverState,
862 event: StoredEvent,
863) -> anyhow::Result<Option<TerminalOutcome>> {
864 if !event_belongs_to_run(&event, display.session_id, active_run_id) {
865 return Ok(None);
866 }
867 if display.mode == OutputMode::Json {
868 write_json_line(
869 output,
870 &JsonEvent {
871 r#type: "event",
872 event: &event,
873 },
874 )?;
875 }
876 match event.payload {
877 EventPayload::ToolCallStarted { start }
878 if display.args.verbose && display.mode == OutputMode::Json =>
879 {
880 subscribe_tool_output(engine, start.tool_call_id, state, output)?;
881 }
882 EventPayload::ModelTurnCommitted { .. } => {
883 state.turns = state.turns.saturating_add(1);
884 if state.turns >= display.args.max_turns && !state.cancellation_requested {
885 progress(display.args, stderr, "turn limit reached; cancelling run");
886 request_cancel(engine, active_run_id, CancellationCause::User, state).await?;
887 }
888 }
889 EventPayload::ApprovalFinalized { decision, .. }
890 if decision.reason_code
891 == cookie_agent_protocol::ApprovalReasonCode::AutoApproveNRejected
892 && !state.cancellation_requested =>
893 {
894 state.approval_rejections = state.approval_rejections.saturating_add(1);
895 progress(
896 display.args,
897 stderr,
898 "auto-approve(N) rejection; cancelling run",
899 );
900 request_cancel(engine, active_run_id, CancellationCause::Permission, state).await?;
901 }
902 EventPayload::RunCompleted { final_text } => {
903 return Ok(Some(TerminalOutcome {
904 status: TerminalStatus::Completed,
905 final_text,
906 error: None,
907 }));
908 }
909 EventPayload::RunFailed {
910 error,
911 model_error,
912 resolved_model,
913 } => {
914 let error = cookie_agent_protocol::diagnostics::run_error(
915 &error,
916 model_error.as_ref(),
917 resolved_model.as_ref(),
918 );
919 writeln!(stderr, "cookie run: {error}")?;
920 return Ok(Some(TerminalOutcome {
921 status: TerminalStatus::Failed,
922 final_text: None,
923 error: Some(error),
924 }));
925 }
926 EventPayload::RunCancelled { reason } => {
927 let error = reason.map(|reason| reason.to_string());
928 if let Some(error) = &error {
929 writeln!(stderr, "cookie run cancelled: {error}")?;
930 }
931 return Ok(Some(TerminalOutcome {
932 status: TerminalStatus::Cancelled,
933 final_text: None,
934 error,
935 }));
936 }
937 EventPayload::RunInterrupted { reason } => {
938 let error = reason.map(|reason| reason.to_string());
939 if let Some(error) = &error {
940 writeln!(stderr, "cookie run interrupted: {error}")?;
941 }
942 return Ok(Some(TerminalOutcome {
943 status: TerminalStatus::Interrupted,
944 final_text: None,
945 error,
946 }));
947 }
948 EventPayload::ToolCallTerminated { termination }
949 if termination.outcome == cookie_agent_protocol::ToolTerminationOutcome::Failed =>
950 {
951 writeln!(
952 stderr,
953 "cookie tool {}: {}",
954 termination.tool_call_id,
955 cookie_agent_protocol::diagnostics::tool(&termination)
956 )?;
957 }
958 EventPayload::InternalAgentFailed { kind, failure, .. } => {
959 writeln!(
960 stderr,
961 "cookie internal agent {kind:?}: {}",
962 cookie_agent_protocol::diagnostics::internal(&failure)
963 )?;
964 }
965 EventPayload::InternalAgentFallback {
966 kind,
967 from,
968 to,
969 attempts,
970 failure,
971 ..
972 } => {
973 writeln!(
974 stderr,
975 "cookie {}",
976 cookie_agent_protocol::diagnostics::internal_fallback(
977 kind, &from, &to, attempts, &failure
978 )
979 )?;
980 }
981 EventPayload::ModelFallback { from, error, .. } => {
982 writeln!(
983 stderr,
984 "cookie model {} fallback: {}",
985 from.selection.model,
986 cookie_agent_protocol::diagnostics::model(&error)
987 )?;
988 }
989 EventPayload::PluginDiagnostic {
990 plugin, message, ..
991 } => {
992 writeln!(
993 stderr,
994 "cookie plugin {plugin}: {}",
995 cookie_agent_protocol::diagnostics::detail(&message)
996 )?;
997 }
998 _ => {}
999 }
1000 Ok(None)
1001}
1002
1003async fn request_cancel(
1004 engine: &Engine,
1005 run_id: RunId,
1006 cause: CancellationCause,
1007 state: &mut DriverState,
1008) -> anyhow::Result<()> {
1009 state.cancellation_requested = true;
1010 match engine.cancel_run(run_id).await {
1011 Ok(result) => {
1012 if result.cancelled {
1013 state.cancellation = Some(cause);
1014 }
1015 Ok(())
1016 }
1017 Err(EngineError::MissingRun(missing)) if missing == run_id => Ok(()),
1018 Err(error) => Err(anyhow!(error).context("cancel headless run")),
1019 }
1020}
1021
1022async fn reject_escalated(
1023 engine: &Engine,
1024 root_session_id: SessionId,
1025 state: &mut DriverState,
1026) -> anyhow::Result<bool> {
1027 let approvals = engine.list_approvals(root_session_id, Some(ApprovalStatus::Escalated));
1028 let mut rejected_any = false;
1029 for approval in approvals.approvals {
1030 let approval_id = approval.request.approval_id();
1031 if !state.rejected.insert(approval_id) {
1032 continue;
1033 }
1034 let wire = serde_json::to_value(&approval.request)?;
1035 let request_revision = wire["revision"]
1036 .as_u64()
1037 .ok_or_else(|| anyhow!("approval revision is unavailable"))?;
1038 engine
1039 .approval_respond(
1040 ApprovalRespondParams {
1041 session_id: approval.session_id,
1042 approval_id,
1043 request_revision,
1044 operation_fingerprint: approval.request.operation_fingerprint().clone(),
1045 client_response_id: ClientResponseId::new(Uuid::now_v7().to_string())
1046 .expect("UUID is a valid client response ID"),
1047 decision: ApprovalUserDecision::Reject,
1048 feedback: None,
1049 },
1050 headless_origin(),
1051 )
1052 .await
1053 .context("reject escalated headless approval")?;
1054 state.approval_rejections = state.approval_rejections.saturating_add(1);
1055 rejected_any = true;
1056 }
1057 Ok(rejected_any)
1058}
1059
1060fn subscribe_tool_output(
1061 engine: &Engine,
1062 call_id: ToolCallId,
1063 state: &mut DriverState,
1064 output: &mut dyn IoWrite,
1065) -> anyhow::Result<()> {
1066 for stream in engine.tool_output_streams(call_id).unwrap_or_default() {
1067 if let Some((snapshot, receiver)) = engine.subscribe_tool_output(call_id, stream) {
1068 for delta in snapshot.chunks {
1069 write_tool_delta(output, &delta)?;
1070 }
1071 state.tool_outputs.push(receiver);
1072 }
1073 }
1074 Ok(())
1075}
1076
1077fn drain_tool_output(
1078 state: &mut DriverState,
1079 mode: OutputMode,
1080 verbose: bool,
1081 output: &mut dyn IoWrite,
1082) -> anyhow::Result<()> {
1083 if !verbose || mode != OutputMode::Json {
1084 return Ok(());
1085 }
1086 let mut retained = Vec::with_capacity(state.tool_outputs.len());
1087 for mut receiver in state.tool_outputs.drain(..) {
1088 loop {
1089 match receiver.try_recv() {
1090 Ok(OutputMessage::Delta(delta)) => write_tool_delta(output, &delta)?,
1091 Ok(OutputMessage::Gap(gap)) => write_tool_gap(output, &gap)?,
1092 Err(mpsc::error::TryRecvError::Empty) => {
1093 retained.push(receiver);
1094 break;
1095 }
1096 Err(mpsc::error::TryRecvError::Disconnected) => break,
1097 }
1098 }
1099 }
1100 state.tool_outputs = retained;
1101 Ok(())
1102}
1103
1104fn write_tool_delta(output: &mut dyn IoWrite, delta: &OutputDelta) -> anyhow::Result<()> {
1105 write_json_line(
1106 output,
1107 &JsonToolOutput {
1108 r#type: "tool_output",
1109 call_id: delta.call_id,
1110 stream: delta.stream.clone(),
1111 byte_offset: delta.byte_offset,
1112 data: &delta.data,
1113 },
1114 )
1115}
1116
1117fn write_tool_gap(output: &mut dyn IoWrite, gap: &OutputGap) -> anyhow::Result<()> {
1118 write_json_line(
1119 output,
1120 &JsonToolOutputGap {
1121 r#type: "tool_output_gap",
1122 call_id: gap.call_id,
1123 stream: gap.stream.clone(),
1124 next_offset: gap.next_offset,
1125 },
1126 )
1127}
1128
1129fn terminal_exit(terminal: &TerminalOutcome, cancellation: Option<CancellationCause>) -> i32 {
1130 match terminal.status {
1131 TerminalStatus::Completed => 0,
1132 TerminalStatus::Failed => EXIT_FAILURE,
1133 TerminalStatus::Cancelled if cancellation == Some(CancellationCause::Permission) => {
1134 EXIT_PERMISSION
1135 }
1136 TerminalStatus::Cancelled | TerminalStatus::Interrupted => EXIT_CANCELLED,
1137 }
1138}
1139
1140fn write_terminal(
1141 engine: &Engine,
1142 output: &mut dyn IoWrite,
1143 mode: OutputMode,
1144 session_id: SessionId,
1145 run_id: RunId,
1146 state: &DriverState,
1147 result: TerminalResult,
1148) -> anyhow::Result<()> {
1149 let TerminalResult {
1150 outcome: terminal,
1151 exit_code,
1152 } = result;
1153 if mode == OutputMode::Text
1154 && let Some(text) = &terminal.final_text
1155 {
1156 output.write_all(text.as_bytes())?;
1157 output.write_all(b"\n")?;
1158 }
1159 if mode == OutputMode::Json {
1160 let usage = engine
1161 .session_usage(session_id)
1162 .context("load headless usage summary")?
1163 .usage;
1164 write_json_line(
1165 output,
1166 &JsonSummary {
1167 r#type: "summary",
1168 session_id,
1169 run_id,
1170 status: terminal.status,
1171 exit_code,
1172 turns: state.turns,
1173 approval_rejections: state.approval_rejections,
1174 event_recoveries: state.event_recoveries,
1175 cancellation: state.cancellation,
1176 final_text: terminal.final_text,
1177 error: terminal.error,
1178 usage,
1179 },
1180 )?;
1181 }
1182 output.flush()?;
1183 Ok(())
1184}
1185
1186fn progress(args: &RunArgs, stderr: &mut dyn IoWrite, message: &str) {
1187 if args.verbose {
1188 let _ = writeln!(stderr, "cookie run: {message}");
1189 }
1190}
1191
1192fn write_json_line(writer: &mut dyn IoWrite, value: &impl Serialize) -> anyhow::Result<()> {
1193 serde_json::to_writer(&mut *writer, value)?;
1194 writer.write_all(b"\n")?;
1195 writer.flush()?;
1196 Ok(())
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202
1203 #[test]
1204 fn session_diagnostics_do_not_admit_other_sessions_or_runs() {
1205 let session_id = SessionId::new_v7();
1206 let run_id = RunId::new_v7();
1207 let mut event = StoredEvent {
1208 engine_version: None,
1209 origin: None,
1210 session_id,
1211 run_id: None,
1212 seq: 1,
1213 timestamp: "2026-01-01T00:00:00Z".parse().unwrap(),
1214 payload: EventPayload::PluginDiagnostic {
1215 plugin: "review".into(),
1216 kind: cookie_agent_protocol::PluginDiagnosticKind::HookBlocked,
1217 message: "useful diagnostic".into(),
1218 count: 1,
1219 },
1220 };
1221 assert!(event_belongs_to_run(&event, session_id, run_id));
1222 assert!(!event_belongs_to_run(&event, SessionId::new_v7(), run_id));
1223 event.run_id = Some(RunId::new_v7());
1224 assert!(!event_belongs_to_run(&event, session_id, run_id));
1225 event.run_id = Some(run_id);
1226 assert!(event_belongs_to_run(&event, session_id, run_id));
1227 event.run_id = None;
1228 event.payload = EventPayload::RunCompleted { final_text: None };
1229 assert!(!event_belongs_to_run(&event, session_id, run_id));
1230 }
1231
1232 #[test]
1233 fn cursor_suppresses_duplicates_and_accepts_persisted_sequence_gaps() {
1234 let session_id = SessionId::new_v7();
1235 let event = |seq| StoredEvent {
1236 engine_version: None,
1237 origin: None,
1238 session_id,
1239 run_id: None,
1240 seq,
1241 timestamp: "2026-01-01T00:00:00Z".parse().expect("timestamp"),
1242 payload: EventPayload::UserInputSubmitted {
1243 input: "cursor test".into(),
1244 },
1245 };
1246 let mut state = DriverState::new(2);
1247 assert!(matches!(
1248 accept_event(&mut state, event(2)),
1249 AcceptedEvent::Duplicate
1250 ));
1251 assert!(matches!(
1252 accept_event(&mut state, event(4)),
1253 AcceptedEvent::Event(_)
1254 ));
1255 assert_eq!(state.cursor, 4);
1256 assert!(matches!(
1257 accept_event(&mut state, event(3)),
1258 AcceptedEvent::Duplicate
1259 ));
1260 assert_eq!(state.cursor, 4);
1261 }
1262
1263 #[test]
1264 fn permission_exit_requires_a_confirmed_cancelled_terminal() {
1265 let cancelled = TerminalOutcome {
1266 status: TerminalStatus::Cancelled,
1267 final_text: None,
1268 error: None,
1269 };
1270 let interrupted = TerminalOutcome {
1271 status: TerminalStatus::Interrupted,
1272 final_text: None,
1273 error: None,
1274 };
1275 assert_eq!(
1276 terminal_exit(&cancelled, Some(CancellationCause::Permission)),
1277 EXIT_PERMISSION
1278 );
1279 assert_eq!(
1280 terminal_exit(&interrupted, Some(CancellationCause::Permission)),
1281 EXIT_CANCELLED
1282 );
1283 assert_eq!(terminal_exit(&cancelled, None), EXIT_CANCELLED);
1284 }
1285}