Skip to content
Open
40 changes: 36 additions & 4 deletions docs/orgtrack-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id>` 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-<uuid>` → `claude --resume <uuid>`, 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-<ts>-<uuid>` → `codex resume <uuid>` (bare thread uuid
extracted from the rollout stem; Codex looks sessions up globally).
- `cursorcliapp-<id>` → `cursor-agent --resume <id>`.
- `opencodeapp-ses_*` → `opencode --session <id>` (central db, global ids).
- `mimocodeapp-ses_*` → `mimo --session <id>` (OpenCode fork, same flag —
verified against `mimo --help`).
- `clineapp-<epoch>_<rand>` → `cline --id <id>` (global sessions.db).
- `ompapp-<stem>` → `omp --session <transcript-path>` — 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 <workspace> && <command>` 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)

Expand Down
125 changes: 125 additions & 0 deletions src-tauri/crates/orgtrack-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id>` — 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-<uuid>` \
(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
))
}
}
}
12 changes: 10 additions & 2 deletions src-tauri/crates/orgtrack-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -64,6 +64,8 @@ COMMANDS:
usage Token & cost analytics (alias: stats)
check Evaluate usage triggers; exit non-zero on error
show <session-id> Print a session's conversation/activity stream
resume <session-id> Reopen an imported session in its own CLI (claude,
codex, cursor-agent, opencode, mimo, cline, omp)
plugins list Show discovered loader plugins
plugins trust <id> Trust an exec plugin so it may run
help Show this help (alias: --help, -h)
Expand All @@ -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 <fmt> Output: table (default), json, md, csv, or a
formatter plugin id.
--json Shorthand for --format json.
Expand All @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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."
)),
Expand Down Expand Up @@ -256,6 +263,7 @@ fn parse_options(args: &[String]) -> Result<Options, String> {
"--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(
Expand Down
Loading
Loading