Skip to main content

lucy/
app.rs

1use std::collections::HashMap;
2use std::io::{self, BufRead, IsTerminal, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{mpsc, Arc, Condvar, Mutex};
6
7use serde::Deserialize;
8use serde_json::{Map, Value};
9
10use crate::cancellation::CancellationToken;
11use crate::config::{AuthProvider, Config, LlmSettings};
12use crate::context::{resolve_boot_context_with_api_key_env, InstructionSource, SkillEntry};
13use crate::model::{estimate_context_tokens, estimate_message_tokens, ChatMessage, ChatToolCall};
14use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
15use crate::provider::{Provider, ProviderStreamEvent, ProviderTurn};
16use crate::redaction::{
17    conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key, redact_secret,
18    redaction_marker,
19};
20use crate::session::{
21    BackgroundResultDelivery, BackgroundResultPending, ChildSession, ChildSessionStatus, Session,
22};
23
24#[derive(Debug)]
25struct CliOptions {
26    session: Option<String>,
27    list_sessions: bool,
28    jsonl: bool,
29    tui: bool,
30    version: bool,
31    command: Option<CliCommand>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum CliCommand {
36    CodexLogin,
37    CodexLogout,
38}
39
40#[derive(Debug, Deserialize)]
41struct InputRecord {
42    #[serde(rename = "type")]
43    record_type: String,
44    text: Option<String>,
45}
46
47const MAX_CONCURRENT_SUBAGENTS: usize = 4;
48const MAX_SUBAGENT_TASK_BYTES: usize = 64 * 1024;
49const USER_CANCEL_REASON: &str = "user_cancelled";
50const PROVIDER_PHASE: &str = "provider_stream";
51const COMMAND_PHASE: &str = "cmd";
52const AUTO_COMPACTION_THRESHOLD_PERCENT: usize = 95;
53const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
54const COMPACTION_SYSTEM_PROMPT: &str = "You are compacting a coding-agent conversation. Produce a concise, factual continuation summary. Preserve the user's goals, explicit decisions, constraints, files and code changes, commands and results, current implementation state, unresolved work, and exact identifiers that future turns need. Do not invent facts. Return only the summary text; do not call tools.";
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum FrontendMode {
58    Jsonl,
59    Tui,
60}
61
62pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
63where
64    R: BufRead + Send + 'static,
65    W: Write,
66    E: Write,
67{
68    let options = match parse_args(args) {
69        Ok(options) => options,
70        Err(error) => {
71            let mut diagnostics = diagnostics;
72            write_diagnostic(&mut diagnostics, &error);
73            return 2;
74        }
75    };
76    if options.version {
77        if let Err(error) = write_version(output) {
78            let mut diagnostics = diagnostics;
79            write_diagnostic(
80                &mut diagnostics,
81                &format!("unable to write version: {error}"),
82            );
83            return 1;
84        }
85        return 0;
86    }
87
88    let home = match home_directory() {
89        Ok(home) => home,
90        Err(error) => {
91            let mut diagnostics = diagnostics;
92            write_diagnostic(&mut diagnostics, &error);
93            return 1;
94        }
95    };
96    let cwd = match std::env::current_dir() {
97        Ok(cwd) => cwd,
98        Err(_error) => {
99            let mut diagnostics = diagnostics;
100            write_diagnostic(&mut diagnostics, "unable to resolve cwd");
101            return 1;
102        }
103    };
104    run_cli_at_home_with_terminals(
105        args,
106        input,
107        output,
108        diagnostics,
109        &home,
110        &cwd,
111        io::stdin().is_terminal(),
112        io::stdout().is_terminal(),
113    )
114}
115
116pub fn run_cli_at_home<R, W, E>(
117    args: &[String],
118    input: R,
119    output: W,
120    diagnostics: E,
121    home: &Path,
122    cwd: &Path,
123) -> i32
124where
125    R: BufRead + Send + 'static,
126    W: Write,
127    E: Write,
128{
129    // The generic test/library entry point has no terminal handles. The real
130    // binary uses run_cli, which supplies the actual stdio terminal state.
131    run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
132}
133
134#[allow(clippy::too_many_arguments)]
135fn run_cli_at_home_with_terminals<R, W, E>(
136    args: &[String],
137    input: R,
138    output: W,
139    mut diagnostics: E,
140    home: &Path,
141    cwd: &Path,
142    stdin_is_tty: bool,
143    stdout_is_tty: bool,
144) -> i32
145where
146    R: BufRead + Send + 'static,
147    W: Write,
148    E: Write,
149{
150    let options = match parse_args(args) {
151        Ok(options) => options,
152        Err(error) => {
153            let mut diagnostics = diagnostics;
154            write_diagnostic(&mut diagnostics, &error);
155            return 2;
156        }
157    };
158    if options.version {
159        if let Err(error) = write_version(output) {
160            write_diagnostic(
161                &mut diagnostics,
162                &format!("unable to write version: {error}"),
163            );
164            return 1;
165        }
166        return 0;
167    }
168    if let Some(command) = options.command {
169        return run_codex_command(command, home, output, &mut diagnostics);
170    }
171    let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
172        Ok(mode) => mode,
173        Err(error) => {
174            write_diagnostic(&mut diagnostics, &error);
175            return 2;
176        }
177    };
178
179    if options.list_sessions {
180        let mut protocol = ProtocolWriter::new(output);
181        if let Err(error) = Config::ensure_exists(home) {
182            write_diagnostic(&mut diagnostics, &error.to_string());
183            return 1;
184        }
185        let codex_secret = Config::load_or_create(home)
186            .ok()
187            .and_then(|config| config.resolved_auth().ok())
188            .and_then(|auth| configured_codex_secret(home, auth.provider));
189        return match Session::list_with_secret(home, codex_secret.as_deref()) {
190            Ok(sessions) => {
191                for session in sessions {
192                    if let Err(error) = protocol.emit_serializable(&session) {
193                        write_diagnostic(
194                            &mut diagnostics,
195                            &format!("unable to write session metadata: {error}"),
196                        );
197                        return 1;
198                    }
199                }
200                0
201            }
202            Err(error) => {
203                write_diagnostic(&mut diagnostics, &error.to_string());
204                1
205            }
206        };
207    }
208
209    let (session, provider, resumed, attached_agents) = if let Some(id) = options.session.as_deref()
210    {
211        let mut session = match Session::resume(home, id) {
212            Ok(session) => session,
213            Err(error) => {
214                write_diagnostic(&mut diagnostics, &error.to_string());
215                return 1;
216            }
217        };
218        let config = match Config::load_or_create(home) {
219            Ok(config) => config,
220            Err(error) => {
221                write_diagnostic(&mut diagnostics, &error.to_string());
222                return 1;
223            }
224        };
225        let auth = match config.resolved_auth() {
226            Ok(auth) => auth,
227            Err(error) => {
228                write_diagnostic(&mut diagnostics, &error.to_string());
229                return 1;
230            }
231        };
232        if let Some(secret) = configured_codex_secret(home, auth.provider) {
233            session = match Session::resume_with_secret(home, id, Some(&secret)) {
234                Ok(session) => session,
235                Err(error) => {
236                    write_diagnostic_safe(&mut diagnostics, &error.to_string(), Some(&secret));
237                    return 1;
238                }
239            };
240        }
241        let mut selected = match config.resolved_llm() {
242            Ok(settings) => settings,
243            Err(error) => {
244                write_diagnostic_safe(
245                    &mut diagnostics,
246                    &error.to_string(),
247                    configured_api_key(&config).as_deref(),
248                );
249                return 1;
250            }
251        };
252        apply_auth_to_settings(&mut selected, auth.provider);
253        session.llm.model = selected.model;
254        session.llm.effort = selected.effort;
255        session.llm.api_key_env = selected.api_key_env;
256        let provider = match provider_for_settings(home, &session.llm) {
257            Ok(provider) => provider,
258            Err(error) => {
259                write_diagnostic(&mut diagnostics, &error.to_string());
260                return 1;
261            }
262        };
263        if let Err(error) =
264            session.append_provider_settings(session.llm.model.clone(), session.llm.effort.clone())
265        {
266            write_diagnostic_safe(
267                &mut diagnostics,
268                &error.to_string(),
269                Some(&provider.api_key()),
270            );
271            return 1;
272        }
273        if mode == FrontendMode::Tui && conflicts_with_tui_literal(&provider.api_key()) {
274            write_diagnostic_safe(
275                &mut diagnostics,
276                "API key conflicts with terminal UI literals",
277                Some(&provider.api_key()),
278            );
279            return 1;
280        }
281        (session, provider, true, Vec::new())
282    } else {
283        let config = match Config::load_or_create(home) {
284            Ok(config) => config,
285            Err(error) => {
286                write_diagnostic(&mut diagnostics, &error.to_string());
287                return 1;
288            }
289        };
290        let auth = match config.resolved_auth() {
291            Ok(auth) => auth,
292            Err(error) => {
293                write_diagnostic(&mut diagnostics, &error.to_string());
294                return 1;
295            }
296        };
297        let configured_secret = configured_api_key(&config);
298        let api_key_env = auth.api_key_env.clone();
299        let mut llm = match config.resolved_llm() {
300            Ok(llm) => llm,
301            Err(error) => {
302                write_diagnostic_safe(
303                    &mut diagnostics,
304                    &error.to_string(),
305                    configured_secret.as_deref(),
306                );
307                return 1;
308            }
309        };
310        apply_auth_to_settings(&mut llm, auth.provider);
311        let provider = match provider_for_settings(home, &llm) {
312            Ok(provider) => provider,
313            Err(error) => {
314                write_diagnostic_safe(
315                    &mut diagnostics,
316                    &error.to_string(),
317                    configured_secret.as_deref(),
318                );
319                return 1;
320            }
321        };
322        if mode == FrontendMode::Tui && conflicts_with_tui_literal(&provider.api_key()) {
323            write_diagnostic_safe(
324                &mut diagnostics,
325                "API key conflicts with terminal UI literals",
326                Some(&provider.api_key()),
327            );
328            return 1;
329        }
330        let safe_cwd = match std::fs::canonicalize(cwd) {
331            Ok(cwd) if !cwd.display().to_string().contains(&provider.api_key()) => cwd,
332            Ok(_) => {
333                write_diagnostic_safe(
334                    &mut diagnostics,
335                    "session header rejected",
336                    Some(&provider.api_key()),
337                );
338                return 1;
339            }
340            Err(_) => {
341                write_diagnostic_safe(
342                    &mut diagnostics,
343                    "unable to resolve session cwd",
344                    Some(&provider.api_key()),
345                );
346                return 1;
347            }
348        };
349        let context = match resolve_boot_context_with_api_key_env(
350            home,
351            &safe_cwd,
352            &config.system_prompt,
353            api_key_env.as_deref(),
354        ) {
355            Ok(context) => context,
356            Err(error) => {
357                write_diagnostic_safe(
358                    &mut diagnostics,
359                    &error.to_string(),
360                    configured_secret.as_deref(),
361                );
362                return 1;
363            }
364        };
365        let boot_system_prompt = redact_secret(&context.system_prompt, Some(&provider.api_key()));
366        let attached_agents = attached_agents(context.instruction_files, &provider.api_key());
367        let skills = redact_skills(context.skills, &provider.api_key());
368        let session = match Session::create_with_skills_and_secret(
369            home,
370            &safe_cwd,
371            boot_system_prompt,
372            llm,
373            skills,
374            Some(&provider.api_key()),
375        ) {
376            Ok(session) => session,
377            Err(error) => {
378                write_diagnostic_safe(
379                    &mut diagnostics,
380                    &error.to_string(),
381                    Some(&provider.api_key()),
382                );
383                return 1;
384            }
385        };
386        (session, provider, false, attached_agents)
387    };
388
389    let harness = Harness {
390        home: home.to_path_buf(),
391        session,
392        provider,
393        context_window: None,
394        attached_agents,
395        subagents: Arc::new(Mutex::new(HashMap::new())),
396        completed_subagents: mpsc::channel(),
397        subagent_activity: mpsc::channel(),
398    };
399    if mode == FrontendMode::Tui {
400        return match crate::tui::run(harness, resumed, output) {
401            Ok(()) => 0,
402            Err(error) => {
403                write_diagnostic(&mut diagnostics, &error);
404                1
405            }
406        };
407    }
408
409    let mut protocol = ProtocolWriter::new(output);
410    let mut harness = harness;
411    if let Err(error) = protocol.session(&harness.session.id, resumed) {
412        write_diagnostic_safe(
413            &mut diagnostics,
414            &format!("unable to write session event: {error}"),
415            Some(&harness.provider.api_key()),
416        );
417        return 1;
418    }
419
420    let (input_tx, input_rx) = mpsc::channel();
421    std::thread::spawn(move || {
422        for line in input.lines() {
423            if input_tx.send(line).is_err() {
424                break;
425            }
426        }
427    });
428    let mut input_closed = false;
429    loop {
430        while harness.next_subagent_activity().is_some() {}
431        if let Err(error) = harness.collect_completed_subagents(&mut protocol) {
432            let error = redact_secret(&error, Some(&harness.provider.api_key()));
433            let _ = protocol.error(&error);
434        }
435        if input_closed && !harness.has_running_subagents() {
436            // Completion publication precedes the registry's terminal transition,
437            // so this final drain emits every lifecycle event before EOF exit.
438            if let Err(error) = harness.collect_completed_subagents(&mut protocol) {
439                let error = redact_secret(&error, Some(&harness.provider.api_key()));
440                let _ = protocol.error(&error);
441            }
442            break;
443        }
444        let line = match input_rx.recv_timeout(std::time::Duration::from_millis(25)) {
445            Ok(Ok(line)) => line,
446            Ok(Err(error)) => {
447                write_diagnostic_safe(
448                    &mut diagnostics,
449                    &format!("unable to read stdin: {error}"),
450                    Some(&harness.provider.api_key()),
451                );
452                return 1;
453            }
454            Err(mpsc::RecvTimeoutError::Timeout) => continue,
455            Err(mpsc::RecvTimeoutError::Disconnected) => {
456                input_closed = true;
457                continue;
458            }
459        };
460        if line.trim().is_empty() {
461            continue;
462        }
463        let text = match parse_input_message(&line) {
464            Ok(text) => text,
465            Err(error) => {
466                let error = redact_secret(&error, Some(&harness.provider.api_key()));
467                if let Err(write_error) = protocol.error(&error) {
468                    write_diagnostic_safe(
469                        &mut diagnostics,
470                        &format!("unable to write protocol error: {write_error}"),
471                        Some(&harness.provider.api_key()),
472                    );
473                    return 1;
474                }
475                continue;
476            }
477        };
478        if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
479            let error = redact_secret(&error, Some(&harness.provider.api_key()));
480            if let Err(write_error) = protocol.error(&error) {
481                write_diagnostic_safe(
482                    &mut diagnostics,
483                    &format!("unable to write protocol error: {write_error}"),
484                    Some(&harness.provider.api_key()),
485                );
486                return 1;
487            }
488        }
489    }
490    0
491}
492
493pub fn resolve_mode(
494    args: &[String],
495    stdin_is_tty: bool,
496    stdout_is_tty: bool,
497) -> Result<FrontendMode, String> {
498    let options = parse_args(args)?;
499    if options.list_sessions {
500        if options.tui {
501            return Err("--tui cannot be combined with --list-sessions".to_owned());
502        }
503        return Ok(FrontendMode::Jsonl);
504    }
505    if options.tui && !(stdin_is_tty && stdout_is_tty) {
506        return Err("--tui requires a terminal on stdin and stdout".to_owned());
507    }
508    if options.tui {
509        Ok(FrontendMode::Tui)
510    } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
511        Ok(FrontendMode::Jsonl)
512    } else {
513        Ok(FrontendMode::Tui)
514    }
515}
516
517pub(crate) struct Harness {
518    pub(crate) home: PathBuf,
519    pub(crate) session: Session,
520    pub(crate) provider: Provider,
521    /// Model context metadata resolved by the interactive frontend; `None`
522    /// keeps compaction disabled when an OpenAI-compatible provider exposes no
523    /// context-window metadata.
524    pub(crate) context_window: Option<usize>,
525    /// AGENTS.md sources selected for this newly created session's boot context.
526    /// The TUI uses these only while its first-boot welcome is visible.
527    pub(crate) attached_agents: Vec<String>,
528    subagents: Arc<Mutex<HashMap<String, SubagentState>>>,
529    completed_subagents: (
530        mpsc::Sender<SubagentCompletion>,
531        mpsc::Receiver<SubagentCompletion>,
532    ),
533    subagent_activity: (
534        mpsc::Sender<SubagentActivity>,
535        mpsc::Receiver<SubagentActivity>,
536    ),
537}
538
539impl Drop for Harness {
540    fn drop(&mut self) {
541        let controls = self
542            .subagents
543            .lock()
544            .ok()
545            .map(|states| {
546                states
547                    .values()
548                    .filter_map(|state| match state {
549                        SubagentState::Running { control, .. } => Some(control.clone()),
550                        SubagentState::Completed(_) => None,
551                    })
552                    .collect::<Vec<_>>()
553            })
554            .unwrap_or_default();
555        for control in &controls {
556            control.shutdown.store(true, Ordering::Release);
557            control.cancellation.cancel();
558        }
559        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
560        for control in controls {
561            let (result_slot, wake) = &*control.done;
562            let Ok(result) = result_slot.lock() else {
563                continue;
564            };
565            if result.is_some() {
566                continue;
567            }
568            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
569            let _ = wake.wait_timeout(result, remaining);
570        }
571        while let Ok(completion) = self.completed_subagents.1.try_recv() {
572            let _ = self
573                .session
574                .append_background_result_pending(pending_from_completion(completion));
575        }
576    }
577}
578
579#[derive(Debug)]
580enum SubagentCommand {
581    Message(String),
582}
583
584#[derive(Clone)]
585struct SubagentControl {
586    cancellation: CancellationToken,
587    commands: mpsc::Sender<SubagentCommand>,
588    done: Arc<(Mutex<Option<Value>>, Condvar)>,
589    shutdown: Arc<AtomicBool>,
590}
591
592#[derive(Clone)]
593enum SubagentState {
594    Running {
595        control: SubagentControl,
596        attached_turn_id: String,
597    },
598    Completed(Value),
599}
600
601#[derive(Debug, Clone)]
602pub(crate) struct SubagentCompletion {
603    pub(crate) completion_id: String,
604    pub(crate) task_id: String,
605    pub(crate) child_session_id: String,
606    pub(crate) task: String,
607    pub(crate) status: ChildSessionStatus,
608    pub(crate) result: Value,
609    pub(crate) completed_at: u64,
610}
611
612/// TUI-only worker activity. Normal worker messages and tool activity reuse
613/// the same normalized events as the main agent; the task id stays outside the
614/// event so the frontend can route it without changing the public JSONL schema.
615#[derive(Debug, Clone)]
616pub(crate) enum SubagentActivity {
617    Event {
618        task_id: String,
619        event: ProtocolEvent,
620    },
621    ReasoningStarted {
622        task_id: String,
623    },
624    ReasoningCompleted {
625        task_id: String,
626    },
627}
628
629fn should_compact_context(context_tokens: usize, context_window: usize) -> bool {
630    context_window > 0
631        && context_tokens as u128 * 100
632            >= context_window as u128 * AUTO_COMPACTION_THRESHOLD_PERCENT as u128
633}
634
635fn find_compaction_boundary(
636    messages: &[ChatMessage],
637    previous_boundary: Option<usize>,
638) -> Option<usize> {
639    let user_starts = messages
640        .iter()
641        .enumerate()
642        .filter_map(|(index, message)| (message.role == "user").then_some(index))
643        .collect::<Vec<_>>();
644    let mut start = *user_starts.last()?;
645    let end = messages.len();
646    let mut kept_tokens = messages[start..end]
647        .iter()
648        .map(estimate_message_tokens)
649        .sum::<usize>();
650
651    while kept_tokens < COMPACTION_KEEP_RECENT_TOKENS {
652        let Some(previous_start) = user_starts
653            .iter()
654            .copied()
655            .rev()
656            .find(|candidate| *candidate < start)
657        else {
658            break;
659        };
660        start = previous_start;
661        kept_tokens = messages[start..end]
662            .iter()
663            .map(estimate_message_tokens)
664            .sum::<usize>();
665    }
666
667    (start > 0 && previous_boundary.is_none_or(|previous| start > previous)).then_some(start)
668}
669
670impl Harness {
671    pub(crate) fn next_subagent_completion(&mut self) -> Option<SubagentCompletion> {
672        self.completed_subagents.1.try_recv().ok()
673    }
674
675    pub(crate) fn next_subagent_activity(&mut self) -> Option<SubagentActivity> {
676        self.subagent_activity.1.try_recv().ok()
677    }
678
679    /// Transfer the TUI-only activity stream to the frontend so worker output
680    /// can be rendered while the harness thread is blocked in a provider turn.
681    /// The matching sender remains in the harness and is cloned into workers.
682    pub(crate) fn take_subagent_activity_receiver(&mut self) -> mpsc::Receiver<SubagentActivity> {
683        let (_, replacement) = mpsc::channel();
684        std::mem::replace(&mut self.subagent_activity.1, replacement)
685    }
686
687    fn has_running_subagents(&self) -> bool {
688        self.subagents.lock().is_ok_and(|states| {
689            states
690                .values()
691                .any(|state| matches!(state, SubagentState::Running { .. }))
692        })
693    }
694
695    fn spawn_subagent(&self, task: String, logical_turn_id: &str) -> Value {
696        let under_limit = self.subagents.lock().ok().is_some_and(|states| {
697            states
698                .values()
699                .filter(|state| matches!(state, SubagentState::Running { .. }))
700                .count()
701                < MAX_CONCURRENT_SUBAGENTS
702        });
703        if !under_limit {
704            return serde_json::json!({"error": format!("subagent concurrency limit is {MAX_CONCURRENT_SUBAGENTS}")});
705        }
706        let settings = self.session.llm.clone();
707        let boot = self.session.boot_system_prompt.clone();
708        let cwd = self.session.cwd.clone();
709        let secret = self.provider.api_key().to_owned();
710        let child_session = match ChildSession::create(
711            &self.home,
712            &self.session.id,
713            &cwd,
714            boot.clone(),
715            settings.clone(),
716            task.clone(),
717            Some(&secret),
718        ) {
719            Ok(session) => session,
720            Err(error) => return serde_json::json!({"error": error.to_string()}),
721        };
722
723        let (commands, command_rx) = mpsc::channel();
724        let cancellation = CancellationToken::new();
725        let done = Arc::new((Mutex::new(None), Condvar::new()));
726        let shutdown = Arc::new(AtomicBool::new(false));
727        let control = SubagentControl {
728            cancellation: cancellation.clone(),
729            commands,
730            done: Arc::clone(&done),
731            shutdown: Arc::clone(&shutdown),
732        };
733        let task_id = child_session.id.clone();
734        {
735            let mut subagents = match self.subagents.lock() {
736                Ok(subagents) => subagents,
737                Err(_) => return serde_json::json!({"error": "subagent registry unavailable"}),
738            };
739            let running = subagents
740                .values()
741                .filter(|state| matches!(state, SubagentState::Running { .. }))
742                .count();
743            if running >= MAX_CONCURRENT_SUBAGENTS {
744                return serde_json::json!({"error": format!("subagent concurrency limit is {MAX_CONCURRENT_SUBAGENTS}")});
745            }
746            subagents.insert(
747                task_id.clone(),
748                SubagentState::Running {
749                    control: control.clone(),
750                    attached_turn_id: logical_turn_id.to_owned(),
751                },
752            );
753        }
754
755        let child_session_id = child_session.id.clone();
756        let states = Arc::clone(&self.subagents);
757        let completed = self.completed_subagents.0.clone();
758        let activity = self.subagent_activity.0.clone();
759        let completion_task_id = task_id.clone();
760        let activity_id = task_id.clone();
761        let completion_task = task.clone();
762        let home = self.home.clone();
763        std::thread::spawn(move || {
764            let mut child_session = child_session;
765            let raw_result = run_subagent(
766                home,
767                settings,
768                boot,
769                cwd,
770                task,
771                SubagentRunOptions {
772                    cancellation: Some(cancellation),
773                    commands: command_rx,
774                    shutdown,
775                    activity: Some((activity_id, activity)),
776                },
777                &mut child_session,
778            );
779            let result = redact_json_value(raw_result, &secret);
780            let status = if result.get("interrupted").is_some() {
781                ChildSessionStatus::Interrupted
782            } else if result.get("cancelled").is_some() {
783                ChildSessionStatus::Canceled
784            } else if result.get("error").is_some() {
785                ChildSessionStatus::Failed
786            } else {
787                ChildSessionStatus::Completed
788            };
789            let reason = result
790                .get("reason")
791                .and_then(Value::as_str)
792                .map(str::to_owned);
793            let _ = child_session.append_status(status, reason, Some(result.clone()));
794            let completion = SubagentCompletion {
795                completion_id: completion_id_for_child(&child_session_id),
796                task_id: completion_task_id.clone(),
797                child_session_id,
798                task: completion_task,
799                status,
800                result: result.clone(),
801                completed_at: unix_timestamp(),
802            };
803            let _ = completed.send(completion);
804            if let Ok(mut states) = states.lock() {
805                states.insert(
806                    completion_task_id.clone(),
807                    SubagentState::Completed(result.clone()),
808                );
809            }
810            let (result_slot, wake) = &*done;
811            if let Ok(mut slot) = result_slot.lock() {
812                *slot = Some(result);
813                wake.notify_all();
814            }
815        });
816        serde_json::json!({"task_id": task_id, "status": "queued"})
817    }
818
819    fn persist_completion<S: EventSink>(
820        &mut self,
821        completion: SubagentCompletion,
822        sink: &mut S,
823    ) -> Result<(), String> {
824        let pending = pending_from_completion(completion.clone());
825        if self
826            .session
827            .append_background_result_pending(pending)
828            .map_err(|error| error.to_string())?
829        {
830            sink.emit_event(&ProtocolEvent::BackgroundResultPending {
831                completion_id: completion.completion_id,
832                task_id: completion.task_id,
833                child_session_id: completion.child_session_id,
834                status: child_status_name(completion.status).to_owned(),
835                result: completion.result,
836                completed_at: completion.completed_at,
837            })
838            .map_err(|error| format!("unable to emit pending background result: {error}"))?;
839        }
840        Ok(())
841    }
842
843    pub(crate) fn collect_completed_subagents<S: EventSink>(
844        &mut self,
845        sink: &mut S,
846    ) -> Result<usize, String> {
847        let mut count = 0;
848        while let Some(completion) = self.next_subagent_completion() {
849            self.persist_completion(completion, sink)?;
850            count += 1;
851        }
852        Ok(count)
853    }
854
855    fn deliver_pending_background_results<S: EventSink>(
856        &mut self,
857        logical_turn_id: &str,
858        sink: &mut S,
859    ) -> Result<usize, String> {
860        let pending = self.session.undelivered_background_results();
861        let mut delivered_count = 0;
862        for result in pending {
863            if self
864                .session
865                .append_background_result_delivered(
866                    &result.completion_id,
867                    logical_turn_id.to_owned(),
868                    BackgroundResultDelivery::Synthetic,
869                )
870                .map_err(|error| error.to_string())?
871            {
872                sink.emit_event(&ProtocolEvent::BackgroundResultDelivered {
873                    completion_id: result.completion_id,
874                    task_id: result.task_id,
875                    logical_turn_id: logical_turn_id.to_owned(),
876                    delivery: "synthetic".to_owned(),
877                })
878                .map_err(|error| format!("unable to emit delivered background result: {error}"))?;
879                delivered_count += 1;
880            }
881        }
882        Ok(delivered_count)
883    }
884
885    fn mark_wait_delivery<S: EventSink>(
886        &mut self,
887        task_id: &str,
888        logical_turn_id: &str,
889        sink: &mut S,
890    ) -> Result<(), String> {
891        self.collect_completed_subagents(sink)?;
892        let pending = self
893            .session
894            .undelivered_background_results()
895            .into_iter()
896            .find(|pending| pending.task_id == task_id);
897        let Some(pending) = pending else {
898            return Ok(());
899        };
900        if self
901            .session
902            .append_background_result_delivered(
903                &pending.completion_id,
904                logical_turn_id.to_owned(),
905                BackgroundResultDelivery::WaitSubagent,
906            )
907            .map_err(|error| error.to_string())?
908        {
909            sink.emit_event(&ProtocolEvent::BackgroundResultDelivered {
910                completion_id: pending.completion_id,
911                task_id: pending.task_id,
912                logical_turn_id: logical_turn_id.to_owned(),
913                delivery: "wait_subagent".to_owned(),
914            })
915            .map_err(|error| format!("unable to emit delivered background result: {error}"))?;
916        }
917        Ok(())
918    }
919
920    fn has_running_subagents_for_turn(&self, logical_turn_id: &str) -> bool {
921        self.subagents.lock().is_ok_and(|states| {
922            states.values().any(|state| {
923                matches!(
924                    state,
925                    SubagentState::Running { attached_turn_id, .. }
926                        if attached_turn_id == logical_turn_id
927                )
928            })
929        })
930    }
931
932    fn wait_for_attached_completion<S: EventSink>(
933        &mut self,
934        logical_turn_id: &str,
935        sink: &mut S,
936        cancellation: Option<&CancellationToken>,
937    ) -> Result<(), String> {
938        while self.has_running_subagents_for_turn(logical_turn_id) {
939            if cancellation.is_some_and(CancellationToken::is_cancelled) {
940                return Ok(());
941            }
942            match self
943                .completed_subagents
944                .1
945                .recv_timeout(std::time::Duration::from_millis(25))
946            {
947                Ok(completion) => {
948                    self.persist_completion(completion, sink)?;
949                    self.collect_completed_subagents(sink)?;
950                    return Ok(());
951                }
952                Err(mpsc::RecvTimeoutError::Timeout) => {}
953                Err(mpsc::RecvTimeoutError::Disconnected) => {
954                    return Err("subagent completion channel closed".to_owned())
955                }
956            }
957        }
958        Ok(())
959    }
960
961    fn subagent_status(&self, arguments: &str) -> Value {
962        let task_id = match parse_task_id(arguments) {
963            Ok(task_id) => task_id,
964            Err(error) => return serde_json::json!({"error": error}),
965        };
966        match self
967            .subagents
968            .lock()
969            .ok()
970            .and_then(|states| states.get(&task_id).cloned())
971        {
972            Some(SubagentState::Running { .. }) => {
973                serde_json::json!({"task_id": task_id, "status": "running"})
974            }
975            Some(SubagentState::Completed(result)) => {
976                serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "result": result})
977            }
978            None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
979        }
980    }
981
982    fn wait_subagent(&self, arguments: &str, cancellation: Option<&CancellationToken>) -> Value {
983        let value = match serde_json::from_str::<Value>(arguments) {
984            Ok(value) => value,
985            Err(_) => {
986                return serde_json::json!({"error": "wait_subagent arguments must be an object"})
987            }
988        };
989        let task_id = match value.get("task_id").and_then(Value::as_str) {
990            Some(task_id) if !task_id.trim().is_empty() => task_id.trim().to_owned(),
991            _ => return serde_json::json!({"error": "wait_subagent requires a task_id string"}),
992        };
993        let timeout_ms = value
994            .get("timeout_ms")
995            .and_then(Value::as_u64)
996            .unwrap_or(30_000)
997            .clamp(1, 600_000);
998        let state = self
999            .subagents
1000            .lock()
1001            .ok()
1002            .and_then(|states| states.get(&task_id).cloned());
1003        let Some(state) = state else {
1004            return serde_json::json!({"task_id": task_id, "status": "unknown"});
1005        };
1006        let SubagentState::Running { control, .. } = state else {
1007            if let SubagentState::Completed(result) = state {
1008                return serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "result": result});
1009            }
1010            unreachable!();
1011        };
1012        let (result_slot, wake) = &*control.done;
1013        let mut result = match result_slot.lock() {
1014            Ok(result) => result,
1015            Err(_) => {
1016                return serde_json::json!({"task_id": task_id, "status": "failed", "error": "subagent wait unavailable"})
1017            }
1018        };
1019        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
1020        while result.is_none() {
1021            if cancellation.is_some_and(CancellationToken::is_cancelled) {
1022                return serde_json::json!({"task_id": task_id, "status": "parent_canceled"});
1023            }
1024            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1025            if remaining.is_zero() {
1026                return serde_json::json!({"task_id": task_id, "status": "waiting", "timed_out": true});
1027            }
1028            let interval = remaining.min(std::time::Duration::from_millis(25));
1029            let (guard, _) = wake
1030                .wait_timeout(result, interval)
1031                .unwrap_or_else(|poisoned| poisoned.into_inner());
1032            result = guard;
1033        }
1034        match result.clone() {
1035            Some(result) => {
1036                serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "result": result})
1037            }
1038            None => serde_json::json!({"task_id": task_id, "status": "waiting"}),
1039        }
1040    }
1041
1042    fn send_subagent(&self, arguments: &str) -> Value {
1043        let value = match serde_json::from_str::<Value>(arguments) {
1044            Ok(value) => value,
1045            Err(_) => {
1046                return serde_json::json!({"error": "send_subagent arguments must be an object"})
1047            }
1048        };
1049        let task_id = match value.get("task_id").and_then(Value::as_str) {
1050            Some(task_id) if !task_id.trim().is_empty() => task_id.trim().to_owned(),
1051            _ => return serde_json::json!({"error": "send_subagent requires a task_id string"}),
1052        };
1053        let message = match value.get("message").and_then(Value::as_str) {
1054            Some(message)
1055                if !message.trim().is_empty() && message.len() <= MAX_SUBAGENT_TASK_BYTES =>
1056            {
1057                message.to_owned()
1058            }
1059            _ => {
1060                return serde_json::json!({"error": "send_subagent message must be non-empty and bounded"})
1061            }
1062        };
1063        let state = self
1064            .subagents
1065            .lock()
1066            .ok()
1067            .and_then(|states| states.get(&task_id).cloned());
1068        match state {
1069            Some(SubagentState::Running { control, .. }) => {
1070                match control.commands.send(SubagentCommand::Message(message)) {
1071                    Ok(()) => serde_json::json!({"task_id": task_id, "status": "queued"}),
1072                    Err(_) => {
1073                        serde_json::json!({"task_id": task_id, "status": "failed", "error": "subagent command channel closed"})
1074                    }
1075                }
1076            }
1077            Some(SubagentState::Completed(result)) => {
1078                serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "error": "subagent is not running"})
1079            }
1080            None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
1081        }
1082    }
1083
1084    fn cancel_subagent(&self, arguments: &str) -> Value {
1085        let task_id = match parse_task_id(arguments) {
1086            Ok(task_id) => task_id,
1087            Err(error) => return serde_json::json!({"error": error}),
1088        };
1089        let state = self
1090            .subagents
1091            .lock()
1092            .ok()
1093            .and_then(|states| states.get(&task_id).cloned());
1094        match state {
1095            Some(SubagentState::Running { control, .. }) => {
1096                control.cancellation.cancel();
1097                serde_json::json!({"task_id": task_id, "status": "cancellation_requested"})
1098            }
1099            Some(SubagentState::Completed(result)) => {
1100                serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "error": "subagent is not running"})
1101            }
1102            None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
1103        }
1104    }
1105
1106    pub(crate) fn apply_settings(
1107        &mut self,
1108        home: &Path,
1109        model: String,
1110        effort: Option<String>,
1111    ) -> Result<(), String> {
1112        let config = Config::load_or_create(home).map_err(|error| error.to_string())?;
1113        let mut settings = config.resolved_llm().map_err(|error| error.to_string())?;
1114        settings.model = model.trim().to_owned();
1115        settings.effort = effort
1116            .map(|value| value.trim().to_owned())
1117            .filter(|value| !value.is_empty());
1118        // Endpoint and credential remain the session's established provider boundary.
1119        settings.base_url = self.session.llm.base_url.clone();
1120        settings.api_key_env = self.session.llm.api_key_env.clone();
1121        apply_auth_to_settings(&mut settings, auth_provider_for_settings(&self.session.llm));
1122        let provider = provider_for_settings(home, &settings).map_err(|error| error.to_string())?;
1123        // Validate both durable writes before changing the user-owned source of truth.
1124        self.session
1125            .validate_provider_settings(&settings.model, settings.effort.as_deref())
1126            .map_err(|error| error.to_string())?;
1127        Config::save_selection(home, &settings.model, settings.effort.as_deref())
1128            .map_err(|error| error.to_string())?;
1129        self.session
1130            .append_provider_settings(settings.model.clone(), settings.effort.clone())
1131            .map_err(|error| error.to_string())?;
1132        self.session.llm = settings;
1133        self.provider = provider;
1134        self.context_window = self.provider.context_window();
1135        Ok(())
1136    }
1137
1138    fn should_compact(&self, messages: &[ChatMessage]) -> bool {
1139        self.context_window
1140            .is_some_and(|window| should_compact_context(estimate_context_tokens(messages), window))
1141    }
1142
1143    fn compaction_boundary(&self) -> Option<usize> {
1144        let latest_boundary = self
1145            .session
1146            .history
1147            .iter()
1148            .rev()
1149            .find_map(|record| match record {
1150                crate::session::SessionHistoryRecord::Compaction(compaction) => {
1151                    Some(compaction.first_kept_message)
1152                }
1153                _ => None,
1154            });
1155        find_compaction_boundary(&self.session.messages, latest_boundary)
1156    }
1157
1158    fn compact_context<S: EventSink>(
1159        &mut self,
1160        sink: &mut S,
1161        cancellation: Option<&crate::cancellation::CancellationToken>,
1162        tokens_before: usize,
1163    ) -> Result<(), String> {
1164        let Some(boundary) = self.compaction_boundary() else {
1165            return Err("context cannot be compacted without an earlier complete turn".to_owned());
1166        };
1167        let Some(cancellation) = cancellation else {
1168            return Err("context compaction requires a cancellable turn".to_owned());
1169        };
1170        sink.compaction_started()
1171            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
1172        let context_messages = self.session.provider_messages();
1173        let mut summary_messages = Vec::with_capacity(context_messages.len() + 1);
1174        summary_messages.push(ChatMessage::system(self.session.boot_system_prompt.clone()));
1175        summary_messages.push(ChatMessage::system(COMPACTION_SYSTEM_PROMPT.to_owned()));
1176        summary_messages.extend(context_messages.into_iter().skip(1));
1177        let summary = match self.provider.summarize(&summary_messages, cancellation) {
1178            Ok(summary) => redact_secret(&summary, Some(&self.provider.api_key())),
1179            Err(error) if cancellation.is_cancelled() || error.is_cancelled() => {
1180                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1181            }
1182            Err(error) => return Err(format!("unable to compact context: {error}")),
1183        };
1184        self.session
1185            .append_compaction(summary, boundary, tokens_before)
1186            .map_err(|error| format!("unable to persist context compaction: {error}"))?;
1187        let tokens_after = estimate_context_tokens(&self.session.provider_messages());
1188        sink.compaction_finished(tokens_before, tokens_after)
1189            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
1190        Ok(())
1191    }
1192
1193    pub(crate) fn handle_message<S: EventSink>(
1194        &mut self,
1195        text: &str,
1196        sink: &mut S,
1197        cancellation: Option<&crate::cancellation::CancellationToken>,
1198    ) -> Result<(), String> {
1199        let logical_turn_id = format!("turn-{}-{}", self.session.id, self.session.history.len());
1200        self.collect_completed_subagents(sink)?;
1201        if cancellation.is_some_and(CancellationToken::is_cancelled) {
1202            return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1203        }
1204        self.deliver_pending_background_results(&logical_turn_id, sink)?;
1205        let secret = self.provider.api_key().to_owned();
1206        let expanded = expand_skill_invocation(text, &self.session.skills)?;
1207        let user_message = ChatMessage::user(redact_secret(&expanded.text, Some(&secret)));
1208        if let Err(error) = self.session.append_message(user_message) {
1209            if cancellation.is_some_and(|token| token.is_cancelled()) {
1210                let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1211                return interruption
1212                    .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
1213            }
1214            return Err(error.to_string());
1215        }
1216        if let Some(name) = expanded.attached_skill.as_deref() {
1217            sink.skill_instruction_attached(name)
1218                .map_err(|error| format!("unable to emit skill attachment state: {error}"))?;
1219        }
1220
1221        let mut compacted_for_turn = false;
1222        loop {
1223            self.collect_completed_subagents(sink)?;
1224            if cancellation.is_some_and(CancellationToken::is_cancelled) {
1225                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1226            }
1227            self.deliver_pending_background_results(&logical_turn_id, sink)?;
1228            let mut messages = self.session.provider_messages();
1229            let tokens_before = estimate_context_tokens(&messages);
1230            if !compacted_for_turn && self.should_compact(&messages) {
1231                self.compact_context(sink, cancellation, tokens_before)?;
1232                compacted_for_turn = true;
1233                messages = self.session.provider_messages();
1234            }
1235            sink.context_usage(estimate_context_tokens(&messages))
1236                .map_err(|error| format!("unable to emit context usage: {error}"))?;
1237            let mut raw_content = String::new();
1238            let mut redactor = SecretRedactor::new(&secret);
1239            let mut reasoning_active = false;
1240            let stream_result = {
1241                let mut on_event = |event: ProviderStreamEvent| -> io::Result<()> {
1242                    match event {
1243                        ProviderStreamEvent::ReasoningStarted => {
1244                            if !reasoning_active {
1245                                reasoning_active = true;
1246                                sink.reasoning_started()?;
1247                            }
1248                            Ok(())
1249                        }
1250                        ProviderStreamEvent::Text(delta) => {
1251                            if reasoning_active {
1252                                reasoning_active = false;
1253                                sink.reasoning_completed()?;
1254                            }
1255                            raw_content.push_str(&delta);
1256                            redactor.push(&delta, |safe_delta| {
1257                                sink.emit_event(&ProtocolEvent::AssistantDelta {
1258                                    text: safe_delta.to_owned(),
1259                                })
1260                            })
1261                        }
1262                    }
1263                };
1264                match cancellation {
1265                    Some(token) => self
1266                        .provider
1267                        .stream_chat_cancellable_with_options_and_events(
1268                            &messages,
1269                            &mut on_event,
1270                            token,
1271                            true,
1272                            true,
1273                        ),
1274                    None => self.provider.stream_chat(&messages, &mut |delta| {
1275                        raw_content.push_str(delta);
1276                        redactor.push(delta, |safe_delta| {
1277                            sink.emit_event(&ProtocolEvent::AssistantDelta {
1278                                text: safe_delta.to_owned(),
1279                            })
1280                        })
1281                    }),
1282                }
1283            };
1284            redactor
1285                .finish(|safe_delta| {
1286                    sink.emit_event(&ProtocolEvent::AssistantDelta {
1287                        text: safe_delta.to_owned(),
1288                    })
1289                })
1290                .map_err(|error| format!("unable to write assistant delta: {error}"))?;
1291            let turn = match stream_result {
1292                Ok(turn) => {
1293                    if reasoning_active {
1294                        sink.reasoning_completed()
1295                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
1296                    }
1297                    turn
1298                }
1299                Err(error)
1300                    if cancellation.is_some_and(|token| token.is_cancelled())
1301                        || error.is_cancelled() =>
1302                {
1303                    if reasoning_active {
1304                        sink.reasoning_completed()
1305                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
1306                    }
1307                    let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
1308                        content: raw_content,
1309                        tool_calls: Vec::new(),
1310                        reasoning_details: Vec::new(),
1311                    });
1312                    return self.interrupt(
1313                        sink,
1314                        PROVIDER_PHASE,
1315                        &partial.content,
1316                        &partial.tool_calls,
1317                        Vec::new(),
1318                    );
1319                }
1320                Err(error) => {
1321                    if reasoning_active {
1322                        sink.reasoning_completed()
1323                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
1324                    }
1325                    return Err(error.to_string());
1326                }
1327            };
1328            let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
1329
1330            if turn.tool_calls.iter().any(|call| {
1331                !matches!(
1332                    call.name.as_str(),
1333                    "cmd"
1334                        | "spawn_subagent"
1335                        | "check_subagent"
1336                        | "wait_subagent"
1337                        | "send_subagent"
1338                        | "cancel_subagent"
1339                )
1340            }) {
1341                if canceled_after_stream {
1342                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
1343                }
1344                return Err("provider requested an unsupported tool".to_owned());
1345            }
1346            let safe_tool_calls = turn
1347                .tool_calls
1348                .iter()
1349                .map(|call| safe_tool_call(call, &secret))
1350                .collect::<Vec<_>>();
1351            let assistant_content = redact_secret(&turn.content, Some(&secret));
1352            let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
1353            let mut assistant =
1354                ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
1355            assistant.reasoning_details = safe_reasoning_details;
1356            if let Err(error) = self.session.append_message(assistant) {
1357                if cancellation.is_some_and(|token| token.is_cancelled()) {
1358                    let interruption = self.interrupt(
1359                        sink,
1360                        PROVIDER_PHASE,
1361                        &assistant_content,
1362                        &turn.tool_calls,
1363                        Vec::new(),
1364                    );
1365                    return interruption
1366                        .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
1367                }
1368                return Err(error.to_string());
1369            }
1370
1371            if safe_tool_calls.is_empty() {
1372                self.collect_completed_subagents(sink)?;
1373                if canceled_after_stream
1374                    || cancellation.is_some_and(CancellationToken::is_cancelled)
1375                {
1376                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1377                }
1378                if self.deliver_pending_background_results(&logical_turn_id, sink)? > 0 {
1379                    continue;
1380                }
1381                if self.has_running_subagents_for_turn(&logical_turn_id) {
1382                    self.wait_for_attached_completion(&logical_turn_id, sink, cancellation)?;
1383                    if cancellation.is_some_and(CancellationToken::is_cancelled) {
1384                        return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1385                    }
1386                    self.deliver_pending_background_results(&logical_turn_id, sink)?;
1387                    continue;
1388                }
1389                if cancellation.is_some_and(|token| !token.try_complete()) {
1390                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1391                }
1392                sink.context_usage(estimate_context_tokens(&self.session.provider_messages()))
1393                    .map_err(|error| format!("unable to emit context usage: {error}"))?;
1394                sink.emit_event(&ProtocolEvent::TurnEnd)
1395                    .map_err(|error| format!("unable to write turn end: {error}"))?;
1396                return Ok(());
1397            }
1398
1399            for safe_call in &safe_tool_calls {
1400                sink.emit_event(&ProtocolEvent::ToolCall {
1401                    id: safe_call.id.clone(),
1402                    name: safe_call.name.clone(),
1403                    arguments: safe_call.arguments.clone(),
1404                })
1405                .map_err(|error| format!("unable to write tool call: {error}"))?;
1406            }
1407            for (index, raw_call) in turn.tool_calls.iter().enumerate() {
1408                let safe_call = &safe_tool_calls[index];
1409                let result = if raw_call.name == "spawn_subagent" {
1410                    match parse_subagent_arguments(&raw_call.arguments) {
1411                        Ok(task) => self.spawn_subagent(task, &logical_turn_id),
1412                        Err(error) => serde_json::json!({"error": error}),
1413                    }
1414                } else if raw_call.name == "check_subagent" {
1415                    self.subagent_status(&raw_call.arguments)
1416                } else if raw_call.name == "wait_subagent" {
1417                    self.wait_subagent(&raw_call.arguments, cancellation)
1418                } else if raw_call.name == "send_subagent" {
1419                    self.send_subagent(&raw_call.arguments)
1420                } else if raw_call.name == "cancel_subagent" {
1421                    self.cancel_subagent(&raw_call.arguments)
1422                } else if cancellation.is_some_and(|token| token.is_cancelled()) {
1423                    serde_json::to_value(crate::command::canceled_result(
1424                        &safe_call.arguments,
1425                        &secret,
1426                    ))
1427                    .map_err(|error| format!("unable to encode cmd result: {error}"))?
1428                } else {
1429                    serde_json::to_value(crate::command::execute_with_cancellation(
1430                        &raw_call.arguments,
1431                        &self.session.cwd,
1432                        self.provider.api_key_env(),
1433                        Some(&secret),
1434                        cancellation,
1435                    ))
1436                    .map_err(|error| format!("unable to encode cmd result: {error}"))?
1437                };
1438                let mut result = redact_json_value(result, &secret);
1439                if raw_call.name == "wait_subagent"
1440                    && cancellation.is_some_and(CancellationToken::is_cancelled)
1441                {
1442                    result = serde_json::json!({
1443                        "task_id": parse_task_id(&raw_call.arguments).ok(),
1444                        "status": "parent_canceled"
1445                    });
1446                }
1447                let tool_content = serde_json::to_string(&result)
1448                    .map_err(|error| format!("unable to encode tool result: {error}"))?;
1449                let tool_message = ChatMessage::tool(
1450                    safe_call.id.clone(),
1451                    safe_call.name.clone(),
1452                    redact_secret(&tool_content, Some(&secret)),
1453                );
1454                let observation = crate::session::SessionToolResult {
1455                    id: safe_call.id.clone(),
1456                    name: safe_call.name.clone(),
1457                    result: result.clone(),
1458                };
1459                if let Err(error) = self.session.append_message(tool_message) {
1460                    if cancellation.is_some_and(|token| token.is_cancelled()) {
1461                        let interruption =
1462                            self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
1463                        return interruption
1464                            .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
1465                    }
1466                    return Err(error.to_string());
1467                }
1468                sink.emit_event(&ProtocolEvent::ToolResult {
1469                    id: safe_call.id.clone(),
1470                    name: safe_call.name.clone(),
1471                    result: result.clone(),
1472                })
1473                .map_err(|error| format!("unable to write tool result: {error}"))?;
1474                if cancellation.is_some_and(|token| token.is_cancelled()) {
1475                    for pending_call in safe_tool_calls.iter().skip(index + 1) {
1476                        let pending_result = redact_json_value(
1477                            serde_json::to_value(crate::command::canceled_result(
1478                                &pending_call.arguments,
1479                                &secret,
1480                            ))
1481                            .map_err(|error| format!("unable to encode cmd result: {error}"))?,
1482                            &secret,
1483                        );
1484                        let pending_content = serde_json::to_string(&pending_result)
1485                            .map_err(|error| format!("unable to encode tool result: {error}"))?;
1486                        let pending_message = ChatMessage::tool(
1487                            pending_call.id.clone(),
1488                            pending_call.name.clone(),
1489                            redact_secret(&pending_content, Some(&secret)),
1490                        );
1491                        let pending_observation = crate::session::SessionToolResult {
1492                            id: pending_call.id.clone(),
1493                            name: pending_call.name.clone(),
1494                            result: pending_result.clone(),
1495                        };
1496                        if let Err(error) = self.session.append_message(pending_message) {
1497                            if cancellation.is_some_and(|token| token.is_cancelled()) {
1498                                let interruption = self.interrupt(
1499                                    sink,
1500                                    COMMAND_PHASE,
1501                                    "",
1502                                    &[],
1503                                    vec![pending_observation],
1504                                );
1505                                return interruption.map_err(|interrupt_error| {
1506                                    format!("{error}; {interrupt_error}")
1507                                });
1508                            }
1509                            return Err(error.to_string());
1510                        }
1511                        sink.emit_event(&ProtocolEvent::ToolResult {
1512                            id: pending_call.id.clone(),
1513                            name: pending_call.name.clone(),
1514                            result: pending_result.clone(),
1515                        })
1516                        .map_err(|error| format!("unable to write tool result: {error}"))?;
1517                    }
1518                    return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
1519                }
1520                if raw_call.name == "wait_subagent" && result.get("result").is_some() {
1521                    if let Ok(task_id) = parse_task_id(&raw_call.arguments) {
1522                        self.mark_wait_delivery(&task_id, &logical_turn_id, sink)?;
1523                    }
1524                }
1525            }
1526            self.collect_completed_subagents(sink)?;
1527            if cancellation.is_some_and(CancellationToken::is_cancelled) {
1528                return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
1529            }
1530            self.deliver_pending_background_results(&logical_turn_id, sink)?;
1531        }
1532    }
1533
1534    fn interrupt<S: EventSink>(
1535        &mut self,
1536        sink: &mut S,
1537        phase: &str,
1538        assistant_text: &str,
1539        tool_calls: &[ChatToolCall],
1540        tool_results: Vec<crate::session::SessionToolResult>,
1541    ) -> Result<(), String> {
1542        let secret = self.provider.api_key();
1543        let safe_tool_calls = tool_calls
1544            .iter()
1545            .filter(|call| call.name == "cmd")
1546            .map(|call| safe_partial_tool_call(call, &secret))
1547            .collect::<Vec<_>>();
1548        let safe_tool_results = tool_results.clone();
1549        let interruption = crate::session::InterruptionRecord {
1550            timestamp: 0,
1551            reason: USER_CANCEL_REASON.to_owned(),
1552            phase: phase.to_owned(),
1553            assistant_text: redact_secret(assistant_text, Some(&secret)),
1554            tool_calls: safe_tool_calls.clone(),
1555            tool_results,
1556        };
1557        let persistence_error = self.session.append_interruption(interruption).err();
1558        let mut event_error = None;
1559        for call in &safe_tool_calls {
1560            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
1561                id: call.id.clone(),
1562                name: call.name.clone(),
1563                arguments: call.arguments.clone(),
1564            }) {
1565                event_error.get_or_insert(error);
1566            }
1567        }
1568        for observation in &safe_tool_results {
1569            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
1570                id: observation.id.clone(),
1571                name: observation.name.clone(),
1572                result: observation.result.clone(),
1573            }) {
1574                event_error.get_or_insert(error);
1575            }
1576        }
1577        if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
1578            reason: USER_CANCEL_REASON.to_owned(),
1579            phase: phase.to_owned(),
1580        }) {
1581            event_error.get_or_insert(error);
1582        }
1583        match (persistence_error, event_error) {
1584            (None, None) => Ok(()),
1585            (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
1586            (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
1587            (Some(persistence), Some(event)) => Err(format!(
1588                "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
1589            )),
1590        }
1591    }
1592}
1593
1594fn parse_task_id(arguments: &str) -> Result<String, String> {
1595    serde_json::from_str::<Value>(arguments)
1596        .ok()
1597        .and_then(|value| {
1598            value
1599                .get("task_id")
1600                .and_then(Value::as_str)
1601                .filter(|task_id| !task_id.trim().is_empty())
1602                .map(|task_id| task_id.trim().to_owned())
1603        })
1604        .ok_or_else(|| "subagent requires a task_id string".to_owned())
1605}
1606
1607fn subagent_canceled_result(shutdown: &AtomicBool) -> Value {
1608    if shutdown.load(Ordering::Acquire) {
1609        serde_json::json!({"interrupted": true, "reason": "process_shutdown"})
1610    } else {
1611        serde_json::json!({"cancelled": true})
1612    }
1613}
1614
1615fn pending_from_completion(completion: SubagentCompletion) -> BackgroundResultPending {
1616    BackgroundResultPending {
1617        timestamp: 0,
1618        completion_id: completion.completion_id,
1619        task_id: completion.task_id,
1620        child_session_id: completion.child_session_id,
1621        task: completion.task,
1622        status: completion.status,
1623        result: completion.result,
1624        completed_at: completion.completed_at,
1625    }
1626}
1627
1628fn completion_id_for_child(child_session_id: &str) -> String {
1629    format!("completion-{child_session_id}")
1630}
1631
1632fn unix_timestamp() -> u64 {
1633    std::time::SystemTime::now()
1634        .duration_since(std::time::UNIX_EPOCH)
1635        .unwrap_or_default()
1636        .as_secs()
1637}
1638
1639fn child_status_name(status: ChildSessionStatus) -> &'static str {
1640    match status {
1641        ChildSessionStatus::Running => "running",
1642        ChildSessionStatus::Completed => "completed",
1643        ChildSessionStatus::Failed => "failed",
1644        ChildSessionStatus::Canceled => "canceled",
1645        ChildSessionStatus::Interrupted => "interrupted",
1646    }
1647}
1648
1649fn terminal_status(result: &Value) -> &'static str {
1650    if result.get("interrupted").is_some() {
1651        "interrupted"
1652    } else if result.get("cancelled").is_some() {
1653        "canceled"
1654    } else if result.get("error").is_some() {
1655        "failed"
1656    } else {
1657        "completed"
1658    }
1659}
1660
1661fn parse_subagent_arguments(arguments: &str) -> Result<String, String> {
1662    let value: Value = serde_json::from_str(arguments)
1663        .map_err(|_| "spawn_subagent arguments must be a JSON object")?;
1664    let object = value
1665        .as_object()
1666        .ok_or("spawn_subagent arguments must be a JSON object")?;
1667    if object.keys().any(|key| key != "task") {
1668        return Err(
1669            "spawn_subagent accepts only task; model and effort always inherit from the session"
1670                .to_owned(),
1671        );
1672    }
1673    let task = object
1674        .get("task")
1675        .and_then(Value::as_str)
1676        .ok_or("spawn_subagent task must be a string")?
1677        .trim()
1678        .to_owned();
1679    if task.is_empty() || task.len() > MAX_SUBAGENT_TASK_BYTES {
1680        return Err("spawn_subagent task must be non-empty and bounded".to_owned());
1681    }
1682    Ok(task)
1683}
1684
1685struct SubagentRunOptions {
1686    cancellation: Option<CancellationToken>,
1687    commands: mpsc::Receiver<SubagentCommand>,
1688    shutdown: Arc<AtomicBool>,
1689    activity: Option<(String, mpsc::Sender<SubagentActivity>)>,
1690}
1691
1692fn run_subagent(
1693    home: PathBuf,
1694    settings: crate::config::LlmSettings,
1695    _boot_context: String,
1696    cwd: std::path::PathBuf,
1697    task: String,
1698    options: SubagentRunOptions,
1699    child_session: &mut ChildSession,
1700) -> Value {
1701    let selected_model = settings.model.clone();
1702    let selected_effort = settings.effort.clone();
1703    let provider = match provider_for_settings(&home, &settings) {
1704        Ok(provider) => provider,
1705        Err(error) => return serde_json::json!({"error": error.to_string()}),
1706    };
1707    let secret = provider.api_key().to_owned();
1708    if let Err(error) =
1709        child_session.append_message(ChatMessage::user(redact_secret(&task, Some(&secret))))
1710    {
1711        return serde_json::json!({"error": error.to_string()});
1712    }
1713    let activity = options.activity;
1714    let commands = options.commands;
1715    let shutdown = options.shutdown;
1716    let cancellation = options.cancellation.unwrap_or_default();
1717    loop {
1718        while let Ok(command) = commands.try_recv() {
1719            match command {
1720                SubagentCommand::Message(message) => {
1721                    let message = ChatMessage::user(redact_secret(&message, Some(&secret)));
1722                    if let Err(error) = child_session.append_message(message) {
1723                        return serde_json::json!({"error": error.to_string()});
1724                    }
1725                }
1726            }
1727        }
1728        if cancellation.is_cancelled() {
1729            return subagent_canceled_result(&shutdown);
1730        }
1731        let messages = child_session.provider_messages();
1732        let activity_for_stream = activity.clone();
1733        let mut reasoning_active = false;
1734        let mut on_event = move |event: ProviderStreamEvent| -> std::io::Result<()> {
1735            let Some((task_id, sender)) = activity_for_stream.as_ref() else {
1736                return Ok(());
1737            };
1738            match event {
1739                ProviderStreamEvent::ReasoningStarted => {
1740                    if !reasoning_active {
1741                        reasoning_active = true;
1742                        let _ = sender.send(SubagentActivity::ReasoningStarted {
1743                            task_id: task_id.clone(),
1744                        });
1745                    }
1746                }
1747                ProviderStreamEvent::Text(text) => {
1748                    if reasoning_active {
1749                        reasoning_active = false;
1750                        let _ = sender.send(SubagentActivity::ReasoningCompleted {
1751                            task_id: task_id.clone(),
1752                        });
1753                    }
1754                    let _ = sender.send(SubagentActivity::Event {
1755                        task_id: task_id.clone(),
1756                        event: ProtocolEvent::AssistantDelta { text },
1757                    });
1758                }
1759            }
1760            Ok(())
1761        };
1762        let turn = match provider.stream_chat_cancellable_with_options_and_events(
1763            &messages,
1764            &mut on_event,
1765            &cancellation,
1766            true,
1767            false,
1768        ) {
1769            Ok(turn) => turn,
1770            Err(error) if error.is_cancelled() || cancellation.is_cancelled() => {
1771                return subagent_canceled_result(&shutdown)
1772            }
1773            Err(error) => return serde_json::json!({"error": error.to_string()}),
1774        };
1775        if reasoning_active {
1776            if let Some((task_id, sender)) = activity.as_ref() {
1777                let _ = sender.send(SubagentActivity::ReasoningCompleted {
1778                    task_id: task_id.clone(),
1779                });
1780            }
1781        }
1782        if turn.tool_calls.iter().any(|call| call.name != "cmd") {
1783            return serde_json::json!({"error": "subagent requested an unsupported tool"});
1784        }
1785        let safe_tool_calls = turn
1786            .tool_calls
1787            .iter()
1788            .map(|call| safe_tool_call(call, &secret))
1789            .collect::<Vec<_>>();
1790        let mut assistant =
1791            ChatMessage::assistant(redact_secret(&turn.content, Some(&secret)), safe_tool_calls);
1792        assistant.reasoning_details = (!turn.reasoning_details.is_empty()).then(|| {
1793            turn.reasoning_details
1794                .iter()
1795                .map(|detail| redact_json_value(detail.clone(), &secret))
1796                .collect()
1797        });
1798        if let Err(error) = child_session.append_message(assistant) {
1799            return serde_json::json!({"error": error.to_string()});
1800        }
1801        if turn.tool_calls.is_empty() {
1802            return serde_json::json!({"model": selected_model, "effort": selected_effort, "output": redact_secret(&turn.content, Some(&secret))});
1803        }
1804        for call in turn.tool_calls {
1805            if let Some((task_id, sender)) = activity.as_ref() {
1806                let _ = sender.send(SubagentActivity::Event {
1807                    task_id: task_id.clone(),
1808                    event: ProtocolEvent::ToolCall {
1809                        id: call.id.clone(),
1810                        name: call.name.clone(),
1811                        arguments: redact_secret(&call.arguments, Some(&secret)),
1812                    },
1813                });
1814            }
1815            let result = crate::command::execute_with_cancellation(
1816                &call.arguments,
1817                &cwd,
1818                provider.api_key_env(),
1819                Some(&provider.api_key()),
1820                Some(&cancellation),
1821            );
1822            let result_value = redact_json_value(
1823                serde_json::to_value(&result).unwrap_or_else(
1824                    |_| serde_json::json!({"error":"unable to encode command result"}),
1825                ),
1826                &secret,
1827            );
1828            if let Some((task_id, sender)) = activity.as_ref() {
1829                let _ = sender.send(SubagentActivity::Event {
1830                    task_id: task_id.clone(),
1831                    event: ProtocolEvent::ToolResult {
1832                        id: call.id.clone(),
1833                        name: call.name.clone(),
1834                        result: result_value.clone(),
1835                    },
1836                });
1837            }
1838            let content = match serde_json::to_string(&result_value) {
1839                Ok(content) => content,
1840                Err(_) => {
1841                    return serde_json::json!({"error": "unable to encode subagent command result"})
1842                }
1843            };
1844            if let Err(error) =
1845                child_session.append_message(ChatMessage::tool(call.id, call.name, content))
1846            {
1847                return serde_json::json!({"error": error.to_string()});
1848            }
1849            if cancellation.is_cancelled() {
1850                return subagent_canceled_result(&shutdown);
1851            }
1852        }
1853    }
1854}
1855
1856struct SecretRedactor {
1857    secret_text: String,
1858    secret: Vec<char>,
1859    marker: String,
1860    pending: String,
1861}
1862
1863impl SecretRedactor {
1864    fn new(secret: &str) -> Self {
1865        Self {
1866            secret_text: secret.to_owned(),
1867            secret: secret.chars().collect(),
1868            marker: redaction_marker(secret).unwrap_or_default(),
1869            pending: String::new(),
1870        }
1871    }
1872
1873    fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
1874    where
1875        F: FnMut(&str) -> io::Result<()>,
1876    {
1877        if self.secret.is_empty() {
1878            return emit(text);
1879        }
1880
1881        let mut output = String::new();
1882        for character in text.chars() {
1883            self.pending.push(character);
1884            if self.pending.chars().eq(self.secret.iter().copied()) {
1885                self.pending.clear();
1886                output.push_str(&self.marker);
1887                continue;
1888            }
1889            if self.pending_is_secret_prefix() {
1890                continue;
1891            }
1892
1893            let pending = self.pending.chars().collect::<Vec<_>>();
1894            let suffix_len = (1..pending.len())
1895                .rev()
1896                .find(|length| {
1897                    pending[pending.len() - length..].iter().copied().eq(self
1898                        .secret
1899                        .iter()
1900                        .copied()
1901                        .take(*length))
1902                })
1903                .unwrap_or(0);
1904            let safe_len = pending.len() - suffix_len;
1905            output.extend(pending[..safe_len].iter());
1906            self.pending = pending[safe_len..].iter().collect();
1907        }
1908
1909        if output.is_empty() {
1910            Ok(())
1911        } else {
1912            let safe_output = redact_secret(&output, Some(&self.secret_text));
1913            emit(&safe_output)
1914        }
1915    }
1916
1917    fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
1918    where
1919        F: FnMut(&str) -> io::Result<()>,
1920    {
1921        let pending = std::mem::take(&mut self.pending);
1922        if pending.is_empty() {
1923            return Ok(());
1924        }
1925        let safe_pending = redact_secret(&pending, Some(&self.secret_text));
1926        emit(&safe_pending)
1927    }
1928
1929    fn pending_is_secret_prefix(&self) -> bool {
1930        let length = self.pending.chars().count();
1931        length < self.secret.len()
1932            && self
1933                .pending
1934                .chars()
1935                .zip(self.secret.iter().copied())
1936                .all(|(pending, secret)| pending == secret)
1937    }
1938}
1939
1940/// Return the AGENTS.md files selected for the current new-session boot context.
1941/// Paths are secret-redacted before they can reach the terminal UI.
1942fn attached_agents(instruction_files: Vec<InstructionSource>, secret: &str) -> Vec<String> {
1943    instruction_files
1944        .into_iter()
1945        .filter(|source| {
1946            source
1947                .path
1948                .file_name()
1949                .is_some_and(|name| name == "AGENTS.md")
1950        })
1951        .map(|source| redact_secret(&source.path.display().to_string(), Some(secret)))
1952        .collect()
1953}
1954
1955/// Store a secret-safe skill snapshot with the session. The source is read
1956/// once during secure context discovery; later invocations never follow paths.
1957fn escape_xml_attribute(text: &str) -> String {
1958    text.replace('&', "&amp;")
1959        .replace('<', "&lt;")
1960        .replace('>', "&gt;")
1961        .replace('\"', "&quot;")
1962        .replace('\'', "&apos;")
1963}
1964
1965fn redact_skills(skills: Vec<SkillEntry>, secret: &str) -> Vec<SkillEntry> {
1966    skills
1967        .into_iter()
1968        .map(|skill| SkillEntry {
1969            name: redact_secret(&skill.name, Some(secret)),
1970            description: redact_secret(&skill.description, Some(secret)),
1971            path: std::path::PathBuf::from(redact_secret(
1972                &skill.path.display().to_string(),
1973                Some(secret),
1974            )),
1975            contents: redact_secret(&skill.contents, Some(secret)),
1976            model_invocable: skill.model_invocable,
1977        })
1978        .collect()
1979}
1980
1981/// The message delivered to the provider and the optional name of the saved
1982/// skill snapshot that was attached to it.
1983#[derive(Debug)]
1984struct ExpandedSkillInvocation {
1985    text: String,
1986    attached_skill: Option<String>,
1987}
1988
1989/// Expand slash-prefixed skill names into the user message sent to the
1990/// provider. This deliberately adds no model-facing tool: skills are context,
1991/// not an executable capability of their own.
1992fn expand_skill_invocation(
1993    text: &str,
1994    skills: &[SkillEntry],
1995) -> Result<ExpandedSkillInvocation, String> {
1996    let Some(invocation) = text.strip_prefix('/') else {
1997        return Ok(ExpandedSkillInvocation {
1998            text: text.to_owned(),
1999            attached_skill: None,
2000        });
2001    };
2002    let mut pieces = invocation.splitn(2, char::is_whitespace);
2003    let name = pieces.next().unwrap_or_default();
2004    if name.is_empty() {
2005        return Err("skill command requires a skill name: /<name> [args]".to_owned());
2006    }
2007    let Some(skill) = skills.iter().find(|skill| skill.name == name) else {
2008        return Err(format!("unknown skill: {name}"));
2009    };
2010    let arguments = pieces.next().unwrap_or_default().trim();
2011    let mut message = format!(
2012        "<skill name=\"{}\" location=\"{}\">\n{}\n</skill>",
2013        escape_xml_attribute(&skill.name),
2014        escape_xml_attribute(&skill.path.display().to_string()),
2015        skill.contents.trim()
2016    );
2017    if !arguments.is_empty() {
2018        message.push_str("\n\nUser: ");
2019        message.push_str(arguments);
2020    }
2021    Ok(ExpandedSkillInvocation {
2022        text: message,
2023        attached_skill: Some(skill.name.clone()),
2024    })
2025}
2026
2027#[cfg(test)]
2028fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
2029    safe_tool_call(
2030        &ChatToolCall {
2031            id: String::new(),
2032            name: "cmd".to_owned(),
2033            arguments: arguments.to_owned(),
2034        },
2035        secret,
2036    )
2037    .arguments
2038}
2039
2040fn safe_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
2041    let valid = match call.name.as_str() {
2042        "cmd" => serde_json::from_str::<Value>(&call.arguments)
2043            .ok()
2044            .and_then(|value| value.as_object().cloned())
2045            .is_some_and(|object| {
2046                object.len() == 1 && object.get("command").is_some_and(Value::is_string)
2047            }),
2048        "spawn_subagent" => parse_subagent_arguments(&call.arguments).is_ok(),
2049        "check_subagent" | "cancel_subagent" => serde_json::from_str::<Value>(&call.arguments)
2050            .ok()
2051            .and_then(|value| value.as_object().cloned())
2052            .is_some_and(|object| {
2053                object.len() == 1 && object.get("task_id").is_some_and(Value::is_string)
2054            }),
2055        "wait_subagent" => serde_json::from_str::<Value>(&call.arguments)
2056            .ok()
2057            .and_then(|value| value.as_object().cloned())
2058            .is_some_and(|object| {
2059                object.get("task_id").is_some_and(Value::is_string)
2060                    && object
2061                        .get("timeout_ms")
2062                        .is_none_or(|timeout| timeout.as_u64().is_some())
2063                    && object
2064                        .keys()
2065                        .all(|key| key == "task_id" || key == "timeout_ms")
2066            }),
2067        "send_subagent" => serde_json::from_str::<Value>(&call.arguments)
2068            .ok()
2069            .and_then(|value| value.as_object().cloned())
2070            .is_some_and(|object| {
2071                object.len() == 2
2072                    && object.get("task_id").is_some_and(Value::is_string)
2073                    && object.get("message").is_some_and(Value::is_string)
2074            }),
2075        _ => false,
2076    };
2077    let arguments = if valid {
2078        serde_json::to_string(&redact_json_value(
2079            serde_json::from_str(&call.arguments).unwrap_or(Value::Null),
2080            secret,
2081        ))
2082        .unwrap_or_else(|_| "{}".to_owned())
2083    } else {
2084        "{}".to_owned()
2085    };
2086    ChatToolCall {
2087        id: redact_secret(&call.id, Some(secret)),
2088        name: redact_secret(&call.name, Some(secret)),
2089        arguments,
2090    }
2091}
2092
2093fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
2094    let arguments = if serde_json::from_str::<Value>(&call.arguments)
2095        .ok()
2096        .and_then(|value| value.as_object().cloned())
2097        .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
2098    {
2099        safe_tool_call(call, secret).arguments
2100    } else {
2101        // An incomplete argument fragment is an observation only. Do not
2102        // preserve malformed provider JSON: decoding it later could expose a
2103        // credential that was hidden by the outer JSON string.
2104        "{}".to_owned()
2105    };
2106    ChatToolCall {
2107        id: redact_secret(&call.id, Some(secret)),
2108        name: redact_secret(&call.name, Some(secret)),
2109        arguments,
2110    }
2111}
2112
2113fn redact_json_value(value: Value, secret: &str) -> Value {
2114    match value {
2115        Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
2116        Value::Array(values) => Value::Array(
2117            values
2118                .into_iter()
2119                .map(|value| redact_json_value(value, secret))
2120                .collect(),
2121        ),
2122        Value::Object(object) => {
2123            let marker = redaction_marker(secret).unwrap_or_default();
2124            let mut redacted = Map::new();
2125            for (key, value) in object {
2126                let mut safe_key = if is_structural_key(&key) {
2127                    key
2128                } else {
2129                    redact_secret(&key, Some(secret))
2130                };
2131                if redacted.contains_key(&safe_key) {
2132                    if marker.is_empty() {
2133                        continue;
2134                    }
2135                    while redacted.contains_key(&safe_key) {
2136                        safe_key.push_str(&marker);
2137                    }
2138                }
2139                redacted.insert(safe_key, redact_json_value(value, secret));
2140            }
2141            Value::Object(redacted)
2142        }
2143        value => value,
2144    }
2145}
2146
2147fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
2148    if details.is_empty() {
2149        return None;
2150    }
2151    match redact_json_value(Value::Array(details.to_vec()), secret) {
2152        Value::Array(details) => Some(details),
2153        _ => None,
2154    }
2155}
2156
2157fn write_version<W: Write>(mut output: W) -> io::Result<()> {
2158    writeln!(output, "lucy {}", env!("CARGO_PKG_VERSION"))
2159}
2160
2161fn parse_args(args: &[String]) -> Result<CliOptions, String> {
2162    let mut options = CliOptions {
2163        session: None,
2164        list_sessions: false,
2165        jsonl: false,
2166        tui: false,
2167        version: false,
2168        command: None,
2169    };
2170    if args.len() == 2 && args[0] == "codex" {
2171        let command = match args[1].as_str() {
2172            "login" => CliCommand::CodexLogin,
2173            "logout" => CliCommand::CodexLogout,
2174            _ => return Err("usage: lucy codex <login|logout>".to_owned()),
2175        };
2176        options.command = Some(command);
2177        return Ok(options);
2178    }
2179    if args.first().is_some_and(|arg| arg == "codex") {
2180        return Err("usage: lucy codex <login|logout>".to_owned());
2181    }
2182    let mut index = 0;
2183    while index < args.len() {
2184        match args[index].as_str() {
2185            "--session" => {
2186                if options.list_sessions || options.session.is_some() {
2187                    return Err("--session cannot be combined or repeated".to_owned());
2188                }
2189                index += 1;
2190                let Some(id) = args.get(index) else {
2191                    return Err("--session requires an id".to_owned());
2192                };
2193                options.session = Some(id.clone());
2194            }
2195            "--list-sessions" => {
2196                if options.session.is_some() || options.list_sessions {
2197                    return Err("--list-sessions cannot be combined or repeated".to_owned());
2198                }
2199                options.list_sessions = true;
2200            }
2201            "--jsonl" => {
2202                if options.jsonl || options.tui {
2203                    return Err("--jsonl cannot be combined or repeated".to_owned());
2204                }
2205                options.jsonl = true;
2206            }
2207            "--tui" => {
2208                if options.tui || options.jsonl {
2209                    return Err("--tui cannot be combined or repeated".to_owned());
2210                }
2211                options.tui = true;
2212            }
2213            "--version" => {
2214                if options.version {
2215                    return Err("--version cannot be repeated".to_owned());
2216                }
2217                options.version = true;
2218            }
2219            "--help" | "-h" => {
2220                return Err(
2221                    "usage: lucy [--version] [--jsonl|--tui] [--session <id>] [--list-sessions] | lucy codex <login|logout>"
2222                        .to_owned(),
2223                );
2224            }
2225            _ => return Err("unknown argument".to_owned()),
2226        }
2227        index += 1;
2228    }
2229    Ok(options)
2230}
2231
2232fn parse_input_message(line: &str) -> Result<String, String> {
2233    let record: InputRecord = serde_json::from_str(line)
2234        .map_err(|_| "input must be a JSONL message record".to_owned())?;
2235    if record.record_type != "message" {
2236        return Err("input record type must be message".to_owned());
2237    }
2238    record
2239        .text
2240        .ok_or_else(|| "message record requires a text string".to_owned())
2241}
2242
2243fn home_directory() -> Result<PathBuf, String> {
2244    std::env::var_os("HOME")
2245        .map(PathBuf::from)
2246        .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
2247}
2248
2249fn run_codex_command<W: Write, E: Write>(
2250    command: CliCommand,
2251    home: &Path,
2252    mut output: W,
2253    diagnostics: &mut E,
2254) -> i32 {
2255    match command {
2256        CliCommand::CodexLogin => match crate::auth::login(home) {
2257            Ok(_) => {
2258                let _ = writeln!(output, "Codex login successful");
2259                0
2260            }
2261            Err(error) => {
2262                write_diagnostic(diagnostics, &error.to_string());
2263                1
2264            }
2265        },
2266        CliCommand::CodexLogout => match crate::auth::AuthStore::for_home(home).logout() {
2267            Ok(true) => {
2268                let _ = writeln!(output, "Codex logout successful");
2269                0
2270            }
2271            Ok(false) => {
2272                let _ = writeln!(output, "Codex was not logged in");
2273                0
2274            }
2275            Err(error) => {
2276                write_diagnostic(diagnostics, &error.to_string());
2277                1
2278            }
2279        },
2280    }
2281}
2282
2283fn apply_auth_to_settings(settings: &mut LlmSettings, provider: AuthProvider) {
2284    if provider == AuthProvider::CodexSubscription {
2285        settings.api_key_env = crate::codex_provider::CODEX_ENV_SENTINEL.to_owned();
2286    }
2287}
2288
2289fn auth_provider_for_settings(settings: &LlmSettings) -> AuthProvider {
2290    if settings.api_key_env == crate::codex_provider::CODEX_ENV_SENTINEL {
2291        AuthProvider::CodexSubscription
2292    } else {
2293        AuthProvider::Openrouter
2294    }
2295}
2296
2297fn provider_for_settings(
2298    home: &Path,
2299    settings: &LlmSettings,
2300) -> Result<Provider, crate::provider::ProviderError> {
2301    match auth_provider_for_settings(settings) {
2302        AuthProvider::CodexSubscription => Provider::new_codex(home, settings),
2303        AuthProvider::Openrouter => Provider::new(settings),
2304    }
2305}
2306
2307fn configured_codex_secret(home: &Path, provider: AuthProvider) -> Option<String> {
2308    if provider != AuthProvider::CodexSubscription {
2309        return None;
2310    }
2311    crate::auth::AuthStore::for_home(home)
2312        .load()
2313        .ok()
2314        .flatten()
2315        .map(|credentials| credentials.access)
2316        .filter(|secret| !secret.is_empty())
2317}
2318
2319fn configured_api_key_env(config: &Config) -> Option<String> {
2320    config.resolved_auth().ok()?.api_key_env
2321}
2322
2323fn configured_api_key(config: &Config) -> Option<String> {
2324    configured_api_key_env(config)
2325        .and_then(|api_key_env| std::env::var(api_key_env).ok())
2326        .filter(|secret| !secret.is_empty())
2327}
2328
2329fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
2330    write_diagnostic_safe_with_environment(
2331        diagnostics,
2332        message,
2333        secret,
2334        std::env::vars().map(|(_, value)| value),
2335    );
2336}
2337
2338fn write_diagnostic_safe_with_environment<W, I>(
2339    diagnostics: &mut W,
2340    message: &str,
2341    secret: Option<&str>,
2342    environment_values: I,
2343) where
2344    W: Write,
2345    I: IntoIterator<Item = String>,
2346{
2347    let mut safe_line = format!("!: {message}");
2348    safe_line = redact_secret(&safe_line, secret);
2349    let mut environment_secrets = environment_values
2350        .into_iter()
2351        .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
2352        .collect::<Vec<_>>();
2353    environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
2354    for environment_secret in environment_secrets {
2355        safe_line = redact_secret(&safe_line, Some(&environment_secret));
2356    }
2357    let _ = writeln!(diagnostics, "{safe_line}");
2358}
2359
2360fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
2361    write_diagnostic_safe(diagnostics, message, None);
2362}
2363
2364#[cfg(test)]
2365mod tests {
2366    use super::*;
2367    use crate::cancellation::CancellationToken;
2368    use std::io::{Cursor, Read, Write};
2369    use std::net::TcpListener;
2370    use std::thread;
2371
2372    #[test]
2373    fn codex_subcommands_parse_without_entering_a_session() {
2374        assert_eq!(
2375            parse_args(&["codex".to_owned(), "login".to_owned()])
2376                .expect("codex login")
2377                .command,
2378            Some(CliCommand::CodexLogin)
2379        );
2380        assert_eq!(
2381            parse_args(&["codex".to_owned(), "logout".to_owned()])
2382                .expect("codex logout")
2383                .command,
2384            Some(CliCommand::CodexLogout)
2385        );
2386        assert_eq!(
2387            parse_args(&["codex".to_owned(), "status".to_owned()])
2388                .expect_err("unknown codex command"),
2389            "usage: lucy codex <login|logout>"
2390        );
2391    }
2392
2393    #[test]
2394    fn codex_logout_is_idempotent_and_does_not_bootstrap_a_session() {
2395        let home = std::env::temp_dir().join(format!("lucy-codex-logout-{}", std::process::id()));
2396        let _ = std::fs::remove_dir_all(&home);
2397        let cwd = std::env::current_dir().expect("cwd");
2398        let mut output = Vec::new();
2399        let mut diagnostics = Vec::new();
2400        let exit = run_cli_at_home(
2401            &["codex".to_owned(), "logout".to_owned()],
2402            Cursor::new(Vec::<u8>::new()),
2403            &mut output,
2404            &mut diagnostics,
2405            &home,
2406            &cwd,
2407        );
2408        assert_eq!(exit, 0);
2409        assert!(String::from_utf8_lossy(&output).contains("not logged in"));
2410        assert!(diagnostics.is_empty());
2411        assert!(!home.exists());
2412    }
2413
2414    #[test]
2415    fn auto_compaction_triggers_at_or_above_ninety_five_percent_only() {
2416        assert!(!should_compact_context(94, 100));
2417        assert!(should_compact_context(95, 100));
2418        assert!(should_compact_context(96, 100));
2419        assert!(!should_compact_context(100, 0));
2420    }
2421
2422    #[test]
2423    fn compaction_boundary_keeps_complete_recent_turns() {
2424        let messages = [
2425            ChatMessage::user("old request".to_owned()),
2426            ChatMessage::assistant("old answer".to_owned(), Vec::new()),
2427            ChatMessage::user("recent request".to_owned()),
2428            ChatMessage::assistant("recent answer ".repeat(8_000), Vec::new()),
2429        ];
2430
2431        assert_eq!(find_compaction_boundary(&messages, None), Some(2));
2432        assert_eq!(find_compaction_boundary(&messages, Some(2)), None);
2433    }
2434
2435    #[test]
2436    fn spawn_subagent_accepts_only_a_task_and_rejects_setting_overrides() {
2437        assert_eq!(
2438            parse_subagent_arguments(r#"{"task":"inspect"}"#),
2439            Ok("inspect".to_owned())
2440        );
2441        for arguments in [
2442            r#"{"task":"inspect","model":"other-model"}"#,
2443            r#"{"task":"inspect","effort":"high"}"#,
2444        ] {
2445            let error = parse_subagent_arguments(arguments).expect_err("override rejected");
2446            assert!(error.contains("model and effort always inherit from the session"));
2447        }
2448    }
2449
2450    #[test]
2451    fn spawned_worker_has_no_tool_round_limit() {
2452        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("worker listener");
2453        listener
2454            .set_nonblocking(true)
2455            .expect("worker listener nonblocking");
2456        let address = listener.local_addr().expect("worker address");
2457        let mut responses = (0..33)
2458            .map(|index| {
2459                let tool = serde_json::json!({
2460                    "id": "provider-id",
2461                    "object": "chat.completion.chunk",
2462                    "choices": [{
2463                        "index": 0,
2464                        "delta": {
2465                            "tool_calls": [{
2466                                "index": 0,
2467                                "id": format!("worker-call-{index}"),
2468                                "type": "function",
2469                                "function": {
2470                                    "name": "cmd",
2471                                    "arguments": "{\"command\":\"true\"}"
2472                                }
2473                            }]
2474                        },
2475                        "finish_reason": "tool_calls"
2476                    }]
2477                });
2478                format!("data: {tool}\n\ndata: [DONE]\n\n")
2479            })
2480            .collect::<Vec<_>>();
2481        responses.push(normalized_provider_response("worker complete"));
2482        let expected_requests = responses.len();
2483        let server = thread::spawn(move || {
2484            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2485            let mut requests = 0;
2486            for response in responses {
2487                let (mut stream, _) = loop {
2488                    match listener.accept() {
2489                        Ok((stream, address)) => {
2490                            stream
2491                                .set_nonblocking(false)
2492                                .expect("worker connection blocking");
2493                            break (stream, address);
2494                        }
2495                        Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2496                            assert!(
2497                                std::time::Instant::now() < deadline,
2498                                "worker request timed out"
2499                            );
2500                            thread::sleep(std::time::Duration::from_millis(5));
2501                        }
2502                        Err(error) => panic!("worker accept: {error}"),
2503                    }
2504                };
2505                let mut reader = std::io::BufReader::new(stream.try_clone().expect("worker clone"));
2506                let mut content_length = 0usize;
2507                loop {
2508                    let mut line = String::new();
2509                    reader.read_line(&mut line).expect("worker request header");
2510                    if line == "\r\n" {
2511                        break;
2512                    }
2513                    if let Some((name, value)) = line.split_once(':') {
2514                        if name.eq_ignore_ascii_case("content-length") {
2515                            content_length = value.trim().parse().expect("worker content length");
2516                        }
2517                    }
2518                }
2519                let mut body = vec![0_u8; content_length];
2520                reader.read_exact(&mut body).expect("worker request body");
2521                let header = format!(
2522                    "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2523                    response.len()
2524                );
2525                stream.write_all(header.as_bytes()).expect("worker header");
2526                stream
2527                    .write_all(response.as_bytes())
2528                    .expect("worker response");
2529                stream.flush().expect("worker flush");
2530                requests += 1;
2531            }
2532            requests
2533        });
2534
2535        let key_env = format!("LUCY_WORKER_LOOP_KEY_{}", std::process::id());
2536        std::env::set_var(&key_env, "provider-secret");
2537        let settings = crate::config::LlmSettings {
2538            base_url: format!("http://{address}/v1"),
2539            model: "worker-model".to_owned(),
2540            api_key_env: key_env.clone(),
2541            effort: None,
2542        };
2543        let cwd = std::env::current_dir().expect("worker cwd");
2544        let home = std::env::temp_dir().join(format!("lucy-worker-session-{}", std::process::id()));
2545        std::fs::create_dir_all(&home).expect("worker home");
2546        let mut child_session = ChildSession::create(
2547            &home,
2548            "parent-session",
2549            &cwd,
2550            "boot context".to_owned(),
2551            settings.clone(),
2552            "inspect many steps".to_owned(),
2553            Some("provider-secret"),
2554        )
2555        .expect("child session");
2556        let (_, commands) = mpsc::channel();
2557        let result = run_subagent(
2558            home.clone(),
2559            settings,
2560            "boot context".to_owned(),
2561            cwd,
2562            "inspect many steps".to_owned(),
2563            SubagentRunOptions {
2564                cancellation: Some(CancellationToken::new()),
2565                commands,
2566                shutdown: Arc::new(AtomicBool::new(false)),
2567                activity: None,
2568            },
2569            &mut child_session,
2570        );
2571
2572        assert_eq!(result["output"], "worker complete");
2573        assert_eq!(server.join().expect("worker server"), expected_requests);
2574        std::env::remove_var(key_env);
2575        std::fs::remove_dir_all(home).expect("worker home cleanup");
2576    }
2577
2578    fn normalized_provider_response(text: &str) -> String {
2579        let payload = serde_json::json!({
2580            "id": "provider-id",
2581            "object": "chat.completion.chunk",
2582            "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
2583        });
2584        format!("data: {payload}\n\ndata: [DONE]\n\n")
2585    }
2586
2587    #[test]
2588    fn mid_turn_compaction_summarizes_without_tools_then_continues_original_request() {
2589        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("compaction listener");
2590        let address = listener.local_addr().expect("compaction address");
2591        let responses = ["summary", "continued"];
2592        let server = thread::spawn(move || {
2593            let mut requests = Vec::new();
2594            for response_text in responses {
2595                let (mut stream, _) = listener.accept().expect("compaction request");
2596                let mut request = String::new();
2597                let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone"));
2598                let mut content_length = 0usize;
2599                loop {
2600                    let mut line = String::new();
2601                    reader.read_line(&mut line).expect("request header");
2602                    if line == "\r\n" {
2603                        break;
2604                    }
2605                    if let Some((name, value)) = line.split_once(':') {
2606                        if name.eq_ignore_ascii_case("content-length") {
2607                            content_length = value.trim().parse().expect("content length");
2608                        }
2609                    }
2610                }
2611                let mut body = vec![0u8; content_length];
2612                reader.read_exact(&mut body).expect("request body");
2613                request.push_str(std::str::from_utf8(&body).expect("request JSON"));
2614                requests.push(serde_json::from_str::<Value>(&request).expect("request value"));
2615                let payload = serde_json::json!({
2616                    "choices": [{
2617                        "delta": {"content": response_text},
2618                        "finish_reason": null
2619                    }]
2620                });
2621                let body = format!("data: {payload}\n\ndata: [DONE]\n\n");
2622                let header = format!(
2623                    "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2624                    body.len()
2625                );
2626                stream
2627                    .write_all(header.as_bytes())
2628                    .expect("response header");
2629                stream.write_all(body.as_bytes()).expect("response body");
2630                stream.flush().expect("response flush");
2631            }
2632            requests
2633        });
2634
2635        let key_env = format!("LUCY_COMPACTION_APP_KEY_{}", std::process::id());
2636        std::env::set_var(&key_env, "provider-secret");
2637        let settings = crate::config::LlmSettings {
2638            base_url: format!("http://{address}/v1"),
2639            model: "model".to_owned(),
2640            api_key_env: key_env.clone(),
2641            effort: None,
2642        };
2643        let provider = Provider::new(&settings).expect("provider");
2644        let home = std::env::temp_dir().join(format!("lucy-app-compaction-{}", std::process::id()));
2645        let _ = std::fs::remove_dir_all(&home);
2646        std::fs::create_dir(&home).expect("temp home");
2647        let cwd = std::env::current_dir().expect("cwd");
2648        let mut session = Session::create_with_secret(
2649            &home,
2650            &cwd,
2651            "prompt".to_owned(),
2652            settings,
2653            Some("provider-secret"),
2654        )
2655        .expect("session");
2656        session
2657            .append_message(ChatMessage::user("old request".to_owned()))
2658            .expect("old user");
2659        session
2660            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
2661            .expect("old answer");
2662        session
2663            .append_message(ChatMessage::user("recent request".to_owned()))
2664            .expect("recent user");
2665        session
2666            .append_message(ChatMessage::assistant(
2667                "recent answer ".repeat(8_000),
2668                Vec::new(),
2669            ))
2670            .expect("recent answer");
2671
2672        struct Sink {
2673            events: Vec<ProtocolEvent>,
2674            compaction_started: bool,
2675            compaction_finished: bool,
2676        }
2677        impl EventSink for Sink {
2678            fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
2679                self.events.push(event.clone());
2680                Ok(())
2681            }
2682            fn compaction_started(&mut self) -> io::Result<()> {
2683                self.compaction_started = true;
2684                Ok(())
2685            }
2686            fn compaction_finished(&mut self, _: usize, _: usize) -> io::Result<()> {
2687                self.compaction_finished = true;
2688                Ok(())
2689            }
2690        }
2691
2692        let mut harness = Harness {
2693            home: std::env::temp_dir(),
2694            session,
2695            provider,
2696            context_window: Some(1),
2697            attached_agents: Vec::new(),
2698            subagents: Arc::new(Mutex::new(HashMap::new())),
2699            completed_subagents: mpsc::channel(),
2700            subagent_activity: mpsc::channel(),
2701        };
2702        let cancellation = CancellationToken::new();
2703        let mut sink = Sink {
2704            events: Vec::new(),
2705            compaction_started: false,
2706            compaction_finished: false,
2707        };
2708        harness
2709            .handle_message("continue", &mut sink, Some(&cancellation))
2710            .expect("continued turn");
2711
2712        let requests = server.join().expect("server");
2713        assert_eq!(requests.len(), 2);
2714        assert!(requests[0].get("tools").is_none());
2715        assert!(requests[1].get("tools").is_some());
2716        assert!(sink.compaction_started);
2717        assert!(sink.compaction_finished);
2718        assert!(sink.events.iter().any(
2719            |event| matches!(event, ProtocolEvent::AssistantDelta { text } if text == "continued")
2720        ));
2721        assert!(harness
2722            .session
2723            .history
2724            .iter()
2725            .any(|record| matches!(record, crate::session::SessionHistoryRecord::Compaction(_))));
2726        let provider_text = harness
2727            .session
2728            .provider_messages()
2729            .iter()
2730            .filter_map(|message| message.content.as_deref())
2731            .collect::<Vec<_>>()
2732            .join("\n");
2733        assert!(!provider_text.contains("old request"));
2734        assert!(provider_text.contains("continue"));
2735
2736        std::env::remove_var(key_env);
2737        std::fs::remove_dir_all(home).expect("cleanup");
2738    }
2739
2740    #[test]
2741    fn completed_subagents_are_persisted_before_the_next_parent_request() {
2742        let key_env = format!("LUCY_PARENT_NOTIFICATION_KEY_{}", std::process::id());
2743        std::env::set_var(&key_env, "provider-secret");
2744        let home =
2745            std::env::temp_dir().join(format!("lucy-parent-notification-{}", std::process::id()));
2746        let _ = std::fs::remove_dir_all(&home);
2747        std::fs::create_dir_all(&home).expect("home");
2748        let cwd = std::env::current_dir().expect("cwd");
2749        let settings = crate::config::LlmSettings {
2750            base_url: "http://localhost".to_owned(),
2751            model: "model".to_owned(),
2752            api_key_env: key_env.clone(),
2753            effort: None,
2754        };
2755        let provider = Provider::new(&settings).expect("provider");
2756        let session = Session::create_with_secret(
2757            &home,
2758            &cwd,
2759            "prompt".to_owned(),
2760            settings,
2761            Some("provider-secret"),
2762        )
2763        .expect("session");
2764        let (completion_tx, completion_rx) = mpsc::channel();
2765        let mut harness = Harness {
2766            home: home.clone(),
2767            session,
2768            provider,
2769            context_window: None,
2770            attached_agents: Vec::new(),
2771            subagents: Arc::new(Mutex::new(HashMap::new())),
2772            completed_subagents: (completion_tx, completion_rx),
2773            subagent_activity: mpsc::channel(),
2774        };
2775        harness
2776            .completed_subagents
2777            .0
2778            .send(SubagentCompletion {
2779                completion_id: "completion-1".to_owned(),
2780                task_id: "subagent-1".to_owned(),
2781                child_session_id: "child-1".to_owned(),
2782                task: "inspect".to_owned(),
2783                status: ChildSessionStatus::Completed,
2784                result: serde_json::json!({"output":"done"}),
2785                completed_at: 1,
2786            })
2787            .expect("completion");
2788        struct TestSink(Vec<ProtocolEvent>);
2789        impl EventSink for TestSink {
2790            fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
2791                self.0.push(event.clone());
2792                Ok(())
2793            }
2794        }
2795        let mut sink = TestSink(Vec::new());
2796        harness
2797            .collect_completed_subagents(&mut sink)
2798            .expect("collect completion");
2799
2800        assert!(
2801            harness.session.messages.is_empty(),
2802            "a terminal child result must never become parent user input"
2803        );
2804        assert!(harness.session.history.iter().any(|record| matches!(
2805            record,
2806            crate::session::SessionHistoryRecord::BackgroundResultPending(_)
2807        )));
2808        assert!(sink.0.iter().any(|event| matches!(
2809            event,
2810            ProtocolEvent::BackgroundResultPending { task_id, .. } if task_id == "subagent-1"
2811        )));
2812        harness
2813            .mark_wait_delivery("subagent-1", "turn-1", &mut sink)
2814            .expect("wait delivery");
2815        assert_eq!(
2816            harness
2817                .deliver_pending_background_results("turn-1", &mut sink)
2818                .expect("no synthetic duplicate"),
2819            0
2820        );
2821        assert!(harness.session.provider_messages().iter().all(|message| {
2822            message.name.as_deref() != Some(crate::session::BACKGROUND_RESULT_TOOL_NAME)
2823        }));
2824        let raw = std::fs::read_to_string(&harness.session.path).expect("parent JSONL");
2825        assert!(raw.contains("background_result_pending"));
2826        assert!(!raw.contains("provider-secret"));
2827
2828        let (commands, _command_rx) = mpsc::channel();
2829        let wait_control = SubagentControl {
2830            cancellation: CancellationToken::new(),
2831            commands,
2832            done: Arc::new((Mutex::new(None), Condvar::new())),
2833            shutdown: Arc::new(AtomicBool::new(false)),
2834        };
2835        harness.subagents.lock().expect("registry").insert(
2836            "subagent-wait".to_owned(),
2837            SubagentState::Running {
2838                control: wait_control,
2839                attached_turn_id: "turn-wait".to_owned(),
2840            },
2841        );
2842        let parent_cancel = CancellationToken::new();
2843        parent_cancel.cancel();
2844        let started = std::time::Instant::now();
2845        let wait_result = harness.wait_subagent(
2846            r#"{"task_id":"subagent-wait","timeout_ms":600000}"#,
2847            Some(&parent_cancel),
2848        );
2849        assert_eq!(wait_result["status"], "parent_canceled");
2850        assert!(started.elapsed() < std::time::Duration::from_millis(100));
2851        harness
2852            .subagents
2853            .lock()
2854            .expect("registry")
2855            .remove("subagent-wait");
2856
2857        harness
2858            .completed_subagents
2859            .0
2860            .send(SubagentCompletion {
2861                completion_id: "completion-shutdown".to_owned(),
2862                task_id: "subagent-shutdown".to_owned(),
2863                child_session_id: "child-shutdown".to_owned(),
2864                task: "shutdown".to_owned(),
2865                status: ChildSessionStatus::Interrupted,
2866                result: serde_json::json!({"interrupted":true,"reason":"process_shutdown"}),
2867                completed_at: 2,
2868            })
2869            .expect("shutdown completion");
2870        let session_id = harness.session.id.clone();
2871        drop(harness);
2872        let resumed = Session::resume(&home, &session_id).expect("resume parent");
2873        assert!(resumed
2874            .undelivered_background_results()
2875            .iter()
2876            .any(|pending| {
2877                pending.completion_id == "completion-shutdown"
2878                    && pending.status == ChildSessionStatus::Interrupted
2879            }));
2880        std::env::remove_var(key_env);
2881        std::fs::remove_dir_all(home).expect("cleanup");
2882    }
2883
2884    #[test]
2885    fn completion_identity_is_derived_from_the_durable_child_session() {
2886        assert_eq!(
2887            completion_id_for_child("subagent-child-a"),
2888            "completion-subagent-child-a"
2889        );
2890        assert_ne!(
2891            completion_id_for_child("subagent-child-a"),
2892            completion_id_for_child("subagent-child-b")
2893        );
2894    }
2895
2896    #[test]
2897    fn parses_only_message_records() {
2898        assert_eq!(
2899            parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
2900            "hello"
2901        );
2902        assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
2903        assert_eq!(
2904            parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
2905            ""
2906        );
2907    }
2908
2909    #[test]
2910    fn resolves_terminal_and_forced_modes() {
2911        assert_eq!(
2912            resolve_mode(&[], true, true).expect("default TUI"),
2913            FrontendMode::Tui
2914        );
2915        assert_eq!(
2916            resolve_mode(&[], true, false).expect("automatic JSONL"),
2917            FrontendMode::Jsonl
2918        );
2919        assert_eq!(
2920            resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
2921            FrontendMode::Jsonl
2922        );
2923        assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
2924    }
2925
2926    #[test]
2927    fn redactor_does_not_leak_a_secret_across_deltas() {
2928        let mut redactor = SecretRedactor::new("secret");
2929        let mut output = Vec::new();
2930        redactor
2931            .push("prefix sec", |text| {
2932                output.push(text.to_owned());
2933                Ok(())
2934            })
2935            .expect("push");
2936        redactor
2937            .push("ret suffix", |text| {
2938                output.push(text.to_owned());
2939                Ok(())
2940            })
2941            .expect("push");
2942        redactor
2943            .finish(|text| {
2944                output.push(text.to_owned());
2945                Ok(())
2946            })
2947            .expect("finish");
2948        let output = output.join("");
2949        assert_eq!(
2950            output,
2951            format!("prefix {} suffix", redaction_marker("secret").unwrap())
2952        );
2953        assert!(!output.contains("secret"));
2954    }
2955
2956    #[test]
2957    fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
2958        let mut redactor = SecretRedactor::new("n0");
2959        let mut output = String::new();
2960        redactor
2961            .push("\n0", |text| {
2962                output.push_str(text);
2963                Ok(())
2964            })
2965            .expect("push");
2966        redactor
2967            .finish(|text| {
2968                output.push_str(text);
2969                Ok(())
2970            })
2971            .expect("finish");
2972        assert!(!output.contains("n0"));
2973        assert_eq!(output, redaction_marker("n0").unwrap());
2974    }
2975
2976    #[test]
2977    fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
2978        let mut redactor = SecretRedactor::new("secret");
2979        let mut output = Vec::new();
2980        redactor
2981            .push("xsecre", |text| {
2982                output.push(text.to_owned());
2983                Ok(())
2984            })
2985            .expect("first delta");
2986        redactor
2987            .push("t", |text| {
2988                output.push(text.to_owned());
2989                Ok(())
2990            })
2991            .expect("second delta");
2992        redactor
2993            .finish(|text| {
2994                output.push(text.to_owned());
2995                Ok(())
2996            })
2997            .expect("finish");
2998        let output = output.join("");
2999        assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
3000        assert!(!output.contains("secret"));
3001    }
3002
3003    #[test]
3004    fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
3005        for secret in ["REDACTED", "[REDACTED]"] {
3006            let mut redactor = SecretRedactor::new(secret);
3007            let split = secret.len() / 2;
3008            let (first, second) = secret.split_at(split);
3009            let mut output = String::new();
3010            redactor
3011                .push(first, |text| {
3012                    output.push_str(text);
3013                    Ok(())
3014                })
3015                .expect("first delta");
3016            redactor
3017                .push(second, |text| {
3018                    output.push_str(text);
3019                    Ok(())
3020                })
3021                .expect("second delta");
3022            redactor
3023                .finish(|text| {
3024                    output.push_str(text);
3025                    Ok(())
3026                })
3027                .expect("finish");
3028            assert!(!output.contains(secret));
3029            assert!(output.len() <= secret.len());
3030        }
3031    }
3032
3033    #[test]
3034    fn malformed_tool_arguments_use_a_safe_copy() {
3035        let secret = "provider-secret";
3036        let escaped = secret
3037            .chars()
3038            .map(|character| format!(r#"\u{:04x}"#, character as u32))
3039            .collect::<String>();
3040        let arguments = format!(r#"{{"command":"{escaped}""#);
3041        let safe = redact_tool_arguments(&arguments, secret);
3042        assert_eq!(safe, "{}");
3043        serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
3044        assert!(!safe.contains(secret));
3045        assert!(!safe.contains(&escaped));
3046        for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
3047            assert_eq!(redact_tool_arguments(invalid, secret), "{}");
3048        }
3049    }
3050
3051    #[test]
3052    fn structured_redaction_preserves_tool_and_result_schema_keys() {
3053        let secret = "provider-secret";
3054        let value = serde_json::json!({
3055            "command": "printf provider-secret",
3056            "stdout": "provider-secret",
3057            "stderr": "ordinary",
3058            "exit_code": 0,
3059            "timed_out": false,
3060            "stdout_truncated": false,
3061            "stderr_truncated": false,
3062            "unknown-provider-secret": "provider-secret"
3063        });
3064        let redacted = redact_json_value(value, secret);
3065        for key in [
3066            "command",
3067            "stdout",
3068            "stderr",
3069            "exit_code",
3070            "timed_out",
3071            "stdout_truncated",
3072            "stderr_truncated",
3073        ] {
3074            assert!(redacted.get(key).is_some(), "missing schema key: {key}");
3075        }
3076        let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
3077        assert!(!encoded.contains(secret));
3078        assert!(redacted.get("unknown-provider-secret").is_none());
3079    }
3080
3081    #[test]
3082    fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
3083        let value = serde_json::json!({
3084            "exit_code": 0,
3085            "timed_out": false,
3086            "stdout_truncated": true,
3087            "error": null,
3088        });
3089        let redacted = redact_json_value(value, "0");
3090        assert!(redacted["exit_code"].is_number());
3091        assert!(redacted["timed_out"].is_boolean());
3092        assert!(redacted["stdout_truncated"].is_boolean());
3093        assert!(redacted["error"].is_null());
3094    }
3095
3096    #[test]
3097    fn reasoning_details_are_recursively_redacted_before_persistence() {
3098        let details = vec![serde_json::json!({
3099            "type": "reasoning.text",
3100            "text": "provider-secret",
3101            "nested": [{"value": "provider-secret"}],
3102            "provider-secret": "provider-secret"
3103        })];
3104        let redacted = redact_reasoning_details(&details, "provider-secret")
3105            .expect("non-empty reasoning details");
3106        let redacted = Value::Array(redacted);
3107        let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
3108        assert!(!encoded.contains("provider-secret"));
3109        assert_eq!(redacted[0]["type"], "reasoning.text");
3110        assert_eq!(redacted[0]["text"], "[REDACTED]");
3111        assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
3112        assert!(redacted[0].get("provider-secret").is_none());
3113    }
3114
3115    #[test]
3116    fn malformed_input_error_does_not_echo_secret_bearing_input() {
3117        let error =
3118            parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
3119                .expect_err("invalid input");
3120        assert!(!error.contains("provider-secret"));
3121    }
3122
3123    #[test]
3124    fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
3125        let mut output = Vec::new();
3126        let error = parse_input_message("not json").expect_err("invalid input");
3127        let mut protocol = ProtocolWriter::new(&mut output);
3128        protocol.error(&error).expect("error event");
3129        assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
3130        let _ = Cursor::new("");
3131    }
3132
3133    #[test]
3134    fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
3135        let secret = "lucy";
3136        let mut diagnostics = Vec::new();
3137        write_diagnostic_safe_with_environment(
3138            &mut diagnostics,
3139            secret,
3140            None,
3141            vec![secret.to_owned()],
3142        );
3143        let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
3144        assert!(!diagnostics.contains(secret));
3145    }
3146    #[test]
3147    fn attached_agents_keeps_only_agents_files_and_redacts_their_paths() {
3148        let sources = vec![
3149            InstructionSource {
3150                path: std::path::PathBuf::from("/project/AGENTS.md"),
3151                contents: "agents".to_owned(),
3152            },
3153            InstructionSource {
3154                path: std::path::PathBuf::from("/project/CLAUDE.md"),
3155                contents: "claude".to_owned(),
3156            },
3157            InstructionSource {
3158                path: std::path::PathBuf::from("/private-secret/AGENTS.md"),
3159                contents: "agents".to_owned(),
3160            },
3161        ];
3162
3163        assert_eq!(
3164            attached_agents(sources, "secret"),
3165            vec!["/project/AGENTS.md", "/private-!/AGENTS.md"]
3166        );
3167    }
3168
3169    #[test]
3170    fn expands_slash_prefixed_skill_names_and_keeps_ordinary_messages() {
3171        let skill = SkillEntry {
3172            name: "release-notes".to_owned(),
3173            description: "Writes release notes".to_owned(),
3174            path: std::path::PathBuf::from("/skills/release-notes/SKILL.md"),
3175            contents: "# Release notes\nUse the template.".to_owned(),
3176            model_invocable: true,
3177        };
3178        let expanded = expand_skill_invocation("/release-notes v1.2", std::slice::from_ref(&skill))
3179            .expect("skill command");
3180        assert!(expanded.text.contains("# Release notes"));
3181        assert!(expanded.text.contains("User: v1.2"));
3182        assert_eq!(expanded.attached_skill.as_deref(), Some("release-notes"));
3183        let ordinary = expand_skill_invocation("ordinary message", &[]).expect("ordinary message");
3184        assert_eq!(ordinary.text, "ordinary message");
3185        assert_eq!(ordinary.attached_skill, None);
3186        assert_eq!(
3187            expand_skill_invocation("/missing", &[]).unwrap_err(),
3188            "unknown skill: missing"
3189        );
3190        assert_eq!(
3191            expand_skill_invocation("/skill:release-notes", &[skill]).unwrap_err(),
3192            "unknown skill: skill:release-notes"
3193        );
3194    }
3195}