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 +72 -10
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_1.md +21 -0
- package/changelogs/CHANGELOGS_0_26_2.md +20 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +18 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/commands/conversation.py +8 -2
- package/src/commands/runs.py +2 -4
- package/src/conversation.py +294 -17
- package/src/interactive_usage.py +24 -4
- package/src/session_service.py +5 -16
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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
|
|
248
|
-
|
|
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
|
|
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
|
|
265
|
-
per call
|
|
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.
|
|
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
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
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
|
-
|
|
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
|
package/src/commands/runs.py
CHANGED
|
@@ -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
|
-
#
|
|
767
|
-
#
|
|
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 = {
|
package/src/conversation.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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": []}
|
package/src/interactive_usage.py
CHANGED
|
@@ -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
|
|
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
|
package/src/session_service.py
CHANGED
|
@@ -689,12 +689,13 @@ def record_launch_history(store, name, payload):
|
|
|
689
689
|
**payload,
|
|
690
690
|
}
|
|
691
691
|
store["append_launch_history"](entry)
|
|
692
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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):
|