cdx-manager 0.26.7 → 0.26.8
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 +9 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_8.md +19 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +5 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +73 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +10 -6
- package/src/commands/conversation.py +10 -3
- package/src/commands/runs.py +15 -14
- package/src/conversation.py +41 -6
- package/src/process_conversation.py +81 -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
|
|
|
@@ -120,6 +120,14 @@ cdx run work --cwd /path/to/repo --prompt "Implement the change" --reasoning-eff
|
|
|
120
120
|
cdx run --continue-run RUN_ID --cwd /path/to/repo --prompt "Fix the remaining test failure" --json
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
Pass provider arguments for a single run with `--extra-args 'ARGS'`. They use the session setting's POSIX quoting and validation (nonempty, at most 512 characters, no control characters, balanced quotes), and are appended after the profile's `extra_args`. They do not change the profile or subsequent interactive launches. Provider flag semantics remain unvalidated, as listed under `unvalidated.extra_args` in `cdx schema --json`; existing provider permission and workspace isolation handling still applies. Codex headless launches retain their existing behavior of ignoring session and run extra arguments.
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cdx run claude-work --cwd /path/to/repo --permission default --prompt 'Run: touch t' --extra-args '--allowedTools "Bash(touch t)" --strict-mcp-config' --json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This allows the requested Claude tool and restricts MCP configuration for this run only. The option also follows detached runs and profile failover. With `--continue-run`, Claude extra arguments that override model, effort or conversation are refused, whether supplied by the session or this run.
|
|
130
|
+
|
|
123
131
|
`--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.
|
|
124
132
|
|
|
125
133
|
Headless runs, including detached, remote and continued runs, keep their
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.26.
|
|
1
|
+
0.26.8
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# CDX Manager 0.26.8
|
|
2
|
+
|
|
3
|
+
Release date: 2026-11-07
|
|
4
|
+
|
|
5
|
+
## Improvements
|
|
6
|
+
|
|
7
|
+
- Resolve the conversation of a live Codex process (#125,
|
|
8
|
+
`2150671b`). `cdx conversation <profile> --pid PID` and
|
|
9
|
+
`--process-identity ID` read the exact conversation of a native Codex
|
|
10
|
+
process, without falling back to workspace or recency matching.
|
|
11
|
+
- Add per-run provider arguments to `cdx run` (#120, `92620af`).
|
|
12
|
+
`--extra-args 'ARGS'` is validated like the session setting and appended
|
|
13
|
+
after the profile's arguments, without changing saved settings or
|
|
14
|
+
interactive launches.
|
|
15
|
+
|
|
16
|
+
## Maintenance
|
|
17
|
+
|
|
18
|
+
- Record tray companion checksums for v0.26.7 (`bd53cb9`).
|
|
19
|
+
- Record release archive checksums for v0.26.7 (`145ec42`).
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Unreleased
|
|
2
2
|
|
|
3
|
+
## Per-run provider arguments (#120)
|
|
4
|
+
|
|
5
|
+
- Add `cdx run --extra-args 'ARGS'` with the session setting's validation and POSIX splitting, appended after the selected profile's arguments without changing saved settings or interactive launches. Preserve existing provider permission and isolation handling.
|
|
6
|
+
- Forward run arguments to detached supervisors and profile failover; reject Claude model, effort and conversation overrides during continuation. List the option under `cdx schema --json` unvalidated arguments and cover run-only Claude tool allowlists and strict MCP configuration.
|
|
7
|
+
|
|
3
8
|
## Claude profile quota status line
|
|
4
9
|
|
|
5
10
|
- Install a fast cache-only status line for Claude profiles at creation, settings updates and interactive launch/resume. Show the shared launcher gauge, both remaining windows, profile name, low-quota resets and a stale marker; use `quota ?` when data is unavailable.
|
|
@@ -870,6 +870,17 @@
|
|
|
870
870
|
"x86_64-pc-windows-msvc": "82fcceda389ba33ef60bc01ccce62249c0a1866479611192761778646ad33880",
|
|
871
871
|
"x86_64-unknown-linux-musl": "a1194dc6e672729e7d6ba079b7c1a393e7098e83edd6ab3b6fd63832a7dad136"
|
|
872
872
|
}
|
|
873
|
+
},
|
|
874
|
+
"v0.26.7": {
|
|
875
|
+
"github_tarball_sha256": "26e961c60c2473946cb5bfadec467b4d4ca9fd5a07cd313145cf4c4d427b1239",
|
|
876
|
+
"github_zip_sha256": "324c8f938f037e19c31b19e9fc6d4721ca822a7aa9a14d8e0e1af8da52aafaad",
|
|
877
|
+
"tray_assets": {
|
|
878
|
+
"aarch64-apple-darwin": "80088939f95fb6508f98b1e9a29643372dcc5e4cb2f72f06c5016f1708f6f526",
|
|
879
|
+
"aarch64-unknown-linux-musl": "29a044fa1241f38ce2892bd37388258ab1f10d66a74c3beef6462d85a8ab52c2",
|
|
880
|
+
"x86_64-apple-darwin": "d32be4180b361195ec9faef888dc9ff1a996bb74295882008ccb2b07dd16789d",
|
|
881
|
+
"x86_64-pc-windows-msvc": "098c27b535f041b72b6aac89d2c34464a986178be881336b89f99c8dfb5fdb5d",
|
|
882
|
+
"x86_64-unknown-linux-musl": "195737be7f25dc30bab676f53aed2c426612aee55de4cfc95a55a5b2d8ca9b76"
|
|
883
|
+
}
|
|
873
884
|
}
|
|
874
885
|
}
|
|
875
886
|
}
|
package/docs/cli.md
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
| `cdx agents [--max-age-hours N] [--json]` | Each session's last known agent state (working, waiting, done, failed, limit, ended), waiting first; records older than N hours (default 24) are dropped. See [Agent state](sessions-and-notifications.md#agent-state) |
|
|
59
59
|
| `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
|
|
60
60
|
| `cdx select --provider PROVIDER [--min-reasoning-effort minimal\|low\|medium\|high\|xhigh] [--min-power minimal\|low\|medium\|high\|xhigh] [--require-ready] [--refresh] --json` | Select a suitable session for headless automation |
|
|
61
|
-
| `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--budget USD] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
|
|
61
|
+
| `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--budget USD] [--extra-args 'ARGS'] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
|
|
62
62
|
| `cdx run-report <run_id> --json` | Full report, transcript metadata, and final payload for a run; `final_message` is the run's last assistant message, read from Claude's `result` envelope or Codex's last `agent_message` (null when neither exists) |
|
|
63
63
|
| `cdx run-status <run_id> --json` | Status of one run by id |
|
|
64
64
|
| `cdx run-tail <run_id> [--lines N] --json` | Last lines of a run's own output, while it is still running or after it finished |
|
|
@@ -683,3 +683,75 @@ Interactive launches appear as `status: "running"` as soon as the provider start
|
|
|
683
683
|
History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
|
|
684
684
|
|
|
685
685
|
`first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
|
|
686
|
+
|
|
687
|
+
### Resolve the conversation of a live Codex process
|
|
688
|
+
|
|
689
|
+
Consumers such as remote-ops can request process-scoped resolution without
|
|
690
|
+
falling back to workspace/recency:
|
|
691
|
+
|
|
692
|
+
```bash
|
|
693
|
+
cdx conversation work2 --pid 12345 --tail 20 --json
|
|
694
|
+
cdx conversation work2 --process-identity 'linux:12345:<opaque digest from response>' --json
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Supply the native Codex PID, not the shell, tmux pane PID, Node launcher, or CDX
|
|
698
|
+
supervisor. CDX does not traverse descendants or choose between processes.
|
|
699
|
+
`work2` supplies the provider/profile boundary; its saved conversation is ignored
|
|
700
|
+
in process mode. The selectors are mutually exclusive and cannot be combined with
|
|
701
|
+
`--conversation`, `--cwd`, or `--since`.
|
|
702
|
+
|
|
703
|
+
The Python API is `read_conversation("codex", auth_home, pid=12345, tail=20)` in
|
|
704
|
+
`cdx_manager.conversation` (`src.conversation` in a source checkout). Retain
|
|
705
|
+
`process_identity` and pass it instead of `pid` on later calls to reject PID reuse.
|
|
706
|
+
A bare PID identifies its current occupant, not a previously observed process.
|
|
707
|
+
The opaque identity is a lifetime guard, not an authorization grant.
|
|
708
|
+
|
|
709
|
+
Process-selected pages expose:
|
|
710
|
+
|
|
711
|
+
| Field | Current contract |
|
|
712
|
+
| --- | --- |
|
|
713
|
+
| `resolution_status` | `unknown` until native selected-conversation evidence is available |
|
|
714
|
+
| `resolution_method` | `null`; kernel process identity alone is not a conversation resolution method |
|
|
715
|
+
| `conversation_id` | `null`; no candidate or guessed ID is exposed |
|
|
716
|
+
| `process_identity` | Opaque Linux lifetime guard when process validation succeeds |
|
|
717
|
+
| `reason` | Explicit evidence boundary or process validation failure |
|
|
718
|
+
|
|
719
|
+
**Current measured boundary:** process-to-selected-conversation resolution is not
|
|
720
|
+
available. Even a sole open writable rollout proves only that a process owns a
|
|
721
|
+
thread. During a TUI conversation switch it may belong to a retained thread while
|
|
722
|
+
the selected thread has no rollout yet. Resume argv, session/profile state, cwd,
|
|
723
|
+
mtime, a unique new transcript, and fuzzy text also cannot prove the current
|
|
724
|
+
selection. This implementation deliberately returns
|
|
725
|
+
`native_conversation_identity_unavailable` after validating a live Codex process;
|
|
726
|
+
it does not claim that an open file establishes the selected conversation.
|
|
727
|
+
Remote-ops must treat this as an explicit unknown, never retry via recency.
|
|
728
|
+
|
|
729
|
+
Process mode returns `status: "unavailable"`, empty entries, null conversation ID
|
|
730
|
+
and no cursors. With `--cursor` or `--before`, it returns `reset_required` and no
|
|
731
|
+
entries: a cursor from an explicit-ID or recency lookup cannot cross into process
|
|
732
|
+
mode. No process cursor is issued until it can be bound to both a verified process
|
|
733
|
+
lifetime and a native conversation selection/generation. Existing explicit-ID,
|
|
734
|
+
recency, forward, tail and backward pagination contracts remain unchanged.
|
|
735
|
+
|
|
736
|
+
Only Linux `/proc` process identity is currently supported. Each validation reads
|
|
737
|
+
at most 8 KiB each from process `stat`, `status` and kernel `boot_id`, twice, plus
|
|
738
|
+
the executable symlink/stat. The guard includes PID, start ticks, boot ID, all
|
|
739
|
+
four target UIDs and executable device/inode. Wrong user, a non-`codex` executable,
|
|
740
|
+
dead/reused PID, inaccessible/oversized/malformed evidence, or unsupported
|
|
741
|
+
provider/platform fails closed. Other reasons include `unrelated_user`,
|
|
742
|
+
`unrelated_process`, `process_not_running`, `process_identity_changed`,
|
|
743
|
+
`native_evidence_unavailable`, `evidence_limit`, and
|
|
744
|
+
`unsupported_process_resolution`. No argv, environment, credential store,
|
|
745
|
+
transcript file, directory scan or history database is read in process mode.
|
|
746
|
+
A process identity does not prove profile ownership; missing native profile and
|
|
747
|
+
thread association therefore remains unknown even for an existing profile.
|
|
748
|
+
|
|
749
|
+
The smallest required launch-time tracking is a provider-native event containing
|
|
750
|
+
the selected conversation ID on fresh start, resume and every selection change.
|
|
751
|
+
The launcher must bind each event atomically to the child's boot ID/PID/start ticks,
|
|
752
|
+
profile and a monotonic selection generation; invalidate it on exit. A future
|
|
753
|
+
resolver can consume that bounded record and bind cursors to the same generation.
|
|
754
|
+
An original resume ID or launch timestamp alone is insufficient. No launch/resume
|
|
755
|
+
flags or dependencies are changed here; implementing this requires a verified
|
|
756
|
+
provider lifecycle event source, which the inspected interactive interface does
|
|
757
|
+
not supply. See the mission fragment for the local native probe and Tower checks.
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -273,7 +273,7 @@ def _print_help(use_color=False):
|
|
|
273
273
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
|
|
274
274
|
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
275
275
|
f" {_style('cdx resume <name> [--project PATH] [--conversation <id>] [--json]', '36', use_color)}",
|
|
276
|
-
f" {_style('cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
|
|
276
|
+
f" {_style('cdx conversation <name> [--conversation ID | --pid PID | --process-identity ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
|
|
277
277
|
f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
|
|
278
278
|
f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
|
|
279
279
|
f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -22,6 +22,7 @@ from .config import (
|
|
|
22
22
|
)
|
|
23
23
|
from .errors import CdxArgumentError, CdxError
|
|
24
24
|
from .provider_runtime import _normalize_reasoning_effort
|
|
25
|
+
from .session_service import _normalize_launch_settings
|
|
25
26
|
|
|
26
27
|
STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
|
|
27
28
|
DOCTOR_USAGE = "Usage: cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]"
|
|
@@ -49,7 +50,7 @@ CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
|
|
|
49
50
|
SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
|
|
50
51
|
SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
|
|
51
52
|
NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
|
|
52
|
-
RUN_USAGE = "Usage: cdx run [session] [--remote SSH_TARGET] [--remote-prefix COMMAND] (--cwd|--dir) PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--budget USD] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json"
|
|
53
|
+
RUN_USAGE = "Usage: cdx run [session] [--remote SSH_TARGET] [--remote-prefix COMMAND] (--cwd|--dir) PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--budget USD] [--extra-args 'ARGS'] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json"
|
|
53
54
|
RUN_USAGE += "\nRemote Ollama generates text only; it is not an autonomous coding agent and does not edit the repository. Requires an installed worker model; no authentication."
|
|
54
55
|
RUN_JSON_REQUIRED = "cdx run: --json is required."
|
|
55
56
|
RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
|
|
@@ -505,13 +506,13 @@ def cdx_schema():
|
|
|
505
506
|
"kind": {"accepted": list(RUN_KIND_VALUES)},
|
|
506
507
|
"provider": {"accepted": list(PROVIDERS)},
|
|
507
508
|
},
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
# validates it, so publishing it here would claim a guarantee that does
|
|
511
|
-
# not exist. A caller must treat it as opaque.
|
|
509
|
+
# Provider flag semantics remain opaque; only the argument string's
|
|
510
|
+
# syntax and size are checked by cdx.
|
|
512
511
|
"unvalidated": {
|
|
513
512
|
"extra_args": {
|
|
514
|
-
"
|
|
513
|
+
"commands": ["set", "run"],
|
|
514
|
+
"option": "--extra-args",
|
|
515
|
+
"reason": "Split into literal argv entries; cdx does not verify provider flag semantics.",
|
|
515
516
|
"not_checked_by": ["cdx schema", "cdx doctor --check-provider-flags"],
|
|
516
517
|
},
|
|
517
518
|
},
|
|
@@ -697,6 +698,7 @@ def _parse_run_args(args):
|
|
|
697
698
|
"--provider": {"key": "provider", "type": "str", "default": None, "transform": _parse_run_provider},
|
|
698
699
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
699
700
|
"--continue-run": {"key": "continue_run", "type": "str", "default": None},
|
|
701
|
+
"--extra-args": {"key": "extra_args", "type": "str", "default": None},
|
|
700
702
|
"--kind": {"key": "kind", "type": "str", "default": "assistant"},
|
|
701
703
|
"--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
|
|
702
704
|
"--power": {"key": "power", "type": "str", "default": None},
|
|
@@ -755,6 +757,7 @@ def _parse_run_args(args):
|
|
|
755
757
|
raise CdxArgumentError("cdx run: --remote-prefix requires --remote and a nonempty command.",
|
|
756
758
|
code=ARG_CODE_INVALID_VALUE, arguments=["--remote-prefix"])
|
|
757
759
|
effort = _parse_run_effort(parsed["reasoning_effort"], parsed["power"])
|
|
760
|
+
extra = _normalize_launch_settings({"extra_args": parsed["extra_args"]}).get("extra_args")
|
|
758
761
|
return {
|
|
759
762
|
"name": name,
|
|
760
763
|
"remote": parsed["remote"],
|
|
@@ -765,6 +768,7 @@ def _parse_run_args(args):
|
|
|
765
768
|
"prompt_file": parsed["prompt_file"],
|
|
766
769
|
"prompt": parsed["prompt"],
|
|
767
770
|
"continue_run": parsed["continue_run"],
|
|
771
|
+
"extra_args": extra,
|
|
768
772
|
"kind": parsed["kind"],
|
|
769
773
|
"model": parsed["model"],
|
|
770
774
|
"permission": parsed["permission"],
|
|
@@ -6,13 +6,15 @@ from ..conversation import read_conversation
|
|
|
6
6
|
from ..errors import CdxError
|
|
7
7
|
from ..provider_runtime import _get_auth_home
|
|
8
8
|
|
|
9
|
-
CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json"
|
|
9
|
+
CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID | --pid PID | --process-identity ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json"
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def handle_conversation(rest, ctx):
|
|
13
13
|
parsed = _parse_flag_args(rest, {
|
|
14
14
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
15
15
|
"--conversation": {"key": "conversation", "type": "str"},
|
|
16
|
+
"--pid": {"key": "pid", "type": "str"},
|
|
17
|
+
"--process-identity": {"key": "process_identity", "type": "str"},
|
|
16
18
|
"--cwd": {"key": "cwd", "type": "str"},
|
|
17
19
|
"--since": {"key": "since", "type": "str"},
|
|
18
20
|
"--tail": {"key": "tail", "type": "str"},
|
|
@@ -26,15 +28,20 @@ def handle_conversation(rest, ctx):
|
|
|
26
28
|
if not session:
|
|
27
29
|
raise CdxError(f"Unknown session: {parsed['names'][0]}")
|
|
28
30
|
try:
|
|
31
|
+
pid = int(parsed["pid"]) if parsed["pid"] is not None else None
|
|
29
32
|
limit = int(parsed["limit"])
|
|
30
33
|
tail = int(parsed["tail"]) if parsed["tail"] is not None else None
|
|
31
34
|
except ValueError:
|
|
32
35
|
raise CdxError(CONVERSATION_USAGE) from None
|
|
36
|
+
process_selected = parsed["pid"] is not None or parsed["process_identity"] is not None
|
|
37
|
+
if parsed["pid"] is not None and parsed["process_identity"] is not None:
|
|
38
|
+
raise CdxError(CONVERSATION_USAGE)
|
|
33
39
|
page = read_conversation(
|
|
34
40
|
session["provider"], _get_auth_home(session),
|
|
35
|
-
conversation_id=parsed["conversation"] or (session.get("conversation") or {}).get("id"),
|
|
41
|
+
conversation_id=parsed["conversation"] or (None if process_selected else (session.get("conversation") or {}).get("id")),
|
|
42
|
+
pid=pid, process_identity=parsed["process_identity"],
|
|
36
43
|
cwd=parsed["cwd"], started_at=parsed["since"],
|
|
37
|
-
cursor=parsed["cursor"], limit=limit, tail=tail, before=parsed["before"], fallback_to_recency=not parsed["conversation"],
|
|
44
|
+
cursor=parsed["cursor"], limit=limit, tail=tail, before=parsed["before"], fallback_to_recency=not process_selected and not parsed["conversation"],
|
|
38
45
|
)
|
|
39
46
|
_write_json(ctx, _json_success("conversation", "Conversation page loaded.", session=session["name"], **page))
|
|
40
47
|
return 0
|
package/src/commands/runs.py
CHANGED
|
@@ -209,6 +209,7 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
|
209
209
|
("--project", "project"),
|
|
210
210
|
("--budget", "budget"),
|
|
211
211
|
("--continue-run", "continue_run"),
|
|
212
|
+
("--extra-args", "extra_args"),
|
|
212
213
|
("--model", "model"),
|
|
213
214
|
("--kind", "kind"),
|
|
214
215
|
("--permission", "permission"),
|
|
@@ -216,7 +217,8 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
|
216
217
|
):
|
|
217
218
|
value = parsed.get(key)
|
|
218
219
|
if value:
|
|
219
|
-
|
|
220
|
+
# A literal provider flag may also be a cdx flag, so bind the value.
|
|
221
|
+
argv += [f"{flag}={value}"] if key == "extra_args" else [flag, str(value)]
|
|
220
222
|
# reasoning_effort and power are normalized to the same value upstream;
|
|
221
223
|
# passing one back is enough and avoids re-tripping the must-match check.
|
|
222
224
|
if parsed.get("reasoning_effort"):
|
|
@@ -612,6 +614,14 @@ def _budget_observation(session, paths, baseline):
|
|
|
612
614
|
return observation
|
|
613
615
|
|
|
614
616
|
|
|
617
|
+
def _run_session(session, launch_updates, extra_args):
|
|
618
|
+
"""Apply run-only settings without mutating the profile or its successors."""
|
|
619
|
+
launch = {**(session.get("launch") or {}), **launch_updates}
|
|
620
|
+
if extra_args is not None:
|
|
621
|
+
launch["extra_args"] = " ".join(value for value in (launch.get("extra_args"), extra_args) if value)
|
|
622
|
+
return {**session, "launch": launch}
|
|
623
|
+
|
|
624
|
+
|
|
615
625
|
def _continue_run_session(parsed, ctx):
|
|
616
626
|
"""Resolve only the parent's profile and exact persisted conversation."""
|
|
617
627
|
previous = _run_registry(ctx).get(parsed["continue_run"])
|
|
@@ -651,7 +661,7 @@ def _continue_run_session(parsed, ctx):
|
|
|
651
661
|
pinned_options = {"--model", "--effort", "--fallback-model", "--resume", "--continue",
|
|
652
662
|
"--session-id", "--fork-session", "--resume-session-at", "--from-pr",
|
|
653
663
|
"--settings", "--agent", "--agents"}
|
|
654
|
-
for arg in split_extra_args((session.get("
|
|
664
|
+
for arg in split_extra_args(_run_session(session, {}, parsed.get("extra_args"))["launch"].get("extra_args")) or []:
|
|
655
665
|
if arg.split("=", 1)[0] in pinned_options or arg.startswith(("-r", "-c")):
|
|
656
666
|
raise CdxError("--continue-run cannot use Claude extra_args that override model, effort, or conversation.",
|
|
657
667
|
code="continue_run_settings_mismatch")
|
|
@@ -767,13 +777,7 @@ def handle_run(rest, ctx):
|
|
|
767
777
|
# the profile's current conversation. Continuations use the parent run.
|
|
768
778
|
if not parsed.get("continue_run"):
|
|
769
779
|
session = ctx["service"]["begin_conversation"](session["name"])
|
|
770
|
-
run_session =
|
|
771
|
-
**session,
|
|
772
|
-
"launch": {
|
|
773
|
-
**(session.get("launch") or {}),
|
|
774
|
-
**launch_updates,
|
|
775
|
-
},
|
|
776
|
-
}
|
|
780
|
+
run_session = _run_session(session, launch_updates, parsed.get("extra_args"))
|
|
777
781
|
if not parsed.get("remote"):
|
|
778
782
|
_ensure_session_authentication(
|
|
779
783
|
run_session,
|
|
@@ -970,7 +974,7 @@ def handle_run(rest, ctx):
|
|
|
970
974
|
else:
|
|
971
975
|
selection_row = successor.get("row") or {}
|
|
972
976
|
next_session = successor["session"]
|
|
973
|
-
run_session =
|
|
977
|
+
run_session = _run_session(next_session, launch_updates, parsed.get("extra_args"))
|
|
974
978
|
# Each new profile starts with the requested model, or its own default.
|
|
975
979
|
models = capacity_models(run_session)
|
|
976
980
|
model_index = capacity_retries = 0
|
|
@@ -1025,10 +1029,7 @@ def handle_run(rest, ctx):
|
|
|
1025
1029
|
failover_error_code = "failover_exhausted"
|
|
1026
1030
|
break
|
|
1027
1031
|
selection_row = successor.get("row") or {}
|
|
1028
|
-
run_session =
|
|
1029
|
-
**successor["session"],
|
|
1030
|
-
"launch": {**(successor["session"].get("launch") or {}), **launch_updates},
|
|
1031
|
-
}
|
|
1032
|
+
run_session = _run_session(successor["session"], launch_updates, parsed.get("extra_args"))
|
|
1032
1033
|
attempted.append(run_session["name"])
|
|
1033
1034
|
models = capacity_models(run_session)
|
|
1034
1035
|
model_index = capacity_retries = 0
|
package/src/conversation.py
CHANGED
|
@@ -379,7 +379,47 @@ def _read_tail(handle, stat, identity, provider, page, count, before):
|
|
|
379
379
|
return page
|
|
380
380
|
|
|
381
381
|
|
|
382
|
+
def _validate_page_options(limit, tail, cursor, before):
|
|
383
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
|
|
384
|
+
raise CdxError("Conversation limit must be an integer between 1 and 1000.")
|
|
385
|
+
if tail is not None and (type(tail) is not int or not 1 <= tail <= 1000):
|
|
386
|
+
raise CdxError("Conversation tail must be an integer between 1 and 1000.")
|
|
387
|
+
if cursor is not None and (tail is not None or before is not None):
|
|
388
|
+
raise CdxError("Conversation --cursor cannot be combined with --tail or --before.")
|
|
389
|
+
|
|
390
|
+
|
|
382
391
|
def read_conversation(provider, auth_home=None, *, conversation_id=None, started_at=None,
|
|
392
|
+
terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False,
|
|
393
|
+
tail=None, before=None, cwd=None, pid=None, process_identity=None):
|
|
394
|
+
"""Read a conversation page, optionally bound to a live native process.
|
|
395
|
+
|
|
396
|
+
Process selection never uses saved session IDs or recency. Keep the returned
|
|
397
|
+
opaque process_identity for later polls to detect PID reuse. Unknown evidence
|
|
398
|
+
returns no entries. See docs/cli.md for platform and snapshot limitations.
|
|
399
|
+
"""
|
|
400
|
+
_validate_page_options(limit, tail, cursor, before)
|
|
401
|
+
options = dict(conversation_id=conversation_id, started_at=started_at,
|
|
402
|
+
terminal_transcript=terminal_transcript, cursor=cursor, limit=limit,
|
|
403
|
+
fallback_to_recency=fallback_to_recency, tail=tail, before=before, cwd=cwd)
|
|
404
|
+
if pid is None and process_identity is None:
|
|
405
|
+
return _read_conversation_page(provider, auth_home, **options)
|
|
406
|
+
if conversation_id is not None or cwd is not None or started_at is not None or terminal_transcript is not None:
|
|
407
|
+
raise CdxError("Process selection cannot be combined with conversation, cwd, since or terminal transcript selection.")
|
|
408
|
+
from .process_conversation import resolve_process_conversation
|
|
409
|
+
|
|
410
|
+
resolution = resolve_process_conversation(provider, auth_home, pid=pid, process_identity=process_identity)
|
|
411
|
+
page = {"status": "unavailable", "provider": provider, "entries": [], "next_cursor": None,
|
|
412
|
+
"has_more": False, "resolution_scope": None, **resolution}
|
|
413
|
+
if tail is not None or before is not None:
|
|
414
|
+
page["before_cursor"] = None
|
|
415
|
+
# No process-bound cursor is issued until native selected-thread evidence
|
|
416
|
+
# exists. Never replay an explicit-ID or recency cursor through this mode.
|
|
417
|
+
if cursor is not None or before is not None:
|
|
418
|
+
page["status"] = "reset_required"
|
|
419
|
+
return page
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _read_conversation_page(provider, auth_home=None, *, conversation_id=None, started_at=None,
|
|
383
423
|
terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False, tail=None, before=None, cwd=None):
|
|
384
424
|
"""Return a JSON-serializable page; pass next_cursor back unchanged to poll.
|
|
385
425
|
|
|
@@ -394,12 +434,7 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
394
434
|
Tool bodies and reasoning blocks are never returned. Ollama PTY captures
|
|
395
435
|
cannot reliably distinguish roles and are explicitly unsupported.
|
|
396
436
|
"""
|
|
397
|
-
|
|
398
|
-
raise CdxError("Conversation limit must be an integer between 1 and 1000.")
|
|
399
|
-
if tail is not None and (type(tail) is not int or not 1 <= tail <= 1000):
|
|
400
|
-
raise CdxError("Conversation tail must be an integer between 1 and 1000.")
|
|
401
|
-
if cursor is not None and (tail is not None or before is not None):
|
|
402
|
-
raise CdxError("Conversation --cursor cannot be combined with --tail or --before.")
|
|
437
|
+
_validate_page_options(limit, tail, cursor, before)
|
|
403
438
|
if started_at is not None:
|
|
404
439
|
try:
|
|
405
440
|
datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Bounded Linux process identity, without guessing a selected conversation.
|
|
2
|
+
|
|
3
|
+
No argv, environment, credential stores, transcript searches or parent/child
|
|
4
|
+
heuristics participate in identity resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
PROC_ROOT = Path("/proc")
|
|
14
|
+
_UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
15
|
+
_IDENTITY = re.compile(r"linux:(\d+):[0-9a-f]{64}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Unknown(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bounded(path, limit=8192):
|
|
23
|
+
with open(path, "rb") as handle:
|
|
24
|
+
value = handle.read(limit + 1)
|
|
25
|
+
if len(value) > limit:
|
|
26
|
+
raise _Unknown("evidence_limit")
|
|
27
|
+
return value.decode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _process(pid):
|
|
31
|
+
root = PROC_ROOT / str(pid)
|
|
32
|
+
fields = _bounded(root / "stat").rsplit(")", 1)[1].split()
|
|
33
|
+
if fields[0] in ("Z", "X", "x"):
|
|
34
|
+
raise _Unknown("process_not_running")
|
|
35
|
+
uids = next(line.split()[1:] for line in _bounded(root / "status").splitlines() if line.startswith("Uid:"))
|
|
36
|
+
if len(uids) != 4 or any(int(uid) != os.getuid() for uid in uids):
|
|
37
|
+
raise _Unknown("unrelated_user")
|
|
38
|
+
executable = os.readlink(root / "exe")
|
|
39
|
+
if Path(executable).name != "codex":
|
|
40
|
+
raise _Unknown("unrelated_process")
|
|
41
|
+
exe = os.stat(root / "exe")
|
|
42
|
+
boot = _bounded(PROC_ROOT / "sys/kernel/random/boot_id").strip()
|
|
43
|
+
if not re.fullmatch(_UUID, boot):
|
|
44
|
+
raise _Unknown("invalid_evidence")
|
|
45
|
+
digest = hashlib.sha256(f"{boot}:{pid}:{fields[19]}:{uids}:{exe.st_dev}:{exe.st_ino}".encode()).hexdigest()
|
|
46
|
+
return f"linux:{pid}:{digest}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_process_conversation(provider, auth_home, *, pid=None, process_identity=None):
|
|
50
|
+
"""Return explicit unknown when the native selected-thread link is absent.
|
|
51
|
+
|
|
52
|
+
A bare PID means the process alive *now*. Persist process_identity to reject
|
|
53
|
+
reuse on subsequent calls. The identity is an opaque guard, not a secret or
|
|
54
|
+
authorization grant. Only Linux /proc and interactive Codex are supported.
|
|
55
|
+
"""
|
|
56
|
+
result = {"resolution_status": "unknown", "resolution_method": None,
|
|
57
|
+
"conversation_id": None, "process_identity": None, "reason": None}
|
|
58
|
+
if provider != "codex" or sys.platform != "linux":
|
|
59
|
+
return {**result, "reason": "unsupported_process_resolution"}
|
|
60
|
+
if process_identity is not None:
|
|
61
|
+
match = _IDENTITY.fullmatch(process_identity) if isinstance(process_identity, str) and len(process_identity) <= 100 else None
|
|
62
|
+
if not match or pid is not None:
|
|
63
|
+
return {**result, "reason": "invalid_process_identity"}
|
|
64
|
+
pid = int(match[1])
|
|
65
|
+
if type(pid) is not int or not 0 < pid < 2**31 or not auth_home:
|
|
66
|
+
return {**result, "reason": "invalid_process_identity"}
|
|
67
|
+
try:
|
|
68
|
+
identity = _process(pid)
|
|
69
|
+
if process_identity is not None and identity != process_identity:
|
|
70
|
+
raise _Unknown("process_identity_changed")
|
|
71
|
+
result["process_identity"] = identity
|
|
72
|
+
if _process(pid) != identity:
|
|
73
|
+
raise _Unknown("evidence_changed")
|
|
74
|
+
# Even a sole writable rollout may belong to a retained thread while
|
|
75
|
+
# another thread is selected but has not created its rollout. Neither
|
|
76
|
+
# that descriptor nor resume argv is a native selected-thread event.
|
|
77
|
+
return {**result, "reason": "native_conversation_identity_unavailable"}
|
|
78
|
+
except _Unknown as exc:
|
|
79
|
+
return {**result, "reason": str(exc)}
|
|
80
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError, StopIteration, UnicodeError, RecursionError):
|
|
81
|
+
return {**result, "reason": "native_evidence_unavailable"}
|