cdx-manager 0.26.1 → 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
@@ -65,6 +65,15 @@ row (`null` when absent). `cdx show` is an alias for `cdx config` and displays
65
65
 
66
66
  ## Cache efficiency and fix rounds
67
67
 
68
+
69
+ `cdx history --interactive --cwd /path/to/project --limit 50 --json` finds interactive launches without mission runs consuming the result window. `--cwd` compares resolved paths (including symlinks and `~`), exactly, without including child directories. Both filters apply before `--limit` and also work with profile names, period filters and `--summary`. `--interactive` excludes headless `run` entries, detached entries and labels starting with `mission:`; unlabelled legacy launches remain eligible.
70
+
71
+ Interactive launches appear as `status: "running"` as soon as the provider starts, including `cdx start <name> --dir PATH` (an alias for `cdx <name> --dir PATH`) inside tmux. Completion or failure updates the same run ID. Active runtimes from older supervisors are included while their process remains alive; vanished unrecorded launches cannot be reconstructed.
72
+
73
+ History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
74
+
75
+ `first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
76
+
68
77
  `cdx history --json` records uncached input, cache reads, cache writes, output tokens, and `usage.cache_hit_ratio` beside the existing token totals. The ratio is cache reads divided by all input tokens, excluding output. Missing provider splits stay `null`, and text views show `cache n/d` instead of claiming a zero hit rate.
69
78
 
70
79
  `cdx stats --json` adds `cache_usage` sums and an input-weighted `cache_hit_ratio` to each session, project (`--by project`), model (`--by model`), and total. `cache_usage` preserves unknown sums as `null`; existing numeric aggregate fields keep their previous behavior. Ratios include only runs with measured input and cache reads. Coverage is explicit in `measured_runs` and `measured_input_tokens`. `run_types.cold` and `run_types.continued` expose counts, sums, and ratios for comparison. Text views show values such as `cache 72 %` next to tokens.
@@ -246,41 +255,94 @@ for a fresh credential, and that cleanup works after disconnect and SIGTERM.
246
255
 
247
256
  ### Read an interactive conversation
248
257
 
249
- `cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json`
258
+ `cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json`
250
259
  returns a read-only JSON page for the profile's current conversation. An explicit
251
260
  conversation ID uses an exact transcript lookup and never falls back. When the
252
261
  profile's recorded ID has no transcript on this host (for example after a remote
253
- run on another host), or there is no recorded ID, the command uses the existing
254
- latest local transcript resolver under the profile's auth home. This fallback is
255
- best effort and does not rewrite the recorded ID. JSON `resolution_method` is
262
+ run on another host), or there is no recorded ID, the command selects the latest
263
+ local transcript for `--cwd PATH`, defaulting to the process working directory.
264
+ Claude-compatible providers use `.claude/projects/<path-slug>`; Codex requires
265
+ a matching `cwd` in the rollout's initial `session_meta` record. Missing or
266
+ unreadable Codex metadata is excluded. `--since ISO` further restricts fallback
267
+ to transcripts modified at or after the session start timestamp (for example
268
+ `--since 2026-11-04T00:39:00Z`). Callers should pass a timestamp with a timezone.
269
+ Without a candidate, JSON reports `status: "unavailable"` and
270
+ `reason: "no_conversation_yet"`, including while a new session awaits folder trust.
271
+ This fallback is best effort and does not rewrite the recorded ID. Exact ID
272
+ lookups remain independent of these fallback constraints. JSON `resolution_method` is
256
273
  `conversation_id` for an exact match, `recency` for a local fallback, or `null`
257
- when no transcript is resolved.
274
+ when no transcript is resolved. JSON `resolution_scope` records the canonical
275
+ absolute `cwd` and supplied `since` timestamp for fallback attempts, including
276
+ unavailable results; `since` is `null` when omitted. Exact lookups and unsupported
277
+ providers report a `null` scope. A missing explicit ID still reports
278
+ `transcript_not_found`.
258
279
 
259
280
  The normal cdx JSON envelope includes `status`, `reason`, `provider`, `session`,
260
281
  `entries`, `next_cursor`, and `has_more`. Each entry has exactly `role`
261
282
  (`user`, `assistant`, or `tool`), `text`, `tool_name`, `tool_summary`, `at`, and
262
- `id`. Missing values are `null`; tool text is empty. Entries follow file and
283
+ `id`. Missing values are `null`; normal tool text is empty. Entries follow file and
263
284
  content-block order, with stable IDs for the same file position. Timestamps are
264
285
  provider timestamps when valid. Tool calls/results have a name and a short
265
286
  summary; arguments, results, attachments, and reasoning blocks are omitted.
266
- Result names are recovered from up to 100 recent calls, otherwise `tool`.
287
+ Result names are recovered from recent calls, otherwise `tool`. The cache holds
288
+ up to 100 names and is trimmed oldest first to 2 KiB of compact JSON when saved
289
+ in a cursor. Existing version-1 cursors with larger caches remain readable.
267
290
  Common credential patterns and secret assignments in text are redacted without
268
291
  opening credential stores. Review conversation text before sharing: arbitrary
269
292
  unlabelled sensitive prose cannot be identified automatically.
270
293
 
271
294
  Pass `next_cursor` unchanged as `--cursor` to fetch the next page or poll for new
272
295
  entries. `--limit` bounds entries (default 100, range 1–1000). Reading seeks to
273
- the saved byte position rather than replaying history, and scans at most 8 MiB
274
- per call. A page may be empty while still returning a usable cursor. Poll on a
296
+ the saved byte position rather than replaying history, and stops scanning after
297
+ 8 MiB per call, finishing the current record when it crosses that budget.
298
+ A page may be empty while still returning a usable cursor. Poll on a
275
299
  timer: `has_more` means unread bytes, which can include an incomplete final line
276
300
  that will only become readable after the provider appends its newline.
277
301
 
302
+ Start near the present with `cdx conversation work --tail 100 --json`.
303
+ `--tail N` returns the last N normalized entries (1–1000) in chronological order.
304
+ It reads backwards in 64 KiB blocks, retaining at most a 1 MiB record prefix;
305
+ it does not replay the transcript from the beginning. Records that produce
306
+ multiple entries can split across pages. The same redaction, tool summaries,
307
+ oversized-record placeholders and file-position IDs apply. Recent tool names are
308
+ recovered from a 64 KiB lookback (extended to 8 MiB when resolving a result in the selected window), with the usual `tool`
309
+ fallback. Finding N entries can require scanning more bytes when records
310
+ are oversized or contain no visible entries. Incomplete trailing records are
311
+ excluded until their newline arrives.
312
+
313
+ Tail and backward pages add `before_cursor`: pass it unchanged as
314
+ `--before CURSOR --limit N` to load the preceding entries, also in chronological
315
+ order. `before_cursor: null` means the beginning has been reached. You can also
316
+ use `--before CURSOR --tail N`; `--tail` sets the count when both count options
317
+ are present. `next_cursor` still works with `--cursor` to follow entries after
318
+ the returned page, including messages appended after an initial tail read.
319
+ `has_more` continues to describe unread bytes **after** that page; use
320
+ `before_cursor` to decide whether older pages remain. `--cursor` cannot be
321
+ combined with `--tail` or `--before`. Without either new option, the existing
322
+ forward page contract is unchanged.
323
+
324
+ Backward cursors retain the original file size, modification time and boundary
325
+ anchors. Replacement, truncation or a detected in-place rewrite returns
326
+ `reset_required`; appending new messages preserves backward pagination. As with
327
+ forward cursors, transcripts must be append-only: bounded boundary checks cannot
328
+ detect every arbitrary edit elsewhere in a file that also grows.
329
+
278
330
  Codex response items and Claude-compatible JSONL transcripts (Claude and
279
- Antigravity) are supported through the existing transcript resolver. Missing or
331
+ Antigravity) are supported through the existing transcript resolver. Only
332
+ `response_item` records from Codex and `user`/`assistant` records with `message`
333
+ from Claude-compatible transcripts are read, including their tool blocks. All
334
+ other record types are ignored, even when they repeat conversation content. Missing or
280
335
  unreadable files return `status: "unavailable"` with a reason. Ollama's terminal
281
336
  capture does not reliably identify message roles and returns
282
337
  `status: "unsupported"`, including when no capture exists. A transcript record
283
- larger than 1 MiB returns `unavailable`. A replaced/truncated file or invalid
338
+ larger than 1 MiB is skipped with bounded reads through its newline. For an allowed conversation record with readable type and message metadata, it produces
339
+ one entry containing `[élément trop volumineux ignoré]`, with a stable ID and a
340
+ role recovered from structural metadata in the readable prefix, or `tool` when
341
+ unknown. Records whose type or message cannot be established from the bounded
342
+ prefix are ignored. Its body, images and attachments are never returned. The cursor advances
343
+ past that record so later messages remain accessible. An oversized final record
344
+ without a newline produces no entry yet and is retried on the next call.
345
+ A replaced/truncated file or invalid
284
346
  cursor returns `reset_required`; discard the cursor and reload the conversation.
285
347
  Cursors are opaque, versioned, and bound to a provider and file. Treat transcripts
286
348
  as append-only; reset the client if a provider rewrites earlier history.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.1
1
+ 0.26.3
@@ -0,0 +1,20 @@
1
+ # CDX Manager 0.26.2
2
+
3
+ ## Fixes
4
+
5
+ - Scope conversation recency fallback to `--cwd PATH` (the process working
6
+ directory by default), using Claude project folders or Codex `session_meta.cwd`.
7
+ Support `--since ISO` to exclude transcripts modified before session start.
8
+ Return `unavailable` / `no_conversation_yet` when no candidate matches instead
9
+ of showing another project. Add `resolution_scope` without changing
10
+ `resolution_method` or exact conversation ID lookup semantics.
11
+
12
+ - Read only Claude-compatible `user` and `assistant` records with `message`,
13
+ preserving their tool calls and results. Ignore all other record types,
14
+ including Claude Code request blobs that repeat user prompts without dates.
15
+ - Apply the same allowlist to oversized records; retain only `response_item`
16
+ records for Codex so event copies cannot appear as conversation entries.
17
+ Ignore oversized records when the bounded prefix cannot establish their type
18
+ and message metadata.
19
+ - Cover the reported Claude Code 2.1.317 request-blob shape, dated messages,
20
+ tools, cursor pagination and ignored metadata for all structured providers.
@@ -0,0 +1,22 @@
1
+ # CDX Manager 0.26.3
2
+
3
+ Release date: 2026-11-04
4
+
5
+ ## Improvements
6
+
7
+ - Make interactive session history findable and resumable (#110,
8
+ `5abd37830095ee3519bd130ac6d80da143b29e27`). Filter `cdx history` by resolved
9
+ working directory (`--cwd PATH`) and interactive launches (`--interactive`)
10
+ before applying the result limit. Record launches immediately and update the
11
+ same history row on exit so active sessions remain visible without duplicates.
12
+ - Support `cdx start <name>` through the named launch path, including tmux.
13
+ Prefer exact Claude session IDs and unique project/time-scoped Codex transcript
14
+ metadata; mark ambiguous recency matches as unverified. Recover clean, redacted
15
+ first-user prompts past provider wrappers, reminders, tools and bare file paths.
16
+
17
+ ## Maintenance
18
+
19
+ - Record tray companion checksums for v0.26.2
20
+ (`54f201a2886f5293658f214e5725d60426dcfb28`).
21
+ - Record release archive checksums for v0.26.2
22
+ (`3bc40eaada4b401e222bf599335b8b3d4dc80326`).
@@ -1,5 +1,24 @@
1
1
  # Unreleased
2
2
 
3
+ ## Find and resume interactive sessions
4
+
5
+ - Add exact resolved `cdx history --cwd PATH` and `--interactive` filters before the result limit, preserving the JSON envelope and period/summary support.
6
+ - Record interactive launches immediately, update the same row on exit, expose active older runtimes and support `cdx start <name>` through the named launch path, including tmux.
7
+ - Prefer exact Claude session IDs and unique project/time-scoped Codex transcript metadata; expose ambiguous recency as unverified. Recover clean, redacted `first_user_prompt` text past provider wrappers, reminders, tools and bare file paths.
8
+ - Cover filter ordering, live history, completion/failure deduplication, legacy launch arguments, transcript identity ambiguity, missing evidence and prompt cleanup.
9
+
10
+ ## Conversation tail pagination (planned for 0.26.2)
11
+
12
+ - Add `cdx conversation --tail N` (1–1000), chronological backward pages via `before_cursor` and `--before`, and a compatible `next_cursor` for live polling. Read JSONL backwards in bounded 64 KiB blocks, preserve normalization, redaction, stable IDs and oversized-record skipping, and reject stale backward cursors.
13
+ - Measure tail I/O on 6,000-entry Codex and Claude-compatible transcripts and cover backward traversal, content-block splits, append polling and incomplete/oversized records.
14
+
15
+ ## Read past oversized conversation records
16
+
17
+ - Skip JSONL records larger than 1 MiB using bounded reads instead of returning `record_too_large`. Emit a short placeholder with a stable ID and the role from readable structural metadata, falling back to `tool`, then advance the cursor so subsequent messages remain accessible. Never return image or attachment bodies.
18
+ - Retry oversized trailing records without a newline on the next poll. The 8 MiB page scan budget finishes the current record before returning.
19
+ - Bound the cursor's recent tool-name cache to 2 KiB of compact JSON, evicting oldest entries while keeping version-1 cursors with up to 100 names readable.
20
+ - Cover 20 MiB records in the middle, cursors immediately before them, incomplete oversized tails, bounded reads, stable IDs, conservative role detection and legacy cursor compatibility.
21
+
3
22
  ## Preserve interactive conversations across headless runs
4
23
 
5
24
  - Keep fresh Claude run identities in run records and history without changing the profile's interactive conversation. Skip interactive Codex identity observation when recording headless run history, including detached, remote and continued runs.
@@ -804,6 +804,28 @@
804
804
  "x86_64-pc-windows-msvc": "24ba2a8ed13de1bdc0401661a51c105c2c7ed186f3b17a871cd721ac8adf4916",
805
805
  "x86_64-unknown-linux-musl": "200c8ab9465625784a32ae326d923bca97b5f9616809c43cb5dc73e0e5d9ca17"
806
806
  }
807
+ },
808
+ "v0.26.1": {
809
+ "github_tarball_sha256": "b1ccc629c8697354dc5c8e1b892a94a9668061ef6667711af2bd62816e3bdff7",
810
+ "github_zip_sha256": "5ba11ac492690967f7c076b32c6e3e13dd183b7c097c706ff16e3df61364bee9",
811
+ "tray_assets": {
812
+ "aarch64-apple-darwin": "d570430988a271c87403e176f5689115ca6c214990a13831c7d1ed69b7943353",
813
+ "aarch64-unknown-linux-musl": "1b8acda432cff9d58fc3aaaac5d03f684ae4887f4976821ccecc383c79d6bc98",
814
+ "x86_64-apple-darwin": "b51111f3c6f747671f9416f8f7ed544a5a98318121694c33440b6b54782c1080",
815
+ "x86_64-pc-windows-msvc": "07cf6f66d58320494833107cf369b66cdf171649c8128d6463028a9aa7058494",
816
+ "x86_64-unknown-linux-musl": "e18869d50747b1eb7924005554e11154f04a27363b8b3148f7ae0a1042d6ca7a"
817
+ }
818
+ },
819
+ "v0.26.2": {
820
+ "github_tarball_sha256": "c85ae3c9158a933eb151b6f74563da6fafb8160e01091e925224678bae7bc394",
821
+ "github_zip_sha256": "d6b70ab53b617fb364e6704684421422cbc13cabd5ba15706fac8f543eea2721",
822
+ "tray_assets": {
823
+ "aarch64-apple-darwin": "8d1089505821ee59b9b0f49ef2fdc63b081bafe468add02b83e84d9939cd2be0",
824
+ "aarch64-unknown-linux-musl": "816a48a84d99e84403a11eea76214c8a56301919b13556fd1ffe7e1459092fd1",
825
+ "x86_64-apple-darwin": "38d028177b64f616e72a81d52a451ba174f434757e2e503ba196e653b08ae6c3",
826
+ "x86_64-pc-windows-msvc": "7846a4b56ec3006428b9778cbee3e0c1fabea8f279ca9183cf34abb460bb4c29",
827
+ "x86_64-unknown-linux-musl": "d761aa0bbda8661c842f0b18e438c047ec023d30ed733d0044d1663ddd480355"
828
+ }
807
829
  }
808
830
  }
809
831
  }
package/docs/cli.md CHANGED
@@ -28,7 +28,7 @@
28
28
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
29
29
  | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--notify on\|off] [--agent-state on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
30
30
  | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--agent-state\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
31
- | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
31
+ | `cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
32
32
  | `cdx history backfill-projects [--mapping FILE] [--dry-run \| --apply] [--json]` | Recover source repository attribution from exact recorded run evidence or an operator mapping; defaults to a read-only preview |
33
33
  | `cdx history backfill-models [--dry-run \| --apply] [--json]` | Recover missing historical usage models from evidence tied to each run; defaults to a read-only preview |
34
34
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
@@ -673,3 +673,13 @@ shows the before/after versions, including major upgrades. JSON keeps the
673
673
  pre-update plan and existing result fields; tool verification adds
674
674
  `version_before`, `version_after`, and `latest_version` to successful-command
675
675
  results, plus `reason` and a nonzero `returncode` if verification fails.
676
+
677
+ ## Find interactive conversations
678
+
679
+ `cdx history --interactive --cwd /path/to/project --limit 50 --json` finds interactive launches without mission runs consuming the result window. `--cwd` compares resolved paths (including symlinks and `~`), exactly, without including child directories. Both filters apply before `--limit` and also work with profile names, period filters and `--summary`. `--interactive` excludes headless `run` entries, detached entries and labels starting with `mission:`; unlabelled legacy launches remain eligible.
680
+
681
+ Interactive launches appear as `status: "running"` as soon as the provider starts, including `cdx start <name> --dir PATH` (an alias for `cdx <name> --dir PATH`) inside tmux. Completion or failure updates the same run ID. Active runtimes from older supervisors are included while their process remains alive; vanished unrecorded launches cannot be reconstructed.
682
+
683
+ History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
684
+
685
+ `first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
@@ -172,6 +172,7 @@ Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including su
172
172
  cdx history
173
173
  cdx history work
174
174
  cdx history work --limit 5
175
+ cdx history --interactive --cwd ~/projects/example --limit 50 --json
175
176
  cdx history work --json
176
177
  cdx history --summary
177
178
  cdx history --summary --since 7d
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.26.1"
7
+ version = "0.26.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
package/src/cli.py CHANGED
@@ -267,12 +267,13 @@ def _print_help(use_color=False):
267
267
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
268
268
  f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
269
269
  f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--max-parallel|--all) [--json]', '36', use_color)}",
270
+ f" {_style('cdx start <name> [--dir PATH] [--resume] [--json]', '36', use_color)}",
270
271
  f" {_style('cdx history backfill-projects [--mapping FILE] [--dry-run|--apply] [--json]', '36', use_color)}",
271
- f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
272
+ f" {_style('cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
272
273
  f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
273
274
  f" {_style('cdx last [--json]', '36', use_color)}",
274
275
  f" {_style('cdx resume <name> [--project PATH] [--conversation <id>] [--json]', '36', use_color)}",
275
- f" {_style('cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json', '36', use_color)}",
276
+ f" {_style('cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
276
277
  f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
277
278
  f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
278
279
  f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",
@@ -651,6 +652,10 @@ def main(argv, options=None):
651
652
  if handler:
652
653
  return handler(rest, ctx)
653
654
 
655
+ if command == "start":
656
+ if not rest or rest[0].startswith("-"):
657
+ raise CdxError("Usage: cdx start <name> [--dir PATH] [--resume] [--json]")
658
+ command, *rest = rest
654
659
  from .commands.launch import _parse_launch_args, _pop_value_flag, _read_launch_prompt
655
660
  project_path, rest = _pop_value_flag(rest, "--project", "Usage: cdx <name> [--project PATH]")
656
661
  directory, launch_args, prompt, on_busy = _parse_launch_args(rest)
package/src/cli_args.py CHANGED
@@ -40,7 +40,7 @@ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (-
40
40
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
41
41
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
42
42
  CONFIGS_USAGE = "Usage: cdx configs [--json]"
43
- HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
43
+ HISTORY_USAGE = "Usage: cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
44
44
  STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]"
45
45
  STATS_GROUPS = ("session", "project", "model")
46
46
  LAST_USAGE = "Usage: cdx last [--json]"
@@ -916,6 +916,8 @@ def _parse_history_args(args, now=None):
916
916
  now = now or datetime.now().astimezone()
917
917
  parsed = _parse_flag_args(args, {
918
918
  "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
919
+ "--cwd": {"key": "cwd", "type": "str", "default": None},
920
+ "--interactive": {"key": "interactive", "type": "bool", "default": False},
919
921
  "--summary": {"key": "summary", "type": "bool", "default": False},
920
922
  "--since": {"key": "since", "type": "str", "default": None},
921
923
  "--from": {"key": "from", "type": "str", "default": None},
@@ -926,6 +928,8 @@ def _parse_history_args(args, now=None):
926
928
  return {
927
929
  "name": parsed["names"][0] if parsed["names"] else None,
928
930
  "limit": parsed["limit"],
931
+ "cwd": parsed["cwd"],
932
+ "interactive": parsed["interactive"],
929
933
  "summary": parsed["summary"],
930
934
  "period": period,
931
935
  "json": parsed["json"],
@@ -6,13 +6,17 @@ from ..conversation import read_conversation
6
6
  from ..errors import CdxError
7
7
  from ..provider_runtime import _get_auth_home
8
8
 
9
- CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json"
9
+ CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json"
10
10
 
11
11
 
12
12
  def handle_conversation(rest, ctx):
13
13
  parsed = _parse_flag_args(rest, {
14
14
  "--json": {"key": "json", "type": "bool", "default": False},
15
15
  "--conversation": {"key": "conversation", "type": "str"},
16
+ "--cwd": {"key": "cwd", "type": "str"},
17
+ "--since": {"key": "since", "type": "str"},
18
+ "--tail": {"key": "tail", "type": "str"},
19
+ "--before": {"key": "before", "type": "str"},
16
20
  "--cursor": {"key": "cursor", "type": "str"},
17
21
  "--limit": {"key": "limit", "type": "str", "default": "100"},
18
22
  }, CONVERSATION_USAGE, positionals_key="names", max_positionals=1)
@@ -23,12 +27,14 @@ def handle_conversation(rest, ctx):
23
27
  raise CdxError(f"Unknown session: {parsed['names'][0]}")
24
28
  try:
25
29
  limit = int(parsed["limit"])
30
+ tail = int(parsed["tail"]) if parsed["tail"] is not None else None
26
31
  except ValueError:
27
32
  raise CdxError(CONVERSATION_USAGE) from None
28
33
  page = read_conversation(
29
34
  session["provider"], _get_auth_home(session),
30
35
  conversation_id=parsed["conversation"] or (session.get("conversation") or {}).get("id"),
31
- cursor=parsed["cursor"], limit=limit, fallback_to_recency=not parsed["conversation"],
36
+ cwd=parsed["cwd"], started_at=parsed["since"],
37
+ cursor=parsed["cursor"], limit=limit, tail=tail, before=parsed["before"], fallback_to_recency=not parsed["conversation"],
32
38
  )
33
39
  _write_json(ctx, _json_success("conversation", "Conversation page loaded.", session=session["name"], **page))
34
40
  return 0
@@ -334,8 +334,15 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
334
334
  def runtime_lifecycle(event, info):
335
335
  nonlocal runtime_run_id
336
336
  if event == "started":
337
- runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd, **project})
337
+ runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd, **project, "runId": runtime_run_id})
338
338
  runtime_run_id = runtime.get("runId")
339
+ ctx["service"]["record_launch_history"](session["name"], {
340
+ **info, **project, "run_id": runtime_run_id, "cwd": cwd,
341
+ "action": "resume" if resume else "launch", "status": "running",
342
+ "ended_at": None, "duration_ms": None,
343
+ "conversation_id": _conversation_id(session) if resume or session["provider"] == PROVIDER_CLAUDE else None,
344
+ "first_user_prompt": initial_prompt,
345
+ })
339
346
  elif event == "finished" and runtime_run_id:
340
347
  ctx["service"]["finish_session_runtime"](
341
348
  session["name"],
@@ -367,7 +374,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
367
374
  except CdxError as error:
368
375
  run_info = getattr(error, "run_info", {}) or {}
369
376
  run_info = _attach_interactive_usage(
370
- session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
377
+ session, {**run_info, "cwd": cwd}, ctx["service"]["get_launch_history"](session["name"], limit=None),
371
378
  baseline=(baseline_path, baseline),
372
379
  uncertain_reason=overlap_reason(),
373
380
  )
@@ -382,6 +389,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
382
389
  if run_info:
383
390
  ctx["service"]["record_launch_history"](session["name"], {
384
391
  "status": "failed",
392
+ "action": "resume" if resume else "launch",
385
393
  "cwd": cwd,
386
394
  "error": str(error),
387
395
  "exit_code": error.exit_code,
@@ -391,7 +399,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
391
399
  })
392
400
  raise
393
401
  success_run_info = _attach_interactive_usage(
394
- session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
402
+ session, {**run_info, "cwd": cwd}, ctx["service"]["get_launch_history"](session["name"], limit=None),
395
403
  baseline=(baseline_path, baseline),
396
404
  uncertain_reason=overlap_reason(),
397
405
  )
@@ -452,9 +460,14 @@ def _attach_interactive_usage(session, run_info, history=None, baseline=None, un
452
460
  recorded nothing would break the chain for every run after it.
453
461
  """
454
462
  run_info = dict(run_info or {})
463
+ from ..history_content import enrich_history
464
+
465
+ if session.get("provider") == PROVIDER_CLAUDE:
466
+ run_info.setdefault("conversation_id", _conversation_id(session))
467
+ run_info = enrich_history({**run_info, "provider": session.get("provider")}, session)
455
468
  started_at = run_info.get("started_at")
456
469
  cumulative, provider_transcript, match, model = extract_interactive_usage(
457
- session.get("provider"), _get_auth_home(session), started_at, _conversation_id(session),
470
+ session.get("provider"), _get_auth_home(session), started_at, run_info.get("conversation_id") or _conversation_id(session),
458
471
  # Ollama writes no provider transcript; its only record is the PTY
459
472
  # capture this run already took.
460
473
  run_info.get("transcript_path"),
@@ -695,16 +695,29 @@ def handle_history(rest, ctx):
695
695
  now = datetime.fromtimestamp(now_fn()).astimezone()
696
696
  parsed = _parse_history_args(rest, now=now)
697
697
  has_period = _has_history_period(parsed["period"])
698
- limit = 0 if parsed["summary"] or has_period else parsed["limit"]
698
+ filtered = has_period or parsed["cwd"] is not None or parsed["interactive"]
699
+ limit = 0 if parsed["summary"] or filtered else parsed["limit"]
699
700
  entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
700
701
  entries = _filter_history_period(entries, parsed["period"])
702
+ if parsed["cwd"] is not None:
703
+ cwd = os.path.realpath(os.path.expanduser(parsed["cwd"]))
704
+ entries = [entry for entry in entries if entry.get("cwd")
705
+ and os.path.realpath(os.path.expanduser(entry["cwd"])) == cwd]
706
+ if parsed["interactive"]:
707
+ entries = [entry for entry in entries if entry.get("action") != "run"
708
+ and not entry.get("detached") and not entry.get("detach")
709
+ and not str(entry.get("label") or "").startswith("mission:")
710
+ and "--detach" not in (entry.get("args") or [])]
711
+ if filtered and not parsed["summary"]:
712
+ entries = entries[:parsed["limit"]]
713
+ from ..history_content import enrich_history
714
+
715
+ entries = [enrich_history(entry, ctx["service"]["get_session"](entry.get("session_name")) or {}) for entry in entries]
701
716
  entries = [{**entry, "parent_run_id": entry.get("parent_run_id"),
702
717
  **cost_fields(entry.get("usage") if is_vouched(entry.get("usage")) else None, entry.get("usage_model"),
703
718
  models=entry.get("usage_models", (entry.get("usage_evidence") or {}).get("models", [])), attribution=entry.get("usage_attribution")),
704
719
  "usage": {**entry["usage"], "cache_hit_ratio": cache_hit_ratio(entry["usage"], provider=entry.get("provider")) if is_vouched(entry["usage"]) else None}
705
720
  if isinstance(entry.get("usage"), dict) else entry.get("usage")} for entry in entries]
706
- if has_period and not parsed["summary"]:
707
- entries = entries[:parsed["limit"]]
708
721
  message = (
709
722
  f"Listed launch history for {parsed['name']}"
710
723
  if parsed["name"]
@@ -13,6 +13,7 @@ from .interactive_usage import conversation_transcript, resolve_transcript
13
13
 
14
14
  MAX_LINE_BYTES = 1024 * 1024
15
15
  MAX_PAGE_BYTES = 8 * 1024 * 1024
16
+ MAX_CURSOR_NAMES_BYTES = 2048
16
17
  REDACTED = "[REDACTED]"
17
18
 
18
19
 
@@ -47,18 +48,54 @@ def _tool_name(message, names, result=False):
47
48
  return name
48
49
 
49
50
 
51
+ def _conversation_record(provider, kind):
52
+ # Provider bookkeeping can carry the same text as real conversation records.
53
+ return kind == "response_item" if provider == "codex" else kind in ("user", "assistant")
54
+
55
+
56
+ _CLAUDE_HARNESS = re.compile(
57
+ r"\s*(?:<(?P<tag>task-notification|local-command-stdout|local-command-stderr|command-name|command-message|command-args|system-reminder)>"
58
+ r"(?:(?!</(?P=tag)>).)*</(?P=tag)>\s*)+", re.S,
59
+ )
60
+ _CLAUDE_MID_TURN_START = "The user sent a new message while you were working:\n"
61
+ _CLAUDE_MID_TURN_END = re.compile(
62
+ r"\n+This is how Claude Code surfaces messages the user sends mid-turn[^\n]*"
63
+ r"Address the message above as you continue this turn\.\s*\Z"
64
+ )
65
+
66
+
67
+ def _claude_user_content(content):
68
+ """Remove complete harness wrappers, preserving ordinary prose and images."""
69
+ blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content
70
+ if not isinstance(blocks, list):
71
+ return content
72
+ blocks = [dict(block) if isinstance(block, dict) else block for block in blocks]
73
+ texts = [block for block in blocks if isinstance(block, dict)
74
+ and block.get("type") == "text" and isinstance(block.get("text"), str)]
75
+ # Require both anchored boundaries; a quoted sentence alone is user content.
76
+ if texts and texts[0]["text"].startswith(_CLAUDE_MID_TURN_START):
77
+ end = _CLAUDE_MID_TURN_END.search(texts[-1]["text"])
78
+ if end:
79
+ texts[-1]["text"] = texts[-1]["text"][:end.start()]
80
+ texts[0]["text"] = texts[0]["text"][len(_CLAUDE_MID_TURN_START):].strip("\n")
81
+ return [block for block in blocks if not (
82
+ isinstance(block, dict) and block.get("type") == "text"
83
+ and isinstance(block.get("text"), str)
84
+ and (not block["text"].strip() or _CLAUDE_HARNESS.fullmatch(block["text"]))
85
+ )]
86
+
87
+
50
88
  def _entries(provider, record, names):
51
89
  if not isinstance(record, dict):
52
90
  return []
91
+ if not _conversation_record(provider, record.get("type")):
92
+ return []
53
93
  stamp = record.get("timestamp")
54
94
  try:
55
95
  datetime.fromisoformat(stamp.replace("Z", "+00:00"))
56
96
  except (AttributeError, TypeError, ValueError):
57
97
  stamp = None
58
98
  if provider == "codex":
59
- # event_msg repeats response_item messages; consume only canonical items.
60
- if record.get("type") != "response_item":
61
- return []
62
99
  message = record.get("payload")
63
100
  else:
64
101
  message = record.get("message")
@@ -78,6 +115,8 @@ def _entries(provider, record, names):
78
115
  if role == "tool":
79
116
  return [_entry("tool", name=_tool_name(message, names, True), summary="Result recorded", at=stamp)]
80
117
  content = message.get("content")
118
+ if provider == "claude" and role == "user":
119
+ content = _claude_user_content(content)
81
120
  if isinstance(content, str):
82
121
  return [_entry(role, content, at=stamp)]
83
122
  if not isinstance(content, list):
@@ -92,6 +131,9 @@ def _entries(provider, record, names):
92
131
  elif kind in ("tool_use", "tool_result"):
93
132
  name = _tool_name(block, names, kind == "tool_result")
94
133
  entries.append(_entry("tool", name=name, summary="Call recorded" if kind == "tool_use" else "Result recorded", at=stamp))
134
+ if provider == "claude" and role == "user" and not any(e["role"] == "user" for e in entries):
135
+ if any(isinstance(block, dict) and block.get("type") == "image" for block in content):
136
+ entries.append(_entry("user", at=stamp))
95
137
  return entries
96
138
 
97
139
 
@@ -100,21 +142,238 @@ def _anchor(handle, offset):
100
142
  return hashlib.sha256(handle.read(min(offset, 128))).hexdigest()
101
143
 
102
144
 
145
+ def _field_start(prefix, field, start=0):
146
+ """Find an object field before any incomplete value in a bounded prefix."""
147
+ decoder = json.JSONDecoder()
148
+ whitespace = re.compile(r"\s*")
149
+ try:
150
+ start = whitespace.match(prefix, start).end()
151
+ if prefix[start] != "{":
152
+ return None
153
+ start += 1
154
+ while True:
155
+ start = whitespace.match(prefix, start).end()
156
+ key, end = decoder.raw_decode(prefix, start)
157
+ if not isinstance(key, str):
158
+ return None
159
+ end = whitespace.match(prefix, end).end()
160
+ if prefix[end] != ":":
161
+ return None
162
+ start = whitespace.match(prefix, end + 1).end()
163
+ if key == field:
164
+ return start
165
+ _, end = decoder.raw_decode(prefix, start)
166
+ end = whitespace.match(prefix, end).end()
167
+ if prefix[end] != ",":
168
+ return None
169
+ start = end + 1
170
+ except (ValueError, IndexError, RecursionError):
171
+ return None
172
+
173
+
174
+ def _oversized_entry(provider, prefix):
175
+ prefix = prefix.decode("utf-8", errors="replace")
176
+ kind = _field_start(prefix, "type")
177
+ try:
178
+ value, _ = json.JSONDecoder().raw_decode(prefix, kind) if kind is not None else (None, 0)
179
+ except (ValueError, RecursionError):
180
+ value = None
181
+ if not _conversation_record(provider, value):
182
+ return None
183
+ message = _field_start(prefix, "payload" if provider == "codex" else "message")
184
+ if message is None or prefix[message:message + 1] != "{":
185
+ return None
186
+ role = _field_start(prefix, "role", message)
187
+ if role is None and provider != "codex":
188
+ role = _field_start(prefix, "type")
189
+ try:
190
+ value, _ = json.JSONDecoder().raw_decode(prefix, role) if role is not None else (None, 0)
191
+ except (ValueError, RecursionError):
192
+ value = None
193
+ return _entry(value if value in ("user", "assistant", "tool") else "tool",
194
+ "[élément trop volumineux ignoré]")
195
+
196
+
197
+ REVERSE_BLOCK_BYTES = 64 * 1024
198
+
199
+
200
+ def _encode_cursor(handle, identity, offset, index, names):
201
+ names = dict(names)
202
+ while len(json.dumps(names, separators=(",", ":")).encode()) > MAX_CURSOR_NAMES_BYTES:
203
+ names.pop(next(iter(names)))
204
+ state = {"v": 1, "file": identity, "offset": offset, "index": index,
205
+ "anchor": _anchor(handle, offset), "names": names}
206
+ return base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
207
+
208
+
209
+ def _reverse_records(handle, end):
210
+ """Yield line positions and bounded prefixes, including an incomplete tail."""
211
+ position, line_end, length, prefix = end, end, 0, b""
212
+ while position:
213
+ start = max(0, position - REVERSE_BLOCK_BYTES)
214
+ handle.seek(start)
215
+ block = handle.read(position - start)
216
+ right = len(block)
217
+ while right:
218
+ newline = block.rfind(b"\n", 0, right)
219
+ left = newline + 1
220
+ part = block[left:right]
221
+ length += len(part)
222
+ prefix = (part + prefix)[:MAX_LINE_BYTES + 1]
223
+ if newline < 0:
224
+ break
225
+ if length:
226
+ yield start + left, line_end, prefix, length
227
+ line_end = start + left
228
+ length, prefix, right = 1, b"\n", newline
229
+ position = start
230
+ if length:
231
+ yield 0, line_end, prefix, length
232
+
233
+
234
+ def _record_entries(provider, prefix, length, names):
235
+ if length > MAX_LINE_BYTES:
236
+ entry = _oversized_entry(provider, prefix)
237
+ return [entry] if entry is not None else []
238
+ try:
239
+ return _entries(provider, json.loads(prefix), names)
240
+ except (ValueError, UnicodeError, RecursionError):
241
+ return []
242
+
243
+
244
+ class _NameLookups(dict):
245
+ def __init__(self):
246
+ super().__init__()
247
+ self.missing = set()
248
+
249
+ def get(self, key, default=None):
250
+ if key not in self:
251
+ self.missing.add(key)
252
+ return super().get(key, default)
253
+
254
+
255
+ def _read_tail(handle, stat, identity, provider, page, count, before):
256
+ end, stop_index = stat.st_size, None
257
+ snapshot = {"size": stat.st_size, "mtime": stat.st_mtime_ns,
258
+ "anchor": _anchor(handle, stat.st_size)}
259
+ if before is not None:
260
+ try:
261
+ if not isinstance(before, str) or len(before) > 4096:
262
+ raise ValueError
263
+ state = json.loads(base64.urlsafe_b64decode(before.encode("ascii")))
264
+ end, stop_index = state["offset"], state["index"]
265
+ snapshot = state["snapshot"]
266
+ size = snapshot["size"]
267
+ if (state["v"] != 2 or state["file"] != identity or state["direction"] != "before"
268
+ or type(end) is not int or type(stop_index) is not int
269
+ or type(size) is not int or not 0 <= end <= size <= stat.st_size
270
+ or not 0 <= stop_index <= MAX_LINE_BYTES
271
+ or state["anchor"] != _anchor(handle, end)
272
+ or snapshot["anchor"] != _anchor(handle, size)
273
+ or (size == stat.st_size and snapshot["mtime"] != stat.st_mtime_ns)):
274
+ raise ValueError
275
+ except (ValueError, TypeError, KeyError, AttributeError, UnicodeError):
276
+ return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
277
+ # Include the boundary record when a page split its content blocks.
278
+ boundary_end = end
279
+ if stop_index:
280
+ handle.seek(end)
281
+ line = handle.readline(MAX_LINE_BYTES + 1)
282
+ if not line.endswith(b"\n") or len(line) > MAX_LINE_BYTES:
283
+ return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
284
+ boundary_end = end + len(line)
285
+ selected, remaining, names = [], count, {}
286
+ earliest = (end, stop_index or 0)
287
+ forward_end = (end, stop_index or 0)
288
+ lookback_start = None
289
+ missing_names = set()
290
+ for start, record_end, prefix, length in _reverse_records(handle, boundary_end):
291
+ if not prefix.endswith(b"\n") and length <= MAX_LINE_BYTES:
292
+ # The first reverse record may be an unfinished provider write.
293
+ forward_end = (start, 0)
294
+ continue
295
+ if length > MAX_LINE_BYTES:
296
+ handle.seek(record_end - 1)
297
+ if handle.read(1) != b"\n":
298
+ forward_end = (start, 0)
299
+ continue
300
+ local_names = _NameLookups()
301
+ entries = _record_entries(provider, prefix, length, local_names)
302
+ if start == end and stop_index and stop_index >= len(entries):
303
+ return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
304
+ if remaining:
305
+ upper = stop_index if start == end and stop_index is not None else len(entries)
306
+ lower = max(0, upper - remaining)
307
+ if upper > lower:
308
+ selected.append((start, prefix, length, lower, upper))
309
+ missing_names.update(local_names.missing)
310
+ missing_names.difference_update(local_names)
311
+ remaining -= upper - lower
312
+ earliest = (start, lower)
313
+ if len(selected) == 1:
314
+ forward_end = (start, upper) if upper < len(entries) else (record_end, 0)
315
+ if not remaining:
316
+ lookback_start = start
317
+ else:
318
+ # Recover recent call names before normalizing the selected window.
319
+ for key, name in local_names.items():
320
+ names.setdefault(key, name)
321
+ missing_names.difference_update(local_names)
322
+ budget = MAX_PAGE_BYTES if missing_names else REVERSE_BLOCK_BYTES
323
+ if len(names) >= 100 or lookback_start - start >= budget:
324
+ break
325
+ if remaining:
326
+ earliest = (0, 0)
327
+ names = dict(reversed(list(names.items())))
328
+ for start, prefix, length, lower, upper in reversed(selected):
329
+ entries = _record_entries(provider, prefix, length, names)
330
+ for index in range(lower, upper):
331
+ entry = entries[index]
332
+ entry["id"] = f"{identity[:16]}:{start}:{index}"
333
+ page["entries"].append(entry)
334
+ offset, index = forward_end
335
+ page["next_cursor"] = _encode_cursor(handle, identity, offset, index, names)
336
+ page["has_more"] = offset < stat.st_size
337
+ offset, index = earliest
338
+ page["before_cursor"] = None
339
+ if offset or index:
340
+ state = {"v": 2, "direction": "before", "file": identity, "offset": offset,
341
+ "index": index, "anchor": _anchor(handle, offset), "snapshot": snapshot}
342
+ page["before_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
343
+ return page
344
+
345
+
103
346
  def read_conversation(provider, auth_home=None, *, conversation_id=None, started_at=None,
104
- terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False):
347
+ terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False, tail=None, before=None, cwd=None):
105
348
  """Return a JSON-serializable page; pass next_cursor back unchanged to poll.
106
349
 
107
350
  Entries follow file/block order. Cursors bind a byte offset and block index
108
351
  to the resolved file; reset_required means discard the old cursor and page
109
352
  again from the beginning. Incomplete trailing lines are retried next time.
353
+ tail selects the latest entries; before selects entries preceding an opaque
354
+ backward cursor. Both return before_cursor as well as a forward next_cursor.
110
355
  Only implicit session identities may opt into fallback_to_recency.
356
+ Recency is restricted to cwd (the process directory by default), and to
357
+ transcripts modified at or after started_at when supplied.
111
358
  Tool bodies and reasoning blocks are never returned. Ollama PTY captures
112
359
  cannot reliably distinguish roles and are explicitly unsupported.
113
360
  """
114
361
  if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
115
362
  raise CdxError("Conversation limit must be an integer between 1 and 1000.")
363
+ if tail is not None and (type(tail) is not int or not 1 <= tail <= 1000):
364
+ raise CdxError("Conversation tail must be an integer between 1 and 1000.")
365
+ if cursor is not None and (tail is not None or before is not None):
366
+ raise CdxError("Conversation --cursor cannot be combined with --tail or --before.")
367
+ if started_at is not None:
368
+ try:
369
+ datetime.fromisoformat(started_at.replace("Z", "+00:00"))
370
+ except (ValueError, AttributeError, TypeError):
371
+ raise CdxError("Conversation --since must be an ISO timestamp.") from None
372
+ scope = {"cwd": os.path.realpath(os.path.expanduser(cwd or os.getcwd())), "since": started_at}
116
373
  page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False,
117
- "resolution_method": None}
374
+ "resolution_method": None, "resolution_scope": None}
375
+ if tail is not None or before is not None:
376
+ page["before_cursor"] = None
118
377
  if provider not in PROVIDERS or provider == "ollama":
119
378
  return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
120
379
  # An explicit conversation must never fall back to an unrelated recent file.
@@ -123,15 +382,20 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
123
382
  if path:
124
383
  page["resolution_method"] = "conversation_id"
125
384
  elif fallback_to_recency:
126
- path, page["resolution_method"] = resolve_transcript(provider, auth_home)
385
+ page["resolution_scope"] = scope
386
+ path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, cwd=scope["cwd"])
127
387
  else:
128
- path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, terminal_transcript=terminal_transcript)
388
+ page["resolution_scope"] = scope
389
+ path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, terminal_transcript=terminal_transcript, cwd=scope["cwd"])
129
390
  if not path:
130
- return {**page, "status": "unavailable", "reason": "transcript_not_found"}
391
+ reason = "no_conversation_yet" if page["resolution_scope"] is not None else "transcript_not_found"
392
+ return {**page, "status": "unavailable", "reason": reason}
131
393
  try:
132
394
  with open(path, "rb") as handle:
133
395
  stat = os.fstat(handle.fileno())
134
396
  identity = hashlib.sha256(f"{provider}:{os.path.realpath(path)}:{stat.st_dev}:{stat.st_ino}".encode()).hexdigest()
397
+ if tail is not None or before is not None:
398
+ return _read_tail(handle, stat, identity, provider, page, tail if tail is not None else limit, before)
135
399
  offset, index, names = 0, 0, {}
136
400
  if cursor is not None:
137
401
  try:
@@ -158,14 +422,22 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
158
422
  start = handle.tell()
159
423
  line = handle.readline(MAX_LINE_BYTES + 1)
160
424
  if len(line) > MAX_LINE_BYTES:
161
- return {**page, "status": "unavailable", "reason": "record_too_large", "entries": []}
162
- if not line or not line.endswith(b"\n"):
163
- break
164
- scanned += len(line)
165
- try:
166
- entries = _entries(provider, json.loads(line), names)
167
- except (ValueError, UnicodeError):
168
- entries = []
425
+ entry = _oversized_entry(provider, line)
426
+ # Finish this record even beyond the page scan budget. Never
427
+ # retain its body, and retry from start if its newline is pending.
428
+ while line and not line.endswith(b"\n"):
429
+ line = handle.readline(MAX_LINE_BYTES + 1)
430
+ if not line:
431
+ break
432
+ entries = [entry] if entry is not None else []
433
+ else:
434
+ if not line or not line.endswith(b"\n"):
435
+ break
436
+ try:
437
+ entries = _entries(provider, json.loads(line), names)
438
+ except (ValueError, UnicodeError):
439
+ entries = []
440
+ scanned += handle.tell() - start
169
441
  for block_index in range(index, len(entries)):
170
442
  entry = entries[block_index]
171
443
  entry["id"] = f"{identity[:16]}:{start}:{block_index}"
@@ -180,8 +452,7 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
180
452
  break
181
453
  offset, index = handle.tell(), 0
182
454
  page["has_more"] = offset < stat.st_size
183
- state = {"v": 1, "file": identity, "offset": offset, "index": index, "anchor": _anchor(handle, offset), "names": names}
184
- page["next_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
455
+ page["next_cursor"] = _encode_cursor(handle, identity, offset, index, names)
185
456
  return page
186
457
  except OSError:
187
458
  return {**page, "status": "unavailable", "reason": "transcript_unreadable", "entries": []}
@@ -0,0 +1,144 @@
1
+ """Bounded provider evidence for resumable interactive history rows."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import time
8
+
9
+ from .conversation import MAX_LINE_BYTES, MAX_PAGE_BYTES, _entries, _redact
10
+ from .interactive_usage import (
11
+ MAX_TRANSCRIPT_CANDIDATES,
12
+ MAX_TRANSCRIPT_SCAN_SECONDS,
13
+ _timestamp,
14
+ conversation_transcript,
15
+ resolve_transcript,
16
+ )
17
+
18
+ _WRAPPERS = re.compile(
19
+ r"<(?P<tag>local-command-caveat|command-name|command-message|command-args|local-command-stdout|"
20
+ r"local-command-stderr|system-reminder|environment_context|instructions)>.*?</(?P=tag)>", re.S,
21
+ )
22
+
23
+
24
+ def _identity_arg(args):
25
+ args = list(args or [])
26
+ if "-c" in args:
27
+ index = args.index("-c") + 1
28
+ try:
29
+ args = shlex.split(args[index])
30
+ except (IndexError, ValueError, TypeError):
31
+ return None
32
+ for index, arg in enumerate(args):
33
+ if arg == "--":
34
+ break
35
+ if arg in ("--session-id", "--resume", "resume") and index + 1 < len(args):
36
+ identifier = args[index + 1]
37
+ if isinstance(identifier, str) and re.fullmatch(r"[0-9a-fA-F-]{36}", identifier):
38
+ return identifier
39
+ return None
40
+
41
+
42
+ def clean_prompt(text):
43
+ text = _WRAPPERS.sub("", text).strip()
44
+ lines = [line for line in text.splitlines() if not re.fullmatch(
45
+ r"\s*(?:(?:/|~/|[A-Za-z]:[\\/]|\.\.?[/\\])\S+|[\w./\\-]+\.[A-Za-z0-9]{1,10})\s*", line)]
46
+ return _redact("\n".join(lines).strip())
47
+
48
+
49
+ def first_user_prompt(provider, path):
50
+ if not path:
51
+ return None
52
+ try:
53
+ with open(path, "rb") as handle:
54
+ scanned = 0
55
+ names = {}
56
+ while scanned < MAX_PAGE_BYTES:
57
+ line = handle.readline(MAX_LINE_BYTES + 1)
58
+ if not line:
59
+ break
60
+ scanned += len(line)
61
+ if len(line) > MAX_LINE_BYTES:
62
+ return None
63
+ try:
64
+ record = json.loads(line)
65
+ except (ValueError, UnicodeError, RecursionError):
66
+ continue
67
+ texts = [clean_prompt(entry["text"]) for entry in _entries(provider, record, names)
68
+ if entry["role"] == "user"]
69
+ text = "\n".join(text for text in texts if text)
70
+ if text:
71
+ return text[:2000]
72
+ except OSError:
73
+ pass
74
+ return None
75
+
76
+
77
+ def _new_codex_id(auth_home, entry):
78
+ """Only a unique rollout created in this launch's project/time can identify it."""
79
+ start = _timestamp(entry.get("started_at"))
80
+ end = _timestamp(entry.get("ended_at"))
81
+ cwd = entry.get("cwd")
82
+ if start is None or not cwd:
83
+ return None
84
+ deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
85
+ found = set()
86
+ count = 0
87
+ for directory, _, names in os.walk(os.path.join(auth_home, "sessions")):
88
+ if time.monotonic() >= deadline:
89
+ return None
90
+ for name in names:
91
+ if time.monotonic() >= deadline:
92
+ return None
93
+ if not name.endswith(".jsonl"):
94
+ continue
95
+ count += 1
96
+ if count > MAX_TRANSCRIPT_CANDIDATES:
97
+ return None
98
+ try:
99
+ with open(os.path.join(directory, name), encoding="utf-8") as handle:
100
+ record = json.loads(handle.readline(64 * 1024))
101
+ payload = record.get("payload") or {}
102
+ stamp = _timestamp(record.get("timestamp") or payload.get("timestamp"))
103
+ identifier = payload.get("id") or payload.get("session_id")
104
+ if (record.get("type") == "session_meta" and stamp is not None and stamp >= start
105
+ and (end is None or stamp <= end) and isinstance(payload.get("cwd"), str)
106
+ and os.path.realpath(payload["cwd"]) == os.path.realpath(cwd)
107
+ and isinstance(identifier, str) and conversation_transcript("codex", auth_home, identifier)):
108
+ found.add(identifier)
109
+ except (OSError, ValueError, TypeError, AttributeError, RecursionError):
110
+ continue
111
+ return next(iter(found)) if len(found) == 1 else None
112
+
113
+
114
+ def enrich_history(entry, session):
115
+ entry = dict(entry)
116
+ provider = entry.get("provider") or session.get("provider")
117
+ home = session.get("authHome")
118
+ if provider not in ("claude", "codex") or not home:
119
+ return entry
120
+ identifier = entry.get("conversation_id")
121
+ if not identifier:
122
+ identifier = _identity_arg(entry.get("args")) if entry.get("action") != "run" else None
123
+ if not identifier and provider == "codex" and entry.get("action") != "run":
124
+ identifier = _new_codex_id(home, entry)
125
+ path = conversation_transcript(provider, home, identifier) if identifier else None
126
+ if path:
127
+ entry.update(conversation_id=identifier, provider_transcript_path=path,
128
+ provider_transcript_match="conversation_id")
129
+ elif identifier:
130
+ # A known identity whose transcript is absent must never target another session.
131
+ entry["conversation_id"] = identifier
132
+ path = None
133
+ else:
134
+ path = entry.get("provider_transcript_path")
135
+ if not path and entry.get("action") != "run":
136
+ path, _ = resolve_transcript(provider, home, entry.get("started_at"), cwd=entry.get("cwd"))
137
+ if path and entry.get("action") != "run":
138
+ entry.update(provider_transcript_path=path, provider_transcript_match="recency")
139
+ prompt = first_user_prompt(provider, path)
140
+ if prompt:
141
+ entry["first_user_prompt"] = prompt
142
+ elif entry.get("first_user_prompt"):
143
+ entry["first_user_prompt"] = clean_prompt(entry["first_user_prompt"]) or None
144
+ return entry
@@ -14,6 +14,7 @@ import re
14
14
  import time
15
15
 
16
16
  from .run_usage import claude_usage_dedup_key, normalize_usage
17
+ from .shared_memory import claude_project_name
17
18
  from .status_source import _normalize_terminal_transcript
18
19
 
19
20
  # Readers stream JSONL line-by-line. A 16 MiB cap excluded ordinary long
@@ -84,7 +85,7 @@ def extract_interactive_usage(provider, auth_home, started_at=None, conversation
84
85
 
85
86
 
86
87
  def resolve_transcript(provider, auth_home, started_at=None, conversation_id=None,
87
- terminal_transcript=None):
88
+ terminal_transcript=None, cwd=None):
88
89
  """The transcript belonging to this session, and how confidently."""
89
90
  if provider == "ollama":
90
91
  # No provider-native transcript exists to resolve. The run's own PTY
@@ -108,7 +109,7 @@ def resolve_transcript(provider, auth_home, started_at=None, conversation_id=Non
108
109
  # a Codex rollout lives under the session's own auth home, so the
109
110
  # newest one touched during this run is this session's current
110
111
  # conversation, not somebody else's file.
111
- path = _latest_transcript(provider, auth_home, started_at)
112
+ path = _latest_transcript(provider, auth_home, started_at, cwd=cwd)
112
113
  return path, (MATCH_RECENCY if path else None)
113
114
 
114
115
 
@@ -166,10 +167,25 @@ def _codex_rollout_for(auth_home, conversation_id):
166
167
  _READERS = {}
167
168
 
168
169
 
169
- def _latest_transcript(provider, auth_home, started_at):
170
+ def _codex_matches_cwd(path, cwd):
171
+ """Fail closed when the bounded session metadata cannot identify a project."""
172
+ try:
173
+ with open(path, encoding="utf-8") as handle:
174
+ record = json.loads(handle.readline(64 * 1024))
175
+ recorded = record["payload"]["cwd"] if record["type"] == "session_meta" else None
176
+ return isinstance(recorded, str) and os.path.isabs(recorded) and os.path.realpath(recorded) == cwd
177
+ except (OSError, ValueError, KeyError, TypeError, RecursionError):
178
+ return False
179
+
180
+
181
+ def _latest_transcript(provider, auth_home, started_at, cwd=None):
170
182
  if not auth_home:
171
183
  return None
172
184
  root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
185
+ if cwd is not None:
186
+ cwd = os.path.realpath(cwd)
187
+ if provider != "codex":
188
+ root = os.path.join(root, claude_project_name(cwd))
173
189
  if not os.path.isdir(root):
174
190
  return None
175
191
  cutoff = _timestamp(started_at)
@@ -181,9 +197,11 @@ def _latest_transcript(provider, auth_home, started_at):
181
197
  # reached". Returning a partial answer here is what made a wrong
182
198
  # attribution indistinguishable from a real measurement.
183
199
  return None
184
- if provider == "claude":
200
+ if provider == "claude" or cwd is not None:
185
201
  subdirs[:] = [name for name in subdirs if name != "subagents"]
186
202
  for name in names:
203
+ if time.monotonic() >= deadline:
204
+ return None
187
205
  if not name.endswith(".jsonl"):
188
206
  continue
189
207
  path = os.path.join(directory, name)
@@ -192,6 +210,8 @@ def _latest_transcript(provider, auth_home, started_at):
192
210
  except OSError:
193
211
  continue
194
212
  if cutoff is None or modified >= cutoff:
213
+ if cwd is not None and provider == "codex" and not _codex_matches_cwd(path, cwd):
214
+ continue
195
215
  candidates.append((modified, path))
196
216
  if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
197
217
  return None
@@ -688,9 +688,18 @@ def record_launch_history(store, name, payload):
688
688
  "launch": session.get("launch") or {},
689
689
  **payload,
690
690
  }
691
- store["append_launch_history"](entry)
691
+ from .history_content import enrich_history
692
+
692
693
  if entry.get("action") != "run":
693
- _observe_codex_conversation(store, session)
694
+ entry = enrich_history(entry, session)
695
+ store["append_launch_history"](entry)
696
+ if entry.get("action") != "run" and entry.get("status") != "running":
697
+ if session["provider"] == PROVIDER_CODEX and entry.get("provider_transcript_match") == "conversation_id":
698
+ store["update_session"](name, lambda s: {
699
+ **s, "conversation": {"id": entry["conversation_id"], "provenance": "observed", "recordedAt": _local_now_iso()},
700
+ })
701
+ elif entry.get("action") not in ("launch", "resume"):
702
+ _observe_codex_conversation(store, session)
694
703
  return entry
695
704
 
696
705
 
@@ -726,7 +735,25 @@ def _observe_codex_conversation(store, session):
726
735
  def get_launch_history(store, name=None, limit=20):
727
736
  if name and not store["get_session"](name):
728
737
  raise CdxError(f"Unknown session: {name}")
729
- return store["list_launch_history"](session_name=name, limit=limit)
738
+ entries = store["list_launch_history"](session_name=name, limit=0)
739
+ known = {entry.get("run_id") for entry in entries if entry.get("run_id")}
740
+ # Older supervisors only wrote history on exit. Expose their active runtime
741
+ # without inventing completed history or duplicating newly recorded launches.
742
+ sessions = [store["get_session"](name)] if name else store["list_sessions"]()
743
+ for session in sessions:
744
+ runtime = _session_runtime(store, session["name"])
745
+ if not runtime or not runtime.get("runId") or runtime["runId"] in known:
746
+ continue
747
+ entries.insert(0, {
748
+ "schema_version": 1, "session_name": session["name"], "provider": session["provider"],
749
+ "launch": session.get("launch") or {}, "action": "launch", "status": "running",
750
+ "run_id": runtime["runId"], "started_at": runtime.get("startedAt"),
751
+ "cwd": runtime.get("cwd"), "pid": runtime.get("pid"), "label": runtime.get("label"),
752
+ "project": runtime.get("project"), "project_source": runtime.get("project_source", "unknown"),
753
+ "transcript_path": runtime.get("transcriptPath"),
754
+ "conversation_id": (session.get("conversation") or {}).get("id") if session["provider"] == PROVIDER_CLAUDE else None,
755
+ })
756
+ return entries[:limit] if limit else entries
730
757
 
731
758
 
732
759
  def backfill_interactive_usage(store, name=None, dry_run=True):
@@ -253,6 +253,24 @@ def create_session_store(base_dir):
253
253
  def append_launch_history(entry):
254
254
  with _file_lock(lock_file):
255
255
  _ensure_dir(state_dir)
256
+ if entry.get("run_id") and entry.get("action") in ("launch", "resume"):
257
+ try:
258
+ with open(launch_history_file, encoding="utf-8") as handle:
259
+ lines = handle.readlines()
260
+ except FileNotFoundError:
261
+ lines = []
262
+ for index, line in enumerate(lines):
263
+ try:
264
+ previous = json.loads(line)
265
+ except ValueError:
266
+ continue
267
+ if (previous.get("run_id") == entry["run_id"]
268
+ and previous.get("session_name") == entry.get("session_name")
269
+ and previous.get("action") in ("launch", "resume")):
270
+ lines[index] = json.dumps({**previous, **entry}, separators=(",", ":")) + "\n"
271
+ atomic_write(launch_history_file, "".join(lines))
272
+ _fsync_directory(state_dir)
273
+ return
256
274
  with open(launch_history_file, "a", encoding="utf-8") as handle:
257
275
  handle.write(json.dumps(entry, separators=(",", ":")))
258
276
  handle.write("\n")