From 1524adf0161e5afe3b65f1fcc3afba0df068c3da Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Tue, 4 Aug 2026 13:54:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(resume):=20bare=20`path=20resume`=20?= =?UTF-8?q?=E2=80=94=20recency-first=20cross-harness=20session=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #110. With no , resume opens a picker over the newest 100 sessions from every installed harness (ranked cwd-first then newest), derives the pick (write-through cache with the freshness fast path, mirroring share), then flows into the existing harness-picker -> project -> exec pipeline. A tail row (N older sessions — load everything) runs the full sweep; --from narrows the source harness; --project always sweeps fully. Codex candidates rank stat-only and hydrate from the listing cache (counts included) or one cached streaming read. New SessionPicker seam (FixedPicker sequences + RecordingExec) drives 12 bare-mode integration tests headlessly. A scoped-to-project-first variant was built and reverted during dogfooding — see the spec addendum. path-cli 0.17.0; toolpath-codex 0.6.3 (peek_metadata + HeadHunt refactor). --- CHANGELOG.md | 36 +- CLAUDE.md | 3 +- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/path-cli/Cargo.toml | 2 +- crates/path-cli/src/cmd_resume.rs | 568 ++++++++++++++++-- crates/path-cli/src/cmd_share.rs | 220 ++++++- crates/path-cli/src/lib.rs | 4 +- crates/path-cli/tests/resume.rs | 421 ++++++++++++- crates/path-cli/tests/support/mod.rs | 105 +++- crates/toolpath-codex/Cargo.toml | 2 +- crates/toolpath-codex/src/io.rs | 369 +++++++++--- .../2026-08-03-bare-resume-session-picker.md | 121 ++++ ...08-03-bare-resume-session-picker-design.md | 127 ++++ site/_data/crates.json | 4 +- 15 files changed, 1858 insertions(+), 132 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-03-bare-resume-session-picker.md create mode 100644 docs/superpowers/specs/2026-08-03-bare-resume-session-picker-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d28b0c..79d3ad46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to the Toolpath workspace are documented here. +## Bare `path resume` — one picker from any session to any harness — 2026-08-03 + +`path resume` with no argument now opens a cross-harness session +picker: every session from every installed harness (Claude Code, +Gemini CLI, Codex, Copilot, opencode, Cursor, Pi), ranked with the +current project's sessions first — pick one, pick a target harness, +and you're back in the conversation. Previously resume always required +an explicit input (URL, file, or cache id); the local "resume that +session I ran earlier, maybe in a different harness" loop needed a +`share`-shaped detour or a manual `p import`. + +- **path-cli** (0.17.0): the bare picker is recency-first — it + hydrates only the newest 100 sessions across every harness, so it + opens in milliseconds regardless of history size. Codex candidates + rank stat-only by mtime; each hydrates from the listing cache when + its stamp matches (instant, message count included) or via one + streaming read that is then cached, so counts appear everywhere and + only new/changed sessions ever pay a read. A tail row ("N older + sessions — load everything") runs the full sweep on demand, and + `--project` always sweeps fully since its matches may be old. + (`toolpath-codex` 0.6.3 also gains an O(1) head+tail `peek_metadata` + for count-free peeking.) It reuses `share`'s aggregation and picker rows, + then flows into the existing harness-picker → project → exec + pipeline. New bare-mode flags: + `--from ` narrows the session picker to one harness + (the resume target is still `--harness` / the harness picker) and + `--project ` narrows it to one project directory, mirroring + `share --project`. Cache policy mirrors `share`: derived docs are + written through to the cache and recorded in the sync manifest by + default, with a freshness fast path that skips re-deriving unchanged + sessions (`--force` re-derives, `--no-cache` stays in-memory). + Closes #110. The session picker sits behind a new + `cmd_resume::SessionPicker` seam (`FixedPicker` test double), + mirroring `ExecStrategy`, and `ResumeArgs` now implements `Default`. ## Warm picker opens are effectively instant — 2026-08-04 The session picker (`path share`, and bare `path resume` once #154 @@ -36,7 +70,6 @@ the others before anything appeared. chain-index cache is single-threaded); everything else fans out. Row concatenation keeps the old provider order, so ranking tie-breaks are unchanged. - ## Codex session listing no longer parses every byte — 2026-08-04 Listing Codex sessions read every rollout file end to end through @@ -53,6 +86,7 @@ upcoming bare `resume` picker — into a minute-plus silent stall. method: a first prompt buried past the head budget reports as `None`, and `line_count` counts non-empty lines rather than successfully parsed ones. + ## Projected Claude sessions are resumable again — 2026-07-30 Two fixes found by live-resuming a projected session against the real diff --git a/CLAUDE.md b/CLAUDE.md index 2bc061c7..31e8df09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,7 @@ cargo run -p path-cli -- share --url https://my-pathbase.example # Resume a Toolpath document into your coding agent of choice (interactive harness picker) cargo run -p path-cli -- resume cargo run -p path-cli -- resume --harness claude -C /path/to/project +cargo run -p path-cli -- resume # bare: pick any local session from any harness # Plumbing: export toolpath documents into external formats. is a # cache id or a file path. @@ -283,7 +284,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. -- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. +- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. **Bare mode**: `path resume` with no `` opens a cross-harness *session* picker (reusing `share`'s aggregation and picker rows, cwd-ranked), derives the picked session, then flows into the same harness-picker → project → exec pipeline. `--from ` narrows the session picker to one harness (the resume *target* is still `--harness`/the harness picker); `--project ` narrows it to one project directory (mirrors `share --project`); `-C` is both the ranking anchor and the projection/exec cwd. Cache policy mirrors `share`: write-through by default (cache + sync-manifest record), with a `sync::fresh_cache_id` fast path that loads the cached doc when the source is unchanged; `--force` re-derives, `--no-cache` derives in-memory only; `--url` is inert in bare mode. Without a TTY, bare mode bails: `no input provided and no TTY for interactive selection; pass an (URL, file, or cache id), or rerun in a terminal`. The session picker is mockable via `cmd_resume::SessionPicker` (`FixedPicker` in tests), mirroring `ExecStrategy`. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when `p cache rm` evicts a doc (rm downgrades the record; the next sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share` and `/path:query`). The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` context commands and model-issued Bash must not contain `$PWD`/variables (Claude Code's permission checker rejects commands it can't statically analyze — hence the `sessions` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. diff --git a/Cargo.lock b/Cargo.lock index 08c5b8d0..a82df06c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.3" +version = "0.17.0" dependencies = [ "anyhow", "assert_cmd", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "toolpath-codex" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 3dffef1d..498e1078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } -toolpath-codex = { version = "0.6.2", path = "crates/toolpath-codex" } +toolpath-codex = { version = "0.6.3", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.16.3", path = "crates/path-cli" } +path-cli = { version = "0.17.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 348e3a67..22923f35 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.3" +version = "0.17.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 163634fe..044a8957 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -11,6 +11,18 @@ //! 3. Existing file path → read directly. //! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`. //! +//! ## Bare mode +//! +//! With no `` at all, [`run_bare`] opens a cross-harness +//! session picker (reusing `path share`'s aggregation and picker +//! rows), derives the picked session (write-through cache, mirroring +//! share; a manifest-fresh cache entry short-circuits the derive), +//! then flows into the same harness-picker → project → exec pipeline. +//! `--from` narrows the session picker to one harness; `--project` +//! narrows it to one project directory. The picker is mockable via +//! [`SessionPicker`] ([`FixedPicker`] in tests), mirroring +//! [`ExecStrategy`]. +//! //! ## Harness selection //! //! With `--harness X`, `X` is validated against `$PATH` and used. @@ -46,13 +58,15 @@ use std::path::PathBuf; use crate::harness::Harness; -#[derive(Args, Debug)] +#[derive(Args, Debug, Default)] pub struct ResumeArgs { /// Toolpath document to resume from. Accepted shapes: a Pathbase /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a - /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). - pub input: String, + /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). Omit + /// entirely to pick a session interactively across all installed + /// harnesses. + pub input: Option, /// Working directory to run the resumed harness from. Defaults to /// the current shell cwd. The on-disk projection is keyed on this @@ -64,20 +78,36 @@ pub struct ResumeArgs { #[arg(long, value_enum)] pub harness: Option, + /// Bare mode only: show only this harness's sessions in the + /// session picker. The resume target is still --harness / the + /// harness picker. + #[arg(long, value_enum, conflicts_with = "input")] + pub from: Option, + + /// Bare mode only: show only sessions tied to this project + /// directory (mirrors path share --project). + #[arg(long, conflicts_with = "input")] + pub project: Option, + /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for /// ephemeral environments where you don't want the cache to grow. + /// In bare mode: derive the picked session in-memory only, without + /// probing or writing the cache. #[arg(long)] pub no_cache: bool, /// Force a re-fetch from Pathbase even if a cache entry exists, /// overwriting it with the new bytes. Default behavior is to use - /// the cached doc on hit and never round-trip. + /// the cached doc on hit and never round-trip. In bare mode: skip + /// the cache freshness probe and always re-derive the picked + /// session. #[arg(long)] pub force: bool, /// Pathbase server URL. Falls back to the stored session's URL, - /// then `$PATHBASE_URL`, then `https://pathbase.dev`. + /// then `$PATHBASE_URL`, then `https://pathbase.dev`. Inert in + /// bare mode, which never fetches from Pathbase. #[arg(long)] pub url: Option, } @@ -89,6 +119,10 @@ pub fn run(args: ResumeArgs) -> Result<()> { /// Internal entry point that the integration tests call with a /// `RecordingExec` strategy. Production callers use [`run`]. pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { + if args.input.is_none() { + return run_bare(&args, exec, &FuzzySessionPicker); + } + let (graph, source_harness) = resolve_input(&args)?; let path = ensure_path_with_agent(&graph)?; @@ -117,6 +151,372 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<() use toolpath::v1::{Graph, Path as TPath, PathOrRef}; +// ── bare mode: cross-harness session picker ───────────────────────── + +/// Outcome of a session-picker invocation, index-based so the caller +/// keeps working with its own `ArtifactRow`s instead of re-parsing +/// picker lines. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PickChoice { + /// The row at this index in the offered lines was picked. + Index(usize), + /// User pressed Esc / Ctrl-C / Ctrl-D. + Cancelled, + /// Picker exited cleanly with nothing matched / nothing selected. + NoMatch, +} + +/// Pluggable session picker for bare `path resume`, mirroring +/// [`ExecStrategy`]: production uses [`FuzzySessionPicker`] (the same +/// fzf/skim backend `path share` uses), tests use [`FixedPicker`]. +pub trait SessionPicker { + /// Whether an interactive pick can run at all (TTY + backend). + fn available(&self) -> bool { + true + } + fn pick(&self, lines: &[String], header: &str) -> Result; +} + +/// Production picker: share-identical fzf/skim invocation over the +/// 5-col TSV rows from `cmd_share::format_picker_row`. +pub struct FuzzySessionPicker; + +impl SessionPicker for FuzzySessionPicker { + fn available(&self) -> bool { + crate::fuzzy::available() + } + + fn pick(&self, lines: &[String], header: &str) -> Result { + let opts = crate::fuzzy::PickOptions { + with_nth: "4", + prompt: "resume> ", + preview: Some("{exe} show --ansi {1} --project {2} --session {3}"), + preview_window: "up:60%:wrap-word", + header: Some(header), + tiebreak: "index", + multi: false, + }; + match crate::fuzzy::pick(lines, &opts)? { + crate::fuzzy::PickResult::Selected(rows) => match rows.into_iter().next() { + Some(line) => Ok(PickChoice::Index(index_of_selected(lines, &line)?)), + // Selected with an empty payload should not happen (the + // picker exits 0 only when a row was confirmed); treat + // it like no-match for safety, as share does. + None => Ok(PickChoice::NoMatch), + }, + crate::fuzzy::PickResult::NoMatch => Ok(PickChoice::NoMatch), + crate::fuzzy::PickResult::Cancelled => Ok(PickChoice::Cancelled), + } + } +} + +/// Map the line the picker returned back to its index in the offered +/// lines. Plain line equality is safe: columns 1-3 (harness, key, +/// session id) make every row unique. +fn index_of_selected(lines: &[String], selected: &str) -> Result { + lines + .iter() + .position(|l| l == selected) + .ok_or_else(|| anyhow::anyhow!("picker returned an unrecognized row: {selected}")) +} + +/// Scripted picker for tests: returns a fixed [`PickChoice`] and +/// records the lines it was offered. Public like [`RecordingExec`] so +/// integration tests can drive [`run_bare`] without a TTY. +pub struct FixedPicker { + /// Choices consumed one per `pick` call; the final entry repeats + /// forever so single-choice pickers behave identically across + /// multiple rounds. + choices: std::sync::Mutex>, + available: bool, + offered: std::sync::Mutex>>, +} + +impl FixedPicker { + /// Picker that selects the row at `index`. + pub fn select(index: usize) -> Self { + Self::new(vec![PickChoice::Index(index)], true) + } + + /// Picker that answers successive `pick` calls with successive + /// choices; the last choice repeats if calls outnumber choices. + pub fn sequence(choices: Vec) -> Self { + Self::new(choices, true) + } + + /// Picker that reports a user cancel. + pub fn cancelled() -> Self { + Self::new(vec![PickChoice::Cancelled], true) + } + + /// Picker that reports no match. + pub fn no_match() -> Self { + Self::new(vec![PickChoice::NoMatch], true) + } + + /// Picker whose `available()` is false (no TTY / no backend). + pub fn offline() -> Self { + Self::new(vec![PickChoice::NoMatch], false) + } + + fn new(choices: Vec, available: bool) -> Self { + Self { + choices: std::sync::Mutex::new(choices.into_iter().collect()), + available, + offered: std::sync::Mutex::new(Vec::new()), + } + } + + /// Every line offered across all `pick` calls, flattened in call + /// order. + pub fn offered(&self) -> Vec { + self.offered.lock().unwrap().concat() + } + + /// The lines offered to each `pick` call, one entry per call. + pub fn offered_calls(&self) -> Vec> { + self.offered.lock().unwrap().clone() + } +} + +impl SessionPicker for FixedPicker { + fn available(&self) -> bool { + self.available + } + + fn pick(&self, lines: &[String], _header: &str) -> Result { + self.offered.lock().unwrap().push(lines.to_vec()); + let mut choices = self.choices.lock().unwrap(); + Ok(if choices.len() > 1 { + choices.pop_front().expect("non-empty choice queue") + } else { + choices.front().copied().unwrap_or(PickChoice::NoMatch) + }) + } +} + +/// How many sessions the recency-first bare picker hydrates up front. +const RECENT_LIMIT: usize = 100; + +/// Outcome of one session-picker round, distinguishing a real row +/// from the synthetic "N older sessions" tail row. +enum Picked { + Row(usize), + Tail, + Cancelled, + NoMatch, +} + +/// Run one picker round over `rows`. When `older` is Some(n), a tail +/// row advertising the n unhydrated older sessions is appended; the +/// tail is recognized by position, not by parsing the line back. +fn pick_session_round( + picker: &dyn SessionPicker, + rows: &[crate::cmd_share::ArtifactRow], + header: &str, + older: Option, +) -> Result { + let mut lines: Vec = rows + .iter() + .map(crate::cmd_share::format_picker_row) + .collect(); + if let Some(n) = older { + // Shape matches the real 5-col TSV rows so with_nth/preview + // substitution stay well-formed; cols 1-3 are inert + // placeholders no provider name collides with. + lines.push(format!( + "_older\t-\t-\t\u{2026} {n} older sessions \u{2014} load everything\t-" + )); + } + match picker.pick(&lines, header)? { + PickChoice::Index(i) if older.is_some() && i == rows.len() => Ok(Picked::Tail), + PickChoice::Index(i) if i < rows.len() => Ok(Picked::Row(i)), + PickChoice::Index(i) => anyhow::bail!("picker returned out-of-range index {i}"), + PickChoice::Cancelled => Ok(Picked::Cancelled), + PickChoice::NoMatch => Ok(Picked::NoMatch), + } +} + +/// The full cross-harness sweep: every session from every harness, +/// with the pre-gather notice (big trees take a beat). Returns None +/// for a quiet no-match exit; Esc/Ctrl-C exits 130 directly. +fn pick_from_full_sweep( + args: &ResumeArgs, + picker: &dyn SessionPicker, + bundle: &crate::harness::HarnessBundle, + cwd: &std::path::Path, + harness_filter: Option, +) -> Result> { + eprintln!("Gathering sessions from installed harnesses..."); + let rows = + crate::cmd_share::gather_artifacts(bundle, cwd, harness_filter, args.project.as_deref()); + if rows.is_empty() { + // With a --from filter, the generic all-harness status table + // would be misleading (other harnesses may have sessions) — + // name the filter instead. + if let Some(from) = args.from { + anyhow::bail!( + "no {} sessions found; drop --from to see sessions from other harnesses", + from.name() + ); + } + crate::cmd_share::bail_no_sessions(bundle, args.project.as_deref(), "resumable")?; + anyhow::bail!("no resumable sessions"); + } + match pick_session_round(picker, &rows, "pick a session to resume", None)? { + Picked::Row(i) => Ok(Some(rows[i].clone())), + Picked::Tail => anyhow::bail!("picker returned the tail row, but none was offered"), + Picked::Cancelled => std::process::exit(130), + Picked::NoMatch => Ok(None), + } +} + +/// Bare `path resume`: no `` — recency-first picker across all +/// installed harnesses (reusing `path share`'s aggregation), derive +/// the pick (write-through cache, mirroring share), then flow into the +/// existing harness-picker → project → exec pipeline. +pub fn run_bare( + args: &ResumeArgs, + exec: &dyn ExecStrategy, + picker: &dyn SessionPicker, +) -> Result<()> { + // Interactivity guard first — before gathering anything. + if !picker.available() { + anyhow::bail!( + "no input provided and no TTY for interactive selection; pass an (URL, file, or cache id), or rerun in a terminal" + ); + } + + // Validate a pinned target now so the user doesn't pick a session + // only to hit a missing binary. With `arg = Some`, `pick_harness` + // only validates PATH and returns — the same call re-runs cheaply + // after the derive. + if let Some(h) = args.harness { + pick_harness(Some(h), None, None)?; + } + + let cwd = match args.cwd.as_ref() { + Some(p) => { + std::fs::canonicalize(p).with_context(|| format!("resolve cwd path {}", p.display()))? + } + None => std::env::current_dir()?, + }; + + let bundle = crate::harness::HarnessBundle::from_environment(); + let harness_filter = args.from.map(|h| h.artifact_type()); + + // Recency-first: hydrate only the newest RECENT_LIMIT sessions + // across every harness (codex by stat-sort + O(1) peeks), so the + // picker opens in milliseconds regardless of history size. A tail + // row surfaces however many older sessions exist; picking it (or + // passing --project, whose matches may be arbitrarily old) runs + // the full sweep. Ranking is identical in both views. + let picked: Option = if args.project.is_some() { + pick_from_full_sweep(args, picker, &bundle, &cwd, harness_filter)? + } else { + let recent = crate::cmd_share::gather_recent(&bundle, &cwd, harness_filter, RECENT_LIMIT); + if recent.rows.is_empty() { + pick_from_full_sweep(args, picker, &bundle, &cwd, harness_filter)? + } else { + match pick_session_round( + picker, + &recent.rows, + "pick a session to resume", + Some(recent.older).filter(|n| *n > 0), + )? { + Picked::Row(i) => Some(recent.rows[i].clone()), + Picked::Tail => pick_from_full_sweep(args, picker, &bundle, &cwd, harness_filter)?, + // Esc / Ctrl-C: deliberate user cancel — exit 130 like share. + Picked::Cancelled => std::process::exit(130), + // No row matched the query — quiet exit, no extra noise. + Picked::NoMatch => None, + } + } + }; + let Some(row) = picked else { + return Ok(()); + }; + let row = &row; + // Same cleanup share applies to its picker rows — strip Claude's + // slash-command/local-command XML envelopes from the raw title. + eprintln!( + "Picked {} session {:?}", + row.artifact_type.name(), + crate::fuzzy::clean_for_picker_display(&row.title) + ); + + let graph = load_or_derive_session_doc(&bundle, row, args)?; + let path = ensure_path_with_agent(&graph)?; + + let source = row + .artifact_type + .harness() + .or_else(|| infer_source_harness(path)); + let target = pick_harness(args.harness, source, None)?; + eprintln!( + "Picked harness: {}{}", + target.name(), + if Some(target) == source { + " (source)" + } else { + "" + } + ); + + let session_id = project_into_harness(path, target, &cwd)?; + let (binary, argv) = invocation_for(target, &session_id, &cwd); + exec_harness(&binary, &argv, &cwd, exec) +} + +/// Turn a picked session row into a toolpath `Graph`, write-through +/// caching by default like `share`: a manifest-fresh cache entry is +/// loaded directly (skipped with `--force`); otherwise the session is +/// derived and, unless `--no-cache`, cached + recorded in the sync +/// manifest. +fn load_or_derive_session_doc( + bundle: &crate::harness::HarnessBundle, + row: &crate::cmd_share::ArtifactRow, + args: &ResumeArgs, +) -> Result { + // Path-keyed providers (claude/gemini/pi) key derives by project + // path; for cwd-keyed ones `row.path` is `None` — exactly how + // `share_explicit` passes it. + let project = row.path.as_deref(); + + if !args.no_cache + && !args.force + && let Some(cache_id) = + crate::sync::fresh_cache_id(bundle, row.artifact_type, project, &row.session_id) + { + let doc_path = crate::cache::cache_path(&cache_id)?; + let json = std::fs::read_to_string(&doc_path) + .with_context(|| format!("read {}", doc_path.display()))?; + eprintln!( + "Cache is current for {} session {cache_id}; resuming without re-deriving", + row.artifact_type.name() + ); + return Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e)); + } + + let derived = crate::cmd_share::derive_session(row.artifact_type, project, &row.session_id)?; + if !args.no_cache { + let path = crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; + if let Some(stub) = &derived.provenance + && let Err(e) = crate::sync::record_artifact(stub, &derived.cache_id) + { + eprintln!("warning: sync manifest not updated: {e}"); + } + eprintln!( + "Cached {} session → {} ({})", + row.artifact_type.name(), + derived.cache_id, + path.display() + ); + } + Ok(derived.doc) +} + /// Read a path's source harness from `meta.source` (set by /// `toolpath-convo::derive_path` to the provider id), falling back to /// actor-string sniffing across the path's steps. @@ -197,7 +597,10 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { - let raw = args.input.as_str(); + let raw = args + .input + .as_deref() + .expect("resolve_input requires input — bare mode is handled by run_bare"); enum Shape<'a> { PathbaseUrl(&'a str), @@ -601,12 +1004,10 @@ mod tests { std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); let args = ResumeArgs { - input: doc_file.to_string_lossy().to_string(), + input: Some(doc_file.to_string_lossy().to_string()), cwd: Some(cwd.path().to_path_buf()), harness: Some(Harness::Claude), - no_cache: false, - force: false, - url: None, + ..Default::default() }; let recorder = RecordingExec::default(); @@ -737,12 +1138,8 @@ mod tests { std::fs::write(&p, graph.to_json().unwrap()).unwrap(); let args = ResumeArgs { - input: p.to_string_lossy().to_string(), - cwd: None, - harness: None, - no_cache: false, - force: false, - url: None, + input: Some(p.to_string_lossy().to_string()), + ..Default::default() }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -768,15 +1165,12 @@ mod tests { let server = MockServer::start("HTTP/1.1 200 OK", body_static); let args = ResumeArgs { - input: format!( + input: Some(format!( "{}/u/alex/repos/pathstash/graphs/fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537", server.base() - ), - cwd: None, - harness: None, + )), no_cache: true, // skip cache write in tests - force: false, - url: None, + ..Default::default() }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -829,15 +1223,11 @@ mod tests { let server = MockServer::start("HTTP/1.1 500 Internal Server Error", "boom"); let args = ResumeArgs { - input: format!( + input: Some(format!( "{}/u/alex/repos/pathstash/graphs/{FIXTURE_UUID}", server.base() - ), - cwd: None, - harness: None, - no_cache: false, - force: false, - url: None, + )), + ..Default::default() }; let result = resolve_input(&args); @@ -860,12 +1250,8 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()); let args = ResumeArgs { - input: "definitely/not/a/real/cache/id".to_string(), - cwd: None, - harness: None, - no_cache: false, - force: false, - url: None, + input: Some("definitely/not/a/real/cache/id".to_string()), + ..Default::default() }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); @@ -1054,6 +1440,19 @@ mod tests { prev, } } + + /// Replaces `PATH` with an empty tempdir — no binaries at all. + fn empty() -> Self { + let bin_dir = fake_path_with(&[]); + let prev = std::env::var_os("PATH"); + unsafe { + std::env::set_var("PATH", bin_dir.path()); + } + Self { + _bin_dir: bin_dir, + prev, + } + } } impl Drop for ScopedPathForResume { @@ -1094,6 +1493,105 @@ mod tests { } } + // ── bare mode ─────────────────────────────────────────────────── + + /// Local wrapper so clap's parse rules for `ResumeArgs` (positional + /// optionality, conflicts) can be exercised without the full CLI. + #[derive(clap::Parser, Debug)] + struct TestCli { + #[command(flatten)] + args: ResumeArgs, + } + + #[test] + fn bare_args_input_is_optional() { + use clap::Parser; + let cli = TestCli::try_parse_from(["path-resume"]).unwrap(); + assert!(cli.args.input.is_none()); + + let cli = TestCli::try_parse_from(["path-resume", "some-input"]).unwrap(); + assert_eq!(cli.args.input.as_deref(), Some("some-input")); + } + + #[test] + fn from_conflicts_with_input() { + use clap::Parser; + let err = + TestCli::try_parse_from(["path-resume", "some-input", "--from", "claude"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn project_conflicts_with_input() { + use clap::Parser; + let err = TestCli::try_parse_from(["path-resume", "some-input", "--project", "/tmp/p"]) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn index_of_selected_maps_line_back_to_row() { + let lines = vec![ + "claude\t/p\tsess-a\tdisplay a\ttitle a".to_string(), + "codex\t/q\tsess-b\tdisplay b\ttitle b".to_string(), + ]; + assert_eq!(index_of_selected(&lines, &lines[0]).unwrap(), 0); + assert_eq!(index_of_selected(&lines, &lines[1]).unwrap(), 1); + } + + #[test] + fn index_of_selected_unknown_line_errors() { + let lines = vec!["claude\t/p\tsess-a\tdisplay\ttitle".to_string()]; + let err = index_of_selected(&lines, "not-a-row").unwrap_err(); + assert!( + err.to_string().contains("unrecognized row"), + "actual: {err}" + ); + } + + #[test] + fn fixed_picker_records_offered_lines() { + let picker = FixedPicker::select(1); + let lines = vec!["row a".to_string(), "row b".to_string()]; + let choice = picker.pick(&lines, "header").unwrap(); + assert_eq!(choice, PickChoice::Index(1)); + assert_eq!(picker.offered(), lines); + } + + #[test] + fn run_bare_offline_picker_errors_with_no_tty_text() { + let args = ResumeArgs::default(); + let recorder = RecordingExec::default(); + let err = run_bare(&args, &recorder, &FixedPicker::offline()).unwrap_err(); + assert_eq!( + err.to_string(), + "no input provided and no TTY for interactive selection; pass an (URL, file, or cache id), or rerun in a terminal" + ); + } + + #[test] + fn run_bare_unavailable_target_harness_errors_before_picking() { + let _env = crate::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _path_guard = ScopedPathForResume::empty(); + let args = ResumeArgs { + harness: Some(Harness::Claude), + ..Default::default() + }; + let recorder = RecordingExec::default(); + let picker = FixedPicker::select(0); + let err = run_bare(&args, &recorder, &picker).unwrap_err(); + assert!( + err.to_string().contains("`claude` isn't on PATH"), + "actual: {err}" + ); + assert!( + picker.offered().is_empty(), + "target validation must precede the session picker" + ); + } + #[test] fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 51a09510..8b546328 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -208,6 +208,212 @@ pub(crate) fn gather_artifacts( rows } +/// Result of a recency-bounded gather: the newest `limit` sessions +/// across every harness (fully hydrated rows), plus how many older +/// sessions exist beyond them. +pub(crate) struct RecentRows { + pub(crate) rows: Vec, + pub(crate) older: usize, +} + +/// Like [`gather_artifacts`] but bounded: hydrate metadata only for +/// roughly the newest `limit` sessions, so the picker opens in +/// milliseconds regardless of history size. Codex — whose full scan is +/// seconds of raw I/O on big trees — is enumerated stat-only (file +/// mtimes), and only its newest `limit` rollouts get an O(1) head+tail +/// peek ([`toolpath_codex::ConvoIO::peek_metadata`]); its rows carry +/// `message_count: None`. The other providers' listings are already +/// cheap and run in full, in parallel. The merged result is ranked +/// exactly like [`gather_artifacts`] (cwd-matches first, then newest) +/// and truncated to `limit`; everything not shown is counted in +/// `older` so callers can offer a "load everything" affordance. +pub(crate) fn gather_recent( + bundle: &HarnessBundle, + cwd: &std::path::Path, + harness_filter: Option, + limit: usize, +) -> RecentRows { + let canonical_cwd = canonicalize_or_self(cwd); + let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h); + + let mut listing_cache = ListingCache::load(); + let claude_cache = listing_cache.section(ArtifactType::Claude); + let codex_cache = listing_cache.section(ArtifactType::Codex); + let opencode_cache = listing_cache.section(ArtifactType::Opencode); + + let mut rows = Vec::new(); + let mut refreshed: Vec<(ArtifactType, ProviderListings)> = Vec::new(); + let mut codex_recent: (Vec, usize) = (Vec::new(), 0); + let cwd_ref = &canonical_cwd; + type CollectOutput = (Vec, Option<(ArtifactType, ProviderListings)>); + std::thread::scope(|s| { + let mut handles: Vec> = Vec::new(); + + macro_rules! spawn_collect { + ($ty:expr, $mgr:expr, $collect:ident) => { + if want($ty) + && let Some(mgr) = $mgr + { + handles.push(s.spawn(move || { + let mut out = Vec::new(); + $collect(mgr, cwd_ref, None, &mut out); + (out, None) + })); + } + }; + } + + let codex_handle = if want(ArtifactType::Codex) + && let Some(mgr) = &bundle.codex + { + let cache = &codex_cache; + Some(s.spawn(move || collect_codex_recent(mgr, cwd_ref, limit, cache))) + } else { + None + }; + + spawn_collect!(ArtifactType::Gemini, &bundle.gemini, collect_gemini); + spawn_collect!(ArtifactType::Pi, &bundle.pi, collect_pi); + spawn_collect!(ArtifactType::Copilot, &bundle.copilot, collect_copilot); + if want(ArtifactType::Opencode) + && let Some(mgr) = &bundle.opencode + { + let cache = &opencode_cache; + handles.push(s.spawn(move || { + let mut out = Vec::new(); + let fresh = collect_opencode(mgr, cwd_ref, None, cache, &mut out); + (out, Some((ArtifactType::Opencode, fresh))) + })); + } + spawn_collect!(ArtifactType::Cursor, &bundle.cursor, collect_cursor); + + if want(ArtifactType::Claude) + && let Some(mgr) = &bundle.claude + { + let fresh = collect_claude(mgr, cwd_ref, None, &claude_cache, &mut rows); + refreshed.push((ArtifactType::Claude, fresh)); + } + + for handle in handles { + match handle.join() { + Ok((out, section)) => { + rows.extend(out); + if let Some(section) = section { + refreshed.push(section); + } + } + Err(_) => eprintln!("warning: a session collector panicked; its rows are skipped"), + } + } + if let Some(h) = codex_handle { + match h.join() { + Ok((r, older, fresh)) => { + codex_recent = (r, older); + refreshed.push((ArtifactType::Codex, fresh)); + } + Err(_) => eprintln!("warning: the codex collector panicked; its rows are skipped"), + } + } + }); + + for (artifact_type, fresh) in refreshed { + listing_cache.replace_section(artifact_type, fresh); + } + listing_cache.save_if_dirty(); + + let (codex_rows, codex_older) = codex_recent; + rows.extend(codex_rows); + rows.sort_by(|a, b| { + b.matches_cwd + .cmp(&a.matches_cwd) + .then_with(|| b.last_activity.cmp(&a.last_activity)) + }); + let cut = rows.len().saturating_sub(limit); + rows.truncate(limit); + RecentRows { + rows, + older: cut + codex_older, + } +} + +/// Codex arm of [`gather_recent`]: enumerate stat-only, rank by +/// modified time, and hydrate only the newest `limit` rollouts — +/// from the listing cache when the stamp matches (instant, count +/// included), else one full streaming `read_metadata` whose result is +/// cached against the file stamp. Entries beyond the limit carry +/// their existing cache records forward untouched (a stale stamp +/// re-hydrates whenever the artifact next surfaces); ids no longer +/// enumerated drop out. +fn collect_codex_recent( + mgr: &toolpath_codex::CodexConvo, + canonical_cwd: &std::path::Path, + limit: usize, + cache: &ProviderListings, +) -> (Vec, usize, ProviderListings) { + let mut refs = codex_source(mgr).enumerate(); + refs.sort_by(|a, b| b.modified.cmp(&a.modified)); + let older = refs.len().saturating_sub(limit); + + // One lazy directory walk maps session id -> rollout path for the + // misses (same trick as the full-sweep collector). + let mut files: Option> = None; + + let mut fresh = ProviderListings::new(); + let mut rows = Vec::with_capacity(limit.min(refs.len())); + for (i, r) in refs.iter().enumerate() { + if i >= limit { + // Not hydrated this round: keep whatever the cache knew. + if let Some(entry) = cache.get(&r.id) { + fresh.insert(r.id.clone(), entry.clone()); + } + continue; + } + let row = match cache.get(&r.id) { + Some(entry) if entry.matches(r) => Some(row_from_cached( + ArtifactType::Codex, + &entry.row, + canonical_cwd, + )), + _ => { + let files = files.get_or_insert_with(|| { + mgr.io() + .list_rollout_files() + .unwrap_or_default() + .into_iter() + .filter_map(|p| { + let stem = p.file_stem()?.to_str()?; + Some((toolpath_codex::session_id_from_stem(stem).to_string(), p)) + }) + .collect() + }); + match files.get(&r.id) { + Some(file) => match mgr.io().read_metadata(file) { + Ok(m) => Some(codex_row(m, canonical_cwd)), + Err(e) => { + eprintln!("Warning: failed to read {}: {e}", file.display()); + None + } + }, + None => None, + } + } + }; + if let Some(row) = row { + fresh.insert( + r.id.clone(), + CachedListing { + modified: r.modified, + size: r.size, + row: cached_from_row(&row), + }, + ); + rows.push(row); + } + } + rows.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); + (rows, older, fresh) +} + fn canonicalize_or_self(p: &std::path::Path) -> std::path::PathBuf { std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) } @@ -760,7 +966,7 @@ pub fn run(args: ShareArgs) -> Result<()> { let rows = gather_artifacts(&bundle, &cwd, harness, project_filter); if rows.is_empty() { - return bail_no_sessions(&bundle, project_filter); + return bail_no_sessions(&bundle, project_filter, "shareable"); } if !crate::fuzzy::available() { @@ -833,9 +1039,13 @@ pub fn run(args: ShareArgs) -> Result<()> { share_explicit(h, &session, &explicit, auth, base_url) } -fn bail_no_sessions( +/// Error out with a per-harness status table explaining why no agent +/// sessions were found. `adjective` names the caller's verb space — +/// share passes "shareable", bare `resume` passes "resumable". +pub(crate) fn bail_no_sessions( bundle: &HarnessBundle, project_filter: Option<&std::path::Path>, + adjective: &str, ) -> Result<()> { if let Some(p) = project_filter { anyhow::bail!( @@ -877,7 +1087,7 @@ fn bail_no_sessions( &harness_status_pi(bundle, home.as_deref()), )); eprint!("{summary}"); - anyhow::bail!("no shareable sessions"); + anyhow::bail!("no {adjective} sessions"); } /// Cross-platform `$HOME` lookup matching the providers' internal helpers. @@ -1121,7 +1331,7 @@ fn share_explicit( /// The display column is space-padded rather than tab-separated so the /// columns line up consistently across pickers — terminal tab stops /// produce ugly variable gaps in both fzf and skim. -fn format_picker_row(row: &ArtifactRow) -> String { +pub(crate) fn format_picker_row(row: &ArtifactRow) -> String { let key = row .path .clone() @@ -1168,7 +1378,7 @@ fn parse_picker_row(line: &str) -> Option<(ArtifactType, String, String, String) use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe}; -fn derive_session( +pub(crate) fn derive_session( harness: ArtifactType, project: Option<&str>, session: &str, diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 515b82db..67202074 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -87,7 +87,9 @@ enum Commands { args: cmd_share::ShareArgs, }, /// Resume an agent session into the chosen harness, projecting the - /// document and exec'ing the harness's resume command. + /// document and exec'ing the harness's resume command. With no + /// input, picks a session interactively across all installed + /// harnesses. #[cfg(not(target_os = "emscripten"))] Resume { #[command(flatten)] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..ca51f161 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,7 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::cmd_resume::{FixedPicker, RecordingExec, ResumeArgs, run_bare, run_with_strategy}; use path_cli::harness::Harness; mod support; @@ -259,12 +259,10 @@ fn cache_id_input_loads_and_projects() { .unwrap(); let resume_args = ResumeArgs { - input: cache_id.to_string(), + input: Some(cache_id.to_string()), cwd: Some(cwd.path().to_path_buf()), harness: Some(Harness::Claude), - no_cache: false, - force: false, - url: None, + ..Default::default() }; let recorder = RecordingExec::default(); @@ -330,6 +328,327 @@ fn agentless_path_returns_clear_error() { assert!(err.to_string().contains("no agent session")); } +// ── Bare mode: cross-harness session picker ───────────────────────── + +#[test] +fn bare_resume_picks_session_derives_projects_and_execs() { + let _env = env_lock(); + let home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + write_claude_session( + &home.home_dir(), + "-test-project", + "bare-session-one", + "Add a feature", + ); + + let args = ResumeArgs { + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + ..Default::default() + }; + let recorder = RecordingExec::default(); + let picker = FixedPicker::select(0); + run_bare(&args, &recorder, &picker).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + assert!(!cap.args[1].is_empty(), "session id should be non-empty"); + assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); + + // The projection wrote a JSONL beyond the fixture's own — i.e. one + // outside the fixture's project directory. + let projects = home.home_dir().join(".claude/projects"); + let projected: Vec<_> = files_with_ext(&projects, "jsonl") + .into_iter() + .filter(|p| !p.parent().unwrap().ends_with("-test-project")) + .collect(); + assert!( + !projected.is_empty(), + "no projected JSONL written outside the fixture project" + ); +} + +#[test] +fn bare_resume_no_sessions_bails_with_status_table() { + let _env = env_lock(); + let _home = ScopedHome::new(); + + let args = ResumeArgs::default(); + let recorder = RecordingExec::default(); + let err = run_bare(&args, &recorder, &FixedPicker::select(0)).unwrap_err(); + assert!( + err.to_string().contains("no resumable sessions"), + "actual: {err}" + ); + assert!(recorder.captured().binary.is_empty(), "must not exec"); +} + +#[test] +fn bare_resume_from_filters_the_picker() { + let _env = env_lock(); + let home = ScopedHome::new(); + + write_claude_session(&home.home_dir(), "-test-project", "claude-sess-one", "hi"); + write_codex_session( + &home.home_dir(), + "00000000-0000-0000-0000-0000000000aa", + "/work/proj", + ); + + let args = ResumeArgs { + from: Some(Harness::Codex), + ..Default::default() + }; + let recorder = RecordingExec::default(); + let picker = FixedPicker::no_match(); + run_bare(&args, &recorder, &picker).unwrap(); + + let offered = picker.offered(); + assert!(!offered.is_empty(), "codex fixture should be offered"); + assert!( + offered.iter().all(|l| l.starts_with("codex\t")), + "every offered line must be codex: {offered:?}" + ); + assert!( + offered.iter().all(|l| !l.starts_with("claude\t")), + "claude rows must be filtered out: {offered:?}" + ); +} + +#[test] +fn bare_resume_from_with_no_matching_sessions_mentions_filter() { + let _env = env_lock(); + let home = ScopedHome::new(); + + // A claude session exists, but the --from filter names codex — + // the error must name the filter instead of showing the generic + // all-harness status table. + write_claude_session(&home.home_dir(), "-test-project", "claude-only-sess", "hi"); + + let args = ResumeArgs { + from: Some(Harness::Codex), + ..Default::default() + }; + let recorder = RecordingExec::default(); + let err = run_bare(&args, &recorder, &FixedPicker::select(0)).unwrap_err(); + assert_eq!( + err.to_string(), + "no codex sessions found; drop --from to see sessions from other harnesses" + ); + assert!(recorder.captured().binary.is_empty(), "must not exec"); +} + +#[test] +fn bare_resume_cwd_flag_ranks_matching_sessions_first() { + let _env = env_lock(); + let home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + + // The matching session gets a strictly OLDER embedded timestamp + // (codex `last_activity` is the max embedded JSONL timestamp, not + // file mtime). Recency ranking alone would therefore put the + // NON-matching session first — so `offered()[0]` below passes only + // if the cwd match genuinely outranks recency. A broken + // `matches_cwd` (always false) deterministically fails this test. + write_codex_session_at( + &home.home_dir(), + "00000000-0000-0000-0000-0000000000bb", + canonical_cwd.to_str().unwrap(), + "2026-05-07T00:00", + ); + write_codex_session_at( + &home.home_dir(), + "00000000-0000-0000-0000-0000000000cc", + "/somewhere/else", + "2026-05-07T01:00", + ); + + let args = ResumeArgs { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }; + let recorder = RecordingExec::default(); + let picker = FixedPicker::no_match(); + run_bare(&args, &recorder, &picker).unwrap(); + + let offered = picker.offered(); + assert_eq!(offered.len(), 2, "both codex fixtures should be offered"); + assert!( + offered[0].contains("00000000-0000-0000-0000-0000000000bb"), + "cwd-matching session must rank first: {offered:?}" + ); +} + +#[test] +fn bare_resume_writes_cache_and_manifest_by_default() { + let _env = env_lock(); + let home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + write_claude_session( + &home.home_dir(), + "-test-project", + "bare-cache-session", + "prompt", + ); + + let args = ResumeArgs { + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + ..Default::default() + }; + run_bare(&args, &RecordingExec::default(), &FixedPicker::select(0)).unwrap(); + + let config_dir = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()); + let cached: Vec<_> = files_with_ext(&config_dir.join("documents"), "json") + .into_iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("claude-")) + }) + .collect(); + assert!(!cached.is_empty(), "no claude-*.json written to the cache"); + + let manifest = std::fs::read_to_string(config_dir.join("manifest.json")) + .expect("manifest.json should exist after a bare resume"); + assert!( + manifest.contains("bare-cache-session"), + "manifest must record the derived session: {manifest}" + ); +} + +#[test] +fn bare_resume_no_cache_skips_cache_write() { + let _env = env_lock(); + let home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + write_claude_session( + &home.home_dir(), + "-test-project", + "bare-nocache-session", + "prompt", + ); + + let args = ResumeArgs { + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: true, + ..Default::default() + }; + run_bare(&args, &RecordingExec::default(), &FixedPicker::select(0)).unwrap(); + + let config_dir = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()); + assert!( + files_with_ext(&config_dir.join("documents"), "json").is_empty(), + "--no-cache must not write cache docs" + ); + assert!( + !config_dir.join("manifest.json").exists(), + "--no-cache must not record the manifest" + ); +} + +#[test] +fn bare_resume_fresh_cache_fast_path_uses_cached_doc() { + let _env = env_lock(); + let home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + + write_claude_session( + &home.home_dir(), + "-test-project", + "fast-path-session", + "prompt", + ); + + // Run 1: derives and writes cache + manifest. `--project` pins the + // picker to the fixture so run 2 can't accidentally pick the + // session run 1 projects under $HOME/.claude/projects. + let cwd1 = tempfile::tempdir().unwrap(); + let args1 = ResumeArgs { + cwd: Some(cwd1.path().to_path_buf()), + harness: Some(Harness::Claude), + project: Some(std::path::PathBuf::from("/test/project")), + ..Default::default() + }; + run_bare(&args1, &RecordingExec::default(), &FixedPicker::select(0)).unwrap(); + + // Overwrite the cached doc with a sentinel graph. fresh_cache_id + // stats the SOURCE session file (untouched), so the overwritten + // doc still rides the fast path. + let config_dir = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()); + let cached = files_with_ext(&config_dir.join("documents"), "json") + .into_iter() + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("claude-")) + }) + .expect("run 1 should have cached the derived doc"); + let mut sentinel = make_convo_path("agent:claude-code", "claude-code://sentinel-session"); + sentinel.steps[0] + .change + .get_mut("claude-code://sentinel-session") + .unwrap() + .structural + .as_mut() + .unwrap() + .extra + .insert( + "text".to_string(), + serde_json::json!("SENTINEL-BARE-RESUME-FAST-PATH"), + ); + let sentinel_graph = toolpath::v1::Graph::from_path(sentinel); + std::fs::write(&cached, sentinel_graph.to_json().unwrap()).unwrap(); + + // Run 2 into a fresh cwd: the fast path must project the sentinel + // doc, not a re-derivation of the fixture. + let cwd2 = tempfile::tempdir().unwrap(); + let args2 = ResumeArgs { + cwd: Some(cwd2.path().to_path_buf()), + harness: Some(Harness::Claude), + project: Some(std::path::PathBuf::from("/test/project")), + ..Default::default() + }; + run_bare(&args2, &RecordingExec::default(), &FixedPicker::select(0)).unwrap(); + + let projects = home.home_dir().join(".claude/projects"); + let has_sentinel = files_with_ext(&projects, "jsonl").iter().any(|p| { + std::fs::read_to_string(p) + .map(|s| s.contains("SENTINEL-BARE-RESUME-FAST-PATH")) + .unwrap_or(false) + }); + assert!( + has_sentinel, + "run 2 must project the cached sentinel doc via the freshness fast path" + ); +} + +#[test] +fn bare_resume_no_match_returns_ok_without_exec() { + let _env = env_lock(); + let home = ScopedHome::new(); + + write_claude_session(&home.home_dir(), "-test-project", "nomatch-session", "hi"); + + let args = ResumeArgs::default(); + let recorder = RecordingExec::default(); + run_bare(&args, &recorder, &FixedPicker::no_match()).unwrap(); + assert!( + recorder.captured().binary.is_empty(), + "NoMatch must not exec anything" + ); +} + #[test] fn explicit_harness_not_on_path_errors() { let _env = env_lock(); @@ -350,3 +669,95 @@ fn explicit_harness_not_on_path_errors() { assert!(s.contains("isn't on PATH"), "actual: {s}"); assert!(s.contains("claude"), "actual: {s}"); } + +// ── Recency-first picker (top-N hydration + tail row) ─────────────── + +#[test] +fn bare_resume_recent_view_mixes_all_harnesses_in_one_call() { + let _env = env_lock(); + let home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + + write_claude_session(&home.home_dir(), "-mix-proj", "mix-claude", "claude prompt"); + write_codex_session( + &home.home_dir(), + "00000000-0000-0000-0000-0000000000aa", + "/mix/proj", + ); + + let args = ResumeArgs { + harness: Some(Harness::Claude), + ..Default::default() + }; + let recorder = RecordingExec::default(); + let picker = FixedPicker::select(0); + run_bare(&args, &recorder, &picker).unwrap(); + + let calls = picker.offered_calls(); + assert_eq!( + calls.len(), + 1, + "one recency round, no second sweep: {calls:?}" + ); + let view = &calls[0]; + assert!( + view.iter().any(|l| l.starts_with("claude\t")), + "recency view must include the claude session: {view:?}" + ); + assert!( + view.iter().any(|l| l.starts_with("codex\t")), + "recency view must include the codex session: {view:?}" + ); + assert!( + view.iter().all(|l| !l.contains("older sessions")), + "small history must not offer a tail row: {view:?}" + ); + assert_eq!(recorder.captured().binary, "claude"); +} + +#[test] +fn bare_resume_tail_row_loads_everything() { + let _env = env_lock(); + let home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + + // 101 codex sessions: one more than RECENT_LIMIT, so the recency + // view shows 100 rows plus a "1 older sessions" tail row. + for i in 0..101u32 { + let id = format!("00000000-0000-0000-0000-0000000{:05}", i); + let stamp = format!("2026-05-07T{:02}:{:02}", i / 60, i % 60); + write_codex_session_at(&home.home_dir(), &id, "/tail/proj", &stamp); + } + + let args = ResumeArgs { + harness: Some(Harness::Claude), + ..Default::default() + }; + let recorder = RecordingExec::default(); + // Round 1: 100 rows + tail at index 100 -> pick the tail. + // Round 2: the full sweep -> pick the top row. + let picker = FixedPicker::sequence(vec![ + path_cli::cmd_resume::PickChoice::Index(100), + path_cli::cmd_resume::PickChoice::Index(0), + ]); + run_bare(&args, &recorder, &picker).unwrap(); + + let calls = picker.offered_calls(); + assert_eq!( + calls.len(), + 2, + "tail row must trigger the full sweep: {calls:?}" + ); + assert_eq!(calls[0].len(), 101, "100 rows + tail row"); + let tail = calls[0].last().unwrap(); + assert!( + tail.contains("1 older sessions"), + "tail row must count the unhydrated sessions: {tail}" + ); + assert_eq!(calls[1].len(), 101, "full sweep hydrates all 101 sessions"); + assert!( + calls[1].iter().all(|l| !l.contains("older sessions")), + "full sweep offers no tail row" + ); + assert_eq!(recorder.captured().binary, "claude"); +} diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index bf7597ba..c846f909 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -27,11 +27,20 @@ pub fn env_lock() -> std::sync::MutexGuard<'static, ()> { .unwrap_or_else(|e| e.into_inner()) } -/// RAII guard that pins `$HOME` and `$TOOLPATH_CONFIG_DIR` to a tempdir. +/// RAII guard that pins `$HOME`, `$TOOLPATH_CONFIG_DIR`, +/// `$XDG_DATA_HOME`, and `$COPILOT_HOME` to a tempdir. `XDG_DATA_HOME` +/// is pinned because the opencode provider resolves its database +/// through it before falling back to `~/.local/share`; `COPILOT_HOME` +/// because the copilot provider honors it as a full root override +/// before falling back to `~/.copilot` — without the pins, bare-resume +/// aggregation would leak the developer's real sessions into gather +/// results. pub struct ScopedHome { _td: tempfile::TempDir, prev_home: Option, prev_config: Option, + prev_xdg_data: Option, + prev_copilot_home: Option, } impl ScopedHome { @@ -39,14 +48,20 @@ impl ScopedHome { let td = tempfile::tempdir().unwrap(); let prev_home = std::env::var_os("HOME"); let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR"); + let prev_xdg_data = std::env::var_os("XDG_DATA_HOME"); + let prev_copilot_home = std::env::var_os("COPILOT_HOME"); unsafe { std::env::set_var("HOME", td.path()); std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath")); + std::env::set_var("XDG_DATA_HOME", td.path().join(".local/share")); + std::env::set_var("COPILOT_HOME", td.path().join(".copilot")); } Self { _td: td, prev_home, prev_config, + prev_xdg_data, + prev_copilot_home, } } @@ -66,6 +81,14 @@ impl Drop for ScopedHome { Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), } + match &self.prev_xdg_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } + match &self.prev_copilot_home { + Some(v) => std::env::set_var("COPILOT_HOME", v), + None => std::env::remove_var("COPILOT_HOME"), + } } } } @@ -181,13 +204,85 @@ pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf { /// Construct `ResumeArgs` for a file-input + explicit-harness test. pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs { ResumeArgs { - input: input.to_string_lossy().to_string(), + input: Some(input.to_string_lossy().to_string()), cwd: Some(cwd.to_path_buf()), harness: Some(harness), - no_cache: false, - force: false, - url: None, + ..Default::default() + } +} + +/// Write a minimal Claude Code session fixture under +/// `/.claude/projects//.jsonl` (one user +/// prompt + one assistant reply), modeled on `cmd_share`'s unit-test +/// fixture. The recorded `cwd` becomes the row's project path when the +/// slug unsanitizes to it (e.g. slug `-test-project` → `/test/project`). +pub fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { + let project_dir = home.join(".claude/projects").join(project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let cwd = project_slug.replace('-', "/"); + let user = format!( + r#"{{"type":"user","uuid":"u-{session}","timestamp":"2026-01-02T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"{prompt}"}}}}"# + ); + let asst = format!( + r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2026-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"# + ); + std::fs::write( + project_dir.join(format!("{session}.jsonl")), + format!("{user}\n{asst}\n"), + ) + .unwrap(); +} + +/// Write a minimal Codex rollout fixture under +/// `/.codex/sessions/2026/05/07/rollout-…-.jsonl` with the +/// given recorded `cwd`, modeled on `cmd_share`'s unit-test fixture. +/// `id` must be UUID-shaped (the artifact id comes from the filename +/// stem's trailing UUID). +pub fn write_codex_session(home: &Path, id: &str, cwd: &str) { + write_codex_session_at(home, id, cwd, "2026-05-07T00:00"); +} + +/// [`write_codex_session`] with control over the embedded timestamps. +/// `stamp` is minute-precision RFC 3339 without seconds or zone — e.g. +/// `2026-05-07T00:00` — so a single fixture's three lines get +/// `:00`/`:01`/`:02` seconds and callers can order whole fixtures +/// relative to each other. Codex's listed `last_activity` is the max +/// embedded JSONL timestamp, so distinct stamps give a deterministic +/// recency ordering. +pub fn write_codex_session_at(home: &Path, id: &str, cwd: &str, stamp: &str) { + let dir = home.join(".codex/sessions/2026/05/07"); + std::fs::create_dir_all(&dir).unwrap(); + let file_stamp = stamp.replace(':', "-"); + let file = dir.join(format!("rollout-{file_stamp}-00-{id}.jsonl")); + let meta = format!( + r#"{{"timestamp":"{stamp}:00Z","type":"session_meta","payload":{{"id":"{id}","timestamp":"{stamp}:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"# + ); + let user = format!( + r#"{{"timestamp":"{stamp}:01Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"hi"}}]}}}}"# + ); + let asst = format!( + r#"{{"timestamp":"{stamp}:02Z","type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"hello"}}]}}}}"# + ); + std::fs::write(file, format!("{meta}\n{user}\n{asst}\n")).unwrap(); +} + +/// Recursively collect every file under `root` with the given extension. +pub fn files_with_ext(root: &Path, ext: &str) -> Vec { + fn walk(p: &Path, ext: &str, out: &mut Vec) { + if !p.exists() { + return; + } + if p.is_dir() { + for e in std::fs::read_dir(p).unwrap() { + walk(&e.unwrap().path(), ext, out); + } + } else if p.extension().and_then(|s| s.to_str()) == Some(ext) { + out.push(p.to_path_buf()); + } } + let mut out = Vec::new(); + walk(root, ext, &mut out); + out } /// Recursively walk `root` looking for a file with the given extension. diff --git a/crates/toolpath-codex/Cargo.toml b/crates/toolpath-codex/Cargo.toml index 75138402..1c66e5ec 100644 --- a/crates/toolpath-codex/Cargo.toml +++ b/crates/toolpath-codex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-codex" -version = "0.6.2" +version = "0.6.3" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-codex/src/io.rs b/crates/toolpath-codex/src/io.rs index e1054a75..e2abadd0 100644 --- a/crates/toolpath-codex/src/io.rs +++ b/crates/toolpath-codex/src/io.rs @@ -92,15 +92,8 @@ impl ConvoIO { /// `line_count` counts non-empty lines (unparseable ones /// included) rather than successfully parsed ones. pub fn read_metadata>(&self, path: P) -> Result { - use crate::types::{ResponseItem, RolloutLine}; use std::io::BufRead; - // Parse at most this many non-empty head lines looking for - // session_meta / timestamps / the first user prompt. Real - // sessions surface the prompt within the first dozen lines - // (after session_meta, turn_context, and injected context). - const HEAD_PARSE_BUDGET: usize = 100; - let path = path.as_ref(); if !path.exists() { return Err(crate::error::ConvoError::SessionNotFound( @@ -114,13 +107,7 @@ impl ConvoIO { let mut last_nonempty = String::new(); let mut line_count = 0usize; - let mut head_parsed = 0usize; - let mut first_line_meta_id: Option = None; - let mut meta: Option> = None; - let mut started_at = None; - let mut last_ts = None; - let mut first_user: Option = None; - let mut first_user_fallback: Option = None; + let mut hunt = HeadHunt::default(); loop { raw.clear(); @@ -142,70 +129,225 @@ impl ConvoIO { continue; } line_count += 1; + hunt.ingest(line_count, trimmed); + std::mem::swap(&mut last_nonempty, &mut raw); + } + + hunt.ingest_tail_line(last_nonempty.trim()); + Ok(hunt.into_metadata(path, line_count)) + } + + /// Like [`Self::read_metadata`] but O(1) in file size: reads one + /// chunk from the head (session_meta, first timestamps, first user + /// prompt) and one chunk from the tail (newest timestamp), never + /// streaming the bytes in between. The price is no `line_count` — + /// this is the right call for recency-ranked listing surfaces that + /// hydrate only the newest N sessions. + pub fn peek_metadata>(&self, path: P) -> Result { + use std::io::{Read, Seek, SeekFrom}; + + const CHUNK: u64 = 256 * 1024; + + let path = path.as_ref(); + if !path.exists() { + return Err(crate::error::ConvoError::SessionNotFound( + path.display().to_string(), + )); + } - let still_hunting = meta.is_none() || started_at.is_none() || first_user.is_none(); - if still_hunting && head_parsed < HEAD_PARSE_BUDGET { - head_parsed += 1; - if let Ok(line) = serde_json::from_str::(trimmed) { - if let Some(ts) = line.parsed_timestamp() { - if started_at.is_none_or(|s| ts < s) { - started_at = Some(ts); - } - if last_ts.is_none_or(|l| ts > l) { - last_ts = Some(ts); - } - } - if line_count == 1 && line.kind == "session_meta" { - first_line_meta_id = line - .payload - .get("id") - .and_then(|v| v.as_str()) - .map(str::to_string); - } - if first_user.is_none() - && line.kind == "event_msg" - && line.payload.get("type").and_then(|v| v.as_str()) == Some("user_message") - && let Some(msg) = line.payload.get("message").and_then(|v| v.as_str()) - && !msg.is_empty() - { - first_user = Some(msg.to_string()); - } - match line.item() { - RolloutItem::SessionMeta(m) if meta.is_none() => meta = Some(m), - RolloutItem::ResponseItem(ResponseItem::Message(m)) - if m.role == "user" && first_user_fallback.is_none() => - { - let t = m.text(); - if !t.is_empty() { - first_user_fallback = Some(t); - } - } - _ => {} - } + let mut file = std::fs::File::open(path)?; + let len = file.metadata()?.len(); + + let mut head = vec![0u8; CHUNK.min(len) as usize]; + file.read_exact(&mut head)?; + let head_text = String::from_utf8_lossy(&head); + // When the chunk cut a line in half, the final fragment is not + // a complete record — drop it (the tail chunk covers file end). + let head_complete = if len > CHUNK { + match head_text.rfind('\n') { + Some(i) => &head_text[..i], + None => "", + } + } else { + &head_text + }; + + let mut hunt = HeadHunt::default(); + let mut line_no = 0usize; + for line in head_complete.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + line_no += 1; + hunt.ingest(line_no, trimmed); + } + + if len > CHUNK { + let mut tail = vec![0u8; CHUNK as usize]; + file.seek(SeekFrom::Start(len - CHUNK))?; + file.read_exact(&mut tail)?; + let tail_text = String::from_utf8_lossy(&tail); + // Skip the first fragment (likely mid-line after the seek); + // walk backward to the last parseable line. + for line in tail_text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if hunt.try_ingest_tail_line(trimmed) { + break; } } - std::mem::swap(&mut last_nonempty, &mut raw); } - // The tail line carries the newest timestamp in an - // append-only log; parse just that one. - if let Ok(line) = serde_json::from_str::(last_nonempty.trim()) - && let Some(ts) = line.parsed_timestamp() - && last_ts.is_none_or(|l| ts > l) + // Last resort for last_activity: the file's mtime — good + // enough for a recency listing when no line parsed. + if hunt.last_ts.is_none() + && let Ok(meta) = std::fs::metadata(path) + && let Ok(modified) = meta.modified() + { + hunt.last_ts = Some(chrono::DateTime::::from(modified)); + } + + let m = hunt.into_metadata(path, 0); + Ok(SessionPeek { + id: m.id, + file_path: m.file_path, + started_at: m.started_at, + last_activity: m.last_activity, + cwd: m.cwd, + cli_version: m.cli_version, + first_user_message: m.first_user_message, + git_branch: m.git_branch, + git_commit: m.git_commit, + }) + } + + pub fn session_exists(&self, session_id: &str) -> bool { + self.resolver.find_rollout_file(session_id).is_ok() + } +} + +/// [`SessionMetadata`] minus `line_count`, produced by +/// [`ConvoIO::peek_metadata`], which never reads enough of the file to +/// count lines. +#[derive(Debug, Clone)] +pub struct SessionPeek { + pub id: String, + pub file_path: PathBuf, + pub started_at: Option>, + pub last_activity: Option>, + pub cwd: Option, + pub cli_version: Option, + pub first_user_message: Option, + pub git_branch: Option, + pub git_commit: Option, +} + +/// Shared head-of-file metadata hunt used by both `read_metadata` +/// (streaming) and `peek_metadata` (chunked): session_meta, first/last +/// timestamps, and the first user prompt, all bounded by +/// `HEAD_PARSE_BUDGET` parsed lines. +#[derive(Default)] +struct HeadHunt { + parsed: usize, + first_line_meta_id: Option, + meta: Option>, + started_at: Option>, + last_ts: Option>, + first_user: Option, + first_user_fallback: Option, +} + +impl HeadHunt { + /// Parse at most this many non-empty head lines looking for + /// session_meta / timestamps / the first user prompt. Real + /// sessions surface the prompt within the first dozen lines + /// (after session_meta, turn_context, and injected context). + const HEAD_PARSE_BUDGET: usize = 100; + + fn ingest(&mut self, line_no: usize, trimmed: &str) { + use crate::types::{ResponseItem, RolloutLine}; + + let still_hunting = + self.meta.is_none() || self.started_at.is_none() || self.first_user.is_none(); + if !still_hunting || self.parsed >= Self::HEAD_PARSE_BUDGET { + return; + } + self.parsed += 1; + let Ok(line) = serde_json::from_str::(trimmed) else { + return; + }; + if let Some(ts) = line.parsed_timestamp() { + if self.started_at.is_none_or(|s| ts < s) { + self.started_at = Some(ts); + } + if self.last_ts.is_none_or(|l| ts > l) { + self.last_ts = Some(ts); + } + } + if line_no == 1 && line.kind == "session_meta" { + self.first_line_meta_id = line + .payload + .get("id") + .and_then(|v| v.as_str()) + .map(str::to_string); + } + if self.first_user.is_none() + && line.kind == "event_msg" + && line.payload.get("type").and_then(|v| v.as_str()) == Some("user_message") + && let Some(msg) = line.payload.get("message").and_then(|v| v.as_str()) + && !msg.is_empty() { - last_ts = Some(ts); + self.first_user = Some(msg.to_string()); + } + match line.item() { + RolloutItem::SessionMeta(m) if self.meta.is_none() => self.meta = Some(m), + RolloutItem::ResponseItem(ResponseItem::Message(m)) + if m.role == "user" && self.first_user_fallback.is_none() => + { + let t = m.text(); + if !t.is_empty() { + self.first_user_fallback = Some(t); + } + } + _ => {} } + } + + /// The tail line carries the newest timestamp in an append-only + /// log; parse just that one. + fn ingest_tail_line(&mut self, trimmed: &str) { + self.try_ingest_tail_line(trimmed); + } + /// Returns true when the line parsed as a rollout line (whether or + /// not it carried a newer timestamp). + fn try_ingest_tail_line(&mut self, trimmed: &str) -> bool { + use crate::types::RolloutLine; + let Ok(line) = serde_json::from_str::(trimmed) else { + return false; + }; + if let Some(ts) = line.parsed_timestamp() + && self.last_ts.is_none_or(|l| ts > l) + { + self.last_ts = Some(ts); + } + true + } + + fn into_metadata(self, path: &std::path::Path, line_count: usize) -> SessionMetadata { // Same id rule as RolloutReader::derive_session_id: the first // line's session_meta payload wins, else the filename stem. - let id = first_line_meta_id.unwrap_or_else(|| { + let id = self.first_line_meta_id.unwrap_or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .map(|stem| crate::paths::session_id_from_stem(stem).to_string()) .unwrap_or_else(|| "unknown".to_string()) }); - let (cwd, cli_version, git_branch, git_commit) = match &meta { + let (cwd, cli_version, git_branch, git_commit) = match &self.meta { Some(m) => ( Some(m.cwd.clone()), Some(m.cli_version.clone()), @@ -215,22 +357,18 @@ impl ConvoIO { None => (None, None, None, None), }; - Ok(SessionMetadata { + SessionMetadata { id, file_path: path.to_path_buf(), - started_at, - last_activity: last_ts, + started_at: self.started_at, + last_activity: self.last_ts, cwd, cli_version, - first_user_message: first_user.or(first_user_fallback), + first_user_message: self.first_user.or(self.first_user_fallback), git_branch, git_commit, line_count, - }) - } - - pub fn session_exists(&self, session_id: &str) -> bool { - self.resolver.find_rollout_file(session_id).is_ok() + } } } @@ -427,4 +565,93 @@ mod tests { "2026-04-22T10:05:00Z" ); } + + #[test] + fn peek_matches_read_metadata_on_small_file() { + let (_t, io) = setup(); + let files = io.list_rollout_files().unwrap(); + let full = io.read_metadata(&files[0]).unwrap(); + let peek = io.peek_metadata(&files[0]).unwrap(); + assert_eq!(peek.id, full.id); + assert_eq!(peek.started_at, full.started_at); + assert_eq!(peek.last_activity, full.last_activity); + assert_eq!(peek.cwd, full.cwd); + assert_eq!(peek.first_user_message, full.first_user_message); + assert_eq!(peek.git_branch, full.git_branch); + } + + /// A file much larger than the peek chunk: the head facts come + /// from the first chunk, last_activity from the tail chunk, and + /// the middle is never needed. + #[test] + fn peek_big_file_tail_supplies_last_activity() { + let (_t, io) = setup(); + let mut lines = vec![ + r#"{"timestamp":"2026-04-22T10:00:00.000Z","type":"session_meta","payload":{"id":"019dpeek-eee","cwd":"/tmp/p","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#.to_string(), + r#"{"timestamp":"2026-04-22T10:00:01.000Z","type":"event_msg","payload":{"type":"user_message","message":"peek prompt"}}"#.to_string(), + ]; + // ~600 KB of filler so the file dwarfs the 256 KiB chunk. + let filler = "x".repeat(2000); + for i in 0..300 { + lines.push(format!( + r#"{{"timestamp":"2026-04-22T10:10:00.000Z","type":"event_msg","payload":{{"type":"task_started","n":{i},"pad":"{filler}"}}}}"# + )); + } + lines.push( + r#"{"timestamp":"2026-04-22T12:00:00.000Z","type":"event_msg","payload":{"type":"task_complete"}}"#.to_string(), + ); + let path = write_rollout( + &io, + "rollout-2026-04-22T10-00-00-019dpeek-eee.jsonl", + &lines.join("\n"), + ); + assert!(std::fs::metadata(&path).unwrap().len() > 512 * 1024); + let m = io.peek_metadata(&path).unwrap(); + assert_eq!(m.id, "019dpeek-eee"); + assert_eq!(m.first_user_message.as_deref(), Some("peek prompt")); + assert_eq!( + m.last_activity + .unwrap() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "2026-04-22T12:00:00Z" + ); + assert_eq!( + m.started_at + .unwrap() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "2026-04-22T10:00:00Z" + ); + } + + /// A truncated final line (crash mid-write) must not lose the tail + /// timestamp: the reverse walk skips junk to the last parseable line. + #[test] + fn peek_tolerates_truncated_tail_line() { + let (_t, io) = setup(); + let filler = "y".repeat(2000); + let mut lines = vec![ + r#"{"timestamp":"2026-04-22T10:00:00.000Z","type":"session_meta","payload":{"id":"019dcut-fff","cwd":"/tmp/p","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#.to_string(), + ]; + for i in 0..300 { + lines.push(format!( + r#"{{"timestamp":"2026-04-22T11:00:00.000Z","type":"event_msg","payload":{{"type":"task_started","n":{i},"pad":"{filler}"}}}}"# + )); + } + lines.push( + r#"{"timestamp":"2026-04-22T11:30:00.000Z","type":"event_msg","payload":{"type":"task_complete"}}"#.to_string(), + ); + lines.push(r#"{"timestamp":"2026-04-22T11:59:59.000Z","type":"event_"#.to_string()); // truncated + let path = write_rollout( + &io, + "rollout-2026-04-22T10-00-00-019dcut-fff.jsonl", + &lines.join("\n"), + ); + let m = io.peek_metadata(&path).unwrap(); + assert_eq!( + m.last_activity + .unwrap() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "2026-04-22T11:30:00Z" + ); + } } diff --git a/docs/superpowers/plans/2026-08-03-bare-resume-session-picker.md b/docs/superpowers/plans/2026-08-03-bare-resume-session-picker.md new file mode 100644 index 00000000..8e58f1c0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-bare-resume-session-picker.md @@ -0,0 +1,121 @@ +# Bare `path resume` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `path resume` with no argument opens a cross-harness session picker (reusing `path share`'s aggregation), derives the picked session (write-through cache, mirroring share), then flows into the existing harness-picker → project → exec pipeline. + +**Spec:** `docs/superpowers/specs/2026-08-03-bare-resume-session-picker-design.md` + +--- + +## File map + +- **Modify** `crates/path-cli/src/cmd_share.rs` — lift `format_picker_row` + `derive_session` to `pub(crate)`; `bail_no_sessions` gains an adjective param. +- **Modify** `crates/path-cli/src/cmd_resume.rs` — `input: Option`, `--from`/`--project` flags, `Default`, `SessionPicker` seam, `run_bare`, unit tests. +- **Modify** `crates/path-cli/src/lib.rs` — `Commands::Resume` doc comment mentions bare mode. +- **Modify** `crates/path-cli/tests/support/mod.rs` — `args_explicit` via `..Default::default()`; claude/codex fixture writers; `ScopedHome` pins `XDG_DATA_HOME`. +- **Modify** `crates/path-cli/tests/resume.rs` — bare-mode integration tests. +- **Modify** `CLAUDE.md`, `CHANGELOG.md`, `Cargo.toml`, `crates/path-cli/Cargo.toml`, `site/_data/crates.json` — docs + 0.17.0 bump. + +--- + +## Task 1: Lift share helpers + +- [ ] `format_picker_row` → `pub(crate)` +- [ ] `derive_session` → `pub(crate)` +- [ ] `bail_no_sessions(bundle, project_filter, adjective)` — share passes "shareable" +- [ ] No behavior changes; `cargo test -p path-cli` green + +## Task 2: ResumeArgs surface + Default + +- [ ] `input: Option` with bare-mode doc sentence +- [ ] `--from ` + `--project ` mid-struct after `harness`, both `conflicts_with = "input"` +- [ ] `--url` / `--force` / `--no-cache` doc comments note bare-mode semantics +- [ ] `#[derive(Default)]`; convert struct-literal constructors (cmd_resume unit tests, tests/support `args_explicit`, tests/resume.rs) to `..Default::default()` +- [ ] `resolve_input` uses `args.input.as_deref().expect("resolve_input requires input — bare mode is handled by run_bare")` + +## Task 3: Picker seam + run_bare + +- [ ] `PickChoice` + `SessionPicker` + `FuzzySessionPicker` + `index_of_selected` + `FixedPicker` +- [ ] `run_with_strategy` early branch to `run_bare(&args, exec, &FuzzySessionPicker)` +- [ ] `run_bare`: availability guard (exact no-TTY string) → target pre-validation → cwd → gather → empty check ("resumable") → pick → load-or-derive (fresh_cache_id fast path; write-through + record_artifact) → ensure_path_with_agent → source → pick_harness → project → exec +- [ ] Unit tests: bare_args_input_is_optional, from_conflicts_with_input, project_conflicts_with_input, index_of_selected_maps_line_back_to_row, index_of_selected_unknown_line_errors, fixed_picker_records_offered_lines, run_bare_offline_picker_errors_with_no_tty_text, run_bare_unavailable_target_harness_errors_before_picking + +## Task 4: Integration tests + +- [ ] Fixture writers `write_claude_session` / `write_codex_session` in tests/support (modeled on cmd_share unit-test fixtures); `ScopedHome` pins `XDG_DATA_HOME` +- [ ] bare_resume_picks_session_derives_projects_and_execs +- [ ] bare_resume_no_sessions_bails_with_status_table +- [ ] bare_resume_from_filters_the_picker +- [ ] bare_resume_cwd_flag_ranks_matching_sessions_first +- [ ] bare_resume_writes_cache_and_manifest_by_default +- [ ] bare_resume_no_cache_skips_cache_write +- [ ] bare_resume_fresh_cache_fast_path_uses_cached_doc +- [ ] bare_resume_no_match_returns_ok_without_exec + +## Task 5: Docs + version bump + +- [ ] CLAUDE.md: bare-resume example line in CLI usage; extend the `path resume` Things-to-know bullet (bare mode, --from, --project, cache policy, no-TTY error) +- [ ] lib.rs `Commands::Resume` doc comment mentions bare mode +- [ ] path-cli 0.16.1 → 0.17.0 in crates/path-cli/Cargo.toml, root Cargo.toml, site/_data/crates.json, CHANGELOG.md (H2 at top) +- [ ] `scripts/quality_gates.sh -site` green + +## Self-Review Notes + +Deviations from the spec found while implementing: + +- The cache fast path's stderr line adapts share's verb: share prints + "…; uploading without re-deriving", resume prints "…; resuming + without re-deriving". Same shape, resume-appropriate wording. +- Two share-mirroring stderr confirmations were added that the spec's + implementation shape didn't list: `Picked session ""` + after the session pick (the flow continues into a second picker, so + the user should see what they committed to) and share's + `Cached <harness> session → <id> (<path>)` line after a write-through. +- Target pre-validation is implemented as a literal + `pick_harness(Some(h), None, None)` call rather than duplicating the + error text — same message verbatim, and the later post-derive call + re-validates for free. +- `bare_resume_fresh_cache_fast_path_uses_cached_doc` passes + `--project /test/project` on both runs: run 1's projection writes a + *new* claude session under the scoped `$HOME`, which would outrank + the fixture by recency in run 2's picker; the project filter pins the + picker to the fixture row (and exercises `--project` for free). +- `bare_resume_cwd_flag_ranks_matching_sessions_first` records the + *canonicalized* tempdir path in the codex fixture (macOS tempdirs + canonicalize `/var/…` → `/private/var/…`), and writes the matching + fixture first (older mtime) so the ranking win is attributable to the + cwd match, not recency. +- `tests/support/mod.rs::ScopedHome` now also pins `$XDG_DATA_HOME` + (PR #142's fix has not landed) so the opencode collector cannot leak + the developer's real database into gather results. +- Version-slot collision: 0.17.0 is also claimed by in-flight PRs #138 + and #145; whichever lands second rebases its CHANGELOG H2 and takes + the next slot. + +### Review fixes + +Adversarial review found five issues; fixed in one follow-up commit: + +- **Vacuous ranking test**: `write_codex_session` hardcoded identical + embedded timestamps into both fixtures, so + `bare_resume_cwd_flag_ranks_matching_sessions_first` tied on + `last_activity` and passed by enumeration order. Added + `write_codex_session_at` (minute-precision stamp param) and gave the + NON-matching fixture a strictly newer timestamp; verified by + temporarily sabotaging `matches_cwd: false` in the codex collector + (test failed) and reverting (test passed). +- **COPILOT_HOME leak**: `ScopedHome` now pins `COPILOT_HOME` under the + tempdir (and restores it in Drop) — toolpath-copilot honors it as a + full root override, so a developer's real sessions could leak into + bare-mode gather results. +- **Misleading empty result with --from**: `run_bare` with a `--from` + filter and zero rows now bails with + `no <name> sessions found; drop --from to see sessions from other + harnesses` instead of the generic all-harness status table; pinned by + `bare_resume_from_with_no_matching_sessions_mentions_filter`. +- **Raw title in confirmation**: the `Picked … session` line now runs + the title through `fuzzy::clean_for_picker_display` (already + pub(crate)), matching what the picker rows show. +- **toolpath-codex fmt hunks**: kept in the branch's style commit as-is + (standalone landing impractical). diff --git a/docs/superpowers/specs/2026-08-03-bare-resume-session-picker-design.md b/docs/superpowers/specs/2026-08-03-bare-resume-session-picker-design.md new file mode 100644 index 00000000..862099a9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-bare-resume-session-picker-design.md @@ -0,0 +1,127 @@ +# Bare `path resume` — cross-harness session picker + +**Status:** approved design, implementing +**Date:** 2026-08-03 +**Issue:** #110 + +## Intent + +`path resume` today requires an `<input>` (Pathbase URL, shorthand, +file, or cache id). But the most common resume wish is local: "pick up +that session I ran earlier — maybe in a different harness". Bare +`path resume` (no argument) opens a cross-harness session picker +(reusing `path share`'s aggregation), derives the picked session, and +flows into the existing harness-picker → project → exec pipeline. + +No new TUI is introduced — the existing fzf/skim pickers are reused. +A native ratatui picker is a sibling PR behind the `fuzzy::pick` seam; +this change does not touch `fuzzy.rs`. + +## Surface + +``` +path resume # bare: pick any session from any harness +path resume --from codex # bare: only codex sessions in the picker +path resume --project /p # bare: only sessions tied to /p +path resume --harness claude # bare: resume target pinned; harness picker skipped +path resume -C /work/proj # ranking anchor AND projection/exec cwd +path resume <input> [...] # explicit mode, unchanged +``` + +- `ResumeArgs.input` becomes `Option<String>`; omitting it enters bare + mode. +- `--from <harness>`: bare mode only — narrows the *session picker* to + one harness. The resume *target* is still `--harness` / the harness + picker. `conflicts_with = "input"`. +- `--project <path>`: bare mode only — narrows the session picker to + sessions tied to that project directory (mirrors + `path share --project`). `conflicts_with = "input"`. +- `--url` is inert in bare mode (bare mode never fetches from + Pathbase); documented in its help text. +- `--force` in bare mode: skip the cache freshness probe, always + re-derive the picked session. + +## Behavior matrix + +| Invocation | Behavior | +| --- | --- | +| bare + TTY | session picker → doc → harness picker → project → exec | +| bare, no TTY | bail exactly: `no input provided and no TTY for interactive selection; pass an <input> (URL, file, or cache id), or rerun in a terminal` — checked BEFORE gathering | +| bare `--harness X` | X validated on PATH BEFORE the session picker (pick_harness's error text); unfiltered session picker; harness picker skipped | +| bare `--from Y` | `harness_filter = Some(Y.artifact_type())` into `gather_artifacts` | +| bare `-C P` | canonicalized P is BOTH the ranking anchor passed to `gather_artifacts` AND the projection/exec cwd | +| 0 sessions gathered | `bail_no_sessions` with "resumable" wording (per-harness status table) | +| picker Cancelled | `std::process::exit(130)` | +| picker NoMatch | quiet `Ok(())` | + +## Cache policy (locked decision — deviates from issue #110's sketch) + +Issue #110 sketched an ephemeral derive. Locked decision instead: +**write-through by default, mirroring `share`.** + +- Read fast-path: if `!no_cache && !force`, + `sync::fresh_cache_id(bundle, row.artifact_type, project, &row.session_id)` + → load the cached doc and print share's "Cache is current …" line + (with a resume-appropriate verb). +- Otherwise `cmd_share::derive_session`, then unless `no_cache`: + `cache::write_cached(&derived.cache_id, &derived.doc, true)` + + `sync::record_artifact` (non-fatal warn on failure, like share). +- The `project` argument for `fresh_cache_id`/`derive_session` is + `row.path.as_deref()` — `Some` for path-keyed providers + (claude/gemini/pi), `None` for cwd-keyed ones — mirroring exactly how + `share_explicit` passes it. + +## Decisions Locked In + +| Decision | Choice | +| --- | --- | +| Picker UI | Existing fzf/skim via `fuzzy::pick`; no new TUI (sibling PR) | +| Session aggregation | Reuse `cmd_share::gather_artifacts` (all 7 providers, cwd-ranked) | +| Picker rows | Reuse `cmd_share::format_picker_row` (5-col TSV), share-identical `PickOptions` with prompt `resume> ` | +| Cache policy | Write-through by default, mirroring share (deviates from issue #110's ephemeral sketch) | +| No-TTY error | Exact string, checked before gathering | +| Target validation | `--harness X` validated on PATH before the session picker fires | +| New flags placement | Mid-struct, immediately after `harness` (keeps hunks disjoint from in-flight PR #145, which appends at the END) | +| Picker seam | `SessionPicker` trait + `PickChoice` enum + `FixedPicker` test double, mirroring `ExecStrategy`/`RecordingExec` | +| `resolve_input` | `args.input.as_deref().expect(…)` — unreachable in bare mode by the `run_with_strategy` guard | +| `project_into_harness` | Reused as-is; never call `cmd_export::project_claude` directly (in-flight #150 changes its return type) | +| `ResumeArgs` construction | Gains `Default` so callers use `..Default::default()` — a deliberate rebase gift to #145 | +| Version | path-cli 0.16.1 → 0.17.0 (0.17.0 is also claimed by in-flight #138/#145 — collision recorded) | + +## Implementation shape + +1. `run_with_strategy` gains a compact early branch: + `if args.input.is_none() { return run_bare(&args, exec, &FuzzySessionPicker); }` + (#145 will insert its `--remote` early-return above it later). +2. `pub fn run_bare(args, exec, picker)`: availability guard → target + pre-validation → cwd resolve → `HarnessBundle::from_environment()` → + `gather_artifacts` → empty check → format rows → `picker.pick` → + `PickChoice::Index(i)` → load-or-derive → `ensure_path_with_agent` → + `source = row.artifact_type.harness().or_else(|| infer_source_harness(path))` + → `pick_harness(args.harness, source, None)` → + `project_into_harness` → `invocation_for` → `exec_harness`. +3. Picker seam: `pub enum PickChoice { Index(usize), Cancelled, NoMatch }`; + `pub trait SessionPicker { fn available(&self) -> bool { true } fn pick(&self, lines: &[String], header: &str) -> Result<PickChoice>; }`. + `FuzzySessionPicker` (prod) maps `Selected` → index via a pure + `index_of_selected(lines, selected_line)` (line equality is safe: + cols 1–3 are unique per row). `FixedPicker` test double records the + offered lines. +4. `cmd_share.rs` lifts ONLY `format_picker_row` + `derive_session` to + `pub(crate)`; `bail_no_sessions` gains an adjective parameter + (share: "shareable", resume: "resumable"). No behavior changes. + +## Addendum (2026-08-04): recency-first hydration + +Dogfooding on a 3.9 GB codex tree showed the full cross-harness sweep +costs seconds per launch. The bare picker now hydrates only the newest +`RECENT_LIMIT` (100) sessions: codex rollouts are ranked stat-only by +file mtime and the top slice gets an O(1) head+tail `peek_metadata` +(no `message_count`); the other providers' listings are cheap and run +in full, in parallel. The merged view is ranked exactly like the full +sweep and truncated to the limit, with a tail row ("N older sessions — +load everything") that runs `gather_artifacts` on demand. `--project` +always uses the full sweep (its matches may be arbitrarily old). An +earlier scoped-to-project-first design was built and reverted: it hid +other projects' sessions behind an extra step, which is not how the +picker should rank — recency plus cwd-first sorting already puts the +right rows on top. diff --git a/site/_data/crates.json b/site/_data/crates.json index 05d00d3e..3598d7ce 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -49,7 +49,7 @@ }, { "name": "toolpath-codex", - "version": "0.6.2", + "version": "0.6.3", "description": "Derive from Codex CLI rollout files", "docs": "https://docs.rs/toolpath-codex", "crate": "https://crates.io/crates/toolpath-codex", @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.16.3", + "version": "0.17.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli",