Skip to main content

lucy/
app.rs

1use std::io::{self, BufRead, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3use std::sync::mpsc;
4
5use serde::Deserialize;
6use serde_json::{Map, Value};
7
8use crate::cancellation::CancellationToken;
9use crate::config::{AuthProvider, Config, LlmSettings};
10use crate::context::{resolve_boot_context_with_api_key_env, InstructionSource, SkillEntry};
11use crate::model::{estimate_context_tokens, estimate_message_tokens, ChatMessage, ChatToolCall};
12use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
13use crate::provider::{Provider, ProviderStreamEvent, ProviderTurn};
14use crate::redaction::{
15    conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key, redact_secret,
16    redaction_marker,
17};
18use crate::session::Session;
19
20#[derive(Debug)]
21struct CliOptions {
22    session: Option<String>,
23    list_sessions: bool,
24    jsonl: bool,
25    tui: bool,
26    version: bool,
27    command: Option<CliCommand>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum CliCommand {
32    CodexLogin,
33    CodexLogout,
34}
35
36#[derive(Debug, Deserialize)]
37struct InputRecord {
38    #[serde(rename = "type")]
39    record_type: String,
40    text: Option<String>,
41}
42
43const USER_CANCEL_REASON: &str = "user_cancelled";
44const PROVIDER_PHASE: &str = "provider_stream";
45const COMMAND_PHASE: &str = "cmd";
46const AUTO_COMPACTION_THRESHOLD_PERCENT: usize = 95;
47const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
48const 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.";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FrontendMode {
52    Jsonl,
53    Tui,
54}
55
56pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
57where
58    R: BufRead + Send + 'static,
59    W: Write,
60    E: Write,
61{
62    let options = match parse_args(args) {
63        Ok(options) => options,
64        Err(error) => {
65            let mut diagnostics = diagnostics;
66            write_diagnostic(&mut diagnostics, &error);
67            return 2;
68        }
69    };
70    if options.version {
71        if let Err(error) = write_version(output) {
72            let mut diagnostics = diagnostics;
73            write_diagnostic(
74                &mut diagnostics,
75                &format!("unable to write version: {error}"),
76            );
77            return 1;
78        }
79        return 0;
80    }
81
82    let home = match home_directory() {
83        Ok(home) => home,
84        Err(error) => {
85            let mut diagnostics = diagnostics;
86            write_diagnostic(&mut diagnostics, &error);
87            return 1;
88        }
89    };
90    let cwd = match std::env::current_dir() {
91        Ok(cwd) => cwd,
92        Err(_error) => {
93            let mut diagnostics = diagnostics;
94            write_diagnostic(&mut diagnostics, "unable to resolve cwd");
95            return 1;
96        }
97    };
98    run_cli_at_home_with_terminals(
99        args,
100        input,
101        output,
102        diagnostics,
103        &home,
104        &cwd,
105        io::stdin().is_terminal(),
106        io::stdout().is_terminal(),
107    )
108}
109
110pub fn run_cli_at_home<R, W, E>(
111    args: &[String],
112    input: R,
113    output: W,
114    diagnostics: E,
115    home: &Path,
116    cwd: &Path,
117) -> i32
118where
119    R: BufRead + Send + 'static,
120    W: Write,
121    E: Write,
122{
123    // The generic test/library entry point has no terminal handles. The real
124    // binary uses run_cli, which supplies the actual stdio terminal state.
125    run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
126}
127
128#[allow(clippy::too_many_arguments)]
129fn run_cli_at_home_with_terminals<R, W, E>(
130    args: &[String],
131    input: R,
132    output: W,
133    mut diagnostics: E,
134    home: &Path,
135    cwd: &Path,
136    stdin_is_tty: bool,
137    stdout_is_tty: bool,
138) -> i32
139where
140    R: BufRead + Send + 'static,
141    W: Write,
142    E: Write,
143{
144    let options = match parse_args(args) {
145        Ok(options) => options,
146        Err(error) => {
147            let mut diagnostics = diagnostics;
148            write_diagnostic(&mut diagnostics, &error);
149            return 2;
150        }
151    };
152    if options.version {
153        if let Err(error) = write_version(output) {
154            write_diagnostic(
155                &mut diagnostics,
156                &format!("unable to write version: {error}"),
157            );
158            return 1;
159        }
160        return 0;
161    }
162    if let Some(command) = options.command {
163        return run_codex_command(command, home, output, &mut diagnostics);
164    }
165    let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
166        Ok(mode) => mode,
167        Err(error) => {
168            write_diagnostic(&mut diagnostics, &error);
169            return 2;
170        }
171    };
172
173    if options.list_sessions {
174        let mut protocol = ProtocolWriter::new(output);
175        if let Err(error) = Config::ensure_exists(home) {
176            write_diagnostic(&mut diagnostics, &error.to_string());
177            return 1;
178        }
179        let codex_secret = Config::load_or_create(home)
180            .ok()
181            .and_then(|config| config.resolved_auth().ok())
182            .and_then(|auth| configured_codex_secret(home, auth.provider));
183        return match Session::list_with_secret(home, codex_secret.as_deref()) {
184            Ok(sessions) => {
185                for session in sessions {
186                    if let Err(error) = protocol.emit_serializable(&session) {
187                        write_diagnostic(
188                            &mut diagnostics,
189                            &format!("unable to write session metadata: {error}"),
190                        );
191                        return 1;
192                    }
193                }
194                0
195            }
196            Err(error) => {
197                write_diagnostic(&mut diagnostics, &error.to_string());
198                1
199            }
200        };
201    }
202
203    let (session, provider, resumed, attached_agents) = if let Some(id) = options.session.as_deref()
204    {
205        let mut session = match Session::resume(home, id) {
206            Ok(session) => session,
207            Err(error) => {
208                write_diagnostic(&mut diagnostics, &error.to_string());
209                return 1;
210            }
211        };
212        let config = match Config::load_or_create(home) {
213            Ok(config) => config,
214            Err(error) => {
215                write_diagnostic(&mut diagnostics, &error.to_string());
216                return 1;
217            }
218        };
219        let auth = match config.resolved_auth() {
220            Ok(auth) => auth,
221            Err(error) => {
222                write_diagnostic(&mut diagnostics, &error.to_string());
223                return 1;
224            }
225        };
226        if let Some(secret) = configured_codex_secret(home, auth.provider) {
227            session = match Session::resume_with_secret(home, id, Some(&secret)) {
228                Ok(session) => session,
229                Err(error) => {
230                    write_diagnostic_safe(&mut diagnostics, &error.to_string(), Some(&secret));
231                    return 1;
232                }
233            };
234        }
235        let mut selected = match config.resolved_llm() {
236            Ok(settings) => settings,
237            Err(error) => {
238                write_diagnostic_safe(
239                    &mut diagnostics,
240                    &error.to_string(),
241                    configured_api_key(&config).as_deref(),
242                );
243                return 1;
244            }
245        };
246        apply_auth_to_settings(&mut selected, auth.provider);
247        session.llm.model = selected.model;
248        session.llm.effort = selected.effort;
249        session.llm.api_key_env = selected.api_key_env;
250        let provider = match provider_for_settings(home, &session.llm) {
251            Ok(provider) => provider,
252            Err(error) => {
253                write_diagnostic(&mut diagnostics, &error.to_string());
254                return 1;
255            }
256        };
257        if let Err(error) =
258            session.append_provider_settings(session.llm.model.clone(), session.llm.effort.clone())
259        {
260            write_diagnostic_safe(
261                &mut diagnostics,
262                &error.to_string(),
263                Some(&provider.api_key()),
264            );
265            return 1;
266        }
267        if mode == FrontendMode::Tui && conflicts_with_tui_literal(&provider.api_key()) {
268            write_diagnostic_safe(
269                &mut diagnostics,
270                "API key conflicts with terminal UI literals",
271                Some(&provider.api_key()),
272            );
273            return 1;
274        }
275        (session, provider, true, Vec::new())
276    } else {
277        let config = match Config::load_or_create(home) {
278            Ok(config) => config,
279            Err(error) => {
280                write_diagnostic(&mut diagnostics, &error.to_string());
281                return 1;
282            }
283        };
284        let auth = match config.resolved_auth() {
285            Ok(auth) => auth,
286            Err(error) => {
287                write_diagnostic(&mut diagnostics, &error.to_string());
288                return 1;
289            }
290        };
291        let configured_secret = configured_api_key(&config);
292        let api_key_env = auth.api_key_env.clone();
293        let mut llm = match config.resolved_llm() {
294            Ok(llm) => llm,
295            Err(error) => {
296                write_diagnostic_safe(
297                    &mut diagnostics,
298                    &error.to_string(),
299                    configured_secret.as_deref(),
300                );
301                return 1;
302            }
303        };
304        apply_auth_to_settings(&mut llm, auth.provider);
305        let provider = match provider_for_settings(home, &llm) {
306            Ok(provider) => provider,
307            Err(error) => {
308                write_diagnostic_safe(
309                    &mut diagnostics,
310                    &error.to_string(),
311                    configured_secret.as_deref(),
312                );
313                return 1;
314            }
315        };
316        if mode == FrontendMode::Tui && conflicts_with_tui_literal(&provider.api_key()) {
317            write_diagnostic_safe(
318                &mut diagnostics,
319                "API key conflicts with terminal UI literals",
320                Some(&provider.api_key()),
321            );
322            return 1;
323        }
324        let safe_cwd = match std::fs::canonicalize(cwd) {
325            Ok(cwd) if !cwd.display().to_string().contains(&provider.api_key()) => cwd,
326            Ok(_) => {
327                write_diagnostic_safe(
328                    &mut diagnostics,
329                    "session header rejected",
330                    Some(&provider.api_key()),
331                );
332                return 1;
333            }
334            Err(_) => {
335                write_diagnostic_safe(
336                    &mut diagnostics,
337                    "unable to resolve session cwd",
338                    Some(&provider.api_key()),
339                );
340                return 1;
341            }
342        };
343        let context = match resolve_boot_context_with_api_key_env(
344            home,
345            &safe_cwd,
346            &config.system_prompt,
347            api_key_env.as_deref(),
348        ) {
349            Ok(context) => context,
350            Err(error) => {
351                write_diagnostic_safe(
352                    &mut diagnostics,
353                    &error.to_string(),
354                    configured_secret.as_deref(),
355                );
356                return 1;
357            }
358        };
359        let boot_system_prompt = redact_secret(&context.system_prompt, Some(&provider.api_key()));
360        let attached_agents = attached_agents(context.instruction_files, &provider.api_key());
361        let skills = redact_skills(context.skills, &provider.api_key());
362        let session = match Session::create_with_skills_and_secret(
363            home,
364            &safe_cwd,
365            boot_system_prompt,
366            llm,
367            skills,
368            Some(&provider.api_key()),
369        ) {
370            Ok(session) => session,
371            Err(error) => {
372                write_diagnostic_safe(
373                    &mut diagnostics,
374                    &error.to_string(),
375                    Some(&provider.api_key()),
376                );
377                return 1;
378            }
379        };
380        (session, provider, false, attached_agents)
381    };
382
383    let harness = Harness {
384        home: home.to_path_buf(),
385        session,
386        provider,
387        context_window: None,
388        attached_agents,
389    };
390    if mode == FrontendMode::Tui {
391        return match crate::tui::run(harness, resumed, output) {
392            Ok(()) => 0,
393            Err(error) => {
394                write_diagnostic(&mut diagnostics, &error);
395                1
396            }
397        };
398    }
399
400    let mut protocol = ProtocolWriter::new(output);
401    let mut harness = harness;
402    if let Err(error) = protocol.session(&harness.session.id, resumed) {
403        write_diagnostic_safe(
404            &mut diagnostics,
405            &format!("unable to write session event: {error}"),
406            Some(harness.provider.api_key().as_str()),
407        );
408        return 1;
409    }
410
411    let (input_tx, input_rx) = mpsc::channel();
412    std::thread::spawn(move || {
413        for line in input.lines() {
414            if input_tx.send(line).is_err() {
415                break;
416            }
417        }
418    });
419    let mut input_closed = false;
420    loop {
421        if input_closed {
422            break;
423        }
424        let line = match input_rx.recv_timeout(std::time::Duration::from_millis(25)) {
425            Ok(Ok(line)) => line,
426            Ok(Err(error)) => {
427                write_diagnostic_safe(
428                    &mut diagnostics,
429                    &format!("unable to read stdin: {error}"),
430                    Some(harness.provider.api_key().as_str()),
431                );
432                return 1;
433            }
434            Err(mpsc::RecvTimeoutError::Timeout) => continue,
435            Err(mpsc::RecvTimeoutError::Disconnected) => {
436                input_closed = true;
437                continue;
438            }
439        };
440        if line.trim().is_empty() {
441            continue;
442        }
443        let text = match parse_input_message(&line) {
444            Ok(text) => text,
445            Err(error) => {
446                let error = redact_secret(&error, Some(harness.provider.api_key().as_str()));
447                if let Err(write_error) = protocol.error(&error) {
448                    write_diagnostic_safe(
449                        &mut diagnostics,
450                        &format!("unable to write protocol error: {write_error}"),
451                        Some(harness.provider.api_key().as_str()),
452                    );
453                    return 1;
454                }
455                continue;
456            }
457        };
458        if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
459            let error = redact_secret(&error, Some(harness.provider.api_key().as_str()));
460            if let Err(write_error) = protocol.error(&error) {
461                write_diagnostic_safe(
462                    &mut diagnostics,
463                    &format!("unable to write protocol error: {write_error}"),
464                    Some(harness.provider.api_key().as_str()),
465                );
466                return 1;
467            }
468        }
469    }
470    0
471}
472
473pub fn resolve_mode(
474    args: &[String],
475    stdin_is_tty: bool,
476    stdout_is_tty: bool,
477) -> Result<FrontendMode, String> {
478    let options = parse_args(args)?;
479    if options.list_sessions {
480        if options.tui {
481            return Err("--tui cannot be combined with --list-sessions".to_owned());
482        }
483        return Ok(FrontendMode::Jsonl);
484    }
485    if options.tui && !(stdin_is_tty && stdout_is_tty) {
486        return Err("--tui requires a terminal on stdin and stdout".to_owned());
487    }
488    if options.tui {
489        Ok(FrontendMode::Tui)
490    } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
491        Ok(FrontendMode::Jsonl)
492    } else {
493        Ok(FrontendMode::Tui)
494    }
495}
496
497pub(crate) struct Harness {
498    pub(crate) home: PathBuf,
499    pub(crate) session: Session,
500    pub(crate) provider: Provider,
501    /// Model context metadata resolved by the interactive frontend; `None`
502    /// keeps compaction disabled when an OpenAI-compatible provider exposes no
503    /// context-window metadata.
504    pub(crate) context_window: Option<usize>,
505    /// AGENTS.md sources selected for this newly created session's boot context.
506    /// The TUI uses these only while its first-boot welcome is visible.
507    pub(crate) attached_agents: Vec<String>,
508}
509
510fn should_compact_context(context_tokens: usize, context_window: usize) -> bool {
511    context_window > 0
512        && context_tokens as u128 * 100
513            >= context_window as u128 * AUTO_COMPACTION_THRESHOLD_PERCENT as u128
514}
515
516fn find_compaction_boundary(
517    messages: &[ChatMessage],
518    previous_boundary: Option<usize>,
519) -> Option<usize> {
520    let user_starts = messages
521        .iter()
522        .enumerate()
523        .filter_map(|(index, message)| (message.role == "user").then_some(index))
524        .collect::<Vec<_>>();
525    let mut start = *user_starts.last()?;
526    let end = messages.len();
527    let mut kept_tokens = messages[start..end]
528        .iter()
529        .map(estimate_message_tokens)
530        .sum::<usize>();
531
532    while kept_tokens < COMPACTION_KEEP_RECENT_TOKENS {
533        let Some(previous_start) = user_starts
534            .iter()
535            .copied()
536            .rev()
537            .find(|candidate| *candidate < start)
538        else {
539            break;
540        };
541        start = previous_start;
542        kept_tokens = messages[start..end]
543            .iter()
544            .map(estimate_message_tokens)
545            .sum::<usize>();
546    }
547
548    (start > 0 && previous_boundary.is_none_or(|previous| start > previous)).then_some(start)
549}
550
551impl Harness {
552    pub(crate) fn apply_settings(
553        &mut self,
554        home: &Path,
555        model: String,
556        effort: Option<String>,
557    ) -> Result<(), String> {
558        let config = Config::load_or_create(home).map_err(|error| error.to_string())?;
559        let mut settings = config.resolved_llm().map_err(|error| error.to_string())?;
560        settings.model = model.trim().to_owned();
561        settings.effort = effort
562            .map(|value| value.trim().to_owned())
563            .filter(|value| !value.is_empty());
564        // Endpoint and credential remain the session's established provider boundary.
565        settings.base_url = self.session.llm.base_url.clone();
566        settings.api_key_env = self.session.llm.api_key_env.clone();
567        apply_auth_to_settings(&mut settings, auth_provider_for_settings(&self.session.llm));
568        let provider = provider_for_settings(home, &settings).map_err(|error| error.to_string())?;
569        // Validate the candidate before changing the user-owned source of truth.
570        Config::save_selection(home, &settings.model, settings.effort.as_deref())
571            .map_err(|error| error.to_string())?;
572        self.session
573            .append_provider_settings(settings.model.clone(), settings.effort.clone())
574            .map_err(|error| error.to_string())?;
575        self.session.llm = settings;
576        self.provider = provider;
577        self.context_window = self.provider.context_window();
578        Ok(())
579    }
580
581    fn should_compact(&self, messages: &[ChatMessage]) -> bool {
582        self.context_window
583            .is_some_and(|window| should_compact_context(estimate_context_tokens(messages), window))
584    }
585
586    fn compaction_boundary(&self) -> Option<usize> {
587        let latest_boundary = self
588            .session
589            .history
590            .iter()
591            .rev()
592            .find_map(|record| match record {
593                crate::session::SessionHistoryRecord::Compaction(compaction) => {
594                    Some(compaction.first_kept_message)
595                }
596                _ => None,
597            });
598        find_compaction_boundary(&self.session.messages, latest_boundary)
599    }
600
601    fn compact_context<S: EventSink>(
602        &mut self,
603        sink: &mut S,
604        cancellation: Option<&crate::cancellation::CancellationToken>,
605        tokens_before: usize,
606    ) -> Result<(), String> {
607        let Some(boundary) = self.compaction_boundary() else {
608            return Err("context cannot be compacted without an earlier complete turn".to_owned());
609        };
610        let Some(cancellation) = cancellation else {
611            return Err("context compaction requires a cancellable turn".to_owned());
612        };
613        sink.compaction_started()
614            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
615        let context_messages = self.session.provider_messages();
616        let mut summary_messages = Vec::with_capacity(context_messages.len() + 1);
617        summary_messages.push(ChatMessage::system(self.session.boot_system_prompt.clone()));
618        summary_messages.push(ChatMessage::system(COMPACTION_SYSTEM_PROMPT.to_owned()));
619        summary_messages.extend(context_messages.into_iter().skip(1));
620        let summary = match self.provider.summarize(&summary_messages, cancellation) {
621            Ok(summary) => redact_secret(&summary, Some(self.provider.api_key().as_str())),
622            Err(error) if cancellation.is_cancelled() || error.is_cancelled() => {
623                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
624            }
625            Err(error) => return Err(format!("unable to compact context: {error}")),
626        };
627        self.session
628            .append_compaction(summary, boundary, tokens_before)
629            .map_err(|error| format!("unable to persist context compaction: {error}"))?;
630        let tokens_after = estimate_context_tokens(&self.session.provider_messages());
631        sink.compaction_finished(tokens_before, tokens_after)
632            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
633        Ok(())
634    }
635
636    pub(crate) fn handle_message<S: EventSink>(
637        &mut self,
638        text: &str,
639        sink: &mut S,
640        cancellation: Option<&crate::cancellation::CancellationToken>,
641    ) -> Result<(), String> {
642        if cancellation.is_some_and(CancellationToken::is_cancelled) {
643            return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
644        }
645        let secret = self.provider.api_key();
646        let expanded = expand_skill_invocation(text, &self.session.skills)?;
647        let user_message = ChatMessage::user(redact_secret(&expanded.text, Some(&secret)));
648        if let Err(error) = self.session.append_message(user_message) {
649            if cancellation.is_some_and(|token| token.is_cancelled()) {
650                let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
651                return interruption
652                    .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
653            }
654            return Err(error.to_string());
655        }
656        if let Some(name) = expanded.attached_skill.as_deref() {
657            sink.skill_instruction_attached(name)
658                .map_err(|error| format!("unable to emit skill attachment state: {error}"))?;
659        }
660
661        let mut compacted_for_turn = false;
662        loop {
663            if cancellation.is_some_and(CancellationToken::is_cancelled) {
664                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
665            }
666            let mut messages = self.session.provider_messages();
667            let tokens_before = estimate_context_tokens(&messages);
668            if !compacted_for_turn && self.should_compact(&messages) {
669                self.compact_context(sink, cancellation, tokens_before)?;
670                compacted_for_turn = true;
671                messages = self.session.provider_messages();
672            }
673            sink.context_usage(estimate_context_tokens(&messages))
674                .map_err(|error| format!("unable to emit context usage: {error}"))?;
675            let mut raw_content = String::new();
676            let mut redactor = SecretRedactor::new(&secret);
677            let mut reasoning_active = false;
678            let stream_result = {
679                let mut on_event = |event: ProviderStreamEvent| -> io::Result<()> {
680                    match event {
681                        ProviderStreamEvent::ReasoningStarted => {
682                            if !reasoning_active {
683                                reasoning_active = true;
684                                sink.reasoning_started()?;
685                            }
686                            Ok(())
687                        }
688                        ProviderStreamEvent::Text(delta) => {
689                            if reasoning_active {
690                                reasoning_active = false;
691                                sink.reasoning_completed()?;
692                            }
693                            raw_content.push_str(&delta);
694                            redactor.push(&delta, |safe_delta| {
695                                sink.emit_event(&ProtocolEvent::AssistantDelta {
696                                    text: safe_delta.to_owned(),
697                                })
698                            })
699                        }
700                    }
701                };
702                match cancellation {
703                    Some(token) => self
704                        .provider
705                        .stream_chat_cancellable_with_options_and_events(
706                            &messages,
707                            &mut on_event,
708                            token,
709                            true,
710                        ),
711                    None => self.provider.stream_chat(&messages, &mut |delta| {
712                        raw_content.push_str(delta);
713                        redactor.push(delta, |safe_delta| {
714                            sink.emit_event(&ProtocolEvent::AssistantDelta {
715                                text: safe_delta.to_owned(),
716                            })
717                        })
718                    }),
719                }
720            };
721            redactor
722                .finish(|safe_delta| {
723                    sink.emit_event(&ProtocolEvent::AssistantDelta {
724                        text: safe_delta.to_owned(),
725                    })
726                })
727                .map_err(|error| format!("unable to write assistant delta: {error}"))?;
728            let turn = match stream_result {
729                Ok(turn) => {
730                    if reasoning_active {
731                        sink.reasoning_completed()
732                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
733                    }
734                    turn
735                }
736                Err(error)
737                    if cancellation.is_some_and(|token| token.is_cancelled())
738                        || error.is_cancelled() =>
739                {
740                    if reasoning_active {
741                        sink.reasoning_completed()
742                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
743                    }
744                    let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
745                        content: raw_content,
746                        tool_calls: Vec::new(),
747                        reasoning_details: Vec::new(),
748                    });
749                    return self.interrupt(
750                        sink,
751                        PROVIDER_PHASE,
752                        &partial.content,
753                        &partial.tool_calls,
754                        Vec::new(),
755                    );
756                }
757                Err(error) => {
758                    if reasoning_active {
759                        sink.reasoning_completed()
760                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
761                    }
762                    return Err(error.to_string());
763                }
764            };
765            let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
766
767            if turn
768                .tool_calls
769                .iter()
770                .any(|call| !matches!(call.name.as_str(), "cmd"))
771            {
772                if canceled_after_stream {
773                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
774                }
775                return Err("provider requested an unsupported tool".to_owned());
776            }
777            let safe_tool_calls = turn
778                .tool_calls
779                .iter()
780                .map(|call| safe_tool_call(call, &secret))
781                .collect::<Vec<_>>();
782            let assistant_content = redact_secret(&turn.content, Some(&secret));
783            let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
784            let mut assistant =
785                ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
786            assistant.reasoning_details = safe_reasoning_details;
787            if let Err(error) = self.session.append_message(assistant) {
788                if cancellation.is_some_and(|token| token.is_cancelled()) {
789                    let interruption = self.interrupt(
790                        sink,
791                        PROVIDER_PHASE,
792                        &assistant_content,
793                        &turn.tool_calls,
794                        Vec::new(),
795                    );
796                    return interruption
797                        .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
798                }
799                return Err(error.to_string());
800            }
801
802            if safe_tool_calls.is_empty() {
803                if canceled_after_stream
804                    || cancellation.is_some_and(CancellationToken::is_cancelled)
805                {
806                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
807                }
808                if cancellation.is_some_and(|token| !token.try_complete()) {
809                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
810                }
811                sink.context_usage(estimate_context_tokens(&self.session.provider_messages()))
812                    .map_err(|error| format!("unable to emit context usage: {error}"))?;
813                sink.emit_event(&ProtocolEvent::TurnEnd)
814                    .map_err(|error| format!("unable to write turn end: {error}"))?;
815                return Ok(());
816            }
817
818            for safe_call in &safe_tool_calls {
819                sink.emit_event(&ProtocolEvent::ToolCall {
820                    id: safe_call.id.clone(),
821                    name: safe_call.name.clone(),
822                    arguments: safe_call.arguments.clone(),
823                })
824                .map_err(|error| format!("unable to write tool call: {error}"))?;
825            }
826            for (index, raw_call) in turn.tool_calls.iter().enumerate() {
827                let safe_call = &safe_tool_calls[index];
828                let result = if cancellation.is_some_and(|token| token.is_cancelled()) {
829                    serde_json::to_value(crate::command::canceled_result(
830                        &safe_call.arguments,
831                        &secret,
832                    ))
833                    .map_err(|error| format!("unable to encode cmd result: {error}"))?
834                } else {
835                    serde_json::to_value(crate::command::execute_with_cancellation(
836                        &raw_call.arguments,
837                        &self.session.cwd,
838                        self.provider.api_key_env(),
839                        Some(&secret),
840                        cancellation,
841                    ))
842                    .map_err(|error| format!("unable to encode cmd result: {error}"))?
843                };
844                let result = redact_json_value(result, &secret);
845                let tool_content = serde_json::to_string(&result)
846                    .map_err(|error| format!("unable to encode tool result: {error}"))?;
847                let tool_message = ChatMessage::tool(
848                    safe_call.id.clone(),
849                    safe_call.name.clone(),
850                    redact_secret(&tool_content, Some(&secret)),
851                );
852                let observation = crate::session::SessionToolResult {
853                    id: safe_call.id.clone(),
854                    name: safe_call.name.clone(),
855                    result: result.clone(),
856                };
857                if let Err(error) = self.session.append_message(tool_message) {
858                    if cancellation.is_some_and(|token| token.is_cancelled()) {
859                        let interruption =
860                            self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
861                        return interruption
862                            .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
863                    }
864                    return Err(error.to_string());
865                }
866                sink.emit_event(&ProtocolEvent::ToolResult {
867                    id: safe_call.id.clone(),
868                    name: safe_call.name.clone(),
869                    result: result.clone(),
870                })
871                .map_err(|error| format!("unable to write tool result: {error}"))?;
872                if cancellation.is_some_and(|token| token.is_cancelled()) {
873                    for pending_call in safe_tool_calls.iter().skip(index + 1) {
874                        let pending_result = redact_json_value(
875                            serde_json::to_value(crate::command::canceled_result(
876                                &pending_call.arguments,
877                                &secret,
878                            ))
879                            .map_err(|error| format!("unable to encode cmd result: {error}"))?,
880                            &secret,
881                        );
882                        let pending_content = serde_json::to_string(&pending_result)
883                            .map_err(|error| format!("unable to encode tool result: {error}"))?;
884                        let pending_message = ChatMessage::tool(
885                            pending_call.id.clone(),
886                            pending_call.name.clone(),
887                            redact_secret(&pending_content, Some(&secret)),
888                        );
889                        let pending_observation = crate::session::SessionToolResult {
890                            id: pending_call.id.clone(),
891                            name: pending_call.name.clone(),
892                            result: pending_result.clone(),
893                        };
894                        if let Err(error) = self.session.append_message(pending_message) {
895                            if cancellation.is_some_and(|token| token.is_cancelled()) {
896                                let interruption = self.interrupt(
897                                    sink,
898                                    COMMAND_PHASE,
899                                    "",
900                                    &[],
901                                    vec![pending_observation],
902                                );
903                                return interruption.map_err(|interrupt_error| {
904                                    format!("{error}; {interrupt_error}")
905                                });
906                            }
907                            return Err(error.to_string());
908                        }
909                        sink.emit_event(&ProtocolEvent::ToolResult {
910                            id: pending_call.id.clone(),
911                            name: pending_call.name.clone(),
912                            result: pending_result.clone(),
913                        })
914                        .map_err(|error| format!("unable to write tool result: {error}"))?;
915                    }
916                    return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
917                }
918            }
919            if cancellation.is_some_and(CancellationToken::is_cancelled) {
920                return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
921            }
922        }
923    }
924
925    fn interrupt<S: EventSink>(
926        &mut self,
927        sink: &mut S,
928        phase: &str,
929        assistant_text: &str,
930        tool_calls: &[ChatToolCall],
931        tool_results: Vec<crate::session::SessionToolResult>,
932    ) -> Result<(), String> {
933        let secret = self.provider.api_key();
934        let safe_tool_calls = tool_calls
935            .iter()
936            .filter(|call| call.name == "cmd")
937            .map(|call| safe_partial_tool_call(call, &secret))
938            .collect::<Vec<_>>();
939        let safe_tool_results = tool_results.clone();
940        let interruption = crate::session::InterruptionRecord {
941            timestamp: 0,
942            reason: USER_CANCEL_REASON.to_owned(),
943            phase: phase.to_owned(),
944            assistant_text: redact_secret(assistant_text, Some(&secret)),
945            tool_calls: safe_tool_calls.clone(),
946            tool_results,
947        };
948        let persistence_error = self.session.append_interruption(interruption).err();
949        let mut event_error = None;
950        for call in &safe_tool_calls {
951            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
952                id: call.id.clone(),
953                name: call.name.clone(),
954                arguments: call.arguments.clone(),
955            }) {
956                event_error.get_or_insert(error);
957            }
958        }
959        for observation in &safe_tool_results {
960            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
961                id: observation.id.clone(),
962                name: observation.name.clone(),
963                result: observation.result.clone(),
964            }) {
965                event_error.get_or_insert(error);
966            }
967        }
968        if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
969            reason: USER_CANCEL_REASON.to_owned(),
970            phase: phase.to_owned(),
971        }) {
972            event_error.get_or_insert(error);
973        }
974        match (persistence_error, event_error) {
975            (None, None) => Ok(()),
976            (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
977            (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
978            (Some(persistence), Some(event)) => Err(format!(
979                "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
980            )),
981        }
982    }
983}
984
985struct SecretRedactor {
986    secret_text: String,
987    secret: Vec<char>,
988    marker: String,
989    pending: String,
990}
991
992impl SecretRedactor {
993    fn new(secret: &str) -> Self {
994        Self {
995            secret_text: secret.to_owned(),
996            secret: secret.chars().collect(),
997            marker: redaction_marker(secret).unwrap_or_default(),
998            pending: String::new(),
999        }
1000    }
1001
1002    fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
1003    where
1004        F: FnMut(&str) -> io::Result<()>,
1005    {
1006        if self.secret.is_empty() {
1007            return emit(text);
1008        }
1009
1010        let mut output = String::new();
1011        for character in text.chars() {
1012            self.pending.push(character);
1013            if self.pending.chars().eq(self.secret.iter().copied()) {
1014                self.pending.clear();
1015                output.push_str(&self.marker);
1016                continue;
1017            }
1018            if self.pending_is_secret_prefix() {
1019                continue;
1020            }
1021
1022            let pending = self.pending.chars().collect::<Vec<_>>();
1023            let suffix_len = (1..pending.len())
1024                .rev()
1025                .find(|length| {
1026                    pending[pending.len() - length..].iter().copied().eq(self
1027                        .secret
1028                        .iter()
1029                        .copied()
1030                        .take(*length))
1031                })
1032                .unwrap_or(0);
1033            let safe_len = pending.len() - suffix_len;
1034            output.extend(pending[..safe_len].iter());
1035            self.pending = pending[safe_len..].iter().collect();
1036        }
1037
1038        if output.is_empty() {
1039            Ok(())
1040        } else {
1041            let safe_output = redact_secret(&output, Some(&self.secret_text));
1042            emit(&safe_output)
1043        }
1044    }
1045
1046    fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
1047    where
1048        F: FnMut(&str) -> io::Result<()>,
1049    {
1050        let pending = std::mem::take(&mut self.pending);
1051        if pending.is_empty() {
1052            return Ok(());
1053        }
1054        let safe_pending = redact_secret(&pending, Some(&self.secret_text));
1055        emit(&safe_pending)
1056    }
1057
1058    fn pending_is_secret_prefix(&self) -> bool {
1059        let length = self.pending.chars().count();
1060        length < self.secret.len()
1061            && self
1062                .pending
1063                .chars()
1064                .zip(self.secret.iter().copied())
1065                .all(|(pending, secret)| pending == secret)
1066    }
1067}
1068
1069/// Return the AGENTS.md files selected for the current new-session boot context.
1070/// Paths are secret-redacted before they can reach the terminal UI.
1071fn attached_agents(instruction_files: Vec<InstructionSource>, secret: &str) -> Vec<String> {
1072    instruction_files
1073        .into_iter()
1074        .filter(|source| {
1075            source
1076                .path
1077                .file_name()
1078                .is_some_and(|name| name == "AGENTS.md")
1079        })
1080        .map(|source| redact_secret(&source.path.display().to_string(), Some(secret)))
1081        .collect()
1082}
1083
1084/// Store a secret-safe skill snapshot with the session. The source is read
1085/// once during secure context discovery; later invocations never follow paths.
1086fn escape_xml_attribute(text: &str) -> String {
1087    text.replace('&', "&amp;")
1088        .replace('<', "&lt;")
1089        .replace('>', "&gt;")
1090        .replace('\"', "&quot;")
1091        .replace('\'', "&apos;")
1092}
1093
1094fn redact_skills(skills: Vec<SkillEntry>, secret: &str) -> Vec<SkillEntry> {
1095    skills
1096        .into_iter()
1097        .map(|skill| SkillEntry {
1098            name: redact_secret(&skill.name, Some(secret)),
1099            description: redact_secret(&skill.description, Some(secret)),
1100            path: std::path::PathBuf::from(redact_secret(
1101                &skill.path.display().to_string(),
1102                Some(secret),
1103            )),
1104            contents: redact_secret(&skill.contents, Some(secret)),
1105            model_invocable: skill.model_invocable,
1106        })
1107        .collect()
1108}
1109
1110/// The message delivered to the provider and the optional name of the saved
1111/// skill snapshot that was attached to it.
1112#[derive(Debug)]
1113struct ExpandedSkillInvocation {
1114    text: String,
1115    attached_skill: Option<String>,
1116}
1117
1118/// Expand slash-prefixed skill names into the user message sent to the
1119/// provider. This deliberately adds no model-facing tool: skills are context,
1120/// not an executable capability of their own.
1121fn expand_skill_invocation(
1122    text: &str,
1123    skills: &[SkillEntry],
1124) -> Result<ExpandedSkillInvocation, String> {
1125    let Some(invocation) = text.strip_prefix('/') else {
1126        return Ok(ExpandedSkillInvocation {
1127            text: text.to_owned(),
1128            attached_skill: None,
1129        });
1130    };
1131    let mut pieces = invocation.splitn(2, char::is_whitespace);
1132    let name = pieces.next().unwrap_or_default();
1133    if name.is_empty() {
1134        return Err("skill command requires a skill name: /<name> [args]".to_owned());
1135    }
1136    let Some(skill) = skills.iter().find(|skill| skill.name == name) else {
1137        return Err(format!("unknown skill: {name}"));
1138    };
1139    let arguments = pieces.next().unwrap_or_default().trim();
1140    let mut message = format!(
1141        "<skill name=\"{}\" location=\"{}\">\n{}\n</skill>",
1142        escape_xml_attribute(&skill.name),
1143        escape_xml_attribute(&skill.path.display().to_string()),
1144        skill.contents.trim()
1145    );
1146    if !arguments.is_empty() {
1147        message.push_str("\n\nUser: ");
1148        message.push_str(arguments);
1149    }
1150    Ok(ExpandedSkillInvocation {
1151        text: message,
1152        attached_skill: Some(skill.name.clone()),
1153    })
1154}
1155
1156#[cfg(test)]
1157fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
1158    safe_tool_call(
1159        &ChatToolCall {
1160            id: String::new(),
1161            name: "cmd".to_owned(),
1162            arguments: arguments.to_owned(),
1163        },
1164        secret,
1165    )
1166    .arguments
1167}
1168
1169fn safe_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1170    let valid = match call.name.as_str() {
1171        "cmd" => serde_json::from_str::<Value>(&call.arguments)
1172            .ok()
1173            .and_then(|value| value.as_object().cloned())
1174            .is_some_and(|object| {
1175                object.len() == 1 && object.get("command").is_some_and(Value::is_string)
1176            }),
1177        _ => false,
1178    };
1179    let arguments = if valid {
1180        serde_json::to_string(&redact_json_value(
1181            serde_json::from_str(&call.arguments).unwrap_or(Value::Null),
1182            secret,
1183        ))
1184        .unwrap_or_else(|_| "{}".to_owned())
1185    } else {
1186        "{}".to_owned()
1187    };
1188    ChatToolCall {
1189        id: redact_secret(&call.id, Some(secret)),
1190        name: redact_secret(&call.name, Some(secret)),
1191        arguments,
1192    }
1193}
1194
1195fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1196    let arguments = if serde_json::from_str::<Value>(&call.arguments)
1197        .ok()
1198        .and_then(|value| value.as_object().cloned())
1199        .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
1200    {
1201        safe_tool_call(call, secret).arguments
1202    } else {
1203        // An incomplete argument fragment is an observation only. Do not
1204        // preserve malformed provider JSON: decoding it later could expose a
1205        // credential that was hidden by the outer JSON string.
1206        "{}".to_owned()
1207    };
1208    ChatToolCall {
1209        id: redact_secret(&call.id, Some(secret)),
1210        name: redact_secret(&call.name, Some(secret)),
1211        arguments,
1212    }
1213}
1214
1215fn redact_json_value(value: Value, secret: &str) -> Value {
1216    match value {
1217        Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
1218        Value::Array(values) => Value::Array(
1219            values
1220                .into_iter()
1221                .map(|value| redact_json_value(value, secret))
1222                .collect(),
1223        ),
1224        Value::Object(object) => {
1225            let marker = redaction_marker(secret).unwrap_or_default();
1226            let mut redacted = Map::new();
1227            for (key, value) in object {
1228                let mut safe_key = if is_structural_key(&key) {
1229                    key
1230                } else {
1231                    redact_secret(&key, Some(secret))
1232                };
1233                if redacted.contains_key(&safe_key) {
1234                    if marker.is_empty() {
1235                        continue;
1236                    }
1237                    while redacted.contains_key(&safe_key) {
1238                        safe_key.push_str(&marker);
1239                    }
1240                }
1241                redacted.insert(safe_key, redact_json_value(value, secret));
1242            }
1243            Value::Object(redacted)
1244        }
1245        value => value,
1246    }
1247}
1248
1249fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
1250    if details.is_empty() {
1251        return None;
1252    }
1253    match redact_json_value(Value::Array(details.to_vec()), secret) {
1254        Value::Array(details) => Some(details),
1255        _ => None,
1256    }
1257}
1258
1259fn write_version<W: Write>(mut output: W) -> io::Result<()> {
1260    writeln!(output, "lucy {}", env!("CARGO_PKG_VERSION"))
1261}
1262
1263fn parse_args(args: &[String]) -> Result<CliOptions, String> {
1264    let mut options = CliOptions {
1265        session: None,
1266        list_sessions: false,
1267        jsonl: false,
1268        tui: false,
1269        version: false,
1270        command: None,
1271    };
1272    if args.len() == 2 && args[0] == "codex" {
1273        options.command = Some(match args[1].as_str() {
1274            "login" => CliCommand::CodexLogin,
1275            "logout" => CliCommand::CodexLogout,
1276            _ => return Err("usage: lucy codex <login|logout>".to_owned()),
1277        });
1278        return Ok(options);
1279    }
1280    if args.first().is_some_and(|arg| arg == "codex") {
1281        return Err("usage: lucy codex <login|logout>".to_owned());
1282    }
1283    let mut index = 0;
1284    while index < args.len() {
1285        match args[index].as_str() {
1286            "--session" => {
1287                if options.list_sessions || options.session.is_some() {
1288                    return Err("--session cannot be combined or repeated".to_owned());
1289                }
1290                index += 1;
1291                let Some(id) = args.get(index) else {
1292                    return Err("--session requires an id".to_owned());
1293                };
1294                options.session = Some(id.clone());
1295            }
1296            "--list-sessions" => {
1297                if options.session.is_some() || options.list_sessions {
1298                    return Err("--list-sessions cannot be combined or repeated".to_owned());
1299                }
1300                options.list_sessions = true;
1301            }
1302            "--jsonl" => {
1303                if options.jsonl || options.tui {
1304                    return Err("--jsonl cannot be combined or repeated".to_owned());
1305                }
1306                options.jsonl = true;
1307            }
1308            "--tui" => {
1309                if options.tui || options.jsonl {
1310                    return Err("--tui cannot be combined or repeated".to_owned());
1311                }
1312                options.tui = true;
1313            }
1314            "--version" => {
1315                if options.version {
1316                    return Err("--version cannot be repeated".to_owned());
1317                }
1318                options.version = true;
1319            }
1320            "--help" | "-h" => {
1321                return Err(
1322                    "usage: lucy [--version] [--jsonl|--tui] [--session <id>] [--list-sessions] | lucy codex <login|logout>"
1323                        .to_owned(),
1324                );
1325            }
1326            _ => return Err("unknown argument".to_owned()),
1327        }
1328        index += 1;
1329    }
1330    Ok(options)
1331}
1332
1333fn parse_input_message(line: &str) -> Result<String, String> {
1334    let record: InputRecord = serde_json::from_str(line)
1335        .map_err(|_| "input must be a JSONL message record".to_owned())?;
1336    if record.record_type != "message" {
1337        return Err("input record type must be message".to_owned());
1338    }
1339    record
1340        .text
1341        .ok_or_else(|| "message record requires a text string".to_owned())
1342}
1343
1344fn home_directory() -> Result<PathBuf, String> {
1345    std::env::var_os("HOME")
1346        .map(PathBuf::from)
1347        .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
1348}
1349
1350fn configured_api_key_env(config: &Config) -> Option<String> {
1351    config.resolved_auth().ok()?.api_key_env
1352}
1353
1354fn configured_api_key(config: &Config) -> Option<String> {
1355    configured_api_key_env(config)
1356        .and_then(|api_key_env| std::env::var(api_key_env).ok())
1357        .filter(|secret| !secret.is_empty())
1358}
1359
1360fn run_codex_command<W: Write, E: Write>(
1361    command: CliCommand,
1362    home: &Path,
1363    mut output: W,
1364    diagnostics: &mut E,
1365) -> i32 {
1366    match command {
1367        CliCommand::CodexLogin => match crate::auth::login(home) {
1368            Ok(_) => {
1369                let _ = writeln!(output, "Codex login successful");
1370                0
1371            }
1372            Err(error) => {
1373                write_diagnostic(diagnostics, &error.to_string());
1374                1
1375            }
1376        },
1377        CliCommand::CodexLogout => match crate::auth::AuthStore::for_home(home).logout() {
1378            Ok(true) => {
1379                let _ = writeln!(output, "Codex logout successful");
1380                0
1381            }
1382            Ok(false) => {
1383                let _ = writeln!(output, "Codex was not logged in");
1384                0
1385            }
1386            Err(error) => {
1387                write_diagnostic(diagnostics, &error.to_string());
1388                1
1389            }
1390        },
1391    }
1392}
1393
1394fn apply_auth_to_settings(settings: &mut LlmSettings, provider: AuthProvider) {
1395    if provider == AuthProvider::CodexSubscription {
1396        settings.api_key_env = crate::codex_provider::CODEX_ENV_SENTINEL.to_owned();
1397    }
1398}
1399
1400fn auth_provider_for_settings(settings: &LlmSettings) -> AuthProvider {
1401    if settings.api_key_env == crate::codex_provider::CODEX_ENV_SENTINEL {
1402        AuthProvider::CodexSubscription
1403    } else {
1404        AuthProvider::Openrouter
1405    }
1406}
1407
1408fn provider_for_settings(
1409    home: &Path,
1410    settings: &LlmSettings,
1411) -> Result<Provider, crate::provider::ProviderError> {
1412    match auth_provider_for_settings(settings) {
1413        AuthProvider::CodexSubscription => Provider::new_codex(home, settings),
1414        AuthProvider::Openrouter => Provider::new(settings),
1415    }
1416}
1417
1418fn configured_codex_secret(home: &Path, provider: AuthProvider) -> Option<String> {
1419    if provider != AuthProvider::CodexSubscription {
1420        return None;
1421    }
1422    crate::auth::AuthStore::for_home(home)
1423        .load()
1424        .ok()
1425        .flatten()
1426        .map(|credentials| credentials.access)
1427        .filter(|secret| !secret.is_empty())
1428}
1429
1430fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
1431    write_diagnostic_safe_with_environment(
1432        diagnostics,
1433        message,
1434        secret,
1435        std::env::vars().map(|(_, value)| value),
1436    );
1437}
1438
1439fn write_diagnostic_safe_with_environment<W, I>(
1440    diagnostics: &mut W,
1441    message: &str,
1442    secret: Option<&str>,
1443    environment_values: I,
1444) where
1445    W: Write,
1446    I: IntoIterator<Item = String>,
1447{
1448    let mut safe_line = format!("!: {message}");
1449    safe_line = redact_secret(&safe_line, secret);
1450    let mut environment_secrets = environment_values
1451        .into_iter()
1452        .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
1453        .collect::<Vec<_>>();
1454    environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
1455    for environment_secret in environment_secrets {
1456        safe_line = redact_secret(&safe_line, Some(&environment_secret));
1457    }
1458    let _ = writeln!(diagnostics, "{safe_line}");
1459}
1460
1461fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
1462    write_diagnostic_safe(diagnostics, message, None);
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468    use crate::cancellation::CancellationToken;
1469    use std::io::{Cursor, Read, Write};
1470    use std::net::TcpListener;
1471    use std::thread;
1472
1473    #[test]
1474    fn codex_subcommands_parse_without_entering_a_session() {
1475        assert_eq!(
1476            parse_args(&["codex".to_owned(), "login".to_owned()])
1477                .expect("codex login")
1478                .command,
1479            Some(CliCommand::CodexLogin)
1480        );
1481        assert_eq!(
1482            parse_args(&["codex".to_owned(), "logout".to_owned()])
1483                .expect("codex logout")
1484                .command,
1485            Some(CliCommand::CodexLogout)
1486        );
1487        assert_eq!(
1488            parse_args(&["codex".to_owned(), "status".to_owned()])
1489                .expect_err("unknown codex command"),
1490            "usage: lucy codex <login|logout>"
1491        );
1492    }
1493
1494    #[test]
1495    fn codex_logout_is_idempotent_and_does_not_bootstrap_a_session() {
1496        let home = std::env::temp_dir().join(format!("lucy-codex-logout-{}", std::process::id()));
1497        let _ = std::fs::remove_dir_all(&home);
1498        let cwd = std::env::current_dir().expect("cwd");
1499        let mut output = Vec::new();
1500        let mut diagnostics = Vec::new();
1501        let exit = run_cli_at_home(
1502            &["codex".to_owned(), "logout".to_owned()],
1503            Cursor::new(Vec::<u8>::new()),
1504            &mut output,
1505            &mut diagnostics,
1506            &home,
1507            &cwd,
1508        );
1509        assert_eq!(exit, 0);
1510        assert!(String::from_utf8_lossy(&output).contains("not logged in"));
1511        assert!(diagnostics.is_empty());
1512        assert!(!home.exists());
1513    }
1514
1515    #[test]
1516    fn auto_compaction_triggers_at_or_above_ninety_five_percent_only() {
1517        assert!(!should_compact_context(94, 100));
1518        assert!(should_compact_context(95, 100));
1519        assert!(should_compact_context(96, 100));
1520        assert!(!should_compact_context(100, 0));
1521    }
1522
1523    #[test]
1524    fn compaction_boundary_keeps_complete_recent_turns() {
1525        let messages = [
1526            ChatMessage::user("old request".to_owned()),
1527            ChatMessage::assistant("old answer".to_owned(), Vec::new()),
1528            ChatMessage::user("recent request".to_owned()),
1529            ChatMessage::assistant("recent answer ".repeat(8_000), Vec::new()),
1530        ];
1531
1532        assert_eq!(find_compaction_boundary(&messages, None), Some(2));
1533        assert_eq!(find_compaction_boundary(&messages, Some(2)), None);
1534    }
1535
1536    #[test]
1537    fn mid_turn_compaction_summarizes_without_tools_then_continues_original_request() {
1538        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("compaction listener");
1539        let address = listener.local_addr().expect("compaction address");
1540        let responses = ["summary", "continued"];
1541        let server = thread::spawn(move || {
1542            let mut requests = Vec::new();
1543            for response_text in responses {
1544                let (mut stream, _) = listener.accept().expect("compaction request");
1545                let mut request = String::new();
1546                let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone"));
1547                let mut content_length = 0usize;
1548                loop {
1549                    let mut line = String::new();
1550                    reader.read_line(&mut line).expect("request header");
1551                    if line == "\r\n" {
1552                        break;
1553                    }
1554                    if let Some((name, value)) = line.split_once(':') {
1555                        if name.eq_ignore_ascii_case("content-length") {
1556                            content_length = value.trim().parse().expect("content length");
1557                        }
1558                    }
1559                }
1560                let mut body = vec![0u8; content_length];
1561                reader.read_exact(&mut body).expect("request body");
1562                request.push_str(std::str::from_utf8(&body).expect("request JSON"));
1563                requests.push(serde_json::from_str::<Value>(&request).expect("request value"));
1564                let payload = serde_json::json!({
1565                    "choices": [{
1566                        "delta": {"content": response_text},
1567                        "finish_reason": null
1568                    }]
1569                });
1570                let body = format!("data: {payload}\n\ndata: [DONE]\n\n");
1571                let header = format!(
1572                    "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1573                    body.len()
1574                );
1575                stream
1576                    .write_all(header.as_bytes())
1577                    .expect("response header");
1578                stream.write_all(body.as_bytes()).expect("response body");
1579                stream.flush().expect("response flush");
1580            }
1581            requests
1582        });
1583
1584        let key_env = format!("LUCY_COMPACTION_APP_KEY_{}", std::process::id());
1585        std::env::set_var(&key_env, "provider-secret");
1586        let settings = crate::config::LlmSettings {
1587            base_url: format!("http://{address}/v1"),
1588            model: "model".to_owned(),
1589            api_key_env: key_env.clone(),
1590            effort: None,
1591        };
1592        let provider = Provider::new(&settings).expect("provider");
1593        let home = std::env::temp_dir().join(format!("lucy-app-compaction-{}", std::process::id()));
1594        let _ = std::fs::remove_dir_all(&home);
1595        std::fs::create_dir(&home).expect("temp home");
1596        let cwd = std::env::current_dir().expect("cwd");
1597        let mut session = Session::create_with_secret(
1598            &home,
1599            &cwd,
1600            "prompt".to_owned(),
1601            settings,
1602            Some("provider-secret"),
1603        )
1604        .expect("session");
1605        session
1606            .append_message(ChatMessage::user("old request".to_owned()))
1607            .expect("old user");
1608        session
1609            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1610            .expect("old answer");
1611        session
1612            .append_message(ChatMessage::user("recent request".to_owned()))
1613            .expect("recent user");
1614        session
1615            .append_message(ChatMessage::assistant(
1616                "recent answer ".repeat(8_000),
1617                Vec::new(),
1618            ))
1619            .expect("recent answer");
1620
1621        struct Sink {
1622            events: Vec<ProtocolEvent>,
1623            compaction_started: bool,
1624            compaction_finished: bool,
1625        }
1626        impl EventSink for Sink {
1627            fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
1628                self.events.push(event.clone());
1629                Ok(())
1630            }
1631            fn compaction_started(&mut self) -> io::Result<()> {
1632                self.compaction_started = true;
1633                Ok(())
1634            }
1635            fn compaction_finished(&mut self, _: usize, _: usize) -> io::Result<()> {
1636                self.compaction_finished = true;
1637                Ok(())
1638            }
1639        }
1640
1641        let mut harness = Harness {
1642            home: std::env::temp_dir(),
1643            session,
1644            provider,
1645            context_window: Some(1),
1646            attached_agents: Vec::new(),
1647        };
1648        let cancellation = CancellationToken::new();
1649        let mut sink = Sink {
1650            events: Vec::new(),
1651            compaction_started: false,
1652            compaction_finished: false,
1653        };
1654        harness
1655            .handle_message("continue", &mut sink, Some(&cancellation))
1656            .expect("continued turn");
1657
1658        let requests = server.join().expect("server");
1659        assert_eq!(requests.len(), 2);
1660        assert!(requests[0].get("tools").is_none());
1661        assert!(requests[1].get("tools").is_some());
1662        assert!(sink.compaction_started);
1663        assert!(sink.compaction_finished);
1664        assert!(sink.events.iter().any(
1665            |event| matches!(event, ProtocolEvent::AssistantDelta { text } if text == "continued")
1666        ));
1667        assert!(harness
1668            .session
1669            .history
1670            .iter()
1671            .any(|record| matches!(record, crate::session::SessionHistoryRecord::Compaction(_))));
1672        let provider_text = harness
1673            .session
1674            .provider_messages()
1675            .iter()
1676            .filter_map(|message| message.content.as_deref())
1677            .collect::<Vec<_>>()
1678            .join("\n");
1679        assert!(!provider_text.contains("old request"));
1680        assert!(provider_text.contains("continue"));
1681
1682        std::env::remove_var(key_env);
1683        std::fs::remove_dir_all(home).expect("cleanup");
1684    }
1685
1686    #[test]
1687    fn parses_only_message_records() {
1688        assert_eq!(
1689            parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
1690            "hello"
1691        );
1692        assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
1693        assert_eq!(
1694            parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
1695            ""
1696        );
1697    }
1698
1699    #[test]
1700    fn resolves_terminal_and_forced_modes() {
1701        assert_eq!(
1702            resolve_mode(&[], true, true).expect("default TUI"),
1703            FrontendMode::Tui
1704        );
1705        assert_eq!(
1706            resolve_mode(&[], true, false).expect("automatic JSONL"),
1707            FrontendMode::Jsonl
1708        );
1709        assert_eq!(
1710            resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
1711            FrontendMode::Jsonl
1712        );
1713        assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
1714    }
1715
1716    #[test]
1717    fn redactor_does_not_leak_a_secret_across_deltas() {
1718        let mut redactor = SecretRedactor::new("secret");
1719        let mut output = Vec::new();
1720        redactor
1721            .push("prefix sec", |text| {
1722                output.push(text.to_owned());
1723                Ok(())
1724            })
1725            .expect("push");
1726        redactor
1727            .push("ret suffix", |text| {
1728                output.push(text.to_owned());
1729                Ok(())
1730            })
1731            .expect("push");
1732        redactor
1733            .finish(|text| {
1734                output.push(text.to_owned());
1735                Ok(())
1736            })
1737            .expect("finish");
1738        let output = output.join("");
1739        assert_eq!(
1740            output,
1741            format!("prefix {} suffix", redaction_marker("secret").unwrap())
1742        );
1743        assert!(!output.contains("secret"));
1744    }
1745
1746    #[test]
1747    fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1748        let mut redactor = SecretRedactor::new("n0");
1749        let mut output = String::new();
1750        redactor
1751            .push("\n0", |text| {
1752                output.push_str(text);
1753                Ok(())
1754            })
1755            .expect("push");
1756        redactor
1757            .finish(|text| {
1758                output.push_str(text);
1759                Ok(())
1760            })
1761            .expect("finish");
1762        assert!(!output.contains("n0"));
1763        assert_eq!(output, redaction_marker("n0").unwrap());
1764    }
1765
1766    #[test]
1767    fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1768        let mut redactor = SecretRedactor::new("secret");
1769        let mut output = Vec::new();
1770        redactor
1771            .push("xsecre", |text| {
1772                output.push(text.to_owned());
1773                Ok(())
1774            })
1775            .expect("first delta");
1776        redactor
1777            .push("t", |text| {
1778                output.push(text.to_owned());
1779                Ok(())
1780            })
1781            .expect("second delta");
1782        redactor
1783            .finish(|text| {
1784                output.push(text.to_owned());
1785                Ok(())
1786            })
1787            .expect("finish");
1788        let output = output.join("");
1789        assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1790        assert!(!output.contains("secret"));
1791    }
1792
1793    #[test]
1794    fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1795        for secret in ["REDACTED", "[REDACTED]"] {
1796            let mut redactor = SecretRedactor::new(secret);
1797            let split = secret.len() / 2;
1798            let (first, second) = secret.split_at(split);
1799            let mut output = String::new();
1800            redactor
1801                .push(first, |text| {
1802                    output.push_str(text);
1803                    Ok(())
1804                })
1805                .expect("first delta");
1806            redactor
1807                .push(second, |text| {
1808                    output.push_str(text);
1809                    Ok(())
1810                })
1811                .expect("second delta");
1812            redactor
1813                .finish(|text| {
1814                    output.push_str(text);
1815                    Ok(())
1816                })
1817                .expect("finish");
1818            assert!(!output.contains(secret));
1819            assert!(output.len() <= secret.len());
1820        }
1821    }
1822
1823    #[test]
1824    fn malformed_tool_arguments_use_a_safe_copy() {
1825        let secret = "provider-secret";
1826        let escaped = secret
1827            .chars()
1828            .map(|character| format!(r#"\u{:04x}"#, character as u32))
1829            .collect::<String>();
1830        let arguments = format!(r#"{{"command":"{escaped}""#);
1831        let safe = redact_tool_arguments(&arguments, secret);
1832        assert_eq!(safe, "{}");
1833        serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1834        assert!(!safe.contains(secret));
1835        assert!(!safe.contains(&escaped));
1836        for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1837            assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1838        }
1839    }
1840
1841    #[test]
1842    fn structured_redaction_preserves_tool_and_result_schema_keys() {
1843        let secret = "provider-secret";
1844        let value = serde_json::json!({
1845            "command": "printf provider-secret",
1846            "stdout": "provider-secret",
1847            "stderr": "ordinary",
1848            "exit_code": 0,
1849            "timed_out": false,
1850            "stdout_truncated": false,
1851            "stderr_truncated": false,
1852            "unknown-provider-secret": "provider-secret"
1853        });
1854        let redacted = redact_json_value(value, secret);
1855        for key in [
1856            "command",
1857            "stdout",
1858            "stderr",
1859            "exit_code",
1860            "timed_out",
1861            "stdout_truncated",
1862            "stderr_truncated",
1863        ] {
1864            assert!(redacted.get(key).is_some(), "missing schema key: {key}");
1865        }
1866        let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
1867        assert!(!encoded.contains(secret));
1868        assert!(redacted.get("unknown-provider-secret").is_none());
1869    }
1870
1871    #[test]
1872    fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
1873        let value = serde_json::json!({
1874            "exit_code": 0,
1875            "timed_out": false,
1876            "stdout_truncated": true,
1877            "error": null,
1878        });
1879        let redacted = redact_json_value(value, "0");
1880        assert!(redacted["exit_code"].is_number());
1881        assert!(redacted["timed_out"].is_boolean());
1882        assert!(redacted["stdout_truncated"].is_boolean());
1883        assert!(redacted["error"].is_null());
1884    }
1885
1886    #[test]
1887    fn reasoning_details_are_recursively_redacted_before_persistence() {
1888        let details = vec![serde_json::json!({
1889            "type": "reasoning.text",
1890            "text": "provider-secret",
1891            "nested": [{"value": "provider-secret"}],
1892            "provider-secret": "provider-secret"
1893        })];
1894        let redacted = redact_reasoning_details(&details, "provider-secret")
1895            .expect("non-empty reasoning details");
1896        let redacted = Value::Array(redacted);
1897        let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
1898        assert!(!encoded.contains("provider-secret"));
1899        assert_eq!(redacted[0]["type"], "reasoning.text");
1900        assert_eq!(redacted[0]["text"], "[REDACTED]");
1901        assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
1902        assert!(redacted[0].get("provider-secret").is_none());
1903    }
1904
1905    #[test]
1906    fn malformed_input_error_does_not_echo_secret_bearing_input() {
1907        let error =
1908            parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
1909                .expect_err("invalid input");
1910        assert!(!error.contains("provider-secret"));
1911    }
1912
1913    #[test]
1914    fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
1915        let mut output = Vec::new();
1916        let error = parse_input_message("not json").expect_err("invalid input");
1917        let mut protocol = ProtocolWriter::new(&mut output);
1918        protocol.error(&error).expect("error event");
1919        assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
1920        let _ = Cursor::new("");
1921    }
1922
1923    #[test]
1924    fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
1925        let secret = "lucy";
1926        let mut diagnostics = Vec::new();
1927        write_diagnostic_safe_with_environment(
1928            &mut diagnostics,
1929            secret,
1930            None,
1931            vec![secret.to_owned()],
1932        );
1933        let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
1934        assert!(!diagnostics.contains(secret));
1935    }
1936    #[test]
1937    fn attached_agents_keeps_only_agents_files_and_redacts_their_paths() {
1938        let sources = vec![
1939            InstructionSource {
1940                path: std::path::PathBuf::from("/project/AGENTS.md"),
1941                contents: "agents".to_owned(),
1942            },
1943            InstructionSource {
1944                path: std::path::PathBuf::from("/project/CLAUDE.md"),
1945                contents: "claude".to_owned(),
1946            },
1947            InstructionSource {
1948                path: std::path::PathBuf::from("/private-secret/AGENTS.md"),
1949                contents: "agents".to_owned(),
1950            },
1951        ];
1952
1953        assert_eq!(
1954            attached_agents(sources, "secret"),
1955            vec!["/project/AGENTS.md", "/private-!/AGENTS.md"]
1956        );
1957    }
1958
1959    #[test]
1960    fn expands_slash_prefixed_skill_names_and_keeps_ordinary_messages() {
1961        let skill = SkillEntry {
1962            name: "release-notes".to_owned(),
1963            description: "Writes release notes".to_owned(),
1964            path: std::path::PathBuf::from("/skills/release-notes/SKILL.md"),
1965            contents: "# Release notes\nUse the template.".to_owned(),
1966            model_invocable: true,
1967        };
1968        let expanded = expand_skill_invocation("/release-notes v1.2", std::slice::from_ref(&skill))
1969            .expect("skill command");
1970        assert!(expanded.text.contains("# Release notes"));
1971        assert!(expanded.text.contains("User: v1.2"));
1972        assert_eq!(expanded.attached_skill.as_deref(), Some("release-notes"));
1973        let ordinary = expand_skill_invocation("ordinary message", &[]).expect("ordinary message");
1974        assert_eq!(ordinary.text, "ordinary message");
1975        assert_eq!(ordinary.attached_skill, None);
1976        assert_eq!(
1977            expand_skill_invocation("/missing", &[]).unwrap_err(),
1978            "unknown skill: missing"
1979        );
1980        assert_eq!(
1981            expand_skill_invocation("/skill:release-notes", &[skill]).unwrap_err(),
1982            "unknown skill: skill:release-notes"
1983        );
1984    }
1985}