cdx-manager 0.26.2 → 0.26.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_3.md +22 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +7 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +11 -1
- package/docs/sessions-and-notifications.md +1 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +6 -1
- package/src/cli_args.py +5 -1
- package/src/commands/launch.py +17 -4
- package/src/commands/status.py +16 -3
- package/src/history_content.py +144 -0
- package/src/session_service.py +30 -3
- package/src/session_store.py +18 -0
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
|
|
|
@@ -65,6 +65,15 @@ row (`null` when absent). `cdx show` is an alias for `cdx config` and displays
|
|
|
65
65
|
|
|
66
66
|
## Cache efficiency and fix rounds
|
|
67
67
|
|
|
68
|
+
|
|
69
|
+
`cdx history --interactive --cwd /path/to/project --limit 50 --json` finds interactive launches without mission runs consuming the result window. `--cwd` compares resolved paths (including symlinks and `~`), exactly, without including child directories. Both filters apply before `--limit` and also work with profile names, period filters and `--summary`. `--interactive` excludes headless `run` entries, detached entries and labels starting with `mission:`; unlabelled legacy launches remain eligible.
|
|
70
|
+
|
|
71
|
+
Interactive launches appear as `status: "running"` as soon as the provider starts, including `cdx start <name> --dir PATH` (an alias for `cdx <name> --dir PATH`) inside tmux. Completion or failure updates the same run ID. Active runtimes from older supervisors are included while their process remains alive; vanished unrecorded launches cannot be reconstructed.
|
|
72
|
+
|
|
73
|
+
History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
|
|
74
|
+
|
|
75
|
+
`first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
|
|
76
|
+
|
|
68
77
|
`cdx history --json` records uncached input, cache reads, cache writes, output tokens, and `usage.cache_hit_ratio` beside the existing token totals. The ratio is cache reads divided by all input tokens, excluding output. Missing provider splits stay `null`, and text views show `cache n/d` instead of claiming a zero hit rate.
|
|
69
78
|
|
|
70
79
|
`cdx stats --json` adds `cache_usage` sums and an input-weighted `cache_hit_ratio` to each session, project (`--by project`), model (`--by model`), and total. `cache_usage` preserves unknown sums as `null`; existing numeric aggregate fields keep their previous behavior. Ratios include only runs with measured input and cache reads. Coverage is explicit in `measured_runs` and `measured_input_tokens`. `run_types.cold` and `run_types.continued` expose counts, sums, and ratios for comparison. Text views show values such as `cache 72 %` next to tokens.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.26.
|
|
1
|
+
0.26.3
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# CDX Manager 0.26.3
|
|
2
|
+
|
|
3
|
+
Release date: 2026-11-04
|
|
4
|
+
|
|
5
|
+
## Improvements
|
|
6
|
+
|
|
7
|
+
- Make interactive session history findable and resumable (#110,
|
|
8
|
+
`5abd37830095ee3519bd130ac6d80da143b29e27`). Filter `cdx history` by resolved
|
|
9
|
+
working directory (`--cwd PATH`) and interactive launches (`--interactive`)
|
|
10
|
+
before applying the result limit. Record launches immediately and update the
|
|
11
|
+
same history row on exit so active sessions remain visible without duplicates.
|
|
12
|
+
- Support `cdx start <name>` through the named launch path, including tmux.
|
|
13
|
+
Prefer exact Claude session IDs and unique project/time-scoped Codex transcript
|
|
14
|
+
metadata; mark ambiguous recency matches as unverified. Recover clean, redacted
|
|
15
|
+
first-user prompts past provider wrappers, reminders, tools and bare file paths.
|
|
16
|
+
|
|
17
|
+
## Maintenance
|
|
18
|
+
|
|
19
|
+
- Record tray companion checksums for v0.26.2
|
|
20
|
+
(`54f201a2886f5293658f214e5725d60426dcfb28`).
|
|
21
|
+
- Record release archive checksums for v0.26.2
|
|
22
|
+
(`3bc40eaada4b401e222bf599335b8b3d4dc80326`).
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Unreleased
|
|
2
2
|
|
|
3
|
+
## Find and resume interactive sessions
|
|
4
|
+
|
|
5
|
+
- Add exact resolved `cdx history --cwd PATH` and `--interactive` filters before the result limit, preserving the JSON envelope and period/summary support.
|
|
6
|
+
- Record interactive launches immediately, update the same row on exit, expose active older runtimes and support `cdx start <name>` through the named launch path, including tmux.
|
|
7
|
+
- Prefer exact Claude session IDs and unique project/time-scoped Codex transcript metadata; expose ambiguous recency as unverified. Recover clean, redacted `first_user_prompt` text past provider wrappers, reminders, tools and bare file paths.
|
|
8
|
+
- Cover filter ordering, live history, completion/failure deduplication, legacy launch arguments, transcript identity ambiguity, missing evidence and prompt cleanup.
|
|
9
|
+
|
|
3
10
|
## Conversation tail pagination (planned for 0.26.2)
|
|
4
11
|
|
|
5
12
|
- Add `cdx conversation --tail N` (1–1000), chronological backward pages via `before_cursor` and `--before`, and a compatible `next_cursor` for live polling. Read JSONL backwards in bounded 64 KiB blocks, preserve normalization, redaction, stable IDs and oversized-record skipping, and reject stale backward cursors.
|
|
@@ -815,6 +815,17 @@
|
|
|
815
815
|
"x86_64-pc-windows-msvc": "07cf6f66d58320494833107cf369b66cdf171649c8128d6463028a9aa7058494",
|
|
816
816
|
"x86_64-unknown-linux-musl": "e18869d50747b1eb7924005554e11154f04a27363b8b3148f7ae0a1042d6ca7a"
|
|
817
817
|
}
|
|
818
|
+
},
|
|
819
|
+
"v0.26.2": {
|
|
820
|
+
"github_tarball_sha256": "c85ae3c9158a933eb151b6f74563da6fafb8160e01091e925224678bae7bc394",
|
|
821
|
+
"github_zip_sha256": "d6b70ab53b617fb364e6704684421422cbc13cabd5ba15706fac8f543eea2721",
|
|
822
|
+
"tray_assets": {
|
|
823
|
+
"aarch64-apple-darwin": "8d1089505821ee59b9b0f49ef2fdc63b081bafe468add02b83e84d9939cd2be0",
|
|
824
|
+
"aarch64-unknown-linux-musl": "816a48a84d99e84403a11eea76214c8a56301919b13556fd1ffe7e1459092fd1",
|
|
825
|
+
"x86_64-apple-darwin": "38d028177b64f616e72a81d52a451ba174f434757e2e503ba196e653b08ae6c3",
|
|
826
|
+
"x86_64-pc-windows-msvc": "7846a4b56ec3006428b9778cbee3e0c1fabea8f279ca9183cf34abb460bb4c29",
|
|
827
|
+
"x86_64-unknown-linux-musl": "d761aa0bbda8661c842f0b18e438c047ec023d30ed733d0044d1663ddd480355"
|
|
828
|
+
}
|
|
818
829
|
}
|
|
819
830
|
}
|
|
820
831
|
}
|
package/docs/cli.md
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
| `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
|
|
29
29
|
| `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--notify on\|off] [--agent-state on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
|
|
30
30
|
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--agent-state\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
31
|
-
| `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
31
|
+
| `cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
32
32
|
| `cdx history backfill-projects [--mapping FILE] [--dry-run \| --apply] [--json]` | Recover source repository attribution from exact recorded run evidence or an operator mapping; defaults to a read-only preview |
|
|
33
33
|
| `cdx history backfill-models [--dry-run \| --apply] [--json]` | Recover missing historical usage models from evidence tied to each run; defaults to a read-only preview |
|
|
34
34
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
@@ -673,3 +673,13 @@ shows the before/after versions, including major upgrades. JSON keeps the
|
|
|
673
673
|
pre-update plan and existing result fields; tool verification adds
|
|
674
674
|
`version_before`, `version_after`, and `latest_version` to successful-command
|
|
675
675
|
results, plus `reason` and a nonzero `returncode` if verification fails.
|
|
676
|
+
|
|
677
|
+
## Find interactive conversations
|
|
678
|
+
|
|
679
|
+
`cdx history --interactive --cwd /path/to/project --limit 50 --json` finds interactive launches without mission runs consuming the result window. `--cwd` compares resolved paths (including symlinks and `~`), exactly, without including child directories. Both filters apply before `--limit` and also work with profile names, period filters and `--summary`. `--interactive` excludes headless `run` entries, detached entries and labels starting with `mission:`; unlabelled legacy launches remain eligible.
|
|
680
|
+
|
|
681
|
+
Interactive launches appear as `status: "running"` as soon as the provider starts, including `cdx start <name> --dir PATH` (an alias for `cdx <name> --dir PATH`) inside tmux. Completion or failure updates the same run ID. Active runtimes from older supervisors are included while their process remains alive; vanished unrecorded launches cannot be reconstructed.
|
|
682
|
+
|
|
683
|
+
History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
|
|
684
|
+
|
|
685
|
+
`first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
|
|
@@ -172,6 +172,7 @@ Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including su
|
|
|
172
172
|
cdx history
|
|
173
173
|
cdx history work
|
|
174
174
|
cdx history work --limit 5
|
|
175
|
+
cdx history --interactive --cwd ~/projects/example --limit 50 --json
|
|
175
176
|
cdx history work --json
|
|
176
177
|
cdx history --summary
|
|
177
178
|
cdx history --summary --since 7d
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -267,8 +267,9 @@ def _print_help(use_color=False):
|
|
|
267
267
|
f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
|
|
268
268
|
f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
|
|
269
269
|
f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--max-parallel|--all) [--json]', '36', use_color)}",
|
|
270
|
+
f" {_style('cdx start <name> [--dir PATH] [--resume] [--json]', '36', use_color)}",
|
|
270
271
|
f" {_style('cdx history backfill-projects [--mapping FILE] [--dry-run|--apply] [--json]', '36', use_color)}",
|
|
271
|
-
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
272
|
+
f" {_style('cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
272
273
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
|
|
273
274
|
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
274
275
|
f" {_style('cdx resume <name> [--project PATH] [--conversation <id>] [--json]', '36', use_color)}",
|
|
@@ -651,6 +652,10 @@ def main(argv, options=None):
|
|
|
651
652
|
if handler:
|
|
652
653
|
return handler(rest, ctx)
|
|
653
654
|
|
|
655
|
+
if command == "start":
|
|
656
|
+
if not rest or rest[0].startswith("-"):
|
|
657
|
+
raise CdxError("Usage: cdx start <name> [--dir PATH] [--resume] [--json]")
|
|
658
|
+
command, *rest = rest
|
|
654
659
|
from .commands.launch import _parse_launch_args, _pop_value_flag, _read_launch_prompt
|
|
655
660
|
project_path, rest = _pop_value_flag(rest, "--project", "Usage: cdx <name> [--project PATH]")
|
|
656
661
|
directory, launch_args, prompt, on_busy = _parse_launch_args(rest)
|
package/src/cli_args.py
CHANGED
|
@@ -40,7 +40,7 @@ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (-
|
|
|
40
40
|
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
41
41
|
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
42
42
|
CONFIGS_USAGE = "Usage: cdx configs [--json]"
|
|
43
|
-
HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
43
|
+
HISTORY_USAGE = "Usage: cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
44
44
|
STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]"
|
|
45
45
|
STATS_GROUPS = ("session", "project", "model")
|
|
46
46
|
LAST_USAGE = "Usage: cdx last [--json]"
|
|
@@ -916,6 +916,8 @@ def _parse_history_args(args, now=None):
|
|
|
916
916
|
now = now or datetime.now().astimezone()
|
|
917
917
|
parsed = _parse_flag_args(args, {
|
|
918
918
|
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
919
|
+
"--cwd": {"key": "cwd", "type": "str", "default": None},
|
|
920
|
+
"--interactive": {"key": "interactive", "type": "bool", "default": False},
|
|
919
921
|
"--summary": {"key": "summary", "type": "bool", "default": False},
|
|
920
922
|
"--since": {"key": "since", "type": "str", "default": None},
|
|
921
923
|
"--from": {"key": "from", "type": "str", "default": None},
|
|
@@ -926,6 +928,8 @@ def _parse_history_args(args, now=None):
|
|
|
926
928
|
return {
|
|
927
929
|
"name": parsed["names"][0] if parsed["names"] else None,
|
|
928
930
|
"limit": parsed["limit"],
|
|
931
|
+
"cwd": parsed["cwd"],
|
|
932
|
+
"interactive": parsed["interactive"],
|
|
929
933
|
"summary": parsed["summary"],
|
|
930
934
|
"period": period,
|
|
931
935
|
"json": parsed["json"],
|
package/src/commands/launch.py
CHANGED
|
@@ -334,8 +334,15 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
334
334
|
def runtime_lifecycle(event, info):
|
|
335
335
|
nonlocal runtime_run_id
|
|
336
336
|
if event == "started":
|
|
337
|
-
runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd, **project})
|
|
337
|
+
runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd, **project, "runId": runtime_run_id})
|
|
338
338
|
runtime_run_id = runtime.get("runId")
|
|
339
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
340
|
+
**info, **project, "run_id": runtime_run_id, "cwd": cwd,
|
|
341
|
+
"action": "resume" if resume else "launch", "status": "running",
|
|
342
|
+
"ended_at": None, "duration_ms": None,
|
|
343
|
+
"conversation_id": _conversation_id(session) if resume or session["provider"] == PROVIDER_CLAUDE else None,
|
|
344
|
+
"first_user_prompt": initial_prompt,
|
|
345
|
+
})
|
|
339
346
|
elif event == "finished" and runtime_run_id:
|
|
340
347
|
ctx["service"]["finish_session_runtime"](
|
|
341
348
|
session["name"],
|
|
@@ -367,7 +374,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
367
374
|
except CdxError as error:
|
|
368
375
|
run_info = getattr(error, "run_info", {}) or {}
|
|
369
376
|
run_info = _attach_interactive_usage(
|
|
370
|
-
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
|
|
377
|
+
session, {**run_info, "cwd": cwd}, ctx["service"]["get_launch_history"](session["name"], limit=None),
|
|
371
378
|
baseline=(baseline_path, baseline),
|
|
372
379
|
uncertain_reason=overlap_reason(),
|
|
373
380
|
)
|
|
@@ -382,6 +389,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
382
389
|
if run_info:
|
|
383
390
|
ctx["service"]["record_launch_history"](session["name"], {
|
|
384
391
|
"status": "failed",
|
|
392
|
+
"action": "resume" if resume else "launch",
|
|
385
393
|
"cwd": cwd,
|
|
386
394
|
"error": str(error),
|
|
387
395
|
"exit_code": error.exit_code,
|
|
@@ -391,7 +399,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
391
399
|
})
|
|
392
400
|
raise
|
|
393
401
|
success_run_info = _attach_interactive_usage(
|
|
394
|
-
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
|
|
402
|
+
session, {**run_info, "cwd": cwd}, ctx["service"]["get_launch_history"](session["name"], limit=None),
|
|
395
403
|
baseline=(baseline_path, baseline),
|
|
396
404
|
uncertain_reason=overlap_reason(),
|
|
397
405
|
)
|
|
@@ -452,9 +460,14 @@ def _attach_interactive_usage(session, run_info, history=None, baseline=None, un
|
|
|
452
460
|
recorded nothing would break the chain for every run after it.
|
|
453
461
|
"""
|
|
454
462
|
run_info = dict(run_info or {})
|
|
463
|
+
from ..history_content import enrich_history
|
|
464
|
+
|
|
465
|
+
if session.get("provider") == PROVIDER_CLAUDE:
|
|
466
|
+
run_info.setdefault("conversation_id", _conversation_id(session))
|
|
467
|
+
run_info = enrich_history({**run_info, "provider": session.get("provider")}, session)
|
|
455
468
|
started_at = run_info.get("started_at")
|
|
456
469
|
cumulative, provider_transcript, match, model = extract_interactive_usage(
|
|
457
|
-
session.get("provider"), _get_auth_home(session), started_at, _conversation_id(session),
|
|
470
|
+
session.get("provider"), _get_auth_home(session), started_at, run_info.get("conversation_id") or _conversation_id(session),
|
|
458
471
|
# Ollama writes no provider transcript; its only record is the PTY
|
|
459
472
|
# capture this run already took.
|
|
460
473
|
run_info.get("transcript_path"),
|
package/src/commands/status.py
CHANGED
|
@@ -695,16 +695,29 @@ def handle_history(rest, ctx):
|
|
|
695
695
|
now = datetime.fromtimestamp(now_fn()).astimezone()
|
|
696
696
|
parsed = _parse_history_args(rest, now=now)
|
|
697
697
|
has_period = _has_history_period(parsed["period"])
|
|
698
|
-
|
|
698
|
+
filtered = has_period or parsed["cwd"] is not None or parsed["interactive"]
|
|
699
|
+
limit = 0 if parsed["summary"] or filtered else parsed["limit"]
|
|
699
700
|
entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
|
|
700
701
|
entries = _filter_history_period(entries, parsed["period"])
|
|
702
|
+
if parsed["cwd"] is not None:
|
|
703
|
+
cwd = os.path.realpath(os.path.expanduser(parsed["cwd"]))
|
|
704
|
+
entries = [entry for entry in entries if entry.get("cwd")
|
|
705
|
+
and os.path.realpath(os.path.expanduser(entry["cwd"])) == cwd]
|
|
706
|
+
if parsed["interactive"]:
|
|
707
|
+
entries = [entry for entry in entries if entry.get("action") != "run"
|
|
708
|
+
and not entry.get("detached") and not entry.get("detach")
|
|
709
|
+
and not str(entry.get("label") or "").startswith("mission:")
|
|
710
|
+
and "--detach" not in (entry.get("args") or [])]
|
|
711
|
+
if filtered and not parsed["summary"]:
|
|
712
|
+
entries = entries[:parsed["limit"]]
|
|
713
|
+
from ..history_content import enrich_history
|
|
714
|
+
|
|
715
|
+
entries = [enrich_history(entry, ctx["service"]["get_session"](entry.get("session_name")) or {}) for entry in entries]
|
|
701
716
|
entries = [{**entry, "parent_run_id": entry.get("parent_run_id"),
|
|
702
717
|
**cost_fields(entry.get("usage") if is_vouched(entry.get("usage")) else None, entry.get("usage_model"),
|
|
703
718
|
models=entry.get("usage_models", (entry.get("usage_evidence") or {}).get("models", [])), attribution=entry.get("usage_attribution")),
|
|
704
719
|
"usage": {**entry["usage"], "cache_hit_ratio": cache_hit_ratio(entry["usage"], provider=entry.get("provider")) if is_vouched(entry["usage"]) else None}
|
|
705
720
|
if isinstance(entry.get("usage"), dict) else entry.get("usage")} for entry in entries]
|
|
706
|
-
if has_period and not parsed["summary"]:
|
|
707
|
-
entries = entries[:parsed["limit"]]
|
|
708
721
|
message = (
|
|
709
722
|
f"Listed launch history for {parsed['name']}"
|
|
710
723
|
if parsed["name"]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Bounded provider evidence for resumable interactive history rows."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .conversation import MAX_LINE_BYTES, MAX_PAGE_BYTES, _entries, _redact
|
|
10
|
+
from .interactive_usage import (
|
|
11
|
+
MAX_TRANSCRIPT_CANDIDATES,
|
|
12
|
+
MAX_TRANSCRIPT_SCAN_SECONDS,
|
|
13
|
+
_timestamp,
|
|
14
|
+
conversation_transcript,
|
|
15
|
+
resolve_transcript,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_WRAPPERS = re.compile(
|
|
19
|
+
r"<(?P<tag>local-command-caveat|command-name|command-message|command-args|local-command-stdout|"
|
|
20
|
+
r"local-command-stderr|system-reminder|environment_context|instructions)>.*?</(?P=tag)>", re.S,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _identity_arg(args):
|
|
25
|
+
args = list(args or [])
|
|
26
|
+
if "-c" in args:
|
|
27
|
+
index = args.index("-c") + 1
|
|
28
|
+
try:
|
|
29
|
+
args = shlex.split(args[index])
|
|
30
|
+
except (IndexError, ValueError, TypeError):
|
|
31
|
+
return None
|
|
32
|
+
for index, arg in enumerate(args):
|
|
33
|
+
if arg == "--":
|
|
34
|
+
break
|
|
35
|
+
if arg in ("--session-id", "--resume", "resume") and index + 1 < len(args):
|
|
36
|
+
identifier = args[index + 1]
|
|
37
|
+
if isinstance(identifier, str) and re.fullmatch(r"[0-9a-fA-F-]{36}", identifier):
|
|
38
|
+
return identifier
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clean_prompt(text):
|
|
43
|
+
text = _WRAPPERS.sub("", text).strip()
|
|
44
|
+
lines = [line for line in text.splitlines() if not re.fullmatch(
|
|
45
|
+
r"\s*(?:(?:/|~/|[A-Za-z]:[\\/]|\.\.?[/\\])\S+|[\w./\\-]+\.[A-Za-z0-9]{1,10})\s*", line)]
|
|
46
|
+
return _redact("\n".join(lines).strip())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def first_user_prompt(provider, path):
|
|
50
|
+
if not path:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
with open(path, "rb") as handle:
|
|
54
|
+
scanned = 0
|
|
55
|
+
names = {}
|
|
56
|
+
while scanned < MAX_PAGE_BYTES:
|
|
57
|
+
line = handle.readline(MAX_LINE_BYTES + 1)
|
|
58
|
+
if not line:
|
|
59
|
+
break
|
|
60
|
+
scanned += len(line)
|
|
61
|
+
if len(line) > MAX_LINE_BYTES:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
record = json.loads(line)
|
|
65
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
66
|
+
continue
|
|
67
|
+
texts = [clean_prompt(entry["text"]) for entry in _entries(provider, record, names)
|
|
68
|
+
if entry["role"] == "user"]
|
|
69
|
+
text = "\n".join(text for text in texts if text)
|
|
70
|
+
if text:
|
|
71
|
+
return text[:2000]
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _new_codex_id(auth_home, entry):
|
|
78
|
+
"""Only a unique rollout created in this launch's project/time can identify it."""
|
|
79
|
+
start = _timestamp(entry.get("started_at"))
|
|
80
|
+
end = _timestamp(entry.get("ended_at"))
|
|
81
|
+
cwd = entry.get("cwd")
|
|
82
|
+
if start is None or not cwd:
|
|
83
|
+
return None
|
|
84
|
+
deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
|
|
85
|
+
found = set()
|
|
86
|
+
count = 0
|
|
87
|
+
for directory, _, names in os.walk(os.path.join(auth_home, "sessions")):
|
|
88
|
+
if time.monotonic() >= deadline:
|
|
89
|
+
return None
|
|
90
|
+
for name in names:
|
|
91
|
+
if time.monotonic() >= deadline:
|
|
92
|
+
return None
|
|
93
|
+
if not name.endswith(".jsonl"):
|
|
94
|
+
continue
|
|
95
|
+
count += 1
|
|
96
|
+
if count > MAX_TRANSCRIPT_CANDIDATES:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
with open(os.path.join(directory, name), encoding="utf-8") as handle:
|
|
100
|
+
record = json.loads(handle.readline(64 * 1024))
|
|
101
|
+
payload = record.get("payload") or {}
|
|
102
|
+
stamp = _timestamp(record.get("timestamp") or payload.get("timestamp"))
|
|
103
|
+
identifier = payload.get("id") or payload.get("session_id")
|
|
104
|
+
if (record.get("type") == "session_meta" and stamp is not None and stamp >= start
|
|
105
|
+
and (end is None or stamp <= end) and isinstance(payload.get("cwd"), str)
|
|
106
|
+
and os.path.realpath(payload["cwd"]) == os.path.realpath(cwd)
|
|
107
|
+
and isinstance(identifier, str) and conversation_transcript("codex", auth_home, identifier)):
|
|
108
|
+
found.add(identifier)
|
|
109
|
+
except (OSError, ValueError, TypeError, AttributeError, RecursionError):
|
|
110
|
+
continue
|
|
111
|
+
return next(iter(found)) if len(found) == 1 else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def enrich_history(entry, session):
|
|
115
|
+
entry = dict(entry)
|
|
116
|
+
provider = entry.get("provider") or session.get("provider")
|
|
117
|
+
home = session.get("authHome")
|
|
118
|
+
if provider not in ("claude", "codex") or not home:
|
|
119
|
+
return entry
|
|
120
|
+
identifier = entry.get("conversation_id")
|
|
121
|
+
if not identifier:
|
|
122
|
+
identifier = _identity_arg(entry.get("args")) if entry.get("action") != "run" else None
|
|
123
|
+
if not identifier and provider == "codex" and entry.get("action") != "run":
|
|
124
|
+
identifier = _new_codex_id(home, entry)
|
|
125
|
+
path = conversation_transcript(provider, home, identifier) if identifier else None
|
|
126
|
+
if path:
|
|
127
|
+
entry.update(conversation_id=identifier, provider_transcript_path=path,
|
|
128
|
+
provider_transcript_match="conversation_id")
|
|
129
|
+
elif identifier:
|
|
130
|
+
# A known identity whose transcript is absent must never target another session.
|
|
131
|
+
entry["conversation_id"] = identifier
|
|
132
|
+
path = None
|
|
133
|
+
else:
|
|
134
|
+
path = entry.get("provider_transcript_path")
|
|
135
|
+
if not path and entry.get("action") != "run":
|
|
136
|
+
path, _ = resolve_transcript(provider, home, entry.get("started_at"), cwd=entry.get("cwd"))
|
|
137
|
+
if path and entry.get("action") != "run":
|
|
138
|
+
entry.update(provider_transcript_path=path, provider_transcript_match="recency")
|
|
139
|
+
prompt = first_user_prompt(provider, path)
|
|
140
|
+
if prompt:
|
|
141
|
+
entry["first_user_prompt"] = prompt
|
|
142
|
+
elif entry.get("first_user_prompt"):
|
|
143
|
+
entry["first_user_prompt"] = clean_prompt(entry["first_user_prompt"]) or None
|
|
144
|
+
return entry
|
package/src/session_service.py
CHANGED
|
@@ -688,9 +688,18 @@ def record_launch_history(store, name, payload):
|
|
|
688
688
|
"launch": session.get("launch") or {},
|
|
689
689
|
**payload,
|
|
690
690
|
}
|
|
691
|
-
|
|
691
|
+
from .history_content import enrich_history
|
|
692
|
+
|
|
692
693
|
if entry.get("action") != "run":
|
|
693
|
-
|
|
694
|
+
entry = enrich_history(entry, session)
|
|
695
|
+
store["append_launch_history"](entry)
|
|
696
|
+
if entry.get("action") != "run" and entry.get("status") != "running":
|
|
697
|
+
if session["provider"] == PROVIDER_CODEX and entry.get("provider_transcript_match") == "conversation_id":
|
|
698
|
+
store["update_session"](name, lambda s: {
|
|
699
|
+
**s, "conversation": {"id": entry["conversation_id"], "provenance": "observed", "recordedAt": _local_now_iso()},
|
|
700
|
+
})
|
|
701
|
+
elif entry.get("action") not in ("launch", "resume"):
|
|
702
|
+
_observe_codex_conversation(store, session)
|
|
694
703
|
return entry
|
|
695
704
|
|
|
696
705
|
|
|
@@ -726,7 +735,25 @@ def _observe_codex_conversation(store, session):
|
|
|
726
735
|
def get_launch_history(store, name=None, limit=20):
|
|
727
736
|
if name and not store["get_session"](name):
|
|
728
737
|
raise CdxError(f"Unknown session: {name}")
|
|
729
|
-
|
|
738
|
+
entries = store["list_launch_history"](session_name=name, limit=0)
|
|
739
|
+
known = {entry.get("run_id") for entry in entries if entry.get("run_id")}
|
|
740
|
+
# Older supervisors only wrote history on exit. Expose their active runtime
|
|
741
|
+
# without inventing completed history or duplicating newly recorded launches.
|
|
742
|
+
sessions = [store["get_session"](name)] if name else store["list_sessions"]()
|
|
743
|
+
for session in sessions:
|
|
744
|
+
runtime = _session_runtime(store, session["name"])
|
|
745
|
+
if not runtime or not runtime.get("runId") or runtime["runId"] in known:
|
|
746
|
+
continue
|
|
747
|
+
entries.insert(0, {
|
|
748
|
+
"schema_version": 1, "session_name": session["name"], "provider": session["provider"],
|
|
749
|
+
"launch": session.get("launch") or {}, "action": "launch", "status": "running",
|
|
750
|
+
"run_id": runtime["runId"], "started_at": runtime.get("startedAt"),
|
|
751
|
+
"cwd": runtime.get("cwd"), "pid": runtime.get("pid"), "label": runtime.get("label"),
|
|
752
|
+
"project": runtime.get("project"), "project_source": runtime.get("project_source", "unknown"),
|
|
753
|
+
"transcript_path": runtime.get("transcriptPath"),
|
|
754
|
+
"conversation_id": (session.get("conversation") or {}).get("id") if session["provider"] == PROVIDER_CLAUDE else None,
|
|
755
|
+
})
|
|
756
|
+
return entries[:limit] if limit else entries
|
|
730
757
|
|
|
731
758
|
|
|
732
759
|
def backfill_interactive_usage(store, name=None, dry_run=True):
|
package/src/session_store.py
CHANGED
|
@@ -253,6 +253,24 @@ def create_session_store(base_dir):
|
|
|
253
253
|
def append_launch_history(entry):
|
|
254
254
|
with _file_lock(lock_file):
|
|
255
255
|
_ensure_dir(state_dir)
|
|
256
|
+
if entry.get("run_id") and entry.get("action") in ("launch", "resume"):
|
|
257
|
+
try:
|
|
258
|
+
with open(launch_history_file, encoding="utf-8") as handle:
|
|
259
|
+
lines = handle.readlines()
|
|
260
|
+
except FileNotFoundError:
|
|
261
|
+
lines = []
|
|
262
|
+
for index, line in enumerate(lines):
|
|
263
|
+
try:
|
|
264
|
+
previous = json.loads(line)
|
|
265
|
+
except ValueError:
|
|
266
|
+
continue
|
|
267
|
+
if (previous.get("run_id") == entry["run_id"]
|
|
268
|
+
and previous.get("session_name") == entry.get("session_name")
|
|
269
|
+
and previous.get("action") in ("launch", "resume")):
|
|
270
|
+
lines[index] = json.dumps({**previous, **entry}, separators=(",", ":")) + "\n"
|
|
271
|
+
atomic_write(launch_history_file, "".join(lines))
|
|
272
|
+
_fsync_directory(state_dir)
|
|
273
|
+
return
|
|
256
274
|
with open(launch_history_file, "a", encoding="utf-8") as handle:
|
|
257
275
|
handle.write(json.dumps(entry, separators=(",", ":")))
|
|
258
276
|
handle.write("\n")
|