diff --git a/docs/orgtrack-cli.md b/docs/orgtrack-cli.md index e4cc91e187..48867912f1 100644 --- a/docs/orgtrack-cli.md +++ b/docs/orgtrack-cli.md @@ -30,10 +30,42 @@ The CLI is only argument parsing, orchestration, and formatting over: 3. `usage_dashboard::*` and `load_activity_chunks_for_session` — analyze & replay. -Commands: `sources`, `scan`, `list`/`ls`, `search`, `usage`/`stats`, `show`. -See the crate README for the full surface. Verified end-to-end against the local -machine's real history (Claude Code + Codex: 289 listable sessions, 449 -token-bearing sessions, ~$5.5k of estimated spend, 97.8% cache-hit rate). +Commands: `sources`, `scan`, `list`/`ls`, `search`, `usage`/`stats`, `show`, +`resume`. See the crate README for the full surface. Verified end-to-end against +the local machine's real history (Claude Code + Codex: 289 listable sessions, +449 token-bearing sessions, ~$5.5k of estimated spend, 97.8% cache-hit rate). + +### `resume` — continue an imported session in its own CLI + +`orgtrack resume ` reopens an imported session in the CLI that +owns it, sharing the desktop app's "Continue in CLI" plumbing +(`orgtrack_core::sources::cli_resume`): + +- `claudecodeapp-` → `claude --resume `, executed from the + session's recorded workspace (Claude Code keys session storage on the + project path, so the original cwd is required — verified empirically: + resuming from another directory fails with "No conversation found"). +- `codexapp-rollout--` → `codex resume ` (bare thread uuid + extracted from the rollout stem; Codex looks sessions up globally). +- `cursorcliapp-` → `cursor-agent --resume `. +- `opencodeapp-ses_*` → `opencode --session ` (central db, global ids). +- `mimocodeapp-ses_*` → `mimo --session ` (OpenCode fork, same flag — + verified against `mimo --help`). +- `clineapp-_` → `cline --id ` (global sessions.db). +- `ompapp-` → `omp --session ` — oh-my-pi resolves + bare ids against the *current project's* session dir, so the plan + addresses the session file by absolute path instead (works from anywhere). + +Only that session's provider is scanned. By default the process execs the +CLI (the TUI takes over the terminal); `--print` emits the +`cd && ` line instead. + +Not resumable, and why: `cursor_ide` (composer ids share no space with +`cursor-agent`'s chat store — checked empirically, zero overlap), `windsurf` +/ `warp` / `trae` / `qoder` (app-bound stores, no CLI resume surface), and +`zcode` / `qoder_cli` / `workbuddy` (their CLIs likely resume — OpenCode +fork / documented `/resume` / Claude Code fork — but no binary was present +to verify the exact flag shape; extend `cli_resume` once one is). ## Why a Rust binary (not the TS stub) diff --git a/src-tauri/crates/orgtrack-cli/src/commands.rs b/src-tauri/crates/orgtrack-cli/src/commands.rs index 734503481b..9cc770afce 100644 --- a/src-tauri/crates/orgtrack-cli/src/commands.rs +++ b/src-tauri/crates/orgtrack-cli/src/commands.rs @@ -945,3 +945,128 @@ pub(crate) fn chunk_role(chunk: &ActivityChunk) -> String { other => other.to_string(), } } + +/// The seven imported sources whose owning CLI can reopen a session by id. +/// Maps a canonical (prefixed) session id back to its source so `resume` +/// scans exactly one provider instead of all of them. +fn resume_source_for_session_id(session_id: &str) -> Option<&'static str> { + use orgtrack_core::sources::imported_history::metadata; + use orgtrack_core::sources::{claude_code, cline, codex, cursor_cli, mimo_code, omp, opencode}; + if session_id.starts_with(claude_code::SESSION_PREFIX) { + Some(metadata::SOURCE_CLAUDE_CODE) + } else if session_id.starts_with(codex::SESSION_PREFIX) { + Some(metadata::SOURCE_CODEX_APP) + } else if session_id.starts_with(cursor_cli::SESSION_PREFIX) { + Some(metadata::SOURCE_CURSOR_CLI) + } else if session_id.starts_with(opencode::history::OPENCODE_SESSION_PREFIX) { + Some(metadata::SOURCE_OPENCODE) + } else if session_id.starts_with(mimo_code::history::MIMO_CODE_SESSION_PREFIX) { + Some(metadata::SOURCE_MIMO_CODE) + } else if session_id.starts_with(cline::history::CLINE_SESSION_PREFIX) { + Some(metadata::SOURCE_CLINE) + } else if session_id.starts_with(omp::history::OMP_SESSION_PREFIX) { + Some(metadata::SOURCE_OMP) + } else { + None + } +} + +/// `orgtrack resume ` — reopen an imported session in the CLI +/// that owns it (`claude --resume`, `codex resume`, `cursor-agent --resume`). +/// Scans only that session's provider into the index, plans the invocation +/// via core's `cli_resume`, then either prints it (`--print`) or replaces +/// this process with it so the CLI's TUI takes over the terminal. +pub(crate) fn cmd_resume(opts: &Options) -> Result<(), String> { + use orgtrack_core::sources::cli_resume::{cli_resume_plan_for_cached_session, shell_quote}; + + let Some(session_id) = opts.positionals.first().cloned() else { + return Err( + "resume needs a session id, e.g. `orgtrack resume claudecodeapp-` \ + (ids come from `orgtrack list`)" + .into(), + ); + }; + let Some(source) = resume_source_for_session_id(&session_id) else { + return Err(format!( + "'{session_id}' is not from a CLI-resumable source — resume supports \ + claude_code, codex_app, cursor_cli, opencode, mimo_code, cline, and \ + omp session ids" + )); + }; + + let target = db_target(opts)?; + if !opts.no_scan { + // Scope the scan to the one provider that owns this id; resume never + // needs the other tools' sessions in the index. + let scoped = Options { + sources: vec![source.to_string()], + db: opts.db.clone(), + timeout: opts.timeout, + no_plugins: true, + ..Options::default() + }; + scan_all(&target.path, &scoped, &[]); + } + let conn = open_conn(&target.path)?; + let Some((plan, _session)) = cli_resume_plan_for_cached_session(&conn, &session_id)? else { + return Err(format!( + "'{session_id}' was not found in {source}'s local history (or is a \ + subagent transcript). Run `orgtrack list --source {source}` to see ids." + )); + }; + + let cwd = plan + .cwd + .as_deref() + .filter(|path| std::path::Path::new(path).is_dir()); + let command_line = match cwd { + Some(dir) => format!("cd {} && {}", shell_quote(dir), plan.display_command()), + None => plan.display_command(), + }; + + if opts.print { + println!("{command_line}"); + return Ok(()); + } + + if plan.requires_cwd && cwd.is_none() { + return Err(format!( + "{} can only resume this session from its original folder, which no \ + longer exists ({}). Use --print to get the command anyway.", + plan.default_binary, + plan.cwd.as_deref().unwrap_or("unknown") + )); + } + + eprintln!( + "Resuming {source} session {} …\n$ {command_line}", + plan.native_session_id + ); + let mut command = std::process::Command::new(plan.default_binary); + command.args(&plan.resume_args); + if let Some(dir) = cwd { + command.current_dir(dir); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // exec never returns on success — the owning CLI takes over the tty. + let err = command.exec(); + Err(format!("failed to launch {}: {err}", plan.default_binary)) + } + #[cfg(not(unix))] + { + let status = command + .status() + .map_err(|err| format!("failed to launch {}: {err}", plan.default_binary))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "{} exited with {status}", + plan.default_binary + )) + } + } +} diff --git a/src-tauri/crates/orgtrack-cli/src/main.rs b/src-tauri/crates/orgtrack-cli/src/main.rs index 8bf1fb6f7f..5a5f02d963 100644 --- a/src-tauri/crates/orgtrack-cli/src/main.rs +++ b/src-tauri/crates/orgtrack-cli/src/main.rs @@ -30,8 +30,8 @@ mod store; mod triggers; use crate::commands::{ - cmd_check, cmd_list, cmd_plugins, cmd_scan, cmd_search_content, cmd_show, cmd_sources, - cmd_usage, + cmd_check, cmd_list, cmd_plugins, cmd_resume, cmd_scan, cmd_search_content, cmd_show, + cmd_sources, cmd_usage, }; use crate::scan::validate_sources; @@ -64,6 +64,8 @@ COMMANDS: usage Token & cost analytics (alias: stats) check Evaluate usage triggers; exit non-zero on error show Print a session's conversation/activity stream + resume Reopen an imported session in its own CLI (claude, + codex, cursor-agent, opencode, mimo, cline, omp) plugins list Show discovered loader plugins plugins trust Trust an exec plugin so it may run help Show this help (alias: --help, -h) @@ -83,6 +85,7 @@ OPTIONS: across machines). Shows in `list --json`. --no-scan Skip disk scan; read an existing --db as-is. --no-plugins Ignore discovered loader plugins. + --print `resume` only prints the command instead of running it. --format Output: table (default), json, md, csv, or a formatter plugin id. --json Shorthand for --format json. @@ -103,6 +106,8 @@ EXAMPLES: orgtrack list --format md > sessions.md orgtrack usage --format csv > usage.csv orgtrack show claude_code-4f1e... --format md > session.md + orgtrack resume claudecodeapp-4f1e... + orgtrack resume codexapp-rollout-2026-07-29T10-00-00-4f1e... --print "; fn main() { @@ -136,6 +141,7 @@ pub(crate) struct Options { pub(crate) content: bool, pub(crate) strict: bool, pub(crate) json: bool, + pub(crate) print: bool, } impl Options { @@ -222,6 +228,7 @@ fn run(args: &[String]) -> Result<(), String> { "usage" | "stats" => cmd_usage(&opts, loaders, formatters), "check" => cmd_check(&opts, loaders, formatters, &discovered.hooks), "show" => cmd_show(&opts, loaders, processors, formatters), + "resume" => cmd_resume(&opts), _ => Err(format!( "unknown command '{other}'. Run `orgtrack help` for usage." )), @@ -256,6 +263,7 @@ fn parse_options(args: &[String]) -> Result { "--triggers" => opts.triggers = Some(next_value(&mut iter, "--triggers")?.to_string()), "--content" => opts.content = true, "--strict" => opts.strict = true, + "--print" => opts.print = true, "--timeout" => { let raw = next_value(&mut iter, "--timeout")?; opts.timeout = Some( diff --git a/src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs b/src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs new file mode 100644 index 0000000000..4708b0c6ef --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs @@ -0,0 +1,527 @@ +//! Native-CLI resume planning for imported external sessions. +//! +//! An imported transcript is read-only inside ORGII, but the CLI that wrote +//! it can usually reopen the very same conversation (`claude --resume`, +//! `codex resume`, `cursor-agent --resume`, `opencode --session`, …). This +//! module owns the mapping from an imported-history cache row to that +//! invocation, so the desktop app (chat-panel TUI terminal) and the +//! `orgtrack` CLI agree on which sources are resumable and how the command +//! line is spelled. +//! +//! Only sources whose CLI has a session resume entry point verified against +//! the real binary (or an unambiguous fork of one) belong here. Deliberately +//! absent: +//! - `cursor_ide` — IDE composers live in `state.vscdb` and share no id +//! space with `cursor-agent`'s `~/.cursor/chats` store (verified +//! empirically: zero overlap), so no CLI can reopen them. +//! - `windsurf`, `warp`, `trae`, `qoder` — app/IDE-bound conversation +//! stores with no CLI resume surface at all. +//! - `zcode`, `qoder_cli`, `workbuddy` — their CLIs likely resume +//! (OpenCode fork / documented `/resume` / Claude Code fork), but no +//! binary was available to verify the exact flag shape; extend here once +//! one is. + +use rusqlite::Connection; +use serde::Serialize; + +use super::imported_history::cache::{ + query_cached_session_by_session_id_from_conn, ImportedHistoryCachedSession, +}; +use super::imported_history::metadata::{ + SOURCE_CLAUDE_CODE, SOURCE_CLINE, SOURCE_CODEX_APP, SOURCE_CURSOR_CLI, SOURCE_MIMO_CODE, + SOURCE_OMP, SOURCE_OPENCODE, +}; + +/// How to hand an imported external session back to the CLI that owns it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliResumePlan { + /// Imported-history source id the plan derives from. + pub source: &'static str, + /// `code_sessions.cli_agent_type` value of the owning CLI, so hosts can + /// reuse managed-session infrastructure (launch profiles, TUI rows, + /// managed-mirror dedup) unchanged. + pub cli_agent_type: &'static str, + /// Bare binary to launch when no launch-profile override applies. + pub default_binary: &'static str, + /// Arguments appended after the binary to reopen the session. + pub resume_args: Vec, + /// The session id the CLI itself accepts (bare thread uuid / chat id). + pub native_session_id: String, + /// Working directory the resume should run in — the session's recorded + /// workspace. `None` when the source never recorded one. + pub cwd: Option, + /// Whether the CLI can only locate the session from its original + /// working directory (Claude Code keys session storage on the project + /// path; Codex and cursor-agent look sessions up globally). + pub requires_cwd: bool, +} + +impl CliResumePlan { + /// The full resume invocation as one display string, shell-quoted for + /// the host's default chat-panel PTY shell — PowerShell on Windows + /// (`terminal::pty_commands::shells` makes it the Windows default), + /// POSIX elsewhere. + pub fn display_command(&self) -> String { + self.display_command_for_shell(cfg!(windows)) + } + + /// `display_command`, with the target shell pinned explicitly instead + /// of read from the host OS, so both quoting styles can be exercised + /// without `#[cfg(windows)]`-gating the test itself. + pub fn display_command_for_shell(&self, windows: bool) -> String { + std::iter::once(self.default_binary.to_string()) + .chain(self.resume_args.iter().cloned()) + .map(|part| shell_quote_for_shell(&part, windows)) + .collect::>() + .join(" ") + } +} + +/// Shell-quote for the host's default chat-panel PTY shell (PowerShell on +/// Windows, POSIX elsewhere). Safe-charset values pass through unquoted so +/// the common `claude --resume ` stays copy-paste clean. +pub fn shell_quote(value: &str) -> String { + shell_quote_for_shell(value, cfg!(windows)) +} + +/// `shell_quote`, with the target shell pinned explicitly instead of read +/// from the host OS (see `display_command_for_shell`). +pub fn shell_quote_for_shell(value: &str, windows: bool) -> String { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"_./:@%+=,-".contains(&byte)) + { + return value.to_string(); + } + if windows { + // PowerShell single-quoted strings pass backslashes through + // literally (unlike POSIX), so a Windows path only needs its + // embedded single quotes escaped, by doubling them. + format!("'{}'", value.replace('\'', "''")) + } else { + format!("'{}'", value.replace('\'', r"'\''")) + } +} + +/// Build the resume plan for one imported session, or `None` when the +/// source has no CLI resume path (or the id shape rules it out). +/// `source_path` is the imported transcript/store path; only path-addressed +/// CLIs (oh-my-pi's `--session `) consume it. +pub fn cli_resume_plan( + source: &str, + source_session_id: &str, + repo_path: Option<&str>, + source_path: Option<&str>, +) -> Option { + let cwd = repo_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string); + match source { + // Claude Code sessions are `.jsonl` under the project slug; + // the file stem IS the id `--resume` accepts. Non-uuid stems + // (fixtures, sidecars) are not resumable. + SOURCE_CLAUDE_CODE => { + if !is_uuid_like(source_session_id) { + return None; + } + Some(CliResumePlan { + source: SOURCE_CLAUDE_CODE, + cli_agent_type: "claude_code", + default_binary: "claude", + resume_args: vec!["--resume".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: true, + }) + } + // Codex imports key on the rollout file stem + // (`rollout--`), while `codex resume` takes + // the bare thread uuid — same suffix extraction the managed-mirror + // dedup uses. + SOURCE_CODEX_APP => { + let thread_uuid = codex_thread_uuid_from_stem(source_session_id)?; + Some(CliResumePlan { + source: SOURCE_CODEX_APP, + cli_agent_type: "codex", + default_binary: "codex", + resume_args: vec!["resume".to_string(), thread_uuid.to_string()], + native_session_id: thread_uuid.to_string(), + cwd, + requires_cwd: false, + }) + } + // cursor-agent stores one `store.db` per chat uuid; `--resume ` + // reopens it from anywhere. + SOURCE_CURSOR_CLI => { + if !is_uuid_like(source_session_id) { + return None; + } + Some(CliResumePlan { + source: SOURCE_CURSOR_CLI, + cli_agent_type: "cursor_cli", + default_binary: "cursor-agent", + resume_args: vec!["--resume".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: false, + }) + } + // OpenCode keeps every session in one central SQLite db keyed by + // `ses_*` ids; `--session ` reopens one from anywhere. + SOURCE_OPENCODE => { + if !is_plain_session_token(source_session_id) + || !source_session_id.starts_with("ses_") + { + return None; + } + Some(CliResumePlan { + source: SOURCE_OPENCODE, + cli_agent_type: "opencode", + default_binary: "opencode", + resume_args: vec!["--session".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: false, + }) + } + // MiMo Code is an OpenCode fork with the same `ses_*` central store + // and the same `--session ` flag (verified against `mimo --help`). + SOURCE_MIMO_CODE => { + if !is_plain_session_token(source_session_id) + || !source_session_id.starts_with("ses_") + { + return None; + } + Some(CliResumePlan { + source: SOURCE_MIMO_CODE, + cli_agent_type: "mimo_code", + default_binary: "mimo", + resume_args: vec!["--session".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: false, + }) + } + // Cline registers sessions (`_` ids) in a global + // sessions.db; `--id ` resumes one by id. + SOURCE_CLINE => { + if !is_plain_session_token(source_session_id) { + return None; + } + Some(CliResumePlan { + source: SOURCE_CLINE, + cli_agent_type: "cline", + default_binary: "cline", + resume_args: vec!["--id".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: false, + }) + } + // oh-my-pi (pi family) looks bare ids up in the *current project's* + // session dir, so id-resume is cwd-fragile — but `--session` also + // accepts an absolute session-file path, which resolves from + // anywhere. Requires the imported transcript path. + SOURCE_OMP => { + let path = source_path.map(str::trim).filter(|path| !path.is_empty())?; + if !is_plain_session_token(source_session_id) { + return None; + } + Some(CliResumePlan { + source: SOURCE_OMP, + cli_agent_type: "omp", + default_binary: "omp", + resume_args: vec!["--session".to_string(), path.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: false, + }) + } + _ => None, + } +} + +/// A sane single-token session id: non-empty, no whitespace or quoting +/// hazards. Provider arms add their own shape checks on top. +fn is_plain_session_token(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +/// Resolve a canonical (prefixed) session id against the imported-history +/// cache and plan its CLI resume. Returns the cache row alongside the plan +/// so hosts can run freshness/availability checks (`source_path` on disk, +/// cwd existence) without a second query. Subagent rows resolve to `None`: +/// their transcripts are children of a conversation the CLI resumes as a +/// whole. +pub fn cli_resume_plan_for_cached_session( + conn: &Connection, + session_id: &str, +) -> Result, String> { + let Some((source, session)) = query_cached_session_by_session_id_from_conn(conn, session_id)? + else { + return Ok(None); + }; + if session.parent_session_id.is_some() { + return Ok(None); + } + let plan = cli_resume_plan( + &source, + &session.source_session_id, + session.repo_path.as_deref(), + Some(session.source_path.as_str()), + ); + Ok(plan.map(|plan| (plan, session))) +} + +/// `rollout--` → ``. Accepts a bare +/// uuid too (runner bindings and older imports carry that form). +fn codex_thread_uuid_from_stem(stem: &str) -> Option<&str> { + if is_uuid_like(stem) { + return Some(stem); + } + if stem.len() > 37 { + let (head, tail) = stem.split_at(stem.len() - 36); + if head.ends_with('-') && is_uuid_like(tail) { + return Some(tail); + } + } + None +} + +fn is_uuid_like(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "019f6e88-3bc8-77b3-9f21-30af8dd9a1cd"; + + #[test] + fn claude_plan_uses_resume_flag_and_requires_cwd() { + let plan = + cli_resume_plan(SOURCE_CLAUDE_CODE, UUID, Some("/tmp/project"), None).expect("plan"); + assert_eq!(plan.cli_agent_type, "claude_code"); + assert_eq!(plan.default_binary, "claude"); + assert_eq!(plan.resume_args, vec!["--resume", UUID]); + assert_eq!(plan.native_session_id, UUID); + assert_eq!(plan.cwd.as_deref(), Some("/tmp/project")); + assert!(plan.requires_cwd); + assert_eq!(plan.display_command(), format!("claude --resume {UUID}")); + } + + #[test] + fn claude_rejects_non_uuid_stems() { + assert!(cli_resume_plan(SOURCE_CLAUDE_CODE, "claude-meta", None, None).is_none()); + } + + #[test] + fn codex_plan_extracts_thread_uuid_from_rollout_stem() { + let stem = format!("rollout-2026-07-17T13-24-09-{UUID}"); + let plan = cli_resume_plan(SOURCE_CODEX_APP, &stem, None, None).expect("plan"); + assert_eq!(plan.cli_agent_type, "codex"); + assert_eq!(plan.resume_args, vec!["resume", UUID]); + assert_eq!(plan.native_session_id, UUID); + assert!(!plan.requires_cwd); + assert!(plan.cwd.is_none()); + } + + #[test] + fn codex_plan_accepts_bare_uuid_and_rejects_boundaryless_suffix() { + assert!(cli_resume_plan(SOURCE_CODEX_APP, UUID, None, None).is_some()); + // 36 hex-ish tail without a '-' boundary before it must not match. + let boundaryless = format!("rollout{UUID}"); + assert!(cli_resume_plan(SOURCE_CODEX_APP, &boundaryless, None, None).is_none()); + } + + #[test] + fn cursor_cli_plan_uses_resume_flag_globally() { + let plan = cli_resume_plan(SOURCE_CURSOR_CLI, UUID, Some(" "), None).expect("plan"); + assert_eq!(plan.default_binary, "cursor-agent"); + assert_eq!(plan.resume_args, vec!["--resume", UUID]); + // Blank repo paths normalize away instead of producing a " " cwd. + assert!(plan.cwd.is_none()); + assert!(!plan.requires_cwd); + } + + #[test] + fn unsupported_sources_yield_no_plan() { + for source in ["cursor_ide", "windsurf", "warp", "trae", "definitely_not"] { + assert!(cli_resume_plan(source, UUID, None, None).is_none(), "{source}"); + } + } + + #[test] + fn opencode_family_plans_use_session_flag_and_require_ses_ids() { + for (source, binary) in [(SOURCE_OPENCODE, "opencode"), (SOURCE_MIMO_CODE, "mimo")] { + let plan = cli_resume_plan(source, "ses_09189826fffe1Z2F3Yiih6BMdM", None, None) + .expect("plan"); + assert_eq!(plan.default_binary, binary); + assert_eq!( + plan.resume_args, + vec!["--session", "ses_09189826fffe1Z2F3Yiih6BMdM"] + ); + assert!(!plan.requires_cwd); + // Non-ses ids (sqlite artifacts, fixtures) never plan. + assert!(cli_resume_plan(source, "msg_0123", None, None).is_none()); + assert!(cli_resume_plan(source, "ses_ has space", None, None).is_none()); + } + } + + #[test] + fn cline_plan_uses_id_flag() { + let plan = cli_resume_plan(SOURCE_CLINE, "1784035830913_lu2zm", None, None).expect("plan"); + assert_eq!(plan.default_binary, "cline"); + assert_eq!(plan.resume_args, vec!["--id", "1784035830913_lu2zm"]); + assert!(!plan.requires_cwd); + } + + #[test] + fn omp_plan_addresses_the_session_file_and_needs_a_path() { + let plan = cli_resume_plan( + SOURCE_OMP, + "2026-07-20_abcdef01-2345-6789-abcd-ef0123456789", + Some("/tmp/project"), + Some("/Users/x/.omp/agent/sessions/-tmp-project/session.jsonl"), + ) + .expect("plan"); + assert_eq!(plan.default_binary, "omp"); + assert_eq!( + plan.resume_args, + vec![ + "--session", + "/Users/x/.omp/agent/sessions/-tmp-project/session.jsonl" + ] + ); + assert!(!plan.requires_cwd); + // Without the transcript path there is nothing to address. + assert!(cli_resume_plan(SOURCE_OMP, "stem", Some("/tmp/project"), None).is_none()); + assert!(cli_resume_plan(SOURCE_OMP, "stem", None, Some(" ")).is_none()); + } + + #[test] + fn display_command_quotes_unsafe_arguments_posix() { + let mut plan = cli_resume_plan(SOURCE_CLAUDE_CODE, UUID, None, None).expect("plan"); + plan.resume_args = vec!["--resume".to_string(), "a b'c".to_string()]; + // Pinned to POSIX rather than `display_command()` so this assertion + // is stable regardless of the host OS running the test suite. + assert_eq!( + plan.display_command_for_shell(false), + r#"claude --resume 'a b'\''c'"# + ); + } + + #[test] + fn display_command_quotes_unsafe_arguments_powershell_on_windows() { + let mut plan = cli_resume_plan(SOURCE_CLAUDE_CODE, UUID, None, None).expect("plan"); + // A backslash-heavy Windows path (e.g. omp's `--session `) + // must pass through unescaped under PowerShell single-quoting, + // where POSIX's `'\''` escaping would corrupt it. + plan.resume_args = vec![ + "--session".to_string(), + r"C:\Users\me\.omp\agent\sessions\a b'c.jsonl".to_string(), + ]; + assert_eq!( + plan.display_command_for_shell(true), + r#"claude --session 'C:\Users\me\.omp\agent\sessions\a b''c.jsonl'"# + ); + } + + #[test] + fn shell_quote_matches_shell_quote_for_shell_at_cfg_windows() { + // `shell_quote` should defer to the host's actual target OS, not + // silently default to POSIX. + assert_eq!( + shell_quote("a b'c"), + shell_quote_for_shell("a b'c", cfg!(windows)) + ); + } + + #[test] + fn cached_lookup_plans_only_resumable_rows() { + use crate::sources::imported_history::metadata::{ + ImportedHistoryCacheInput, ImportedHistoryImpactStats, + }; + + let mut conn = Connection::open_in_memory().expect("open"); + crate::store::sqlite::SqliteRecordStore::init_tables(&conn).expect("init core tables"); + crate::store::sqlite::SqliteRecordStore::init_source_cache_tables(&conn) + .expect("init source cache tables"); + + let input = |source: &'static str, source_session_id: &str, session_id: &str| { + ImportedHistoryCacheInput { + source, + source_session_id: source_session_id.to_string(), + session_id: session_id.to_string(), + source_path: "/tmp/source".to_string(), + source_record_key: source_session_id.to_string(), + source_mtime_ms: 1, + source_size_bytes: 1, + source_fingerprint: "fp".to_string(), + parser_version: 1, + name: "session".to_string(), + created_at_ms: 1, + updated_at_ms: 2, + model: None, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + repo_path: Some("/tmp/project".to_string()), + branch: None, + impact: ImportedHistoryImpactStats::default(), + listable: true, + source_metadata_json: None, + parent_session_id: None, + } + }; + let claude_session_id = format!("claudecodeapp-{UUID}"); + crate::sources::imported_history::cache::upsert_imported_session_cache_from_conn( + &mut conn, + &[ + input(SOURCE_CLAUDE_CODE, UUID, &claude_session_id), + input("opencode", "ses_123", "opencodeapp-ses_123"), + input("windsurf", "cascade-1", "windsurfapp-cascade-1"), + ], + ) + .expect("upsert"); + + let (plan, session) = cli_resume_plan_for_cached_session(&conn, &claude_session_id) + .expect("query") + .expect("plan"); + assert_eq!(plan.native_session_id, UUID); + assert_eq!(session.repo_path.as_deref(), Some("/tmp/project")); + + // The cached row's transcript path feeds path-addressed providers. + let (opencode_plan, opencode_session) = + cli_resume_plan_for_cached_session(&conn, "opencodeapp-ses_123") + .expect("query") + .expect("plan"); + assert_eq!(opencode_plan.resume_args, vec!["--session", "ses_123"]); + assert_eq!(opencode_session.source_path, "/tmp/source"); + + assert!(cli_resume_plan_for_cached_session(&conn, "windsurfapp-cascade-1") + .expect("query") + .is_none()); + assert!(cli_resume_plan_for_cached_session(&conn, "unknown-id") + .expect("query") + .is_none()); + } +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/mod.rs index c1929a1c6a..251c7c17a9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/mod.rs @@ -39,6 +39,7 @@ pub trait SourceAdapter { pub mod activity; pub mod anthropic_jsonl; pub mod claude_code; +pub mod cli_resume; pub mod cline; pub mod codex; pub mod cursor_cli; diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 7663db9c86..98b30e080e 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -1055,6 +1055,7 @@ orgtrack::history_commands::external_cli_sources_detect, orgtrack::history_commands::external_cli_source_probe, orgtrack::history_commands::external_history_rescan_source, orgtrack::history_commands::external_history_rescan_sources, +orgtrack::history_commands::external_history_cli_resume_plan, orgtrack::history_commands::external_history_auto_import_recent_paths, orgtrack::history_commands::windsurf_history_chunks, orgtrack::history_commands::windsurf_recent_paths, diff --git a/src-tauri/src/orgtrack/history_commands.rs b/src-tauri/src/orgtrack/history_commands.rs index 8fb43be859..d23225b81c 100644 --- a/src-tauri/src/orgtrack/history_commands.rs +++ b/src-tauri/src/orgtrack/history_commands.rs @@ -537,6 +537,53 @@ pub async fn external_history_rescan_sources( .map_err(|err| format!("Task join error: {err}"))? } +/// [`orgtrack_core::sources::cli_resume::CliResumePlan`] plus the two +/// freshness checks only the desktop host can answer: whether the recorded +/// workspace directory and the source transcript/store are still on disk. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalHistoryCliResumePlanWire { + #[serde(flatten)] + pub plan: orgtrack_core::sources::cli_resume::CliResumePlan, + pub display_command: String, + pub cwd_exists: bool, + pub source_available: bool, +} + +/// Plan how to reopen an imported external session in its own CLI. +/// `Ok(None)` when the session is unknown, a subagent child, or its source +/// has no CLI resume entry point (e.g. Cursor IDE composers). +#[tauri::command] +pub async fn external_history_cli_resume_plan( + session_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let conn = open_cache_conn()?; + let Some((plan, session)) = + orgtrack_core::sources::cli_resume::cli_resume_plan_for_cached_session( + &conn, + &session_id, + )? + else { + return Ok(None); + }; + let cwd_exists = plan + .cwd + .as_deref() + .is_some_and(|path| Path::new(path).is_dir()); + let source_available = + !session.source_path.is_empty() && Path::new(&session.source_path).exists(); + Ok(Some(ExternalHistoryCliResumePlanWire { + display_command: plan.display_command(), + plan, + cwd_exists, + source_available, + })) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + #[tauri::command] pub async fn orgtrack_get_cursor_sessions( start_date: String, diff --git a/src/api/tauri/agent/cliTerminalSession.test.ts b/src/api/tauri/agent/cliTerminalSession.test.ts index b3de3fd65e..02ae20abdd 100644 --- a/src/api/tauri/agent/cliTerminalSession.test.ts +++ b/src/api/tauri/agent/cliTerminalSession.test.ts @@ -1,8 +1,27 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliLaunchProfileView } from "@src/api/tauri/rpc/schemas/agentOrgs"; -import { formatCliTuiCommand } from "./cliTerminalSession"; +import { + appendCliCommandArgs, + deriveExpectedProcess, + formatCliTuiCommand, + resolveCliTuiCommand, +} from "./cliTerminalSession"; + +const { getLaunchProfile } = vi.hoisted(() => ({ + getLaunchProfile: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { + agentOrgs: { + launchProfiles: { + get: getLaunchProfile, + }, + }, + }, +})); function profile( overrides: Partial = {} @@ -31,9 +50,9 @@ function profile( describe("formatCliTuiCommand", () => { it("adds required interactive arguments to the detected executable", () => { - expect(formatCliTuiCommand(profile(), "/opt/trae/bin/trae-cli")).toBe( - "/opt/trae/bin/trae-cli interactive" - ); + expect( + formatCliTuiCommand(profile(), "/opt/trae/bin/trae-cli", false) + ).toBe("/opt/trae/bin/trae-cli interactive"); }); it("omits Codex's prompt-required exec subcommand for interactive TUI launches", () => { @@ -46,12 +65,13 @@ describe("formatCliTuiCommand", () => { requiredArgs: ["exec"], args: ["--dangerously-bypass-approvals-and-sandbox"], }), - "codex" + "codex", + false ) ).toBe("codex --dangerously-bypass-approvals-and-sandbox"); }); - it("honors command and argument overrides with shell-safe quoting", () => { + it("honors command and argument overrides with POSIX shell-safe quoting", () => { expect( formatCliTuiCommand( profile({ @@ -60,8 +80,146 @@ describe("formatCliTuiCommand", () => { requiredArgs: [], args: ["interactive", "two words"], }), - "trae-cli" + "trae-cli", + false ) ).toBe("'/Applications/Trae Agent/trae-cli' interactive 'two words'"); }); + + it("quotes a backslash-heavy Windows path for PowerShell instead of POSIX-escaping it", () => { + expect( + formatCliTuiCommand( + profile({ + commandOverridden: true, + command: "C:\\Users\\me\\Trae Agent\\trae-cli.exe", + requiredArgs: [], + args: ["interactive"], + }), + "trae-cli", + true + ) + ).toBe("'C:\\Users\\me\\Trae Agent\\trae-cli.exe' interactive"); + }); + + it("doubles embedded single quotes for PowerShell instead of POSIX '\\'' escaping", () => { + expect( + formatCliTuiCommand( + profile({ + commandOverridden: true, + command: "trae-cli", + requiredArgs: [], + args: ["a 'quoted' word"], + }), + "trae-cli", + true + ) + ).toBe("trae-cli 'a ''quoted'' word'"); + }); + + it("passes safe-charset arguments through unquoted on both platforms", () => { + const withOverride = profile({ + commandOverridden: true, + command: "/opt/trae/bin/trae-cli", + requiredArgs: [], + args: ["interactive"], + }); + expect(formatCliTuiCommand(withOverride, "trae-cli", false)).toBe( + "/opt/trae/bin/trae-cli interactive" + ); + expect(formatCliTuiCommand(withOverride, "trae-cli", true)).toBe( + "/opt/trae/bin/trae-cli interactive" + ); + }); +}); + +describe("appendCliCommandArgs", () => { + it("appends resume arguments after the resolved profile command", () => { + expect( + appendCliCommandArgs( + "claude --permission-mode plan", + ["--resume", "b52f4220-8b0b-46c5-8ee6-001ebf91c6ed"], + false + ) + ).toBe( + "claude --permission-mode plan --resume b52f4220-8b0b-46c5-8ee6-001ebf91c6ed" + ); + }); + + it("quotes unsafe arguments POSIX-style and drops blank ones", () => { + expect( + appendCliCommandArgs("codex", ["resume", " ", "two words"], false) + ).toBe("codex resume 'two words'"); + expect(appendCliCommandArgs("codex", [], false)).toBe("codex"); + }); + + it("quotes a Windows session-file path for PowerShell (backslashes pass through literally)", () => { + expect( + appendCliCommandArgs( + "omp", + ["--session", "C:\\Users\\me\\.omp\\agent\\sessions\\session.jsonl"], + true + ) + ).toBe( + "omp --session 'C:\\Users\\me\\.omp\\agent\\sessions\\session.jsonl'" + ); + }); + + it("keeps a safe-charset resume id unquoted for PowerShell", () => { + expect( + appendCliCommandArgs( + "claude", + ["--resume", "b52f4220-8b0b-46c5-8ee6-001ebf91c6ed"], + true + ) + ).toBe("claude --resume b52f4220-8b0b-46c5-8ee6-001ebf91c6ed"); + }); +}); + +describe("resolveCliTuiCommand", () => { + beforeEach(() => { + getLaunchProfile.mockReset(); + }); + + it("falls back to the detected command when the launch-profile RPC rejects", async () => { + getLaunchProfile.mockRejectedValueOnce(new Error("IPC unavailable")); + + await expect( + resolveCliTuiCommand("claude_code", "/opt/claude/bin/claude") + ).resolves.toBe("/opt/claude/bin/claude"); + expect(getLaunchProfile).toHaveBeenCalledWith({ agentName: "claude_code" }); + }); + + it("formats the resolved command from a successful profile fetch", async () => { + getLaunchProfile.mockResolvedValueOnce( + profile({ requiredArgs: [], args: ["--dangerously-skip-permissions"] }) + ); + + await expect( + resolveCliTuiCommand("trae_cli", "/opt/trae/bin/trae-cli") + ).resolves.toBe("/opt/trae/bin/trae-cli --dangerously-skip-permissions"); + }); +}); + +describe("deriveExpectedProcess", () => { + it("returns the first whitespace-delimited token for a plain binary", () => { + expect(deriveExpectedProcess("claude --resume abc123")).toBe("claude"); + }); + + it("unwraps a POSIX-quoted binary containing spaces instead of splitting mid-path", () => { + expect( + deriveExpectedProcess("'/Applications/Trae Agent/trae-cli' interactive") + ).toBe("/Applications/Trae Agent/trae-cli"); + }); + + it("unwraps a PowerShell-quoted binary containing spaces", () => { + expect( + deriveExpectedProcess( + "'C:\\Program Files\\Trae\\trae-cli.exe' interactive" + ) + ).toBe("C:\\Program Files\\Trae\\trae-cli.exe"); + }); + + it("returns undefined for a blank command", () => { + expect(deriveExpectedProcess(" ")).toBeUndefined(); + }); }); diff --git a/src/api/tauri/agent/cliTerminalSession.ts b/src/api/tauri/agent/cliTerminalSession.ts index 70d14e6c87..41359d2ac5 100644 --- a/src/api/tauri/agent/cliTerminalSession.ts +++ b/src/api/tauri/agent/cliTerminalSession.ts @@ -12,6 +12,7 @@ import { invoke } from "@tauri-apps/api/core"; import { rpc } from "@src/api/tauri/rpc"; import type { CliLaunchProfileView } from "@src/api/tauri/rpc/schemas/agentOrgs"; import { CLI_AGENT, type CliAgentType } from "@src/api/types/keys"; +import { isWindows } from "@src/util/platform/tauri"; export interface CliTuiSessionCreateParams { platform: CliAgentType; @@ -33,14 +34,58 @@ export interface CliTuiSessionInfo { repoPath?: string | null; } +const SAFE_SHELL_ARG_PATTERN = /^[A-Za-z0-9_./:@%+=,-]+$/; + +/** POSIX single-quote escaping: wrap in `'…'`, escape embedded `'` as `'\''`. */ function quotePosixShellArg(value: string): string { - if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value; + if (SAFE_SHELL_ARG_PATTERN.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } +/** + * PowerShell single-quote escaping. Unlike POSIX, backslashes inside a + * PowerShell single-quoted string are literal — no escaping needed — so a + * Windows path like `C:\Users\me\session.jsonl` passes through unchanged; + * only an embedded `'` needs doubling. + */ +function quotePowerShellArg(value: string): string { + if (SAFE_SHELL_ARG_PATTERN.test(value)) return value; + return `'${value.replace(/'/g, "''")}'`; +} + +/** + * Quote a single shell argument for the chat-panel PTY's default shell. + * That default is PowerShell on Windows (`shells.rs`'s Windows branch + * pushes PowerShell as `is_default: true`) and a POSIX shell everywhere + * else, so POSIX `'\''`-escaping mis-quotes Windows paths (e.g. omp's + * `--session `). `windows` is injectable so tests can pin either + * quoting style regardless of the host OS running the test. + */ +function quoteShellArg(value: string, windows: boolean): string { + return windows ? quotePowerShellArg(value) : quotePosixShellArg(value); +} + +/** + * Append extra arguments (e.g. a resume flag + session id) to an + * already-resolved terminal command, quoting each the same way the launch + * profile formatter does. + */ +export function appendCliCommandArgs( + command: string, + args: string[], + windows: boolean = isWindows() +): string { + const suffix = args + .filter((arg) => arg.trim().length > 0) + .map((arg) => quoteShellArg(arg, windows)) + .join(" "); + return suffix.length > 0 ? `${command} ${suffix}` : command; +} + export function formatCliTuiCommand( profile: CliLaunchProfileView, - detectedCommand: string + detectedCommand: string, + windows: boolean = isWindows() ): string { const executable = profile.commandOverridden ? profile.command @@ -52,7 +97,7 @@ export function formatCliTuiCommand( profile.agentName === CLI_AGENT.CODEX ? [] : profile.requiredArgs; return [executable, ...requiredArgs, ...profile.args] .filter((part) => part.trim().length > 0) - .map(quotePosixShellArg) + .map((part) => quoteShellArg(part, windows)) .join(" "); } @@ -71,6 +116,49 @@ export async function resolveCliTuiCommand( } } +/** + * First token of a resolved CLI command line, unquoted — used to match the + * PTY's live foreground process name (`useTerminalProcessPoller`) against + * the command actually launched. + * + * A plain whitespace split mis-handles a leading quoted binary (a + * `commandOverridden` launch profile, or an omp `--session ` resume + * whose binary itself contains spaces): `'/Applications/Trae Agent/trae-cli' + * interactive`.split(/\s+/) would wrongly yield `'/Applications/Trae`. This + * detects a single leading quoted token — POSIX `'\''`-escaped or + * PowerShell `''`-doubled, the two styles `quoteShellArg` above produces — + * and returns it unquoted. + */ +export function deriveExpectedProcess(command: string): string | undefined { + const trimmed = command.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("'")) { + const closingIndex = findClosingQuoteIndex(trimmed); + if (closingIndex > 0) { + const inner = trimmed.slice(1, closingIndex); + const unescaped = inner.replace(/'\\''/g, "'").replace(/''/g, "'"); + return unescaped || undefined; + } + } + const [binary] = trimmed.split(/\s+/); + return binary || undefined; +} + +/** + * Index of the `'` that closes a leading quoted token: the first `'` (after + * the opening one) followed by whitespace or end-of-string. Embedded quotes + * in either escaping style are always followed by another quote or + * backslash, never whitespace, so they're skipped. + */ +function findClosingQuoteIndex(value: string): number { + for (let index = 1; index < value.length; index += 1) { + if (value[index] !== "'") continue; + const next = value[index + 1]; + if (next === undefined || /\s/.test(next)) return index; + } + return -1; +} + export async function cliAgentCreateTuiSession( params: CliTuiSessionCreateParams ): Promise { diff --git a/src/api/tauri/externalHistory/imported/descriptors.ts b/src/api/tauri/externalHistory/imported/descriptors.ts index bea3e0aa98..2b36bb0fbb 100644 --- a/src/api/tauri/externalHistory/imported/descriptors.ts +++ b/src/api/tauri/externalHistory/imported/descriptors.ts @@ -5,6 +5,20 @@ export type { ImportedHistorySourceId } from "@src/types/session/externalHistory export type ImportedHistoryListCategory = `external_history:${ImportedHistorySourceId}`; +/** + * Native-CLI continuation capability of an imported source. Present only + * when `orgtrack_core::sources::cli_resume` can plan a resume for the + * source (the backend stays authoritative per session — subagent rows and + * malformed ids still resolve to no plan). Sources without it are pure + * read-only replays: no continue button and no chat composer. + */ +export interface ImportedHistoryCliResume { + /** `code_sessions.cli_agent_type` of the owning CLI (launch-profile key). */ + agentType: string; + /** Fallback label when the CLI registry has not answered (or errored). */ + displayName: string; +} + export interface ImportedHistorySourceDescriptor { sourceId: ImportedHistorySourceId; listCategory: ImportedHistoryListCategory; @@ -15,6 +29,7 @@ export interface ImportedHistorySourceDescriptor { listable: true; replayable: true; supportsWindowedReplay: boolean; + cliResume?: ImportedHistoryCliResume; } export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySourceDescriptor[] = @@ -40,6 +55,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "cursor_cli", + displayName: "Cursor CLI", + }, }, { sourceId: "codex_app", @@ -51,6 +70,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "codex", + displayName: "Codex", + }, }, { sourceId: "claude_code", @@ -62,6 +85,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "claude_code", + displayName: "Claude Code", + }, }, { sourceId: "opencode", @@ -73,6 +100,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "opencode", + displayName: "OpenCode", + }, }, { sourceId: "windsurf", @@ -117,6 +148,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "cline", + displayName: "Cline", + }, }, { sourceId: "warp", @@ -161,6 +196,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "mimo_code", + displayName: "MiMo Code", + }, }, { sourceId: "omp", @@ -172,6 +211,10 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource listable: true, replayable: true, supportsWindowedReplay: true, + cliResume: { + agentType: "omp", + displayName: "OMP", + }, }, { sourceId: "qoder_cli", diff --git a/src/api/tauri/externalHistory/imported/index.ts b/src/api/tauri/externalHistory/imported/index.ts index aa68f9a7a7..603ead6534 100644 --- a/src/api/tauri/externalHistory/imported/index.ts +++ b/src/api/tauri/externalHistory/imported/index.ts @@ -212,6 +212,19 @@ export function getImportedHistorySourceBySessionId( ); } +/** + * The native-CLI continuation capability of the source owning `sessionId`, + * or `undefined` when the source is a pure read-only replay (no CLI can + * reopen its sessions). Sync and prefix-driven so render gates (composer, + * continue button) don't need the backend plan call; the backend stays + * authoritative per session. + */ +export function getImportedHistoryCliResume( + sessionId: string | null | undefined +) { + return getImportedHistorySourceBySessionId(sessionId)?.cliResume; +} + export function getImportedHistorySourceByListCategory( category: ImportedHistoryListCategory ): ImportedHistorySource | undefined { diff --git a/src/api/tauri/externalHistory/index.ts b/src/api/tauri/externalHistory/index.ts index b78bb6deb3..4cf2198e81 100644 --- a/src/api/tauri/externalHistory/index.ts +++ b/src/api/tauri/externalHistory/index.ts @@ -12,6 +12,10 @@ export { externalHistoryRescanSources, type ExternalHistoryScanResult, } from "./rescan"; +export { + externalHistoryCliResumePlan, + type ExternalHistoryCliResumePlan, +} from "./resume"; export { fetchExternalSourceStats, fetchExternalSourceStatsBatch, diff --git a/src/api/tauri/externalHistory/resume.ts b/src/api/tauri/externalHistory/resume.ts new file mode 100644 index 0000000000..0fce7dc3bf --- /dev/null +++ b/src/api/tauri/externalHistory/resume.ts @@ -0,0 +1,44 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Backend plan for reopening an imported external session in the CLI that + * owns it (`claude --resume`, `codex resume`, `cursor-agent --resume`). + * Mirrors `ExternalHistoryCliResumePlanWire` in + * `src-tauri/src/orgtrack/history_commands.rs` (camelCase JSON). + */ +export interface ExternalHistoryCliResumePlan { + /** Imported-history source id (`claude_code` / `codex_app` / `cursor_cli`). */ + source: string; + /** `code_sessions.cli_agent_type` of the owning CLI (launch-profile key). */ + cliAgentType: string; + /** Bare binary to run when the CLI registry has no detected command. */ + defaultBinary: string; + /** Arguments appended after the binary to reopen the session. */ + resumeArgs: string[]; + /** The session id the CLI itself accepts. */ + nativeSessionId: string; + /** Recorded workspace directory of the session, when known. */ + cwd: string | null; + /** Whether the CLI can only locate the session from its original cwd. */ + requiresCwd: boolean; + /** Human-readable `binary args…` string for tooltips/copy. */ + displayCommand: string; + /** Whether `cwd` still exists as a directory on this machine. */ + cwdExists: boolean; + /** Whether the source transcript/store behind the import is still on disk. */ + sourceAvailable: boolean; +} + +/** + * `null` when the session is unknown to the imported-history cache, is a + * subagent child, or its source has no CLI resume path (e.g. Cursor IDE + * composers — no CLI can reopen those). + */ +export async function externalHistoryCliResumePlan( + sessionId: string +): Promise { + return invoke( + "external_history_cli_resume_plan", + { sessionId } + ); +} diff --git a/src/engines/ChatPanel/ChatPanelTerminalContent.tsx b/src/engines/ChatPanel/ChatPanelTerminalContent.tsx index a54ce457cf..077032a9c9 100644 --- a/src/engines/ChatPanel/ChatPanelTerminalContent.tsx +++ b/src/engines/ChatPanel/ChatPanelTerminalContent.tsx @@ -145,9 +145,14 @@ export function ChatPanelTerminalContent({ // Small delay so the shell prompt has time to render before we send input const timerId = window.setTimeout(() => { + // Terminate with CR, not LF: terminals send `\r` for Enter, and + // Windows PowerShell + PSReadLine leaves an LF-terminated command + // sitting unexecuted at a continuation prompt. POSIX shells accept + // CR as line-accept too (ICRNL / readline), so CR is correct on + // every platform. invokeTauri("write_pty", { sessionId: ptySessionId, - data: `${cliCommand}\n`, + data: `${cliCommand}\r`, }).catch((err: unknown) => { logger.warn("Failed to inject CLI command into PTY", err); }); diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index 75bb938303..ddba35ea69 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -26,6 +26,7 @@ import { useAtomValue, useStore } from "jotai"; import React, { memo, useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { getImportedHistoryCliResume } from "@src/api/tauri/externalHistory"; import Message from "@src/components/Message"; import { useShowInteractArea } from "@src/contexts/workspace/ChatContext"; import { forkExternalHistoryIntoOrgiiSession } from "@src/engines/ChatPanel/externalHistoryFork"; @@ -161,7 +162,12 @@ const ChatView: React.FC = memo( // picker — it never writes back into Codex/Claude/Cursor/etc. const showInteractArea = useShowInteractArea(); - const showExternalHistoryForkComposer = isImportedHistory && !readOnly; + // Sources whose CLI cannot reopen a session (Cursor IDE, Windsurf, + // Trae, …) are pure read-only replays: no composer, no continuation + // affordance. Only CLI-continuable histories offer the fork composer. + const importedCliResume = getImportedHistoryCliResume(sessionId); + const showExternalHistoryForkComposer = + isImportedHistory && !readOnly && Boolean(importedCliResume); const handleExternalHistoryForkSubmit = useCallback( async (input: SubmitOverrideInput) => { if (!isImportedHistory) return false; diff --git a/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx b/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx index bef7676828..27d66fa131 100644 --- a/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx +++ b/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx @@ -8,6 +8,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { getImportedHistoryCliResume } from "@src/api/tauri/externalHistory"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { @@ -39,6 +40,14 @@ export function ChatViewPostHistoryOverlays({ sessionId, }: ChatViewPostHistoryOverlaysProps) { const { t: tNavigation } = useTranslation("navigation"); + // The composer only renders for CLI-continuable sources (ChatView gates + // `showExternalHistoryForkComposer` on the same `getImportedHistoryCliResume` + // check), so `cliResume` is always defined whenever this placeholder runs. + const cliResume = getImportedHistoryCliResume(sessionId); + const composerPlaceholder = tNavigation( + "collaboration.continueCli.composerPlaceholder", + { agent: cliResume?.displayName ?? "" } + ); return ( <> @@ -55,9 +64,7 @@ export function ChatViewPostHistoryOverlays({ void; +} + +/** + * "Continue in " header action for imported external sessions. + * + * The imported transcript stays read-only inside ORGII, but the CLI that + * wrote it can reopen the very conversation (`claude --resume`, + * `codex resume`, `cursor-agent --resume`). This button hands the session + * back to that CLI inside a chat-panel terminal tab, backed by a managed + * TUI session row so worktree cwd, live status, and the managed-mirror + * dedup behave exactly like a GUI-launched CLI session. + */ +const SessionContinueCliHeaderExtras: React.FC< + SessionContinueCliHeaderExtrasProps +> = ({ session, sessionId, onOpenCliTerminal }) => { + const { t } = useTranslation("navigation"); + const [plan, setPlan] = useState(null); + const [agent, setAgent] = useState(undefined); + const [agentKnown, setAgentKnown] = useState(false); + const [launching, setLaunching] = useState(false); + + const isImported = Boolean(sessionId && isImportedHistorySession(sessionId)); + // Sync capability gate: sources without a CLI resume path never render + // the button, and never pay the backend plan round-trip. The backend + // stays authoritative for per-session cases (subagents, odd ids). + const descriptorCliResume = getImportedHistoryCliResume(sessionId); + + useEffect(() => { + setPlan(null); + if (!sessionId || !isImported || !descriptorCliResume) return undefined; + let cancelled = false; + externalHistoryCliResumePlan(sessionId) + .then((result) => { + if (!cancelled) setPlan(result); + }) + .catch((error) => { + log.warn("external CLI resume plan failed", error); + }); + return () => { + cancelled = true; + }; + }, [sessionId, isImported, descriptorCliResume]); + + useEffect(() => { + setAgent(undefined); + setAgentKnown(false); + if (!plan) return undefined; + let cancelled = false; + loadAvailableAgents() + .then((agents) => { + if (cancelled) return; + setAgent(agents.find((entry) => entry.name === plan.cliAgentType)); + setAgentKnown(true); + }) + .catch((error) => { + // Unknown availability degrades to "try it": the terminal itself + // surfaces a command-not-found, which is more actionable than a + // silently missing button. + log.warn("CLI registry load failed", error); + }); + return () => { + cancelled = true; + }; + }, [plan]); + + const agentDisplayName = useMemo(() => { + if (!plan) return ""; + return ( + agent?.displayName ?? + descriptorCliResume?.displayName ?? + plan.defaultBinary + ); + }, [agent, descriptorCliResume, plan]); + + const disabledReason = useMemo(() => { + if (!plan) return null; + if (agentKnown && agent && !agent.installed) { + return t("collaboration.continueCli.notInstalled", { + agent: agentDisplayName, + }); + } + if (!plan.sourceAvailable) { + return t("collaboration.continueCli.sourceMissing"); + } + if (plan.requiresCwd && (!plan.cwd || !plan.cwdExists)) { + return t("collaboration.continueCli.missingWorkspace", { + agent: agentDisplayName, + }); + } + return null; + }, [agent, agentDisplayName, agentKnown, plan, t]); + + const handleContinue = useCallback(async (): Promise => { + if (!plan || !onOpenCliTerminal || launching) return; + setLaunching(true); + try { + const cliAgentType = plan.cliAgentType as CliAgentType; + const detectedCommand = agent?.command.trim() || plan.defaultBinary; + const baseCommand = await resolveCliTuiCommand( + cliAgentType, + detectedCommand + ); + const command = appendCliCommandArgs(baseCommand, plan.resumeArgs); + const cwd = plan.cwd && plan.cwdExists ? plan.cwd : undefined; + const title = session?.name || agentDisplayName; + // Back the terminal with a managed session row so lifecycle hooks can + // attribute status/transcripts via ORGII_SESSION_ID and the imported + // twin dedupes through the managed mirror. Creation failure degrades + // to an unbound terminal rather than blocking the resume. + let agentSessionId: string | undefined; + try { + const created = await cliAgentCreateTuiSession({ + platform: cliAgentType, + name: title, + repoPath: cwd, + }); + agentSessionId = created.sessionId; + } catch (error) { + log.warn( + "TUI session create failed; opening unbound resume terminal", + error + ); + } + onOpenCliTerminal({ + cliAgentType, + command, + title, + cwd, + agentSessionId, + expectedProcess: deriveExpectedProcess(baseCommand), + }); + } catch (error) { + log.error("failed to continue imported session in its CLI", error); + Message.error( + t("collaboration.continueCli.launchFailed", { + agent: agentDisplayName, + }) + ); + } finally { + setLaunching(false); + } + }, [ + agent, + agentDisplayName, + launching, + onOpenCliTerminal, + plan, + session?.name, + t, + ]); + + if (!isImported || !plan || !onOpenCliTerminal) return null; + + const continueLabel = t("collaboration.continueCli.headerButton", { + agent: agentDisplayName, + }); + const tooltipContent = + disabledReason ?? + t("collaboration.continueCli.headerTooltip", { + agent: agentDisplayName, + command: plan.displayCommand, + }); + + return ( + + +