cookbook-bridge 0.1.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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Robot runner — the Bridge's embodied-agent socket (Coordination v2, sim-first).
3
+ *
4
+ * Unlike the LLM runners (which get a PROMPT and reason), a robot agent gets the
5
+ * task STRUCTURED — env vars, not prose — because its "brain" is a skill program
6
+ * (today a kinematic sim; later a LeRobot policy), not a language model. The
7
+ * contract mirrors every other runner: the Bridge pre-claims, the agent process
8
+ * does the work THROUGH Cookbook MCP tools (upload_file for the camera-proof
9
+ * receipt, remember for the writeback, complete_task to finish), and the Bridge
10
+ * verifies completion via getTask like any other run.
11
+ *
12
+ * Safety posture for embodied agents: this runner NEVER auto-approves anything;
13
+ * the standard delegation policy applies upstream, and for REAL hardware the
14
+ * owner's policy should be 'ask' — a physical action deserves a human yes.
15
+ * (v0 is a simulation; the posture is set now so hardware inherits it.)
16
+ *
17
+ * Config example:
18
+ * { "name": "Robo", "match": ["robo", "robot", "arm"], "runner": "robot",
19
+ * "command": ["python3", "robots/sim-arm/agent.py"], "token": "cbk_mcp_..." }
20
+ */
21
+ import { spawn } from "node:child_process";
22
+
23
+ export function runRobotTask(agent, ws, task, timeoutSeconds, token, cookbookUrl, baseEnv) {
24
+ return new Promise((resolve) => {
25
+ const [cmd, ...args] = agent.command;
26
+ const child = spawn(cmd, args, {
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ env: {
29
+ ...(baseEnv ?? process.env),
30
+ COOKBOOK_URL: cookbookUrl,
31
+ COOKBOOK_TOKEN: token,
32
+ WORKSPACE_ID: ws.id,
33
+ TASK_ID: task.id,
34
+ TASK_TITLE: task.title ?? "",
35
+ TASK_INSTRUCTIONS: task.instructions ?? "",
36
+ },
37
+ });
38
+ let out = "";
39
+ let err = "";
40
+ child.stdout.on("data", (d) => (out += d));
41
+ child.stderr.on("data", (d) => (err += d));
42
+ const killTimer = setTimeout(() => {
43
+ child.kill("SIGTERM");
44
+ setTimeout(() => child.kill("SIGKILL"), 5000);
45
+ }, timeoutSeconds * 1000);
46
+ child.on("error", (e) => {
47
+ clearTimeout(killTimer);
48
+ resolve({ code: -1, out, err: `could not launch robot agent \`${cmd}\`: ${e.message}` });
49
+ });
50
+ child.on("close", (code) => {
51
+ clearTimeout(killTimer);
52
+ resolve({ code, out, err });
53
+ });
54
+ });
55
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Persistent per-thread agent processes (the terminal-feel unlock, Diego 2026-08-20).
3
+ *
4
+ * A terminal feels instant because the process is already alive: you pay CLI boot +
5
+ * MCP handshake once per SESSION, not once per message. This module gives Composer
6
+ * threads the same physics: one `claude -p --input-format stream-json` process per
7
+ * active thread, held open; each follow-up message is written to stdin and the turn
8
+ * ends at the CLI's `result` line. Reply latency collapses to model time.
9
+ *
10
+ * Design walls:
11
+ * - claude-only (the one CLI with a streaming stdin protocol here); callers fall
12
+ * back to the one-shot spawn path for anything else, and on ANY runner error —
13
+ * the runner is an accelerator, never a dependency.
14
+ * - one in-flight send per runner (`busy`); concurrent sends are the caller's cue
15
+ * to use the one-shot path (claim serialization makes this rare).
16
+ * - idle runners are reaped (default 10 min) and every runner dies with the Bridge.
17
+ * - stream-parsing helpers are INJECTED (bridge.mjs dispatches on import, so this
18
+ * module must not import it back).
19
+ */
20
+ import { spawn } from "node:child_process";
21
+
22
+ const IDLE_MS = 10 * 60_000;
23
+ const runners = new Map(); // threadRootId -> Runner
24
+
25
+ /** Build the persistent variant of a one-shot claude command: same binary and
26
+ * allowlist flags, minus the inline prompt, plus the streaming stdin protocol.
27
+ * Returns null when the command isn't claude-shaped (caller falls back). */
28
+ export function persistentCommand(command, resumeSessionId) {
29
+ if (!Array.isArray(command) || command[0] !== "claude") return null;
30
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
31
+ if (resumeSessionId) args.push("--resume", resumeSessionId);
32
+ // Carry over safety-relevant flags from the configured command (allowlist etc.),
33
+ // dropping prompt/format flags this protocol replaces.
34
+ const skip = new Set(["-p", "--print", "{prompt}", "--output-format", "--input-format", "--verbose", "--include-partial-messages", "--resume"]);
35
+ for (let i = 1; i < command.length; i++) {
36
+ const a = command[i];
37
+ if (a === "--output-format" || a === "--input-format" || a === "--resume") { i++; continue; }
38
+ if (skip.has(a)) continue;
39
+ args.push(a);
40
+ }
41
+ return ["claude", ...args];
42
+ }
43
+
44
+ class Runner {
45
+ constructor({ threadId, agent, env, resumeSessionId, helpers, log }) {
46
+ this.threadId = threadId;
47
+ this.agent = agent;
48
+ this.helpers = helpers; // { fold, textFrom, sessionFrom }
49
+ this.log = log;
50
+ this.busy = false;
51
+ this.lastUsedAt = Date.now();
52
+ this.sessionId = resumeSessionId ?? null;
53
+ this.dead = false;
54
+ this.sends = 0; // 0 = still adoptable (a pre-warmed process with no history)
55
+ const command = persistentCommand(agent.command, resumeSessionId);
56
+ if (!command) throw new Error("not a claude-shaped command");
57
+ const [cmd, ...args] = command;
58
+ // Local-access agents carry a cwd (the workspace's mapped folder).
59
+ this.child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], env, ...(agent.cwd ? { cwd: agent.cwd } : {}) });
60
+ this.lineBuf = "";
61
+ this.err = "";
62
+ this.turn = null; // in-flight send state
63
+ this.child.stdout.on("data", (d) => this.#onData(String(d)));
64
+ this.child.stderr.on("data", (d) => { this.err = (this.err + String(d)).slice(-4000); });
65
+ this.child.on("close", (code) => {
66
+ this.dead = true;
67
+ const t = this.turn;
68
+ this.turn = null;
69
+ if (t) t.reject(Object.assign(new Error(`thread runner exited (${code}): ${this.err.slice(-300)}`), { sessionId: this.sessionId }));
70
+ });
71
+ this.child.on("error", (e) => {
72
+ this.dead = true;
73
+ const t = this.turn;
74
+ this.turn = null;
75
+ if (t) t.reject(new Error(`thread runner failed to launch: ${e.message}`));
76
+ });
77
+ }
78
+
79
+ #onData(chunk) {
80
+ this.lineBuf += chunk;
81
+ let nl;
82
+ while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
83
+ const line = this.lineBuf.slice(0, nl).trim();
84
+ this.lineBuf = this.lineBuf.slice(nl + 1);
85
+ if (!line || !this.turn) continue;
86
+ const t = this.turn;
87
+ t.lastActivityAt = Date.now();
88
+ if (!this.sessionId) this.sessionId = this.helpers.sessionFrom(line);
89
+ const spoke = this.helpers.textFrom(line);
90
+ if (spoke) {
91
+ if (spoke.kind === "delta") t.partialText += spoke.text;
92
+ else { t.turnsText += (t.turnsText && spoke.text ? "\n\n" : "") + spoke.text; t.partialText = ""; }
93
+ }
94
+ const r = this.helpers.fold(line, t.acc);
95
+ t.acc = r.acc;
96
+ if (r.resultLine) {
97
+ clearInterval(t.watchdog);
98
+ this.turn = null;
99
+ this.busy = false;
100
+ this.lastUsedAt = Date.now();
101
+ t.resolve({ code: 0, out: r.resultLine, err: "", sessionId: this.sessionId });
102
+ } else {
103
+ t.emit();
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Send one user message; resolves with a one-shot-shaped result envelope. */
109
+ send(text, { onProgress, timeoutMs, livenessMs }) {
110
+ if (this.dead) return Promise.reject(new Error("thread runner is dead"));
111
+ if (this.busy) return Promise.reject(new Error("thread runner busy"));
112
+ this.busy = true;
113
+ this.sends++;
114
+ this.lastUsedAt = Date.now();
115
+ return new Promise((resolve, reject) => {
116
+ const startedAt = Date.now();
117
+ const t = {
118
+ resolve, reject,
119
+ acc: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, num_turns: 0 },
120
+ turnsText: "", partialText: "",
121
+ lastEmit: 0, lastActivityAt: startedAt,
122
+ emit: () => {
123
+ if (!onProgress || Date.now() - t.lastEmit < 1200) return;
124
+ t.lastEmit = Date.now();
125
+ const full = t.partialText ? `${t.turnsText}${t.turnsText ? "\n\n" : ""}${t.partialText}` : t.turnsText;
126
+ const live_text = full.length > 1800 ? "…" + full.slice(-1800) : full;
127
+ if (t.acc.input_tokens === 0 && t.acc.output_tokens === 0 && !live_text) return;
128
+ try {
129
+ onProgress({ ...t.acc, runner: this.agent.name, ...(live_text ? { live_text } : {}), ...(this.sessionId ? { session_ref: this.sessionId } : {}) });
130
+ } catch { /* best-effort */ }
131
+ },
132
+ watchdog: setInterval(() => {
133
+ const now = Date.now();
134
+ const stalled = livenessMs > 0 && now - t.lastActivityAt >= livenessMs;
135
+ const over = now - startedAt >= timeoutMs;
136
+ if (!stalled && !over) return;
137
+ clearInterval(t.watchdog);
138
+ this.kill();
139
+ const e = new Error(stalled ? `thread runner silent for ${Math.round(livenessMs / 1000)}s` : `thread runner hit the ${Math.round(timeoutMs / 1000)}s ceiling`);
140
+ e.partialUsage = t.acc.input_tokens || t.acc.output_tokens ? { ...t.acc } : null;
141
+ e.elapsedMs = now - startedAt;
142
+ e.sessionId = this.sessionId;
143
+ this.turn = null;
144
+ reject(e);
145
+ }, 5000),
146
+ };
147
+ this.turn = t;
148
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
149
+ this.child.stdin.write(msg + "\n", (err) => {
150
+ if (err) { clearInterval(t.watchdog); this.turn = null; this.busy = false; reject(new Error(`stdin write failed: ${err.message}`)); }
151
+ });
152
+ });
153
+ }
154
+
155
+ kill() {
156
+ this.dead = true;
157
+ try { this.child.kill("SIGTERM"); } catch { /* already gone */ }
158
+ setTimeout(() => { try { this.child.kill("SIGKILL"); } catch { /* gone */ } }, 5000);
159
+ }
160
+ }
161
+
162
+ /** The live runner for a thread, or null — never creates. */
163
+ export function hasRunner(threadId) {
164
+ const r = runners.get(threadId);
165
+ return r && !r.dead ? r : null;
166
+ }
167
+
168
+ /** PRE-WARM (0065): boot an idle runner under a pool key before any task exists.
169
+ * No-op if one is already there or the pool is full. */
170
+ export function warmUp({ poolKey, agent, env, helpers, log, cap = 4 }) {
171
+ if (hasRunner(poolKey)) return;
172
+ if (runners.size >= cap) return;
173
+ try {
174
+ const r = new Runner({ threadId: poolKey, agent, env, resumeSessionId: null, helpers, log });
175
+ runners.set(poolKey, r);
176
+ log(` ↳ pre-warming ${agent.name} (${poolKey.slice(0, 24)}…)`);
177
+ } catch { /* not claude-shaped — nothing to warm */ }
178
+ }
179
+
180
+ /** Adopt a pre-warmed, never-used runner into a real thread key. Returns the runner
181
+ * or null (dead, busy, or already carrying a conversation — adoption would leak
182
+ * one thread's context into another). */
183
+ export function adoptRunner(fromKey, toKey) {
184
+ const r = runners.get(fromKey);
185
+ if (!r || r.dead || r.busy || r.sends > 0) return null;
186
+ runners.delete(fromKey);
187
+ runners.set(toKey, r);
188
+ r.threadId = toKey;
189
+ return r;
190
+ }
191
+
192
+ /** Get the live runner for a thread, or create one (resuming a prior session when
193
+ * given). Throws when the agent isn't claude-shaped; callers fall back. */
194
+ export function runnerFor({ threadId, agent, env, resumeSessionId, helpers, log }) {
195
+ const existing = runners.get(threadId);
196
+ if (existing && !existing.dead) return existing;
197
+ if (existing) runners.delete(threadId);
198
+ const r = new Runner({ threadId, agent, env, resumeSessionId, helpers, log });
199
+ runners.set(threadId, r);
200
+ log(` ↳ thread runner started for ${threadId.slice(0, 8)}${resumeSessionId ? " (resuming session)" : ""}`);
201
+ return r;
202
+ }
203
+
204
+ export function reapIdleRunners(log, idleMs = IDLE_MS) {
205
+ const now = Date.now();
206
+ for (const [id, r] of runners) {
207
+ if (r.dead || (now - r.lastUsedAt > idleMs && !r.busy)) {
208
+ if (!r.dead) { r.kill(); log(` ↳ thread runner for ${id.slice(0, 8)} reaped (idle)`); }
209
+ runners.delete(id);
210
+ }
211
+ }
212
+ }
213
+
214
+ export function killAllRunners() {
215
+ for (const [, r] of runners) r.kill();
216
+ runners.clear();
217
+ }
218
+
219
+ export function runnerStats() {
220
+ return { count: runners.size, busy: [...runners.values()].filter((r) => r.busy).length };
221
+ }
package/update.mjs ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Bridge self-updater — "when the app updates, so does the Bridge."
3
+ *
4
+ * The app deploy is the version source of truth: /api/bridge/manifest publishes a sha256
5
+ * per runtime file (computed from what the deploy is actually serving) + a combined
6
+ * version hash. This module hashes the LOCAL files, compares, and applies updates by
7
+ * downloading the same tar every user installs from — verifying every extracted file
8
+ * against the manifest before a single byte on disk changes. The user's config.json
9
+ * (and its token) is NEVER touched.
10
+ *
11
+ * Trust posture (see /security): same-origin as the install itself, hash-verified,
12
+ * old files kept in bridge.backup/, plain readable JS, and `"autoUpdate": false` pins
13
+ * the version entirely. Auto-updates re-exec the running Bridge so fleets track deploys
14
+ * within one check interval.
15
+ *
16
+ * Pure helpers (hashing, comparison, tar parsing) are exported for
17
+ * scripts/test-bridge-update.ts; I/O lives in checkForUpdate/applyUpdate.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { createHash } from "node:crypto";
22
+ import { gunzipSync } from "node:zlib";
23
+
24
+ /** Files the updater manages — must mirror the server's BRIDGE_RUNTIME_FILES. The list
25
+ * itself comes from the MANIFEST at update time (server-driven), so a future deploy can
26
+ * add files without this constant; this is only the local-hash candidate set. */
27
+ export const LOCAL_FILES_GLOB = /\.(mjs|md)$/i;
28
+
29
+ export function sha256(buf) {
30
+ return createHash("sha256").update(buf).digest("hex");
31
+ }
32
+
33
+ /** Hash the local runtime files that exist (missing files = trivially outdated). */
34
+ export function localManifest(dir, names) {
35
+ const files = {};
36
+ for (const name of names) {
37
+ const p = path.join(dir, name);
38
+ if (fs.existsSync(p)) files[name] = sha256(fs.readFileSync(p));
39
+ }
40
+ return files;
41
+ }
42
+
43
+ /** Compare local hashes to the remote manifest → the files that need replacing. */
44
+ export function diffManifest(remoteFiles, localFiles) {
45
+ return Object.keys(remoteFiles).filter((name) => localFiles[name] !== remoteFiles[name]);
46
+ }
47
+
48
+ /** Minimal ustar reader (mirror of the server's zero-dep writer): {name → Buffer}. */
49
+ export function untar(buf) {
50
+ const out = {};
51
+ let off = 0;
52
+ while (off + 512 <= buf.length) {
53
+ const header = buf.subarray(off, off + 512);
54
+ if (header.every((b) => b === 0)) break; // terminator blocks
55
+ const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
56
+ const size = parseInt(header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim(), 8) || 0;
57
+ const body = buf.subarray(off + 512, off + 512 + size);
58
+ if (name) out[name] = Buffer.from(body);
59
+ off += 512 + Math.ceil(size / 512) * 512;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Check the deploy's manifest against local files. Returns
66
+ * { version, changed: [names] } — changed.length === 0 means up to date.
67
+ * Throws on network/shape errors (callers treat check failures as non-fatal).
68
+ */
69
+ export async function checkForUpdate(cfg, dir) {
70
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/manifest`, { headers: { "Cache-Control": "no-store" } });
71
+ if (!res.ok) throw new Error(`manifest HTTP ${res.status}`);
72
+ const manifest = await res.json();
73
+ if (!manifest || typeof manifest.files !== "object") throw new Error("malformed manifest");
74
+ const local = localManifest(dir, Object.keys(manifest.files));
75
+ return { version: manifest.version, files: manifest.files, changed: diffManifest(manifest.files, local) };
76
+ }
77
+
78
+ /** A filesystem-safe backup subdir name for a manifest version (falls back to a
79
+ * monotonic-ish label when no version is given). Exported for the test. */
80
+ export function backupDirName(version) {
81
+ const v = String(version || "").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 40);
82
+ return v || "prev";
83
+ }
84
+
85
+ /**
86
+ * Apply an update: download the tar, VERIFY every runtime file against the manifest
87
+ * hashes (any mismatch aborts before any write), back up current files to a
88
+ * PER-VERSION bridge.backup/<version>/ dir, then replace ATOMICALLY (write each to a
89
+ * .tmp and rename; roll back from the backup if any rename fails mid-way). Never writes
90
+ * config.json. Returns the replaced names.
91
+ *
92
+ * Two audit-2026-07-03 fixes vs the old flat overwrite: (#14a) a bad-but-hash-valid
93
+ * update N+1 can no longer clobber update N's last-good backup — each version keeps its
94
+ * own; (#14b) a mid-loop write failure (disk full, EACCES) no longer leaves a mixed
95
+ * old/new install that crashes lazy module loading on next start — it's rename-based and
96
+ * rolls back.
97
+ */
98
+ export async function applyUpdate(cfg, dir, manifest) {
99
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/download`);
100
+ if (!res.ok) throw new Error(`download HTTP ${res.status}`);
101
+ const tar = untar(gunzipSync(Buffer.from(await res.arrayBuffer())));
102
+
103
+ // Verify EVERYTHING the manifest names, before touching disk.
104
+ const verified = {};
105
+ for (const [name, wantHash] of Object.entries(manifest.files)) {
106
+ const data = tar[`bridge/${name}`];
107
+ if (!data) throw new Error(`update aborted: ${name} missing from archive`);
108
+ const got = sha256(data);
109
+ if (got !== wantHash) throw new Error(`update aborted: ${name} hash mismatch (archive ${got.slice(0, 12)}… ≠ manifest ${String(wantHash).slice(0, 12)}…)`);
110
+ verified[name] = data;
111
+ }
112
+
113
+ // Per-version backup dir — never overwrites a prior version's last-good copies.
114
+ const backupDir = path.join(dir, "bridge.backup", backupDirName(manifest.version));
115
+ fs.mkdirSync(backupDir, { recursive: true });
116
+
117
+ // 1. Write every new file to a sibling .tmp and back up the current one. No live
118
+ // file is replaced yet, so a failure here leaves the install fully intact.
119
+ const staged = []; // { name, target, tmp, backup|null }
120
+ try {
121
+ for (const [name, data] of Object.entries(verified)) {
122
+ const target = path.join(dir, name);
123
+ const tmp = target + ".tmp";
124
+ let backup = null;
125
+ if (fs.existsSync(target)) {
126
+ backup = path.join(backupDir, name);
127
+ fs.copyFileSync(target, backup);
128
+ }
129
+ fs.writeFileSync(tmp, data, { mode: name.endsWith(".mjs") ? 0o755 : 0o644 });
130
+ staged.push({ name, target, tmp, backup });
131
+ }
132
+ } catch (e) {
133
+ for (const s of staged) { try { fs.rmSync(s.tmp, { force: true }); } catch { /* ignore */ } }
134
+ throw new Error(`update aborted before any live file changed: ${e.message}`);
135
+ }
136
+
137
+ // 2. Rename each staged .tmp over its target. If one fails, roll the already-renamed
138
+ // ones back from their backups so we never leave a half-old/half-new install.
139
+ const done = [];
140
+ try {
141
+ for (const s of staged) {
142
+ fs.renameSync(s.tmp, s.target);
143
+ done.push(s);
144
+ }
145
+ } catch (e) {
146
+ for (const s of done) { if (s.backup) { try { fs.copyFileSync(s.backup, s.target); } catch { /* best-effort */ } } }
147
+ for (const s of staged) { try { fs.rmSync(s.tmp, { force: true }); } catch { /* ignore */ } }
148
+ throw new Error(`update rolled back (rename failed on ${e.message}) — still on the previous version`);
149
+ }
150
+ return done.map((s) => s.name);
151
+ }
package/usage.mjs ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Run-cost extraction — "what did this task cost the runner's quota?"
3
+ *
4
+ * Side-effect-free on purpose (bridge.mjs dispatches on import; testable helpers live
5
+ * in modules like this — see scripts/test-bridge-usage.ts). After a verified-done run,
6
+ * the Bridge normalizes whatever the CLI reported and posts it to Cookbook via
7
+ * report_task_usage, so the ASSIGNER sees the cost of the delegation and the RUNNER
8
+ * sees what their quota paid for. Everything here is best-effort: a CLI that reports
9
+ * nothing simply yields duration-only usage — never an error.
10
+ *
11
+ * Known shapes handled:
12
+ * - Claude Code `claude -p --output-format json` (verified against a real run):
13
+ * { result, usage: { input_tokens, output_tokens, cache_read_input_tokens,
14
+ * cache_creation_input_tokens, ... }, total_cost_usd, duration_ms, num_turns,
15
+ * modelUsage: { "<model>": {...} } }
16
+ * - Codex app-server token events (captured by codex-runner.mjs):
17
+ * { input_tokens | inputTokens, cached_input_tokens | cachedInputTokens,
18
+ * output_tokens | outputTokens, ... } — camel/snake both seen in the wild.
19
+ * - Gemini CLI JSON output (best-effort): { stats: { models: { "<model>":
20
+ * { tokens: { prompt, candidates, cached, total } } } } } or a flat usage object.
21
+ */
22
+
23
+ const num = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : undefined);
24
+
25
+ /** Drop undefined fields; return null when nothing of substance remains. */
26
+ function compact(u) {
27
+ const out = {};
28
+ for (const [k, v] of Object.entries(u)) if (v !== undefined && v !== null && v !== "") out[k] = v;
29
+ const hasSubstance =
30
+ out.input_tokens !== undefined || out.output_tokens !== undefined ||
31
+ out.cost_usd !== undefined || out.duration_ms !== undefined;
32
+ return hasSubstance ? out : null;
33
+ }
34
+
35
+ /** Claude Code / generic flat shape: { usage: {...}, total_cost_usd, duration_ms, ... } */
36
+ function fromClaudeJson(j) {
37
+ const u = j.usage;
38
+ if (!u || typeof u !== "object") return null;
39
+ return compact({
40
+ input_tokens: num(u.input_tokens),
41
+ output_tokens: num(u.output_tokens),
42
+ cache_read_input_tokens: num(u.cache_read_input_tokens),
43
+ cache_creation_input_tokens: num(u.cache_creation_input_tokens),
44
+ cost_usd: num(j.total_cost_usd),
45
+ duration_ms: num(j.duration_ms),
46
+ num_turns: num(j.num_turns),
47
+ model: j.modelUsage && typeof j.modelUsage === "object" ? Object.keys(j.modelUsage)[0] : undefined,
48
+ });
49
+ }
50
+
51
+ /** Gemini CLI stats shape: { stats: { models: { "<model>": { tokens: {...} } } } } */
52
+ function fromGeminiStats(j) {
53
+ const models = j?.stats?.models;
54
+ if (!models || typeof models !== "object") return null;
55
+ let input = 0, output = 0, cached = 0, any = false;
56
+ for (const m of Object.values(models)) {
57
+ const t = m?.tokens;
58
+ if (!t) continue;
59
+ any = true;
60
+ input += Number(t.prompt) || 0;
61
+ output += Number(t.candidates) || 0;
62
+ cached += Number(t.cached) || 0;
63
+ }
64
+ if (!any) return null;
65
+ return compact({
66
+ input_tokens: input || undefined,
67
+ output_tokens: output || undefined,
68
+ cache_read_input_tokens: cached || undefined,
69
+ model: Object.keys(models)[0],
70
+ });
71
+ }
72
+
73
+ /** Codex app-server token_usage (camel or snake): captured by codex-runner. */
74
+ export function fromCodexUsage(u) {
75
+ if (!u || typeof u !== "object") return null;
76
+ // Some payloads nest { total_token_usage: {...} } / { last_token_usage: {...} }.
77
+ const t = u.total_token_usage ?? u.totalTokenUsage ?? u;
78
+ return compact({
79
+ input_tokens: num(t.input_tokens ?? t.inputTokens),
80
+ output_tokens: num(t.output_tokens ?? t.outputTokens),
81
+ cache_read_input_tokens: num(t.cached_input_tokens ?? t.cachedInputTokens),
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Extract normalized usage from a finished run.
87
+ * - result: { out, err?, code? } for one-shot CLIs, or { out, usage? } from codex-runner.
88
+ * - wallMs: the Bridge's own wall-clock measurement — the duration fallback, so every
89
+ * report has at least duration even when the CLI says nothing.
90
+ * Returns the report_task_usage argument object, or null if there's nothing to say.
91
+ */
92
+ export function extractUsage(result, agentName, wallMs) {
93
+ let usage = null;
94
+
95
+ // Persistent-runner path: codex-runner captured token events directly.
96
+ if (result && result.usage) usage = fromCodexUsage(result.usage);
97
+
98
+ // One-shot path: stdout may be a JSON document (claude/gemini json output modes).
99
+ if (!usage && result && typeof result.out === "string") {
100
+ const text = result.out.trim();
101
+ // Parse from the first "{"/"[" — CLIs print warnings BEFORE the envelope (the
102
+ // same prefix noise that broke displayText, audit 2026-07-03 #8: fixing one
103
+ // sibling and not the other silently dropped token receipts).
104
+ const start = Math.min(...["{", "["].map((c) => {
105
+ const i = text.indexOf(c);
106
+ return i < 0 ? Infinity : i;
107
+ }));
108
+ if (start !== Infinity) {
109
+ try {
110
+ const j = JSON.parse(text.slice(start));
111
+ usage = fromClaudeJson(j) ?? fromGeminiStats(j);
112
+ } catch {
113
+ /* not JSON — no usage in text mode */
114
+ }
115
+ }
116
+ }
117
+
118
+ const merged = compact({
119
+ ...(usage ?? {}),
120
+ duration_ms: usage?.duration_ms ?? num(wallMs),
121
+ runner: agentName,
122
+ });
123
+ return merged;
124
+ }
125
+
126
+ /**
127
+ * The agent's human-readable answer, for logs/hints: with `--output-format json` the
128
+ * text lives in `.result` (claude) / `.response` (gemini); in text mode it IS stdout.
129
+ */
130
+ export function displayText(out) {
131
+ const text = String(out ?? "").trim();
132
+ // CLIs can print warnings BEFORE the JSON envelope (e.g. claude's workspace-trust
133
+ // notice), so parse from the first "{" rather than requiring the text to start
134
+ // with it. Verified live 2026-07-03: the prefix noise made a volunteer decision
135
+ // read as PASS.
136
+ const brace = text.indexOf("{");
137
+ if (brace >= 0) {
138
+ try {
139
+ const j = JSON.parse(text.slice(brace));
140
+ if (typeof j.result === "string") return j.result;
141
+ if (typeof j.response === "string") return j.response;
142
+ } catch {
143
+ /* fall through */
144
+ }
145
+ }
146
+ return text;
147
+ }