cdx-manager 0.22.0 → 0.24.0
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 +2 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_23_0.md +19 -0
- package/changelogs/CHANGELOGS_0_24_0.md +16 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +10 -8
- package/docs/features.md +1 -1
- package/docs/sessions-and-notifications.md +45 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +54 -6
- package/src/agent_state.py +197 -0
- package/src/cli.py +9 -3
- package/src/cli_args.py +38 -19
- package/src/cli_commands.py +3 -0
- package/src/cli_helpers.py +3 -2
- package/src/commands/launch.py +29 -5
- package/src/commands/runs.py +10 -3
- package/src/commands/status.py +38 -0
- package/src/provider_runtime.py +14 -1
- package/src/run_registry.py +51 -2
- package/src/session_helpers.py +1 -0
- package/src/session_service.py +2 -2
- package/src/session_status.py +19 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
|
|
10
10
|
|
|
@@ -38,6 +38,7 @@ cdx work
|
|
|
38
38
|
| Continue work in another session | `cdx handoff source target` | [Context handoff](docs/features.md#context-handoff) |
|
|
39
39
|
| Run a prompt from a script | `cdx run --prompt "..." --json` | [Headless automation](docs/features.md#headless-automation) |
|
|
40
40
|
| Inspect usage or get an alert | `cdx stats`, `cdx ready` | [Maintenance and notifications](docs/sessions-and-notifications.md) |
|
|
41
|
+
| See which agents wait for you | `cdx agents --json` | [Agent state](docs/sessions-and-notifications.md#agent-state) |
|
|
41
42
|
|
|
42
43
|
CDX checks authentication before interactive launches. Handoff keeps the source transcript in place and asks the receiving agent to verify the conversation and workspace before acting. No account switch or prompt submission happens just because you run `cdx next`.
|
|
43
44
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.24.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# CDX Manager 0.23.0
|
|
2
|
+
|
|
3
|
+
This release gives external orchestrators a stable way to read runs, live sessions and profile locations, so they no longer need to scan processes, parse provider output or rely on cdx's directory layout.
|
|
4
|
+
|
|
5
|
+
## Runs
|
|
6
|
+
|
|
7
|
+
- `cdx runs --running --json` returns every run still in progress, without the `--limit` cap, so a concurrency check always sees the full count.
|
|
8
|
+
- `cdx run-report` now includes `final_message`: the run's last assistant message, read from Claude's result or Codex's last agent message.
|
|
9
|
+
- The accepted run `kind` values are documented with `cdx runs`; `cdx schema --json` remains their source.
|
|
10
|
+
|
|
11
|
+
## Sessions and profiles
|
|
12
|
+
|
|
13
|
+
- `cdx sessions --json` lists interactive sessions launched through cdx that are still running, with their process id, provider, working directory, start time and conversation id.
|
|
14
|
+
- `cdx sessions --all --json` adds idle accounts. Each row includes `profile_dir` and `transcripts_dir`; the credential directory stays hidden.
|
|
15
|
+
|
|
16
|
+
## Resume
|
|
17
|
+
|
|
18
|
+
- `cdx can-resume <name> --conversation <id>` checks one exact conversation on Claude or Codex.
|
|
19
|
+
- `cdx resume <name> --conversation <id>` reopens that conversation. A missing conversation is refused with `conversation_not_found` rather than replaced by the most recent one.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CDX Manager 0.24.0
|
|
2
|
+
|
|
3
|
+
This release lets a dashboard, a terminal board or a chat agent ask cdx which sessions are working, waiting for their user, done or stopped by a usage limit, and lets detached background runs fail over to another account.
|
|
4
|
+
|
|
5
|
+
## Agent state
|
|
6
|
+
|
|
7
|
+
- `cdx set <name> --agent-state on` installs the provider hooks that record each session's state, without desktop alerts; sessions with `--notify on` record it too.
|
|
8
|
+
- Every hook event updates one small record per session under `$CDX_HOME/state/agents/`, written atomically, even when alerts are muted.
|
|
9
|
+
- `cdx agents [--max-age-hours N] [--json]` lists them, waiting first: `working`, `waiting`, `done`, `failed`, `limit` (with `reset_at` when known) or `ended` when the launching process is gone. Records older than N hours (24 by default) are dropped.
|
|
10
|
+
- A `limit` state needs the same two signals as run failover: the provider's own exhaustion error and a refreshed account status that confirms it.
|
|
11
|
+
- The prompt head (120 characters) is kept only with `--notify-preview on`.
|
|
12
|
+
- Codex has no failure hook, so a Codex session never reaches `failed` or `limit`. Codex plugins installed before this release keep their old hooks until reinstalled (`--notify off`, one launch, `--notify on`).
|
|
13
|
+
|
|
14
|
+
## Runs
|
|
15
|
+
|
|
16
|
+
- `cdx run --detach --failover` is now accepted: the detached child watches the run and applies the failover rule, and the run registry records each move under the same `run_id`.
|
|
@@ -551,6 +551,28 @@
|
|
|
551
551
|
"x86_64-pc-windows-msvc": "65cb725a2ae55350b8d9500f4293cd52e709ee017c999d65de9a1e12b472f7ed",
|
|
552
552
|
"x86_64-unknown-linux-musl": "6ff5479d7d57f329e76ad633e963e0d45f059d28004a3777f406b5bdd0713abe"
|
|
553
553
|
}
|
|
554
|
+
},
|
|
555
|
+
"v0.22.0": {
|
|
556
|
+
"github_tarball_sha256": "06d623fad9ee339e6810792d94b925e3a27f62612b5ac015e8fb236093417bf1",
|
|
557
|
+
"github_zip_sha256": "fbfcc2748282f60968bbfb4127f97b2866890e1bf54e5d92e2b81e5526c92f6d",
|
|
558
|
+
"tray_assets": {
|
|
559
|
+
"aarch64-apple-darwin": "57ec5cec2876eecf62397b66f78cbb5028583e06deedab79b2af17bafc7a8d05",
|
|
560
|
+
"aarch64-unknown-linux-musl": "948110290f030caafe46eebf2db6e5565fc48e5fc4983a0c70d946336f39ec9e",
|
|
561
|
+
"x86_64-apple-darwin": "bb873f982b4af1a3cc53235e615fad2e2be8265defea0def0fde463467fa3495",
|
|
562
|
+
"x86_64-pc-windows-msvc": "37ddbc3464014a04a4fefee4cebeb24f16b3d06995844369bee8360526538d43",
|
|
563
|
+
"x86_64-unknown-linux-musl": "6602e175632b7a191434b627bfef1e430d32da90ce0e23379bdf38700ea9ba7c"
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
"v0.23.0": {
|
|
567
|
+
"github_tarball_sha256": "45daac760229b4bc5355628f1f9e5f46c6e61eecc5a320a2ecc31fec2f261f85",
|
|
568
|
+
"github_zip_sha256": "d5f1750548fe8445066fd0328f8c76a8035278537c29d1b39756fbdef8212915",
|
|
569
|
+
"tray_assets": {
|
|
570
|
+
"aarch64-apple-darwin": "98a68a5e98a1708475164fd252eb4e3fdf185cf2c62486f92a625d42c18c69ee",
|
|
571
|
+
"aarch64-unknown-linux-musl": "3dfa8764947edcb7da33e72af70cfc0ce3d4007c9ce0a1fcd4884848af0f5dd7",
|
|
572
|
+
"x86_64-apple-darwin": "00444fd894998db84ad6ab608f528061f6a9f0b50648ecfe61a91c6efdc4e969",
|
|
573
|
+
"x86_64-pc-windows-msvc": "bf35f8d25bbc34730533807df0d9c0e3f7ea2a271f268856759ae87bc9e116fe",
|
|
574
|
+
"x86_64-unknown-linux-musl": "60400fd7d10b0563503afc5bd741f7fefff4276d077fc6e4927f087390d46ccd"
|
|
575
|
+
}
|
|
554
576
|
}
|
|
555
577
|
}
|
|
556
578
|
}
|
package/docs/cli.md
CHANGED
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
| `cdx config <name> [--json]` | Show persistent launch settings for a session |
|
|
25
25
|
| `cdx configs [--json]` | Show persistent launch settings for all sessions in one table |
|
|
26
26
|
| `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
|
|
27
|
-
| `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] [--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] [--json]` | Persist launch settings for one or more sessions |
|
|
28
|
-
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
27
|
+
| `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] [--json]` | Persist launch settings for one or more sessions |
|
|
28
|
+
| `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\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
29
29
|
| `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
30
30
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
31
|
-
| `cdx resume <name> [--json]` | Resume the provider-native conversation for a session using the named command form |
|
|
32
|
-
| `cdx can-resume <name> [--json]` | Check whether a session supports native resume without launching the provider |
|
|
31
|
+
| `cdx resume <name> [--conversation <id>] [--json]` | Resume the provider-native conversation for a session using the named command form; `--conversation` reopens that exact conversation and refuses with `conversation_not_found` if the profile has no such transcript |
|
|
32
|
+
| `cdx can-resume <name> [--conversation <id>] [--json]` | Check whether a session supports native resume without launching the provider; `--conversation` checks that exact conversation and reports `resumable: false` with `conversation_not_found` instead of falling back to the most recent one |
|
|
33
|
+
| `cdx sessions [--all] --json` | Interactive sessions launched through cdx that are still running (`session`, `provider`, `pid`, `cwd`, `started_at`, `conversation_id`, `profile_dir`, `transcripts_dir`); `--all` adds idle enabled accounts so any account's directories can be resolved |
|
|
33
34
|
| `cdx context show\|path\|init\|edit\|clear\|set\|append [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
34
35
|
| `cdx memory [--global\|--project NAME_OR_PATH] [show\|view\|path\|init\|edit\|clear\|set\|append\|list] [text...] [--json]` | Manage explicit user-controlled memory for the current workspace, global scope, or a named/path project |
|
|
35
36
|
| `cdx handoff <name> [--json]` | Reuse the prepared entry for this target/workspace, or use explicitly labelled notes-only recovery if none exists; launch unless `--json` |
|
|
@@ -49,14 +50,15 @@
|
|
|
49
50
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
50
51
|
| `cdx update all [--yes] [--json]` | Check installed providers, RTK, and Ponytail across Codex profiles; show the plan and ask once before applying safe native/Homebrew updates and missing RTK/Ponytail setup |
|
|
51
52
|
| `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
|
|
52
|
-
| `cdx notify` | Internal provider hook target for agent alerts; use `cdx set <name> --notify on` to enable it |
|
|
53
|
+
| `cdx notify` | Internal provider hook target for agent alerts and agent state; use `cdx set <name> --notify on` or `--agent-state on` to enable it |
|
|
54
|
+
| `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) |
|
|
53
55
|
| `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
|
|
54
56
|
| `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 |
|
|
55
|
-
| `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] [--label TEXT] [--max-parallel N] [--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, `--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 |
|
|
56
|
-
| `cdx run-report <run_id> --json` | Full report, transcript metadata, and final payload for a run |
|
|
57
|
+
| `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] [--label TEXT] [--max-parallel N] [--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 |
|
|
58
|
+
| `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) |
|
|
57
59
|
| `cdx run-status <run_id> --json` | Status of one run by id |
|
|
58
60
|
| `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 |
|
|
59
|
-
| `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
|
|
61
|
+
| `cdx runs [--limit N] [--since 7d\|today\|DATE] [--running] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit`; `--running` returns every run still running, uncapped. A run's `kind` is one of the values `cdx schema --json` lists under `enums.kind` |
|
|
60
62
|
| `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
|
|
61
63
|
| `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--by session\|project\|model] [--json]` | Aggregate launch counts, duration, and known token usage by session, for headless and interactive runs alike. `--by project` groups runs by launch folder (labelled by the shortest trailing part that tells folders apart; `project` in JSON holds the full path), `--by model` by the model that served them (same totals, same pricing; default `session`). PROV. lists every provider a row saw, such as `claude+codex`. Without a session filter it also prints what rtk saved over the period (`rtk` in JSON), read with `rtk gain` from `RTK_DB_PATH` when set, else the shared history, and the older per-profile ones. |
|
|
62
64
|
| `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status; Claude rows also report the auth type (`setup-token` or `full-login`) in a TYPE column and as `auth_type` in JSON. |
|
package/docs/features.md
CHANGED
|
@@ -29,7 +29,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
|
|
|
29
29
|
## Headless automation
|
|
30
30
|
|
|
31
31
|
- **One task, one JSON result.** `cdx run` executes a prompt against a chosen or auto-selected session and returns a stable payload; `--detach` returns a `run_id` immediately.
|
|
32
|
-
- **Survives a rate limit.** `cdx run --failover` notices that a run stopped because its account ran out, moves the task to the next account the ranking allows, and carries on — one `run_id` for the whole task, with every account it occupied listed in `cdx run-report`. A spent account is always confirmed against its own refreshed quota first, so a healthy account is never abandoned. From there two routes migrate a run: the provider said so in words cdx recognises, or — since that wording differs between an enterprise workspace out of credits, a personal plan hitting its 5-hour window, and Claude's own phrasing — simply that the account's quota is gone and the run failed. `cdx run-report` names which route applied. Exhausting every account reports `failover_exhausted`, which a caller can tell apart from the task itself failing.
|
|
32
|
+
- **Survives a rate limit.** `cdx run --failover` notices that a run stopped because its account ran out, moves the task to the next account the ranking allows, and carries on — one `run_id` for the whole task, with every account it occupied listed in `cdx run-report`. A spent account is always confirmed against its own refreshed quota first, so a healthy account is never abandoned. From there two routes migrate a run: the provider said so in words cdx recognises, or — since that wording differs between an enterprise workspace out of credits, a personal plan hitting its 5-hour window, and Claude's own phrasing — simply that the account's quota is gone and the run failed. `cdx run-report` names which route applied. Exhausting every account reports `failover_exhausted`, which a caller can tell apart from the task itself failing. It combines with `--detach`: the detached cdx process supervises the run, applies the same rule, and records each move in the run registry; such a run always uses cdx's own detached child rather than the provider's background agent, which nothing would be watching.
|
|
33
33
|
- **Native background delegation (opt-in).** With `CDX_EXPERIMENTAL_NATIVE_BG=1`, `cdx run --detach` hands the run to Claude Code's own background agents (`--bg`) instead of cdx's detached launcher. The run keeps its cdx `run_id`, closes out with its token usage and final answer read from the provider's own session transcript, and the parked session is stopped once its task is done. Off by default for one release while it settles; the in-house launcher is unchanged and is what every run takes otherwise. Codex keeps the in-house path while its `cloud`/`exec-server` surfaces are marked experimental.
|
|
34
34
|
- **Observable runs.** `cdx run-status`, `cdx run-tail`, `cdx run-report`, and `cdx runs --since` follow a run while it happens and after it ends.
|
|
35
35
|
- **Selection without launching.** `cdx select --provider ... --require-ready --json` answers "which session should this job use?" for orchestrators.
|
|
@@ -38,8 +38,9 @@ visible on a lock screen; disable it per session with
|
|
|
38
38
|
`cdx set <name> --notify-preview off`. Previews never read transcripts, and the
|
|
39
39
|
command a permission request is about is never included.
|
|
40
40
|
|
|
41
|
-
Both providers are subscribed to the same two hooks: `Stop` when a turn
|
|
42
|
-
`PermissionRequest` when one blocks on your approval
|
|
41
|
+
Both providers are subscribed to the same two alerting hooks: `Stop` when a turn
|
|
42
|
+
ends and `PermissionRequest` when one blocks on your approval, plus
|
|
43
|
+
`UserPromptSubmit`, which raises no alert and only feeds the agent state below. Claude Code is also
|
|
43
44
|
subscribed to `Notification`, for the "still waiting for you" states that no
|
|
44
45
|
other hook reports; the permission notifications it also sends are dropped,
|
|
45
46
|
because `PermissionRequest` already reported the same call immediately and by
|
|
@@ -71,6 +72,48 @@ disabled — cdx installs no hooks at all rather than asking you to approve
|
|
|
71
72
|
something that could show you nothing. Headless `cdx run` never notifies: its
|
|
72
73
|
caller already learns of completion from the return value.
|
|
73
74
|
|
|
75
|
+
## Agent state
|
|
76
|
+
|
|
77
|
+
Every hook event `cdx notify` receives also rewrites one small record for that
|
|
78
|
+
session under `$CDX_HOME/state/agents/<session>.json`, whether alerts are on,
|
|
79
|
+
muted or off. `cdx agents` lists them, so a dashboard, a chat agent or a
|
|
80
|
+
terminal board can ask which sessions are working, waiting for you, done,
|
|
81
|
+
failed or stopped by a usage limit without reading transcripts or terminals:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cdx set work1 --agent-state on # hooks for state only, no desktop alerts
|
|
85
|
+
cdx agents # table, waiting sessions first
|
|
86
|
+
cdx agents --json # the same for scripts
|
|
87
|
+
cdx agents --max-age-hours 4 # drop records older than 4 hours (default 24)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Sessions with `--notify on` keep state already; `--agent-state on` is for
|
|
91
|
+
sessions that want state without banners. It installs the same hooks, and it
|
|
92
|
+
installs them even where no notification could be shown (an SSH login, a
|
|
93
|
+
headless WSL), since a remote dashboard is exactly what watches those.
|
|
94
|
+
|
|
95
|
+
| State | Set by |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `working` | a prompt was submitted (`UserPromptSubmit`) |
|
|
98
|
+
| `waiting` | a permission request, or Claude's idle or needs-input notification |
|
|
99
|
+
| `done` | the turn ended (`Stop`) |
|
|
100
|
+
| `failed` | the turn ended on a provider error (Claude's `StopFailure`); `reason` is the error class |
|
|
101
|
+
| `limit` | that error says the account ran out and the account's own refreshed status agrees, the same two-signal rule as `cdx run --failover`; `reset_at` is the account's reset time when known |
|
|
102
|
+
| `ended` | computed by `cdx agents`: the cdx process that launched the session is gone; `last_state` keeps what it was |
|
|
103
|
+
|
|
104
|
+
`cdx agents --json` returns `{"ok": true, "action": "agents", "max_age_hours": 24,
|
|
105
|
+
"agents": [...]}`, each entry carrying `session`, `provider`, `cwd`, `project`,
|
|
106
|
+
`state`, `reason`, `prompt`, `reset_at`, `pid` and `updated_at` (ISO 8601, UTC).
|
|
107
|
+
`prompt` is the head of the last prompt, at most 120 characters, and only for
|
|
108
|
+
sessions with `--notify-preview on`; otherwise it is `null`. Records older than
|
|
109
|
+
the maximum age are deleted when listed.
|
|
110
|
+
|
|
111
|
+
A waiting session stays `waiting` until its next prompt or the end of the turn:
|
|
112
|
+
cdx does not subscribe to every tool call to notice that you answered. A Codex
|
|
113
|
+
profile whose cdx plugin was installed before this release keeps its old hooks
|
|
114
|
+
(no `working` state) until the plugin is reinstalled with `cdx set <name>
|
|
115
|
+
--notify off`, a launch, then `--notify on` and another launch.
|
|
116
|
+
|
|
74
117
|
Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. cdx picks the next known reset, registers a native OS notification, and exits immediately. If no future reset is known, it exits successfully and explains that no notification was scheduled.
|
|
75
118
|
|
|
76
119
|
```bash
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -15,10 +15,12 @@ import re
|
|
|
15
15
|
import shutil
|
|
16
16
|
import subprocess
|
|
17
17
|
|
|
18
|
+
from . import agent_state
|
|
18
19
|
from .config import get_cdx_home
|
|
19
20
|
from .fs_utils import atomic_write
|
|
20
21
|
from .notify import notification_channel, send_desktop_notification
|
|
21
22
|
from .tray_alerts import alerts_enabled
|
|
23
|
+
from .tray_defaults import alerts_default
|
|
22
24
|
from .tray_events import publish
|
|
23
25
|
|
|
24
26
|
# Set on the provider process at launch; the hook runs as its child and inherits both.
|
|
@@ -121,6 +123,10 @@ def _is_reportable(payload, event):
|
|
|
121
123
|
reports exactly — `permission_prompt` duplicates PermissionRequest — or an
|
|
122
124
|
internal state the user has no action to take on.
|
|
123
125
|
"""
|
|
126
|
+
if event == "userpromptsubmit":
|
|
127
|
+
# Subscribed for the agent state only; a prompt the user just typed is
|
|
128
|
+
# not news to them.
|
|
129
|
+
return False
|
|
124
130
|
if event != "notification":
|
|
125
131
|
return True
|
|
126
132
|
kind = str(payload.get("notification_type") or "").lower()
|
|
@@ -291,8 +297,9 @@ def handle_notify(rest, ctx):
|
|
|
291
297
|
# call being asked about, so a stray line would allow or deny it. The
|
|
292
298
|
# notification paths below print nothing; this is the statement of why
|
|
293
299
|
# they must not start.
|
|
300
|
+
_record_state(ctx, payload, env)
|
|
294
301
|
composed = compose_notification(payload, env, ctx.get("cwd"))
|
|
295
|
-
if composed:
|
|
302
|
+
if composed and _alerts_wanted(ctx, env.get(SESSION_ENV)):
|
|
296
303
|
# Muted stops the banner, not the record: the event still reaches a
|
|
297
304
|
# running tray so the menu can show what was missed, and the direct
|
|
298
305
|
# path stays quiet too — otherwise quitting the tray would un-mute.
|
|
@@ -313,6 +320,43 @@ def handle_notify(rest, ctx):
|
|
|
313
320
|
return 0
|
|
314
321
|
|
|
315
322
|
|
|
323
|
+
def _record_state(ctx, payload, env):
|
|
324
|
+
"""Keep this event as the session's state, before and apart from any alert.
|
|
325
|
+
|
|
326
|
+
Recorded whether alerts are on, muted or off: a dashboard asking which
|
|
327
|
+
session waits for its user must not go blind because the banner was muted.
|
|
328
|
+
Its own guard, so a failed write never costs the alert either.
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
event = hook_event(payload)
|
|
332
|
+
prompt = ""
|
|
333
|
+
if event == "userpromptsubmit" and env.get(PREVIEW_ENV) == "1":
|
|
334
|
+
prompt = _notification_text(payload.get("prompt"), agent_state._PROMPT_LIMIT)
|
|
335
|
+
directory = payload.get("cwd") or payload.get("workspace") or ctx.get("cwd") or os.getcwd()
|
|
336
|
+
agent_state.record(
|
|
337
|
+
ctx, env.get(SESSION_ENV), payload, event, error_class(payload), directory, env, prompt=prompt,
|
|
338
|
+
)
|
|
339
|
+
except Exception: # noqa: BLE001 - the caller is an agent turn
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _alerts_wanted(ctx, name):
|
|
344
|
+
"""False only for a session whose hooks exist for its state alone.
|
|
345
|
+
|
|
346
|
+
`--agent-state on` installs the same hooks as `--notify on`, so without this
|
|
347
|
+
a session that asked for state only would start raising desktop alerts.
|
|
348
|
+
Anything unreadable keeps the alert: that was the behaviour before state.
|
|
349
|
+
"""
|
|
350
|
+
service = ctx.get("service") or {}
|
|
351
|
+
try:
|
|
352
|
+
session = service["get_session"](name)
|
|
353
|
+
except Exception: # noqa: BLE001 - a missing store is not a reason to drop alerts
|
|
354
|
+
return True
|
|
355
|
+
if not agent_state.agent_state_enabled(session):
|
|
356
|
+
return True
|
|
357
|
+
return notifications_enabled(session) or alerts_default(service["base_dir"])
|
|
358
|
+
|
|
359
|
+
|
|
316
360
|
def launch_notify_env(session, enabled, env=None):
|
|
317
361
|
"""Env the provider is launched with, carrying who we are and privacy settings.
|
|
318
362
|
|
|
@@ -325,7 +369,7 @@ def launch_notify_env(session, enabled, env=None):
|
|
|
325
369
|
running tray that never receives an alert, with nothing reporting an error.
|
|
326
370
|
"""
|
|
327
371
|
env = os.environ if env is None else env
|
|
328
|
-
values = {SESSION_ENV: session["name"], "CDX_HOME": get_cdx_home(env)}
|
|
372
|
+
values = {SESSION_ENV: session["name"], "CDX_HOME": get_cdx_home(env), agent_state.PID_ENV: str(os.getpid())}
|
|
329
373
|
if enabled and (session.get("launch") or {}).get("notify_preview") is True:
|
|
330
374
|
values[PREVIEW_ENV] = "1"
|
|
331
375
|
return values
|
|
@@ -343,7 +387,7 @@ def notifications_enabled(session):
|
|
|
343
387
|
return launch.get("notify") is True
|
|
344
388
|
|
|
345
389
|
|
|
346
|
-
def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=None):
|
|
390
|
+
def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=None, keep_state=False):
|
|
347
391
|
"""Install or remove cdx's notification hooks in this session's own home.
|
|
348
392
|
|
|
349
393
|
Idempotent, so an existing session picks the hooks up on its next launch and
|
|
@@ -356,7 +400,9 @@ def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=
|
|
|
356
400
|
anywhere under `CODEX_HOME` is never looked at.
|
|
357
401
|
"""
|
|
358
402
|
env = env or os.environ
|
|
359
|
-
|
|
403
|
+
# `keep_state` needs the hooks whether or not anything can be shown here:
|
|
404
|
+
# a session driven over SSH is exactly the one a remote dashboard watches.
|
|
405
|
+
if enabled and not keep_state and not notification_channel(env):
|
|
360
406
|
# Nothing could be delivered on this host (an SSH login, say), so
|
|
361
407
|
# installing a hook would only buy an approval prompt for a feature that
|
|
362
408
|
# shows nothing. Removing existing hooks is no better: Codex would ask to
|
|
@@ -458,7 +504,7 @@ def _write_codex_plugin(auth_home, command, base_dir=None):
|
|
|
458
504
|
os.path.join(root, "hooks", "cdx-hooks.json"): {
|
|
459
505
|
"hooks": {
|
|
460
506
|
event: [{"hooks": [{"type": "command", "command": f"{command} {HOOK_ARG}"}]}]
|
|
461
|
-
for event in ("Stop", "PermissionRequest")
|
|
507
|
+
for event in ("UserPromptSubmit", "Stop", "PermissionRequest")
|
|
462
508
|
},
|
|
463
509
|
},
|
|
464
510
|
}
|
|
@@ -509,7 +555,9 @@ def _apply_hooks(path, enabled, command):
|
|
|
509
555
|
# StopFailure is Claude-only: Codex documents no equivalent, so a turn its
|
|
510
556
|
# provider kills stays silent there and the documentation says so rather
|
|
511
557
|
# than implying a parity that does not exist.
|
|
512
|
-
for
|
|
558
|
+
# UserPromptSubmit is there for the agent state only: it is what says a
|
|
559
|
+
# session went back to work, and `cdx notify` raises no alert for it.
|
|
560
|
+
for event in ("UserPromptSubmit", "Stop", "StopFailure", "Notification", "PermissionRequest"):
|
|
513
561
|
# Rebuilt from whatever is there minus our own entry, so user-authored
|
|
514
562
|
# hooks on the same event survive both installing and removing.
|
|
515
563
|
entries = [entry for entry in hooks.get(event, []) if not _is_ours(entry)]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""What each cdx session's agent is doing right now, kept from its hook events.
|
|
2
|
+
|
|
3
|
+
`cdx notify` hears every hook event and used to forget it once the alert was
|
|
4
|
+
out. Here the same event also rewrites one small record per session, so a
|
|
5
|
+
dashboard, a chat agent or a terminal board can ask `cdx agents --json` which
|
|
6
|
+
sessions are working, waiting for their user, done, failed or out of quota
|
|
7
|
+
without parsing transcripts or terminals.
|
|
8
|
+
|
|
9
|
+
One file per session rather than one shared file: two sessions finishing in
|
|
10
|
+
the same instant then never race over a single document.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
|
|
17
|
+
from .errors import CdxError
|
|
18
|
+
from .fs_utils import atomic_write
|
|
19
|
+
from .run_failover import _mentions_exhaustion, status_confirms_exhaustion
|
|
20
|
+
from .session_helpers import _process_is_running
|
|
21
|
+
|
|
22
|
+
AGENTS_USAGE = "Usage: cdx agents [--max-age-hours N] [--json]"
|
|
23
|
+
DEFAULT_MAX_AGE_HOURS = 24
|
|
24
|
+
# Set on the provider at launch: the cdx process that waits on it. The hook is
|
|
25
|
+
# a grandchild at best (Claude Code runs it through a shell), so its own parent
|
|
26
|
+
# is no proof the agent is alive; the launcher is.
|
|
27
|
+
PID_ENV = "CDX_AGENT_PID"
|
|
28
|
+
_PROMPT_LIMIT = 120
|
|
29
|
+
_WAITING_NOTIFICATIONS = {"", "idle_prompt", "agent_needs_input", "permission_prompt"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def agent_state_enabled(session):
|
|
33
|
+
"""Whether this session asked for its hooks to keep state, alerts or not."""
|
|
34
|
+
return ((session or {}).get("launch") or {}).get("agent_state") is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def state_dir(base_dir):
|
|
38
|
+
return os.path.join(base_dir, "state", "agents")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def classify(payload, event, error_class):
|
|
42
|
+
"""(state, reason) for this event, or None when it changes nothing."""
|
|
43
|
+
if event == "userpromptsubmit":
|
|
44
|
+
return "working", None
|
|
45
|
+
if event == "permissionrequest":
|
|
46
|
+
tool = payload.get("tool_name")
|
|
47
|
+
return "waiting", f"permission ({tool})" if isinstance(tool, str) and tool else "permission"
|
|
48
|
+
if event == "notification":
|
|
49
|
+
kind = str(payload.get("notification_type") or "").lower()
|
|
50
|
+
return ("waiting", kind or "notification") if kind in _WAITING_NOTIFICATIONS else None
|
|
51
|
+
if event in ("stop", "agentturncomplete"):
|
|
52
|
+
return "done", None
|
|
53
|
+
if event == "stopfailure":
|
|
54
|
+
return "failed", error_class or "turn failed"
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def confirmed_limit(ctx, name, payload, error_class):
|
|
59
|
+
"""The status row proving a usage limit, or None.
|
|
60
|
+
|
|
61
|
+
The run_failover rule, both halves: the provider has to call the failure
|
|
62
|
+
exhaustion in its own words, and the account's own status has to agree. A
|
|
63
|
+
turn that merely failed, or an account whose status cannot be read, stays
|
|
64
|
+
`failed`; marking a healthy session `limit` would send someone to wait out a
|
|
65
|
+
reset that is not coming.
|
|
66
|
+
"""
|
|
67
|
+
message = payload.get("error_message")
|
|
68
|
+
if not _mentions_exhaustion([error_class] + ([message] if isinstance(message, str) else [])):
|
|
69
|
+
return None
|
|
70
|
+
# Imported here: commands sit above this module, and the refresh-then-cache
|
|
71
|
+
# read is theirs to own.
|
|
72
|
+
from .commands.runs import _status_row_for
|
|
73
|
+
row = _status_row_for(ctx, name)
|
|
74
|
+
return row if status_confirms_exhaustion(row) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def record(ctx, name, payload, event, error_class, cwd, env, prompt=None):
|
|
78
|
+
"""Rewrite this session's record for one hook event. Writes nothing to stdout."""
|
|
79
|
+
base_dir = (ctx.get("service") or {}).get("base_dir")
|
|
80
|
+
# The name comes from the environment; it must stay a plain file name.
|
|
81
|
+
if not base_dir or not name or os.path.basename(name) != name or name in (".", ".."):
|
|
82
|
+
return None
|
|
83
|
+
classified = classify(payload, event, error_class)
|
|
84
|
+
if not classified:
|
|
85
|
+
return None
|
|
86
|
+
state, reason = classified
|
|
87
|
+
path = os.path.join(state_dir(base_dir), f"{name}.json")
|
|
88
|
+
current = _read(path) or {}
|
|
89
|
+
if state == "working":
|
|
90
|
+
# Only under the preview opt-in, like any other free text a hook carries.
|
|
91
|
+
current["prompt"] = prompt or None
|
|
92
|
+
current.setdefault("prompt", None)
|
|
93
|
+
reset_at = None
|
|
94
|
+
if state == "failed":
|
|
95
|
+
row = confirmed_limit(ctx, name, payload, error_class)
|
|
96
|
+
if row is not None:
|
|
97
|
+
state, reset_at = "limit", row.get("reset_at")
|
|
98
|
+
provider = current.get("provider")
|
|
99
|
+
get_session = (ctx.get("service") or {}).get("get_session")
|
|
100
|
+
if get_session:
|
|
101
|
+
provider = (get_session(name) or {}).get("provider") or provider
|
|
102
|
+
pid = env.get(PID_ENV)
|
|
103
|
+
current.update({
|
|
104
|
+
"session": name,
|
|
105
|
+
"provider": provider,
|
|
106
|
+
"cwd": str(cwd),
|
|
107
|
+
"project": os.path.basename(str(cwd).rstrip("/\\")) or str(cwd),
|
|
108
|
+
"state": state,
|
|
109
|
+
"reason": reason,
|
|
110
|
+
"reset_at": reset_at,
|
|
111
|
+
"pid": int(pid) if pid and pid.isdigit() else current.get("pid"),
|
|
112
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
113
|
+
})
|
|
114
|
+
atomic_write(path, json.dumps(current, indent=2) + "\n", mode=0o600)
|
|
115
|
+
return current
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read(path):
|
|
119
|
+
try:
|
|
120
|
+
with open(path, encoding="utf-8") as handle:
|
|
121
|
+
document = json.load(handle)
|
|
122
|
+
except (OSError, ValueError):
|
|
123
|
+
return None
|
|
124
|
+
return document if isinstance(document, dict) else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def list_agents(base_dir, max_age_hours=DEFAULT_MAX_AGE_HOURS, now=None, is_running=_process_is_running):
|
|
128
|
+
"""Live records, waiting first then newest; stale ones are deleted on the way."""
|
|
129
|
+
now = now or datetime.now(timezone.utc)
|
|
130
|
+
cutoff = now - timedelta(hours=max_age_hours)
|
|
131
|
+
directory = state_dir(base_dir)
|
|
132
|
+
try:
|
|
133
|
+
names = sorted(os.listdir(directory))
|
|
134
|
+
except OSError:
|
|
135
|
+
return []
|
|
136
|
+
agents = []
|
|
137
|
+
for file_name in names:
|
|
138
|
+
if not file_name.endswith(".json"):
|
|
139
|
+
continue
|
|
140
|
+
path = os.path.join(directory, file_name)
|
|
141
|
+
entry = _read(path)
|
|
142
|
+
try:
|
|
143
|
+
updated = datetime.fromisoformat(entry["updated_at"])
|
|
144
|
+
except (TypeError, KeyError, ValueError):
|
|
145
|
+
updated = None
|
|
146
|
+
if updated is None or updated.tzinfo is None or updated < cutoff:
|
|
147
|
+
try:
|
|
148
|
+
os.remove(path)
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
continue
|
|
152
|
+
if entry.get("pid") and not is_running(entry["pid"]):
|
|
153
|
+
entry["last_state"], entry["state"] = entry.get("state"), "ended"
|
|
154
|
+
agents.append(entry)
|
|
155
|
+
agents.sort(key=lambda entry: entry["updated_at"], reverse=True)
|
|
156
|
+
agents.sort(key=lambda entry: entry.get("state") != "waiting")
|
|
157
|
+
return agents
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def handle_agents(rest, ctx):
|
|
161
|
+
# Imported here: this module is loaded by the hook path through
|
|
162
|
+
# agent_notify, and the CLI helpers import provider_runtime, which imports
|
|
163
|
+
# agent_notify back.
|
|
164
|
+
from .cli_args import _parse_flag_args
|
|
165
|
+
from .cli_helpers import _json_success, _write_json
|
|
166
|
+
from .cli_render import _pad_table
|
|
167
|
+
|
|
168
|
+
parsed = _parse_flag_args(rest, {
|
|
169
|
+
"--max-age-hours": {"key": "max_age_hours", "type": "str", "default": None},
|
|
170
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
171
|
+
}, AGENTS_USAGE)
|
|
172
|
+
max_age = DEFAULT_MAX_AGE_HOURS
|
|
173
|
+
if parsed["max_age_hours"] is not None:
|
|
174
|
+
try:
|
|
175
|
+
max_age = int(parsed["max_age_hours"])
|
|
176
|
+
except ValueError:
|
|
177
|
+
max_age = 0
|
|
178
|
+
if max_age < 1:
|
|
179
|
+
raise CdxError(AGENTS_USAGE)
|
|
180
|
+
agents = list_agents(ctx["service"]["base_dir"], max_age)
|
|
181
|
+
if parsed["json"]:
|
|
182
|
+
_write_json(ctx, _json_success("agents", "Agents loaded.", agents=agents, max_age_hours=max_age))
|
|
183
|
+
return 0
|
|
184
|
+
if not agents:
|
|
185
|
+
ctx["out"]("No agent state recorded. Turn it on with: cdx set <name> --agent-state on\n")
|
|
186
|
+
return 0
|
|
187
|
+
rows = [["SESSION", "STATE", "PROJECT", "REASON", "UPDATED"]]
|
|
188
|
+
for entry in agents:
|
|
189
|
+
rows.append([
|
|
190
|
+
entry.get("session") or "",
|
|
191
|
+
entry.get("state") or "",
|
|
192
|
+
entry.get("project") or "",
|
|
193
|
+
entry.get("reason") or "",
|
|
194
|
+
entry.get("updated_at") or "",
|
|
195
|
+
])
|
|
196
|
+
ctx["out"](_pad_table(rows) + "\n")
|
|
197
|
+
return 0
|
package/src/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ import sys
|
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
|
|
8
8
|
from .agent_notify import handle_notify
|
|
9
|
+
from .agent_state import handle_agents
|
|
9
10
|
from .cli_args import SET_USAGE
|
|
10
11
|
from .cli_commands import (
|
|
11
12
|
API_SCHEMA_VERSION,
|
|
@@ -50,6 +51,7 @@ from .cli_commands import (
|
|
|
50
51
|
handle_runs,
|
|
51
52
|
handle_schema,
|
|
52
53
|
handle_select,
|
|
54
|
+
handle_sessions,
|
|
53
55
|
handle_set,
|
|
54
56
|
handle_stats,
|
|
55
57
|
handle_status,
|
|
@@ -152,6 +154,7 @@ __all__ = [
|
|
|
152
154
|
_COMMAND_HANDLERS = {
|
|
153
155
|
"auth": handle_auth,
|
|
154
156
|
"add": handle_add,
|
|
157
|
+
"agents": handle_agents,
|
|
155
158
|
"cp": handle_copy,
|
|
156
159
|
"clean": handle_clean,
|
|
157
160
|
"config": handle_config,
|
|
@@ -187,6 +190,7 @@ _COMMAND_HANDLERS = {
|
|
|
187
190
|
"runs": handle_runs,
|
|
188
191
|
"schema": handle_schema,
|
|
189
192
|
"select": handle_select,
|
|
193
|
+
"sessions": handle_sessions,
|
|
190
194
|
"set": handle_set,
|
|
191
195
|
"stats": handle_stats,
|
|
192
196
|
"status": handle_status,
|
|
@@ -237,13 +241,15 @@ def _print_help(use_color=False):
|
|
|
237
241
|
f" {_style('cdx status --small|-s [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
|
|
238
242
|
f" {_style('cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
|
|
239
243
|
f" {_style('cdx next [--json] [--refresh]', '36', use_color)}",
|
|
244
|
+
f" {_style('cdx agents [--max-age-hours N] [--json]', '36', use_color)}",
|
|
240
245
|
f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
|
|
241
246
|
f" {_style('cdx auth refresh <name|all> [--json]', '36', use_color)}",
|
|
242
247
|
f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
|
|
243
248
|
f" {_style('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 review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json', '36', use_color)}",
|
|
244
|
-
f" {_style('cdx runs [--limit N] [--since 7d|today|DATE] --json', '36', use_color)}",
|
|
249
|
+
f" {_style('cdx runs [--limit N] [--since 7d|today|DATE] [--running] --json', '36', use_color)}",
|
|
245
250
|
f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
|
|
246
251
|
f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
|
|
252
|
+
f" {_style('cdx sessions [--all] --json', '36', use_color)}",
|
|
247
253
|
f" {_style('cdx run-tail <run_id> [--lines N] --json', '36', use_color)}",
|
|
248
254
|
f" {_style('cdx schema --json', '36', use_color)}",
|
|
249
255
|
f" {_style('cdx context show|path|init|edit|clear|set|append [text...] [--json]', '36', use_color)}",
|
|
@@ -257,8 +263,8 @@ def _print_help(use_color=False):
|
|
|
257
263
|
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
258
264
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
|
|
259
265
|
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
260
|
-
f" {_style('cdx resume <name> [--json]', '36', use_color)}",
|
|
261
|
-
f" {_style('cdx can-resume <name> [--json]', '36', use_color)}",
|
|
266
|
+
f" {_style('cdx resume <name> [--conversation <id>] [--json]', '36', use_color)}",
|
|
267
|
+
f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
|
|
262
268
|
f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
|
|
263
269
|
f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",
|
|
264
270
|
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -33,8 +33,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
|
|
|
33
33
|
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
34
34
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
|
|
35
35
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
36
|
-
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] [--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] [--json]"
|
|
37
|
-
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
|
|
36
|
+
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] [--json]"
|
|
37
|
+
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|--all) [--json]"
|
|
38
38
|
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
39
39
|
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
40
40
|
CONFIGS_USAGE = "Usage: cdx configs [--json]"
|
|
@@ -42,8 +42,9 @@ HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|t
|
|
|
42
42
|
STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]"
|
|
43
43
|
STATS_GROUPS = ("session", "project", "model")
|
|
44
44
|
LAST_USAGE = "Usage: cdx last [--json]"
|
|
45
|
-
RESUME_USAGE = "Usage: cdx resume <name> [--json]"
|
|
46
|
-
CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
|
|
45
|
+
RESUME_USAGE = "Usage: cdx resume <name> [--conversation <id>] [--json]"
|
|
46
|
+
CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
|
|
47
|
+
SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
|
|
47
48
|
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"
|
|
48
49
|
NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
|
|
49
50
|
RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) 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] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
|
|
@@ -52,7 +53,6 @@ RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
|
|
|
52
53
|
RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
|
|
53
54
|
RUN_CWD_REQUIRED = "cdx run: --cwd PATH is required."
|
|
54
55
|
RUN_PROMPT_SOURCE_REQUIRED = "cdx run: specify exactly one prompt source: --prompt TEXT or --prompt-file PATH."
|
|
55
|
-
RUN_DETACH_FAILOVER_CONFLICT = "cdx run: --failover needs to watch the run, which --detach gives up."
|
|
56
56
|
RUN_KIND_VALUES = ("assistant", "code-review", "retro")
|
|
57
57
|
# Aliases of the shared definitions in config, kept only so the existing
|
|
58
58
|
# RUN_* spellings in this module keep reading naturally. They are references,
|
|
@@ -61,7 +61,7 @@ RUN_PERMISSION_VALUES = PERMISSION_INPUT_VALUES
|
|
|
61
61
|
RUN_PERMISSION_CANONICAL_VALUES = PERMISSION_VALUES
|
|
62
62
|
RUN_PERMISSION_ALIASES = PERMISSION_ALIASES
|
|
63
63
|
RUN_EFFORT_VALUES = REASONING_EFFORT_VALUES
|
|
64
|
-
RUNS_USAGE = "Usage: cdx runs [--limit N] [--since 7d|today|DATE] --json"
|
|
64
|
+
RUNS_USAGE = "Usage: cdx runs [--limit N] [--since 7d|today|DATE] [--running] --json"
|
|
65
65
|
RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
|
|
66
66
|
RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
|
|
67
67
|
RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
|
|
@@ -80,6 +80,28 @@ ARG_CODE_OUT_OF_RANGE = "argument_value_out_of_range"
|
|
|
80
80
|
ARG_CODE_UNKNOWN = "unknown_argument"
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _pop_value_flag(args, flag, usage):
|
|
84
|
+
"""Remove `flag VALUE` / `flag=VALUE` from args; return (value or None, rest)."""
|
|
85
|
+
value, rest, index = None, [], 0
|
|
86
|
+
while index < len(args):
|
|
87
|
+
arg = args[index]
|
|
88
|
+
name, sep, inline = arg.partition("=")
|
|
89
|
+
if name != flag:
|
|
90
|
+
rest.append(arg)
|
|
91
|
+
index += 1
|
|
92
|
+
continue
|
|
93
|
+
if sep:
|
|
94
|
+
candidate, index = inline, index + 1
|
|
95
|
+
elif index + 1 < len(args):
|
|
96
|
+
candidate, index = args[index + 1], index + 2
|
|
97
|
+
else:
|
|
98
|
+
raise CdxError(usage)
|
|
99
|
+
if value is not None or not candidate:
|
|
100
|
+
raise CdxError(usage)
|
|
101
|
+
value = candidate
|
|
102
|
+
return value, rest
|
|
103
|
+
|
|
104
|
+
|
|
83
105
|
def _parse_json_flag(args):
|
|
84
106
|
json_flag = "--json" in args
|
|
85
107
|
cleaned = [arg for arg in args if arg != "--json"]
|
|
@@ -252,6 +274,7 @@ def _parse_set_args(args):
|
|
|
252
274
|
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _on_off("rtk")},
|
|
253
275
|
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _on_off("logics")},
|
|
254
276
|
"--notify": {"key": "notify", "type": "str", "default": None, "transform": _on_off("notify")},
|
|
277
|
+
"--agent-state": {"key": "agent_state", "type": "str", "default": None, "transform": _on_off("agent state")},
|
|
255
278
|
"--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _on_off("notify preview")},
|
|
256
279
|
"--scrub-env": {"key": "scrub_env", "type": "str", "default": None, "transform": _on_off("scrub env")},
|
|
257
280
|
"--shared-memory": {"key": "shared_memory", "type": "str", "default": None, "transform": _on_off("shared memory")},
|
|
@@ -278,7 +301,7 @@ def _parse_set_args(args):
|
|
|
278
301
|
raise CdxError(SET_USAGE)
|
|
279
302
|
settings = {
|
|
280
303
|
key: parsed[key]
|
|
281
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy")
|
|
304
|
+
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")
|
|
282
305
|
if parsed[key] is not None
|
|
283
306
|
}
|
|
284
307
|
if not settings:
|
|
@@ -301,6 +324,7 @@ def _parse_unset_args(args):
|
|
|
301
324
|
"--rtk": {"key": "rtk", "type": "bool", "default": False},
|
|
302
325
|
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
303
326
|
"--notify": {"key": "notify", "type": "bool", "default": False},
|
|
327
|
+
"--agent-state": {"key": "agent_state", "type": "bool", "default": False},
|
|
304
328
|
"--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
|
|
305
329
|
"--scrub-env": {"key": "scrub_env", "type": "bool", "default": False},
|
|
306
330
|
"--shared-memory": {"key": "shared_memory", "type": "bool", "default": False},
|
|
@@ -327,7 +351,7 @@ def _parse_unset_args(args):
|
|
|
327
351
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
328
352
|
raise CdxError(UNSET_USAGE)
|
|
329
353
|
_UNSET_KEYS = (
|
|
330
|
-
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro",
|
|
354
|
+
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro",
|
|
331
355
|
"model", "fallback_model", "budget", "extra_args", "priority", "policy",
|
|
332
356
|
)
|
|
333
357
|
keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
|
|
@@ -483,11 +507,6 @@ def cdx_schema():
|
|
|
483
507
|
"arguments": ["--prompt-file", "--prompt"],
|
|
484
508
|
"reason": "Exactly one prompt source.",
|
|
485
509
|
},
|
|
486
|
-
{
|
|
487
|
-
"command": "run",
|
|
488
|
-
"arguments": ["--detach", "--failover"],
|
|
489
|
-
"reason": "Failover has to watch the run to react to a rate limit; --detach stops watching.",
|
|
490
|
-
},
|
|
491
510
|
],
|
|
492
511
|
# Not mutually exclusive: both may be given, and cdx accepts them when
|
|
493
512
|
# they agree. A caller generating validation from `mutually_exclusive`
|
|
@@ -671,12 +690,6 @@ def _parse_run_args(args):
|
|
|
671
690
|
code=ARG_CODE_MUTUALLY_EXCLUSIVE,
|
|
672
691
|
arguments=["--prompt-file", "--prompt"],
|
|
673
692
|
)
|
|
674
|
-
if parsed["detach"] and parsed["failover"]:
|
|
675
|
-
raise CdxArgumentError(
|
|
676
|
-
RUN_DETACH_FAILOVER_CONFLICT,
|
|
677
|
-
code=ARG_CODE_MUTUALLY_EXCLUSIVE,
|
|
678
|
-
arguments=["--detach", "--failover"],
|
|
679
|
-
)
|
|
680
693
|
if not parsed["prompt_file"] and not parsed["prompt"]:
|
|
681
694
|
raise CdxArgumentError(
|
|
682
695
|
RUN_PROMPT_SOURCE_REQUIRED,
|
|
@@ -1033,8 +1046,13 @@ def _parse_runs_args(rest, now=None):
|
|
|
1033
1046
|
parsed = _parse_flag_args(rest, {
|
|
1034
1047
|
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
1035
1048
|
"--since": {"key": "since", "type": "str", "default": None},
|
|
1049
|
+
"--running": {"key": "running", "type": "bool", "default": False},
|
|
1036
1050
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1037
1051
|
}, RUNS_USAGE, max_positionals=0)
|
|
1052
|
+
if parsed["running"] and parsed["since"] is not None:
|
|
1053
|
+
# --since returns completed runs only, --running the ones not completed:
|
|
1054
|
+
# together they can only ever answer "nothing".
|
|
1055
|
+
raise CdxError("cdx runs: --running and --since are mutually exclusive.")
|
|
1038
1056
|
since = None
|
|
1039
1057
|
if parsed["since"] is not None:
|
|
1040
1058
|
# Same cursor forms as `cdx history --since`, parsed by the same code:
|
|
@@ -1048,6 +1066,7 @@ def _parse_runs_args(rest, now=None):
|
|
|
1048
1066
|
# a watchdog behind by more than `limit` completions would never see
|
|
1049
1067
|
# the older ones at all.
|
|
1050
1068
|
"limit_given": any(arg == "--limit" or arg.startswith("--limit=") for arg in rest),
|
|
1069
|
+
"running": parsed["running"],
|
|
1051
1070
|
"json": parsed["json"],
|
|
1052
1071
|
}
|
|
1053
1072
|
|
package/src/cli_commands.py
CHANGED
|
@@ -265,6 +265,9 @@ from .commands.status import (
|
|
|
265
265
|
from .commands.status import (
|
|
266
266
|
handle_next as handle_next,
|
|
267
267
|
)
|
|
268
|
+
from .commands.status import (
|
|
269
|
+
handle_sessions as handle_sessions,
|
|
270
|
+
)
|
|
268
271
|
from .commands.status import (
|
|
269
272
|
handle_stats as handle_stats,
|
|
270
273
|
)
|
package/src/cli_helpers.py
CHANGED
|
@@ -312,6 +312,7 @@ def _format_launch_config(session, use_color=False):
|
|
|
312
312
|
("rtk", "RTK"),
|
|
313
313
|
("logics", "Logics"),
|
|
314
314
|
("notify", "Agent alerts"),
|
|
315
|
+
("agent_state", "Agent state"),
|
|
315
316
|
("notify_preview", "Response preview"),
|
|
316
317
|
("scrub_env", "Scrub agent env"),
|
|
317
318
|
("shared_memory", "Shared memory"),
|
|
@@ -343,14 +344,14 @@ def _format_launch_settings_hint(name="<name>"):
|
|
|
343
344
|
)
|
|
344
345
|
|
|
345
346
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
346
|
-
if key in ("fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro"):
|
|
347
|
+
if key in ("fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
|
|
347
348
|
if launch.get(key) is True:
|
|
348
349
|
return _style("on", "32", use_color)
|
|
349
350
|
if launch.get(key) is False:
|
|
350
351
|
return _style("off", "2", use_color)
|
|
351
352
|
if key == "logics":
|
|
352
353
|
return _dim("auto", use_color)
|
|
353
|
-
if key in ("notify", "notify_preview", "scrub_env", "shared_memory", "retro"):
|
|
354
|
+
if key in ("notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
|
|
354
355
|
return _style("off", "2", use_color)
|
|
355
356
|
return _dim("default", use_color)
|
|
356
357
|
value = launch.get(key)
|
package/src/commands/launch.py
CHANGED
|
@@ -8,7 +8,8 @@ import shlex
|
|
|
8
8
|
|
|
9
9
|
from .. import retro, shared_memory
|
|
10
10
|
from ..agent_notify import notifications_enabled, provision, supports_agent_alerts
|
|
11
|
-
from ..
|
|
11
|
+
from ..agent_state import agent_state_enabled
|
|
12
|
+
from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag, _pop_value_flag
|
|
12
13
|
from ..cli_helpers import (
|
|
13
14
|
API_SCHEMA_VERSION,
|
|
14
15
|
_json_failure,
|
|
@@ -30,6 +31,7 @@ from ..provider_runtime import (
|
|
|
30
31
|
_ensure_session_authentication,
|
|
31
32
|
_get_auth_home,
|
|
32
33
|
_run_interactive_provider_command,
|
|
34
|
+
with_requested_conversation,
|
|
33
35
|
)
|
|
34
36
|
from ..run_command import PROMPT_STDIN, read_run_prompt
|
|
35
37
|
from ..run_usage import estimate_cost
|
|
@@ -160,12 +162,14 @@ def _format_resume_capability(capability, use_color=False):
|
|
|
160
162
|
)
|
|
161
163
|
|
|
162
164
|
def handle_can_resume(rest, ctx):
|
|
165
|
+
conversation_id, rest = _pop_value_flag(rest, "--conversation", CAN_RESUME_USAGE)
|
|
163
166
|
json_flag, args = _parse_json_flag(rest)
|
|
164
167
|
if len(args) != 1:
|
|
165
168
|
raise CdxError(CAN_RESUME_USAGE)
|
|
166
169
|
session = ctx["service"]["get_session"](args[0])
|
|
167
170
|
if not session:
|
|
168
171
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
172
|
+
requested = with_requested_conversation(session, conversation_id) if conversation_id else session
|
|
169
173
|
if session.get("enabled", True) is False:
|
|
170
174
|
capability = {
|
|
171
175
|
"session": session["name"],
|
|
@@ -175,8 +179,19 @@ def handle_can_resume(rest, ctx):
|
|
|
175
179
|
"reason": "session_disabled",
|
|
176
180
|
"command_preview": [],
|
|
177
181
|
}
|
|
182
|
+
elif requested is None:
|
|
183
|
+
capability = {
|
|
184
|
+
"session": session["name"],
|
|
185
|
+
"provider": session["provider"],
|
|
186
|
+
"resumable": False,
|
|
187
|
+
"strategy": "provider_conversation_id",
|
|
188
|
+
"identity": conversation_id,
|
|
189
|
+
"provenance": "requested",
|
|
190
|
+
"reason": "conversation_not_found",
|
|
191
|
+
"command_preview": [],
|
|
192
|
+
}
|
|
178
193
|
else:
|
|
179
|
-
capability = _resume_capability_for_session(
|
|
194
|
+
capability = _resume_capability_for_session(requested, ctx)
|
|
180
195
|
if json_flag:
|
|
181
196
|
_write_json(ctx, {
|
|
182
197
|
"schema_version": API_SCHEMA_VERSION,
|
|
@@ -188,13 +203,15 @@ def handle_can_resume(rest, ctx):
|
|
|
188
203
|
return 0
|
|
189
204
|
|
|
190
205
|
def handle_resume(rest, ctx):
|
|
206
|
+
conversation_id, rest = _pop_value_flag(rest, "--conversation", RESUME_USAGE)
|
|
191
207
|
directory, rest, prompt = _parse_launch_args(rest)
|
|
192
208
|
if prompt["prompt"] is not None or prompt["prompt_file"] is not None:
|
|
193
209
|
raise CdxError("cdx resume: --prompt and --prompt-file are not supported when resuming.")
|
|
194
210
|
json_flag, args = _parse_json_flag(rest)
|
|
195
211
|
if len(args) != 1:
|
|
196
212
|
raise CdxError(RESUME_USAGE)
|
|
197
|
-
return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory
|
|
213
|
+
return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory,
|
|
214
|
+
conversation_id=conversation_id)
|
|
198
215
|
|
|
199
216
|
|
|
200
217
|
def _notification_provisioning_enabled(session, ctx, json_flag):
|
|
@@ -237,7 +254,8 @@ def _link_shared_memory(session, ctx, cwd, json_flag):
|
|
|
237
254
|
ctx["out"](f"{_warn(message, ctx['use_color'])} {detail}\n")
|
|
238
255
|
|
|
239
256
|
|
|
240
|
-
def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None
|
|
257
|
+
def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None,
|
|
258
|
+
conversation_id=None):
|
|
241
259
|
json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
|
|
242
260
|
if force_json is not None:
|
|
243
261
|
json_flag = force_json
|
|
@@ -250,6 +268,8 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
250
268
|
raise CdxError(f"Unknown session: {command}")
|
|
251
269
|
if session.get("enabled", True) is False:
|
|
252
270
|
raise CdxError(f"Session is disabled: {command}")
|
|
271
|
+
if conversation_id and with_requested_conversation(session, conversation_id) is None:
|
|
272
|
+
raise CdxError(f"Conversation {conversation_id} not found in session {command}.", code="conversation_not_found")
|
|
253
273
|
capability = _resume_capability_for_session(session, ctx) if resume else None
|
|
254
274
|
if capability and not capability["resumable"]:
|
|
255
275
|
raise CdxError(
|
|
@@ -257,6 +277,8 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
257
277
|
)
|
|
258
278
|
cwd = _launch_directory(session, ctx, directory, json_flag)
|
|
259
279
|
session = ctx["service"]["launch_session"](command, resume=resume)
|
|
280
|
+
if conversation_id:
|
|
281
|
+
session = with_requested_conversation(session, conversation_id)
|
|
260
282
|
capability = _resume_capability_for_session(session, ctx) if resume else None
|
|
261
283
|
if not json_flag and not ctx["stdin_is_tty"]:
|
|
262
284
|
ctx["out"](f"Using directory: {cwd}\n")
|
|
@@ -280,13 +302,15 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
280
302
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
281
303
|
_write_update_notice(ctx)
|
|
282
304
|
alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
|
|
305
|
+
keep_state = agent_state_enabled(session)
|
|
283
306
|
installed = provision(
|
|
284
307
|
_get_auth_home(session),
|
|
285
308
|
session["provider"],
|
|
286
|
-
alerts_allowed,
|
|
309
|
+
alerts_allowed or keep_state,
|
|
287
310
|
ctx.get("env"),
|
|
288
311
|
spawn_sync=ctx.get("spawn_sync"),
|
|
289
312
|
base_dir=ctx["service"]["base_dir"],
|
|
313
|
+
keep_state=keep_state,
|
|
290
314
|
)
|
|
291
315
|
if installed and not json_flag:
|
|
292
316
|
notice = f"Notification hooks installed for {session['name']}"
|
package/src/commands/runs.py
CHANGED
|
@@ -43,7 +43,7 @@ from ..provider_runtime import (
|
|
|
43
43
|
)
|
|
44
44
|
from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
|
|
45
45
|
from ..run_failover import MAX_FAILOVER_TRANSITIONS, failover_reason
|
|
46
|
-
from ..run_registry import RunRegistry, build_code_review_report
|
|
46
|
+
from ..run_registry import RunRegistry, build_code_review_report, final_message
|
|
47
47
|
from ..run_usage import extract_run_observation
|
|
48
48
|
from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
|
|
49
49
|
from ..status_view import _now_timestamp, _priority_reset_timestamp
|
|
@@ -204,6 +204,10 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
|
204
204
|
argv += ["--reasoning-effort", str(parsed["reasoning_effort"])]
|
|
205
205
|
if parsed.get("timeout_seconds"):
|
|
206
206
|
argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
|
|
207
|
+
# The child is the supervisor: it watches the run like any blocking one, so
|
|
208
|
+
# it applies the failover rule and records each move in the registry.
|
|
209
|
+
if parsed.get("failover"):
|
|
210
|
+
argv.append("--failover")
|
|
207
211
|
argv.append("--json")
|
|
208
212
|
return argv
|
|
209
213
|
|
|
@@ -415,7 +419,7 @@ def handle_runs(rest, ctx):
|
|
|
415
419
|
raise CdxError(RUNS_USAGE)
|
|
416
420
|
since = parsed.get("since")
|
|
417
421
|
_reconcile_background_runs(ctx)
|
|
418
|
-
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
|
|
422
|
+
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since, running=parsed.get("running", False))
|
|
419
423
|
warnings = []
|
|
420
424
|
if since is not None and parsed.get("limit_given"):
|
|
421
425
|
warnings.append({
|
|
@@ -503,6 +507,7 @@ def handle_run_report(rest, ctx):
|
|
|
503
507
|
_write_json(ctx, _json_success("run-report", "Run report loaded.", report={
|
|
504
508
|
"run": run,
|
|
505
509
|
"final_payload": run.get("final_payload"),
|
|
510
|
+
"final_message": final_message(run),
|
|
506
511
|
"task_report": run.get("task_report"),
|
|
507
512
|
"artifacts": run.get("artifacts") or {},
|
|
508
513
|
"usage": run.get("usage"),
|
|
@@ -636,7 +641,9 @@ def handle_run(rest, ctx):
|
|
|
636
641
|
# agent and report on it; fall back to the in-house launcher
|
|
637
642
|
# otherwise, and whenever the provider declines to start one.
|
|
638
643
|
launch = None
|
|
639
|
-
|
|
644
|
+
# A provider's own background agent has no cdx process watching it,
|
|
645
|
+
# so nothing could fail it over; --failover keeps the in-house child.
|
|
646
|
+
if not parsed.get("failover") and supports_native_background(
|
|
640
647
|
run_session.get("provider"),
|
|
641
648
|
env=ctx.get("env"),
|
|
642
649
|
spawn_sync=ctx.get("spawn_text"),
|
package/src/commands/status.py
CHANGED
|
@@ -12,6 +12,7 @@ from datetime import datetime
|
|
|
12
12
|
from ..claude_refresh import _refresh_claude_sessions
|
|
13
13
|
from ..cli_args import (
|
|
14
14
|
LAST_USAGE,
|
|
15
|
+
SESSIONS_USAGE,
|
|
15
16
|
STATUS_USAGE,
|
|
16
17
|
_filter_history_period,
|
|
17
18
|
_has_history_period,
|
|
@@ -59,6 +60,7 @@ from ..run_usage import (
|
|
|
59
60
|
token_prices,
|
|
60
61
|
weighted_usage,
|
|
61
62
|
)
|
|
63
|
+
from ..session_status import session_paths
|
|
62
64
|
from ..status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
|
|
63
65
|
|
|
64
66
|
|
|
@@ -926,3 +928,39 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
|
|
|
926
928
|
rows_by_name = _rows_by_session(rows)
|
|
927
929
|
for item in refresh_errors:
|
|
928
930
|
write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def handle_sessions(rest, ctx):
|
|
934
|
+
"""Interactive sessions cdx launched that are still running, or every account.
|
|
935
|
+
|
|
936
|
+
Read from the runtime cdx records at launch, so a provider started outside
|
|
937
|
+
cdx is not listed. A runtime whose process has died is reported stale and
|
|
938
|
+
dropped by the same check `cdx status` uses for `active`. `--all` adds the
|
|
939
|
+
idle enabled accounts, so a caller can resolve any account's directories.
|
|
940
|
+
"""
|
|
941
|
+
json_flag, args = _parse_json_flag(rest)
|
|
942
|
+
include_all = "--all" in args
|
|
943
|
+
args = [arg for arg in args if arg != "--all"]
|
|
944
|
+
if not json_flag or args:
|
|
945
|
+
raise CdxError(SESSIONS_USAGE)
|
|
946
|
+
service = ctx["service"]
|
|
947
|
+
rows = []
|
|
948
|
+
for session in service["list_sessions"]():
|
|
949
|
+
if session.get("enabled", True) is False:
|
|
950
|
+
continue
|
|
951
|
+
runtime = service["active_session_runtime"](session["name"]) or {}
|
|
952
|
+
if not runtime and not include_all:
|
|
953
|
+
continue
|
|
954
|
+
rows.append({
|
|
955
|
+
"session": session["name"],
|
|
956
|
+
"provider": session["provider"],
|
|
957
|
+
"active": bool(runtime),
|
|
958
|
+
"pid": runtime.get("pid"),
|
|
959
|
+
"cwd": runtime.get("cwd"),
|
|
960
|
+
"started_at": runtime.get("startedAt"),
|
|
961
|
+
"runtime_id": runtime.get("runId"),
|
|
962
|
+
"conversation_id": (session.get("conversation") or {}).get("id"),
|
|
963
|
+
**session_paths(session),
|
|
964
|
+
})
|
|
965
|
+
_write_json(ctx, _json_success("sessions", "Sessions loaded.", sessions=rows))
|
|
966
|
+
return 0
|
package/src/provider_runtime.py
CHANGED
|
@@ -12,6 +12,7 @@ import uuid
|
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
|
|
14
14
|
from .agent_notify import launch_notify_env, notifications_enabled
|
|
15
|
+
from .agent_state import agent_state_enabled
|
|
15
16
|
from .claude_credentials import peek_keychain_credentials, read_claude_login_file
|
|
16
17
|
from .claude_usage import _clean_oauth_token, _decode_jwt_claims, _secure_storage_overrides, claude_profile_store_dir
|
|
17
18
|
from .codex_usage import codex_auth_lock, diagnostic_needs_codex_login, fetch_codex_rate_limit_diagnostic
|
|
@@ -716,7 +717,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
716
717
|
for key, value in _shared_tool_cache_overrides(env).items():
|
|
717
718
|
env.setdefault(key, value)
|
|
718
719
|
_shared_rtk_history(env)
|
|
719
|
-
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
720
|
+
env.update(launch_notify_env(session, notifications_enabled(session) or agent_state_enabled(session)))
|
|
720
721
|
env.update(_scrub_env(session))
|
|
721
722
|
preference_args = _launch_preference_args(session, env=env)
|
|
722
723
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
@@ -820,6 +821,18 @@ def _conversation_is_resumable(session, provider, conversation_id):
|
|
|
820
821
|
return False
|
|
821
822
|
|
|
822
823
|
|
|
824
|
+
def with_requested_conversation(session, conversation_id):
|
|
825
|
+
"""The session as if `conversation_id` were the one it last opened.
|
|
826
|
+
|
|
827
|
+
Returns None when that conversation does not exist in this session's
|
|
828
|
+
profile: an explicitly named resume must fail, not quietly fall back to
|
|
829
|
+
--last/--continue and open a different conversation.
|
|
830
|
+
"""
|
|
831
|
+
if not _conversation_is_resumable(session, session.get("provider"), conversation_id):
|
|
832
|
+
return None
|
|
833
|
+
return {**session, "conversation": {"id": conversation_id, "provenance": "requested"}}
|
|
834
|
+
|
|
835
|
+
|
|
823
836
|
def _redacted_resume_command_preview(session, cwd=None):
|
|
824
837
|
capability = get_resume_capability(session, cwd=cwd)
|
|
825
838
|
return capability.get("command_preview")
|
package/src/run_registry.py
CHANGED
|
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|
|
7
7
|
|
|
8
8
|
from .errors import CdxError
|
|
9
9
|
from .fs_utils import atomic_write
|
|
10
|
-
from .run_usage import coerce_usage
|
|
10
|
+
from .run_usage import _parse_json_records, coerce_usage
|
|
11
11
|
from .session_helpers import _process_is_running
|
|
12
12
|
|
|
13
13
|
|
|
@@ -367,7 +367,7 @@ class RunRegistry:
|
|
|
367
367
|
return run
|
|
368
368
|
return None
|
|
369
369
|
|
|
370
|
-
def list(self, limit=20, since=None):
|
|
370
|
+
def list(self, limit=20, since=None, running=False):
|
|
371
371
|
"""Recent runs, newest first.
|
|
372
372
|
|
|
373
373
|
`since` selects every run that *completed* strictly after that moment
|
|
@@ -384,6 +384,10 @@ class RunRegistry:
|
|
|
384
384
|
if changed:
|
|
385
385
|
_write_registry(self.path, data)
|
|
386
386
|
runs = data["runs"]
|
|
387
|
+
if running:
|
|
388
|
+
# Every in-flight run, uncapped: a caller counting live runs against
|
|
389
|
+
# a cap must not be handed a truncated count.
|
|
390
|
+
return [run for run in runs if run.get("status") == "running"]
|
|
387
391
|
if since is not None:
|
|
388
392
|
return [run for run in runs if _completed_after(run, since)]
|
|
389
393
|
return runs[: max(0, int(limit or 20))]
|
|
@@ -410,6 +414,51 @@ def read_text_file(path, limit=120000):
|
|
|
410
414
|
return ""
|
|
411
415
|
|
|
412
416
|
|
|
417
|
+
def _record_final_message(record):
|
|
418
|
+
"""The final assistant text one provider record carries, or None."""
|
|
419
|
+
if not isinstance(record, dict):
|
|
420
|
+
return None
|
|
421
|
+
# Claude: `--output-format json` ends with a `result` envelope.
|
|
422
|
+
if record.get("type") == "result" and isinstance(record.get("result"), str):
|
|
423
|
+
return record["result"]
|
|
424
|
+
# Codex `exec --json`: an `item.completed` event wrapping an agent_message.
|
|
425
|
+
item = record.get("item")
|
|
426
|
+
if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
427
|
+
return item["text"]
|
|
428
|
+
# Older Codex builds wrapped it as {"msg": {"type": "agent_message", "message": ...}}.
|
|
429
|
+
msg = record.get("msg")
|
|
430
|
+
if isinstance(msg, dict) and msg.get("type") == "agent_message" and isinstance(msg.get("message"), str):
|
|
431
|
+
return msg["message"]
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def final_message(run):
|
|
436
|
+
"""The run's last assistant message, read the same way for every provider.
|
|
437
|
+
|
|
438
|
+
A `--bg` run records it from the transcript as `final_payload.result`; any
|
|
439
|
+
other run left it in its stdout, in the provider's own event format. None
|
|
440
|
+
when neither holds one (a plain-text provider, a run that died early).
|
|
441
|
+
"""
|
|
442
|
+
result = (run.get("final_payload") or {}).get("result")
|
|
443
|
+
if isinstance(result, str) and result.strip():
|
|
444
|
+
return result
|
|
445
|
+
stdout_path = (run.get("artifacts") or {}).get("stdout_path")
|
|
446
|
+
if not stdout_path:
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
with open(stdout_path, encoding="utf-8", errors="replace") as handle:
|
|
450
|
+
text = handle.read()
|
|
451
|
+
except OSError:
|
|
452
|
+
return None
|
|
453
|
+
if not text.strip():
|
|
454
|
+
return None
|
|
455
|
+
for record in reversed(_parse_json_records(text)):
|
|
456
|
+
message = _record_final_message(record)
|
|
457
|
+
if message is not None:
|
|
458
|
+
return message
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
413
462
|
def build_code_review_report(run_id, final_payload):
|
|
414
463
|
stdout = read_text_file(final_payload.get("stdout_path") if final_payload else None)
|
|
415
464
|
parsed = None
|
package/src/session_helpers.py
CHANGED
package/src/session_service.py
CHANGED
|
@@ -148,7 +148,7 @@ def _seed_codex_auth_from_global(auth_home, env=None):
|
|
|
148
148
|
return True
|
|
149
149
|
|
|
150
150
|
|
|
151
|
-
_ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
|
|
151
|
+
_ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "agent_state": "agent state", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
def _has_control_chars(text):
|
|
@@ -323,7 +323,7 @@ def unset_launch_settings(store, name, keys):
|
|
|
323
323
|
if not keys:
|
|
324
324
|
raise CdxError("At least one launch setting is required.")
|
|
325
325
|
allowed = {
|
|
326
|
-
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
326
|
+
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview",
|
|
327
327
|
"scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy",
|
|
328
328
|
}
|
|
329
329
|
unknown = [key for key in keys if key not in allowed]
|
package/src/session_status.py
CHANGED
|
@@ -338,6 +338,25 @@ def _fetch_codex_status(codex_status_fetcher, custom_codex_status_fetcher, defau
|
|
|
338
338
|
timeout = timeout_seconds or default_status_timeout_seconds
|
|
339
339
|
return codex_status_fetcher(session, timeout=timeout)
|
|
340
340
|
|
|
341
|
+
def session_paths(session):
|
|
342
|
+
"""Where a session's files live, so a caller never rebuilds cdx's layout.
|
|
343
|
+
|
|
344
|
+
`profile_dir` is the session's own directory and `transcripts_dir` the
|
|
345
|
+
provider's conversation store inside it (None for a provider cdx does not
|
|
346
|
+
read transcripts from). The credential home itself is deliberately not
|
|
347
|
+
named, the same rule `cdx status` rows follow.
|
|
348
|
+
"""
|
|
349
|
+
auth_home = session.get("authHome")
|
|
350
|
+
transcripts = {
|
|
351
|
+
PROVIDER_CLAUDE: (".claude", "projects"),
|
|
352
|
+
PROVIDER_CODEX: ("sessions",),
|
|
353
|
+
}.get(session.get("provider"))
|
|
354
|
+
return {
|
|
355
|
+
"profile_dir": session.get("sessionRoot") or auth_home,
|
|
356
|
+
"transcripts_dir": os.path.join(auth_home, *transcripts) if auth_home and transcripts else None,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
341
360
|
def _status_row_from_session(store, s):
|
|
342
361
|
status = s.get("lastStatus")
|
|
343
362
|
enabled = s.get("enabled", True) is not False
|