Skip to main content

agent_session/
lib.rs

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;
27// The provider session-resume resolver, session-history scanning primitives, and
28// bounded-scan budgets live in `nils-provider-resume` so `codex-cli`,
29// `claude-cli`, and this crate share one implementation. Items reused by the
30// `provider_prompt` module via `crate::` are re-exported at crate scope.
31use 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            // Provider telemetry is deliberately fail-open: malformed or stale
325            // hook input must never block a prompt, permission, or turn.
326            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        &timestamp,
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    // The shared resolver owns the bounded history scan and returns a structured
1062    // outcome; the user-facing error text and exit-code mapping stay here.
1063    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
1152/// Resolve the `systemd-run` binary used to launch the tmux server inside a
1153/// transient systemd `--user` scope, or `None` to launch tmux directly.
1154///
1155/// `agent-session serve` starts each session as a child `tmux new-session -d`,
1156/// so the tmux server it spawns lands in the caller's cgroup. Under the
1157/// agent-console serve systemd service that means the server shares the unit
1158/// cgroup, and a service stop/restart can kill every live session
1159/// (`sympoies/agent-console#122`). Wrapping the server start in
1160/// `systemd-run --user --scope` moves it into its own transient scope cgroup, a
1161/// sibling of the service, so the sessions survive even an explicit
1162/// cgroup-wide kill of the serve unit.
1163///
1164/// This is opt-in via `AGENT_SESSION_TMUX_SCOPE` (the serve launcher sets it) and
1165/// only engages when a systemd `--user` manager is actually reachable, so an
1166/// opt-in on an unsupported host (no user manager, missing `systemd-run`,
1167/// non-Linux) degrades to a direct launch instead of failing session creation.
1168fn 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    // A running systemd --user manager exposes this socket; without it
1176    // `systemd-run --user` cannot register the scope.
1177    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
1188/// Build the base command for a `tmux new-session` that may start the tmux
1189/// server. With `scope_runner` set the server is launched inside a transient
1190/// systemd user scope (see [`tmux_scope_runner`]); otherwise tmux runs directly.
1191/// Callers append the `new-session ...` arguments to the returned command; both
1192/// forms accept the same trailing arguments because `systemd-run`'s `--`
1193/// hands everything after the tmux binary straight to tmux.
1194fn 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    // The initial prompt is submitted; `send` deliberately leaves this to
1531    // an explicit `--key enter`.
1532    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
1537/// Load `file` into a named tmux buffer and paste it into `target`, deleting the
1538/// buffer after paste (`-d`) or on failure. Shared by `paste_prompt` (initial
1539/// prompt) and `send` (steering text) so the buffer lifecycle lives in one place.
1540fn 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
1605/// Push literal text (via a private buffer file, never argv/stdout) and then
1606/// each special key into the live pane. `send-keys` interprets the tmux key
1607/// names, so approvals like Enter/Esc/Ctrl-C/arrows work from mobile.
1608fn 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    // Empty text (an empty `--text ""` or an empty stdin pipe) collapses to
1638    // `None` so the caller's empty-send guard treats it as "no text" rather than
1639    // pasting an empty buffer and reporting `sent_text: true` for a no-op. A
1640    // whitespace-only value is preserved: a space can be a meaningful keystroke.
1641    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
1697/// Run `tmux capture-pane -p -S -<tail>` for a session. Returns `Ok(Some(text))`
1698/// on success, `Ok(None)` when tmux ran but capture failed (a non-running or
1699/// gone pane), and `Err` only when the tmux binary could not be spawned. Shared
1700/// by `glance` and `logs` so the capture invocation lives in one place.
1701fn 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
1728/// Read the tmux paste buffer for a session's server (`tmux show-buffer`). tmux
1729/// buffers are server-global, so this returns the most recently set buffer on the
1730/// socket — which, for an agent pane whose TUI copies mouse selections into the
1731/// buffer (e.g. Claude Code's "copied N chars to tmux buffer"), is the user's last
1732/// on-screen selection. The `id` only validates the session (and picks the
1733/// daemon's socket); the buffer itself is not session-scoped. A fresh server with
1734/// no buffer yet exits non-zero ("no buffers") — treated as an empty selection,
1735/// not an error, so "nothing selected yet" is a normal empty result.
1736fn session_clipboard_buffer(
1737    context: &CliContext,
1738    id: &str,
1739    tmux_bin: &Path,
1740) -> Result<String, CliError> {
1741    // Validate the session exists first (clean not-found) before touching tmux.
1742    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
1774/// `capture-pane` pads its output to the full pane height with blank lines, so a
1775/// short, top-anchored pane ends with many empties. Drop the trailing blank
1776/// lines before taking the tail, or `glance` would show the empty bottom of the
1777/// pane instead of the actual recent content.
1778fn 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
1825/// Bump `updated_at` to now so `list` can order by real control-plane activity.
1826/// Applied on `send` (a steering action); intentionally not on `glance`, which
1827/// is a high-frequency dashboard poll that would otherwise make `updated_at`
1828/// track polling rather than activity.
1829fn 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    // The persisted record above is the source of truth. Claude also carries its
1846    // own prompt-bar display name, which we set once via `--name` at launch
1847    // (`start_interactive_tmux`) and which never changes afterwards, so a renamed
1848    // session would show a stale name in the terminal while the console shows the
1849    // new one. Claude exposes `/rename <name>` as a runtime rename, so push the
1850    // new title into the live pane to keep the two in sync. Best-effort: a tmux
1851    // hiccup must not fail the title update, and Codex/Hermes have no such display
1852    // name so this is Claude-only and only when the title actually changed.
1853    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
1863/// Push Claude's `/rename <name>` slash command into the live pane so the
1864/// prompt-bar display name follows a title change. Reuses the same buffered
1865/// paste + Enter injection as steering `send`, and collapses any embedded
1866/// newlines so the rename stays a single submitted line.
1867fn 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    // Create the session dir as an ATOMIC ownership claim (fail if it already
3427    // exists) rather than create_dir_all. This closes a create/create race:
3428    // without it, two concurrent creates of the same id both pass the earlier
3429    // exists() check, both proceed, and the one whose tmux new-session loses the
3430    // duplicate-name race runs cleanup_created_record -> remove_dir_all on the
3431    // shared dir, deleting the winner's session.json and orphaning a live agent.
3432    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
3736/// First `name` found on `PATH` as a regular file, or `None`.
3737fn 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        // Trailing blank/whitespace-only lines are dropped...
3876        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        // ...but internal blank lines are preserved (only the tail is trimmed).
3882        assert_eq!(strip_trailing_blank_lines("a\n\nb\n\n\n"), "a\n\nb");
3883        // An all-blank pane collapses to empty.
3884        assert_eq!(strip_trailing_blank_lines("\n\n\n"), "");
3885        assert_eq!(strip_trailing_blank_lines(""), "");
3886        // Content with no trailing blanks is unchanged.
3887        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        // The caller appends `new-session ...`, so the base command has no args.
3899        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}