1mod activity;
2mod cli;
3pub mod completion;
4mod provider_prompt;
5mod serve;
6
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8use std::env;
9use std::ffi::OsString;
10use std::fs::{self, OpenOptions};
11use std::io::{self, Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::Command as ProcessCommand;
14use std::thread;
15use std::time::{Duration, Instant, SystemTime};
16
17use clap::Parser;
18use clap::error::ErrorKind;
19use jiff::{Timestamp, Zoned};
20use nils_common::cli_contract::{
21 Envelope, EnvelopeError, OutputFormat, emit_parse_error, exit, schema_version_for,
22};
23use nils_common::fs::{
24 SECRET_FILE_MODE, display_path, expand_home, home_dir, normalize_path, write_atomic,
25};
26use nils_common::git::parse_git_remote_url;
27use nils_provider_resume::{
32 CODEX_RESUME_SCAN_MAX_DEPTH, ClaudeResumeScanBudget, CodexResumeScanBudget, ResumeIdError,
33 ResumeProvider, ResumeResolveError, collect_claude_provider_resume_matches,
34 collect_codex_provider_resume_matches, normalize_resume_id, resolve_resume_source,
35};
36pub(crate) use nils_provider_resume::{
37 claude_projects_root, codex_sessions_root, read_claude_session_cwd, read_codex_session_meta,
38};
39use serde::{Deserialize, Serialize};
40use serde_json::{Value, json};
41
42use cli::{AgentKind, Cli, Command, SpecialKey};
43
44const SESSION_DOCUMENT_VERSION: &str = "agent-session.session.v1";
45const SESSION_RESUME_DOCUMENT_VERSION: &str = "agent-session.resume.v1";
46const SESSION_RESUME_FILE: &str = "resume.json";
47const BINARY: &str = "agent-session";
48const START_COMMAND: &str = "start";
49const RUN_COMMAND: &str = "run";
50const LIST_COMMAND: &str = "list";
51const COMMAND_COMMAND: &str = "command";
52const LOGS_COMMAND: &str = "logs";
53const SEND_COMMAND: &str = "send";
54const GLANCE_COMMAND: &str = "glance";
55const RESUME_COMMAND: &str = "resume";
56const ACTIVITY_EVENT_COMMAND: &str = "activity-event";
57const ACTIVITY_STATUS_COMMAND: &str = "activity-status";
58const ACTIVITY_DOCTOR_COMMAND: &str = "activity-doctor";
59const ACTIVITY_SETUP_COMMAND: &str = "activity-setup";
60const DELETE_COMMAND: &str = "delete";
61const WORKDIR_USAGE_FILE: &str = "workdir-usage.json";
62const CODEX_RESUME_CAPTURE_TIMEOUT_MS: u64 = 1500;
63const CODEX_RESUME_CAPTURE_POLL_MS: u64 = 100;
64const CODEX_RESUME_AMBIGUITY_WINDOW_MS: u64 = 500;
65const CODEX_RESUME_BACKFILL_MAX_AGE_SECS: u64 = 10 * 60;
66
67pub fn run() -> i32 {
68 run_with_args(env::args_os())
69}
70
71pub fn run_with_args<I, T>(args: I) -> i32
72where
73 I: IntoIterator<Item = T>,
74 T: Into<OsString> + Clone,
75{
76 let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
77 let cli = match Cli::try_parse_from(raw_args.clone()) {
78 Ok(cli) => cli,
79 Err(err) => {
80 let kind = err.kind();
81 if matches!(
82 kind,
83 ErrorKind::DisplayHelp
84 | ErrorKind::DisplayVersion
85 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
86 ) {
87 let _ = err.print();
88 return err.exit_code();
89 }
90
91 let format = detect_format_from_args(&raw_args);
92 let code = match kind {
93 ErrorKind::InvalidSubcommand => "unknown-subcommand",
94 _ => "parse-error",
95 };
96 let message = render_clap_message(&err);
97 return emit_parse_error(BINARY, format, code, &message);
98 }
99 };
100
101 dispatch(cli)
102}
103
104fn dispatch(cli: Cli) -> i32 {
105 if let Command::Completion(args) = cli.command {
106 return completion::run(args.shell);
107 }
108
109 let format = command_format(&cli.command);
110 let context = match CliContext::resolve(cli.state_dir, cli.host) {
111 Ok(context) => context,
112 Err(err) => {
113 return render_error("error", format, err);
114 }
115 };
116
117 match cli.command {
118 Command::Start(args) => run_start(&context, args),
119 Command::Run(args) => run_one_shot(&context, args),
120 Command::List(args) => run_list(&context, args),
121 Command::Show(args) => run_command(&context, args),
122 Command::Attach(args) => run_attach(&context, args),
123 Command::Logs(args) => run_logs(&context, args),
124 Command::Send(args) => run_send(&context, args),
125 Command::Glance(args) => run_glance(&context, args),
126 Command::Resume(args) => run_resume(&context, args),
127 Command::Activity(args) => run_activity(&context, args),
128 Command::Serve(args) => serve::run_serve(&context, args),
129 Command::Delete(args) => run_delete(&context, args),
130 Command::Completion(_) => unreachable!("completion is handled before context resolution"),
131 }
132}
133
134fn command_format(command: &Command) -> OutputFormat {
135 match command {
136 Command::Start(args) => args.format,
137 Command::Run(args) => args.format,
138 Command::List(args) => args.format,
139 Command::Show(args) => args.format,
140 Command::Logs(args) => args.format,
141 Command::Send(args) => args.format,
142 Command::Glance(args) => args.format,
143 Command::Resume(args) => args.format,
144 Command::Activity(args) => match &args.command {
145 cli::ActivityCommand::Event(args) => args.format,
146 cli::ActivityCommand::Status(args) => args.format,
147 cli::ActivityCommand::Hook(_) => OutputFormat::Text,
148 cli::ActivityCommand::Doctor(args) => args.format,
149 cli::ActivityCommand::Setup(args) => args.format,
150 },
151 Command::Delete(args) => args.format,
152 Command::Attach(_) | Command::Serve(_) | Command::Completion(_) => OutputFormat::Text,
153 }
154}
155
156fn detect_format_from_args(args: &[OsString]) -> OutputFormat {
157 let mut index = 1;
158 while index < args.len() {
159 let Some(arg) = args[index].to_str() else {
160 index += 1;
161 continue;
162 };
163 if arg == "--format" {
164 if args
165 .get(index + 1)
166 .and_then(|value| value.to_str())
167 .is_some_and(|value| value.eq_ignore_ascii_case("json"))
168 {
169 return OutputFormat::Json;
170 }
171 index += 2;
172 continue;
173 }
174 if let Some(value) = arg.strip_prefix("--format=")
175 && value.eq_ignore_ascii_case("json")
176 {
177 return OutputFormat::Json;
178 }
179 index += 1;
180 }
181 OutputFormat::Text
182}
183
184fn render_clap_message(err: &clap::Error) -> String {
185 let rendered = err.to_string();
186 rendered
187 .lines()
188 .find(|line| !line.trim().is_empty())
189 .map(|line| {
190 let line = line.trim();
191 line.strip_prefix("error:")
192 .map(str::trim)
193 .unwrap_or(line)
194 .to_string()
195 })
196 .unwrap_or_else(|| "command-line parse failed".to_string())
197}
198
199fn run_start(context: &CliContext, args: cli::StartArgs) -> i32 {
200 let format = args.format;
201 match start_session(context, args) {
202 Ok(view) => render_single_success(
203 START_COMMAND,
204 view.format,
205 &view.result,
206 render_started_text,
207 ),
208 Err(err) => render_error(START_COMMAND, format, err),
209 }
210}
211
212fn run_one_shot(context: &CliContext, args: cli::RunArgs) -> i32 {
213 let format = args.format;
214 match start_run_session(context, args) {
215 Ok(view) => {
216 render_single_success(RUN_COMMAND, view.format, &view.result, render_started_text)
217 }
218 Err(err) => render_error(RUN_COMMAND, format, err),
219 }
220}
221
222fn run_list(context: &CliContext, args: cli::ListArgs) -> i32 {
223 match list_sessions(context, None) {
224 Ok(results) => render_list_success(args.format, &results),
225 Err(err) => render_error(LIST_COMMAND, args.format, err),
226 }
227}
228
229fn run_command(context: &CliContext, args: cli::SessionRefArgs) -> i32 {
230 match load_session_view(context, &args.id, None) {
231 Ok(view) => render_single_success(COMMAND_COMMAND, args.format, &view, render_command_text),
232 Err(err) => render_error(COMMAND_COMMAND, args.format, err),
233 }
234}
235
236fn run_attach(context: &CliContext, args: cli::AttachArgs) -> i32 {
237 match load_session_record(context, &args.id) {
238 Ok(record) => {
239 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
240 let status = ProcessCommand::new(&tmux_bin)
241 .arg("attach-session")
242 .arg("-t")
243 .arg(&record.tmux_session)
244 .status();
245 match status {
246 Ok(status) if status.success() => exit::SUCCESS,
247 Ok(status) => {
248 eprintln!("error: tmux attach failed with status {status}");
249 exit::RUNTIME
250 }
251 Err(err) => {
252 eprintln!("error: failed to run {}: {err}", tmux_bin.display());
253 exit::RUNTIME
254 }
255 }
256 }
257 Err(err) => render_error("attach", OutputFormat::Text, err),
258 }
259}
260
261fn run_logs(context: &CliContext, args: cli::LogsArgs) -> i32 {
262 match load_session_record(context, &args.id).and_then(|record| {
263 session_logs(
264 &record,
265 args.tail,
266 &resolve_tmux_bin(args.tmux_bin.as_deref()),
267 )
268 }) {
269 Ok(result) => render_single_success(LOGS_COMMAND, args.format, &result, render_logs_text),
270 Err(err) => render_error(LOGS_COMMAND, args.format, err),
271 }
272}
273
274fn run_send(context: &CliContext, args: cli::SendArgs) -> i32 {
275 let format = args.format;
276 match send_to_session(context, args) {
277 Ok(result) => render_single_success(SEND_COMMAND, format, &result, render_send_text),
278 Err(err) => render_error(SEND_COMMAND, format, err),
279 }
280}
281
282fn run_glance(context: &CliContext, args: cli::GlanceArgs) -> i32 {
283 let format = args.format;
284 match glance_session(context, args) {
285 Ok(result) => render_single_success(GLANCE_COMMAND, format, &result, render_glance_text),
286 Err(err) => render_error(GLANCE_COMMAND, format, err),
287 }
288}
289
290fn run_resume(context: &CliContext, args: cli::ResumeArgs) -> i32 {
291 let format = args.format;
292 match resume_session(context, args) {
293 Ok(result) => render_single_success(RESUME_COMMAND, format, &result, render_resumed_text),
294 Err(err) => render_error(RESUME_COMMAND, format, err),
295 }
296}
297
298fn run_activity(context: &CliContext, args: cli::ActivityArgs) -> i32 {
299 match args.command {
300 cli::ActivityCommand::Event(args) => {
301 let format = args.format;
302 let result = activity::read_event_from_stdin()
303 .and_then(|event| activity::ingest_event(context, &args.id, event));
304 match result {
305 Ok(result) => render_single_success(
306 ACTIVITY_EVENT_COMMAND,
307 format,
308 &result,
309 render_activity_text,
310 ),
311 Err(err) => render_error(ACTIVITY_EVENT_COMMAND, format, err),
312 }
313 }
314 cli::ActivityCommand::Status(args) => match activity::activity_status(context, &args.id) {
315 Ok(result) => render_single_success(
316 ACTIVITY_STATUS_COMMAND,
317 args.format,
318 &result,
319 render_activity_text,
320 ),
321 Err(err) => render_error(ACTIVITY_STATUS_COMMAND, args.format, err),
322 },
323 cli::ActivityCommand::Hook(args) => {
324 activity::ingest_provider_hook_fail_open(context, args.agent, args.event.as_deref());
327 exit::SUCCESS
328 }
329 cli::ActivityCommand::Doctor(args) => match activity::doctor(context, args.agent) {
330 Ok(result) => render_single_success(
331 ACTIVITY_DOCTOR_COMMAND,
332 args.format,
333 &result,
334 render_doctor_text,
335 ),
336 Err(err) => render_error(ACTIVITY_DOCTOR_COMMAND, args.format, err),
337 },
338 cli::ActivityCommand::Setup(args) => {
339 let action = if args.dry_run {
340 activity::SetupAction::DryRun
341 } else if args.apply {
342 activity::SetupAction::Apply
343 } else if args.remove {
344 activity::SetupAction::Remove
345 } else {
346 activity::SetupAction::Repair
347 };
348 match activity::setup(args.agent, action) {
349 Ok(result) => render_single_success(
350 ACTIVITY_SETUP_COMMAND,
351 args.format,
352 &result,
353 render_setup_text,
354 ),
355 Err(err) => render_error(ACTIVITY_SETUP_COMMAND, args.format, err),
356 }
357 }
358 }
359}
360
361fn run_delete(context: &CliContext, args: cli::DeleteArgs) -> i32 {
362 match delete_session(
363 context,
364 &args.id,
365 resolve_tmux_bin(args.tmux_bin.as_deref()),
366 ) {
367 Ok(result) => {
368 render_single_success(DELETE_COMMAND, args.format, &result, render_delete_text)
369 }
370 Err(err) => render_error(DELETE_COMMAND, args.format, err),
371 }
372}
373
374#[derive(Debug, Clone)]
375struct CliContext {
376 state_dir: PathBuf,
377 host: Option<String>,
378}
379
380impl CliContext {
381 fn resolve(state_dir: Option<PathBuf>, host: Option<String>) -> Result<Self, CliError> {
382 let state_dir = resolve_state_dir(state_dir)?;
383 let host = resolve_host(
384 host.or_else(|| non_empty_env("AGENT_SESSION_HOST"))
385 .or_else(short_hostname),
386 )?;
387 Ok(Self { state_dir, host })
388 }
389}
390
391#[derive(Debug, Clone, Deserialize, Serialize)]
392struct SessionRecord {
393 schema_version: String,
394 id: String,
395 agent: String,
396 mode: String,
397 title: Option<String>,
398 cwd: String,
399 tmux_session: String,
400 prompt_file: Option<String>,
401 log_file: Option<String>,
402 created_at: String,
403 updated_at: String,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 provider_resume: Option<ProviderResume>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 runtime: Option<RuntimeInfo>,
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
409 agent_args: Vec<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 agent_bin: Option<String>,
412 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
413 extra: BTreeMap<String, Value>,
414 #[serde(skip)]
415 resume_sidecar_extra: BTreeMap<String, Value>,
416}
417
418#[derive(Debug, Clone, Deserialize, Serialize)]
419struct ProviderResume {
420 provider: String,
421 session_id: String,
422 captured_at: String,
423 capture_method: String,
424 resume_args: Vec<String>,
425 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
426 extra: BTreeMap<String, Value>,
427}
428
429#[derive(Debug, Clone, Serialize)]
430struct ProviderResumeView {
431 provider: String,
432 session_id: String,
433 captured_at: String,
434 capture_method: String,
435 resume_args: Vec<String>,
436}
437
438impl From<&ProviderResume> for ProviderResumeView {
439 fn from(provider_resume: &ProviderResume) -> Self {
440 Self {
441 provider: provider_resume.provider.clone(),
442 session_id: provider_resume.session_id.clone(),
443 captured_at: provider_resume.captured_at.clone(),
444 capture_method: provider_resume.capture_method.clone(),
445 resume_args: provider_resume.resume_args.clone(),
446 }
447 }
448}
449
450#[derive(Debug, Clone, Deserialize, Serialize)]
451struct RuntimeInfo {
452 kind: String,
453 tmux_session: String,
454 generation: u64,
455 started_at: String,
456 #[serde(default, skip_serializing_if = "String::is_empty")]
457 launch_id: String,
458 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
459 extra: BTreeMap<String, Value>,
460}
461
462#[derive(Debug, Clone, Deserialize, Serialize)]
463struct DurableResumeRecord {
464 schema_version: String,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 provider_resume: Option<ProviderResume>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 runtime: Option<RuntimeInfo>,
469 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 agent_args: Vec<String>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 agent_bin: Option<String>,
473 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
474 extra: BTreeMap<String, Value>,
475}
476
477#[derive(Debug, Serialize)]
478struct SessionView {
479 id: String,
480 agent: String,
481 mode: String,
482 title: Option<String>,
483 cwd: String,
484 tmux_session: String,
485 status: String,
486 resumable: bool,
487 repo_name: Option<String>,
488 #[serde(skip_serializing_if = "Option::is_none")]
489 provider_resume: Option<ProviderResumeView>,
490 attach_command: String,
491 ssh_attach_command: Option<String>,
492 prompt_file: Option<String>,
493 log_file: Option<String>,
494 created_at: String,
495 updated_at: String,
496 #[serde(skip_serializing_if = "Option::is_none")]
497 last_terminal_activity_at: Option<String>,
498 #[serde(skip_serializing_if = "Option::is_none")]
499 runtime_started_at: Option<String>,
500 #[serde(skip_serializing_if = "Option::is_none")]
501 turn_state: Option<activity::TurnState>,
502}
503
504#[derive(Debug)]
505struct StartView {
506 format: OutputFormat,
507 result: SessionView,
508}
509
510pub(crate) struct ProviderResumeImportArgs {
511 pub(crate) agent: AgentKind,
512 pub(crate) provider_resume_id: String,
513 pub(crate) title: Option<String>,
514 pub(crate) id: Option<String>,
515 pub(crate) tmux_bin: Option<PathBuf>,
516 pub(crate) agent_bin: Option<PathBuf>,
517 pub(crate) agent_args: Vec<String>,
518 pub(crate) format: OutputFormat,
519}
520
521#[derive(Debug, Serialize)]
522struct DeleteResult {
523 id: String,
524 tmux_session: String,
525 killed: bool,
526 deleted: bool,
527 session_dir: String,
528}
529
530#[derive(Debug, Serialize)]
531struct LogsResult {
532 id: String,
533 source: String,
534 text: String,
535}
536
537#[derive(Debug, Serialize)]
538struct SendResult {
539 id: String,
540 tmux_session: String,
541 sent_text: bool,
542 keys: Vec<String>,
543}
544
545#[derive(Debug, Serialize)]
546struct GlanceResult {
547 id: String,
548 agent: String,
549 title: Option<String>,
550 tmux_session: String,
551 status: String,
552 resumable: bool,
553 repo_name: Option<String>,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 provider_resume: Option<ProviderResumeView>,
556 tail: String,
557 created_at: String,
558 updated_at: String,
559 #[serde(skip_serializing_if = "Option::is_none")]
560 last_terminal_activity_at: Option<String>,
561 #[serde(skip_serializing_if = "Option::is_none")]
562 runtime_started_at: Option<String>,
563 #[serde(skip_serializing_if = "Option::is_none")]
564 turn_state: Option<activity::TurnState>,
565}
566
567#[derive(Debug, Serialize)]
568struct AttachmentResult {
569 id: String,
570 filename: String,
571 path: String,
572 bytes: usize,
573 content_type: Option<String>,
574}
575
576#[derive(Debug, Serialize)]
577struct WorkdirResult {
578 path: String,
579 name: String,
580 root: String,
581 is_git_repo: bool,
582 last_used: Option<String>,
583}
584
585#[derive(Debug, Default, Clone, Copy)]
586struct WorkdirSearchOptions {
587 git_only: bool,
588 exclude_worktrees: bool,
589}
590
591#[derive(Debug, Default, Deserialize, Serialize)]
592struct WorkdirUsage {
593 entries: BTreeMap<String, String>,
594}
595
596#[derive(Debug, Clone)]
597struct CliError(Box<CliErrorData>);
598
599#[derive(Debug, Clone)]
600struct CliErrorData {
601 code: String,
602 message: String,
603 details: Option<Value>,
604 exit_code: i32,
605}
606
607impl CliError {
608 fn code(&self) -> &str {
609 &self.0.code
610 }
611
612 fn usage(code: impl Into<String>, message: impl Into<String>, details: Option<Value>) -> Self {
613 Self(Box::new(CliErrorData {
614 code: code.into(),
615 message: message.into(),
616 details,
617 exit_code: exit::USAGE,
618 }))
619 }
620
621 fn runtime(
622 code: impl Into<String>,
623 message: impl Into<String>,
624 details: Option<Value>,
625 ) -> Self {
626 Self(Box::new(CliErrorData {
627 code: code.into(),
628 message: message.into(),
629 details,
630 exit_code: exit::RUNTIME,
631 }))
632 }
633
634 fn data(code: impl Into<String>, message: impl Into<String>, details: Option<Value>) -> Self {
635 Self(Box::new(CliErrorData {
636 code: code.into(),
637 message: message.into(),
638 details,
639 exit_code: exit::DATA,
640 }))
641 }
642
643 fn into_inner(self) -> CliErrorData {
644 *self.0
645 }
646}
647
648fn start_session(context: &CliContext, args: cli::StartArgs) -> Result<StartView, CliError> {
649 validate_agent_args(args.agent, &args.agent_args)?;
650 let cwd = resolve_cwd(args.cwd.as_deref())?;
651 let prompt = read_prompt(&args.prompt, args.prompt_file.as_deref(), args.prompt_stdin)?;
652 let provider_plan = initial_provider_resume_plan(args.agent, &cwd);
653 let launch_started_at = SystemTime::now();
654 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
655 let agent_bin = resolve_agent_bin(args.agent, args.agent_bin.as_deref());
656 let mut created = create_record(RecordRequest {
657 context,
658 agent: args.agent,
659 mode: "interactive",
660 title: args.title.as_deref(),
661 explicit_id: args.id.as_deref(),
662 cwd: &cwd,
663 prompt: prompt.as_deref(),
664 log_file_name: None,
665 provider_resume: provider_plan.provider_resume.clone(),
666 agent_args: args.agent_args.clone(),
667 agent_bin: Some(display_path(&agent_bin)),
668 })?;
669
670 if let Err(err) = start_interactive_tmux(
671 &tmux_bin,
672 &agent_bin,
673 args.agent,
674 &context.state_dir,
675 &created.record,
676 &provider_plan.launch_args,
677 &args.agent_args,
678 ) {
679 cleanup_created_record(&created);
680 return Err(err);
681 }
682 if created.prompt_file.is_some() {
683 if args.paste_delay_ms > 0 {
684 thread::sleep(Duration::from_millis(args.paste_delay_ms));
685 }
686 if let Err(err) = paste_prompt(&tmux_bin, &created.record) {
687 let _ = kill_tmux_session(&tmux_bin, &created.record.tmux_session);
688 cleanup_created_record(&created);
689 return Err(err);
690 }
691 }
692 if created.record.provider_resume.is_none()
693 && let Some(provider_resume) =
694 capture_provider_resume_after_launch(args.agent, &created.record, launch_started_at)
695 {
696 created.record.provider_resume = Some(provider_resume);
697 created.record = persist_or_reload_session_record(context, &created.record);
698 }
699
700 let result = session_view(
701 context,
702 &created.record,
703 Some("running".to_string()),
704 Some(&tmux_bin),
705 );
706 record_workdir_usage(context, &cwd);
707 Ok(StartView {
708 format: args.format,
709 result,
710 })
711}
712
713fn start_run_session(context: &CliContext, args: cli::RunArgs) -> Result<StartView, CliError> {
714 validate_agent_args(args.agent, &args.agent_args)?;
715 let cwd = resolve_cwd(args.cwd.as_deref())?;
716 let prompt = read_prompt(&args.prompt, args.prompt_file.as_deref(), args.prompt_stdin)?;
717 let prompt = prompt
718 .filter(|value| !value.trim().is_empty())
719 .ok_or_else(|| {
720 CliError::usage(
721 "missing-prompt",
722 "run requires --prompt, --prompt-file, or --prompt-stdin",
723 None,
724 )
725 })?;
726 let log_file = Some("output.log");
727 let created = create_record(RecordRequest {
728 context,
729 agent: args.agent,
730 mode: "run",
731 title: args.title.as_deref(),
732 explicit_id: args.id.as_deref(),
733 cwd: &cwd,
734 prompt: Some(&prompt),
735 log_file_name: log_file,
736 provider_resume: None,
737 agent_args: args.agent_args.clone(),
738 agent_bin: None,
739 })?;
740
741 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
742 let agent_bin = resolve_agent_bin(args.agent, args.agent_bin.as_deref());
743 if let Err(err) = start_run_tmux(
744 &tmux_bin,
745 &agent_bin,
746 args.agent,
747 &context.state_dir,
748 &created.record,
749 &args.agent_args,
750 ) {
751 cleanup_created_record(&created);
752 return Err(err);
753 }
754
755 let result = session_view(
756 context,
757 &created.record,
758 Some("running".to_string()),
759 Some(&tmux_bin),
760 );
761 record_workdir_usage(context, &cwd);
762 Ok(StartView {
763 format: args.format,
764 result,
765 })
766}
767
768pub(crate) fn start_provider_resume_session(
769 context: &CliContext,
770 args: ProviderResumeImportArgs,
771) -> Result<StartView, CliError> {
772 validate_provider_resume_import_agent_args(args.agent, &args.agent_args)?;
773 validate_agent_args(args.agent, &args.agent_args)?;
774 let provider_resume_id = normalize_provider_resume_id(&args.provider_resume_id)?;
775 let source = resolve_provider_resume_source(args.agent, &provider_resume_id)?;
776 let cwd = resolve_cwd(Some(&source.cwd))?;
777 let cwd_string = display_path(&cwd);
778 let resume_args = canonical_provider_resume_args(args.agent, &cwd_string, &provider_resume_id)
779 .ok_or_else(|| {
780 CliError::usage(
781 "unsupported-provider-resume-agent",
782 format!(
783 "{} sessions cannot be imported by provider resume id",
784 args.agent.as_str()
785 ),
786 Some(json!({ "agent": args.agent.as_str() })),
787 )
788 })?;
789 let provider_resume = ProviderResume {
790 provider: args.agent.as_str().to_string(),
791 session_id: provider_resume_id,
792 captured_at: Zoned::now().timestamp().to_string(),
793 capture_method: source.capture_method,
794 resume_args,
795 extra: BTreeMap::new(),
796 };
797 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
798 let agent_bin = resolve_agent_bin(args.agent, args.agent_bin.as_deref());
799 let created = create_record(RecordRequest {
800 context,
801 agent: args.agent,
802 mode: "interactive",
803 title: args.title.as_deref(),
804 explicit_id: args.id.as_deref(),
805 cwd: &cwd,
806 prompt: None,
807 log_file_name: None,
808 provider_resume: Some(provider_resume.clone()),
809 agent_args: args.agent_args,
810 agent_bin: Some(display_path(&agent_bin)),
811 })?;
812
813 if let Err(err) = start_resume_tmux(
814 &tmux_bin,
815 &agent_bin,
816 &context.state_dir,
817 &created.record,
818 &provider_resume.resume_args,
819 ) {
820 cleanup_created_record(&created);
821 return Err(err);
822 }
823
824 let result = session_view(
825 context,
826 &created.record,
827 Some("running".to_string()),
828 Some(&tmux_bin),
829 );
830 record_workdir_usage(context, &cwd);
831 Ok(StartView {
832 format: args.format,
833 result,
834 })
835}
836
837struct CreatedRecord {
838 record: SessionRecord,
839 prompt_file: Option<PathBuf>,
840 session_dir: PathBuf,
841}
842
843struct RecordRequest<'a> {
844 context: &'a CliContext,
845 agent: AgentKind,
846 mode: &'a str,
847 title: Option<&'a str>,
848 explicit_id: Option<&'a str>,
849 cwd: &'a Path,
850 prompt: Option<&'a str>,
851 log_file_name: Option<&'a str>,
852 provider_resume: Option<ProviderResume>,
853 agent_args: Vec<String>,
854 agent_bin: Option<String>,
855}
856
857fn create_record(request: RecordRequest<'_>) -> Result<CreatedRecord, CliError> {
858 let now = Zoned::now();
859 let timestamp = now.strftime("%Y%m%d-%H%M%S").to_string();
860 let iso = now.timestamp().to_string();
861 let title_slug = request.title.map(slugify);
862 let id = resolve_session_id(
863 request.context,
864 request.explicit_id,
865 request.agent,
866 ×tamp,
867 title_slug.as_deref(),
868 )?;
869 let tmux_session = format!("hs-{}-{id}", request.agent.as_str());
870 let session_dir = session_dir(request.context, &id);
871 private_dir(&session_dir)?;
872
873 let prompt_file = match request.prompt {
874 Some(prompt) => {
875 let path = session_dir.join("prompt.md");
876 write_private_file(&path, prompt.as_bytes())?;
877 Some(path)
878 }
879 None => None,
880 };
881 let log_file = request.log_file_name.map(|name| session_dir.join(name));
882 let record = SessionRecord {
883 schema_version: SESSION_DOCUMENT_VERSION.to_string(),
884 id,
885 agent: request.agent.as_str().to_string(),
886 mode: request.mode.to_string(),
887 title: request.title.map(str::to_string),
888 cwd: display_path(request.cwd),
889 tmux_session: tmux_session.clone(),
890 prompt_file: prompt_file.as_ref().map(|path| display_path(path)),
891 log_file: log_file.as_ref().map(|path| display_path(path)),
892 created_at: iso.clone(),
893 updated_at: iso,
894 provider_resume: request.provider_resume,
895 runtime: Some(RuntimeInfo {
896 kind: "tmux".to_string(),
897 tmux_session: tmux_session.clone(),
898 generation: 1,
899 started_at: now.timestamp().to_string(),
900 launch_id: uuid::Uuid::new_v4().to_string(),
901 extra: BTreeMap::new(),
902 }),
903 agent_args: request.agent_args,
904 agent_bin: request.agent_bin,
905 extra: BTreeMap::new(),
906 resume_sidecar_extra: BTreeMap::new(),
907 };
908
909 write_session_record(request.context, &record)?;
910 if let Err(err) = activity::activate_runtime(request.context, &record) {
911 let _ = fs::remove_dir_all(&session_dir);
912 return Err(err);
913 }
914 Ok(CreatedRecord {
915 record,
916 prompt_file,
917 session_dir,
918 })
919}
920
921fn cleanup_created_record(created: &CreatedRecord) {
922 let _ = fs::remove_dir_all(&created.session_dir);
923}
924
925#[derive(Debug, Default)]
926struct InitialProviderPlan {
927 provider_resume: Option<ProviderResume>,
928 launch_args: Vec<String>,
929}
930
931fn initial_provider_resume_plan(agent: AgentKind, _cwd: &Path) -> InitialProviderPlan {
932 match agent {
933 AgentKind::Claude => {
934 let session_id = uuid::Uuid::new_v4().to_string();
935 InitialProviderPlan {
936 provider_resume: Some(ProviderResume {
937 provider: agent.as_str().to_string(),
938 session_id: session_id.clone(),
939 captured_at: Zoned::now().timestamp().to_string(),
940 capture_method: "claude-explicit-session-id".to_string(),
941 resume_args: vec!["--resume".to_string(), session_id.clone()],
942 extra: BTreeMap::new(),
943 }),
944 launch_args: vec!["--session-id".to_string(), session_id],
945 }
946 }
947 AgentKind::Codex => InitialProviderPlan::default(),
948 AgentKind::Hermes => InitialProviderPlan::default(),
949 }
950}
951
952fn validate_agent_args(agent: AgentKind, args: &[String]) -> Result<(), CliError> {
953 if agent != AgentKind::Claude {
954 return Ok(());
955 }
956 for arg in args {
957 if let Some(flag) = reserved_claude_resume_arg(arg) {
958 return Err(CliError::usage(
959 "reserved-agent-arg",
960 format!(
961 "{flag} is managed by agent-session for durable Claude resume; do not pass it via --agent-arg"
962 ),
963 Some(json!({ "agent": agent.as_str(), "flag": flag })),
964 ));
965 }
966 }
967 Ok(())
968}
969
970fn validate_provider_resume_import_agent_args(
971 agent: AgentKind,
972 args: &[String],
973) -> Result<(), CliError> {
974 if args.is_empty() {
975 return Ok(());
976 }
977 Err(CliError::usage(
978 "provider-resume-agent-args-conflict",
979 "provider_resume_id mode owns the resume command; omit agent_args",
980 Some(json!({ "agent": agent.as_str() })),
981 ))
982}
983
984fn reserved_claude_resume_arg(arg: &str) -> Option<&'static str> {
985 [
986 ("--session-id", false),
987 ("--resume", false),
988 ("-r", true),
989 ("--continue", false),
990 ("-c", false),
991 ("--fork-session", false),
992 ("--from-pr", false),
993 ]
994 .into_iter()
995 .find_map(|(flag, allow_attached_short_value)| {
996 reserved_agent_arg_matches(arg, flag, allow_attached_short_value).then_some(flag)
997 })
998}
999
1000fn reserved_codex_resume_arg(arg: &str) -> Option<&'static str> {
1001 [
1002 ("--cd", false),
1003 ("-C", true),
1004 ("--last", false),
1005 ("--all", false),
1006 ("--include-non-interactive", false),
1007 ]
1008 .into_iter()
1009 .find_map(|(flag, allow_attached_short_value)| {
1010 reserved_agent_arg_matches(arg, flag, allow_attached_short_value).then_some(flag)
1011 })
1012}
1013
1014fn reserved_agent_arg_matches(
1015 arg: &str,
1016 flag: &'static str,
1017 allow_attached_short_value: bool,
1018) -> bool {
1019 if arg == flag {
1020 return true;
1021 }
1022 arg.strip_prefix(flag).is_some_and(|rest| {
1023 rest.starts_with('=')
1024 || (allow_attached_short_value
1025 && flag.starts_with('-')
1026 && !flag.starts_with("--")
1027 && !rest.is_empty())
1028 })
1029}
1030
1031struct ProviderResumeSource {
1032 cwd: PathBuf,
1033 capture_method: String,
1034}
1035
1036fn normalize_provider_resume_id(session_id: &str) -> Result<String, CliError> {
1037 normalize_resume_id(session_id).map_err(|err| {
1038 let message = match err {
1039 ResumeIdError::Empty => "provider resume id must not be empty",
1040 ResumeIdError::ControlChar => "provider resume id must not contain control characters",
1041 };
1042 CliError::usage("invalid-provider-resume-id", message, None)
1043 })
1044}
1045
1046fn resolve_provider_resume_source(
1047 agent: AgentKind,
1048 session_id: &str,
1049) -> Result<ProviderResumeSource, CliError> {
1050 let provider = match agent {
1051 AgentKind::Codex => ResumeProvider::Codex,
1052 AgentKind::Claude => ResumeProvider::Claude,
1053 AgentKind::Hermes => {
1054 return Err(CliError::usage(
1055 "unsupported-provider-resume-agent",
1056 "hermes sessions cannot be imported by provider resume id",
1057 Some(json!({ "agent": agent.as_str() })),
1058 ));
1059 }
1060 };
1061 match resolve_resume_source(provider, session_id) {
1064 Ok(resolved) => Ok(ProviderResumeSource {
1065 cwd: resolved.cwd,
1066 capture_method: resolved.capture_method.to_string(),
1067 }),
1068 Err(ResumeResolveError::NotFound) => Err(provider_resume_not_found(agent, session_id)),
1069 Err(ResumeResolveError::Ambiguous { cwd_count }) => {
1070 Err(provider_resume_ambiguous(agent, session_id, cwd_count))
1071 }
1072 Err(ResumeResolveError::Truncated) => {
1073 Err(provider_resume_scan_truncated(agent, session_id))
1074 }
1075 }
1076}
1077
1078fn provider_resume_not_found(agent: AgentKind, session_id: &str) -> CliError {
1079 CliError::data(
1080 "provider-resume-not-found",
1081 format!(
1082 "no {} provider history contains resume id: {session_id}",
1083 agent.as_str()
1084 ),
1085 Some(json!({ "agent": agent.as_str(), "provider_resume_id": session_id })),
1086 )
1087}
1088
1089fn provider_resume_ambiguous(agent: AgentKind, session_id: &str, cwd_count: usize) -> CliError {
1090 CliError::data(
1091 "provider-resume-ambiguous",
1092 format!(
1093 "{} provider history has multiple cwd matches for resume id: {session_id}",
1094 agent.as_str()
1095 ),
1096 Some(json!({
1097 "agent": agent.as_str(),
1098 "provider_resume_id": session_id,
1099 "cwd_count": cwd_count,
1100 })),
1101 )
1102}
1103
1104fn provider_resume_scan_truncated(agent: AgentKind, session_id: &str) -> CliError {
1105 CliError::runtime(
1106 "provider-resume-scan-truncated",
1107 format!(
1108 "{} provider history scan was truncated before resume id could be resolved: {session_id}",
1109 agent.as_str()
1110 ),
1111 Some(json!({ "agent": agent.as_str(), "provider_resume_id": session_id })),
1112 )
1113}
1114
1115pub(crate) fn resolve_provider_transcript_path_from_roots(
1116 agent: AgentKind,
1117 session_id: &str,
1118 codex_root: Option<&Path>,
1119 claude_root: Option<&Path>,
1120) -> Option<PathBuf> {
1121 let mut matches = BTreeSet::new();
1122 let truncated = match agent {
1123 AgentKind::Codex => {
1124 let mut budget = CodexResumeScanBudget::from_env();
1125 collect_codex_provider_resume_matches(
1126 codex_root?,
1127 0,
1128 session_id,
1129 &mut matches,
1130 &mut budget,
1131 );
1132 budget.truncated
1133 }
1134 AgentKind::Claude => {
1135 let mut budget = ClaudeResumeScanBudget::from_env();
1136 collect_claude_provider_resume_matches(
1137 claude_root?,
1138 session_id,
1139 &mut matches,
1140 &mut budget,
1141 );
1142 budget.truncated
1143 }
1144 AgentKind::Hermes => return None,
1145 };
1146 if truncated || matches.len() != 1 {
1147 return None;
1148 }
1149 matches.into_iter().next().map(|candidate| candidate.path)
1150}
1151
1152fn tmux_scope_runner() -> Option<PathBuf> {
1169 if !env_truthy("AGENT_SESSION_TMUX_SCOPE") {
1170 return None;
1171 }
1172 if !cfg!(target_os = "linux") {
1173 return None;
1174 }
1175 let runtime_dir = env::var_os("XDG_RUNTIME_DIR")?;
1178 if !Path::new(&runtime_dir)
1179 .join("systemd")
1180 .join("private")
1181 .exists()
1182 {
1183 return None;
1184 }
1185 binary_on_path("systemd-run")
1186}
1187
1188fn new_session_command(tmux_bin: &Path, scope_runner: Option<&Path>) -> ProcessCommand {
1195 match scope_runner {
1196 Some(runner) => {
1197 let mut command = ProcessCommand::new(runner);
1198 command
1199 .arg("--user")
1200 .arg("--scope")
1201 .arg("--quiet")
1202 .arg("--collect")
1203 .arg("--")
1204 .arg(tmux_bin);
1205 command
1206 }
1207 None => ProcessCommand::new(tmux_bin),
1208 }
1209}
1210
1211fn start_interactive_tmux(
1212 tmux_bin: &Path,
1213 agent_bin: &Path,
1214 agent: AgentKind,
1215 state_dir: &Path,
1216 record: &SessionRecord,
1217 provider_launch_args: &[String],
1218 agent_args: &[String],
1219) -> Result<(), CliError> {
1220 let mut command = new_session_command(tmux_bin, tmux_scope_runner().as_deref());
1221 command
1222 .arg("new-session")
1223 .arg("-d")
1224 .arg("-s")
1225 .arg(&record.tmux_session)
1226 .arg("-c")
1227 .arg(&record.cwd);
1228 add_runtime_tmux_environment(&mut command, state_dir, record)?;
1229 command.arg("--").arg(agent_bin);
1230
1231 match agent {
1232 AgentKind::Codex => {
1233 command.arg("--cd").arg(&record.cwd).arg("--no-alt-screen");
1234 }
1235 AgentKind::Claude => {
1236 command.args(provider_launch_args);
1237 if let Some(title) = record
1238 .title
1239 .as_deref()
1240 .filter(|value| !value.trim().is_empty())
1241 {
1242 command.arg("--name").arg(title);
1243 }
1244 }
1245 AgentKind::Hermes => {
1246 command.arg("chat");
1247 }
1248 }
1249 command.args(agent_args);
1250 run_status(command, "tmux new-session")
1251}
1252
1253fn start_run_tmux(
1254 tmux_bin: &Path,
1255 agent_bin: &Path,
1256 agent: AgentKind,
1257 state_dir: &Path,
1258 record: &SessionRecord,
1259 agent_args: &[String],
1260) -> Result<(), CliError> {
1261 let prompt_file = record.prompt_file.as_ref().ok_or_else(|| {
1262 CliError::runtime(
1263 "missing-prompt-file",
1264 "session prompt file is missing",
1265 None,
1266 )
1267 })?;
1268 let log_file = record.log_file.as_ref().ok_or_else(|| {
1269 CliError::runtime("missing-log-file", "session log file is missing", None)
1270 })?;
1271 let mut parts = Vec::new();
1272 parts.push(shell_words::quote(&display_path(agent_bin)).into_owned());
1273 match agent {
1274 AgentKind::Codex => {
1275 parts.push("exec".to_string());
1276 parts.push("--cd".to_string());
1277 parts.push(shell_words::quote(&record.cwd).into_owned());
1278 }
1279 AgentKind::Claude => {
1280 parts.push("-p".to_string());
1281 }
1282 AgentKind::Hermes => {
1283 return Err(CliError::usage(
1284 "unsupported-run-agent",
1285 "hermes does not support one-shot run mode; use start --agent hermes",
1286 None,
1287 ));
1288 }
1289 }
1290 parts.extend(
1291 agent_args
1292 .iter()
1293 .map(|arg| shell_words::quote(arg).into_owned()),
1294 );
1295 parts.push(format!("\"$(cat {})\"", shell_words::quote(prompt_file)));
1296 let script = format!(
1297 "set -u\n{} > {} 2>&1\n",
1298 parts.join(" "),
1299 shell_words::quote(log_file)
1300 );
1301
1302 let mut command = new_session_command(tmux_bin, tmux_scope_runner().as_deref());
1303 command
1304 .arg("new-session")
1305 .arg("-d")
1306 .arg("-s")
1307 .arg(&record.tmux_session)
1308 .arg("-c")
1309 .arg(&record.cwd);
1310 add_runtime_tmux_environment(&mut command, state_dir, record)?;
1311 command.arg("--").arg("sh").arg("-lc").arg(script);
1312 run_status(command, "tmux new-session")
1313}
1314
1315fn capture_provider_resume_after_launch(
1316 agent: AgentKind,
1317 record: &SessionRecord,
1318 launch_started_at: SystemTime,
1319) -> Option<ProviderResume> {
1320 match agent {
1321 AgentKind::Codex => capture_codex_resume(record, launch_started_at),
1322 AgentKind::Claude | AgentKind::Hermes => None,
1323 }
1324}
1325
1326#[derive(Debug)]
1327struct CodexResumeCandidate {
1328 session_id: String,
1329 created_at: SystemTime,
1330}
1331
1332fn capture_codex_resume(
1333 record: &SessionRecord,
1334 launch_started_at: SystemTime,
1335) -> Option<ProviderResume> {
1336 let root = codex_sessions_root()?;
1337 let timeout = Duration::from_millis(env_u64(
1338 "AGENT_SESSION_CODEX_CAPTURE_TIMEOUT_MS",
1339 CODEX_RESUME_CAPTURE_TIMEOUT_MS,
1340 ));
1341 let poll = Duration::from_millis(
1342 env_u64(
1343 "AGENT_SESSION_CODEX_CAPTURE_POLL_MS",
1344 CODEX_RESUME_CAPTURE_POLL_MS,
1345 )
1346 .max(1),
1347 );
1348 let ambiguity_window = Duration::from_millis(env_u64(
1349 "AGENT_SESSION_CODEX_AMBIGUITY_WINDOW_MS",
1350 CODEX_RESUME_AMBIGUITY_WINDOW_MS,
1351 ));
1352 let started = Instant::now();
1353 let mut observed_singleton: Option<(String, Instant)> = None;
1354
1355 loop {
1356 let mut candidates = Vec::new();
1357 let mut budget = CodexResumeScanBudget::from_env();
1358 collect_codex_resume_candidates(
1359 &root,
1360 0,
1361 launch_started_at,
1362 &record.cwd,
1363 &mut candidates,
1364 &mut budget,
1365 );
1366 if budget.truncated {
1367 return None;
1368 }
1369
1370 let candidate_ids: BTreeSet<String> = candidates
1371 .into_iter()
1372 .map(|candidate| candidate.session_id)
1373 .collect();
1374 match candidate_ids.len() {
1375 1 => {
1376 let candidate_id = candidate_ids.iter().next().expect("singleton candidate");
1377 match &observed_singleton {
1378 Some((observed, first_seen_at)) if observed == candidate_id => {
1379 if codex_candidate_satisfied_ambiguity_window(
1380 *first_seen_at,
1381 ambiguity_window,
1382 ) {
1383 return Some(codex_provider_resume(record, candidate_id));
1384 }
1385 }
1386 Some(_) => return None,
1387 None => observed_singleton = Some((candidate_id.clone(), Instant::now())),
1388 }
1389 }
1390 0 => {
1391 if observed_singleton.is_some() {
1392 return None;
1393 }
1394 }
1395 _ => return None,
1396 }
1397 if timeout.is_zero() || started.elapsed() >= timeout {
1398 return observed_singleton
1399 .as_ref()
1400 .filter(|(_, first_seen_at)| {
1401 codex_candidate_satisfied_ambiguity_window(*first_seen_at, ambiguity_window)
1402 })
1403 .map(|(session_id, _)| codex_provider_resume(record, session_id));
1404 }
1405 let remaining = timeout.saturating_sub(started.elapsed());
1406 thread::sleep(poll.min(remaining));
1407 }
1408}
1409
1410fn capture_codex_resume_from_history(record: &SessionRecord) -> Option<ProviderResume> {
1411 let root = codex_sessions_root()?;
1412 let earliest = record
1413 .created_at
1414 .parse::<Timestamp>()
1415 .ok()
1416 .map(SystemTime::from)?;
1417 let latest = earliest.checked_add(Duration::from_secs(CODEX_RESUME_BACKFILL_MAX_AGE_SECS))?;
1418 let mut candidates = Vec::new();
1419 let mut budget = CodexResumeScanBudget::from_env();
1420 collect_codex_resume_candidates(
1421 &root,
1422 0,
1423 earliest,
1424 &record.cwd,
1425 &mut candidates,
1426 &mut budget,
1427 );
1428 if budget.truncated {
1429 return None;
1430 }
1431
1432 let candidate_ids: BTreeSet<String> = candidates
1433 .into_iter()
1434 .filter(|candidate| candidate.created_at <= latest)
1435 .map(|candidate| candidate.session_id)
1436 .collect();
1437 if candidate_ids.len() == 1 {
1438 let candidate_id = candidate_ids.iter().next().expect("singleton candidate");
1439 return Some(codex_provider_resume(record, candidate_id));
1440 }
1441 None
1442}
1443
1444fn codex_candidate_satisfied_ambiguity_window(
1445 first_seen_at: Instant,
1446 ambiguity_window: Duration,
1447) -> bool {
1448 ambiguity_window.is_zero() || first_seen_at.elapsed() >= ambiguity_window
1449}
1450
1451fn codex_provider_resume(record: &SessionRecord, session_id: &str) -> ProviderResume {
1452 ProviderResume {
1453 provider: "codex".to_string(),
1454 session_id: session_id.to_string(),
1455 captured_at: Zoned::now().timestamp().to_string(),
1456 capture_method: "codex-session-meta".to_string(),
1457 resume_args: canonical_provider_resume_args(AgentKind::Codex, &record.cwd, session_id)
1458 .expect("codex resume args"),
1459 extra: BTreeMap::new(),
1460 }
1461}
1462
1463fn collect_codex_resume_candidates(
1464 dir: &Path,
1465 depth: usize,
1466 earliest: SystemTime,
1467 cwd: &str,
1468 candidates: &mut Vec<CodexResumeCandidate>,
1469 budget: &mut CodexResumeScanBudget,
1470) {
1471 if depth > CODEX_RESUME_SCAN_MAX_DEPTH {
1472 return;
1473 }
1474 let Ok(entries) = fs::read_dir(dir) else {
1475 return;
1476 };
1477 for entry in entries.flatten() {
1478 if !budget.visit_entry() {
1479 return;
1480 }
1481 let path = entry.path();
1482 let Ok(file_type) = entry.file_type() else {
1483 continue;
1484 };
1485 if file_type.is_dir() {
1486 collect_codex_resume_candidates(&path, depth + 1, earliest, cwd, candidates, budget);
1487 if budget.truncated {
1488 return;
1489 }
1490 continue;
1491 }
1492 if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
1493 continue;
1494 }
1495 let modified_at = entry
1496 .metadata()
1497 .and_then(|metadata| metadata.modified())
1498 .unwrap_or(SystemTime::UNIX_EPOCH);
1499 if modified_at < earliest {
1500 continue;
1501 }
1502 if let Some(meta) = read_codex_session_meta(&path) {
1503 if meta.cwd != cwd {
1504 continue;
1505 }
1506 if meta.created_at < earliest {
1507 continue;
1508 }
1509 candidates.push(CodexResumeCandidate {
1510 session_id: meta.session_id,
1511 created_at: meta.created_at,
1512 });
1513 }
1514 }
1515}
1516
1517fn paste_prompt(tmux_bin: &Path, record: &SessionRecord) -> Result<(), CliError> {
1518 let prompt_file = record.prompt_file.as_ref().ok_or_else(|| {
1519 CliError::runtime(
1520 "missing-prompt-file",
1521 "session prompt file is missing",
1522 None,
1523 )
1524 })?;
1525 let buffer_name = format!("{}-prompt", record.id);
1526 let target = format!("{}:0.0", record.tmux_session);
1527
1528 load_and_paste_buffer(tmux_bin, &buffer_name, &target, Path::new(prompt_file))?;
1529
1530 let mut enter = ProcessCommand::new(tmux_bin);
1533 enter.arg("send-keys").arg("-t").arg(&target).arg("Enter");
1534 run_status(enter, "tmux send-keys")
1535}
1536
1537fn load_and_paste_buffer(
1541 tmux_bin: &Path,
1542 buffer_name: &str,
1543 target: &str,
1544 file: &Path,
1545) -> Result<(), CliError> {
1546 let mut load = ProcessCommand::new(tmux_bin);
1547 load.arg("load-buffer").arg("-b").arg(buffer_name).arg(file);
1548 run_status(load, "tmux load-buffer")?;
1549
1550 let mut paste = ProcessCommand::new(tmux_bin);
1551 paste
1552 .arg("paste-buffer")
1553 .arg("-b")
1554 .arg(buffer_name)
1555 .arg("-d")
1556 .arg("-t")
1557 .arg(target);
1558 if let Err(err) = run_status(paste, "tmux paste-buffer") {
1559 delete_tmux_buffer(tmux_bin, buffer_name);
1560 return Err(err);
1561 }
1562 Ok(())
1563}
1564
1565fn delete_tmux_buffer(tmux_bin: &Path, buffer_name: &str) {
1566 let _ = ProcessCommand::new(tmux_bin)
1567 .arg("delete-buffer")
1568 .arg("-b")
1569 .arg(buffer_name)
1570 .status();
1571}
1572
1573fn send_to_session(context: &CliContext, args: cli::SendArgs) -> Result<SendResult, CliError> {
1574 let text = read_send_text(&args.text, args.text_stdin)?;
1575 if text.is_none() && args.keys.is_empty() {
1576 return Err(CliError::usage(
1577 "empty-send",
1578 "send requires --text, --text-stdin, or at least one --key",
1579 None,
1580 ));
1581 }
1582 let mut record = load_session_record(context, &args.id)?;
1583 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
1584 if live_status(&tmux_bin, &record.tmux_session) != "running" {
1585 return Err(CliError::runtime(
1586 "session-not-running",
1587 format!("session is not running: {}", record.id),
1588 Some(json!({ "id": record.id })),
1589 ));
1590 }
1591 send_input(context, &record, text.as_deref(), &args.keys, &tmux_bin)?;
1592 touch_updated_at(context, &mut record)?;
1593 Ok(SendResult {
1594 id: record.id.clone(),
1595 tmux_session: record.tmux_session.clone(),
1596 sent_text: text.is_some(),
1597 keys: args
1598 .keys
1599 .iter()
1600 .map(|key| key.as_str().to_string())
1601 .collect(),
1602 })
1603}
1604
1605fn send_input(
1609 context: &CliContext,
1610 record: &SessionRecord,
1611 text: Option<&str>,
1612 keys: &[SpecialKey],
1613 tmux_bin: &Path,
1614) -> Result<(), CliError> {
1615 let target = format!("{}:0.0", record.tmux_session);
1616 if let Some(text) = text {
1617 let buffer_name = format!("{}-send", record.id);
1618 let temp = session_dir(context, &record.id).join("send-input");
1619 write_private_file(&temp, text.as_bytes())?;
1620 let result = load_and_paste_buffer(tmux_bin, &buffer_name, &target, &temp);
1621 let _ = fs::remove_file(&temp);
1622 result?;
1623 }
1624 for key in keys {
1625 let mut command = ProcessCommand::new(tmux_bin);
1626 command
1627 .arg("send-keys")
1628 .arg("-t")
1629 .arg(&target)
1630 .arg(key.tmux_key());
1631 run_status(command, "tmux send-keys")?;
1632 }
1633 Ok(())
1634}
1635
1636fn read_send_text(text: &Option<String>, text_stdin: bool) -> Result<Option<String>, CliError> {
1637 match (text, text_stdin) {
1642 (Some(_), true) => Err(CliError::usage(
1643 "multiple-text-sources",
1644 "use only one of --text or --text-stdin",
1645 None,
1646 )),
1647 (Some(value), false) => Ok(Some(value.clone()).filter(|value| !value.is_empty())),
1648 (None, true) => {
1649 let mut input = String::new();
1650 io::stdin().read_to_string(&mut input).map_err(|err| {
1651 CliError::runtime(
1652 "stdin-read-failed",
1653 format!("failed to read stdin: {err}"),
1654 None,
1655 )
1656 })?;
1657 Ok(Some(input).filter(|value| !value.is_empty()))
1658 }
1659 (None, false) => Ok(None),
1660 }
1661}
1662
1663fn glance_session(context: &CliContext, args: cli::GlanceArgs) -> Result<GlanceResult, CliError> {
1664 let record = load_session_record_with_provider_resume_backfill(context, &args.id)?;
1665 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
1666 let status = session_status(&tmux_bin, &record);
1667 let tail = if status == "running" {
1668 capture_pane_tail(&record, args.tail, &tmux_bin)?
1669 } else {
1670 String::new()
1671 };
1672 let last_terminal_activity_at = last_terminal_activity_at(&tmux_bin, &record, &status);
1673 Ok(GlanceResult {
1674 id: record.id.clone(),
1675 agent: record.agent.clone(),
1676 title: record.title.clone(),
1677 tmux_session: record.tmux_session.clone(),
1678 status,
1679 resumable: is_resumable(&record),
1680 repo_name: repo_name_from_cwd(&record.cwd),
1681 provider_resume: record
1682 .provider_resume
1683 .as_ref()
1684 .map(ProviderResumeView::from),
1685 tail,
1686 created_at: record.created_at.clone(),
1687 updated_at: record.updated_at.clone(),
1688 last_terminal_activity_at,
1689 runtime_started_at: record
1690 .runtime
1691 .as_ref()
1692 .map(|runtime| runtime.started_at.clone()),
1693 turn_state: activity::state_for_view(context, &record),
1694 })
1695}
1696
1697fn run_capture_pane(
1702 record: &SessionRecord,
1703 tail: usize,
1704 tmux_bin: &Path,
1705) -> Result<Option<String>, CliError> {
1706 let start = format!("-{}", tail.max(1));
1707 let output = ProcessCommand::new(tmux_bin)
1708 .arg("capture-pane")
1709 .arg("-p")
1710 .arg("-t")
1711 .arg(&record.tmux_session)
1712 .arg("-S")
1713 .arg(start)
1714 .output()
1715 .map_err(|err| {
1716 CliError::runtime(
1717 "tmux-capture-failed",
1718 format!("failed to run {}: {err}", tmux_bin.display()),
1719 Some(json!({ "tmux_session": record.tmux_session })),
1720 )
1721 })?;
1722 if !output.status.success() {
1723 return Ok(None);
1724 }
1725 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
1726}
1727
1728fn session_clipboard_buffer(
1737 context: &CliContext,
1738 id: &str,
1739 tmux_bin: &Path,
1740) -> Result<String, CliError> {
1741 let _record = load_session_record(context, id)?;
1743 let output = ProcessCommand::new(tmux_bin)
1744 .arg("show-buffer")
1745 .output()
1746 .map_err(|err| {
1747 CliError::runtime(
1748 "tmux-show-buffer-failed",
1749 format!("failed to run {}: {err}", tmux_bin.display()),
1750 None,
1751 )
1752 })?;
1753 if !output.status.success() {
1754 return Ok(String::new());
1755 }
1756 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1757}
1758
1759fn capture_pane_tail(
1760 record: &SessionRecord,
1761 tail: usize,
1762 tmux_bin: &Path,
1763) -> Result<String, CliError> {
1764 match run_capture_pane(record, tail, tmux_bin)? {
1765 Some(text) => Ok(tail_lines(&strip_trailing_blank_lines(&text), tail)),
1766 None => Err(CliError::runtime(
1767 "tmux-capture-failed",
1768 "tmux capture-pane failed",
1769 Some(json!({ "tmux_session": record.tmux_session })),
1770 )),
1771 }
1772}
1773
1774fn strip_trailing_blank_lines(text: &str) -> String {
1779 let mut lines: Vec<&str> = text.lines().collect();
1780 while lines.last().is_some_and(|line| line.trim().is_empty()) {
1781 lines.pop();
1782 }
1783 lines.join("\n")
1784}
1785
1786#[cfg(test)]
1787mod codex_resume_tests {
1788 use super::*;
1789
1790 #[test]
1791 fn codex_resume_scan_truncates_large_stale_tree_by_entry_budget() {
1792 let tmp = tempfile::TempDir::new().unwrap();
1793 let root = tmp.path().join("sessions");
1794 fs::create_dir_all(&root).unwrap();
1795 for index in 0..32 {
1796 fs::write(
1797 root.join(format!("stale-{index}.jsonl")),
1798 r#"{"timestamp":"2000-01-01T00:00:00Z","type":"session_meta","payload":{"id":"old","cwd":"/repo","source":"cli","timestamp":"2000-01-01T00:00:00Z"}}"#,
1799 )
1800 .unwrap();
1801 }
1802
1803 let mut candidates = Vec::new();
1804 let mut budget = CodexResumeScanBudget {
1805 visited: 0,
1806 max_entries: 5,
1807 deadline: Instant::now() + Duration::from_secs(60),
1808 truncated: false,
1809 };
1810 collect_codex_resume_candidates(
1811 &root,
1812 0,
1813 SystemTime::now(),
1814 "/repo",
1815 &mut candidates,
1816 &mut budget,
1817 );
1818
1819 assert_eq!(budget.visited, 5);
1820 assert!(budget.truncated);
1821 assert!(candidates.is_empty());
1822 }
1823}
1824
1825fn touch_updated_at(context: &CliContext, record: &mut SessionRecord) -> Result<(), CliError> {
1830 record.updated_at = Zoned::now().timestamp().to_string();
1831 write_session_record(context, record)
1832}
1833
1834fn update_session_title(
1835 context: &CliContext,
1836 id: &str,
1837 title: Option<String>,
1838 tmux_bin: &Path,
1839) -> Result<SessionView, CliError> {
1840 let mut record = load_session_record(context, id)?;
1841 let previous_title = record.title.clone();
1842 record.title = normalize_title(title)?;
1843 touch_updated_at(context, &mut record)?;
1844 let status = session_status(tmux_bin, &record);
1845 if status == "running"
1854 && AgentKind::from_name(&record.agent) == Some(AgentKind::Claude)
1855 && record.title != previous_title
1856 && let Some(new_title) = record.title.as_deref()
1857 {
1858 let _ = rename_live_claude_session(context, &record, new_title, tmux_bin);
1859 }
1860 Ok(session_view(context, &record, Some(status), Some(tmux_bin)))
1861}
1862
1863fn rename_live_claude_session(
1868 context: &CliContext,
1869 record: &SessionRecord,
1870 title: &str,
1871 tmux_bin: &Path,
1872) -> Result<(), CliError> {
1873 let single_line = title.replace(['\n', '\r'], " ");
1874 let command = format!("/rename {single_line}");
1875 send_input(
1876 context,
1877 record,
1878 Some(&command),
1879 &[SpecialKey::Enter],
1880 tmux_bin,
1881 )
1882}
1883
1884fn resume_session(context: &CliContext, args: cli::ResumeArgs) -> Result<SessionView, CliError> {
1885 let tmux_bin = resolve_tmux_bin(args.tmux_bin.as_deref());
1886 resume_session_by_id(context, &args.id, &tmux_bin)
1887}
1888
1889fn resume_session_by_id(
1890 context: &CliContext,
1891 id: &str,
1892 tmux_bin: &Path,
1893) -> Result<SessionView, CliError> {
1894 let mut record = load_session_record_with_provider_resume_backfill(context, id)?;
1895 match session_status(tmux_bin, &record).as_str() {
1896 "running" => {
1897 return Ok(session_view(
1898 context,
1899 &record,
1900 Some("running".to_string()),
1901 Some(tmux_bin),
1902 ));
1903 }
1904 "unknown" => {
1905 return Err(CliError::runtime(
1906 "session-status-unknown",
1907 format!("session status could not be checked: {}", record.id),
1908 Some(json!({ "id": record.id })),
1909 ));
1910 }
1911 _ => {}
1912 }
1913 let (provider_resume, agent) = validate_resume_metadata(&record)?;
1914 let previous_record = record.clone();
1915 let previous_activity = activity::capture_snapshot(context, &record.id)?;
1916 let resume_args = provider_resume.resume_args.clone();
1917 let agent_bin = record
1918 .agent_bin
1919 .as_deref()
1920 .map(PathBuf::from)
1921 .unwrap_or_else(|| resolve_agent_bin(agent, None));
1922 let now = Zoned::now();
1923 let next_generation = record
1924 .runtime
1925 .as_ref()
1926 .map(|runtime| runtime.generation.saturating_add(1))
1927 .unwrap_or(1);
1928 record.runtime = Some(RuntimeInfo {
1929 kind: "tmux".to_string(),
1930 tmux_session: record.tmux_session.clone(),
1931 generation: next_generation,
1932 started_at: now.timestamp().to_string(),
1933 launch_id: uuid::Uuid::new_v4().to_string(),
1934 extra: record
1935 .runtime
1936 .as_ref()
1937 .map(|runtime| runtime.extra.clone())
1938 .unwrap_or_default(),
1939 });
1940 record.updated_at = now.timestamp().to_string();
1941 write_session_record(context, &record)?;
1942 activity::activate_runtime(context, &record)?;
1943 if let Err(launch_err) = start_resume_tmux(
1944 tmux_bin,
1945 &agent_bin,
1946 &context.state_dir,
1947 &record,
1948 &resume_args,
1949 ) {
1950 let record_restore = write_session_record(context, &previous_record);
1951 let activity_restore = activity::restore_snapshot(context, &record.id, &previous_activity);
1952 if record_restore.is_err() || activity_restore.is_err() {
1953 return Err(CliError::runtime(
1954 "resume-launch-rollback-failed",
1955 "provider resume launch failed and the prior durable runtime could not be fully restored",
1956 Some(json!({
1957 "id": record.id,
1958 "launch_error": launch_err.code(),
1959 "record_restored": record_restore.is_ok(),
1960 "activity_restored": activity_restore.is_ok()
1961 })),
1962 ));
1963 }
1964 return Err(launch_err);
1965 }
1966 Ok(session_view(
1967 context,
1968 &record,
1969 Some("running".to_string()),
1970 Some(tmux_bin),
1971 ))
1972}
1973
1974fn start_resume_tmux(
1975 tmux_bin: &Path,
1976 agent_bin: &Path,
1977 state_dir: &Path,
1978 record: &SessionRecord,
1979 resume_args: &[String],
1980) -> Result<(), CliError> {
1981 let mut command = new_session_command(tmux_bin, tmux_scope_runner().as_deref());
1982 command
1983 .arg("new-session")
1984 .arg("-d")
1985 .arg("-s")
1986 .arg(&record.tmux_session)
1987 .arg("-c")
1988 .arg(&record.cwd);
1989 add_runtime_tmux_environment(&mut command, state_dir, record)?;
1990 command
1991 .arg("--")
1992 .arg(agent_bin)
1993 .args(resume_args)
1994 .args(&record.agent_args);
1995 run_status(command, "tmux new-session")
1996}
1997
1998fn add_runtime_tmux_environment(
1999 command: &mut ProcessCommand,
2000 state_dir: &Path,
2001 record: &SessionRecord,
2002) -> Result<(), CliError> {
2003 let runtime_id = record
2004 .runtime
2005 .as_ref()
2006 .map(|runtime| runtime.launch_id.as_str())
2007 .filter(|value| !value.is_empty())
2008 .ok_or_else(|| {
2009 CliError::data(
2010 "runtime-id-missing",
2011 "session runtime is missing its launch id",
2012 Some(json!({ "id": record.id })),
2013 )
2014 })?;
2015 for value in [
2016 format!("AGENT_SESSION_ID={}", record.id),
2017 format!("AGENT_SESSION_STATE_DIR={}", display_path(state_dir)),
2018 format!("AGENT_SESSION_RUNTIME_ID={runtime_id}"),
2019 ] {
2020 command.arg("-e").arg(value);
2021 }
2022 Ok(())
2023}
2024
2025fn normalize_title(title: Option<String>) -> Result<Option<String>, CliError> {
2026 let Some(title) = title else {
2027 return Ok(None);
2028 };
2029 let title = title.trim().to_string();
2030 if title.is_empty() {
2031 return Ok(None);
2032 }
2033 if title.chars().count() > 120 {
2034 return Err(CliError::usage(
2035 "title-too-long",
2036 "session title must be 120 characters or fewer",
2037 Some(json!({ "max_chars": 120 })),
2038 ));
2039 }
2040 Ok(Some(title))
2041}
2042
2043fn write_session_attachment(
2044 context: &CliContext,
2045 id: &str,
2046 filename: Option<&str>,
2047 content_type: Option<String>,
2048 bytes: &[u8],
2049) -> Result<AttachmentResult, CliError> {
2050 let record = load_session_record(context, id)?;
2051 let filename = sanitize_attachment_filename(filename.unwrap_or("attachment.bin"));
2052 let dir = session_dir(context, &record.id).join("attachments");
2053 ensure_private_dir(&dir)?;
2054 let path = write_unique_attachment_file(&dir, &filename, bytes)?;
2055 Ok(AttachmentResult {
2056 id: record.id,
2057 filename,
2058 path: display_path(&path),
2059 bytes: bytes.len(),
2060 content_type,
2061 })
2062}
2063
2064fn sanitize_attachment_filename(raw: &str) -> String {
2065 let leaf = Path::new(raw)
2066 .file_name()
2067 .and_then(|value| value.to_str())
2068 .unwrap_or("attachment.bin");
2069 let mut safe = leaf
2070 .chars()
2071 .map(|ch| {
2072 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
2073 ch
2074 } else {
2075 '_'
2076 }
2077 })
2078 .collect::<String>();
2079 safe = safe
2080 .trim_matches(|ch| matches!(ch, '.' | '-' | '_'))
2081 .to_string();
2082 if safe.is_empty() {
2083 safe = "attachment.bin".to_string();
2084 }
2085 if safe.len() > 120 {
2086 safe.truncate(120);
2087 }
2088 safe
2089}
2090
2091fn attachment_candidate_path(dir: &Path, filename: &str, attempt: usize) -> PathBuf {
2092 let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
2093 if attempt == 0 {
2094 dir.join(format!("{stamp}-{filename}"))
2095 } else {
2096 dir.join(format!("{stamp}-{attempt}-{filename}"))
2097 }
2098}
2099
2100fn write_unique_attachment_file(
2101 dir: &Path,
2102 filename: &str,
2103 bytes: &[u8],
2104) -> Result<PathBuf, CliError> {
2105 for attempt in 0..1000 {
2106 let path = attachment_candidate_path(dir, filename, attempt);
2107 let mut options = OpenOptions::new();
2108 options.write(true).create_new(true);
2109 #[cfg(unix)]
2110 {
2111 use std::os::unix::fs::OpenOptionsExt;
2112 options.mode(SECRET_FILE_MODE);
2113 }
2114 let mut file = match options.open(&path) {
2115 Ok(file) => file,
2116 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
2117 Err(err) => {
2118 return Err(CliError::runtime(
2119 "file-write-failed",
2120 format!("failed to write {}: {err}", path.display()),
2121 Some(json!({ "path": display_path(&path) })),
2122 ));
2123 }
2124 };
2125 if let Err(err) = file.write_all(bytes).and_then(|_| file.sync_all()) {
2126 let _ = fs::remove_file(&path);
2127 return Err(CliError::runtime(
2128 "file-write-failed",
2129 format!("failed to write {}: {err}", path.display()),
2130 Some(json!({ "path": display_path(&path) })),
2131 ));
2132 }
2133 return Ok(path);
2134 }
2135 Err(CliError::runtime(
2136 "attachment-name-exhausted",
2137 "failed to allocate a unique attachment filename",
2138 Some(json!({ "filename": filename })),
2139 ))
2140}
2141
2142const WORKDIR_SEARCH_MAX_DEPTH: usize = 4;
2143const WORKDIR_SEARCH_MAX_VISITED: usize = 5000;
2144const WORKDIR_SEARCH_TIMEOUT: Duration = Duration::from_millis(250);
2145
2146fn search_workdirs(
2147 context: &CliContext,
2148 query: Option<&str>,
2149 limit: Option<usize>,
2150 options: WorkdirSearchOptions,
2151) -> Result<Vec<WorkdirResult>, CliError> {
2152 let Some(home) = home_dir() else {
2153 return Ok(Vec::new());
2154 };
2155 let roots = [home.join("Project"), home.join(".config")];
2156 let usage = load_workdir_usage(context);
2157 search_workdirs_in_roots(
2158 &roots,
2159 query.unwrap_or_default(),
2160 limit.unwrap_or(30).clamp(1, 100),
2161 options,
2162 &usage,
2163 )
2164}
2165
2166fn search_workdirs_in_roots(
2167 roots: &[PathBuf],
2168 query: &str,
2169 limit: usize,
2170 options: WorkdirSearchOptions,
2171 usage: &BTreeMap<String, String>,
2172) -> Result<Vec<WorkdirResult>, CliError> {
2173 #[derive(Debug)]
2174 struct Candidate {
2175 result: WorkdirResult,
2176 depth: usize,
2177 }
2178
2179 let query = query.trim().to_ascii_lowercase();
2180 let started = Instant::now();
2181 let mut visited = 0usize;
2182 let mut matches = Vec::new();
2183
2184 for root in roots {
2185 if started.elapsed() >= WORKDIR_SEARCH_TIMEOUT || visited >= WORKDIR_SEARCH_MAX_VISITED {
2186 break;
2187 }
2188 let Ok(root_meta) = fs::symlink_metadata(root) else {
2189 continue;
2190 };
2191 if root_meta.file_type().is_symlink() || !root_meta.is_dir() {
2192 continue;
2193 }
2194 let Ok(canonical_root) = fs::canonicalize(root) else {
2195 continue;
2196 };
2197 let root_display = display_path(root);
2198 let mut queue = VecDeque::from([(root.clone(), 0usize)]);
2199 while let Some((path, depth)) = queue.pop_front() {
2200 if started.elapsed() >= WORKDIR_SEARCH_TIMEOUT || visited >= WORKDIR_SEARCH_MAX_VISITED
2201 {
2202 break;
2203 }
2204 let Ok(canonical_path) = fs::canonicalize(&path) else {
2205 continue;
2206 };
2207 if !canonical_path.starts_with(&canonical_root) {
2208 continue;
2209 }
2210 visited += 1;
2211 let name = path
2212 .file_name()
2213 .and_then(|value| value.to_str())
2214 .unwrap_or_default()
2215 .to_string();
2216 let is_git_repo = is_git_repo(&path);
2217 let is_linked_worktree = is_linked_worktree(&path);
2218 let include = depth > 0
2219 && workdir_matches(&path, &name, &query)
2220 && (!options.git_only || is_git_repo)
2221 && (!options.exclude_worktrees || !is_linked_worktree);
2222 if include {
2223 let path_display = display_path(&path);
2224 matches.push(Candidate {
2225 depth,
2226 result: WorkdirResult {
2227 last_used: usage.get(&path_display).cloned(),
2228 path: path_display,
2229 name,
2230 root: root_display.clone(),
2231 is_git_repo,
2232 },
2233 });
2234 }
2235 if depth >= WORKDIR_SEARCH_MAX_DEPTH {
2236 continue;
2237 }
2238 if options.git_only && is_git_repo {
2239 continue;
2240 }
2241 let Ok(entries) = fs::read_dir(&path) else {
2242 continue;
2243 };
2244 for entry in entries.flatten() {
2245 let Ok(file_type) = entry.file_type() else {
2246 continue;
2247 };
2248 if options.git_only
2249 && entry
2250 .file_name()
2251 .to_str()
2252 .is_some_and(|name| name == ".git")
2253 {
2254 continue;
2255 }
2256 if file_type.is_dir() {
2257 queue.push_back((entry.path(), depth + 1));
2258 }
2259 }
2260 }
2261 }
2262
2263 matches.sort_by(|a, b| {
2264 let base = if options.git_only || options.exclude_worktrees {
2265 b.result
2266 .last_used
2267 .cmp(&a.result.last_used)
2268 .then_with(|| a.result.name.cmp(&b.result.name))
2269 } else {
2270 b.result.is_git_repo.cmp(&a.result.is_git_repo)
2271 };
2272 base.then_with(|| a.depth.cmp(&b.depth))
2273 .then_with(|| a.result.path.cmp(&b.result.path))
2274 });
2275 matches.truncate(limit);
2276 Ok(matches
2277 .into_iter()
2278 .map(|candidate| candidate.result)
2279 .collect())
2280}
2281
2282fn workdir_matches(path: &Path, name: &str, query: &str) -> bool {
2283 if query.is_empty() {
2284 return true;
2285 }
2286 name.to_ascii_lowercase().contains(query)
2287 || path.to_string_lossy().to_ascii_lowercase().contains(query)
2288}
2289
2290fn is_git_repo(path: &Path) -> bool {
2291 path.join(".git").exists()
2292}
2293
2294fn is_linked_worktree(path: &Path) -> bool {
2295 fs::symlink_metadata(path.join(".git"))
2296 .map(|meta| meta.is_file())
2297 .unwrap_or(false)
2298}
2299
2300fn workdir_usage_path(context: &CliContext) -> PathBuf {
2301 context.state_dir.join(WORKDIR_USAGE_FILE)
2302}
2303
2304fn load_workdir_usage(context: &CliContext) -> BTreeMap<String, String> {
2305 let path = workdir_usage_path(context);
2306 let Ok(contents) = fs::read_to_string(path) else {
2307 return BTreeMap::new();
2308 };
2309 serde_json::from_str::<WorkdirUsage>(&contents)
2310 .map(|usage| usage.entries)
2311 .unwrap_or_default()
2312}
2313
2314fn record_workdir_usage(context: &CliContext, cwd: &Path) {
2315 let path = workdir_usage_path(context);
2316 let mut usage = WorkdirUsage {
2317 entries: load_workdir_usage(context),
2318 };
2319 usage
2320 .entries
2321 .insert(display_path(cwd), Zoned::now().timestamp().to_string());
2322 if let Ok(bytes) = serde_json::to_vec_pretty(&usage) {
2323 let _ = write_atomic(&path, &bytes, SECRET_FILE_MODE);
2324 }
2325}
2326
2327fn list_sessions(
2328 context: &CliContext,
2329 tmux_bin: Option<&Path>,
2330) -> Result<Vec<SessionView>, CliError> {
2331 let sessions_root = context.state_dir.join("sessions");
2332 if !sessions_root.exists() {
2333 return Ok(Vec::new());
2334 }
2335 let tmux_bin = tmux_bin
2336 .map(Path::to_path_buf)
2337 .unwrap_or_else(|| resolve_tmux_bin(None));
2338 let tmux_snapshots = tmux_session_snapshots(&tmux_bin);
2339 let mut records = Vec::new();
2340 for entry in fs::read_dir(&sessions_root).map_err(|err| {
2341 CliError::runtime(
2342 "session-list-failed",
2343 format!("failed to read {}: {err}", sessions_root.display()),
2344 Some(json!({ "path": display_path(&sessions_root) })),
2345 )
2346 })? {
2347 let entry = entry.map_err(|err| {
2348 CliError::runtime(
2349 "session-list-failed",
2350 format!("failed to read session entry: {err}"),
2351 None,
2352 )
2353 })?;
2354 if entry.path().is_dir() {
2355 let entry_name = entry.file_name().to_string_lossy().to_string();
2356 let record_path = entry.path().join("session.json");
2357 if record_path.is_file() {
2358 let resolved = ensure_record_in_session_dir(
2359 context,
2360 &record_path,
2361 &entry.path(),
2362 &entry_name,
2363 )?;
2364 let record = read_session_record(&resolved.record_path)?;
2365 validate_record_id(&record, &resolved.expected_id, &resolved.record_path)?;
2366 let record = backfill_provider_resume(context, record);
2367 let (status, last_terminal_activity_at) =
2368 session_list_runtime_snapshot(&tmux_bin, tmux_snapshots.as_ref(), &record);
2369 records.push(session_view_from_parts(
2370 context,
2371 &record,
2372 status,
2373 last_terminal_activity_at,
2374 ));
2375 }
2376 }
2377 }
2378 records.sort_by(|a, b| {
2379 b.updated_at
2380 .cmp(&a.updated_at)
2381 .then_with(|| b.created_at.cmp(&a.created_at))
2382 .then_with(|| a.id.cmp(&b.id))
2383 });
2384 Ok(records)
2385}
2386
2387fn load_session_view(
2388 context: &CliContext,
2389 id: &str,
2390 tmux_bin: Option<&Path>,
2391) -> Result<SessionView, CliError> {
2392 let record = load_session_record_with_provider_resume_backfill(context, id)?;
2393 let tmux_bin = tmux_bin
2394 .map(Path::to_path_buf)
2395 .unwrap_or_else(|| resolve_tmux_bin(None));
2396 let status = session_status(&tmux_bin, &record);
2397 Ok(session_view(
2398 context,
2399 &record,
2400 Some(status),
2401 Some(&tmux_bin),
2402 ))
2403}
2404
2405fn load_session_record(context: &CliContext, id: &str) -> Result<SessionRecord, CliError> {
2406 let resolved = resolve_session_record_path(context, id)?;
2407 let record = read_session_record(&resolved.record_path)?;
2408 validate_record_id(&record, &resolved.expected_id, &resolved.record_path)?;
2409 Ok(record)
2410}
2411
2412fn load_session_record_with_provider_resume_backfill(
2413 context: &CliContext,
2414 id: &str,
2415) -> Result<SessionRecord, CliError> {
2416 load_session_record(context, id).map(|record| backfill_provider_resume(context, record))
2417}
2418
2419fn backfill_provider_resume(context: &CliContext, record: SessionRecord) -> SessionRecord {
2420 if record.provider_resume.is_some()
2421 || AgentKind::from_name(&record.agent) != Some(AgentKind::Codex)
2422 {
2423 return record;
2424 }
2425 let Some(provider_resume) = capture_codex_resume_from_history(&record) else {
2426 return record;
2427 };
2428 let mut record = record;
2429 record.provider_resume = Some(provider_resume);
2430 persist_or_reload_session_record(context, &record)
2431}
2432
2433#[derive(Debug)]
2434struct ResolvedRecordPath {
2435 record_path: PathBuf,
2436 session_dir: PathBuf,
2437 expected_id: String,
2438}
2439
2440fn resolve_session_record_path(
2441 context: &CliContext,
2442 id: &str,
2443) -> Result<ResolvedRecordPath, CliError> {
2444 validate_id(id)?;
2445 let exact_dir = session_dir(context, id);
2446 let exact = exact_dir.join("session.json");
2447 if exact.is_file() {
2448 return ensure_record_in_session_dir(context, &exact, &exact_dir, id);
2449 }
2450 let sessions_root = context.state_dir.join("sessions");
2451 let mut matches = Vec::new();
2452 if sessions_root.exists() {
2453 for entry in fs::read_dir(&sessions_root).map_err(|err| {
2454 CliError::runtime(
2455 "session-list-failed",
2456 format!("failed to read {}: {err}", sessions_root.display()),
2457 Some(json!({ "path": display_path(&sessions_root) })),
2458 )
2459 })? {
2460 let entry = entry.map_err(|err| {
2461 CliError::runtime(
2462 "session-list-failed",
2463 format!("failed to read session entry: {err}"),
2464 None,
2465 )
2466 })?;
2467 let name = entry.file_name().to_string_lossy().to_string();
2468 let record_path = entry.path().join("session.json");
2469 if name.starts_with(id) && record_path.is_file() {
2470 matches.push(ensure_record_in_session_dir(
2471 context,
2472 &record_path,
2473 &entry.path(),
2474 &name,
2475 )?);
2476 }
2477 }
2478 }
2479 match matches.len() {
2480 1 => Ok(matches.remove(0)),
2481 0 => Err(CliError::runtime(
2482 "session-not-found",
2483 format!("session not found: {id}"),
2484 Some(json!({ "id": id })),
2485 )),
2486 _ => Err(CliError::usage(
2487 "ambiguous-session-id",
2488 format!("session id prefix is ambiguous: {id}"),
2489 Some(json!({ "id": id, "matches": matches.len() })),
2490 )),
2491 }
2492}
2493
2494fn ensure_record_in_session_dir(
2495 context: &CliContext,
2496 path: &Path,
2497 expected_session_dir: &Path,
2498 expected_id: &str,
2499) -> Result<ResolvedRecordPath, CliError> {
2500 let sessions_root = context.state_dir.join("sessions");
2501 let canonical_root = fs::canonicalize(&sessions_root).map_err(|err| {
2502 CliError::runtime(
2503 "session-root-unavailable",
2504 format!("failed to canonicalize {}: {err}", sessions_root.display()),
2505 Some(json!({ "path": display_path(&sessions_root) })),
2506 )
2507 })?;
2508 let canonical_session_dir = fs::canonicalize(expected_session_dir).map_err(|err| {
2509 CliError::runtime(
2510 "session-read-failed",
2511 format!(
2512 "failed to canonicalize {}: {err}",
2513 expected_session_dir.display()
2514 ),
2515 Some(json!({ "path": display_path(expected_session_dir) })),
2516 )
2517 })?;
2518 if !canonical_session_dir.starts_with(&canonical_root) {
2519 return Err(CliError::usage(
2520 "session-path-escaped",
2521 "session directory escapes the managed state directory",
2522 Some(json!({ "session_dir": display_path(expected_session_dir) })),
2523 ));
2524 }
2525 let canonical_path = fs::canonicalize(path).map_err(|err| {
2526 CliError::runtime(
2527 "session-read-failed",
2528 format!("failed to canonicalize {}: {err}", path.display()),
2529 Some(json!({ "path": display_path(path) })),
2530 )
2531 })?;
2532 let expected_record_path = canonical_session_dir.join("session.json");
2533 if canonical_path != expected_record_path {
2534 return Err(CliError::usage(
2535 "session-path-escaped",
2536 "session record path escapes the requested session directory",
2537 Some(json!({
2538 "path": display_path(path),
2539 "expected_session_dir": display_path(expected_session_dir),
2540 })),
2541 ));
2542 }
2543 Ok(ResolvedRecordPath {
2544 record_path: canonical_path,
2545 session_dir: canonical_session_dir,
2546 expected_id: expected_id.to_string(),
2547 })
2548}
2549
2550fn read_session_record(path: &Path) -> Result<SessionRecord, CliError> {
2551 let contents = fs::read_to_string(path).map_err(|err| {
2552 CliError::runtime(
2553 "session-read-failed",
2554 format!("failed to read {}: {err}", path.display()),
2555 Some(json!({ "path": display_path(path) })),
2556 )
2557 })?;
2558 let mut record: SessionRecord = serde_json::from_str(&contents).map_err(|err| {
2559 CliError::data(
2560 "session-json-invalid",
2561 format!("failed to parse {}: {err}", path.display()),
2562 Some(json!({ "path": display_path(path) })),
2563 )
2564 })?;
2565 if record.schema_version != SESSION_DOCUMENT_VERSION {
2566 return Err(CliError::data(
2567 "unsupported-session-version",
2568 format!(
2569 "unsupported session schema_version {}; expected {}",
2570 record.schema_version, SESSION_DOCUMENT_VERSION
2571 ),
2572 Some(json!({ "path": display_path(path), "schema_version": record.schema_version })),
2573 ));
2574 }
2575 merge_resume_sidecar(path, &mut record)?;
2576 Ok(record)
2577}
2578
2579fn validate_record_id(
2580 record: &SessionRecord,
2581 expected_id: &str,
2582 path: &Path,
2583) -> Result<(), CliError> {
2584 if record.id != expected_id {
2585 return Err(CliError::data(
2586 "session-record-mismatch",
2587 format!(
2588 "session record id {} does not match directory {}",
2589 record.id, expected_id
2590 ),
2591 Some(json!({
2592 "path": display_path(path),
2593 "record_id": record.id,
2594 "expected_id": expected_id,
2595 })),
2596 ));
2597 }
2598 Ok(())
2599}
2600
2601fn write_session_record(context: &CliContext, record: &SessionRecord) -> Result<(), CliError> {
2602 let bytes = serde_json::to_vec_pretty(record).map_err(|err| {
2603 CliError::runtime(
2604 "session-render-failed",
2605 format!("failed to render session json: {err}"),
2606 None,
2607 )
2608 })?;
2609 let path = session_dir(context, &record.id).join("session.json");
2610 write_resume_sidecar(context, record)?;
2611 write_private_file(&path, &bytes)
2612}
2613
2614fn persist_or_reload_session_record(context: &CliContext, record: &SessionRecord) -> SessionRecord {
2615 match write_session_record(context, record) {
2616 Ok(()) => record.clone(),
2617 Err(_) => load_session_record(context, &record.id).unwrap_or_else(|_| record.clone()),
2618 }
2619}
2620
2621fn merge_resume_sidecar(path: &Path, record: &mut SessionRecord) -> Result<(), CliError> {
2622 let sidecar_path = path.with_file_name(SESSION_RESUME_FILE);
2623 if !sidecar_path.is_file() {
2624 return Ok(());
2625 }
2626 let Ok(contents) = fs::read_to_string(&sidecar_path) else {
2627 return Ok(());
2628 };
2629 let Ok(sidecar) = serde_json::from_str::<DurableResumeRecord>(&contents) else {
2630 return Ok(());
2631 };
2632 if sidecar.schema_version != SESSION_RESUME_DOCUMENT_VERSION {
2633 return Ok(());
2634 }
2635 if let Some(provider_resume) = sidecar.provider_resume.clone() {
2636 if let Some(existing) = record.provider_resume.as_mut() {
2637 merge_extra_fields(&mut existing.extra, provider_resume.extra);
2638 } else {
2639 record.provider_resume = Some(provider_resume);
2640 }
2641 }
2642 if let Some(runtime) = sidecar.runtime.clone() {
2643 if let Some(existing) = record.runtime.as_mut() {
2644 merge_extra_fields(&mut existing.extra, runtime.extra);
2645 } else {
2646 record.runtime = Some(runtime);
2647 }
2648 }
2649 if record.agent_args.is_empty() {
2650 record.agent_args = sidecar.agent_args.clone();
2651 }
2652 if record.agent_bin.is_none() {
2653 record.agent_bin = sidecar.agent_bin.clone();
2654 }
2655 record.resume_sidecar_extra = sidecar.extra;
2656 Ok(())
2657}
2658
2659fn merge_extra_fields(target: &mut BTreeMap<String, Value>, source: BTreeMap<String, Value>) {
2660 for (key, value) in source {
2661 target.entry(key).or_insert(value);
2662 }
2663}
2664
2665fn write_resume_sidecar(context: &CliContext, record: &SessionRecord) -> Result<(), CliError> {
2666 let path = session_dir(context, &record.id).join(SESSION_RESUME_FILE);
2667 let Some(sidecar) = durable_resume_record(record) else {
2668 return remove_current_resume_sidecar_if_present(&path);
2669 };
2670 if should_preserve_existing_unsupported_resume_sidecar(&path) {
2671 return Ok(());
2672 }
2673 let bytes = serde_json::to_vec_pretty(&sidecar).map_err(|err| {
2674 CliError::runtime(
2675 "session-render-failed",
2676 format!("failed to render resume json: {err}"),
2677 None,
2678 )
2679 })?;
2680 write_private_file(&path, &bytes)
2681}
2682
2683fn should_preserve_existing_unsupported_resume_sidecar(path: &Path) -> bool {
2684 match fs::read_to_string(path) {
2685 Ok(contents) => serde_json::from_str::<DurableResumeRecord>(&contents)
2686 .map(|sidecar| sidecar.schema_version != SESSION_RESUME_DOCUMENT_VERSION)
2687 .unwrap_or(true),
2688 Err(_) => false,
2689 }
2690}
2691
2692fn remove_current_resume_sidecar_if_present(path: &Path) -> Result<(), CliError> {
2693 let Ok(contents) = fs::read_to_string(path) else {
2694 return Ok(());
2695 };
2696 let Ok(sidecar) = serde_json::from_str::<DurableResumeRecord>(&contents) else {
2697 return Ok(());
2698 };
2699 if sidecar.schema_version != SESSION_RESUME_DOCUMENT_VERSION {
2700 return Ok(());
2701 }
2702 fs::remove_file(path).map_err(|err| {
2703 CliError::runtime(
2704 "file-delete-failed",
2705 format!("failed to delete {}: {err}", path.display()),
2706 Some(json!({ "path": display_path(path) })),
2707 )
2708 })
2709}
2710
2711fn durable_resume_record(record: &SessionRecord) -> Option<DurableResumeRecord> {
2712 record
2713 .provider_resume
2714 .as_ref()
2715 .map(|provider_resume| DurableResumeRecord {
2716 schema_version: SESSION_RESUME_DOCUMENT_VERSION.to_string(),
2717 provider_resume: Some(provider_resume.clone()),
2718 runtime: record.runtime.clone(),
2719 agent_args: record.agent_args.clone(),
2720 agent_bin: record.agent_bin.clone(),
2721 extra: record.resume_sidecar_extra.clone(),
2722 })
2723}
2724
2725fn session_view(
2726 context: &CliContext,
2727 record: &SessionRecord,
2728 forced_status: Option<String>,
2729 tmux_bin: Option<&Path>,
2730) -> SessionView {
2731 let fallback_tmux;
2732 let tmux_bin = match tmux_bin {
2733 Some(tmux_bin) => tmux_bin,
2734 None => {
2735 fallback_tmux = resolve_tmux_bin(None);
2736 &fallback_tmux
2737 }
2738 };
2739 let status = forced_status.unwrap_or_else(|| session_status(tmux_bin, record));
2740 let last_terminal_activity_at = last_terminal_activity_at(tmux_bin, record, &status);
2741 session_view_from_parts(context, record, status, last_terminal_activity_at)
2742}
2743
2744fn session_view_from_parts(
2745 context: &CliContext,
2746 record: &SessionRecord,
2747 status: String,
2748 last_terminal_activity_at: Option<String>,
2749) -> SessionView {
2750 SessionView {
2751 id: record.id.clone(),
2752 agent: record.agent.clone(),
2753 mode: record.mode.clone(),
2754 title: record.title.clone(),
2755 cwd: record.cwd.clone(),
2756 tmux_session: record.tmux_session.clone(),
2757 status,
2758 resumable: is_resumable(record),
2759 repo_name: repo_name_from_cwd(&record.cwd),
2760 provider_resume: record
2761 .provider_resume
2762 .as_ref()
2763 .map(ProviderResumeView::from),
2764 attach_command: local_attach_command(&record.tmux_session),
2765 ssh_attach_command: context
2766 .host
2767 .as_deref()
2768 .filter(|host| !host.trim().is_empty())
2769 .map(|host| ssh_attach_command(host, &record.tmux_session)),
2770 prompt_file: record.prompt_file.clone(),
2771 log_file: record.log_file.clone(),
2772 created_at: record.created_at.clone(),
2773 updated_at: record.updated_at.clone(),
2774 last_terminal_activity_at,
2775 runtime_started_at: record
2776 .runtime
2777 .as_ref()
2778 .map(|runtime| runtime.started_at.clone()),
2779 turn_state: activity::state_for_view(context, record),
2780 }
2781}
2782
2783fn last_terminal_activity_at(
2784 tmux_bin: &Path,
2785 record: &SessionRecord,
2786 status: &str,
2787) -> Option<String> {
2788 if status != "running" {
2789 return None;
2790 }
2791 tmux_window_activity_at(tmux_bin, &record.tmux_session)
2792}
2793
2794#[derive(Debug, Clone)]
2795struct TmuxSessionSnapshot {
2796 last_terminal_activity_at: Option<String>,
2797}
2798
2799fn session_list_runtime_snapshot(
2800 tmux_bin: &Path,
2801 tmux_snapshots: Option<&BTreeMap<String, TmuxSessionSnapshot>>,
2802 record: &SessionRecord,
2803) -> (String, Option<String>) {
2804 match tmux_snapshots {
2805 Some(snapshots) => match snapshots.get(&record.tmux_session) {
2806 Some(snapshot) => (
2807 "running".to_string(),
2808 snapshot.last_terminal_activity_at.clone(),
2809 ),
2810 None => ("stopped".to_string(), None),
2811 },
2812 None => (session_status(tmux_bin, record), None),
2813 }
2814}
2815
2816fn tmux_session_snapshots(tmux_bin: &Path) -> Option<BTreeMap<String, TmuxSessionSnapshot>> {
2817 let output = ProcessCommand::new(tmux_bin)
2818 .arg("list-windows")
2819 .arg("-a")
2820 .arg("-F")
2821 .arg("#{session_name}\t#{window_activity}")
2822 .output()
2823 .ok()?;
2824 if !output.status.success() {
2825 return None;
2826 }
2827 let raw = String::from_utf8_lossy(&output.stdout);
2828 let mut activity_by_session: BTreeMap<String, Option<i64>> = BTreeMap::new();
2829 for line in raw.lines() {
2830 let Some((session, activity)) = line.split_once('\t') else {
2831 continue;
2832 };
2833 if session.is_empty() {
2834 continue;
2835 }
2836 let Some(epoch_seconds) = parse_tmux_window_activity_seconds(activity) else {
2837 activity_by_session
2838 .entry(session.to_string())
2839 .or_insert(None);
2840 continue;
2841 };
2842 activity_by_session
2843 .entry(session.to_string())
2844 .and_modify(|current| {
2845 *current = Some(current.map_or(epoch_seconds, |value| value.max(epoch_seconds)));
2846 })
2847 .or_insert(Some(epoch_seconds));
2848 }
2849 Some(
2850 activity_by_session
2851 .into_iter()
2852 .map(|(session, maybe_epoch_seconds)| {
2853 (
2854 session,
2855 TmuxSessionSnapshot {
2856 last_terminal_activity_at: maybe_epoch_seconds
2857 .and_then(format_tmux_window_activity),
2858 },
2859 )
2860 })
2861 .collect(),
2862 )
2863}
2864
2865fn tmux_window_activity_at(tmux_bin: &Path, tmux_session: &str) -> Option<String> {
2866 let output = ProcessCommand::new(tmux_bin)
2867 .arg("display-message")
2868 .arg("-p")
2869 .arg("-t")
2870 .arg(tmux_session)
2871 .arg("#{window_activity}")
2872 .output()
2873 .ok()?;
2874 if !output.status.success() {
2875 return None;
2876 }
2877 let raw = String::from_utf8_lossy(&output.stdout);
2878 let epoch_seconds = parse_tmux_window_activity_seconds(raw.trim())?;
2879 format_tmux_window_activity(epoch_seconds)
2880}
2881
2882fn parse_tmux_window_activity_seconds(raw: &str) -> Option<i64> {
2883 let epoch_seconds = raw.trim().parse::<i64>().ok()?;
2884 (epoch_seconds > 0).then_some(epoch_seconds)
2885}
2886
2887fn format_tmux_window_activity(epoch_seconds: i64) -> Option<String> {
2888 Timestamp::from_second(epoch_seconds)
2889 .ok()
2890 .map(|timestamp| timestamp.to_string())
2891}
2892
2893fn session_logs(
2894 record: &SessionRecord,
2895 tail: usize,
2896 tmux_bin: &Path,
2897) -> Result<LogsResult, CliError> {
2898 if let Some(result) = read_session_log_file(record, tail)? {
2899 return Ok(result);
2900 }
2901
2902 if live_status(tmux_bin, &record.tmux_session) == "running"
2903 && let Some(text) = run_capture_pane(record, tail, tmux_bin)?
2904 {
2905 return Ok(LogsResult {
2906 id: record.id.clone(),
2907 source: "tmux".to_string(),
2908 text,
2909 });
2910 }
2911
2912 Err(CliError::runtime(
2913 "logs-unavailable",
2914 "no tmux pane output or log file is available",
2915 Some(json!({ "id": record.id })),
2916 ))
2917}
2918
2919fn read_session_log_file(
2920 record: &SessionRecord,
2921 tail: usize,
2922) -> Result<Option<LogsResult>, CliError> {
2923 if let Some(log_file) = &record.log_file {
2924 match fs::read_to_string(log_file) {
2925 Ok(text) => {
2926 return Ok(Some(LogsResult {
2927 id: record.id.clone(),
2928 source: "file".to_string(),
2929 text: tail_lines(&text, tail),
2930 }));
2931 }
2932 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
2933 Err(err) => {
2934 return Err(CliError::runtime(
2935 "log-read-failed",
2936 format!("failed to read {log_file}: {err}"),
2937 Some(json!({ "log_file": log_file })),
2938 ));
2939 }
2940 }
2941 }
2942 Ok(None)
2943}
2944
2945fn delete_session(
2946 context: &CliContext,
2947 id: &str,
2948 tmux_bin: PathBuf,
2949) -> Result<DeleteResult, CliError> {
2950 let resolved = resolve_session_record_path(context, id)?;
2951 let record = read_session_record(&resolved.record_path)?;
2952 validate_record_id(&record, &resolved.expected_id, &resolved.record_path)?;
2953 let session_dir = resolved.session_dir;
2954 let killed = kill_tmux_session(&tmux_bin, &record.tmux_session);
2955 fs::remove_dir_all(&session_dir).map_err(|err| {
2956 CliError::runtime(
2957 "session-delete-failed",
2958 format!("failed to delete {}: {err}", session_dir.display()),
2959 Some(json!({ "path": display_path(&session_dir) })),
2960 )
2961 })?;
2962 Ok(DeleteResult {
2963 id: record.id,
2964 tmux_session: record.tmux_session,
2965 killed,
2966 deleted: true,
2967 session_dir: display_path(&session_dir),
2968 })
2969}
2970
2971fn kill_tmux_session(tmux_bin: &Path, tmux_session: &str) -> bool {
2972 ProcessCommand::new(tmux_bin)
2973 .arg("kill-session")
2974 .arg("-t")
2975 .arg(tmux_session)
2976 .status()
2977 .map(|status| status.success())
2978 .unwrap_or(false)
2979}
2980
2981fn session_status(tmux_bin: &Path, record: &SessionRecord) -> String {
2982 live_status(tmux_bin, &record.tmux_session)
2983}
2984
2985fn is_resumable(record: &SessionRecord) -> bool {
2986 validate_resume_metadata(record).is_ok()
2987}
2988
2989fn validate_resume_metadata(
2990 record: &SessionRecord,
2991) -> Result<(&ProviderResume, AgentKind), CliError> {
2992 if record.mode != "interactive" {
2993 return Err(CliError::data(
2994 "session-not-resumable",
2995 format!("session mode is not resumable: {}", record.id),
2996 Some(json!({ "id": record.id.clone(), "mode": record.mode.clone() })),
2997 ));
2998 }
2999 let provider_resume = record.provider_resume.as_ref().ok_or_else(|| {
3000 CliError::data(
3001 "session-not-resumable",
3002 format!(
3003 "session has no exact provider resume identity: {}",
3004 record.id
3005 ),
3006 Some(json!({ "id": record.id.clone() })),
3007 )
3008 })?;
3009 if provider_resume.resume_args.is_empty() {
3010 return Err(CliError::data(
3011 "session-not-resumable",
3012 format!("session has no provider resume command: {}", record.id),
3013 Some(json!({ "id": record.id.clone() })),
3014 ));
3015 }
3016 let agent = AgentKind::from_name(&record.agent).ok_or_else(|| {
3017 CliError::data(
3018 "invalid-agent",
3019 format!("unknown agent in session record: {}", record.agent),
3020 Some(json!({ "id": record.id.clone(), "agent": record.agent.clone() })),
3021 )
3022 })?;
3023 if provider_resume.provider != agent.as_str() {
3024 return Err(CliError::data(
3025 "session-provider-mismatch",
3026 "session provider resume metadata does not match the agent",
3027 Some(json!({
3028 "id": record.id.clone(),
3029 "agent": record.agent.clone(),
3030 "provider": provider_resume.provider.clone(),
3031 })),
3032 ));
3033 }
3034 validate_stored_agent_args(record, agent)?;
3035 let expected_args =
3036 canonical_provider_resume_args(agent, &record.cwd, &provider_resume.session_id)
3037 .ok_or_else(|| {
3038 CliError::data(
3039 "session-not-resumable",
3040 format!("session provider is not resumable: {}", record.id),
3041 Some(json!({
3042 "id": record.id.clone(),
3043 "agent": record.agent.clone(),
3044 "provider": provider_resume.provider.clone(),
3045 })),
3046 )
3047 })?;
3048 if provider_resume.session_id.trim().is_empty() || provider_resume.resume_args != expected_args
3049 {
3050 return Err(CliError::data(
3051 "session-not-resumable",
3052 "session provider resume command does not match the stored identity",
3053 Some(json!({
3054 "id": record.id.clone(),
3055 "agent": record.agent.clone(),
3056 "provider": provider_resume.provider.clone(),
3057 })),
3058 ));
3059 }
3060 Ok((provider_resume, agent))
3061}
3062
3063fn canonical_provider_resume_args(
3064 agent: AgentKind,
3065 cwd: &str,
3066 session_id: &str,
3067) -> Option<Vec<String>> {
3068 match agent {
3069 AgentKind::Codex => Some(vec![
3070 "resume".to_string(),
3071 session_id.to_string(),
3072 "--cd".to_string(),
3073 cwd.to_string(),
3074 "--no-alt-screen".to_string(),
3075 ]),
3076 AgentKind::Claude => Some(vec!["--resume".to_string(), session_id.to_string()]),
3077 AgentKind::Hermes => None,
3078 }
3079}
3080
3081fn validate_stored_agent_args(record: &SessionRecord, agent: AgentKind) -> Result<(), CliError> {
3082 let flag = match agent {
3083 AgentKind::Codex => record
3084 .agent_args
3085 .iter()
3086 .find_map(|arg| reserved_codex_resume_arg(arg)),
3087 AgentKind::Claude => record
3088 .agent_args
3089 .iter()
3090 .find_map(|arg| reserved_claude_resume_arg(arg)),
3091 AgentKind::Hermes => None,
3092 };
3093 if let Some(flag) = flag {
3094 return Err(CliError::data(
3095 "session-not-resumable",
3096 "session provider arguments conflict with durable resume identity",
3097 Some(json!({
3098 "id": record.id.clone(),
3099 "agent": record.agent.clone(),
3100 "flag": flag,
3101 })),
3102 ));
3103 }
3104 Ok(())
3105}
3106
3107fn repo_name_from_cwd(cwd: &str) -> Option<String> {
3108 let trimmed = cwd.trim_end_matches(['/', '\\']);
3109 if trimmed.is_empty() {
3110 return None;
3111 }
3112 Path::new(trimmed)
3113 .file_name()
3114 .and_then(|value| value.to_str())
3115 .filter(|value| !value.trim().is_empty())
3116 .map(str::to_string)
3117}
3118
3119fn repo_remote_url_from_cwd(cwd: &str) -> Option<String> {
3120 let trimmed = cwd.trim();
3121 if trimmed.is_empty() {
3122 return None;
3123 }
3124 let root_output = ProcessCommand::new("git")
3125 .arg("-C")
3126 .arg(trimmed)
3127 .args(["rev-parse", "--show-toplevel"])
3128 .output()
3129 .ok()?;
3130 if !root_output.status.success() {
3131 return None;
3132 }
3133 let root = String::from_utf8(root_output.stdout).ok()?;
3134 let root = root.trim();
3135 if root.is_empty() {
3136 return None;
3137 }
3138
3139 let remote_output = ProcessCommand::new("git")
3140 .arg("-C")
3141 .arg(root)
3142 .args(["remote", "get-url", "origin"])
3143 .output()
3144 .ok()?;
3145 if !remote_output.status.success() {
3146 return None;
3147 }
3148 let remote = String::from_utf8(remote_output.stdout).ok()?;
3149 git_remote_web_url(&remote)
3150}
3151
3152fn git_remote_web_url(remote: &str) -> Option<String> {
3153 let parsed = parse_git_remote_url(remote)?;
3154 if parsed.host.trim().is_empty() || parsed.path.trim().is_empty() {
3155 return None;
3156 }
3157 Some(format!("https://{}/{}", parsed.host, parsed.path))
3158}
3159
3160fn live_status(tmux_bin: &Path, tmux_session: &str) -> String {
3161 match ProcessCommand::new(tmux_bin)
3162 .arg("has-session")
3163 .arg("-t")
3164 .arg(tmux_session)
3165 .status()
3166 {
3167 Ok(status) if status.success() => "running".to_string(),
3168 Ok(status) if status.code() == Some(1) => "stopped".to_string(),
3169 Ok(_) => "unknown".to_string(),
3170 Err(_) => "unknown".to_string(),
3171 }
3172}
3173
3174fn run_status(mut command: ProcessCommand, label: &str) -> Result<(), CliError> {
3175 let output = command.output().map_err(|err| {
3176 CliError::runtime(
3177 "command-spawn-failed",
3178 format!("failed to run {label}: {err}"),
3179 None,
3180 )
3181 })?;
3182 if output.status.success() {
3183 return Ok(());
3184 }
3185 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
3186 Err(CliError::runtime(
3187 "command-failed",
3188 if stderr.is_empty() {
3189 format!("{label} failed with status {}", output.status)
3190 } else {
3191 format!("{label} failed: {stderr}")
3192 },
3193 None,
3194 ))
3195}
3196
3197fn read_prompt(
3198 prompt: &Option<String>,
3199 prompt_file: Option<&Path>,
3200 prompt_stdin: bool,
3201) -> Result<Option<String>, CliError> {
3202 let source_count = usize::from(prompt.is_some())
3203 + usize::from(prompt_file.is_some())
3204 + usize::from(prompt_stdin);
3205 if source_count > 1 {
3206 return Err(CliError::usage(
3207 "multiple-prompt-sources",
3208 "use only one of --prompt, --prompt-file, or --prompt-stdin",
3209 None,
3210 ));
3211 }
3212 if let Some(prompt) = prompt {
3213 return Ok(Some(prompt.clone()));
3214 }
3215 if prompt_stdin || prompt_file == Some(Path::new("-")) {
3216 let mut input = String::new();
3217 io::stdin().read_to_string(&mut input).map_err(|err| {
3218 CliError::runtime(
3219 "stdin-read-failed",
3220 format!("failed to read stdin: {err}"),
3221 None,
3222 )
3223 })?;
3224 return Ok(Some(input));
3225 }
3226 if let Some(path) = prompt_file {
3227 let path = absolute_path(path)?;
3228 let input = fs::read_to_string(&path).map_err(|err| {
3229 CliError::runtime(
3230 "prompt-file-read-failed",
3231 format!("failed to read {}: {err}", path.display()),
3232 Some(json!({ "path": display_path(&path) })),
3233 )
3234 })?;
3235 return Ok(Some(input));
3236 }
3237 Ok(None)
3238}
3239
3240fn resolve_state_dir(explicit: Option<PathBuf>) -> Result<PathBuf, CliError> {
3241 if let Some(path) = explicit {
3242 return absolute_path(&path);
3243 }
3244 if let Some(path) = non_empty_env("AGENT_SESSION_STATE_DIR") {
3245 return absolute_path(Path::new(&path));
3246 }
3247 if let Some(path) = non_empty_env("XDG_STATE_HOME") {
3248 return Ok(normalize_path(&PathBuf::from(path).join("agent-session")));
3249 }
3250 let home = home_dir().ok_or_else(|| {
3251 CliError::runtime(
3252 "home-unavailable",
3253 "HOME is unset; pass --state-dir",
3254 Some(json!({ "flag": "--state-dir" })),
3255 )
3256 })?;
3257 Ok(normalize_path(&home.join(".local/state/agent-session")))
3258}
3259
3260fn resolve_cwd(explicit: Option<&Path>) -> Result<PathBuf, CliError> {
3261 let cwd = match explicit {
3262 Some(path) => absolute_path(path)?,
3263 None => env::current_dir().map_err(|err| {
3264 CliError::runtime(
3265 "cwd-unavailable",
3266 format!("failed to read current directory: {err}"),
3267 None,
3268 )
3269 })?,
3270 };
3271 let metadata = fs::metadata(&cwd).map_err(|err| {
3272 CliError::usage(
3273 "cwd-unavailable",
3274 format!("working directory does not exist: {}: {err}", cwd.display()),
3275 Some(json!({ "cwd": display_path(&cwd) })),
3276 )
3277 })?;
3278 if !metadata.is_dir() {
3279 return Err(CliError::usage(
3280 "cwd-not-directory",
3281 format!("working directory is not a directory: {}", cwd.display()),
3282 Some(json!({ "cwd": display_path(&cwd) })),
3283 ));
3284 }
3285 Ok(cwd)
3286}
3287
3288fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
3289 let expanded = expand_home(path);
3290 if expanded.is_absolute() {
3291 return Ok(normalize_path(&expanded));
3292 }
3293 let cwd = env::current_dir().map_err(|err| {
3294 CliError::runtime(
3295 "cwd-unavailable",
3296 format!("failed to read current directory: {err}"),
3297 None,
3298 )
3299 })?;
3300 Ok(normalize_path(&cwd.join(expanded)))
3301}
3302
3303fn resolve_tmux_bin(explicit: Option<&Path>) -> PathBuf {
3304 explicit
3305 .map(Path::to_path_buf)
3306 .or_else(|| non_empty_env("AGENT_SESSION_TMUX_BIN").map(PathBuf::from))
3307 .unwrap_or_else(|| PathBuf::from("tmux"))
3308}
3309
3310fn resolve_host(host: Option<String>) -> Result<Option<String>, CliError> {
3311 let Some(host) = host else {
3312 return Ok(None);
3313 };
3314 let host = host.trim();
3315 if host.is_empty() {
3316 return Ok(None);
3317 }
3318 validate_host(host)?;
3319 Ok(Some(host.to_string()))
3320}
3321
3322fn validate_host(host: &str) -> Result<(), CliError> {
3323 if host.starts_with('-') {
3324 return Err(CliError::usage(
3325 "invalid-host",
3326 "host must not start with '-' because ssh would parse it as an option",
3327 Some(json!({ "host": host })),
3328 ));
3329 }
3330 if host.chars().any(char::is_control) || host.chars().any(char::is_whitespace) {
3331 return Err(CliError::usage(
3332 "invalid-host",
3333 "host must not contain whitespace or control characters",
3334 Some(json!({ "host": host })),
3335 ));
3336 }
3337 Ok(())
3338}
3339
3340fn resolve_agent_bin(agent: AgentKind, explicit: Option<&Path>) -> PathBuf {
3341 if let Some(path) = explicit {
3342 return path.to_path_buf();
3343 }
3344 let env_key = match agent {
3345 AgentKind::Codex => "AGENT_SESSION_CODEX_BIN",
3346 AgentKind::Claude => "AGENT_SESSION_CLAUDE_BIN",
3347 AgentKind::Hermes => "AGENT_SESSION_HERMES_BIN",
3348 };
3349 non_empty_env(env_key)
3350 .map(PathBuf::from)
3351 .unwrap_or_else(|| PathBuf::from(agent.as_str()))
3352}
3353
3354fn resolve_session_id(
3355 context: &CliContext,
3356 explicit_id: Option<&str>,
3357 agent: AgentKind,
3358 timestamp: &str,
3359 title_slug: Option<&str>,
3360) -> Result<String, CliError> {
3361 if let Some(id) = explicit_id {
3362 validate_id(id)?;
3363 if session_dir(context, id).exists() {
3364 return Err(CliError::runtime(
3365 "session-exists",
3366 format!("session already exists: {id}"),
3367 Some(json!({ "id": id })),
3368 ));
3369 }
3370 return Ok(id.to_string());
3371 }
3372 let base = default_session_id_base(timestamp, agent, title_slug);
3373 for index in 0..100 {
3374 let id = if index == 0 {
3375 base.clone()
3376 } else {
3377 format!("{base}-{index}")
3378 };
3379 if !session_dir(context, &id).exists() {
3380 return Ok(id);
3381 }
3382 }
3383 Err(CliError::runtime(
3384 "session-id-exhausted",
3385 "failed to allocate a unique session id",
3386 Some(json!({ "base": base })),
3387 ))
3388}
3389
3390fn default_session_id_base(timestamp: &str, agent: AgentKind, title_slug: Option<&str>) -> String {
3391 match title_slug {
3392 Some(slug) => format!("{timestamp}-{}-{slug}", agent.as_str()),
3393 None => format!("{timestamp}-{}", agent.as_str()),
3394 }
3395}
3396
3397fn validate_id(id: &str) -> Result<(), CliError> {
3398 if id.is_empty()
3399 || !id
3400 .chars()
3401 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
3402 {
3403 return Err(CliError::usage(
3404 "invalid-session-id",
3405 "session id may contain only ASCII letters, digits, '-' and '_'",
3406 Some(json!({ "id": id })),
3407 ));
3408 }
3409 Ok(())
3410}
3411
3412fn session_dir(context: &CliContext, id: &str) -> PathBuf {
3413 context.state_dir.join("sessions").join(id)
3414}
3415
3416fn private_dir(path: &Path) -> Result<(), CliError> {
3417 if let Some(parent) = path.parent() {
3418 fs::create_dir_all(parent).map_err(|err| {
3419 CliError::runtime(
3420 "directory-create-failed",
3421 format!("failed to create {}: {err}", parent.display()),
3422 Some(json!({ "path": display_path(parent) })),
3423 )
3424 })?;
3425 }
3426 match fs::create_dir(path) {
3433 Ok(()) => {}
3434 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
3435 return Err(CliError::runtime(
3436 "session-exists",
3437 format!("session already exists: {}", path.display()),
3438 Some(json!({ "path": display_path(path) })),
3439 ));
3440 }
3441 Err(err) => {
3442 return Err(CliError::runtime(
3443 "directory-create-failed",
3444 format!("failed to create {}: {err}", path.display()),
3445 Some(json!({ "path": display_path(path) })),
3446 ));
3447 }
3448 }
3449 #[cfg(unix)]
3450 {
3451 use std::os::unix::fs::PermissionsExt;
3452 let permissions = fs::Permissions::from_mode(0o700);
3453 fs::set_permissions(path, permissions).map_err(|err| {
3454 CliError::runtime(
3455 "directory-permissions-failed",
3456 format!("failed to set permissions on {}: {err}", path.display()),
3457 Some(json!({ "path": display_path(path) })),
3458 )
3459 })?;
3460 }
3461 Ok(())
3462}
3463
3464fn ensure_private_dir(path: &Path) -> Result<(), CliError> {
3465 fs::create_dir_all(path).map_err(|err| {
3466 CliError::runtime(
3467 "directory-create-failed",
3468 format!("failed to create {}: {err}", path.display()),
3469 Some(json!({ "path": display_path(path) })),
3470 )
3471 })?;
3472 #[cfg(unix)]
3473 {
3474 use std::os::unix::fs::PermissionsExt;
3475 let permissions = fs::Permissions::from_mode(0o700);
3476 fs::set_permissions(path, permissions).map_err(|err| {
3477 CliError::runtime(
3478 "directory-permissions-failed",
3479 format!("failed to set permissions on {}: {err}", path.display()),
3480 Some(json!({ "path": display_path(path) })),
3481 )
3482 })?;
3483 }
3484 Ok(())
3485}
3486
3487fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
3488 write_atomic(path, bytes, SECRET_FILE_MODE).map_err(|err| {
3489 CliError::runtime(
3490 "file-write-failed",
3491 format!("failed to write {}: {err}", path.display()),
3492 Some(json!({ "path": display_path(path) })),
3493 )
3494 })
3495}
3496
3497fn render_single_success<T: Serialize>(
3498 command: &'static str,
3499 format: OutputFormat,
3500 result: &T,
3501 render_text: fn(&T) -> String,
3502) -> i32 {
3503 match format {
3504 OutputFormat::Json => {
3505 let envelope = Envelope::success(schema_version_for(BINARY, command, 1), result);
3506 print_json(&envelope)
3507 }
3508 OutputFormat::Text => {
3509 print!("{}", render_text(result));
3510 exit::SUCCESS
3511 }
3512 }
3513}
3514
3515fn render_list_success(format: OutputFormat, results: &[SessionView]) -> i32 {
3516 match format {
3517 OutputFormat::Json => {
3518 let envelope = Envelope::success(schema_version_for(BINARY, LIST_COMMAND, 1), results);
3519 print_json(&envelope)
3520 }
3521 OutputFormat::Text => {
3522 if results.is_empty() {
3523 println!("no agent sessions");
3524 } else {
3525 for result in results {
3526 println!(
3527 "{} {} {} {}",
3528 result.id, result.agent, result.status, result.cwd
3529 );
3530 }
3531 }
3532 exit::SUCCESS
3533 }
3534 }
3535}
3536
3537fn render_error(command: &'static str, format: OutputFormat, err: CliError) -> i32 {
3538 let err = err.into_inner();
3539 match format {
3540 OutputFormat::Json => {
3541 let mut envelope_error = EnvelopeError::new(err.code, err.message);
3542 if let Some(details) = err.details {
3543 envelope_error = envelope_error.with_details(details);
3544 }
3545 let envelope: Envelope<()> =
3546 Envelope::failure(schema_version_for(BINARY, command, 1), envelope_error);
3547 print_json(&envelope);
3548 }
3549 OutputFormat::Text => {
3550 let _ = writeln!(io::stderr(), "error: {}", err.message);
3551 }
3552 }
3553 err.exit_code
3554}
3555
3556fn print_json<T: Serialize>(value: &T) -> i32 {
3557 match serde_json::to_string(value) {
3558 Ok(serialized) => {
3559 println!("{serialized}");
3560 exit::SUCCESS
3561 }
3562 Err(err) => {
3563 eprintln!("error: failed to serialize json: {err}");
3564 exit::SOFTWARE
3565 }
3566 }
3567}
3568
3569fn render_started_text(result: &SessionView) -> String {
3570 let mut text = format!(
3571 "started {} session {}\ntmux: {}\nattach: {}\n",
3572 result.agent, result.id, result.tmux_session, result.attach_command
3573 );
3574 if let Some(command) = &result.ssh_attach_command {
3575 text.push_str(&format!("ssh: {command}\n"));
3576 }
3577 text.push_str(&format!("delete: agent-session delete {}\n", result.id));
3578 text
3579}
3580
3581fn render_command_text(result: &SessionView) -> String {
3582 match &result.ssh_attach_command {
3583 Some(command) => format!("{command}\nlocal: {}\n", result.attach_command),
3584 None => format!("{}\n", result.attach_command),
3585 }
3586}
3587
3588fn render_logs_text(result: &LogsResult) -> String {
3589 result.text.clone()
3590}
3591
3592fn render_send_text(result: &SendResult) -> String {
3593 let mut parts = Vec::new();
3594 if result.sent_text {
3595 parts.push("text".to_string());
3596 }
3597 if !result.keys.is_empty() {
3598 parts.push(format!("keys [{}]", result.keys.join(" ")));
3599 }
3600 let detail = if parts.is_empty() {
3601 "nothing".to_string()
3602 } else {
3603 parts.join(" + ")
3604 };
3605 format!("sent {detail} to {}\n", result.id)
3606}
3607
3608fn render_glance_text(result: &GlanceResult) -> String {
3609 let mut text = format!("{} {} [{}]\n", result.id, result.agent, result.status);
3610 text.push_str(&result.tail);
3611 if !result.tail.is_empty() && !result.tail.ends_with('\n') {
3612 text.push('\n');
3613 }
3614 text
3615}
3616
3617fn render_resumed_text(result: &SessionView) -> String {
3618 format!(
3619 "resumed {} session {}\ntmux: {}\nattach: {}\n",
3620 result.agent, result.id, result.tmux_session, result.attach_command
3621 )
3622}
3623
3624fn render_activity_text(result: &activity::ActivityResult) -> String {
3625 format!(
3626 "{}: {:?} (revision {})\n",
3627 result.id, result.turn_state.phase, result.turn_state.revision
3628 )
3629}
3630
3631fn render_doctor_text(result: &activity::DoctorResult) -> String {
3632 let mut text = String::new();
3633 for provider in &result.providers {
3634 text.push_str(&format!(
3635 "{}: {} (configured: {})\n",
3636 provider.provider,
3637 provider.classification,
3638 if provider.configured { "yes" } else { "no" }
3639 ));
3640 text.push_str(&format!(" completion: {}\n", provider.completion));
3641 text.push_str(&format!(
3642 " attention: {}\n",
3643 provider.attention_correlation
3644 ));
3645 text.push_str(&format!(" next: {}\n", provider.guidance));
3646 }
3647 text
3648}
3649
3650fn render_setup_text(result: &activity::SetupResult) -> String {
3651 if result.action == "dry-run" {
3652 return format!(
3653 "{} activity setup preview: {} (configured now: {}; would configure: {})\n",
3654 result.provider,
3655 if result.would_change {
3656 "changes required"
3657 } else {
3658 "no change"
3659 },
3660 if result.configured { "yes" } else { "no" },
3661 if result.would_configure { "yes" } else { "no" }
3662 );
3663 }
3664 format!(
3665 "{} activity setup {}: {} (configured: {})\n",
3666 result.provider,
3667 result.action,
3668 if result.changed {
3669 "updated"
3670 } else {
3671 "no change"
3672 },
3673 if result.configured { "yes" } else { "no" }
3674 )
3675}
3676
3677fn render_delete_text(result: &DeleteResult) -> String {
3678 format!(
3679 "deleted {} (tmux killed: {})\n",
3680 result.id,
3681 if result.killed { "yes" } else { "no" }
3682 )
3683}
3684
3685fn local_attach_command(tmux_session: &str) -> String {
3686 format!("tmux attach -t {}", shell_words::quote(tmux_session))
3687}
3688
3689fn ssh_attach_command(host: &str, tmux_session: &str) -> String {
3690 let remote = local_attach_command(tmux_session);
3691 format!(
3692 "ssh -t {} {}",
3693 shell_words::quote(host),
3694 shell_words::quote(&remote)
3695 )
3696}
3697
3698fn slugify(value: &str) -> String {
3699 let mut slug = String::new();
3700 let mut last_dash = false;
3701 for ch in value.chars() {
3702 if ch.is_ascii_alphanumeric() {
3703 slug.push(ch.to_ascii_lowercase());
3704 last_dash = false;
3705 } else if !last_dash {
3706 slug.push('-');
3707 last_dash = true;
3708 }
3709 if slug.len() >= 32 {
3710 break;
3711 }
3712 }
3713 let slug = slug.trim_matches('-').to_string();
3714 if slug.is_empty() {
3715 "session".to_string()
3716 } else {
3717 slug
3718 }
3719}
3720
3721fn non_empty_env(key: &str) -> Option<String> {
3722 env::var(key).ok().filter(|value| !value.trim().is_empty())
3723}
3724
3725fn is_truthy_flag(value: &str) -> bool {
3726 matches!(
3727 value.trim().to_ascii_lowercase().as_str(),
3728 "1" | "true" | "yes" | "on"
3729 )
3730}
3731
3732fn env_truthy(key: &str) -> bool {
3733 non_empty_env(key).is_some_and(|value| is_truthy_flag(&value))
3734}
3735
3736fn binary_on_path(name: &str) -> Option<PathBuf> {
3738 let path = env::var_os("PATH")?;
3739 env::split_paths(&path)
3740 .map(|dir| dir.join(name))
3741 .find(|candidate| candidate.is_file())
3742}
3743
3744fn env_u64(key: &str, default: u64) -> u64 {
3745 non_empty_env(key)
3746 .and_then(|value| value.parse::<u64>().ok())
3747 .unwrap_or(default)
3748}
3749
3750fn short_hostname() -> Option<String> {
3751 let output = ProcessCommand::new("hostname").arg("-s").output().ok()?;
3752 if !output.status.success() {
3753 return None;
3754 }
3755 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
3756 if value.is_empty() { None } else { Some(value) }
3757}
3758
3759fn tail_lines(text: &str, tail: usize) -> String {
3760 if tail == 0 {
3761 return String::new();
3762 }
3763 let lines: Vec<&str> = text.lines().collect();
3764 let start = lines.len().saturating_sub(tail);
3765 let mut output = lines[start..].join("\n");
3766 if !output.is_empty() {
3767 output.push('\n');
3768 }
3769 output
3770}
3771
3772#[cfg(test)]
3773mod tests {
3774 use super::{
3775 AgentKind, CliContext, RecordRequest, create_record, resolve_session_id,
3776 strip_trailing_blank_lines,
3777 };
3778 use pretty_assertions::assert_eq;
3779 use std::fs;
3780 use std::path::Path;
3781
3782 fn test_context(state_dir: &Path) -> CliContext {
3783 CliContext {
3784 state_dir: state_dir.to_path_buf(),
3785 host: None,
3786 }
3787 }
3788
3789 fn create_test_record_id(
3790 context: &CliContext,
3791 agent: AgentKind,
3792 title: Option<&str>,
3793 explicit_id: Option<&str>,
3794 ) -> String {
3795 create_record(RecordRequest {
3796 context,
3797 agent,
3798 mode: "interactive",
3799 title,
3800 explicit_id,
3801 cwd: Path::new("/repo"),
3802 prompt: None,
3803 log_file_name: None,
3804 provider_resume: None,
3805 agent_args: Vec::new(),
3806 agent_bin: None,
3807 })
3808 .unwrap()
3809 .record
3810 .id
3811 }
3812
3813 #[test]
3814 fn create_record_untitled_default_ids_do_not_repeat_agent_slug() {
3815 let tmp = tempfile::TempDir::new().unwrap();
3816
3817 for (agent, slug) in [
3818 (AgentKind::Codex, "codex"),
3819 (AgentKind::Claude, "claude"),
3820 (AgentKind::Hermes, "hermes"),
3821 ] {
3822 let context = test_context(&tmp.path().join(slug));
3823 let id = create_test_record_id(&context, agent, None, None);
3824
3825 assert!(
3826 !id.contains(&format!("{slug}-{slug}")),
3827 "untitled {slug} id should not repeat the agent slug: {id}"
3828 );
3829 assert_eq!(
3830 id.matches(slug).count(),
3831 1,
3832 "untitled {slug} id should include the agent slug once: {id}"
3833 );
3834 }
3835 }
3836
3837 #[test]
3838 fn resolve_session_id_appends_collision_suffix_to_untitled_agent_base() {
3839 let tmp = tempfile::TempDir::new().unwrap();
3840 let context = test_context(tmp.path());
3841 let timestamp = "20260709-121932";
3842 let existing_id = format!("{timestamp}-codex");
3843 fs::create_dir_all(super::session_dir(&context, &existing_id)).unwrap();
3844
3845 let id = resolve_session_id(&context, None, AgentKind::Codex, timestamp, None).unwrap();
3846
3847 assert_eq!(id, format!("{existing_id}-1"));
3848 }
3849
3850 #[test]
3851 fn create_record_title_derived_default_ids_keep_title_slug() {
3852 let tmp = tempfile::TempDir::new().unwrap();
3853 let context = test_context(tmp.path());
3854
3855 let id = create_test_record_id(&context, AgentKind::Codex, Some("New Codex session"), None);
3856
3857 assert!(
3858 id.ends_with("-codex-new-codex-session"),
3859 "title-derived id should preserve the title slug: {id}"
3860 );
3861 }
3862
3863 #[test]
3864 fn create_record_explicit_ids_are_unchanged() {
3865 let tmp = tempfile::TempDir::new().unwrap();
3866 let context = test_context(tmp.path());
3867
3868 let id = create_test_record_id(&context, AgentKind::Codex, None, Some("custom-id"));
3869
3870 assert_eq!(id, "custom-id");
3871 }
3872
3873 #[test]
3874 fn strip_trailing_blank_lines_preserves_content_and_internal_blanks() {
3875 assert_eq!(
3877 strip_trailing_blank_lines("top-line\nsecond-line\n\n\n\n"),
3878 "top-line\nsecond-line"
3879 );
3880 assert_eq!(strip_trailing_blank_lines("a\nb\n \n\t\n"), "a\nb");
3881 assert_eq!(strip_trailing_blank_lines("a\n\nb\n\n\n"), "a\n\nb");
3883 assert_eq!(strip_trailing_blank_lines("\n\n\n"), "");
3885 assert_eq!(strip_trailing_blank_lines(""), "");
3886 assert_eq!(strip_trailing_blank_lines("only"), "only");
3888 }
3889
3890 #[test]
3891 fn new_session_command_runs_tmux_directly_without_scope() {
3892 use super::new_session_command;
3893 use std::ffi::OsStr;
3894 use std::path::Path;
3895
3896 let command = new_session_command(Path::new("/opt/tmux"), None);
3897 assert_eq!(command.get_program(), OsStr::new("/opt/tmux"));
3898 assert_eq!(command.get_args().count(), 0);
3900 }
3901
3902 #[test]
3903 fn new_session_command_wraps_tmux_in_systemd_scope() {
3904 use super::new_session_command;
3905 use std::ffi::OsStr;
3906 use std::path::Path;
3907
3908 let command = new_session_command(
3909 Path::new("/usr/bin/tmux"),
3910 Some(Path::new("/usr/bin/systemd-run")),
3911 );
3912 assert_eq!(command.get_program(), OsStr::new("/usr/bin/systemd-run"));
3913 let args: Vec<_> = command.get_args().collect();
3914 assert_eq!(
3915 args,
3916 vec![
3917 OsStr::new("--user"),
3918 OsStr::new("--scope"),
3919 OsStr::new("--quiet"),
3920 OsStr::new("--collect"),
3921 OsStr::new("--"),
3922 OsStr::new("/usr/bin/tmux"),
3923 ]
3924 );
3925 }
3926
3927 #[test]
3928 fn is_truthy_flag_accepts_common_true_values_case_insensitively() {
3929 use super::is_truthy_flag;
3930
3931 for value in ["1", "true", "TRUE", " Yes ", "on", "On"] {
3932 assert!(is_truthy_flag(value), "expected truthy: {value:?}");
3933 }
3934 for value in ["0", "false", "no", "off", "", " ", "2", "enabled"] {
3935 assert!(!is_truthy_flag(value), "expected falsey: {value:?}");
3936 }
3937 }
3938}