Skip to main content

cookie_agent/
run.rs

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    /// Prompt submitted to the selected agent. Use '-' to read standard input.
131    #[arg(value_name = "PROMPT", group = "prompt_source")]
132    pub positional_prompt: Option<String>,
133    /// Prompt submitted to the selected agent. Use '-' to read standard input.
134    #[arg(short = 'p', long, value_name = "PROMPT", group = "prompt_source")]
135    pub prompt: Option<String>,
136    /// Read the prompt from a file. Use '-' to read standard input.
137    #[arg(short = 'f', long, value_name = "PATH", group = "prompt_source")]
138    pub prompt_file: Option<PathBuf>,
139    /// Root-runnable agent ID.
140    #[arg(short = 'a', long)]
141    pub agent: Option<AgentId>,
142    /// Agent preset used for a newly created session.
143    #[arg(long)]
144    pub preset: Option<String>,
145    /// Available provider/model selection.
146    #[arg(short = 'm', long)]
147    pub model: Option<ModelKey>,
148    /// Named model variant, or 'base' for no variant.
149    #[arg(long)]
150    pub variant: Option<String>,
151    /// Permission handling mode for this session.
152    #[arg(long, value_enum, default_value_t)]
153    pub permission_mode: PermissionModeArg,
154    /// Permission action to allow. May be repeated or comma-delimited.
155    #[arg(long, value_delimiter = ',')]
156    pub allowed_tools: Vec<AllowedTool>,
157    /// Load a user-invocable skill before running the prompt.
158    #[arg(long)]
159    pub skill: Option<String>,
160    /// Arguments supplied to --skill.
161    #[arg(long, requires = "skill")]
162    pub skill_args: Option<String>,
163    /// Maximum committed root model turns.
164    #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..))]
165    pub max_turns: u32,
166    /// Run timeout in seconds.
167    #[arg(long, default_value_t = 600, value_parser = clap::value_parser!(u64).range(1..))]
168    pub timeout: u64,
169    /// Continue an existing session instead of creating one.
170    #[arg(long)]
171    pub resume_session: Option<SessionId>,
172    /// Override the user data directory for sessions and artifacts.
173    #[arg(long)]
174    pub data_dir: Option<PathBuf>,
175    /// Standard-output format.
176    #[arg(short = 'o', long, value_enum)]
177    pub output: Option<OutputMode>,
178    /// Write command output to a file instead of standard output.
179    #[arg(long)]
180    pub output_file: Option<PathBuf>,
181    /// Emit progress and, in JSON mode, tool-output records.
182    #[arg(long)]
183    pub verbose: bool,
184    /// Alias for '--output json'.
185    #[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                // Read below.
557            } 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    // Explicit flags must name something real; a preset or agent carried over
593    // from the session's history falls back best-effort when it is gone.
594    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}