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/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 codex_auth_diagnostic
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
- priority = _recommend_priority_sessions([
107
- row for row in active_rows
108
- if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
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