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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.7-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.9-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
@@ -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.7
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.7",
3
+ "version": "0.26.9",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.26.7"
7
+ version = "0.26.9"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
package/src/cli.py CHANGED
@@ -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
- # Passthrough arguments are deliberately absent from this contract.
509
- # `cdx set --extra-args` reaches the provider unchanged and cdx never
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
- "reason": "Passed to the provider CLI unchanged; cdx neither parses nor verifies these arguments.",
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
@@ -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
- argv += [flag, str(value)]
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("launch") or {}).get("extra_args")) or []:
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 = {**next_session, "launch": {**(next_session.get("launch") or {}), **launch_updates}}
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
@@ -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, tail=None, before=None, cwd=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, 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
- if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
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 conversation_id:
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