cdx-manager 0.26.6 → 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/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]"
@@ -35,8 +36,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
35
36
  MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
36
37
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
37
38
  LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
38
- SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--notify on|off] [--agent-state on|off] [--notify-preview on|off] [--scrub-env on|off] [--shared-memory on|off] [--retro on|off] [--policy use|ask-first|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--max-parallel 0..5] [--json]"
39
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--agent-state|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--max-parallel|--all) [--json]"
39
+ SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--notify on|off] [--agent-state on|off] [--status-line on|off] [--notify-preview on|off] [--scrub-env on|off] [--shared-memory on|off] [--retro on|off] [--policy use|ask-first|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--max-parallel 0..5] [--json]"
40
+ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--agent-state|--status-line|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--max-parallel|--all) [--json]"
40
41
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
41
42
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
42
43
  CONFIGS_USAGE = "Usage: cdx configs [--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."
@@ -278,6 +279,7 @@ def _parse_set_args(args):
278
279
  "--logics": {"key": "logics", "type": "str", "default": None, "transform": _on_off("logics")},
279
280
  "--notify": {"key": "notify", "type": "str", "default": None, "transform": _on_off("notify")},
280
281
  "--agent-state": {"key": "agent_state", "type": "str", "default": None, "transform": _on_off("agent state")},
282
+ "--status-line": {"key": "status_line", "type": "str", "default": None, "transform": _on_off("status line")},
281
283
  "--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _on_off("notify preview")},
282
284
  "--scrub-env": {"key": "scrub_env", "type": "str", "default": None, "transform": _on_off("scrub env")},
283
285
  "--shared-memory": {"key": "shared_memory", "type": "str", "default": None, "transform": _on_off("shared memory")},
@@ -305,7 +307,7 @@ def _parse_set_args(args):
305
307
  raise CdxError(SET_USAGE)
306
308
  settings = {
307
309
  key: parsed[key]
308
- for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel")
310
+ for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel")
309
311
  if parsed[key] is not None
310
312
  }
311
313
  if not settings:
@@ -329,6 +331,7 @@ def _parse_unset_args(args):
329
331
  "--logics": {"key": "logics", "type": "bool", "default": False},
330
332
  "--notify": {"key": "notify", "type": "bool", "default": False},
331
333
  "--agent-state": {"key": "agent_state", "type": "bool", "default": False},
334
+ "--status-line": {"key": "status_line", "type": "bool", "default": False},
332
335
  "--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
333
336
  "--scrub-env": {"key": "scrub_env", "type": "bool", "default": False},
334
337
  "--shared-memory": {"key": "shared_memory", "type": "bool", "default": False},
@@ -356,7 +359,7 @@ def _parse_unset_args(args):
356
359
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
357
360
  raise CdxError(UNSET_USAGE)
358
361
  _UNSET_KEYS = (
359
- "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro",
362
+ "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro",
360
363
  "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel",
361
364
  )
362
365
  keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
@@ -503,13 +506,13 @@ def cdx_schema():
503
506
  "kind": {"accepted": list(RUN_KIND_VALUES)},
504
507
  "provider": {"accepted": list(PROVIDERS)},
505
508
  },
506
- # Passthrough arguments are deliberately absent from this contract.
507
- # `cdx set --extra-args` reaches the provider unchanged and cdx never
508
- # validates it, so publishing it here would claim a guarantee that does
509
- # 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.
510
511
  "unvalidated": {
511
512
  "extra_args": {
512
- "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.",
513
516
  "not_checked_by": ["cdx schema", "cdx doctor --check-provider-flags"],
514
517
  },
515
518
  },
@@ -695,6 +698,7 @@ def _parse_run_args(args):
695
698
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": _parse_run_provider},
696
699
  "--model": {"key": "model", "type": "str", "default": None},
697
700
  "--continue-run": {"key": "continue_run", "type": "str", "default": None},
701
+ "--extra-args": {"key": "extra_args", "type": "str", "default": None},
698
702
  "--kind": {"key": "kind", "type": "str", "default": "assistant"},
699
703
  "--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
700
704
  "--power": {"key": "power", "type": "str", "default": None},
@@ -753,6 +757,7 @@ def _parse_run_args(args):
753
757
  raise CdxArgumentError("cdx run: --remote-prefix requires --remote and a nonempty command.",
754
758
  code=ARG_CODE_INVALID_VALUE, arguments=["--remote-prefix"])
755
759
  effort = _parse_run_effort(parsed["reasoning_effort"], parsed["power"])
760
+ extra = _normalize_launch_settings({"extra_args": parsed["extra_args"]}).get("extra_args")
756
761
  return {
757
762
  "name": name,
758
763
  "remote": parsed["remote"],
@@ -763,6 +768,7 @@ def _parse_run_args(args):
763
768
  "prompt_file": parsed["prompt_file"],
764
769
  "prompt": parsed["prompt"],
765
770
  "continue_run": parsed["continue_run"],
771
+ "extra_args": extra,
766
772
  "kind": parsed["kind"],
767
773
  "model": parsed["model"],
768
774
  "permission": parsed["permission"],
@@ -313,6 +313,7 @@ def _format_launch_config(session, use_color=False):
313
313
  ("logics", "Logics"),
314
314
  ("notify", "Agent alerts"),
315
315
  ("agent_state", "Agent state"),
316
+ ("status_line", "Claude quota status line"),
316
317
  ("notify_preview", "Response preview"),
317
318
  ("scrub_env", "Scrub agent env"),
318
319
  ("shared_memory", "Shared memory"),
@@ -325,6 +326,8 @@ def _format_launch_config(session, use_color=False):
325
326
  ("policy", "Policy"),
326
327
  ("max_parallel", "Max parallel"),
327
328
  ]:
329
+ if key == "status_line" and session["provider"] != "claude":
330
+ continue
328
331
  rows.append([
329
332
  _dim(label, use_color),
330
333
  _format_launch_setting_value(launch, key, use_color=use_color),
@@ -345,14 +348,16 @@ def _format_launch_settings_hint(name="<name>"):
345
348
  )
346
349
 
347
350
  def _format_launch_setting_value(launch, key, use_color=False):
348
- if key in ("fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
351
+ if key in ("fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro"):
349
352
  if launch.get(key) is True:
350
353
  return _style("on", "32", use_color)
351
354
  if launch.get(key) is False:
352
355
  return _style("off", "2", use_color)
356
+ if key == "status_line":
357
+ return _dim("on (Claude)", use_color)
353
358
  if key == "logics":
354
359
  return _dim("auto", use_color)
355
- if key in ("notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
360
+ if key in ("notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro"):
356
361
  return _style("off", "2", use_color)
357
362
  return _dim("default", use_color)
358
363
  value = launch.get(key)
@@ -7,6 +7,7 @@ import threading
7
7
  import time
8
8
  from datetime import datetime, timezone
9
9
 
10
+ from .quota_provenance import quota_windows
10
11
  from .status_source import _is_zero_credit_balance
11
12
 
12
13
  try:
@@ -139,7 +140,7 @@ def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
139
140
  })
140
141
 
141
142
  raw_status_text = json.dumps(snapshot, sort_keys=True)
142
- return {
143
+ status = {
143
144
  "remaining_5h_pct": _remaining_from_used_percent(
144
145
  five_hour.get("usedPercent", five_hour.get("used_percent"))
145
146
  ),
@@ -156,6 +157,8 @@ def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
156
157
  "raw_status_text": raw_status_text,
157
158
  "source_ref": "api:codex-app-server-rate-limits",
158
159
  }
160
+ status["quota_windows"] = quota_windows(status, "codex")
161
+ return status
159
162
 
160
163
 
161
164
  def _reader_thread(stream, output):
@@ -305,13 +308,18 @@ def _request_codex_app_server(auth_home, method, params, failure_reason, timeout
305
308
  pass
306
309
 
307
310
 
308
- def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
309
- diagnostic = fetch_codex_rate_limit_diagnostic(
311
+ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None, diagnostic=False):
312
+ result = fetch_codex_rate_limit_diagnostic(
310
313
  session,
311
314
  timeout=timeout,
312
315
  popen_factory=popen_factory,
313
316
  )
314
- return diagnostic.get("status") if diagnostic.get("ok") else None
317
+ if result.get("ok"):
318
+ return result.get("status")
319
+ if diagnostic:
320
+ reason = "login_expired" if diagnostic_needs_codex_login(result) else result.get("reason")
321
+ return {"_quota_error": reason or "probe_failed"}
322
+ return None
315
323
 
316
324
 
317
325
  def consume_codex_rate_limit_reset_credit(session, idempotency_key, credit_id=None, timeout=5, popen_factory=None):
@@ -103,12 +103,15 @@ def handle_import(rest, ctx):
103
103
  payload = _json_success(
104
104
  "import",
105
105
  message,
106
+ warnings=result.get("status_line_warnings", []),
106
107
  bundle=result,
107
108
  )
108
109
  if parsed["json"]:
109
110
  _write_json(ctx, payload)
110
111
  return 0
111
112
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
113
+ for warning in result.get("status_line_warnings", []):
114
+ ctx["out"](f"{warning}\n")
112
115
  retained = result.get("retained_local_credentials") or []
113
116
  if retained:
114
117
  # Merge keeps local values, so say which sessions kept their own login
@@ -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
@@ -308,6 +308,13 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
308
308
  if not json_flag:
309
309
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
310
310
  _write_update_notice(ctx)
311
+ from ..claude_status_line import provision as provision_status_line
312
+
313
+ status_line_notice = provision_status_line(session, ctx["service"]["base_dir"])
314
+ if status_line_notice:
315
+ warnings.append(status_line_notice)
316
+ if not json_flag:
317
+ ctx["out"](f"{status_line_notice}\n")
311
318
  alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
312
319
  keep_state = agent_state_enabled(session)
313
320
  installed = provision(
@@ -3,6 +3,7 @@
3
3
  Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
+ from ..claude_status_line import provision as provision_status_line
6
7
  from ..cli_args import (
7
8
  _parse_add_args,
8
9
  _parse_copy_args,
@@ -70,6 +71,7 @@ def handle_copy(rest, ctx):
70
71
  json_flag, args = _parse_json_flag(rest)
71
72
  parsed = _parse_copy_args(args)
72
73
  result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
74
+ warning = provision_status_line(result["session"], ctx["service"]["base_dir"])
73
75
  overwritten = " (overwritten)" if result["overwritten"] else ""
74
76
  message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
75
77
  if json_flag:
@@ -78,20 +80,26 @@ def handle_copy(rest, ctx):
78
80
  message,
79
81
  session=result["session"],
80
82
  overwritten=result["overwritten"],
83
+ warnings=[warning] if warning else [],
81
84
  ))
82
85
  return 0
83
86
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
87
+ if warning:
88
+ ctx["out"](f"{warning}\n")
84
89
  return 0
85
90
 
86
91
  def handle_rename(rest, ctx):
87
92
  json_flag, args = _parse_json_flag(rest)
88
93
  parsed = _parse_rename_args(args)
89
94
  session = ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
95
+ warning = provision_status_line(session, ctx["service"]["base_dir"])
90
96
  message = f"Renamed session {parsed['source']} to {parsed['dest']}"
91
97
  if json_flag:
92
- _write_json(ctx, _json_success("rename", message, session=session))
98
+ _write_json(ctx, _json_success("rename", message, session=session, warnings=[warning] if warning else []))
93
99
  return 0
94
100
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
101
+ if warning:
102
+ ctx["out"](f"{warning}\n")
95
103
  return 0
96
104
 
97
105
  def handle_label(rest, ctx):
@@ -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
@@ -4,6 +4,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
6
  from ..agent_notify import supports_agent_alerts
7
+ from ..claude_status_line import provision as provision_status_line
7
8
  from ..cli_args import _parse_launch_setting_alias_args, _parse_set_args, _parse_unset_args
8
9
  from ..cli_helpers import _format_launch_config, _json_success, _write_json
9
10
  from ..cli_render import _success
@@ -70,6 +71,7 @@ def _apply_launch_settings(parsed, ctx, action="set"):
70
71
  ctx["service"]["set_launch_settings"](session["name"], parsed["settings"])
71
72
  for session in targets
72
73
  ]
74
+ warnings = [notice for session in sessions if (notice := provision_status_line(session, ctx["service"]["base_dir"]))]
73
75
  if len(sessions) == 1:
74
76
  session = sessions[0]
75
77
  message = f"Updated launch settings for {session['name']}"
@@ -80,6 +82,7 @@ def _apply_launch_settings(parsed, ctx, action="set"):
80
82
  action,
81
83
  message,
82
84
  updated_count=len(sessions),
85
+ warnings=warnings,
83
86
  session=sessions[0] if len(sessions) == 1 else None,
84
87
  sessions=sessions,
85
88
  launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
@@ -87,6 +90,8 @@ def _apply_launch_settings(parsed, ctx, action="set"):
87
90
  _write_json(ctx, payload)
88
91
  return 0
89
92
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
93
+ for warning in warnings:
94
+ ctx["out"](f"{warning}\n")
90
95
  notice = _notification_setting_notice(parsed["settings"], sessions, action)
91
96
  if notice:
92
97
  ctx["out"](f"{notice}\n")
@@ -100,6 +105,7 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
100
105
  ctx["service"]["unset_launch_settings"](session["name"], parsed["keys"])
101
106
  for session in targets
102
107
  ]
108
+ warnings = [notice for session in sessions if (notice := provision_status_line(session, ctx["service"]["base_dir"]))]
103
109
  if len(sessions) == 1:
104
110
  session = sessions[0]
105
111
  message = f"Cleared launch settings for {session['name']}"
@@ -110,6 +116,7 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
110
116
  action,
111
117
  message,
112
118
  updated_count=len(sessions),
119
+ warnings=warnings,
113
120
  session=sessions[0] if len(sessions) == 1 else None,
114
121
  sessions=sessions,
115
122
  launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
@@ -117,6 +124,8 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
117
124
  _write_json(ctx, payload)
118
125
  return 0
119
126
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
127
+ for warning in warnings:
128
+ ctx["out"](f"{warning}\n")
120
129
  notice = _notification_setting_notice({key: False for key in parsed["keys"]}, sessions, action)
121
130
  if notice:
122
131
  ctx["out"](f"{notice}\n")
@@ -51,6 +51,7 @@ from ..provider_runtime import (
51
51
  _has_local_claude_auth,
52
52
  _probe_provider_auth_status,
53
53
  )
54
+ from ..quota_provenance import mark_quota_unavailable
54
55
  from ..rtk_savings import rtk_saved
55
56
  from ..run_usage import (
56
57
  TOKEN_USAGE_KEYS,
@@ -856,6 +857,8 @@ def handle_status(rest, ctx):
856
857
  }
857
858
  for item in refresh_result.get("errors", [])
858
859
  ]
860
+ quota_errors = {item["session"]: item.get("reason") or "quota_read_failed" for item in refresh_result.get("errors", [])}
861
+ quota_errors.update({item["session"]: "auth_probe_failed" for item in auth_refresh.get("errors", [])})
859
862
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
860
863
  if len(args) == 1:
861
864
  row = ctx["service"]["get_status_row"](
@@ -865,6 +868,8 @@ def handle_status(rest, ctx):
865
868
  cache_only=parsed["cached"],
866
869
  status_timeout_seconds=parsed["timeout"],
867
870
  )
871
+ if row.get("session_name") in quota_errors:
872
+ mark_quota_unavailable(row, quota_errors[row["session_name"]])
868
873
  output_warnings = [
869
874
  *warnings,
870
875
  *_refresh_warning_payloads(refresh_errors, [row]),
@@ -885,6 +890,9 @@ def handle_status(rest, ctx):
885
890
  cache_only=parsed["cached"],
886
891
  status_timeout_seconds=parsed["timeout"],
887
892
  )
893
+ for row in rows:
894
+ if row.get("session_name") in quota_errors:
895
+ mark_quota_unavailable(row, quota_errors[row["session_name"]])
888
896
  output_warnings = [
889
897
  *warnings,
890
898
  *_refresh_warning_payloads(refresh_errors, rows),
@@ -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"}
@@ -0,0 +1,2 @@
1
+ ████████
2
+ ▁▁▁▁▁▁▁▁