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 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.8-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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.7",
3
+ "version": "0.26.8",
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.8"
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,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
- 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.")
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"}