cdx-manager 0.26.2 → 0.26.4

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.2-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.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
@@ -65,6 +65,15 @@ row (`null` when absent). `cdx show` is an alias for `cdx config` and displays
65
65
 
66
66
  ## Cache efficiency and fix rounds
67
67
 
68
+
69
+ `cdx history --interactive --cwd /path/to/project --limit 50 --json` finds interactive launches without mission runs consuming the result window. `--cwd` compares resolved paths (including symlinks and `~`), exactly, without including child directories. Both filters apply before `--limit` and also work with profile names, period filters and `--summary`. `--interactive` excludes headless `run` entries, detached entries and labels starting with `mission:`; unlabelled legacy launches remain eligible.
70
+
71
+ Interactive launches appear as `status: "running"` as soon as the provider starts, including `cdx start <name> --dir PATH` (an alias for `cdx <name> --dir PATH`) inside tmux. Completion or failure updates the same run ID. Active runtimes from older supervisors are included while their process remains alive; vanished unrecorded launches cannot be reconstructed.
72
+
73
+ History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
74
+
75
+ `first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
76
+
68
77
  `cdx history --json` records uncached input, cache reads, cache writes, output tokens, and `usage.cache_hit_ratio` beside the existing token totals. The ratio is cache reads divided by all input tokens, excluding output. Missing provider splits stay `null`, and text views show `cache n/d` instead of claiming a zero hit rate.
69
78
 
70
79
  `cdx stats --json` adds `cache_usage` sums and an input-weighted `cache_hit_ratio` to each session, project (`--by project`), model (`--by model`), and total. `cache_usage` preserves unknown sums as `null`; existing numeric aggregate fields keep their previous behavior. Ratios include only runs with measured input and cache reads. Coverage is explicit in `measured_runs` and `measured_input_tokens`. `run_types.cold` and `run_types.continued` expose counts, sums, and ratios for comparison. Text views show values such as `cache 72 %` next to tokens.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.2
1
+ 0.26.4
@@ -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`).
@@ -0,0 +1,17 @@
1
+ # CDX Manager 0.26.4
2
+
3
+ Release date: 2026-11-05
4
+
5
+ ## Fixes
6
+
7
+ - Show Claude messages typed while the agent is working in conversation pages
8
+ (#112, `7baa22a4604317b5c6fffa1eb8ddb7c8726c283d`). Render human queued
9
+ messages as user entries, including text accompanying pasted images and
10
+ oversized attachments, while keeping image bodies out of conversation output.
11
+
12
+ ## Maintenance
13
+
14
+ - Record tray companion checksums for v0.26.3
15
+ (`c3e861fb6a216ec0c721cff8bb07043921891457`).
16
+ - Record release archive checksums for v0.26.3
17
+ (`da760da0f3b859b017a68d2f4bdde71c38ca883f`).
@@ -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,28 @@
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
+ }
829
+ },
830
+ "v0.26.3": {
831
+ "github_tarball_sha256": "19f55a4b7ccc2de8a05aeee23039749f782214460368de5ce2aa90ee051c5c7e",
832
+ "github_zip_sha256": "67042371673a27de37f6accd5c4cea9128d4a2a8bdd382173a56ffb3bbb34e53",
833
+ "tray_assets": {
834
+ "aarch64-apple-darwin": "520b94d699dce57b381b6efb16f6afd5bf162218437bdaedfe3c152cbf95f42d",
835
+ "aarch64-unknown-linux-musl": "fdd710a5eeba7fbc545518b011f81acbb72087e201c8c577c2eff629bb2130ff",
836
+ "x86_64-apple-darwin": "082c8ea57b34295f24ecbad701d81df37860f25f645f1c3c59382407ff94e865",
837
+ "x86_64-pc-windows-msvc": "7b9d9fc24dd5d4285a5cac7e746614654deaebe197ab38fa6e1c302da34e0358",
838
+ "x86_64-unknown-linux-musl": "51ea6902917697f5d16ef994bb01d0d0c6293b60c8f26b3735cb08925b3af616"
839
+ }
818
840
  }
819
841
  }
820
842
  }
package/docs/cli.md CHANGED
@@ -28,7 +28,7 @@
28
28
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
29
29
  | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--notify on\|off] [--agent-state on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
30
30
  | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--agent-state\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
31
- | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
31
+ | `cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
32
32
  | `cdx history backfill-projects [--mapping FILE] [--dry-run \| --apply] [--json]` | Recover source repository attribution from exact recorded run evidence or an operator mapping; defaults to a read-only preview |
33
33
  | `cdx history backfill-models [--dry-run \| --apply] [--json]` | Recover missing historical usage models from evidence tied to each run; defaults to a read-only preview |
34
34
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
@@ -673,3 +673,13 @@ shows the before/after versions, including major upgrades. JSON keeps the
673
673
  pre-update plan and existing result fields; tool verification adds
674
674
  `version_before`, `version_after`, and `latest_version` to successful-command
675
675
  results, plus `reason` and a nonzero `returncode` if verification fails.
676
+
677
+ ## Find interactive conversations
678
+
679
+ `cdx history --interactive --cwd /path/to/project --limit 50 --json` finds interactive launches without mission runs consuming the result window. `--cwd` compares resolved paths (including symlinks and `~`), exactly, without including child directories. Both filters apply before `--limit` and also work with profile names, period filters and `--summary`. `--interactive` excludes headless `run` entries, detached entries and labels starting with `mission:`; unlabelled legacy launches remain eligible.
680
+
681
+ Interactive launches appear as `status: "running"` as soon as the provider starts, including `cdx start <name> --dir PATH` (an alias for `cdx <name> --dir PATH`) inside tmux. Completion or failure updates the same run ID. Active runtimes from older supervisors are included while their process remains alive; vanished unrecorded launches cannot be reconstructed.
682
+
683
+ History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
684
+
685
+ `first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
@@ -172,6 +172,7 @@ Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including su
172
172
  cdx history
173
173
  cdx history work
174
174
  cdx history work --limit 5
175
+ cdx history --interactive --cwd ~/projects/example --limit 50 --json
175
176
  cdx history work --json
176
177
  cdx history --summary
177
178
  cdx history --summary --since 7d
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
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.2"
7
+ version = "0.26.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
package/src/cli.py CHANGED
@@ -267,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"],
@@ -334,8 +334,15 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
334
334
  def runtime_lifecycle(event, info):
335
335
  nonlocal runtime_run_id
336
336
  if event == "started":
337
- runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd, **project})
337
+ runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd, **project, "runId": runtime_run_id})
338
338
  runtime_run_id = runtime.get("runId")
339
+ ctx["service"]["record_launch_history"](session["name"], {
340
+ **info, **project, "run_id": runtime_run_id, "cwd": cwd,
341
+ "action": "resume" if resume else "launch", "status": "running",
342
+ "ended_at": None, "duration_ms": None,
343
+ "conversation_id": _conversation_id(session) if resume or session["provider"] == PROVIDER_CLAUDE else None,
344
+ "first_user_prompt": initial_prompt,
345
+ })
339
346
  elif event == "finished" and runtime_run_id:
340
347
  ctx["service"]["finish_session_runtime"](
341
348
  session["name"],
@@ -367,7 +374,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
367
374
  except CdxError as error:
368
375
  run_info = getattr(error, "run_info", {}) or {}
369
376
  run_info = _attach_interactive_usage(
370
- session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
377
+ session, {**run_info, "cwd": cwd}, ctx["service"]["get_launch_history"](session["name"], limit=None),
371
378
  baseline=(baseline_path, baseline),
372
379
  uncertain_reason=overlap_reason(),
373
380
  )
@@ -382,6 +389,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
382
389
  if run_info:
383
390
  ctx["service"]["record_launch_history"](session["name"], {
384
391
  "status": "failed",
392
+ "action": "resume" if resume else "launch",
385
393
  "cwd": cwd,
386
394
  "error": str(error),
387
395
  "exit_code": error.exit_code,
@@ -391,7 +399,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
391
399
  })
392
400
  raise
393
401
  success_run_info = _attach_interactive_usage(
394
- session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
402
+ session, {**run_info, "cwd": cwd}, ctx["service"]["get_launch_history"](session["name"], limit=None),
395
403
  baseline=(baseline_path, baseline),
396
404
  uncertain_reason=overlap_reason(),
397
405
  )
@@ -452,9 +460,14 @@ def _attach_interactive_usage(session, run_info, history=None, baseline=None, un
452
460
  recorded nothing would break the chain for every run after it.
453
461
  """
454
462
  run_info = dict(run_info or {})
463
+ from ..history_content import enrich_history
464
+
465
+ if session.get("provider") == PROVIDER_CLAUDE:
466
+ run_info.setdefault("conversation_id", _conversation_id(session))
467
+ run_info = enrich_history({**run_info, "provider": session.get("provider")}, session)
455
468
  started_at = run_info.get("started_at")
456
469
  cumulative, provider_transcript, match, model = extract_interactive_usage(
457
- session.get("provider"), _get_auth_home(session), started_at, _conversation_id(session),
470
+ session.get("provider"), _get_auth_home(session), started_at, run_info.get("conversation_id") or _conversation_id(session),
458
471
  # Ollama writes no provider transcript; its only record is the PTY
459
472
  # capture this run already took.
460
473
  run_info.get("transcript_path"),
@@ -695,16 +695,29 @@ def handle_history(rest, ctx):
695
695
  now = datetime.fromtimestamp(now_fn()).astimezone()
696
696
  parsed = _parse_history_args(rest, now=now)
697
697
  has_period = _has_history_period(parsed["period"])
698
- limit = 0 if parsed["summary"] or has_period else parsed["limit"]
698
+ filtered = has_period or parsed["cwd"] is not None or parsed["interactive"]
699
+ limit = 0 if parsed["summary"] or filtered else parsed["limit"]
699
700
  entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
700
701
  entries = _filter_history_period(entries, parsed["period"])
702
+ if parsed["cwd"] is not None:
703
+ cwd = os.path.realpath(os.path.expanduser(parsed["cwd"]))
704
+ entries = [entry for entry in entries if entry.get("cwd")
705
+ and os.path.realpath(os.path.expanduser(entry["cwd"])) == cwd]
706
+ if parsed["interactive"]:
707
+ entries = [entry for entry in entries if entry.get("action") != "run"
708
+ and not entry.get("detached") and not entry.get("detach")
709
+ and not str(entry.get("label") or "").startswith("mission:")
710
+ and "--detach" not in (entry.get("args") or [])]
711
+ if filtered and not parsed["summary"]:
712
+ entries = entries[:parsed["limit"]]
713
+ from ..history_content import enrich_history
714
+
715
+ entries = [enrich_history(entry, ctx["service"]["get_session"](entry.get("session_name")) or {}) for entry in entries]
701
716
  entries = [{**entry, "parent_run_id": entry.get("parent_run_id"),
702
717
  **cost_fields(entry.get("usage") if is_vouched(entry.get("usage")) else None, entry.get("usage_model"),
703
718
  models=entry.get("usage_models", (entry.get("usage_evidence") or {}).get("models", [])), attribution=entry.get("usage_attribution")),
704
719
  "usage": {**entry["usage"], "cache_hit_ratio": cache_hit_ratio(entry["usage"], provider=entry.get("provider")) if is_vouched(entry["usage"]) else None}
705
720
  if isinstance(entry.get("usage"), dict) else entry.get("usage")} for entry in entries]
706
- if has_period and not parsed["summary"]:
707
- entries = entries[:parsed["limit"]]
708
721
  message = (
709
722
  f"Listed launch history for {parsed['name']}"
710
723
  if parsed["name"]
@@ -50,7 +50,16 @@ def _tool_name(message, names, result=False):
50
50
 
51
51
  def _conversation_record(provider, kind):
52
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")
53
+ # Claude Code records messages typed while the agent works as queued_command attachments.
54
+ return kind == "response_item" if provider == "codex" else kind in ("user", "assistant", "attachment")
55
+
56
+
57
+ def _queued_prompt(record):
58
+ attachment = record.get("attachment")
59
+ if (not isinstance(attachment, dict) or attachment.get("type") != "queued_command"
60
+ or (attachment.get("origin") or {}).get("kind") != "human"):
61
+ return None
62
+ return attachment.get("prompt")
54
63
 
55
64
 
56
65
  _CLAUDE_HARNESS = re.compile(
@@ -97,6 +106,9 @@ def _entries(provider, record, names):
97
106
  stamp = None
98
107
  if provider == "codex":
99
108
  message = record.get("payload")
109
+ elif record.get("type") == "attachment":
110
+ prompt = _queued_prompt(record)
111
+ message = {"role": "user", "content": prompt} if isinstance(prompt, (str, list)) else None
100
112
  else:
101
113
  message = record.get("message")
102
114
  if not isinstance(message, dict):
@@ -180,6 +192,18 @@ def _oversized_entry(provider, prefix):
180
192
  value = None
181
193
  if not _conversation_record(provider, value):
182
194
  return None
195
+ if value == "attachment":
196
+ # An oversized queued message carries a pasted image; keep its leading text when readable.
197
+ attachment = _field_start(prefix, "attachment")
198
+ if attachment is None or '"queued_command"' not in prefix[attachment:attachment + 200]:
199
+ return None
200
+ text = _field_start(prefix, "text", prefix.find("{", prefix.find('"prompt"', attachment)))
201
+ try:
202
+ text, _ = json.JSONDecoder().raw_decode(prefix, text) if text is not None else ("", 0)
203
+ except (ValueError, RecursionError):
204
+ text = ""
205
+ blocks = _claude_user_content([{"type": "text", "text": text}]) if isinstance(text, str) else []
206
+ return _entry("user", blocks[0]["text"] if blocks else "")
183
207
  message = _field_start(prefix, "payload" if provider == "codex" else "message")
184
208
  if message is None or prefix[message:message + 1] != "{":
185
209
  return None
@@ -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
@@ -688,9 +688,18 @@ def record_launch_history(store, name, payload):
688
688
  "launch": session.get("launch") or {},
689
689
  **payload,
690
690
  }
691
- store["append_launch_history"](entry)
691
+ from .history_content import enrich_history
692
+
692
693
  if entry.get("action") != "run":
693
- _observe_codex_conversation(store, session)
694
+ entry = enrich_history(entry, session)
695
+ store["append_launch_history"](entry)
696
+ if entry.get("action") != "run" and entry.get("status") != "running":
697
+ if session["provider"] == PROVIDER_CODEX and entry.get("provider_transcript_match") == "conversation_id":
698
+ store["update_session"](name, lambda s: {
699
+ **s, "conversation": {"id": entry["conversation_id"], "provenance": "observed", "recordedAt": _local_now_iso()},
700
+ })
701
+ elif entry.get("action") not in ("launch", "resume"):
702
+ _observe_codex_conversation(store, session)
694
703
  return entry
695
704
 
696
705
 
@@ -726,7 +735,25 @@ def _observe_codex_conversation(store, session):
726
735
  def get_launch_history(store, name=None, limit=20):
727
736
  if name and not store["get_session"](name):
728
737
  raise CdxError(f"Unknown session: {name}")
729
- return store["list_launch_history"](session_name=name, limit=limit)
738
+ entries = store["list_launch_history"](session_name=name, limit=0)
739
+ known = {entry.get("run_id") for entry in entries if entry.get("run_id")}
740
+ # Older supervisors only wrote history on exit. Expose their active runtime
741
+ # without inventing completed history or duplicating newly recorded launches.
742
+ sessions = [store["get_session"](name)] if name else store["list_sessions"]()
743
+ for session in sessions:
744
+ runtime = _session_runtime(store, session["name"])
745
+ if not runtime or not runtime.get("runId") or runtime["runId"] in known:
746
+ continue
747
+ entries.insert(0, {
748
+ "schema_version": 1, "session_name": session["name"], "provider": session["provider"],
749
+ "launch": session.get("launch") or {}, "action": "launch", "status": "running",
750
+ "run_id": runtime["runId"], "started_at": runtime.get("startedAt"),
751
+ "cwd": runtime.get("cwd"), "pid": runtime.get("pid"), "label": runtime.get("label"),
752
+ "project": runtime.get("project"), "project_source": runtime.get("project_source", "unknown"),
753
+ "transcript_path": runtime.get("transcriptPath"),
754
+ "conversation_id": (session.get("conversation") or {}).get("id") if session["provider"] == PROVIDER_CLAUDE else None,
755
+ })
756
+ return entries[:limit] if limit else entries
730
757
 
731
758
 
732
759
  def backfill_interactive_usage(store, name=None, dry_run=True):
@@ -253,6 +253,24 @@ def create_session_store(base_dir):
253
253
  def append_launch_history(entry):
254
254
  with _file_lock(lock_file):
255
255
  _ensure_dir(state_dir)
256
+ if entry.get("run_id") and entry.get("action") in ("launch", "resume"):
257
+ try:
258
+ with open(launch_history_file, encoding="utf-8") as handle:
259
+ lines = handle.readlines()
260
+ except FileNotFoundError:
261
+ lines = []
262
+ for index, line in enumerate(lines):
263
+ try:
264
+ previous = json.loads(line)
265
+ except ValueError:
266
+ continue
267
+ if (previous.get("run_id") == entry["run_id"]
268
+ and previous.get("session_name") == entry.get("session_name")
269
+ and previous.get("action") in ("launch", "resume")):
270
+ lines[index] = json.dumps({**previous, **entry}, separators=(",", ":")) + "\n"
271
+ atomic_write(launch_history_file, "".join(lines))
272
+ _fsync_directory(state_dir)
273
+ return
256
274
  with open(launch_history_file, "a", encoding="utf-8") as handle:
257
275
  handle.write(json.dumps(entry, separators=(",", ":")))
258
276
  handle.write("\n")