cdx-manager 0.25.9 → 0.26.1

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.25.9-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.1-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
 
@@ -42,6 +42,27 @@ cdx work
42
42
 
43
43
  CDX checks authentication before interactive launches. Handoff keeps the source transcript in place and asks the receiving agent to verify the conversation and workspace before acting. No account switch or prompt submission happens just because you run `cdx next`.
44
44
 
45
+ ## Automatic mission concurrency per profile
46
+
47
+ ```bash
48
+ cdx set work --max-parallel 2
49
+ cdx show work
50
+ cdx unset work --max-parallel
51
+ ```
52
+
53
+ `--max-parallel` accepts an integer from 0 to 5 and stores `launch.max_parallel`
54
+ next to `priority` and `policy`. Zero excludes the profile from automatic
55
+ missions. An absent value adds no profile-specific ceiling: the scheduling
56
+ client applies its own rule. Existing configurations need no migration.
57
+ `cdx unset work --all` also removes the limit.
58
+
59
+ The scheduling client is responsible for enforcing this metadata. It is
60
+ separate from the existing per-command `cdx run --max-parallel` limit.
61
+ `cdx --json` (also `cdx ls --json`) includes the optional field in each
62
+ session's `launch` object; `cdx status --json` exposes `max_parallel` on each
63
+ row (`null` when absent). `cdx show` is an alias for `cdx config` and displays
64
+ `Max parallel` as `default` when unset.
65
+
45
66
  ## Cache efficiency and fix rounds
46
67
 
47
68
  `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.
@@ -57,6 +78,10 @@ cdx run --continue-run RUN_ID --cwd /path/to/repo --prompt "Fix the remaining te
57
78
 
58
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.
59
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
+
60
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.
61
86
 
62
87
  ## Run costs and budgets
@@ -218,3 +243,56 @@ The empty-refresh-token behavior was measured with Codex CLI 0.166.0. Real-host
218
243
  acceptance should verify that the installed matching CLI does not attempt a
219
244
  refresh with the temporary home, that the controller token chain stays unchanged
220
245
  for a fresh credential, and that cleanup works after disconnect and SIGTERM.
246
+
247
+ ### Read an interactive conversation
248
+
249
+ `cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json`
250
+ returns a read-only JSON page for the profile's current conversation. An explicit
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 uses the existing
254
+ latest local transcript resolver under the profile's auth home. This fallback is
255
+ best effort and does not rewrite the recorded ID. JSON `resolution_method` is
256
+ `conversation_id` for an exact match, `recency` for a local fallback, or `null`
257
+ when no transcript is resolved.
258
+
259
+ The normal cdx JSON envelope includes `status`, `reason`, `provider`, `session`,
260
+ `entries`, `next_cursor`, and `has_more`. Each entry has exactly `role`
261
+ (`user`, `assistant`, or `tool`), `text`, `tool_name`, `tool_summary`, `at`, and
262
+ `id`. Missing values are `null`; tool text is empty. Entries follow file and
263
+ content-block order, with stable IDs for the same file position. Timestamps are
264
+ provider timestamps when valid. Tool calls/results have a name and a short
265
+ summary; arguments, results, attachments, and reasoning blocks are omitted.
266
+ Result names are recovered from up to 100 recent calls, otherwise `tool`.
267
+ Common credential patterns and secret assignments in text are redacted without
268
+ opening credential stores. Review conversation text before sharing: arbitrary
269
+ unlabelled sensitive prose cannot be identified automatically.
270
+
271
+ Pass `next_cursor` unchanged as `--cursor` to fetch the next page or poll for new
272
+ entries. `--limit` bounds entries (default 100, range 1–1000). Reading seeks to
273
+ the saved byte position rather than replaying history, and scans at most 8 MiB
274
+ per call. A page may be empty while still returning a usable cursor. Poll on a
275
+ timer: `has_more` means unread bytes, which can include an incomplete final line
276
+ that will only become readable after the provider appends its newline.
277
+
278
+ Codex response items and Claude-compatible JSONL transcripts (Claude and
279
+ Antigravity) are supported through the existing transcript resolver. Missing or
280
+ unreadable files return `status: "unavailable"` with a reason. Ollama's terminal
281
+ capture does not reliably identify message roles and returns
282
+ `status: "unsupported"`, including when no capture exists. A transcript record
283
+ larger than 1 MiB returns `unavailable`. A replaced/truncated file or invalid
284
+ cursor returns `reset_required`; discard the cursor and reload the conversation.
285
+ Cursors are opaque, versioned, and bound to a provider and file. Treat transcripts
286
+ as append-only; reset the client if a provider rewrites earlier history.
287
+
288
+ Python callers can use the same page contract:
289
+
290
+ ```python
291
+ from cdx_manager.conversation import read_conversation
292
+
293
+ page = read_conversation("claude", "/path/to/profile/home", conversation_id="session-id", limit=100)
294
+ next_page = read_conversation(
295
+ "claude", "/path/to/profile/home", conversation_id="session-id",
296
+ cursor=page["next_cursor"], limit=100,
297
+ )
298
+ ```
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.9
1
+ 0.26.1
@@ -0,0 +1,31 @@
1
+ # CDX Manager 0.26.0
2
+
3
+ Release date: 2026-11-03
4
+
5
+ This release adds optional profile concurrency settings for automatic missions
6
+ and incremental JSON access to interactive conversations.
7
+
8
+ ## Automatic mission concurrency
9
+
10
+ - Add an optional per-profile `launch.max_parallel` setting through `cdx set
11
+ <name> --max-parallel N`, accepting integers from 0 to 5. Zero excludes a
12
+ profile from automatic missions; an unset value leaves the scheduling client's
13
+ default in effect. Expose the setting in list JSON, status JSON, and profile
14
+ settings, and clear it with `cdx unset <name> --max-parallel`. Scheduling
15
+ clients enforce this setting; the existing `cdx run --max-parallel` limit
16
+ remains separate. (#101)
17
+
18
+ ## Interactive conversation reading
19
+
20
+ - Add `cdx conversation <name> --json` and the reusable `read_conversation`
21
+ Python function to read Codex, Claude, and Antigravity transcripts
22
+ incrementally with bounded reads and opaque pagination cursors. Return
23
+ normalized messages and tool activity summaries while omitting tool bodies,
24
+ attachments, metadata, and reasoning, and redacting recognized credentials.
25
+ Report missing transcripts as unavailable and Ollama terminal captures as
26
+ unsupported; require a cursor reset after rotation or truncation. (#100)
27
+
28
+ ## Maintenance
29
+
30
+ - Record tray companion checksums for v0.25.9. (`6d0a825b9649da9aead3379e6411e3959cd30a77`)
31
+ - Record release archive checksums for v0.25.9. (`e3682ad8aed14d75c3c686a0c6027f53cfe81cd3`)
@@ -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`)
@@ -1,5 +1,11 @@
1
1
  # Unreleased
2
2
 
3
+ ## Preserve interactive conversations across headless runs
4
+
5
+ - 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.
6
+ - 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`.
7
+ - Cover a remote Claude run followed by local interactive conversation reading, local/continued run identity preservation, missing recorded IDs and missing explicit IDs.
8
+
3
9
  ## Antigravity quota and run usage
4
10
 
5
11
  - 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.
@@ -782,6 +782,28 @@
782
782
  "x86_64-pc-windows-msvc": "9577f9dff7d457094276376f262904dad8c9aff95ed2ca87b834c9c900fb5493",
783
783
  "x86_64-unknown-linux-musl": "db7e322037041c2a898043f0c7a691c22cab6a794aab5b2439a06ac91e92fa39"
784
784
  }
785
+ },
786
+ "v0.25.9": {
787
+ "github_tarball_sha256": "cd6d881144cefbd82b0929f375f471ed076e2f23be60744895aee54de5aead12",
788
+ "github_zip_sha256": "ccd28db3f526a081ed75cefc016f54d7eadf0adcaab401c8aed77d477cf917d3",
789
+ "tray_assets": {
790
+ "aarch64-apple-darwin": "4f90576e9e8db1ec08ed856a91a02f4049c7f7186e6dccac1908ba43c72df6fd",
791
+ "aarch64-unknown-linux-musl": "dcdf50751982f78805492eca059d83238f5d2190b97455b2d96d91283b764cdc",
792
+ "x86_64-apple-darwin": "289818bd5d692a6ba5035f10886318179bb2baa98e22a05e7abd751ea64d19ed",
793
+ "x86_64-pc-windows-msvc": "32889713859cf23d4d82647638d909d62075b92d8e6571e32118ebfa208d81b2",
794
+ "x86_64-unknown-linux-musl": "9e94b799fe4b01d95aeb172c1eba28dc011be69cc2b39b5743f0d81986eaa80f"
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
+ }
785
807
  }
786
808
  }
787
809
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.25.9",
3
+ "version": "0.26.1",
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.25.9"
7
+ version = "0.26.1"
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
@@ -21,6 +21,7 @@ from .cli_commands import (
21
21
  handle_config,
22
22
  handle_configs,
23
23
  handle_context,
24
+ handle_conversation,
24
25
  handle_copy,
25
26
  handle_disable,
26
27
  handle_disk,
@@ -167,6 +168,7 @@ _COMMAND_HANDLERS = {
167
168
  "export": handle_export,
168
169
  "handoff": handle_handoff,
169
170
  "history": handle_history,
171
+ "conversation": handle_conversation,
170
172
  "import": handle_import,
171
173
  "label": handle_label,
172
174
  "last": handle_last,
@@ -201,6 +203,7 @@ _COMMAND_HANDLERS = {
201
203
  }
202
204
 
203
205
  _COMMAND_ALIASES = {
206
+ "show": "config",
204
207
  "ren": "rename",
205
208
  "mv": "rename",
206
209
  }
@@ -237,6 +240,7 @@ def _print_help(use_color=False):
237
240
  _style("Usage:", "1", use_color),
238
241
  f" {_style('cdx', '36', use_color)}",
239
242
  f" {_style('cdx --json', '36', use_color)}",
243
+ f" {_style('cdx ls [--json]', '36', use_color)}",
240
244
  f" {_style('cdx status [--json] [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
241
245
  f" {_style('cdx status --small|-s [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
242
246
  f" {_style('cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
@@ -258,15 +262,17 @@ def _print_help(use_color=False):
258
262
  f" {_style('cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]', '36', use_color)}",
259
263
  f" {_style('cdx disk [profiles] [--candidates] [--json]', '36', use_color)}",
260
264
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
265
+ f" {_style('cdx show <name> [--json]', '36', use_color)}",
261
266
  f" {_style('cdx configs [--json]', '36', use_color)}",
262
267
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
263
268
  f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
264
- f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--all) [--json]', '36', use_color)}",
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)}",
265
270
  f" {_style('cdx history backfill-projects [--mapping FILE] [--dry-run|--apply] [--json]', '36', use_color)}",
266
271
  f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
267
272
  f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
268
273
  f" {_style('cdx last [--json]', '36', use_color)}",
269
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)}",
270
276
  f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
271
277
  f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
272
278
  f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",
@@ -580,6 +586,11 @@ def main(argv, options=None):
580
586
  # directory cannot be resolved (a service or task with a minimal environment).
581
587
  service = options.get("service") or create_session_service({"env": env})
582
588
 
589
+ if argv[:1] == ["ls"]:
590
+ if argv[1:] not in ([], ["--json"]):
591
+ raise CdxError("Usage: cdx ls [--json]")
592
+ argv = argv[1:]
593
+
583
594
  if argv == ["--json"]:
584
595
  rows = service["format_list_rows"]()
585
596
  notices = _get_update_notices(service, env, options)
package/src/cli_args.py CHANGED
@@ -16,6 +16,7 @@ from .config import (
16
16
  PROVIDER_CODEX,
17
17
  PROVIDERS,
18
18
  REASONING_EFFORT_VALUES,
19
+ normalize_launch_max_parallel,
19
20
  normalize_permission,
20
21
  parse_on_off,
21
22
  )
@@ -34,8 +35,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
34
35
  MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
35
36
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
36
37
  LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
37
- SET_USAGE = "Usage: 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]"
38
- UNSET_USAGE = "Usage: 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]"
38
+ SET_USAGE = "Usage: 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] [--max-parallel 0..5] [--json]"
39
+ UNSET_USAGE = "Usage: 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|--max-parallel|--all) [--json]"
39
40
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
40
41
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
41
42
  CONFIGS_USAGE = "Usage: cdx configs [--json]"
@@ -286,6 +287,7 @@ def _parse_set_args(args):
286
287
  "--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
287
288
  "--extra-args": {"key": "extra_args", "type": "str", "default": None},
288
289
  "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
290
+ "--max-parallel": {"key": "max_parallel", "type": "str", "default": None, "transform": normalize_launch_max_parallel},
289
291
  "--policy": {"key": "policy", "type": "str", "default": None},
290
292
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
291
293
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
@@ -303,7 +305,7 @@ def _parse_set_args(args):
303
305
  raise CdxError(SET_USAGE)
304
306
  settings = {
305
307
  key: parsed[key]
306
- for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy")
308
+ for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel")
307
309
  if parsed[key] is not None
308
310
  }
309
311
  if not settings:
@@ -336,6 +338,7 @@ def _parse_unset_args(args):
336
338
  "--budget": {"key": "budget", "type": "bool", "default": False},
337
339
  "--extra-args": {"key": "extra_args", "type": "bool", "default": False},
338
340
  "--priority": {"key": "priority", "type": "bool", "default": False},
341
+ "--max-parallel": {"key": "max_parallel", "type": "bool", "default": False},
339
342
  "--policy": {"key": "policy", "type": "bool", "default": False},
340
343
  "--all": {"key": "all", "type": "bool", "default": False},
341
344
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
@@ -354,7 +357,7 @@ def _parse_unset_args(args):
354
357
  raise CdxError(UNSET_USAGE)
355
358
  _UNSET_KEYS = (
356
359
  "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro",
357
- "model", "fallback_model", "budget", "extra_args", "priority", "policy",
360
+ "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel",
358
361
  )
359
362
  keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
360
363
  if not keys:
@@ -57,8 +57,6 @@ from .commands.backup import ( # noqa: F401
57
57
  handle_export,
58
58
  handle_import,
59
59
  )
60
-
61
- # Re-exported for cli.py and tests; see src/commands/__init__.py.
62
60
  from .commands.context_memory import ( # noqa: F401
63
61
  _list_memory_entries,
64
62
  _memory_payload,
@@ -67,6 +65,9 @@ from .commands.context_memory import ( # noqa: F401
67
65
  handle_context,
68
66
  handle_memory,
69
67
  )
68
+
69
+ # Re-exported for cli.py and tests; see src/commands/__init__.py.
70
+ from .commands.conversation import handle_conversation as handle_conversation
70
71
  from .commands.launch import ( # re-export for cli.py / tests
71
72
  _format_resume_capability as _format_resume_capability,
72
73
  )
@@ -323,6 +323,7 @@ def _format_launch_config(session, use_color=False):
323
323
  ("extra_args", "Extra args"),
324
324
  ("priority", "Priority"),
325
325
  ("policy", "Policy"),
326
+ ("max_parallel", "Max parallel"),
326
327
  ]:
327
328
  rows.append([
328
329
  _dim(label, use_color),
@@ -0,0 +1,34 @@
1
+ """JSON conversation pages for interactive sessions."""
2
+
3
+ from ..cli_args import _parse_flag_args
4
+ from ..cli_helpers import _json_success, _write_json
5
+ from ..conversation import read_conversation
6
+ from ..errors import CdxError
7
+ from ..provider_runtime import _get_auth_home
8
+
9
+ CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json"
10
+
11
+
12
+ def handle_conversation(rest, ctx):
13
+ parsed = _parse_flag_args(rest, {
14
+ "--json": {"key": "json", "type": "bool", "default": False},
15
+ "--conversation": {"key": "conversation", "type": "str"},
16
+ "--cursor": {"key": "cursor", "type": "str"},
17
+ "--limit": {"key": "limit", "type": "str", "default": "100"},
18
+ }, CONVERSATION_USAGE, positionals_key="names", max_positionals=1)
19
+ if not parsed["json"] or len(parsed["names"]) != 1:
20
+ raise CdxError(CONVERSATION_USAGE)
21
+ session = ctx["service"]["get_session"](parsed["names"][0])
22
+ if not session:
23
+ raise CdxError(f"Unknown session: {parsed['names'][0]}")
24
+ try:
25
+ limit = int(parsed["limit"])
26
+ except ValueError:
27
+ raise CdxError(CONVERSATION_USAGE) from None
28
+ page = read_conversation(
29
+ session["provider"], _get_auth_home(session),
30
+ conversation_id=parsed["conversation"] or (session.get("conversation") or {}).get("id"),
31
+ cursor=parsed["cursor"], limit=limit, fallback_to_recency=not parsed["conversation"],
32
+ )
33
+ _write_json(ctx, _json_success("conversation", "Conversation page loaded.", session=session["name"], **page))
34
+ return 0
@@ -763,10 +763,8 @@ def handle_run(rest, ctx):
763
763
  # since nothing else frees their slot.
764
764
  _reconcile_background_runs(ctx)
765
765
  _run_registry(ctx).check_capacity(max_parallel)
766
- # Claude needs its conversation id before the spec is built; Codex
767
- # records its own afterwards, from the rollout. Without this a headless
768
- # Claude run left the session with no identity and `cdx resume` fell
769
- # back to recency for Claude while working by id for Codex.
766
+ # Fresh runs carry their own identity; only interactive launches update
767
+ # the profile's current conversation. Continuations use the parent run.
770
768
  if not parsed.get("continue_run"):
771
769
  session = ctx["service"]["begin_conversation"](session["name"])
772
770
  run_session = {
package/src/config.py CHANGED
@@ -78,6 +78,21 @@ def normalize_permission(value):
78
78
  return text if text in PERMISSION_VALUES else None
79
79
 
80
80
 
81
+ def normalize_launch_max_parallel(value):
82
+ """Validate the optional per-profile automatic mission limit."""
83
+ from .errors import CdxError
84
+
85
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
86
+ raise CdxError("Max parallel must be an integer from 0 to 5.")
87
+ try:
88
+ limit = int(value)
89
+ except ValueError as error:
90
+ raise CdxError("Max parallel must be an integer from 0 to 5.") from error
91
+ if not 0 <= limit <= 5:
92
+ raise CdxError("Max parallel must be an integer from 0 to 5.")
93
+ return limit
94
+
95
+
81
96
  def get_cdx_home(env=None):
82
97
  if env is None:
83
98
  env = os.environ
@@ -0,0 +1,187 @@
1
+ """Incremental, read-only conversation pages from provider JSONL transcripts."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ from datetime import datetime
9
+
10
+ from .config import PROVIDERS
11
+ from .errors import CdxError
12
+ from .interactive_usage import conversation_transcript, resolve_transcript
13
+
14
+ MAX_LINE_BYTES = 1024 * 1024
15
+ MAX_PAGE_BYTES = 8 * 1024 * 1024
16
+ REDACTED = "[REDACTED]"
17
+
18
+
19
+ def _redact(text):
20
+ # There is no shared transcript redactor; launch argument redaction only
21
+ # hides prompt argv. Do not read credential stores to discover secrets.
22
+ text = re.sub(r"-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|$)", REDACTED, text, flags=re.S)
23
+ text = re.sub(r"(?i)\b(?:[\w.-]*(?:password|passwd|secret|api[_-]?key|token)|authorization)\b[\"']?\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;}]+)", REDACTED, text)
24
+ text = re.sub(r"(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+", REDACTED, text)
25
+ text = re.sub(r"\b(?:sk-[a-zA-Z0-9_-]+|gh[pousr]_[a-zA-Z0-9]+|github_pat_[a-zA-Z0-9_]+|AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|xox[baprs]-[a-zA-Z0-9-]+|AIza[a-zA-Z0-9_-]+)\b", REDACTED, text)
26
+ text = re.sub(r"\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b", REDACTED, text)
27
+ text = re.sub(r"https?://(?:hooks\.slack\.com/services|(?:discord(?:app)?\.com)/api/webhooks)/[^\s\"'<>]+", REDACTED, text)
28
+ return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", text)
29
+
30
+
31
+ def _entry(role, text="", name=None, summary=None, at=None):
32
+ return {"role": role, "text": _redact(text), "tool_name": _redact(name) if name else None,
33
+ "tool_summary": summary, "at": at, "id": None}
34
+
35
+
36
+ def _tool_name(message, names, result=False):
37
+ identifier = message.get("call_id") or message.get("tool_use_id") or message.get("id")
38
+ key = hashlib.sha256(identifier.encode()).hexdigest() if isinstance(identifier, str) else None
39
+ name = message.get("name") or (names.get(key) if result else None)
40
+ name = _redact(name) if isinstance(name, str) else "tool"
41
+ if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_.:/-]{0,99}", name):
42
+ name = "tool"
43
+ if key and not result:
44
+ if len(names) >= 100:
45
+ names.pop(next(iter(names)))
46
+ names[key] = name
47
+ return name
48
+
49
+
50
+ def _entries(provider, record, names):
51
+ if not isinstance(record, dict):
52
+ return []
53
+ stamp = record.get("timestamp")
54
+ try:
55
+ datetime.fromisoformat(stamp.replace("Z", "+00:00"))
56
+ except (AttributeError, TypeError, ValueError):
57
+ stamp = None
58
+ if provider == "codex":
59
+ # event_msg repeats response_item messages; consume only canonical items.
60
+ if record.get("type") != "response_item":
61
+ return []
62
+ message = record.get("payload")
63
+ else:
64
+ message = record.get("message")
65
+ if not isinstance(message, dict):
66
+ return []
67
+ kind = message.get("type")
68
+ if isinstance(kind, str) and (kind.endswith("_call") or kind.endswith("_call_output")):
69
+ result = kind.endswith("output")
70
+ tool = message
71
+ if not message.get("name") and kind not in ("function_call", "custom_tool_call", "function_call_output", "custom_tool_call_output"):
72
+ tool = {**message, "name": kind.removesuffix("_output").removesuffix("_call")}
73
+ name = _tool_name(tool, names, result)
74
+ return [_entry("tool", name=name, summary="Result recorded" if result else "Call recorded", at=stamp)]
75
+ role = message.get("role") or record.get("type")
76
+ if role not in ("user", "assistant", "tool"):
77
+ return []
78
+ if role == "tool":
79
+ return [_entry("tool", name=_tool_name(message, names, True), summary="Result recorded", at=stamp)]
80
+ content = message.get("content")
81
+ if isinstance(content, str):
82
+ return [_entry(role, content, at=stamp)]
83
+ if not isinstance(content, list):
84
+ return []
85
+ entries = []
86
+ for block in content:
87
+ if not isinstance(block, dict):
88
+ continue
89
+ kind = block.get("type")
90
+ if kind in ("text", "input_text", "output_text") and isinstance(block.get("text"), str):
91
+ entries.append(_entry(role, block["text"], at=stamp))
92
+ elif kind in ("tool_use", "tool_result"):
93
+ name = _tool_name(block, names, kind == "tool_result")
94
+ entries.append(_entry("tool", name=name, summary="Call recorded" if kind == "tool_use" else "Result recorded", at=stamp))
95
+ return entries
96
+
97
+
98
+ def _anchor(handle, offset):
99
+ handle.seek(max(0, offset - 128))
100
+ return hashlib.sha256(handle.read(min(offset, 128))).hexdigest()
101
+
102
+
103
+ def read_conversation(provider, auth_home=None, *, conversation_id=None, started_at=None,
104
+ terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False):
105
+ """Return a JSON-serializable page; pass next_cursor back unchanged to poll.
106
+
107
+ Entries follow file/block order. Cursors bind a byte offset and block index
108
+ to the resolved file; reset_required means discard the old cursor and page
109
+ again from the beginning. Incomplete trailing lines are retried next time.
110
+ Only implicit session identities may opt into fallback_to_recency.
111
+ Tool bodies and reasoning blocks are never returned. Ollama PTY captures
112
+ cannot reliably distinguish roles and are explicitly unsupported.
113
+ """
114
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
115
+ raise CdxError("Conversation limit must be an integer between 1 and 1000.")
116
+ page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False,
117
+ "resolution_method": None}
118
+ if provider not in PROVIDERS or provider == "ollama":
119
+ return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
120
+ # An explicit conversation must never fall back to an unrelated recent file.
121
+ if conversation_id:
122
+ path = conversation_transcript(provider, auth_home, conversation_id)
123
+ if path:
124
+ page["resolution_method"] = "conversation_id"
125
+ elif fallback_to_recency:
126
+ path, page["resolution_method"] = resolve_transcript(provider, auth_home)
127
+ else:
128
+ path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, terminal_transcript=terminal_transcript)
129
+ if not path:
130
+ return {**page, "status": "unavailable", "reason": "transcript_not_found"}
131
+ try:
132
+ with open(path, "rb") as handle:
133
+ stat = os.fstat(handle.fileno())
134
+ identity = hashlib.sha256(f"{provider}:{os.path.realpath(path)}:{stat.st_dev}:{stat.st_ino}".encode()).hexdigest()
135
+ offset, index, names = 0, 0, {}
136
+ if cursor is not None:
137
+ try:
138
+ if not isinstance(cursor, str) or len(cursor) > 65536:
139
+ raise ValueError
140
+ state = json.loads(base64.urlsafe_b64decode(cursor.encode("ascii")))
141
+ offset, index = state["offset"], state["index"]
142
+ names = state["names"]
143
+ if (not isinstance(names, dict) or len(names) > 100
144
+ or any(not isinstance(k, str) or not re.fullmatch(r"[0-9a-f]{64}", k)
145
+ or not isinstance(v, str) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_.:/-]{0,99}", v)
146
+ or _redact(v) != v for k, v in names.items())):
147
+ raise ValueError
148
+ if (state["v"] != 1 or state["file"] != identity
149
+ or type(offset) is not int or type(index) is not int
150
+ or not 0 <= offset <= stat.st_size or not 0 <= index <= MAX_LINE_BYTES
151
+ or state["anchor"] != _anchor(handle, offset)):
152
+ raise ValueError
153
+ except (ValueError, TypeError, KeyError, AttributeError, UnicodeError):
154
+ return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
155
+ handle.seek(offset)
156
+ scanned = 0
157
+ while len(page["entries"]) < limit and scanned < MAX_PAGE_BYTES:
158
+ start = handle.tell()
159
+ line = handle.readline(MAX_LINE_BYTES + 1)
160
+ if len(line) > MAX_LINE_BYTES:
161
+ return {**page, "status": "unavailable", "reason": "record_too_large", "entries": []}
162
+ if not line or not line.endswith(b"\n"):
163
+ break
164
+ scanned += len(line)
165
+ try:
166
+ entries = _entries(provider, json.loads(line), names)
167
+ except (ValueError, UnicodeError):
168
+ entries = []
169
+ for block_index in range(index, len(entries)):
170
+ entry = entries[block_index]
171
+ entry["id"] = f"{identity[:16]}:{start}:{block_index}"
172
+ page["entries"].append(entry)
173
+ if len(page["entries"]) == limit:
174
+ index = block_index + 1
175
+ break
176
+ else:
177
+ index = len(entries)
178
+ if index < len(entries):
179
+ offset = start
180
+ break
181
+ offset, index = handle.tell(), 0
182
+ page["has_more"] = offset < stat.st_size
183
+ state = {"v": 1, "file": identity, "offset": offset, "index": index, "anchor": _anchor(handle, offset), "names": names}
184
+ page["next_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
185
+ return page
186
+ except OSError:
187
+ return {**page, "status": "unavailable", "reason": "transcript_unreadable", "entries": []}
@@ -45,6 +45,7 @@ from .config import (
45
45
  PROVIDER_CODEX,
46
46
  REASONING_EFFORT_VALUES,
47
47
  get_cdx_home,
48
+ normalize_launch_max_parallel,
48
49
  normalize_permission,
49
50
  split_extra_args,
50
51
  )
@@ -236,6 +237,8 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
236
237
  if priority < MIN_LAUNCH_PRIORITY or priority > MAX_LAUNCH_PRIORITY:
237
238
  raise CdxError(f"Unsupported priority: {settings['priority']}")
238
239
  normalized["priority"] = priority
240
+ if "max_parallel" in settings and settings["max_parallel"] is not None:
241
+ normalized["max_parallel"] = normalize_launch_max_parallel(settings["max_parallel"])
239
242
  if "policy" in settings and settings["policy"] is not None:
240
243
  if settings["policy"] not in ACCOUNT_POLICIES:
241
244
  raise CdxError(f"Unsupported policy: {settings['policy']} (use|ask-first|never)")
@@ -324,7 +327,7 @@ def unset_launch_settings(store, name, keys):
324
327
  raise CdxError("At least one launch setting is required.")
325
328
  allowed = {
326
329
  "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview",
327
- "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy",
330
+ "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel",
328
331
  }
329
332
  unknown = [key for key in keys if key not in allowed]
330
333
  if unknown:
@@ -686,12 +689,13 @@ def record_launch_history(store, name, payload):
686
689
  **payload,
687
690
  }
688
691
  store["append_launch_history"](entry)
689
- _observe_codex_conversation(store, session)
692
+ if entry.get("action") != "run":
693
+ _observe_codex_conversation(store, session)
690
694
  return entry
691
695
 
692
696
 
693
697
  def _observe_codex_conversation(store, session):
694
- """Read Codex's conversation id back after a run, if it wrote one.
698
+ """Read Codex's conversation id back after an interactive launch.
695
699
 
696
700
  Codex mints the id itself, so unlike Claude this can only be observed once
697
701
  the rollout exists. Failing to find one is normal and leaves the session on
@@ -935,26 +939,14 @@ def _conversation_identity_for_launch(session):
935
939
 
936
940
 
937
941
  def begin_conversation(store, name):
938
- """Mint the conversation id a Claude run is about to carry, and store it.
939
-
940
- `launch_session` does this for interactive launches, but a headless run
941
- never goes through it, so `cdx run` used to leave Claude sessions with no
942
- identity at all - `cdx resume` then fell back to recency for Claude while
943
- working by id for Codex, which records its own after the fact. Same shape
944
- for both providers now: whatever ran last is what resume names.
945
-
946
- Returns the updated session, or the unchanged one for providers that mint
947
- nothing here.
948
- """
942
+ """Return a run-local identity without changing the interactive session."""
949
943
  session = store["get_session"](name)
950
944
  if not session:
951
945
  raise CdxError(f"Unknown session: {name}")
952
946
  conversation = _conversation_identity_for_launch(session)
953
947
  if not conversation:
954
948
  return session
955
- return store["update_session"](name, lambda s: {
956
- **s, "conversation": conversation, "updatedAt": _local_now_iso(),
957
- })
949
+ return {**session, "conversation": conversation}
958
950
 
959
951
 
960
952
  def mark_conversation_rescued(store, name, conversation_id):
@@ -380,6 +380,7 @@ def _status_row_from_session(store, s):
380
380
  # ignored `--priority` entirely.
381
381
  "priority": _row_priority(s),
382
382
  "policy": (s.get("launch") or {}).get("policy") or "use",
383
+ "max_parallel": (s.get("launch") or {}).get("max_parallel"),
383
384
  "reasoning_effort": _row_reasoning_effort(s),
384
385
  "active": bool(runtime) if enabled else False,
385
386
  "cwd": runtime.get("cwd") if runtime else None,