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 +70 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_0.md +31 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +12 -1
- package/src/cli_args.py +7 -4
- package/src/cli_commands.py +3 -2
- package/src/cli_helpers.py +1 -0
- package/src/commands/conversation.py +34 -0
- package/src/config.py +15 -0
- package/src/conversation.py +181 -0
- package/src/session_service.py +4 -1
- package/src/session_status.py +1 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
|
|
10
10
|
|
|
@@ -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.
|
|
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
package/pyproject.toml
CHANGED
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:
|
package/src/cli_commands.py
CHANGED
|
@@ -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
|
)
|
package/src/cli_helpers.py
CHANGED
|
@@ -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": []}
|
package/src/session_service.py
CHANGED
|
@@ -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:
|
package/src/session_status.py
CHANGED
|
@@ -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,
|