cdx-manager 0.26.1 → 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.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.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
 
@@ -246,41 +246,94 @@ for a fresh credential, and that cleanup works after disconnect and SIGTERM.
246
246
 
247
247
  ### Read an interactive conversation
248
248
 
249
- `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`
250
250
  returns a read-only JSON page for the profile's current conversation. An explicit
251
251
  conversation ID uses an exact transcript lookup and never falls back. When the
252
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 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
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
256
264
  `conversation_id` for an exact match, `recency` for a local fallback, or `null`
257
- when no transcript is resolved.
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`.
258
270
 
259
271
  The normal cdx JSON envelope includes `status`, `reason`, `provider`, `session`,
260
272
  `entries`, `next_cursor`, and `has_more`. Each entry has exactly `role`
261
273
  (`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
274
+ `id`. Missing values are `null`; normal tool text is empty. Entries follow file and
263
275
  content-block order, with stable IDs for the same file position. Timestamps are
264
276
  provider timestamps when valid. Tool calls/results have a name and a short
265
277
  summary; arguments, results, attachments, and reasoning blocks are omitted.
266
- 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.
267
281
  Common credential patterns and secret assignments in text are redacted without
268
282
  opening credential stores. Review conversation text before sharing: arbitrary
269
283
  unlabelled sensitive prose cannot be identified automatically.
270
284
 
271
285
  Pass `next_cursor` unchanged as `--cursor` to fetch the next page or poll for new
272
286
  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
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
275
290
  timer: `has_more` means unread bytes, which can include an incomplete final line
276
291
  that will only become readable after the provider appends its newline.
277
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
+
278
321
  Codex response items and Claude-compatible JSONL transcripts (Claude and
279
- 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
280
326
  unreadable files return `status: "unavailable"` with a reason. Ollama's terminal
281
327
  capture does not reliably identify message roles and returns
282
328
  `status: "unsupported"`, including when no capture exists. A transcript record
283
- 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
284
337
  cursor returns `reset_required`; discard the cursor and reload the conversation.
285
338
  Cursors are opaque, versioned, and bound to a provider and file. Treat transcripts
286
339
  as append-only; reset the client if a provider rewrites earlier history.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.1
1
+ 0.26.2
@@ -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,17 @@
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
+
3
15
  ## Preserve interactive conversations across headless runs
4
16
 
5
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.
@@ -804,6 +804,17 @@
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
+ }
807
818
  }
808
819
  }
809
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.1",
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.1"
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, 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
@@ -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": []}
@@ -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