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