cdx-manager 0.26.0 → 0.26.2

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.0-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.2-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
 
@@ -78,6 +78,10 @@ cdx run --continue-run RUN_ID --cwd /path/to/repo --prompt "Fix the remaining te
78
78
 
79
79
  `--continue-run` resumes the exact Claude session or Codex thread and pins the previous run's recorded model and effort, even if the profile settings changed. The profile is inferred from the parent; an explicit session or provider must match. An explicit model or effort must also match. Runs and history record `parent_run_id` and `conversation_id`. Detached continuations use the cdx child supervisor with the same pins. Codex continuation usage is measured between exact transcript snapshots before and after the run, since exec resume can report conversation totals. If either boundary is unavailable, per-run usage stays unknown with `usage_attribution: "continuation_usage_unavailable"` in history instead of counting earlier turns again.
80
80
 
81
+ Headless runs, including detached, remote and continued runs, keep their
82
+ conversation identity in run records and history without replacing the profile's
83
+ interactive conversation.
84
+
81
85
  Conservative assumption: a continuation requires a finished Claude or Codex run, a locally available provider transcript, and recorded model and effort settings. Missing runs, profiles, expired sessions, and unavailable settings produce a clear error; the caller can retry without `--continue-run` for a cold run. `--failover` and `--isolate-workspace` cannot be combined with continuation. Capacity recovery and provider model fallback are disabled for continuations so they cannot silently switch model or account. Provider cache retention remains provider-controlled, so resuming does not guarantee a cache hit.
82
86
 
83
87
  ## Run costs and budgets
@@ -242,36 +246,94 @@ for a fresh credential, and that cleanup works after disconnect and SIGTERM.
242
246
 
243
247
  ### Read an interactive conversation
244
248
 
245
- `cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json`
249
+ `cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json`
246
250
  returns a read-only JSON page for the profile's current conversation. An explicit
247
- conversation ID uses an exact transcript lookup; a profile without a recorded ID
248
- uses the existing latest-transcript resolver. This fallback is best effort.
251
+ conversation ID uses an exact transcript lookup and never falls back. When the
252
+ 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 selects the latest
254
+ local transcript for `--cwd PATH`, defaulting to the process working directory.
255
+ Claude-compatible providers use `.claude/projects/<path-slug>`; Codex requires
256
+ a matching `cwd` in the rollout's initial `session_meta` record. Missing or
257
+ unreadable Codex metadata is excluded. `--since ISO` further restricts fallback
258
+ to transcripts modified at or after the session start timestamp (for example
259
+ `--since 2026-11-04T00:39:00Z`). Callers should pass a timestamp with a timezone.
260
+ Without a candidate, JSON reports `status: "unavailable"` and
261
+ `reason: "no_conversation_yet"`, including while a new session awaits folder trust.
262
+ This fallback is best effort and does not rewrite the recorded ID. Exact ID
263
+ lookups remain independent of these fallback constraints. JSON `resolution_method` is
264
+ `conversation_id` for an exact match, `recency` for a local fallback, or `null`
265
+ when no transcript is resolved. JSON `resolution_scope` records the canonical
266
+ absolute `cwd` and supplied `since` timestamp for fallback attempts, including
267
+ unavailable results; `since` is `null` when omitted. Exact lookups and unsupported
268
+ providers report a `null` scope. A missing explicit ID still reports
269
+ `transcript_not_found`.
249
270
 
250
271
  The normal cdx JSON envelope includes `status`, `reason`, `provider`, `session`,
251
272
  `entries`, `next_cursor`, and `has_more`. Each entry has exactly `role`
252
273
  (`user`, `assistant`, or `tool`), `text`, `tool_name`, `tool_summary`, `at`, and
253
- `id`. Missing values are `null`; tool text is empty. Entries follow file and
274
+ `id`. Missing values are `null`; normal tool text is empty. Entries follow file and
254
275
  content-block order, with stable IDs for the same file position. Timestamps are
255
276
  provider timestamps when valid. Tool calls/results have a name and a short
256
277
  summary; arguments, results, attachments, and reasoning blocks are omitted.
257
- Result names are recovered from up to 100 recent calls, otherwise `tool`.
278
+ Result names are recovered from recent calls, otherwise `tool`. The cache holds
279
+ up to 100 names and is trimmed oldest first to 2 KiB of compact JSON when saved
280
+ in a cursor. Existing version-1 cursors with larger caches remain readable.
258
281
  Common credential patterns and secret assignments in text are redacted without
259
282
  opening credential stores. Review conversation text before sharing: arbitrary
260
283
  unlabelled sensitive prose cannot be identified automatically.
261
284
 
262
285
  Pass `next_cursor` unchanged as `--cursor` to fetch the next page or poll for new
263
286
  entries. `--limit` bounds entries (default 100, range 1–1000). Reading seeks to
264
- the saved byte position rather than replaying history, and scans at most 8 MiB
265
- per call. A page may be empty while still returning a usable cursor. Poll on a
287
+ the saved byte position rather than replaying history, and stops scanning after
288
+ 8 MiB per call, finishing the current record when it crosses that budget.
289
+ A page may be empty while still returning a usable cursor. Poll on a
266
290
  timer: `has_more` means unread bytes, which can include an incomplete final line
267
291
  that will only become readable after the provider appends its newline.
268
292
 
293
+ Start near the present with `cdx conversation work --tail 100 --json`.
294
+ `--tail N` returns the last N normalized entries (1–1000) in chronological order.
295
+ It reads backwards in 64 KiB blocks, retaining at most a 1 MiB record prefix;
296
+ it does not replay the transcript from the beginning. Records that produce
297
+ multiple entries can split across pages. The same redaction, tool summaries,
298
+ oversized-record placeholders and file-position IDs apply. Recent tool names are
299
+ recovered from a 64 KiB lookback (extended to 8 MiB when resolving a result in the selected window), with the usual `tool`
300
+ fallback. Finding N entries can require scanning more bytes when records
301
+ are oversized or contain no visible entries. Incomplete trailing records are
302
+ excluded until their newline arrives.
303
+
304
+ Tail and backward pages add `before_cursor`: pass it unchanged as
305
+ `--before CURSOR --limit N` to load the preceding entries, also in chronological
306
+ order. `before_cursor: null` means the beginning has been reached. You can also
307
+ use `--before CURSOR --tail N`; `--tail` sets the count when both count options
308
+ are present. `next_cursor` still works with `--cursor` to follow entries after
309
+ the returned page, including messages appended after an initial tail read.
310
+ `has_more` continues to describe unread bytes **after** that page; use
311
+ `before_cursor` to decide whether older pages remain. `--cursor` cannot be
312
+ combined with `--tail` or `--before`. Without either new option, the existing
313
+ forward page contract is unchanged.
314
+
315
+ Backward cursors retain the original file size, modification time and boundary
316
+ anchors. Replacement, truncation or a detected in-place rewrite returns
317
+ `reset_required`; appending new messages preserves backward pagination. As with
318
+ forward cursors, transcripts must be append-only: bounded boundary checks cannot
319
+ detect every arbitrary edit elsewhere in a file that also grows.
320
+
269
321
  Codex response items and Claude-compatible JSONL transcripts (Claude and
270
- Antigravity) are supported through the existing transcript resolver. Missing or
322
+ Antigravity) are supported through the existing transcript resolver. Only
323
+ `response_item` records from Codex and `user`/`assistant` records with `message`
324
+ from Claude-compatible transcripts are read, including their tool blocks. All
325
+ other record types are ignored, even when they repeat conversation content. Missing or
271
326
  unreadable files return `status: "unavailable"` with a reason. Ollama's terminal
272
327
  capture does not reliably identify message roles and returns
273
328
  `status: "unsupported"`, including when no capture exists. A transcript record
274
- larger than 1 MiB returns `unavailable`. A replaced/truncated file or invalid
329
+ 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
330
+ one entry containing `[élément trop volumineux ignoré]`, with a stable ID and a
331
+ role recovered from structural metadata in the readable prefix, or `tool` when
332
+ unknown. Records whose type or message cannot be established from the bounded
333
+ prefix are ignored. Its body, images and attachments are never returned. The cursor advances
334
+ past that record so later messages remain accessible. An oversized final record
335
+ without a newline produces no entry yet and is retried on the next call.
336
+ A replaced/truncated file or invalid
275
337
  cursor returns `reset_required`; discard the cursor and reload the conversation.
276
338
  Cursors are opaque, versioned, and bound to a provider and file. Treat transcripts
277
339
  as append-only; reset the client if a provider rewrites earlier history.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.0
1
+ 0.26.2
@@ -0,0 +1,21 @@
1
+ # CDX Manager 0.26.1
2
+
3
+ Release date: 2026-11-03
4
+
5
+ ## Fixes
6
+
7
+ - Preserve the profile's interactive conversation across local, detached, remote,
8
+ and continued headless runs. Keep headless Claude identities in run records
9
+ and history, and skip interactive Codex identity observation for run history.
10
+ (#103)
11
+ - Let `cdx conversation` use the latest local transcript when the profile's
12
+ recorded conversation ID has no transcript on this host. Explicit
13
+ `--conversation` lookups remain exact. Expose `resolution_method` as
14
+ `conversation_id`, `recency`, or `null`; recency remains best effort. (#103)
15
+
16
+ ## Maintenance
17
+
18
+ - Record tray companion checksums for v0.26.0.
19
+ (`c942c9f6fdcfabd1ad0442e46f978da3b548971f`)
20
+ - Record release archive checksums for v0.26.0.
21
+ (`9a43f53d7294ecb254bd65aea214831bcd1486fb`)
@@ -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.
@@ -1,5 +1,23 @@
1
1
  # Unreleased
2
2
 
3
+ ## Conversation tail pagination (planned for 0.26.2)
4
+
5
+ - 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.
6
+ - 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.
7
+
8
+ ## Read past oversized conversation records
9
+
10
+ - 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.
11
+ - Retry oversized trailing records without a newline on the next poll. The 8 MiB page scan budget finishes the current record before returning.
12
+ - 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.
13
+ - 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.
14
+
15
+ ## Preserve interactive conversations across headless runs
16
+
17
+ - 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.
18
+ - Let `cdx conversation` fall back to the latest local transcript when a profile's recorded ID has no transcript on this host. Explicit `--conversation` lookups remain exact and never fall back. Report `resolution_method` as `conversation_id`, `recency`, or `null`.
19
+ - Cover a remote Claude run followed by local interactive conversation reading, local/continued run identity preservation, missing recorded IDs and missing explicit IDs.
20
+
3
21
  ## Antigravity quota and run usage
4
22
 
5
23
  - Install a local Antigravity statusLine command in new profiles and on interactive launch, merging settings and relaying the original stdin to any existing command so its output is preserved. Store only the reading time, plan tier and allowlisted quota fields in an atomic mode-600 profile file; never access Google internal endpoints or antigravity-oauth-token.
@@ -793,6 +793,28 @@
793
793
  "x86_64-pc-windows-msvc": "32889713859cf23d4d82647638d909d62075b92d8e6571e32118ebfa208d81b2",
794
794
  "x86_64-unknown-linux-musl": "9e94b799fe4b01d95aeb172c1eba28dc011be69cc2b39b5743f0d81986eaa80f"
795
795
  }
796
+ },
797
+ "v0.26.0": {
798
+ "github_tarball_sha256": "09903dc1c2bcf3c35ad0d52d1f5af2aa9f65584c07f508986dbf6829737d5eb2",
799
+ "github_zip_sha256": "e1aa5af189e9591f49aa1873c90cfb3365d3b3e86b4993866599498c21a0f979",
800
+ "tray_assets": {
801
+ "aarch64-apple-darwin": "1ee383b3ba75b9a19fee981d39ae8d26228e855933392ceb3d8fa55498da238b",
802
+ "aarch64-unknown-linux-musl": "832aa41132f900e446adaa8e23072094b6ba42d82315dac9ccd5f75ae623f172",
803
+ "x86_64-apple-darwin": "791898c594fcb2a0009653b9b50732698c0c451169cf1847a613a8d3e3ab3300",
804
+ "x86_64-pc-windows-msvc": "24ba2a8ed13de1bdc0401661a51c105c2c7ed186f3b17a871cd721ac8adf4916",
805
+ "x86_64-unknown-linux-musl": "200c8ab9465625784a32ae326d923bca97b5f9616809c43cb5dc73e0e5d9ca17"
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
+ }
796
818
  }
797
819
  }
798
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
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.0"
7
+ version = "0.26.2"
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
@@ -272,7 +272,7 @@ def _print_help(use_color=False):
272
272
  f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
273
273
  f" {_style('cdx last [--json]', '36', use_color)}",
274
274
  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)}",
275
+ f" {_style('cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
276
276
  f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
277
277
  f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
278
278
  f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",
@@ -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,
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
@@ -763,10 +763,8 @@ def handle_run(rest, ctx):
763
763
  # since nothing else frees their slot.
764
764
  _reconcile_background_runs(ctx)
765
765
  _run_registry(ctx).check_capacity(max_parallel)
766
- # Claude needs its conversation id before the spec is built; Codex
767
- # records its own afterwards, from the rollout. Without this a headless
768
- # Claude run left the session with no identity and `cdx resume` fell
769
- # back to recency for Claude while working by id for Codex.
766
+ # Fresh runs carry their own identity; only interactive launches update
767
+ # the profile's current conversation. Continuations use the parent run.
770
768
  if not parsed.get("continue_run"):
771
769
  session = ctx["service"]["begin_conversation"](session["name"])
772
770
  run_session = {
@@ -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,32 +142,260 @@ 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):
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.
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.
110
358
  Tool bodies and reasoning blocks are never returned. Ollama PTY captures
111
359
  cannot reliably distinguish roles and are explicitly unsupported.
112
360
  """
113
361
  if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
114
362
  raise CdxError("Conversation limit must be an integer between 1 and 1000.")
115
- page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False}
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}
373
+ page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False,
374
+ "resolution_method": None, "resolution_scope": None}
375
+ if tail is not None or before is not None:
376
+ page["before_cursor"] = None
116
377
  if provider not in PROVIDERS or provider == "ollama":
117
378
  return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
118
379
  # An explicit conversation must never fall back to an unrelated recent file.
119
380
  if conversation_id:
120
381
  path = conversation_transcript(provider, auth_home, conversation_id)
382
+ if path:
383
+ page["resolution_method"] = "conversation_id"
384
+ elif fallback_to_recency:
385
+ page["resolution_scope"] = scope
386
+ path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, cwd=scope["cwd"])
121
387
  else:
122
- path, _match = 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"])
123
390
  if not path:
124
- 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}
125
393
  try:
126
394
  with open(path, "rb") as handle:
127
395
  stat = os.fstat(handle.fileno())
128
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)
129
399
  offset, index, names = 0, 0, {}
130
400
  if cursor is not None:
131
401
  try:
@@ -152,14 +422,22 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
152
422
  start = handle.tell()
153
423
  line = handle.readline(MAX_LINE_BYTES + 1)
154
424
  if len(line) > MAX_LINE_BYTES:
155
- return {**page, "status": "unavailable", "reason": "record_too_large", "entries": []}
156
- if not line or not line.endswith(b"\n"):
157
- break
158
- scanned += len(line)
159
- try:
160
- entries = _entries(provider, json.loads(line), names)
161
- except (ValueError, UnicodeError):
162
- 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
163
441
  for block_index in range(index, len(entries)):
164
442
  entry = entries[block_index]
165
443
  entry["id"] = f"{identity[:16]}:{start}:{block_index}"
@@ -174,8 +452,7 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
174
452
  break
175
453
  offset, index = handle.tell(), 0
176
454
  page["has_more"] = offset < stat.st_size
177
- state = {"v": 1, "file": identity, "offset": offset, "index": index, "anchor": _anchor(handle, offset), "names": names}
178
- page["next_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
455
+ page["next_cursor"] = _encode_cursor(handle, identity, offset, index, names)
179
456
  return page
180
457
  except OSError:
181
458
  return {**page, "status": "unavailable", "reason": "transcript_unreadable", "entries": []}
@@ -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
@@ -689,12 +689,13 @@ def record_launch_history(store, name, payload):
689
689
  **payload,
690
690
  }
691
691
  store["append_launch_history"](entry)
692
- _observe_codex_conversation(store, session)
692
+ if entry.get("action") != "run":
693
+ _observe_codex_conversation(store, session)
693
694
  return entry
694
695
 
695
696
 
696
697
  def _observe_codex_conversation(store, session):
697
- """Read Codex's conversation id back after a run, if it wrote one.
698
+ """Read Codex's conversation id back after an interactive launch.
698
699
 
699
700
  Codex mints the id itself, so unlike Claude this can only be observed once
700
701
  the rollout exists. Failing to find one is normal and leaves the session on
@@ -938,26 +939,14 @@ def _conversation_identity_for_launch(session):
938
939
 
939
940
 
940
941
  def begin_conversation(store, name):
941
- """Mint the conversation id a Claude run is about to carry, and store it.
942
-
943
- `launch_session` does this for interactive launches, but a headless run
944
- never goes through it, so `cdx run` used to leave Claude sessions with no
945
- identity at all - `cdx resume` then fell back to recency for Claude while
946
- working by id for Codex, which records its own after the fact. Same shape
947
- for both providers now: whatever ran last is what resume names.
948
-
949
- Returns the updated session, or the unchanged one for providers that mint
950
- nothing here.
951
- """
942
+ """Return a run-local identity without changing the interactive session."""
952
943
  session = store["get_session"](name)
953
944
  if not session:
954
945
  raise CdxError(f"Unknown session: {name}")
955
946
  conversation = _conversation_identity_for_launch(session)
956
947
  if not conversation:
957
948
  return session
958
- return store["update_session"](name, lambda s: {
959
- **s, "conversation": conversation, "updatedAt": _local_now_iso(),
960
- })
949
+ return {**session, "conversation": conversation}
961
950
 
962
951
 
963
952
  def mark_conversation_rescued(store, name, conversation_id):