cdx-manager 0.12.4 → 0.15.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 +214 -19
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +324 -39
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +190 -2
- package/src/cli_render.py +17 -0
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +849 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +713 -0
- package/src/config.py +70 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +142 -15
- package/src/run_command.py +112 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +127 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +698 -1426
- package/src/session_status.py +550 -0
- package/src/status_source.py +45 -0
- package/src/status_view.py +15 -34
package/src/config.py
CHANGED
|
@@ -7,6 +7,76 @@ PROVIDER_ANTIGRAVITY = "antigravity"
|
|
|
7
7
|
PROVIDER_OLLAMA = "ollama"
|
|
8
8
|
PROVIDERS = (PROVIDER_CODEX, PROVIDER_CLAUDE, PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA)
|
|
9
9
|
|
|
10
|
+
# The values cdx accepts, defined once. Every validator, normalizer, and
|
|
11
|
+
# `cdx schema --json` derives from these. They used to be restated in
|
|
12
|
+
# cli_args, provider_runtime, and session_service — six copies for two
|
|
13
|
+
# concepts — which let `cdx set` and `cdx run` disagree about the same
|
|
14
|
+
# setting, and let `cdx schema --json` describe only one of them.
|
|
15
|
+
# config.py owns them because it is the leaf: everything imports it and it
|
|
16
|
+
# imports nothing of ours, so no ownership choice here can create a cycle.
|
|
17
|
+
|
|
18
|
+
# Ordered weakest to strongest; the order is meaningful for comparisons such
|
|
19
|
+
# as `--min-reasoning-effort`, so consumers must not re-sort it.
|
|
20
|
+
REASONING_EFFORT_VALUES = ("minimal", "low", "medium", "high", "xhigh")
|
|
21
|
+
|
|
22
|
+
# The canonical permissions, i.e. what cdx stores and what providers are
|
|
23
|
+
# mapped from.
|
|
24
|
+
PERMISSION_VALUES = ("review", "default", "auto", "full")
|
|
25
|
+
|
|
26
|
+
# Provider-native spellings accepted as input and normalized to the
|
|
27
|
+
# canonical value above, so a user can paste what their provider calls it.
|
|
28
|
+
PERMISSION_ALIASES = {
|
|
29
|
+
"workspace-write": "default",
|
|
30
|
+
"read-only": "review",
|
|
31
|
+
"danger-full-access": "full",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Everything accepted as input for a permission, canonical values first.
|
|
35
|
+
PERMISSION_INPUT_VALUES = PERMISSION_VALUES + tuple(PERMISSION_ALIASES)
|
|
36
|
+
|
|
37
|
+
# A budget is a ceiling, so the only bound worth enforcing is the one a caller
|
|
38
|
+
# could cross by accident. Zero and negatives express nothing; the upper bound
|
|
39
|
+
# catches a misplaced decimal rather than stating a policy. Defined here, not in
|
|
40
|
+
# each parser, because `cdx set` and `cdx run` validating the same setting from
|
|
41
|
+
# separate literals is how they came to disagree before.
|
|
42
|
+
MAX_LAUNCH_BUDGET_USD = 10000
|
|
43
|
+
|
|
44
|
+
MAX_LAUNCH_EXTRA_ARGS_LENGTH = 512
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_extra_args(value):
|
|
48
|
+
"""Passthrough arguments as an argv list, or None if the string is malformed.
|
|
49
|
+
|
|
50
|
+
POSIX splitting on every platform, deliberately. The alternative - following
|
|
51
|
+
the host's own quoting rules - would make the same `cdx set` mean different
|
|
52
|
+
things on Windows and on macOS, and these strings travel between machines
|
|
53
|
+
through `cdx export`. One rule everywhere is worth more than matching the
|
|
54
|
+
local shell, especially since cdx never invokes a shell: the result is
|
|
55
|
+
passed as argv, so nothing here is ever re-interpreted.
|
|
56
|
+
"""
|
|
57
|
+
import shlex
|
|
58
|
+
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
return shlex.split(str(value), posix=True)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_permission(value):
|
|
68
|
+
"""Canonical form of a permission input, or None if it is not one.
|
|
69
|
+
|
|
70
|
+
Returning None rather than raising keeps the error message and error code
|
|
71
|
+
the caller's business: `cdx run` and `cdx set` word their failures
|
|
72
|
+
differently, but they must agree on what is valid.
|
|
73
|
+
"""
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
text = str(value).strip().lower()
|
|
77
|
+
text = PERMISSION_ALIASES.get(text, text)
|
|
78
|
+
return text if text in PERMISSION_VALUES else None
|
|
79
|
+
|
|
10
80
|
|
|
11
81
|
def get_cdx_home(env=None):
|
|
12
82
|
if env is None:
|
package/src/errors.py
CHANGED
|
@@ -2,3 +2,20 @@ class CdxError(Exception):
|
|
|
2
2
|
def __init__(self, message, exit_code=1):
|
|
3
3
|
super().__init__(message)
|
|
4
4
|
self.exit_code = exit_code
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CdxArgumentError(CdxError):
|
|
8
|
+
"""A usage failure that names its own machine-readable code and arguments.
|
|
9
|
+
|
|
10
|
+
Programmatic callers used to get a single `invalid_request` code carrying the
|
|
11
|
+
whole human usage line, which made a missing `--cwd` indistinguishable from
|
|
12
|
+
passing a session name and `--provider` together. Raising this instead lets
|
|
13
|
+
the JSON payload report `code` plus the offending `arguments` as data, so a
|
|
14
|
+
caller never has to match on message text.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message, *, code, arguments=(), allowed=None, exit_code=1):
|
|
18
|
+
super().__init__(message, exit_code)
|
|
19
|
+
self.code = code
|
|
20
|
+
self.arguments = tuple(arguments)
|
|
21
|
+
self.allowed = tuple(allowed) if allowed is not None else None
|
package/src/health.py
CHANGED
|
@@ -9,10 +9,23 @@ from datetime import datetime, timezone
|
|
|
9
9
|
from urllib.parse import quote, unquote
|
|
10
10
|
|
|
11
11
|
from .cli_render import _pad_table, _style
|
|
12
|
-
from .config import PROVIDER_CODEX
|
|
13
|
-
from .provider_runtime import
|
|
12
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
13
|
+
from .provider_runtime import (
|
|
14
|
+
HEADLESS_CODEX_PERMISSION_ARGS,
|
|
15
|
+
LAUNCH_PERMISSION_ARGS,
|
|
16
|
+
codex_auth_diagnostic,
|
|
17
|
+
)
|
|
14
18
|
from .status_source import _extract_account_identity
|
|
15
19
|
|
|
20
|
+
# The executable each provider is driven through, where it differs from the
|
|
21
|
+
# provider name.
|
|
22
|
+
PROVIDER_CLI_COMMANDS = {
|
|
23
|
+
PROVIDER_CODEX: "codex",
|
|
24
|
+
PROVIDER_CLAUDE: "claude",
|
|
25
|
+
PROVIDER_ANTIGRAVITY: "agy",
|
|
26
|
+
PROVIDER_OLLAMA: "ollama",
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
|
|
17
30
|
def _encode(name):
|
|
18
31
|
return quote(name, safe="")
|
|
@@ -36,7 +49,14 @@ def _issue(status, code, message, detail=None, repairable=False):
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
|
|
39
|
-
def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
52
|
+
def collect_health_report(service, base_dir, env=None, spawn_sync=None, check_provider_flags=False):
|
|
53
|
+
"""Health report for the cdx home, sessions, and provider CLIs.
|
|
54
|
+
|
|
55
|
+
`check_provider_flags` is opt-in because it costs a provider CLI
|
|
56
|
+
invocation per configured provider. When it is off the report still says
|
|
57
|
+
so, rather than omitting the check silently — an absent check must not
|
|
58
|
+
read as a passing one.
|
|
59
|
+
"""
|
|
40
60
|
env = env or os.environ
|
|
41
61
|
issues = []
|
|
42
62
|
|
|
@@ -96,6 +116,15 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
|
96
116
|
|
|
97
117
|
issues.extend(_codex_shared_account_id_issues(codex_diagnostics))
|
|
98
118
|
issues.extend(_collect_profile_issues(base_dir, session_names))
|
|
119
|
+
if check_provider_flags:
|
|
120
|
+
issues.extend(_provider_flag_issues(service, env, spawn_sync))
|
|
121
|
+
else:
|
|
122
|
+
issues.append(_issue(
|
|
123
|
+
"WARN",
|
|
124
|
+
"provider_permission_flags_unchecked",
|
|
125
|
+
"Provider permission flags were not verified; run cdx doctor --check-provider-flags",
|
|
126
|
+
{"reason": "opt_in", "cost": "one provider CLI invocation per configured provider"},
|
|
127
|
+
))
|
|
99
128
|
return {"base_dir": base_dir, "issues": issues, "summary": summarize_health(issues)}
|
|
100
129
|
|
|
101
130
|
|
|
@@ -143,6 +172,125 @@ def _extract_version(text):
|
|
|
143
172
|
return match.group(1) if match else None
|
|
144
173
|
|
|
145
174
|
|
|
175
|
+
def _mapped_provider_flags(provider):
|
|
176
|
+
"""Every distinct flag cdx would pass to this provider, by permission.
|
|
177
|
+
|
|
178
|
+
Read off the same mappings the launch specs use, so a permission added
|
|
179
|
+
there is automatically covered here rather than quietly unverified.
|
|
180
|
+
Values (a sandbox name, a config expression) are not flags and are not
|
|
181
|
+
checked — only the options themselves.
|
|
182
|
+
"""
|
|
183
|
+
mappings = {}
|
|
184
|
+
interactive = LAUNCH_PERMISSION_ARGS.get(provider)
|
|
185
|
+
if interactive is None:
|
|
186
|
+
# No entry at all means nobody declared this provider's mapping, which
|
|
187
|
+
# is different from declaring that it has none.
|
|
188
|
+
return {}
|
|
189
|
+
for permission, args in interactive.items():
|
|
190
|
+
mappings.setdefault(permission, set()).update(
|
|
191
|
+
arg for arg in args if arg.startswith("-")
|
|
192
|
+
)
|
|
193
|
+
if provider == PROVIDER_CODEX:
|
|
194
|
+
for permission, args in HEADLESS_CODEX_PERMISSION_ARGS.items():
|
|
195
|
+
mappings.setdefault(permission, set()).update(
|
|
196
|
+
arg for arg in args if arg.startswith("-")
|
|
197
|
+
)
|
|
198
|
+
return {permission: sorted(flags) for permission, flags in mappings.items() if flags}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _provider_help_text(command, env, spawn_sync=None):
|
|
202
|
+
"""The provider CLI's own option list, or None if it cannot be obtained.
|
|
203
|
+
|
|
204
|
+
Help output is the cheapest authoritative answer to "does this CLI accept
|
|
205
|
+
this flag". Launching a real run would be authoritative too, and far more
|
|
206
|
+
expensive for a diagnostic.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
if spawn_sync:
|
|
210
|
+
result = spawn_sync(command, ["--help"], {"env": env, "timeout": 10})
|
|
211
|
+
if isinstance(result, dict):
|
|
212
|
+
text = f"{result.get('stdout') or ''}\n{result.get('stderr') or ''}"
|
|
213
|
+
else:
|
|
214
|
+
text = f"{getattr(result, 'stdout', '') or ''}\n{getattr(result, 'stderr', '') or ''}"
|
|
215
|
+
else:
|
|
216
|
+
completed = subprocess.run(
|
|
217
|
+
[command, "--help"], env=env, capture_output=True, text=True, timeout=10, check=False,
|
|
218
|
+
)
|
|
219
|
+
text = f"{completed.stdout or ''}\n{completed.stderr or ''}"
|
|
220
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
221
|
+
return None
|
|
222
|
+
return text if text.strip() else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _provider_flag_issues(service, env, spawn_sync=None):
|
|
226
|
+
"""Verify the flags cdx maps for each permission exist in the provider CLI.
|
|
227
|
+
|
|
228
|
+
GitHub issue #8 was exactly this going unchecked: `--experimental-yolo` was
|
|
229
|
+
mapped for ollama, a flag that CLI never had, and nothing noticed until a
|
|
230
|
+
user ran `permission=full` on an ollama session.
|
|
231
|
+
|
|
232
|
+
An unverifiable mapping reports WARN, never OK: "we could not check" is the
|
|
233
|
+
state that bug lived in for months, and it must not read as a pass.
|
|
234
|
+
"""
|
|
235
|
+
issues = []
|
|
236
|
+
providers = sorted({
|
|
237
|
+
session.get("provider") for session in service["list_sessions"]()
|
|
238
|
+
if session.get("provider")
|
|
239
|
+
})
|
|
240
|
+
for provider in providers:
|
|
241
|
+
command = PROVIDER_CLI_COMMANDS.get(provider, provider)
|
|
242
|
+
mappings = _mapped_provider_flags(provider)
|
|
243
|
+
if not mappings:
|
|
244
|
+
issues.append(_issue(
|
|
245
|
+
"OK",
|
|
246
|
+
f"{provider}_permission_flags",
|
|
247
|
+
f"{provider} maps no permission to CLI flags; nothing to verify",
|
|
248
|
+
{"provider": provider, "command": command, "mapped": {}},
|
|
249
|
+
))
|
|
250
|
+
continue
|
|
251
|
+
if not shutil.which(command, path=env.get("PATH")):
|
|
252
|
+
issues.append(_issue(
|
|
253
|
+
"WARN",
|
|
254
|
+
f"{provider}_permission_flags",
|
|
255
|
+
f"{provider} CLI not installed; mapped permission flags could not be verified",
|
|
256
|
+
{"provider": provider, "command": command, "mapped": mappings},
|
|
257
|
+
))
|
|
258
|
+
continue
|
|
259
|
+
help_text = _provider_help_text(command, env, spawn_sync)
|
|
260
|
+
if help_text is None:
|
|
261
|
+
issues.append(_issue(
|
|
262
|
+
"WARN",
|
|
263
|
+
f"{provider}_permission_flags",
|
|
264
|
+
f"{provider} CLI help could not be read; mapped permission flags could not be verified",
|
|
265
|
+
{"provider": provider, "command": command, "mapped": mappings},
|
|
266
|
+
))
|
|
267
|
+
continue
|
|
268
|
+
missing = {
|
|
269
|
+
permission: [flag for flag in flags if flag not in help_text]
|
|
270
|
+
for permission, flags in mappings.items()
|
|
271
|
+
}
|
|
272
|
+
missing = {permission: flags for permission, flags in missing.items() if flags}
|
|
273
|
+
detail = {"provider": provider, "command": command, "mapped": mappings, "missing": missing}
|
|
274
|
+
if missing:
|
|
275
|
+
described = "; ".join(
|
|
276
|
+
f"{permission}: {', '.join(flags)}" for permission, flags in sorted(missing.items())
|
|
277
|
+
)
|
|
278
|
+
issues.append(_issue(
|
|
279
|
+
"FAIL",
|
|
280
|
+
f"{provider}_permission_flags",
|
|
281
|
+
f"{provider} CLI does not accept flags cdx maps ({described})",
|
|
282
|
+
detail,
|
|
283
|
+
))
|
|
284
|
+
else:
|
|
285
|
+
issues.append(_issue(
|
|
286
|
+
"OK",
|
|
287
|
+
f"{provider}_permission_flags",
|
|
288
|
+
f"{provider} CLI accepts every flag cdx maps for its permissions",
|
|
289
|
+
detail,
|
|
290
|
+
))
|
|
291
|
+
return issues
|
|
292
|
+
|
|
293
|
+
|
|
146
294
|
def _provider_capability_hints(command):
|
|
147
295
|
if command == "codex":
|
|
148
296
|
return [
|
package/src/notify.py
CHANGED
|
@@ -7,11 +7,11 @@ import time
|
|
|
7
7
|
from datetime import datetime, timedelta
|
|
8
8
|
|
|
9
9
|
from .errors import CdxError
|
|
10
|
+
from .session_ranking import rank_sessions
|
|
10
11
|
from .status_view import (
|
|
11
12
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
|
|
12
13
|
_parse_reset_timestamp,
|
|
13
14
|
_priority_reset_timestamp,
|
|
14
|
-
_recommend_priority_sessions,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
|
|
@@ -103,10 +103,16 @@ def resolve_notify_event(rows, parsed, now_ts=None):
|
|
|
103
103
|
now_ts = time.time() if now_ts is None else now_ts
|
|
104
104
|
if parsed["mode"] == "next-ready":
|
|
105
105
|
active_rows = [row for row in rows if row.get("enabled", True) is not False]
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
# Same ranking every other selector uses; the pre-filter is what makes
|
|
107
|
+
# this "next to come back" rather than "best right now".
|
|
108
|
+
priority, _decision = rank_sessions(
|
|
109
|
+
[
|
|
110
|
+
row for row in active_rows
|
|
111
|
+
if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
|
|
112
|
+
],
|
|
113
|
+
now_ts,
|
|
114
|
+
_priority_reset_timestamp,
|
|
115
|
+
)
|
|
110
116
|
if not priority:
|
|
111
117
|
return _event(False, "cdx", "No upcoming session reset available", None)
|
|
112
118
|
first = priority[0]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Delegating detached runs to a provider's own background agents.
|
|
2
|
+
|
|
3
|
+
cdx has always detached runs itself: fork the child into its own session, keep
|
|
4
|
+
its pid, sweep the record when that pid dies. Claude Code now ships the same
|
|
5
|
+
thing natively - `--bg` starts a background agent and `claude agents --json`
|
|
6
|
+
lists them - so on that provider cdx can stop owning the mechanism and own the
|
|
7
|
+
routing instead, which is the part no provider will do.
|
|
8
|
+
|
|
9
|
+
Two deliberate limits:
|
|
10
|
+
|
|
11
|
+
Claude only. codex-cli exposes `cloud` and `exec-server`, both marked
|
|
12
|
+
EXPERIMENTAL in its own --help. Trading a working in-house path for a surface
|
|
13
|
+
the provider may change without notice is a bad trade, so Codex keeps the
|
|
14
|
+
in-house launcher until that surface settles.
|
|
15
|
+
|
|
16
|
+
Capability, never version. The installed CLI is asked what it supports, the
|
|
17
|
+
same way `cdx doctor --check-provider-flags` already does, because a version
|
|
18
|
+
comparison goes stale the moment either side ships.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
|
|
26
|
+
from .config import PROVIDER_CLAUDE
|
|
27
|
+
|
|
28
|
+
BACKGROUND_PATH_PROVIDER = "provider_native"
|
|
29
|
+
BACKGROUND_PATH_CDX = "cdx_detached"
|
|
30
|
+
|
|
31
|
+
_CAPABILITY_PROBE_TIMEOUT_SECONDS = 10
|
|
32
|
+
|
|
33
|
+
# Observed on Claude Code 2.1.226 by watching one agent from launch to finish:
|
|
34
|
+
# completion is carried by `state`, not by `status`. `status` alternates
|
|
35
|
+
# idle/busy - it describes activity, not outcome - and an agent waiting on
|
|
36
|
+
# something sits at `state: "blocked"` with `status: "idle"`, which reads
|
|
37
|
+
# exactly like a finished one if you look at `status`.
|
|
38
|
+
_TERMINAL_STATES = {"done", "failed", "cancelled", "canceled", "error", "stopped"}
|
|
39
|
+
_SUCCESS_STATE = "done"
|
|
40
|
+
|
|
41
|
+
# `claude --bg` prints `backgrounded · <id>` on stdout. That is the authoritative
|
|
42
|
+
# handle: the provider assigns the id itself and ignores --session-id, so this
|
|
43
|
+
# line is the only thing that reliably names the agent just started.
|
|
44
|
+
_BACKGROUNDED = re.compile(r"backgrounded\s*[^\w]?\s*([0-9a-fA-F]{6,})")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_backgrounded_id(output):
|
|
48
|
+
"""The agent id `claude --bg` reported, or None."""
|
|
49
|
+
if not output:
|
|
50
|
+
return None
|
|
51
|
+
match = _BACKGROUNDED.search(output)
|
|
52
|
+
return match.group(1) if match else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
EXPERIMENTAL_ENV = "CDX_EXPERIMENTAL_NATIVE_BG"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def supports_native_background(provider, env=None, spawn_sync=None):
|
|
59
|
+
"""Whether the installed provider CLI can run and report background agents.
|
|
60
|
+
|
|
61
|
+
Off unless `CDX_EXPERIMENTAL_NATIVE_BG=1`. Two live trials against Claude
|
|
62
|
+
Code 2.1.226 fixed the identification problems and exposed a deeper one.
|
|
63
|
+
|
|
64
|
+
Fixed: the provider assigns its own id and ignores `--session-id`, so the
|
|
65
|
+
agent is now named from the `backgrounded - <id>` line `--bg` prints, and
|
|
66
|
+
completion is read from `state` rather than `status` (which alternates
|
|
67
|
+
idle/busy and says nothing about outcome).
|
|
68
|
+
|
|
69
|
+
Not fixed, and the reason this stays off:
|
|
70
|
+
|
|
71
|
+
1. A delegated run cannot honour the `cdx run --json` contract. The
|
|
72
|
+
in-house path runs `claude --print --output-format json` and gets a
|
|
73
|
+
structured result, token usage, and a final payload. `--bg` runs the
|
|
74
|
+
interactive TUI; `claude logs <id>` returns an ANSI screen dump. So
|
|
75
|
+
usage accounting, the final payload, and the rate-limit classification
|
|
76
|
+
that `--failover` depends on all have no source.
|
|
77
|
+
2. Nothing yet drives a delegated run to a terminal status. `agent_terminal_status`
|
|
78
|
+
exists but no caller consults the provider, so the record relies on pid
|
|
79
|
+
liveness and a blocked agent reads as running indefinitely.
|
|
80
|
+
3. cdx's own launch spec makes the agent block. A hand-run
|
|
81
|
+
`claude --bg --permission-mode bypassPermissions "..."` reaches
|
|
82
|
+
`state: done`; the same task launched through cdx sits at
|
|
83
|
+
`state: blocked`, most likely because the spec passes a `--session-id`
|
|
84
|
+
that already exists.
|
|
85
|
+
|
|
86
|
+
Delegation therefore needs its own request, not a flag flip.
|
|
87
|
+
"""
|
|
88
|
+
if provider != PROVIDER_CLAUDE:
|
|
89
|
+
return False
|
|
90
|
+
environment = env if env is not None else os.environ
|
|
91
|
+
if str(environment.get(EXPERIMENTAL_ENV, "")).strip() not in ("1", "true", "on", "yes"):
|
|
92
|
+
return False
|
|
93
|
+
runner = spawn_sync or _default_spawn_sync
|
|
94
|
+
try:
|
|
95
|
+
help_text = runner(["claude", "--help"], env=env)
|
|
96
|
+
agents_help = runner(["claude", "agents", "--help"], env=env)
|
|
97
|
+
except (OSError, subprocess.SubprocessError):
|
|
98
|
+
return False
|
|
99
|
+
if help_text is None or agents_help is None:
|
|
100
|
+
return False
|
|
101
|
+
return "--bg" in help_text and "--json" in agents_help
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _default_spawn_sync(argv, env=None):
|
|
105
|
+
try:
|
|
106
|
+
completed = subprocess.run(
|
|
107
|
+
argv,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=_CAPABILITY_PROBE_TIMEOUT_SECONDS,
|
|
111
|
+
env=env,
|
|
112
|
+
check=False,
|
|
113
|
+
)
|
|
114
|
+
except (OSError, subprocess.SubprocessError):
|
|
115
|
+
return None
|
|
116
|
+
return f"{completed.stdout or ''}{completed.stderr or ''}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_background_agents(env=None, cwd=None, spawn_sync=None):
|
|
120
|
+
"""Every background agent the provider knows about, completed ones included.
|
|
121
|
+
|
|
122
|
+
Returns [] rather than raising for any failure: this feeds a status refresh
|
|
123
|
+
that must never take down the command that asked for it.
|
|
124
|
+
"""
|
|
125
|
+
runner = spawn_sync or _default_spawn_sync
|
|
126
|
+
argv = ["claude", "agents", "--json", "--all"]
|
|
127
|
+
if cwd:
|
|
128
|
+
argv += ["--cwd", cwd]
|
|
129
|
+
output = runner(argv, env=env)
|
|
130
|
+
if not output:
|
|
131
|
+
return []
|
|
132
|
+
start = output.find("[")
|
|
133
|
+
if start < 0:
|
|
134
|
+
return []
|
|
135
|
+
try:
|
|
136
|
+
agents = json.loads(output[start:])
|
|
137
|
+
except ValueError:
|
|
138
|
+
return []
|
|
139
|
+
return agents if isinstance(agents, list) else []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_agent(agents, identifier):
|
|
143
|
+
"""The agent with this id, matching either the short id or the session id."""
|
|
144
|
+
for agent in agents:
|
|
145
|
+
if not isinstance(agent, dict):
|
|
146
|
+
continue
|
|
147
|
+
if identifier in (agent.get("id"), agent.get("sessionId")):
|
|
148
|
+
return agent
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def stop_background_agent(identifier, env=None, spawn_sync=None):
|
|
153
|
+
"""Release a parked session once its task is done.
|
|
154
|
+
|
|
155
|
+
`--bg` starts an interactive session that outlives the turn it was given,
|
|
156
|
+
so a one-shot run leaves it alive unless cdx stops it. Six were leaked
|
|
157
|
+
during development before this existed.
|
|
158
|
+
"""
|
|
159
|
+
if not identifier:
|
|
160
|
+
return False
|
|
161
|
+
runner = spawn_sync or _default_spawn_sync
|
|
162
|
+
return runner(["claude", "stop", str(identifier)], env=env) is not None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def turn_is_complete(outcome):
|
|
166
|
+
"""Whether the provider recorded an answer for the prompt cdx submitted.
|
|
167
|
+
|
|
168
|
+
This, not the agent's state, is what says a one-shot delegated task
|
|
169
|
+
finished. A `--bg` session parks at `state: blocked, status: idle` after
|
|
170
|
+
answering and stays there indefinitely, waiting for a next prompt that a
|
|
171
|
+
one-shot run will never send - indistinguishable, by state alone, from an
|
|
172
|
+
agent still thinking.
|
|
173
|
+
"""
|
|
174
|
+
return bool(outcome and outcome.get("result") is not None and outcome.get("usage"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def agent_terminal_status(agent):
|
|
178
|
+
"""The cdx run status a provider agent maps to, or None while it runs.
|
|
179
|
+
|
|
180
|
+
An agent the provider no longer lists has finished in a way cdx cannot
|
|
181
|
+
name, so it reports `failed` rather than `succeeded`: claiming success for
|
|
182
|
+
a run whose outcome was never observed is the one answer that must not be
|
|
183
|
+
guessed.
|
|
184
|
+
"""
|
|
185
|
+
if agent is None:
|
|
186
|
+
return "failed"
|
|
187
|
+
state = str(agent.get("state") or "").strip().lower()
|
|
188
|
+
if state not in _TERMINAL_STATES:
|
|
189
|
+
return None
|
|
190
|
+
return "succeeded" if state == _SUCCESS_STATE else "failed"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def find_session_transcript(auth_home, session_id):
|
|
194
|
+
"""The provider's own JSONL transcript for a background session, or None.
|
|
195
|
+
|
|
196
|
+
Claude Code writes one file per session under `.claude/projects/<cwd>/`,
|
|
197
|
+
named by session id. Located by walking rather than by reconstructing the
|
|
198
|
+
encoded cwd directory, because that encoding is the provider's and is not
|
|
199
|
+
part of any contract cdx should depend on.
|
|
200
|
+
"""
|
|
201
|
+
if not auth_home or not session_id:
|
|
202
|
+
return None
|
|
203
|
+
root = os.path.join(auth_home, ".claude", "projects")
|
|
204
|
+
if not os.path.isdir(root):
|
|
205
|
+
return None
|
|
206
|
+
target = f"{session_id}.jsonl"
|
|
207
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
208
|
+
if target in filenames:
|
|
209
|
+
return os.path.join(dirpath, target)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def read_transcript_outcome(path):
|
|
214
|
+
"""The final assistant text and summed token usage from a session transcript.
|
|
215
|
+
|
|
216
|
+
This is what makes a delegated run able to honour the `cdx run --json`
|
|
217
|
+
contract at all: `--bg` produces no structured stdout, but the provider
|
|
218
|
+
records the same facts here. Usage is summed across assistant messages
|
|
219
|
+
because a session can hold several turns.
|
|
220
|
+
"""
|
|
221
|
+
outcome = {"result": None, "usage": None}
|
|
222
|
+
if not path:
|
|
223
|
+
return outcome
|
|
224
|
+
totals = {"input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0, "total_tokens": 0}
|
|
225
|
+
seen = False
|
|
226
|
+
try:
|
|
227
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
228
|
+
for line in handle:
|
|
229
|
+
line = line.strip()
|
|
230
|
+
if not line.startswith("{"):
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
record = json.loads(line)
|
|
234
|
+
except ValueError:
|
|
235
|
+
continue
|
|
236
|
+
if record.get("type") != "assistant":
|
|
237
|
+
continue
|
|
238
|
+
message = record.get("message")
|
|
239
|
+
if not isinstance(message, dict):
|
|
240
|
+
continue
|
|
241
|
+
content = message.get("content")
|
|
242
|
+
if isinstance(content, list):
|
|
243
|
+
text = " ".join(
|
|
244
|
+
block.get("text", "") for block in content
|
|
245
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
246
|
+
).strip()
|
|
247
|
+
if text:
|
|
248
|
+
outcome["result"] = text
|
|
249
|
+
usage = message.get("usage")
|
|
250
|
+
if isinstance(usage, dict):
|
|
251
|
+
seen = True
|
|
252
|
+
inp = (usage.get("input_tokens") or 0) + (usage.get("cache_creation_input_tokens") or 0) \
|
|
253
|
+
+ (usage.get("cache_read_input_tokens") or 0)
|
|
254
|
+
out = usage.get("output_tokens") or 0
|
|
255
|
+
totals["input_tokens"] += inp
|
|
256
|
+
totals["output_tokens"] += out
|
|
257
|
+
totals["total_tokens"] += inp + out
|
|
258
|
+
except OSError:
|
|
259
|
+
return outcome
|
|
260
|
+
if seen:
|
|
261
|
+
totals["reasoning_tokens"] = None
|
|
262
|
+
outcome["usage"] = totals
|
|
263
|
+
return outcome
|