cdx-manager 0.25.9 → 0.26.0

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.0-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.
@@ -218,3 +239,51 @@ The empty-refresh-token behavior was measured with Codex CLI 0.166.0. Real-host
218
239
  acceptance should verify that the installed matching CLI does not attempt a
219
240
  refresh with the temporary home, that the controller token chain stays unchanged
220
241
  for a fresh credential, and that cleanup works after disconnect and SIGTERM.
242
+
243
+ ### Read an interactive conversation
244
+
245
+ `cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json`
246
+ returns a read-only JSON page for the profile's current conversation. An explicit
247
+ conversation ID uses an exact transcript lookup; a profile without a recorded ID
248
+ uses the existing latest-transcript resolver. This fallback is best effort.
249
+
250
+ The normal cdx JSON envelope includes `status`, `reason`, `provider`, `session`,
251
+ `entries`, `next_cursor`, and `has_more`. Each entry has exactly `role`
252
+ (`user`, `assistant`, or `tool`), `text`, `tool_name`, `tool_summary`, `at`, and
253
+ `id`. Missing values are `null`; tool text is empty. Entries follow file and
254
+ content-block order, with stable IDs for the same file position. Timestamps are
255
+ provider timestamps when valid. Tool calls/results have a name and a short
256
+ summary; arguments, results, attachments, and reasoning blocks are omitted.
257
+ Result names are recovered from up to 100 recent calls, otherwise `tool`.
258
+ Common credential patterns and secret assignments in text are redacted without
259
+ opening credential stores. Review conversation text before sharing: arbitrary
260
+ unlabelled sensitive prose cannot be identified automatically.
261
+
262
+ Pass `next_cursor` unchanged as `--cursor` to fetch the next page or poll for new
263
+ entries. `--limit` bounds entries (default 100, range 1–1000). Reading seeks to
264
+ the saved byte position rather than replaying history, and scans at most 8 MiB
265
+ per call. A page may be empty while still returning a usable cursor. Poll on a
266
+ timer: `has_more` means unread bytes, which can include an incomplete final line
267
+ that will only become readable after the provider appends its newline.
268
+
269
+ Codex response items and Claude-compatible JSONL transcripts (Claude and
270
+ Antigravity) are supported through the existing transcript resolver. Missing or
271
+ unreadable files return `status: "unavailable"` with a reason. Ollama's terminal
272
+ capture does not reliably identify message roles and returns
273
+ `status: "unsupported"`, including when no capture exists. A transcript record
274
+ larger than 1 MiB returns `unavailable`. A replaced/truncated file or invalid
275
+ cursor returns `reset_required`; discard the cursor and reload the conversation.
276
+ Cursors are opaque, versioned, and bound to a provider and file. Treat transcripts
277
+ as append-only; reset the client if a provider rewrites earlier history.
278
+
279
+ Python callers can use the same page contract:
280
+
281
+ ```python
282
+ from cdx_manager.conversation import read_conversation
283
+
284
+ page = read_conversation("claude", "/path/to/profile/home", conversation_id="session-id", limit=100)
285
+ next_page = read_conversation(
286
+ "claude", "/path/to/profile/home", conversation_id="session-id",
287
+ cursor=page["next_cursor"], limit=100,
288
+ )
289
+ ```
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.9
1
+ 0.26.0
@@ -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`)
@@ -782,6 +782,17 @@
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
+ }
785
796
  }
786
797
  }
787
798
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.25.9",
3
+ "version": "0.26.0",
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.0"
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,
32
+ )
33
+ _write_json(ctx, _json_success("conversation", "Conversation page loaded.", session=session["name"], **page))
34
+ return 0
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,181 @@
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):
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
+ Tool bodies and reasoning blocks are never returned. Ollama PTY captures
111
+ cannot reliably distinguish roles and are explicitly unsupported.
112
+ """
113
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
114
+ raise CdxError("Conversation limit must be an integer between 1 and 1000.")
115
+ page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False}
116
+ if provider not in PROVIDERS or provider == "ollama":
117
+ return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
118
+ # An explicit conversation must never fall back to an unrelated recent file.
119
+ if conversation_id:
120
+ path = conversation_transcript(provider, auth_home, conversation_id)
121
+ else:
122
+ path, _match = resolve_transcript(provider, auth_home, started_at, terminal_transcript=terminal_transcript)
123
+ if not path:
124
+ return {**page, "status": "unavailable", "reason": "transcript_not_found"}
125
+ try:
126
+ with open(path, "rb") as handle:
127
+ stat = os.fstat(handle.fileno())
128
+ identity = hashlib.sha256(f"{provider}:{os.path.realpath(path)}:{stat.st_dev}:{stat.st_ino}".encode()).hexdigest()
129
+ offset, index, names = 0, 0, {}
130
+ if cursor is not None:
131
+ try:
132
+ if not isinstance(cursor, str) or len(cursor) > 65536:
133
+ raise ValueError
134
+ state = json.loads(base64.urlsafe_b64decode(cursor.encode("ascii")))
135
+ offset, index = state["offset"], state["index"]
136
+ names = state["names"]
137
+ if (not isinstance(names, dict) or len(names) > 100
138
+ or any(not isinstance(k, str) or not re.fullmatch(r"[0-9a-f]{64}", k)
139
+ or not isinstance(v, str) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_.:/-]{0,99}", v)
140
+ or _redact(v) != v for k, v in names.items())):
141
+ raise ValueError
142
+ if (state["v"] != 1 or state["file"] != identity
143
+ or type(offset) is not int or type(index) is not int
144
+ or not 0 <= offset <= stat.st_size or not 0 <= index <= MAX_LINE_BYTES
145
+ or state["anchor"] != _anchor(handle, offset)):
146
+ raise ValueError
147
+ except (ValueError, TypeError, KeyError, AttributeError, UnicodeError):
148
+ return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
149
+ handle.seek(offset)
150
+ scanned = 0
151
+ while len(page["entries"]) < limit and scanned < MAX_PAGE_BYTES:
152
+ start = handle.tell()
153
+ line = handle.readline(MAX_LINE_BYTES + 1)
154
+ if len(line) > MAX_LINE_BYTES:
155
+ return {**page, "status": "unavailable", "reason": "record_too_large", "entries": []}
156
+ if not line or not line.endswith(b"\n"):
157
+ break
158
+ scanned += len(line)
159
+ try:
160
+ entries = _entries(provider, json.loads(line), names)
161
+ except (ValueError, UnicodeError):
162
+ entries = []
163
+ for block_index in range(index, len(entries)):
164
+ entry = entries[block_index]
165
+ entry["id"] = f"{identity[:16]}:{start}:{block_index}"
166
+ page["entries"].append(entry)
167
+ if len(page["entries"]) == limit:
168
+ index = block_index + 1
169
+ break
170
+ else:
171
+ index = len(entries)
172
+ if index < len(entries):
173
+ offset = start
174
+ break
175
+ offset, index = handle.tell(), 0
176
+ page["has_more"] = offset < stat.st_size
177
+ state = {"v": 1, "file": identity, "offset": offset, "index": index, "anchor": _anchor(handle, offset), "names": names}
178
+ page["next_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
179
+ return page
180
+ except OSError:
181
+ 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:
@@ -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,