cdx-manager 0.26.7 → 0.26.9
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_0_26_9.md +16 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +5 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +82 -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 +61 -9
- package/src/process_conversation.py +140 -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.9
|
|
@@ -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`).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CDX Manager 0.26.9
|
|
2
|
+
|
|
3
|
+
Release date: 2026-11-08
|
|
4
|
+
|
|
5
|
+
## Fixes
|
|
6
|
+
|
|
7
|
+
- Resolve the conversation of a live interactive Codex process from the
|
|
8
|
+
rollout file it holds open (#126, `abb19e0`). `cdx conversation <profile>
|
|
9
|
+
--pid PID` no longer answers `native_conversation_identity_unavailable`
|
|
10
|
+
for an interactive Codex session whose native process writes an exact
|
|
11
|
+
rollout; it still refuses workspace or recency guesses.
|
|
12
|
+
|
|
13
|
+
## Maintenance
|
|
14
|
+
|
|
15
|
+
- Record tray companion checksums for v0.26.8 (`e04fae8`).
|
|
16
|
+
- Record release archive checksums for v0.26.8 (`f41f2b3`).
|
|
@@ -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,28 @@
|
|
|
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
|
+
}
|
|
884
|
+
},
|
|
885
|
+
"v0.26.8": {
|
|
886
|
+
"github_tarball_sha256": "528b9147a815bbe8565e63dc7089951691427cdbf0ec7c2c4a5740029c68d612",
|
|
887
|
+
"github_zip_sha256": "1f829aa5f889663e4b974d5ad91a3160418252d9349afd5899afaa27084cf4cb",
|
|
888
|
+
"tray_assets": {
|
|
889
|
+
"aarch64-apple-darwin": "947ca5e15d9ef9db080eb33e1a09376420118e4af0c9fc049c12d2e8ae9649c5",
|
|
890
|
+
"aarch64-unknown-linux-musl": "004e423dcff66cef815140ca96d516089306bc6e8e940a4d2f6f657eeb2f9f6b",
|
|
891
|
+
"x86_64-apple-darwin": "b5780dcff73502155fc8c24f1b3bdd9f18cea129c7a24c017d40168ee5ba8867",
|
|
892
|
+
"x86_64-pc-windows-msvc": "f68d4fb19c4e94b31662cd46eb9ae8aa2b3e4bf53d3bde4fce1b540df7719e26",
|
|
893
|
+
"x86_64-unknown-linux-musl": "ada7fcbd0c9202cad8a5e55b60a6b85af34210157a4d7352451bcb95bbb103cd"
|
|
894
|
+
}
|
|
873
895
|
}
|
|
874
896
|
}
|
|
875
897
|
}
|
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,84 @@ 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` | `resolved` for verified native rollout evidence, otherwise `unknown` |
|
|
714
|
+
| `resolution_method` | `process_open_rollout` when resolved, otherwise `null` |
|
|
715
|
+
| `conversation_id` | Native `session_meta.payload.id`, checked against the open rollout filename |
|
|
716
|
+
| `process_identity` | Opaque Linux lifetime guard when process validation succeeds |
|
|
717
|
+
| `reason` | `null` on success; explicit evidence boundary or process validation failure otherwise |
|
|
718
|
+
|
|
719
|
+
On Linux, CDX inspects only the supplied PID's open descriptors. A unique writable
|
|
720
|
+
`rollout-…-UUID.jsonl` under the selected profile's `sessions/` directory supplies
|
|
721
|
+
the native identity. Its first record must be a complete `session_meta` with a
|
|
722
|
+
matching UUID and `source: "cli"`. Duplicate descriptors for the same file count
|
|
723
|
+
once; read-only descriptors do not establish the active writer. Multiple distinct
|
|
724
|
+
writable rollouts return `ambiguous_native_rollouts`, even if one is newer. A
|
|
725
|
+
rollout outside the selected profile returns `profile_boundary_mismatch` before
|
|
726
|
+
its contents are read. Resolved symlinks must remain inside the profile, including
|
|
727
|
+
the `sessions/` root. No workspace, mtime, saved resume ID or recency fallback is
|
|
728
|
+
used, even if `fallback_to_recency=True` is supplied through Python.
|
|
729
|
+
|
|
730
|
+
This is a snapshot of the process's sole native rollout writer. A rollout must
|
|
731
|
+
already exist and remain open for writing; a fresh thread before its first
|
|
732
|
+
rollout returns `native_conversation_identity_unavailable`. A retained writer
|
|
733
|
+
and another open writer are ambiguous. The resolver does not infer TUI focus
|
|
734
|
+
from file timestamps or claim to observe selection changes without a writer
|
|
735
|
+
change. Consumers must treat unknown as unknown and must not retry via recency.
|
|
736
|
+
|
|
737
|
+
The exact open file's device/inode is checked against its profile path before
|
|
738
|
+
reading. CDX rechecks the lifetime guard and native rollout evidence before and
|
|
739
|
+
after the message read. A detected exit, PID reuse, switch, replacement or lost
|
|
740
|
+
proof discards all entries. These checks are snapshots, not an atomic provider
|
|
741
|
+
selection subscription; the process can change after the final check.
|
|
742
|
+
|
|
743
|
+
Resolved requests return `status: "ok"` and normalized entries, including support
|
|
744
|
+
for `--tail`. Unknown requests return `status: "unavailable"`, no entries and a
|
|
745
|
+
null conversation ID. Process pagination remains disabled: no cursors are issued,
|
|
746
|
+
`has_more` is false, and `--cursor` or `--before` returns `reset_required` without
|
|
747
|
+
entries. Poll again using the retained process identity and `--tail`; an
|
|
748
|
+
explicit-ID or recency cursor cannot cross into process mode. Existing explicit-ID
|
|
749
|
+
and recency pagination contracts are unchanged.
|
|
750
|
+
|
|
751
|
+
Only Linux `/proc` and interactive Codex are supported. Each evidence pass reads
|
|
752
|
+
at most 8 KiB each from `stat`, `status`, kernel `boot_id` and each candidate's
|
|
753
|
+
`fdinfo`, enumerates at most 512 descriptors, and reads at most 64 KiB of each
|
|
754
|
+
candidate's metadata header. Message reads use the existing bounded conversation
|
|
755
|
+
reader and redaction. The lifetime guard includes PID, start ticks, boot ID, all
|
|
756
|
+
four target UIDs and executable device/inode. Wrong user, a non-`codex` executable,
|
|
757
|
+
dead/reused PID, inaccessible/oversized/malformed evidence, or unsupported
|
|
758
|
+
provider/platform fails closed. Reasons include `unrelated_user`,
|
|
759
|
+
`unrelated_process`, `process_not_running`, `process_identity_changed`,
|
|
760
|
+
`native_evidence_unavailable`, `invalid_native_rollout`, `evidence_changed`,
|
|
761
|
+
`evidence_limit`, and `unsupported_process_resolution`. No argv, environment,
|
|
762
|
+
credential store, descendant traversal, profile transcript search or history
|
|
763
|
+
database participates in process resolution. No launch flags or dependencies
|
|
764
|
+
change. Tower's authenticated TUI still requires a measured comparison of the
|
|
765
|
+
returned UUID with its selected native conversation after launch, resume and
|
|
766
|
+
switch, especially with concurrent sessions.
|
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,8 +379,61 @@ 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,
|
|
383
|
-
terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False,
|
|
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, evidence = 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
|
+
# Process pagination remains disabled: an unbound explicit-ID or recency
|
|
416
|
+
# cursor must never cross into this mode.
|
|
417
|
+
if cursor is not None or before is not None:
|
|
418
|
+
return {**page, "status": "reset_required"}
|
|
419
|
+
if evidence is None:
|
|
420
|
+
return page
|
|
421
|
+
selected = _read_conversation_page(provider, auth_home, limit=limit, tail=tail,
|
|
422
|
+
_transcript=evidence[1], _file_identity=evidence[2])
|
|
423
|
+
checked, current = resolve_process_conversation(provider, auth_home, process_identity=resolution["process_identity"])
|
|
424
|
+
if current != evidence or checked != resolution:
|
|
425
|
+
return {**page, **checked, "resolution_status": "unknown", "resolution_method": None,
|
|
426
|
+
"conversation_id": None, "reason": checked["reason"] or "evidence_changed"}
|
|
427
|
+
if selected["status"] != "ok":
|
|
428
|
+
return {**page, "resolution_status": "unknown", "resolution_method": None,
|
|
429
|
+
"conversation_id": None, "reason": selected["reason"]}
|
|
430
|
+
return {**selected, **resolution, "reason": None, "next_cursor": None, "before_cursor": None,
|
|
431
|
+
"has_more": False}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _read_conversation_page(provider, auth_home=None, *, conversation_id=None, started_at=None,
|
|
435
|
+
terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False, tail=None, before=None, cwd=None,
|
|
436
|
+
_transcript=None, _file_identity=None):
|
|
384
437
|
"""Return a JSON-serializable page; pass next_cursor back unchanged to poll.
|
|
385
438
|
|
|
386
439
|
Entries follow file/block order. Cursors bind a byte offset and block index
|
|
@@ -394,18 +447,13 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
394
447
|
Tool bodies and reasoning blocks are never returned. Ollama PTY captures
|
|
395
448
|
cannot reliably distinguish roles and are explicitly unsupported.
|
|
396
449
|
"""
|
|
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.")
|
|
450
|
+
_validate_page_options(limit, tail, cursor, before)
|
|
403
451
|
if started_at is not None:
|
|
404
452
|
try:
|
|
405
453
|
datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
406
454
|
except (ValueError, AttributeError, TypeError):
|
|
407
455
|
raise CdxError("Conversation --since must be an ISO timestamp.") from None
|
|
408
|
-
scope = {"cwd": os.path.realpath(os.path.expanduser(cwd or os.getcwd())), "since": started_at}
|
|
456
|
+
scope = None if _transcript is not None else {"cwd": os.path.realpath(os.path.expanduser(cwd or os.getcwd())), "since": started_at}
|
|
409
457
|
page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False,
|
|
410
458
|
"resolution_method": None, "resolution_scope": None}
|
|
411
459
|
if tail is not None or before is not None:
|
|
@@ -413,7 +461,9 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
413
461
|
if provider not in PROVIDERS or provider == "ollama":
|
|
414
462
|
return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
|
|
415
463
|
# An explicit conversation must never fall back to an unrelated recent file.
|
|
416
|
-
if
|
|
464
|
+
if _transcript is not None:
|
|
465
|
+
path = _transcript
|
|
466
|
+
elif conversation_id:
|
|
417
467
|
path = conversation_transcript(provider, auth_home, conversation_id)
|
|
418
468
|
if path:
|
|
419
469
|
page["resolution_method"] = "conversation_id"
|
|
@@ -429,6 +479,8 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
429
479
|
try:
|
|
430
480
|
with open(path, "rb") as handle:
|
|
431
481
|
stat = os.fstat(handle.fileno())
|
|
482
|
+
if _file_identity is not None and (stat.st_dev, stat.st_ino) != _file_identity:
|
|
483
|
+
return {**page, "status": "unavailable", "reason": "evidence_changed"}
|
|
432
484
|
identity = hashlib.sha256(f"{provider}:{os.path.realpath(path)}:{stat.st_dev}:{stat.st_ino}".encode()).hexdigest()
|
|
433
485
|
if tail is not None or before is not None:
|
|
434
486
|
return _read_tail(handle, stat, identity, provider, page, tail if tail is not None else limit, before)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Bounded Linux process identity and profile-scoped native rollout evidence.
|
|
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 json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
PROC_ROOT = Path("/proc")
|
|
16
|
+
_UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
17
|
+
_IDENTITY = re.compile(r"linux:(\d+):[0-9a-f]{64}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Unknown(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _bounded(path, limit=8192):
|
|
25
|
+
with open(path, "rb") as handle:
|
|
26
|
+
value = handle.read(limit + 1)
|
|
27
|
+
if len(value) > limit:
|
|
28
|
+
raise _Unknown("evidence_limit")
|
|
29
|
+
return value.decode("utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _process(pid):
|
|
33
|
+
root = PROC_ROOT / str(pid)
|
|
34
|
+
fields = _bounded(root / "stat").rsplit(")", 1)[1].split()
|
|
35
|
+
if fields[0] in ("Z", "X", "x"):
|
|
36
|
+
raise _Unknown("process_not_running")
|
|
37
|
+
uids = next(line.split()[1:] for line in _bounded(root / "status").splitlines() if line.startswith("Uid:"))
|
|
38
|
+
if len(uids) != 4 or any(int(uid) != os.getuid() for uid in uids):
|
|
39
|
+
raise _Unknown("unrelated_user")
|
|
40
|
+
executable = os.readlink(root / "exe")
|
|
41
|
+
if Path(executable).name != "codex":
|
|
42
|
+
raise _Unknown("unrelated_process")
|
|
43
|
+
exe = os.stat(root / "exe")
|
|
44
|
+
boot = _bounded(PROC_ROOT / "sys/kernel/random/boot_id").strip()
|
|
45
|
+
if not re.fullmatch(_UUID, boot):
|
|
46
|
+
raise _Unknown("invalid_evidence")
|
|
47
|
+
digest = hashlib.sha256(f"{boot}:{pid}:{fields[19]}:{uids}:{exe.st_dev}:{exe.st_ino}".encode()).hexdigest()
|
|
48
|
+
return f"linux:{pid}:{digest}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _rollout(pid, auth_home):
|
|
52
|
+
"""Identify a sole writable rollout; multiple native writers are ambiguous."""
|
|
53
|
+
home = Path(auth_home).resolve()
|
|
54
|
+
sessions = (home / "sessions").resolve()
|
|
55
|
+
if not sessions.is_relative_to(home):
|
|
56
|
+
raise _Unknown("profile_boundary_mismatch")
|
|
57
|
+
candidates = set()
|
|
58
|
+
root = PROC_ROOT / str(pid)
|
|
59
|
+
with os.scandir(root / "fd") as descriptors:
|
|
60
|
+
for count, descriptor in enumerate(descriptors):
|
|
61
|
+
if count >= 512:
|
|
62
|
+
raise _Unknown("evidence_limit")
|
|
63
|
+
try:
|
|
64
|
+
target = os.readlink(descriptor.path)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
continue # Unrelated descriptors can close during enumeration.
|
|
67
|
+
name = Path(target).name
|
|
68
|
+
if not name.startswith("rollout-") or ".jsonl" not in name:
|
|
69
|
+
continue
|
|
70
|
+
flags = next(line.split()[1] for line in _bounded(root / "fdinfo" / descriptor.name).splitlines()
|
|
71
|
+
if line.startswith("flags:"))
|
|
72
|
+
if int(flags, 8) & os.O_ACCMODE not in (os.O_WRONLY, os.O_RDWR):
|
|
73
|
+
continue
|
|
74
|
+
path = Path(target).resolve()
|
|
75
|
+
if not path.is_relative_to(sessions):
|
|
76
|
+
raise _Unknown("profile_boundary_mismatch")
|
|
77
|
+
match = re.fullmatch(r"rollout-.*(" + _UUID + r")\.jsonl", name)
|
|
78
|
+
if not match:
|
|
79
|
+
raise _Unknown("invalid_native_rollout")
|
|
80
|
+
file_stat = os.stat(descriptor.path)
|
|
81
|
+
path_stat = path.stat()
|
|
82
|
+
file_identity = (file_stat.st_dev, file_stat.st_ino)
|
|
83
|
+
if not stat.S_ISREG(file_stat.st_mode) or file_identity != (path_stat.st_dev, path_stat.st_ino):
|
|
84
|
+
raise _Unknown("evidence_changed")
|
|
85
|
+
# Read only the metadata header of the exact open file, never a
|
|
86
|
+
# transcript found by scanning the profile or matching a workspace.
|
|
87
|
+
with open(descriptor.path, "rb") as handle:
|
|
88
|
+
opened = os.fstat(handle.fileno())
|
|
89
|
+
if (opened.st_dev, opened.st_ino) != file_identity:
|
|
90
|
+
raise _Unknown("evidence_changed")
|
|
91
|
+
header = handle.readline(65537)
|
|
92
|
+
if len(header) > 65536:
|
|
93
|
+
raise _Unknown("evidence_limit")
|
|
94
|
+
if not header.endswith(b"\n"):
|
|
95
|
+
raise _Unknown("invalid_native_rollout")
|
|
96
|
+
metadata = json.loads(header)
|
|
97
|
+
payload = metadata.get("payload")
|
|
98
|
+
if (metadata.get("type") != "session_meta" or not isinstance(payload, dict)
|
|
99
|
+
or payload.get("id") != match[1] or payload.get("source") != "cli"):
|
|
100
|
+
raise _Unknown("invalid_native_rollout")
|
|
101
|
+
candidates.add((match[1], path, file_identity))
|
|
102
|
+
if len(candidates) > 1:
|
|
103
|
+
raise _Unknown("ambiguous_native_rollouts")
|
|
104
|
+
if not candidates:
|
|
105
|
+
raise _Unknown("native_conversation_identity_unavailable")
|
|
106
|
+
return candidates.pop()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_process_conversation(provider, auth_home, *, pid=None, process_identity=None):
|
|
110
|
+
"""Return public resolution fields and private exact-file evidence, if present.
|
|
111
|
+
|
|
112
|
+
A bare PID means the process alive *now*. Persist process_identity to reject
|
|
113
|
+
reuse on subsequent calls. The identity is an opaque guard, not a secret or
|
|
114
|
+
authorization grant. Only Linux /proc and interactive Codex are supported.
|
|
115
|
+
"""
|
|
116
|
+
result = {"resolution_status": "unknown", "resolution_method": None,
|
|
117
|
+
"conversation_id": None, "process_identity": None, "reason": None}
|
|
118
|
+
if provider != "codex" or sys.platform != "linux":
|
|
119
|
+
return {**result, "reason": "unsupported_process_resolution"}, None
|
|
120
|
+
if process_identity is not None:
|
|
121
|
+
match = _IDENTITY.fullmatch(process_identity) if isinstance(process_identity, str) and len(process_identity) <= 100 else None
|
|
122
|
+
if not match or pid is not None:
|
|
123
|
+
return {**result, "reason": "invalid_process_identity"}, None
|
|
124
|
+
pid = int(match[1])
|
|
125
|
+
if type(pid) is not int or not 0 < pid < 2**31 or not auth_home:
|
|
126
|
+
return {**result, "reason": "invalid_process_identity"}, None
|
|
127
|
+
try:
|
|
128
|
+
identity = _process(pid)
|
|
129
|
+
if process_identity is not None and identity != process_identity:
|
|
130
|
+
raise _Unknown("process_identity_changed")
|
|
131
|
+
result["process_identity"] = identity
|
|
132
|
+
evidence = _rollout(pid, auth_home)
|
|
133
|
+
if _process(pid) != identity or _rollout(pid, auth_home) != evidence:
|
|
134
|
+
raise _Unknown("evidence_changed")
|
|
135
|
+
return {**result, "resolution_status": "resolved", "resolution_method": "process_open_rollout",
|
|
136
|
+
"conversation_id": evidence[0]}, evidence
|
|
137
|
+
except _Unknown as exc:
|
|
138
|
+
return {**result, "reason": str(exc)}, None
|
|
139
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError, StopIteration, UnicodeError, RecursionError, AttributeError):
|
|
140
|
+
return {**result, "reason": "native_evidence_unavailable"}, None
|