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.
package/bridge.mjs ADDED
@@ -0,0 +1,1914 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cookbook Bridge — wakes your local subscription agents to do their Cookbook
4
+ * tasks, so they run themselves instead of you shuttling between chat windows.
5
+ *
6
+ * Claude assigns Gemini a task → it lands on the Cookbook board (open)
7
+ * The Bridge (here) polls, sees it, and wakes Gemini headlessly
8
+ * Gemini — already MCP-connected to Cookbook — does the work and calls
9
+ * complete_task itself, on YOUR subscription (no API credits)
10
+ *
11
+ * The Bridge is just a *waker*: it never does the work, it triggers the right
12
+ * agent and verifies the task got done. Local, free, distributed (each user
13
+ * runs their own Bridge for their own agents — this is the seed of the shippable
14
+ * "Cookbook Bridge" client).
15
+ *
16
+ * Run: node bridge/bridge.mjs (uses bridge/config.json)
17
+ * node bridge/bridge.mjs ./my.json (explicit config path)
18
+ * node bridge/bridge.mjs login (one-click connect, RFC 8628)
19
+ * node bridge/bridge.mjs status (liveness + agent readiness)
20
+ * node bridge/bridge.mjs doctor (preflight: check every prerequisite,
21
+ * print the exact fix for each ✗)
22
+ *
23
+ * "login", "status", and "doctor" are reserved first-args; pass a config path to
24
+ * those subcommands with --config <path>. Plain `node bridge.mjs [path]` is unchanged.
25
+ *
26
+ * Node built-ins only. No dependencies.
27
+ */
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { spawn } from "node:child_process";
32
+
33
+ // LOCAL modules load LAZILY (loadRuntime below), not statically: a Bridge with a
34
+ // missing/corrupt module file must still be able to run `node bridge.mjs update` and
35
+ // repair itself — the update path depends ONLY on update.mjs (node built-ins only).
36
+ // The e2e that forced this: a stale install missing volunteer.mjs couldn't even reach
37
+ // the updater when these were static imports.
38
+ let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings;
39
+ let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION;
40
+ let extractUsage, displayText;
41
+ let volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities;
42
+ let buildPrompt, buildThreadFollowUpPrompt;
43
+ let runnerFor, hasRunner, warmUp, adoptRunner, reapIdleRunners, killAllRunners;
44
+ let hasCodexThread, reapCodexServer, killCodexServer;
45
+ let checkForUpdate, applyUpdate;
46
+ let createLocalServer, toolsForMode, modeForTools, vendorOf;
47
+ let connectAgentsProgrammatic, detectClis;
48
+
49
+ async function loadRuntime() {
50
+ ({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
51
+ ({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
52
+ ({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings } = await import("./cookbook.mjs"));
53
+ ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION } = await import("./harden.mjs"));
54
+ ({ extractUsage, displayText } = await import("./usage.mjs"));
55
+ ({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
56
+ ({ buildPrompt, buildThreadFollowUpPrompt } = await import("./prompt.mjs"));
57
+ ({ runnerFor, hasRunner, warmUp, adoptRunner, reapIdleRunners, killAllRunners } = await import("./thread-runner.mjs"));
58
+ ({ hasCodexThread, reapCodexServer, killCodexServer } = await import("./codex-runner.mjs"));
59
+ ({ checkForUpdate, applyUpdate } = await import("./update.mjs"));
60
+ }
61
+
62
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
63
+
64
+ function log(msg) {
65
+ const ts = new Date().toISOString().slice(11, 19);
66
+ console.log(`[${ts}] ${msg}`);
67
+ }
68
+
69
+ /**
70
+ * A GUI app (the desktop sidecar) inherits a minimal PATH that misses Homebrew
71
+ * (`/opt/homebrew/bin`), `/usr/local/bin`, nvm, and Claude's local install — so a
72
+ * bare agent command like `claude`/`gemini` fails with ENOENT and the task silently
73
+ * never runs (Codex works only because its config uses an absolute path). Prepend the
74
+ * known install dirs to PATH so spawned agents resolve their binaries regardless of
75
+ * how the Bridge was launched. This is the agent-command analog of the Rust
76
+ * find_node() trap fix (bridge.rs), applied to the binaries the Bridge itself spawns.
77
+ */
78
+ function ensureAgentPath() {
79
+ const home = process.env.HOME || "";
80
+ const dirs = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
81
+ if (home) {
82
+ dirs.push(path.join(home, ".claude/local")); // claude CLI local install
83
+ dirs.push(path.join(home, ".bun/bin"), path.join(home, ".local/bin"));
84
+ try {
85
+ const base = path.join(home, ".nvm/versions/node");
86
+ const vers = fs.readdirSync(base).sort();
87
+ if (vers.length) dirs.push(path.join(base, vers[vers.length - 1], "bin"));
88
+ } catch {
89
+ /* no nvm — fine */
90
+ }
91
+ }
92
+ const cur = process.env.PATH || "";
93
+ const have = new Set(cur.split(path.delimiter).filter(Boolean));
94
+ const add = dirs.filter((d) => d && !have.has(d));
95
+ if (add.length) process.env.PATH = [...add, cur].filter(Boolean).join(path.delimiter);
96
+ }
97
+
98
+ /** Resolve a command to an executable path: an absolute/relative path is checked
99
+ * directly; a bare name is searched on PATH (after ensureAgentPath). Returns null if
100
+ * not found — used by `doctor` and the default-agent startup warning. */
101
+ function resolveBin(cmd) {
102
+ if (!cmd) return null;
103
+ if (cmd.includes("/")) {
104
+ try { fs.accessSync(cmd, fs.constants.X_OK); return cmd; } catch { return null; }
105
+ }
106
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
107
+ if (!dir) continue;
108
+ const p = path.join(dir, cmd);
109
+ try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* keep looking */ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /** Pick a config path from a subcommand's args: `--config <path>`, else first
115
+ * positional, else the default config.json next to this file. */
116
+ function configPathFromArgs(args, fallback) {
117
+ const i = args.indexOf("--config");
118
+ if (i >= 0 && args[i + 1]) return path.resolve(args[i + 1]);
119
+ const positional = (args || []).find((a) => a && !a.startsWith("-"));
120
+ return positional ? path.resolve(positional) : fallback;
121
+ }
122
+
123
+ /** Where the running Bridge's config lives (Bridge Local writes local.json next to it). */
124
+ let CONFIG_PATH = null;
125
+
126
+ function loadConfig() {
127
+ const p = process.argv[2] ? path.resolve(process.argv[2]) : path.join(HERE, "config.json");
128
+ CONFIG_PATH = p;
129
+ if (!fs.existsSync(p)) {
130
+ console.error(`No config at ${p}.\nEasiest: run \`node bridge/bridge.mjs login\` — one-click connect, no token to paste.\n(Manual alternative: copy bridge/config.example.json → bridge/config.json and add a token from Account → Tokens.)`);
131
+ process.exit(1);
132
+ }
133
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
134
+ cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
135
+ if (!cfg.cookbookUrl || !cfg.token || cfg.token.startsWith("PASTE")) {
136
+ console.error("Config needs `cookbookUrl` and a real `token` (generate one on your Cookbook account's Tokens page).");
137
+ process.exit(1);
138
+ }
139
+ cfg.pollSeconds = cfg.pollSeconds ?? 15;
140
+ // Persistent per-thread agent processes (terminal-feel replies). Opt-in while it
141
+ // proves itself; the one-shot spawn path remains the fallback either way.
142
+ cfg.persistentThreads = cfg.persistentThreads ?? false;
143
+ // LOCAL WORKSPACE ACCESS (2026-08-21): map a workspace to a folder on THIS machine.
144
+ // localWorkspaces: { "<workspaceId>": { "cwd": "/abs/path", "allowedTools": "…" } }
145
+ // SELF-ASSIGNED tasks in a mapped workspace run IN that folder with real tools
146
+ // (files/shell) — the same trust as the member running the CLI in their own
147
+ // terminal, because they asked. Teammate-assigned tasks NEVER get local access;
148
+ // they stay jailed to workspace tools. This is the terminal-parity wall.
149
+ cfg.localWorkspaces = cfg.localWorkspaces ?? {};
150
+ cfg.maxAttempts = cfg.maxAttempts ?? 2;
151
+ // Phase 1 semantics: taskTimeoutSeconds is the ABSOLUTE CEILING (cost backstop),
152
+ // livenessTimeoutSeconds is the stall detector (no output for this long = dead).
153
+ // History: 300 killed MCP-heavy runs (2026-07-03); 900 killed healthy founding
154
+ // runs (2026-07-13) — no wall-clock fits both, so silence decides, not duration.
155
+ cfg.taskTimeoutSeconds = cfg.taskTimeoutSeconds ?? 3600;
156
+ cfg.livenessTimeoutSeconds = cfg.livenessTimeoutSeconds ?? 300;
157
+ // Parallel slots: how many task runs may be in flight at once (audit: serial
158
+ // execution let one long run block every workspace's queue).
159
+ cfg.maxConcurrentRuns = Math.max(1, cfg.maxConcurrentRuns ?? 2);
160
+ cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
161
+ ensureAgentPath(); // so bare `claude`/`gemini` commands resolve under the app's minimal PATH
162
+ loadRunState(); // restore attempts/given-up so a restart can't grant doomed tasks fresh attempts
163
+ return cfg;
164
+ }
165
+
166
+ /** Which managed agent (if any) handles a task's `assigned_to`. */
167
+ /** Tools a LOCAL run gets: the member's own terminal toolkit + Cookbook. */
168
+ export const DEFAULT_LOCAL_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,mcp__cookbook__*";
169
+
170
+ /** Rewrite a claude-shaped command's --allowedTools for a local-access run. Pure. */
171
+ export function localizeCommand(command, allowedTools) {
172
+ if (!Array.isArray(command)) return command;
173
+ const i = command.indexOf("--allowedTools");
174
+ if (i < 0 || i + 1 >= command.length) return command;
175
+ const out = [...command];
176
+ out[i + 1] = allowedTools || DEFAULT_LOCAL_TOOLS;
177
+ return out;
178
+ }
179
+
180
+ function agentFor(cfg, assignedTo) {
181
+ const a = (assignedTo || "").toLowerCase();
182
+ if (a === "any") {
183
+ return cfg.agents.find((x) => x.name === cfg.default) ?? cfg.agents[0] ?? null;
184
+ }
185
+ // Substring match so "Gemini" matches a connection labelled "Gemini CLI MCP Client".
186
+ return cfg.agents.find((x) => (x.match ?? [x.name]).some((m) => a.includes(String(m).toLowerCase()))) ?? null;
187
+ }
188
+
189
+ /**
190
+ * Approval policy: should this agent auto-run a task, given WHO assigned it?
191
+ * - "anyone" (default): run every task assigned to this agent.
192
+ * - ["dp","erichaneyatx", …]: only run tasks assigned by these people (matched
193
+ * on the assigning member, so it's precise even if they used an agent).
194
+ * Anything not allowed is skipped and left open for you.
195
+ */
196
+ export function allowedByPolicy(cfg, agent, task) {
197
+ let policy = agent.acceptFrom ?? cfg.acceptFrom ?? "anyone";
198
+ if (typeof policy === "string" && policy !== "anyone") policy = [policy];
199
+ if (policy === "anyone" || !Array.isArray(policy)) return true;
200
+ // EXACT match only (Phase 0, audit #9): substring matching made consent fuzzy —
201
+ // acceptFrom:["Ana"] silently accepted tasks from "Ariana"; ["dp"] matched
202
+ // "dprozzi". A CONSENT decision must never guess. Entries match the assigning
203
+ // member's exact name (case-insensitive) or their profile id.
204
+ const whoName = String(task.assigned_by_member || task.assigned_by || "").trim().toLowerCase();
205
+ const whoId = String(task.assigned_by_profile || "").toLowerCase();
206
+ return policy.some((n) => {
207
+ const entry = String(n).trim().toLowerCase();
208
+ return entry !== "" && (entry === whoName || entry === whoId);
209
+ });
210
+ }
211
+
212
+
213
+ /** Spawn the agent's headless CLI with the prompt substituted into its argv.
214
+ * `env` (from agentEnv) strips vendor API-billing keys unless the user opted in —
215
+ * a task must never silently bill an API account instead of the owner's subscription. */
216
+ /**
217
+ * Detect a one-shot claude agent emitting `--output-format json` and rewrite it to
218
+ * `stream-json` (+ `--verbose`, which claude requires with stream-json). This gives
219
+ * the LIVE token ticker for free — no config change — while the final `result` line
220
+ * stream-json emits last is byte-identical to what json mode returns, so
221
+ * extractUsage/failureHint/displayText keep working unchanged. Returns the (possibly
222
+ * rewritten) command array + whether streaming is active. Anything else (gemini,
223
+ * text mode, already-stream-json, no json flag) is returned untouched.
224
+ */
225
+ export function streamingCommand(command) {
226
+ if (!Array.isArray(command)) return { command, streaming: false };
227
+ const i = command.indexOf("--output-format");
228
+ if (i < 0 || command[i + 1] !== "json") return { command, streaming: command.includes("stream-json") };
229
+ const rewritten = [...command];
230
+ rewritten[i + 1] = "stream-json";
231
+ if (!rewritten.includes("--verbose")) rewritten.push("--verbose");
232
+ // Live words stream PER COMPLETED TURN (assistant events), deliberately NOT
233
+ // --include-partial-messages: that flag stores partial-generation artifacts in the
234
+ // session file, and RESUMING such a session trips the API's reasoning-extraction
235
+ // safeguard (observed live 2026-08-20: resumed thread runs refused with
236
+ // `[reasoning_extraction]`). Resume is the flagship; per-turn streaming is plenty.
237
+ return { command: rewritten, streaming: true };
238
+ }
239
+
240
+ /** Pull streamed assistant TEXT out of one stream-json line. Returns
241
+ * {kind:'delta',text} for a partial chunk (--include-partial-messages),
242
+ * {kind:'turn',text} for a completed assistant turn (turn text REPLACES the
243
+ * partial accumulation for that turn — never append both), or null when the
244
+ * line carries no prose (tool calls, usage ticks, init). */
245
+ export function textFromStreamLine(line) {
246
+ let j;
247
+ try { j = JSON.parse(line); } catch { return null; }
248
+ if (j.type === "stream_event") {
249
+ const d = j.event?.delta;
250
+ return d && d.type === "text_delta" && typeof d.text === "string" ? { kind: "delta", text: d.text } : null;
251
+ }
252
+ if (j.type === "assistant" && Array.isArray(j.message?.content)) {
253
+ const text = j.message.content
254
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
255
+ .map((b) => b.text)
256
+ .join("");
257
+ return { kind: "turn", text };
258
+ }
259
+ return null;
260
+ }
261
+
262
+ /** Cap for the live-text tail that rides progress reports (server caps at 2000). */
263
+ export const LIVE_TEXT_CAP = 1800;
264
+
265
+ /** Fold one stream-json line into a running progress total (monotonic-ish: output
266
+ * tokens sum across turns; input/cache take the largest seen). Returns the updated
267
+ * accumulator, or null when the line carries no usage. Also surfaces the final
268
+ * `result` line so the caller can hand extractUsage a single clean envelope. */
269
+ export function foldStreamLine(line, acc) {
270
+ let j;
271
+ try { j = JSON.parse(line); } catch { return { acc, resultLine: null }; }
272
+ if (j.type === "result") return { acc, resultLine: line };
273
+ const u = (j.message && j.message.usage) || j.usage;
274
+ if (!u || typeof u !== "object") return { acc, resultLine: null };
275
+ const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
276
+ return {
277
+ acc: {
278
+ input_tokens: Math.max(acc.input_tokens, n(u.input_tokens)),
279
+ output_tokens: acc.output_tokens + n(u.output_tokens),
280
+ cache_read_input_tokens: Math.max(acc.cache_read_input_tokens, n(u.cache_read_input_tokens)),
281
+ num_turns: acc.num_turns + 1,
282
+ },
283
+ resultLine: null,
284
+ };
285
+ }
286
+
287
+ /** Pull the CLI's session identity off any stream line (claude stream-json carries
288
+ * `session_id` on the init message AND the final result). Null when absent. */
289
+ export function sessionIdFrom(line) {
290
+ try {
291
+ const j = JSON.parse(line);
292
+ return typeof j.session_id === "string" && j.session_id ? j.session_id : null;
293
+ } catch { return null; }
294
+ }
295
+
296
+ /** LIVENESS over wall-clock (Coordination v2 Phase 1): a run producing output is
297
+ * healthy at minute 40; a run that's gone silent died at minute 3 — no fixed
298
+ * wall-clock can tell them apart (300s killed MCP runs, 900s killed founding
299
+ * runs). Kill on SILENCE (no stdout activity for livenessMs — streaming runs
300
+ * only; buffered/non-streaming runs emit nothing until the end) and keep a
301
+ * generous absolute ceiling purely as a cost backstop. Pure for tests. */
302
+ export function shouldKill({ streaming, startedAt, lastActivityAt, now, livenessMs, ceilingMs }) {
303
+ if (now - startedAt >= ceilingMs) return { kill: true, why: `hit the ${Math.round(ceilingMs / 60000)}min absolute ceiling` };
304
+ if (streaming && livenessMs > 0 && now - lastActivityAt >= livenessMs) {
305
+ return { kill: true, why: `no output for ${Math.round(livenessMs / 1000)}s (stalled — likely a hung prompt or dead CLI)` };
306
+ }
307
+ return { kill: false, why: "" };
308
+ }
309
+
310
+ /** RESUME-FIRST RETRIES (Phase 1): rewrite a claude one-shot command to resume the
311
+ * previous attempt's session — the retry CONTINUES the conversation (with the
312
+ * failure fed back as the next message) instead of re-paying for a blank-context
313
+ * redo. Claude only; other CLIs fall back to a fresh run with the error fed
314
+ * forward in the prompt. Pure for tests. */
315
+ export function resumeCommand(command, sessionId) {
316
+ if (!Array.isArray(command) || !sessionId) return { command, resumed: false };
317
+ const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
318
+ if (base !== "claude") return { command, resumed: false };
319
+ if (command.includes("--resume")) return { command, resumed: true };
320
+ return { command: [command[0], "--resume", sessionId, ...command.slice(1)], resumed: true };
321
+ }
322
+
323
+ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
324
+ return new Promise((resolve, reject) => {
325
+ const baseCommand = opts.command ?? agent.command;
326
+ const { command, streaming } = onProgress ? streamingCommand(baseCommand) : { command: baseCommand, streaming: false };
327
+ const [cmd, ...rawArgs] = command;
328
+ const args = rawArgs.map((a) => a.replaceAll("{prompt}", prompt));
329
+ const child = spawn(cmd, args, {
330
+ stdio: ["ignore", "pipe", "pipe"],
331
+ env: env ?? process.env,
332
+ // Local-access runs execute IN the mapped folder (terminal parity).
333
+ ...(agent.cwd ? { cwd: agent.cwd } : {}),
334
+ });
335
+
336
+ let out = "";
337
+ let err = "";
338
+ // Streaming path: line-buffer stdout, fold per-turn usage, throttle-emit progress,
339
+ // and keep ONLY the final result line as `out` so downstream parsers are unchanged.
340
+ let acc = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, num_turns: 0 };
341
+ let resultLine = "";
342
+ let lineBuf = "";
343
+ let lastEmit = 0;
344
+ // Live words (Composer chat feel): finished turns + the current turn's partial
345
+ // deltas. A completed turn REPLACES its partials (same text arrives both ways).
346
+ let turnsText = "";
347
+ let partialText = "";
348
+ const liveText = () => {
349
+ const full = partialText ? `${turnsText}${turnsText ? "\n\n" : ""}${partialText}` : turnsText;
350
+ return full.length > LIVE_TEXT_CAP ? "…" + full.slice(-LIVE_TEXT_CAP) : full;
351
+ };
352
+ const emit = () => {
353
+ const text = liveText();
354
+ if (!onProgress || (acc.input_tokens === 0 && acc.output_tokens === 0 && !text)) return;
355
+ lastEmit = Date.now();
356
+ // session_ref rides every tick once known: the server-visible resume handle that
357
+ // lets a Composer-thread follow-up continue THIS conversation (0064), surviving
358
+ // Bridge restarts (local retryCtx state is trimmed; the task row isn't).
359
+ // Progress needs a token field to pass the server's substance check, so a
360
+ // text-only tick sends output_tokens as-is (0 is fine once input>0 arrives).
361
+ try { onProgress({ ...acc, runner: agent.name, ...(text ? { live_text: text } : {}), ...(sessionId ? { session_ref: sessionId } : {}) }); } catch { /* progress is best-effort */ }
362
+ };
363
+
364
+ let sessionId = null;
365
+ const startedAt = Date.now();
366
+ let lastActivityAt = startedAt;
367
+ child.stdout.on("data", (d) => {
368
+ lastActivityAt = Date.now();
369
+ if (!streaming) { out += d; return; }
370
+ lineBuf += d;
371
+ let nl;
372
+ while ((nl = lineBuf.indexOf("\n")) >= 0) {
373
+ const line = lineBuf.slice(0, nl).trim();
374
+ lineBuf = lineBuf.slice(nl + 1);
375
+ if (!line) continue;
376
+ if (!sessionId) sessionId = sessionIdFrom(line);
377
+ const spoke = textFromStreamLine(line);
378
+ if (spoke) {
379
+ if (spoke.kind === "delta") partialText += spoke.text;
380
+ else {
381
+ turnsText += (turnsText && spoke.text ? "\n\n" : "") + spoke.text;
382
+ partialText = "";
383
+ }
384
+ }
385
+ const r = foldStreamLine(line, acc);
386
+ acc = r.acc;
387
+ if (r.resultLine) resultLine = r.resultLine;
388
+ else if (Date.now() - lastEmit > (agent.progressThrottleMs ?? 1200)) emit();
389
+ }
390
+ });
391
+ child.stderr.on("data", (d) => { lastActivityAt = Date.now(); err += d; });
392
+
393
+ // Liveness watchdog (Phase 1): silence kills fast, healthy work runs long.
394
+ // timeoutSeconds is now the absolute CEILING; livenessSeconds governs stall
395
+ // detection on streaming runs (non-streaming CLIs buffer, so ceiling-only).
396
+ const livenessMs = (opts.livenessSeconds ?? 0) * 1000;
397
+ const ceilingMs = timeoutSeconds * 1000;
398
+ const watchdog = setInterval(() => {
399
+ const verdict = shouldKill({ streaming, startedAt, lastActivityAt, now: Date.now(), livenessMs, ceilingMs });
400
+ if (!verdict.kill) return;
401
+ clearInterval(watchdog);
402
+ child.kill("SIGTERM");
403
+ setTimeout(() => child.kill("SIGKILL"), 5000);
404
+ const e = new Error(`killed: ${verdict.why}`);
405
+ // Carry the partial streaming counts out with the failure: a timed-out run
406
+ // BURNED real quota, and reporting zero made failed runs invisible to the
407
+ // chain token budget (2026-07-13 finding: 30 min of burns, budget saw 0).
408
+ e.partialUsage = acc.input_tokens || acc.output_tokens ? { ...acc } : null;
409
+ e.elapsedMs = Date.now() - startedAt;
410
+ e.sessionId = sessionId;
411
+ reject(e);
412
+ }, 5000);
413
+
414
+ child.on("error", (e) => {
415
+ clearInterval(watchdog);
416
+ reject(new Error(`could not launch \`${cmd}\` (is it installed + on PATH?): ${e.message}`));
417
+ });
418
+ child.on("close", (code) => {
419
+ clearInterval(watchdog);
420
+ // In streaming mode, hand back the final result line (json-mode-identical); fall
421
+ // back to the raw buffer if the run died before emitting one.
422
+ resolve({ code, out: streaming ? (resultLine || lineBuf || out) : out, err, sessionId });
423
+ });
424
+ });
425
+ }
426
+
427
+ /**
428
+ * Run a task on an agent. Two runner shapes:
429
+ * - default: a one-shot headless CLI (Claude `-p`, Gemini `-p`) via spawnAgent.
430
+ * - "app-server": a persistent `codex app-server` driven over JSON-RPC, because
431
+ * Codex's headless `exec` path auto-cancels MCP tool calls (OpenAI #16685).
432
+ * See bridge/codex-runner.mjs. Either way the agent calls complete_task
433
+ * itself, so verification (getTask) is identical.
434
+ */
435
+ async function runAgent(cfg, agent, prompt, onProgress, retry = null, taskCtx = {}) {
436
+ const { env } = agentEnv(cfg); // billing protection (see harden.mjs)
437
+ if (agent.runner === "app-server") {
438
+ const { runCodexTask } = await import("./codex-runner.mjs");
439
+ // Persistent codex server + thread mapping (v2): threadKey gives follow-ups
440
+ // real conversation continuity; onProgress streams agent-message deltas.
441
+ const threadKey = taskCtx.task ? taskCtx.task.thread_root_id ?? taskCtx.task.id : undefined;
442
+ return runCodexTask(agent, prompt, cfg.taskTimeoutSeconds, agent.token || cfg.codexToken, env, onProgress, { threadKey, log });
443
+ }
444
+ if (agent.runner === "robot") {
445
+ // Embodied runner (sim-first): structured task env, not a prompt — the robot's
446
+ // "brain" is a skill program. Same verify-via-getTask contract as every runner.
447
+ const { runRobotTask } = await import("./robot-runner.mjs");
448
+ return runRobotTask(agent, taskCtx.ws, taskCtx.task, cfg.taskTimeoutSeconds, agent.token || cfg.token, cfg.cookbookUrl, env);
449
+ }
450
+ // liveTokens defaults ON; a user can opt out per-agent or globally.
451
+ const live = cfg.liveTokens !== false && agent.liveTokens !== false;
452
+ // RESUME-FIRST (Phase 1): a retry with a saved claude session CONTINUES that
453
+ // session — the prompt becomes the next message in the conversation, carrying
454
+ // the failure back. Other CLIs get the failure fed forward in a fresh prompt.
455
+ const { command, resumed } = resumeCommand(agent.command, retry?.sessionId ?? null);
456
+ return spawnAgent(agent, prompt, cfg.taskTimeoutSeconds, env, live ? onProgress : undefined, {
457
+ command,
458
+ resumed,
459
+ livenessSeconds: cfg.livenessTimeoutSeconds,
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Turn a spawn result into a human cause for the "ran but didn't complete" case —
465
+ * the single most confusing failure in the field (the agent CLI exited fine but the
466
+ * task isn't done because it isn't logged in / has no MCP / a tool was blocked). Reads
467
+ * the agent's own exit code + stderr/stdout and names the likely fix. Returns "" when
468
+ * there's nothing useful (e.g. the persistent app-server runner, whose result differs).
469
+ */
470
+ /** Human line for a daily-cap hold: "X hit their NN-token daily cap on your subscription". */
471
+ function capHoldLine(who, res) {
472
+ const cap = Number(res?.cap);
473
+ const spent = Number(res?.spent);
474
+ const capStr = Number.isFinite(cap) ? cap.toLocaleString() : "the";
475
+ const spentStr = Number.isFinite(spent) ? ` (${spent.toLocaleString()} used today)` : "";
476
+ return `${who} hit their ${capStr}-token daily cap on your subscription${spentStr} — held until it resets (or you raise it in Account → Agents).`;
477
+ }
478
+
479
+ function failureHint(result) {
480
+ if (!result || typeof result !== "object") return "";
481
+ const { code, err, out } = result;
482
+ // JSON-mode envelopes ALWAYS contain the literal substring "permission_denials",
483
+ // so judging on raw output misdiagnosed every failed claude+json run as "a tool
484
+ // was blocked" (live, 2026-07-03). Parse the envelope and judge the REAL fields:
485
+ // the denials array, and the agent's own final text.
486
+ let denials = [];
487
+ let display = "";
488
+ const rawOut = String(out ?? "");
489
+ const brace = rawOut.indexOf("{");
490
+ if (brace >= 0) {
491
+ try {
492
+ const j = JSON.parse(rawOut.slice(brace));
493
+ denials = Array.isArray(j.permission_denials) ? j.permission_denials : [];
494
+ if (typeof j.result === "string") display = j.result;
495
+ } catch { /* not a JSON envelope */ }
496
+ }
497
+ if (denials.length > 0) {
498
+ const names = [...new Set(denials.map((d) => d?.tool_name).filter(Boolean))].slice(0, 3).join(", ");
499
+ return `tool(s) blocked${names ? ` (${names})` : ""} → check \`allowedTools\` matches the agent's MCP server name (CLI-added = mcp__cookbook__*)`;
500
+ }
501
+ // Infrastructure patterns are judged on STDERR only — the model's answer text
502
+ // (`display`) legitimately contains words like "/login" or "MCP server" whenever
503
+ // the TASK is about those things (misdiagnosis class, audit 2026-07-03 #7).
504
+ const infra = String(err || "").toLowerCase();
505
+ const tailSrc = `${err || display || (brace < 0 ? rawOut : "")}`.trim();
506
+ const tail = tailSrc.split("\n").slice(-2).join(" ").slice(0, 240);
507
+ if (infra.includes("not logged in") || infra.includes("please log in"))
508
+ return "the agent CLI isn't logged in → run `claude auth login`";
509
+ if (infra.includes("no mcp") || infra.includes("requires authentication"))
510
+ return "the agent can't reach the Cookbook MCP → run `node bridge/bridge.mjs doctor`";
511
+ if (infra.includes("not allowed") || infra.includes("allowedtools"))
512
+ return "a tool was blocked → check `allowedTools` matches the agent's MCP server name (CLI-added = mcp__cookbook__*)";
513
+ if (typeof code === "number" && code !== 0) return `agent exited ${code}${tail ? `: ${tail}` : ""}`;
514
+ if (tail) return `agent said: ${tail}`;
515
+ return "";
516
+ }
517
+
518
+ // ── run-state persistence ────────────────────────────────────────────────────
519
+ // attempts/givenUp were in-memory only, so EVERY restart (crash, self-update
520
+ // re-exec, manual bounce) reset the counters — and the Bridge resumes its own
521
+ // claimed tasks, so a restart granted every stuck task a fresh pair of attempts
522
+ // (2026-07-13: a doomed goal would have re-burned 6x900s across restarts).
523
+ // Persisted next to config; trimmed so it can't grow unbounded.
524
+ const STATE_PATH = path.join(HERE, "bridge.state.json");
525
+ function loadRunState() {
526
+ try {
527
+ const raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
528
+ for (const [id, n] of Object.entries(raw.attempts ?? {})) attempts.set(id, Number(n) || 0);
529
+ for (const id of raw.givenUp ?? []) givenUp.add(id);
530
+ for (const [id, ctx] of Object.entries(raw.retryCtx ?? {})) retryCtx.set(id, ctx);
531
+ } catch { /* first run / unreadable — start clean */ }
532
+ }
533
+ function saveRunState() {
534
+ try {
535
+ // Trim: these only matter for live tasks; cap so years of ids can't accumulate.
536
+ const attEntries = [...attempts.entries()].slice(-500);
537
+ const given = [...givenUp].slice(-500);
538
+ const retries = [...retryCtx.entries()].slice(-200);
539
+ fs.writeFileSync(STATE_PATH, JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries) }));
540
+ } catch { /* best-effort — never let state persistence break a run */ }
541
+ }
542
+
543
+ const attempts = new Map(); // taskId -> count
544
+ // Retry context (Phase 1): what the LAST failed attempt knew — the claude session
545
+ // to resume and the failure to feed back — so a retry continues instead of redoing.
546
+ const retryCtx = new Map(); // taskId -> { sessionId, reason }
547
+ // Volunteered runs that failed with attempts left: the task is already CLAIMED (by
548
+ // this Bridge's volunteer pre-claim), so the open-task scan can't re-find it — it
549
+ // retries from this shelf instead. In-memory: a restart still strands the claim
550
+ // (visible on the board, cancel by hand) — deliberate v1 legibility over churn.
551
+ const volunteeredRetries = [];
552
+ const inFlight = new Set();
553
+
554
+ // HOT MODE: while a conversation is active, the poll loop runs at 1s AND scopes
555
+ // its sweep to the hot workspace(s) so replies dispatch near-instantly; markHot()
556
+ // stamps activity per workspace, the window decays on its own.
557
+ const HOT_WINDOW_MS = 180_000;
558
+ let lastHotAt = 0;
559
+ const hotWorkspaces = new Map(); // wsId -> last activity ts
560
+ /** Bridge Local (local.mjs) — the loopback control API; null until main() starts it. */
561
+ let localServer = null;
562
+ let lastRunError = null;
563
+ /** Whether the Cookbook token has ever verified this run (gates /status.connected and
564
+ * keeps a token-rejected desktop Bridge alive instead of exiting). */
565
+ let tokenOk = false;
566
+ /** The desktop app sets COOKBOOK_DESKTOP=1 when it spawns the Bridge. Only then do we
567
+ * stay alive on a revoked token (so the user can fix it from the app's Connect UI). A
568
+ * headless/terminal Bridge still exits loudly with the fix — never a silent zombie. */
569
+ const IS_DESKTOP = process.env.COOKBOOK_DESKTOP === "1";
570
+
571
+ /** Run lifecycle → Bridge Local subscribers (the desktop app's notifications). */
572
+ function emitRun(state, ws, task, agent, localMeta) {
573
+ if (!localServer) return;
574
+ try {
575
+ localServer.emit("run", {
576
+ state,
577
+ workspaceId: ws?.id ?? null,
578
+ workspaceName: ws?.name ?? null,
579
+ taskId: task?.id ?? null,
580
+ title: task?.title ?? null,
581
+ agent: agent?.name ?? null,
582
+ ...(localMeta ? { cwd: localMeta.local_cwd, mode: localMeta.local_mode } : {}),
583
+ at: new Date().toISOString(),
584
+ });
585
+ } catch { /* notifications are best-effort */ }
586
+ }
587
+
588
+ /** Re-read config.json into the live cfg (token, agents, folders) without a restart.
589
+ * Used after connect-agents (the login refreshed the Bridge token) and by tools
590
+ * that edit the file while the Bridge runs. Version gates ran at startup only. */
591
+ function applyConfigFromDisk(cfg) {
592
+ if (!CONFIG_PATH) return;
593
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
594
+ if (raw.token && !String(raw.token).startsWith("PASTE")) cfg.token = raw.token;
595
+ if (raw.cookbookUrl) cfg.cookbookUrl = String(raw.cookbookUrl).replace(/\/$/, "");
596
+ cfg.default = raw.default;
597
+ cfg.localWorkspaces = raw.localWorkspaces ?? {};
598
+ const agents = (raw.agents ?? []).filter((a) => a.enabled !== false);
599
+ cfg.agents.splice(0, cfg.agents.length, ...agents);
600
+ // A reload usually follows connect-agents fixing the token — let the next poll
601
+ // re-verify from scratch instead of staying stuck in the rejected state.
602
+ consecutive401s = 0;
603
+ log(`↻ config reloaded — agents: ${agents.map((a) => a.name).join(", ") || "(none)"}`);
604
+ }
605
+
606
+ /** Re-exec this Bridge on the same argv (the self-update restart path). */
607
+ function reexecSelf() {
608
+ log("↻ restarting…");
609
+ const child = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "inherit" });
610
+ child.unref();
611
+ process.exit(0);
612
+ }
613
+
614
+ /** Configured agents + CLIs found on this machine, for Bridge Local's /status. */
615
+ function detectAgentsForStatus(cfg) {
616
+ const rows = cfg.agents.map((a) => {
617
+ const cmd = Array.isArray(a.command) ? a.command[0] : null;
618
+ const binary = resolveBin(cmd);
619
+ return { name: a.name, vendor: vendorOf ? vendorOf(a) : "other", binary, found: !!binary, enabled: true, runner: a.runner ?? "cli", configured: true };
620
+ });
621
+ try {
622
+ for (const cli of detectClis ? detectClis() : []) {
623
+ if (rows.some((r) => r.vendor === cli.vendor)) continue;
624
+ rows.push({ name: cli.agent, vendor: cli.vendor, binary: cli.path, found: true, enabled: false, runner: cli.kind === "codex" ? "app-server" : "cli", configured: false });
625
+ }
626
+ } catch { /* detection is best-effort */ }
627
+ return rows;
628
+ }
629
+
630
+ function markHot(wsId) {
631
+ lastHotAt = Date.now();
632
+ if (wsId) hotWorkspaces.set(wsId, lastHotAt);
633
+ }
634
+ function hotWorkspaceIds() {
635
+ const now = Date.now();
636
+ const ids = new Set();
637
+ for (const [id, ts] of hotWorkspaces) {
638
+ if (now - ts < HOT_WINDOW_MS) ids.add(id);
639
+ else hotWorkspaces.delete(id);
640
+ }
641
+ return ids;
642
+ }
643
+ const givenUp = new Set();
644
+ const skippedLogged = new Set(); // tasks we've already logged as policy-skipped
645
+
646
+ // Volunteer decisions we've already made, so a PASS isn't re-asked every poll and a
647
+ // VOLUNTEER isn't re-burned after an approval round-trip. Key: `${taskId}:${agentName}`.
648
+ const volunteerDecisions = new Map();
649
+
650
+ /**
651
+ * The volunteer path for one open GOAL task (stigmergy v1 — bridge/volunteer.mjs holds
652
+ * the pure logic; this is the I/O). For each opted-in agent, in config order:
653
+ * ask its OWN model the one-word capability question → on VOLUNTEER, run the same
654
+ * delegation-policy gate as any dispatched task (allow / ask-parks-in-inbox / off) →
655
+ * claim ATOMICALLY with claimed_via='volunteered' (two Bridges race in the DB; the
656
+ * loser just moves on). Returns {ws, task, agent} for the run queue on a won claim.
657
+ *
658
+ * v1 honesty: a volunteered task is claimed BEFORE running, so a failed run leaves it
659
+ * visibly claimed on the board (cancel/reassign by hand) rather than silently retried —
660
+ * deliberate: legible failure over invisible churn while the feature earns trust.
661
+ */
662
+ // The owner's UI settings (Account → Agent delegation → Volunteering), cached one
663
+ // minute so a busy board doesn't hammer the server. CONSENT FAILS CLOSED-ish: a
664
+ // transient fetch failure reuses the last SUCCESSFUL value (a UI "off" must not be
665
+ // overridden by local config during a server hiccup); only a server that genuinely
666
+ // lacks the tool yields null = "config decides". Also where the Bridge learns its
667
+ // own profile id (for member-scoped goal candidacy).
668
+ let volSettingsCache = { at: 0, value: null, everSucceeded: false };
669
+ let myProfileId = null;
670
+ async function cachedVolunteerSettings(cfg) {
671
+ if (Date.now() - volSettingsCache.at > 60_000) {
672
+ const r = await getVolunteerSettings(cfg);
673
+ if (r.ok) {
674
+ volSettingsCache = { at: Date.now(), value: r.value, everSucceeded: true };
675
+ if (r.value?.profile_id) myProfileId = r.value.profile_id;
676
+ warnIfTokenExpiring(r.value?.token_expires_at);
677
+ } else {
678
+ volSettingsCache.at = Date.now(); // don't hammer; keep the last-good value
679
+ }
680
+ }
681
+ return volSettingsCache.value;
682
+ }
683
+
684
+ async function considerVolunteering(cfg, ws, task, budget) {
685
+ const merged = mergeVolunteerSettings(cfg, await cachedVolunteerSettings(cfg));
686
+ const enabled = cfg.agents.filter((a) => volunteeringEnabled(cfg, a, merged));
687
+ if (enabled.length === 0) return null;
688
+ const candidates = volunteerCandidates([task], {
689
+ profileId: myProfileId ?? undefined,
690
+ inFlight, givenUp, attempts, maxAttempts: cfg.maxAttempts,
691
+ decided: { get: (id) => (enabled.every((a) => volunteerDecisions.get(`${id}:${a.name}`) === "PASS") ? "PASS" : undefined) },
692
+ });
693
+ if (candidates.length === 0) return null;
694
+
695
+ for (const agent of enabled) {
696
+ const key = `${task.id}:${agent.name}`;
697
+ let decision = volunteerDecisions.get(key);
698
+ if (decision === "PASS") continue;
699
+ if (!decision) {
700
+ if (budget.used >= MAX_DECISIONS_PER_POLL) return null; // next poll considers the rest
701
+ budget.used++;
702
+ let answered = true;
703
+ try {
704
+ const r = await spawnAgent(agent, decisionPrompt(task, effectiveCapabilities(agent, merged) ?? agent.capabilities), cfg.decisionTimeoutSeconds ?? 90, agentEnv(cfg).env);
705
+ decision = parseDecision(displayText(r.out));
706
+ } catch {
707
+ // Conservative THIS poll — but a timeout/hiccup is not the model's answer,
708
+ // so don't cache it: caching made one cold-start permanently mute a capable
709
+ // agent (audit 2026-07-03 #4). The next poll re-asks.
710
+ decision = "PASS";
711
+ answered = false;
712
+ }
713
+ if (answered) volunteerDecisions.set(key, decision);
714
+ log(`${decision === "VOLUNTEER" ? "🙋" : "🤔"} ${agent.name} ${decision === "VOLUNTEER" ? "volunteers for" : "passed on"} goal "${task.title}"`);
715
+ if (decision === "PASS") continue;
716
+ }
717
+
718
+ // Same consent gate as a dispatched task: the OWNER's delegation policy decides.
719
+ // FAILS CLOSED: an unreachable policy check skips this poll (resolveDelegation
720
+ // itself returns "run" only for genuinely-older servers without the tool).
721
+ let policy;
722
+ try {
723
+ policy = await resolveDelegation(cfg, task.id);
724
+ } catch {
725
+ log(`! couldn't check delegation policy for "${task.title}" — skipping this poll (will retry)`);
726
+ return null;
727
+ }
728
+ if (policy.decision === "pending") {
729
+ if (!skippedLogged.has("volpend:" + task.id)) {
730
+ skippedLogged.add("volpend:" + task.id);
731
+ log(`⏳ volunteer claim for "${task.title}" is waiting for your approval → /account/agents`);
732
+ }
733
+ return null; // decision is cached; after approval the next poll claims + runs
734
+ }
735
+ if (policy.decision === "skip") {
736
+ // A daily-cap hold is temporary (resets/raises) — don't cache PASS, just wait.
737
+ if (policy.reason === "daily_cap") {
738
+ if (!skippedLogged.has("volcap:" + task.id)) {
739
+ skippedLogged.add("volcap:" + task.id);
740
+ log(`⛔ ${capHoldLine(task.assigned_by_member || task.assigned_by, policy)}`);
741
+ }
742
+ return null;
743
+ }
744
+ volunteerDecisions.set(key, "PASS"); // policy said no — stop asking
745
+ continue;
746
+ }
747
+
748
+ // The atomic claim IS the cross-Bridge race.
749
+ try {
750
+ await volunteerClaim(cfg, ws.id, task.id);
751
+ } catch (e) {
752
+ log(`… lost the volunteer race for "${task.title}" (${e.message.slice(0, 80)})`);
753
+ return null; // someone else has it; it's no longer open next poll
754
+ }
755
+ // Stamp locally: the queued object predates the claim (listTasks ran before it), and
756
+ // the run prompt tells a volunteered agent it owns the judgment calls.
757
+ return { ws, task: { ...task, claimed_via: "volunteered" }, agent };
758
+ }
759
+ return null;
760
+ }
761
+
762
+ async function processTask(cfg, ws, task, agent) {
763
+ inFlight.add(task.id);
764
+ // PRE-CLAIM (Phase 0, audit #1): a dispatched task must be OURS before we spend
765
+ // quota on it. Without this, a to:'any' task — or the same member's Bridge on a
766
+ // second machine — ran N times and the losers found out at the 409 after paying
767
+ // for the whole run. The volunteer path already pre-claimed; this makes dispatch
768
+ // identical. Losing the claim is a normal outcome, not an error.
769
+ if (task.status === "open") {
770
+ const claimed = await dispatchClaim(cfg, ws.id, task.id);
771
+ if (!claimed) {
772
+ inFlight.delete(task.id);
773
+ return; // another Bridge won the race — their run, their receipt
774
+ }
775
+ task = { ...task, ...claimed };
776
+ }
777
+ // ANCESTOR CHECK (Phase 0, audit #6): if this task rides a chain whose root was
778
+ // cancelled, don't burn an attempt on work the human already stopped. Server-side
779
+ // cascade cancels open/claimed children; this catches the in-flight-retry window.
780
+ if (task.chain_id && task.chain_id !== task.id) {
781
+ const root = await getTask(cfg, ws.id, task.chain_id).catch(() => null);
782
+ if (root && root.status === "cancelled") {
783
+ inFlight.delete(task.id);
784
+ givenUp.add(task.id);
785
+ saveRunState();
786
+ await abandonTask(cfg, ws.id, task.id, "parent chain was cancelled");
787
+ log(`⨯ skipping "${task.title}" — its chain was cancelled`);
788
+ return;
789
+ }
790
+ }
791
+ markHot(ws.id); // a live conversation — poll this workspace fast until it quiets
792
+ attempts.set(task.id, (attempts.get(task.id) ?? 0) + 1);
793
+ saveRunState();
794
+ const n = attempts.get(task.id);
795
+ log(`→ waking ${agent.name} for "${task.title}" in ${ws.name} (attempt ${n}/${cfg.maxAttempts})`);
796
+ let resume = null; // hoisted: the catch path needs to know if the run RESUMED a session
797
+ try {
798
+ // Recall-injection: the team's relevant memory rides into the prompt (best-effort;
799
+ // [] on any failure — memory must never block a run). Query = the task title.
800
+ // Composer follow-ups (0064) skip recall entirely: a resumed session already
801
+ // carries what rode into the original run, and crediting notes that never rode
802
+ // into THIS prompt would corrupt the outcome-weighted signal.
803
+ const { memories, conventions } = task.thread_root_id
804
+ ? { memories: [], conventions: [] }
805
+ : await recallMemories(cfg, ws.id, task.title);
806
+ // Credit both classes on verified completion — conventions earn helpful_count too
807
+ // (the outcome signal that ranks proven rules first).
808
+ const recalledIds = [...memories, ...conventions].map((m) => m && m.id).filter(Boolean);
809
+ if (memories.length) log(` ↳ injecting ${memories.length} team-memory note${memories.length === 1 ? "" : "s"}`);
810
+ if (conventions.length) log(` ↳ + ${conventions.length} team convention${conventions.length === 1 ? "" : "s"} (verbatim)`);
811
+ // Proactive cross-workspace recall: proven knowledge from the member's OTHER projects
812
+ // (a playbook, a gotcha) surfaces here without being pointed at it. Best-effort.
813
+ const crossWorkspace = task.thread_root_id ? [] : await recallAcrossWorkspaces(cfg, task.title, ws.id, 3);
814
+ if (crossWorkspace.length) log(` ↳ + ${crossWorkspace.length} proven note${crossWorkspace.length === 1 ? "" : "s"} from your other projects`);
815
+ const startedAt = Date.now();
816
+ // Live ticker: stream in-flight token counts to the board so the assigner watches
817
+ // the cost accrue. Fire-and-forget + swallow errors — a progress hiccup must never
818
+ // touch the run. (report_task_progress is a no-op once the task leaves 'claimed'.)
819
+ let lastProgressPost = 0;
820
+ let localMeta = null; // { local_cwd, local_mode } once local access is decided below
821
+ const onProgress = (p) => {
822
+ if (Date.now() - lastProgressPost < 1000) return;
823
+ lastProgressPost = Date.now();
824
+ reportTaskProgress(cfg, ws.id, task.id, localMeta ? { ...p, ...localMeta } : p).catch(() => {});
825
+ };
826
+ // Retry attempts CONTINUE, not redo (Phase 1): with a saved claude session the
827
+ // prompt is just the next message in the resumed conversation; without one, the
828
+ // fresh prompt carries the failure + a don't-redo-finished-work instruction.
829
+ const retry = n > 1 ? retryCtx.get(task.id) ?? null : null;
830
+ // COMPOSER THREAD (0064): a follow-up run continues the thread's conversation.
831
+ // The freshest session_ref across the thread (server-side, survives restarts) is
832
+ // the resume handle; resume only works for claude commands (resumeCommand no-ops
833
+ // otherwise), so the cold-baton prompt is ALWAYS the fallback shape. A retry of
834
+ // this very task (retryCtx) outranks the thread handle — it's strictly newer.
835
+ // LOCAL ACCESS (terminal parity): a SELF-assigned task in a locally-mapped
836
+ // workspace runs IN the mapped folder with real tools. Teammate-assigned
837
+ // tasks never qualify (assigner ≠ claimer). The agent object is shadowed so
838
+ // every runner path downstream inherits cwd + widened tools consistently.
839
+ const localMap = cfg.localWorkspaces[ws.id];
840
+ const selfAssigned = task.assigned_by_profile && task.claimed_by_profile && task.assigned_by_profile === task.claimed_by_profile;
841
+ const local = localMap && selfAssigned && localMap.cwd ? localMap : null;
842
+ if (local) {
843
+ const mode = local.mode ?? modeForTools?.(local.allowedTools) ?? "run";
844
+ agent = { ...agent, command: localizeCommand(agent.command, local.allowedTools), cwd: local.cwd };
845
+ localMeta = { local_cwd: local.cwd, local_mode: mode };
846
+ log(` ↳ local access: running in ${local.cwd} (${mode}) with real tools (self-assigned)`);
847
+ // Tell the thread right away where this run lives (the receipt shows it).
848
+ reportTaskProgress(cfg, ws.id, task.id, { stage: `local: ${local.cwd}`, ...localMeta }).catch(() => {});
849
+ }
850
+ emitRun("started", ws, task, agent, localMeta);
851
+ // PERSISTENT RUNNER (terminal feel, opt-in): a live process for this thread means
852
+ // the conversation is already in memory — the prompt is just the next message,
853
+ // and the server-side session lookup (a full list_tasks) is skippable.
854
+ // Key includes the AGENT (vendor-switch safety) and the LOCAL flag — a jailed
855
+ // warm process must never serve a local turn, nor vice versa.
856
+ const threadKey = `${task.thread_root_id ?? task.id}::${agent.name}${local ? "::local" : ""}`;
857
+ const runnerEligible = cfg.persistentThreads && !retry && agent.runner !== "app-server" && agent.runner !== "robot";
858
+ let warmRunner = runnerEligible ? hasRunner(threadKey) : null;
859
+ // ADOPT a pre-warmed runner (0065) for a NEW conversation: the process booted
860
+ // while the member was still typing, so their first words hit a live agent.
861
+ if (!warmRunner && runnerEligible && !task.thread_root_id && !local) {
862
+ // (Local runs never adopt from the warm pool — pooled processes are jailed
863
+ // to workspace tools and the wrong cwd.)
864
+ warmRunner = adoptRunner(`warm::${ws.id}::${agent.name}`, threadKey);
865
+ if (warmRunner) log(` ↳ adopted a pre-warmed ${agent.name} — first message hits a live process`);
866
+ }
867
+ let thread = null;
868
+ if (task.thread_root_id && !warmRunner) {
869
+ thread = await threadResumeContext(cfg, ws.id, task.thread_root_id).catch(() => null);
870
+ if (thread?.root?.status === "cancelled") {
871
+ inFlight.delete(task.id);
872
+ givenUp.add(task.id);
873
+ saveRunState();
874
+ await abandonTask(cfg, ws.id, task.id, "thread was cancelled");
875
+ log(`⨯ skipping "${task.title}" — its thread was cancelled`);
876
+ return;
877
+ }
878
+ }
879
+ // A RETRY never thread-resumes (retryCtx null-session means the resumed session
880
+ // itself failed — go cold on the baton; thread.root still feeds it context).
881
+ const threadSession = !retry ? thread?.sessionRef ?? null : null;
882
+ const canResumeThread = threadSession && resumeCommand(agent.command, threadSession).resumed;
883
+ // Codex keeps its own conversation map (persistent app-server threads).
884
+ const codexWarm = agent.runner === "app-server" && hasCodexThread(task.thread_root_id ?? task.id);
885
+ const conversationWarm = !!warmRunner || !!canResumeThread || codexWarm;
886
+ // CHAT LANE (bridgeFiles): the agent's final message IS the result and the
887
+ // Bridge files it — saves a whole model round-trip (the complete_task tool
888
+ // call) plus the verify fetch, every single turn. Applies to claude runners
889
+ // AND the persistent codex server (its final agent message is the answer).
890
+ const bridgeFiles = cfg.persistentThreads && agent.runner !== "robot";
891
+ let basePrompt = task.thread_root_id
892
+ ? buildThreadFollowUpPrompt(ws, task, { resumed: conversationWarm, root: thread?.root ?? null, bridgeFiles })
893
+ : buildPrompt(ws, task, { memories, conventions, crossWorkspace, volunteered: task.claimed_via === "volunteered", bridgeFiles });
894
+ if (local) {
895
+ basePrompt += `\n\nLOCAL ACCESS: you are running ON the member's machine in ${local.cwd} — this folder is the workspace's local project. You have real file and shell tools; use them for the actual work (build artifacts, code, sites live HERE). Mirror durable outcomes into the Cookbook workspace (files / remember) so the team side stays true.`;
896
+ }
897
+ const prompt = retry?.sessionId
898
+ ? `Your previous attempt on this task was interrupted: ${String(retry.reason ?? "unknown failure").slice(0, 300)}. ` +
899
+ `Continue EXACTLY where you left off — do not redo completed work. If you are close, finish and call complete_task; ` +
900
+ `if the task is impossible from this environment, call abandon_task with the reason.`
901
+ : retry
902
+ ? `${basePrompt}\n\nNOTE: a previous attempt failed (${String(retry.reason ?? "unknown").slice(0, 300)}). ` +
903
+ `Check the workspace and memory for work already done — continue it, don't redo it.`
904
+ : basePrompt;
905
+ if (retry?.sessionId) log(` ↳ resuming previous session (continue, not redo)`);
906
+ else if (warmRunner) log(` ↳ warm thread runner — message goes straight to the live process`);
907
+ else if (canResumeThread) log(` ↳ resuming the thread's conversation (Composer follow-up)`);
908
+ else if (task.thread_root_id) log(` ↳ thread follow-up, no resumable session — running with the cold baton`);
909
+ resume = retry ?? (canResumeThread ? { sessionId: threadSession } : null);
910
+ let result = null;
911
+ if (cfg.persistentThreads && !retry && agent.runner !== "app-server" && agent.runner !== "robot") {
912
+ // Runner path: existing warm process, or boot one (resuming the thread's
913
+ // saved session when there is one). ANY runner failure falls back to the
914
+ // one-shot spawn below — the runner is an accelerator, never a dependency.
915
+ try {
916
+ const live = cfg.liveTokens !== false && agent.liveTokens !== false;
917
+ const r = warmRunner ?? runnerFor({
918
+ threadId: threadKey,
919
+ agent,
920
+ env: agentEnv(cfg).env,
921
+ resumeSessionId: canResumeThread ? threadSession : null,
922
+ helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
923
+ log,
924
+ });
925
+ result = await r.send(prompt, {
926
+ onProgress: live ? onProgress : undefined,
927
+ timeoutMs: cfg.taskTimeoutSeconds * 1000,
928
+ livenessMs: (cfg.livenessTimeoutSeconds ?? 0) * 1000,
929
+ });
930
+ } catch (e) {
931
+ if (/busy|not a claude-shaped/.test(e.message)) {
932
+ log(` ↳ runner unavailable (${e.message}) — one-shot fallback`);
933
+ result = null;
934
+ } else {
935
+ throw e; // real failure: ride the existing retry/abandon machinery
936
+ }
937
+ }
938
+ }
939
+ if (!result) result = await runAgent(cfg, agent, prompt, onProgress, resume, { ws, task });
940
+ if (result && result.sessionId) retryCtx.set(task.id, { sessionId: result.sessionId, reason: retryCtx.get(task.id)?.reason ?? null });
941
+ let after;
942
+ if (bridgeFiles) {
943
+ // File the final message as the result (agent may still have abandoned or
944
+ // self-completed via tools — any conflict just falls back to reading state).
945
+ const finalText = displayText(result?.out).trim().slice(0, 20_000);
946
+ // Success shape differs by runner: claude one-shots exit 0; the codex server
947
+ // resolves with a turn status (no exit code) — failed statuses fall through.
948
+ const cleanExit = result?.code === 0 || (result?.code === undefined && !/failed/i.test(String(result?.status ?? "")));
949
+ if (finalText && cleanExit) {
950
+ try {
951
+ await completeTaskApi(cfg, ws.id, task.id, finalText);
952
+ after = { status: "done" };
953
+ } catch {
954
+ after = await getTask(cfg, ws.id, task.id);
955
+ }
956
+ } else {
957
+ after = await getTask(cfg, ws.id, task.id);
958
+ }
959
+ } else {
960
+ after = await getTask(cfg, ws.id, task.id);
961
+ }
962
+ if (after?.status === "done") {
963
+ retryCtx.delete(task.id);
964
+ log(`✓ ${agent.name} completed "${task.title}"`);
965
+ emitRun("done", ws, task, agent, localMeta);
966
+ // Outcome signal: credit the memory notes that rode into this SUCCESSFUL run,
967
+ // so proven notes surface first next time (outcome-weighted recall). Best-effort.
968
+ if (recalledIds.length) creditRecall(cfg, ws.id, recalledIds).catch(() => {});
969
+ // Quota visibility: tell Cookbook what this run cost (tokens/cost from the CLI's
970
+ // own report when available, wall time always) so the assigner sees the price of
971
+ // the delegation. Fire-and-forget — a usage hiccup must never fail a done task.
972
+ try {
973
+ const usage = extractUsage(result, agent.name, Date.now() - startedAt);
974
+ if (usage) {
975
+ await reportTaskUsage(cfg, ws.id, task.id, usage);
976
+ const tok = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
977
+ log(` ↳ usage reported${tok ? `: ${tok.toLocaleString()} tokens` : ""}${usage.cost_usd ? ` · ~$${usage.cost_usd.toFixed(2)}` : ""}`);
978
+ }
979
+ } catch (e) {
980
+ log(` ↳ usage report skipped (${e.message})`);
981
+ }
982
+ } else {
983
+ // The agent CLI exited but the task isn't done — surface WHY (login/MCP/tools),
984
+ // instead of the old silent "ran but isn't marked done". This is the line that
985
+ // turns a multi-hour debug into a one-glance fix.
986
+ const hint = failureHint(result);
987
+ const why = hint ? ` — ${hint}` : "";
988
+ if (n >= cfg.maxAttempts) {
989
+ givenUp.add(task.id);
990
+ saveRunState();
991
+ // Final attempt: account the burn (see catch path). extractUsage reads the
992
+ // CLI's own final report when the run exited but didn't complete the task.
993
+ try {
994
+ const burned = extractUsage(result, agent.name, Date.now() - startedAt);
995
+ if (burned) reportTaskUsage(cfg, ws.id, task.id, burned).catch(() => {});
996
+ } catch { /* accounting only — never let it touch the failure path */ }
997
+ // HAND BACK LOUDLY (Phase 0, audit #2/#8): mark it abandoned with the hint
998
+ // so the ASSIGNER sees "tried, gave up, here's why" on the board — instead
999
+ // of a task that silently rots open (or strands claimed, invisible to all).
1000
+ retryCtx.delete(task.id);
1001
+ await abandonTask(cfg, ws.id, task.id, hint || `ran ${n} attempt(s) without completing`);
1002
+ emitRun("failed", ws, task, agent, localMeta);
1003
+ log(`✗ ${agent.name} didn't complete "${task.title}" after ${n} attempts${why} — handed back as abandoned.`);
1004
+ } else {
1005
+ // THREAD SELF-HEAL: if this attempt RESUMED a session and still failed, the
1006
+ // session itself may be the problem (e.g. a safeguard refusing the replayed
1007
+ // transcript) — retry COLD on the baton instead of resuming into the same
1008
+ // wall. The cold run's own session_ref then becomes the thread's freshest
1009
+ // handle, so future replies resume a healthy conversation.
1010
+ const resumedAndFailed = task.thread_root_id && resume?.sessionId;
1011
+ retryCtx.set(task.id, {
1012
+ sessionId: resumedAndFailed ? null : result?.sessionId ?? retryCtx.get(task.id)?.sessionId ?? null,
1013
+ reason: hint || "ran but did not complete the task",
1014
+ });
1015
+ saveRunState();
1016
+ // EVERY failed run is a CLAIMED task now (Phase 0 pre-claims dispatch too),
1017
+ // so every one must ride the retry shelf or it strands invisible to the
1018
+ // open scan. (Pre-fix this was volunteered-only: assigned tasks whose
1019
+ // attempt 1 failed logged "will retry" and never did.)
1020
+ volunteeredRetries.push({ ws, task, agent });
1021
+ log(`… ${agent.name} ran but the task isn't marked done${why} — will retry${retryCtx.get(task.id)?.sessionId ? " (resumable)" : ""}.`);
1022
+ }
1023
+ }
1024
+ } catch (e) {
1025
+ if (n >= cfg.maxAttempts) {
1026
+ givenUp.add(task.id);
1027
+ saveRunState();
1028
+ retryCtx.delete(task.id);
1029
+ await abandonTask(cfg, ws.id, task.id, e.message || "failed after final attempt");
1030
+ emitRun("failed", ws, task, agent, null);
1031
+ // FINAL attempt failed: report what the failed runs actually burned, so the
1032
+ // chain token budget sees it. Only on the LAST attempt — usage is first-
1033
+ // report-wins, and an earlier report would block a successful retry's real
1034
+ // one. Best-effort: the server rejects states it won't account (e.g. open).
1035
+ if (e.partialUsage) {
1036
+ reportTaskUsage(cfg, ws.id, task.id, {
1037
+ ...e.partialUsage,
1038
+ duration_ms: e.elapsedMs ?? 0,
1039
+ runner: agent.name,
1040
+ }).then(() => {
1041
+ const tok = (e.partialUsage.input_tokens ?? 0) + (e.partialUsage.output_tokens ?? 0);
1042
+ log(` ↳ burned quota reported despite failure: ${tok.toLocaleString()} tokens`);
1043
+ }).catch(() => {});
1044
+ }
1045
+ }
1046
+ // A volunteered task is CLAIMED — invisible to the open scan — so the throw
1047
+ // branch (timeouts are the COMMON failure here) must feed the retry shelf
1048
+ // exactly like the ran-but-not-done branch, or the claim strands.
1049
+ else {
1050
+ // Same thread self-heal as the ran-not-done branch: a failed RESUMED thread
1051
+ // attempt retries cold rather than back into the same session.
1052
+ const resumedAndFailed = task.thread_root_id && resume?.sessionId;
1053
+ retryCtx.set(task.id, { sessionId: resumedAndFailed ? null : e.sessionId ?? retryCtx.get(task.id)?.sessionId ?? null, reason: e.message });
1054
+ saveRunState();
1055
+ volunteeredRetries.push({ ws, task, agent }); // all claimed failures ride the shelf (see above)
1056
+ }
1057
+ const slow = /timed out after (\d+)s/.exec(e.message);
1058
+ lastRunError = `${agent.name}: ${String(e.message).slice(0, 300)}`;
1059
+ log(`✗ ${agent.name} error on "${task.title}": ${e.message}${slow ? ` — the run hit taskTimeoutSeconds (${slow[1]}s); raise it in config.json if this task is just slow` : ""}`);
1060
+ } finally {
1061
+ inFlight.delete(task.id);
1062
+ markHot(ws.id); // the reply usually lands right after a run finishes — stay fast for it
1063
+ }
1064
+ }
1065
+
1066
+ let consecutive401s = 0;
1067
+
1068
+ // Warn (once per day) when the Bridge token is within 14 days of expiry — the
1069
+ // alternative is a silent death into 401s weeks later (audit 2026-07-03 #9).
1070
+ // Pairs with the 5x401 loud exit above: warned before, clean exit after.
1071
+ let lastExpiryWarnDay = "";
1072
+ function warnIfTokenExpiring(expiresAt) {
1073
+ if (!expiresAt) return;
1074
+ const daysLeft = Math.floor((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
1075
+ if (daysLeft > 14) return;
1076
+ const today = new Date().toISOString().slice(0, 10);
1077
+ if (lastExpiryWarnDay === today) return;
1078
+ lastExpiryWarnDay = today;
1079
+ log(`! this Bridge's token expires in ${Math.max(daysLeft, 0)} day(s) — re-run \`node bridge/bridge.mjs login\` before it does.`);
1080
+ }
1081
+
1082
+ let wsCursor = 0;
1083
+ /** ONE-CALL fast dispatch: list_open_work across every workspace in a single HTTP
1084
+ * round-trip, then the same eligibility pipeline as the sweep (goal/volunteer,
1085
+ * pending/skip messaging, and stale-claim rescue stay on the full sweep — this is
1086
+ * the 1s dispatch lane, not the janitor). Returns false when the server predates
1087
+ * the tool so the caller can fall back to sweeps. */
1088
+ async function quickScan(cfg) {
1089
+ const { supported, work, warmHints } = await listOpenWork(cfg);
1090
+ if (!supported) return false;
1091
+ await dispatchWork(cfg, work, warmHints);
1092
+ return true;
1093
+ }
1094
+
1095
+ // ── THE PUSH CHANNEL (SSE) ────────────────────────────────────────────────────
1096
+ // One outbound connection; the server pushes open work + warm hints the moment
1097
+ // they change (~sub-second dispatch). Connections are short-lived by design
1098
+ // (serverless-honest); this loop reconnects forever. A server without the route
1099
+ // marks it unsupported and the 1s poll carries on — push is an accelerator,
1100
+ // never a dependency.
1101
+ const sse = { connected: false, supported: true, aliveAt: 0 };
1102
+
1103
+ function parseSseFrame(frame) {
1104
+ let event = "message";
1105
+ let data = "";
1106
+ for (const line of frame.split("\n")) {
1107
+ if (line.startsWith("event:")) event = line.slice(6).trim();
1108
+ else if (line.startsWith("data:")) data += line.slice(5).trim();
1109
+ }
1110
+ return { event, data };
1111
+ }
1112
+
1113
+ async function socketLoop(cfg) {
1114
+ let announced = false;
1115
+ while (!sseStopped && sse.supported) {
1116
+ try {
1117
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream`, {
1118
+ headers: { Authorization: `Bearer ${cfg.token}` },
1119
+ });
1120
+ if (res.status === 404 || res.status === 405) {
1121
+ sse.supported = false;
1122
+ log("· server has no push channel — dispatching on the 1s poll");
1123
+ return;
1124
+ }
1125
+ if (!res.ok || !res.body) throw new Error(`stream ${res.status}`);
1126
+ sse.connected = true;
1127
+ sse.aliveAt = Date.now();
1128
+ if (!announced) {
1129
+ announced = true;
1130
+ log("⚡ push channel connected — dispatch is now sub-second");
1131
+ }
1132
+ const reader = res.body.getReader();
1133
+ const dec = new TextDecoder();
1134
+ let buf = "";
1135
+ for (;;) {
1136
+ const { done, value } = await reader.read();
1137
+ if (done) break;
1138
+ sse.aliveAt = Date.now();
1139
+ buf += dec.decode(value, { stream: true });
1140
+ let i;
1141
+ while ((i = buf.indexOf("\n\n")) >= 0) {
1142
+ const frame = buf.slice(0, i);
1143
+ buf = buf.slice(i + 2);
1144
+ if (!frame.trim() || frame.startsWith(":")) continue;
1145
+ const ev = parseSseFrame(frame);
1146
+ if (ev.event === "work" && ev.data) {
1147
+ try {
1148
+ const j = JSON.parse(ev.data);
1149
+ void dispatchWork(cfg, j.work ?? [], j.warm_hints ?? []);
1150
+ } catch { /* malformed frame — next snapshot covers */ }
1151
+ }
1152
+ }
1153
+ }
1154
+ } catch { /* transient — reconnect below */ }
1155
+ sse.connected = false;
1156
+ await new Promise((r) => setTimeout(r, 3000));
1157
+ }
1158
+ }
1159
+ let sseStopped = false;
1160
+
1161
+ /** The ONE dispatch pipeline — fed by the push channel (SSE) and the 1s poll alike.
1162
+ * Re-entrancy-guarded: overlapping snapshots are redundant (each is a full picture),
1163
+ * and claim CAS + inFlight make any stragglers harmless anyway. */
1164
+ let dispatchBusy = false;
1165
+ async function dispatchWork(cfg, work, warmHints) {
1166
+ if (dispatchBusy) return;
1167
+ dispatchBusy = true;
1168
+ try {
1169
+ await dispatchWorkInner(cfg, work, warmHints);
1170
+ } finally {
1171
+ dispatchBusy = false;
1172
+ }
1173
+ }
1174
+
1175
+ async function dispatchWorkInner(cfg, work, warmHints) {
1176
+ // PRE-WARM (0065): the chat surface hinted a conversation is imminent — boot the
1177
+ // runner NOW so the first message hits a live process instead of a cold spawn.
1178
+ if (cfg.persistentThreads) {
1179
+ for (const h of warmHints ?? []) {
1180
+ const agent = agentFor(cfg, h.agent);
1181
+ if (!agent || agent.runner === "app-server" || agent.runner === "robot") continue;
1182
+ warmUp({
1183
+ poolKey: `warm::${h.workspace_id}::${agent.name}`,
1184
+ agent,
1185
+ env: agentEnv(cfg).env,
1186
+ helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
1187
+ log,
1188
+ });
1189
+ }
1190
+ }
1191
+ const queue = [];
1192
+ // RETRY SHELF drains here too — the fast lane demoted the full sweep to a 30s
1193
+ // janitor, which silently made every retry wait up to 30s (Diego's SaaS thread,
1194
+ // 2026-08-20: fail at :26, retry at :03). A failed CLAIMED task must re-attempt
1195
+ // on the next second, exactly like fresh work.
1196
+ while (volunteeredRetries.length > 0) {
1197
+ const retry = volunteeredRetries.shift();
1198
+ if (inFlight.has(retry.task.id) || givenUp.has(retry.task.id)) continue;
1199
+ if ((attempts.get(retry.task.id) ?? 0) >= cfg.maxAttempts) continue;
1200
+ try {
1201
+ const fresh = await getTask(cfg, retry.ws.id, retry.task.id);
1202
+ if (fresh && fresh.status === "claimed") queue.push(retry);
1203
+ } catch {
1204
+ volunteeredRetries.push(retry); // transient — try again next scan
1205
+ break;
1206
+ }
1207
+ }
1208
+ for (const t of work) {
1209
+ if ((t.assigned_to || "").toLowerCase() === "goal") continue;
1210
+ if (inFlight.has(t.id) || givenUp.has(t.id)) continue;
1211
+ if ((attempts.get(t.id) ?? 0) >= cfg.maxAttempts) continue;
1212
+ const agent = agentFor(cfg, t.assigned_to);
1213
+ if (!agent) continue;
1214
+ if (!allowedByPolicy(cfg, agent, t)) continue;
1215
+ let policy;
1216
+ try { policy = await resolveDelegation(cfg, t.id); } catch { continue; }
1217
+ if (policy.decision !== "run") continue;
1218
+ queue.push({ ws: { id: t.workspace_id, name: t.workspace_name ?? "workspace" }, task: t, agent });
1219
+ }
1220
+ queue.sort((a, b) => new Date(a.task.created_at ?? 0) - new Date(b.task.created_at ?? 0));
1221
+ const slots = Math.max(0, cfg.maxConcurrentRuns - inFlight.size);
1222
+ for (const item of queue.slice(0, slots)) {
1223
+ void processTask(cfg, item.ws, item.task, item.agent).catch((e) => log(`✗ run error on "${item.task.title}": ${e.message}`));
1224
+ }
1225
+ }
1226
+
1227
+ async function pollOnce(cfg, onlyWorkspaceIds = null) {
1228
+ let workspaces = await listWorkspaces(cfg);
1229
+ // HOT-SCOPED SWEEP (chat feel): a full sweep across N workspaces costs N HTTP
1230
+ // round-trips — 12 workspaces ≈ 15s, which WAS the reply latency users felt.
1231
+ // While a conversation is hot, in-between sweeps scan only its workspace(s);
1232
+ // the full-fairness sweep still runs on the normal cadence.
1233
+ if (onlyWorkspaceIds) workspaces = workspaces.filter((w) => onlyWorkspaceIds.has(w.id));
1234
+ // FAIRNESS (Phase 1, audit #3): rotate which workspace is scanned first each
1235
+ // poll — scan order used to decide who ate the volunteer budget and the run
1236
+ // slots, starving every workspace after a busy one.
1237
+ if (workspaces.length > 1) {
1238
+ const k = wsCursor++ % workspaces.length;
1239
+ workspaces = [...workspaces.slice(k), ...workspaces.slice(0, k)];
1240
+ }
1241
+ consecutive401s = 0;
1242
+ // Collect eligible (open, managed, not in-flight, not given-up) tasks.
1243
+ const queue = [];
1244
+ // Failed volunteered runs retry first — they're claimed by us, invisible to the
1245
+ // open scan. Verify the task is still live (not completed/cancelled by a human).
1246
+ while (volunteeredRetries.length > 0) {
1247
+ const retry = volunteeredRetries.shift();
1248
+ if (inFlight.has(retry.task.id) || givenUp.has(retry.task.id)) continue;
1249
+ if ((attempts.get(retry.task.id) ?? 0) >= cfg.maxAttempts) continue;
1250
+ try {
1251
+ const fresh = await getTask(cfg, retry.ws.id, retry.task.id);
1252
+ if (fresh && fresh.status === "claimed") queue.push(retry);
1253
+ } catch { volunteeredRetries.push(retry); break; } // server unreachable — retry next poll
1254
+ }
1255
+ // Per-poll budget for volunteer decisions (each is a model call on the owner's quota).
1256
+ const decisionBudget = { used: 0 };
1257
+ for (const ws of workspaces) {
1258
+ let tasks;
1259
+ try {
1260
+ tasks = await listTasks(cfg, ws.id, "open");
1261
+ } catch (e) {
1262
+ log(`! couldn't list tasks for ${ws.name}: ${e.message}`);
1263
+ continue;
1264
+ }
1265
+ for (const t of tasks) {
1266
+ if (inFlight.has(t.id) || givenUp.has(t.id)) continue;
1267
+ if ((attempts.get(t.id) ?? 0) >= cfg.maxAttempts) continue;
1268
+
1269
+ // Open GOAL → the volunteer path (stigmergy), never the dispatch path. OFF unless
1270
+ // an agent opted in (`"volunteer": true`) under the master switch (`volunteering`).
1271
+ if ((t.assigned_to || "").toLowerCase() === "goal") {
1272
+ const claimed = await considerVolunteering(cfg, ws, t, decisionBudget);
1273
+ if (claimed) queue.push(claimed); // {ws, task, agent} — claim already won
1274
+ continue;
1275
+ }
1276
+
1277
+ const agent = agentFor(cfg, t.assigned_to);
1278
+ if (!agent) continue; // no local agent handles this assignee
1279
+
1280
+ // Optional extra LOCAL allowlist (config acceptFrom; default "anyone").
1281
+ if (!allowedByPolicy(cfg, agent, t)) {
1282
+ if (!skippedLogged.has(t.id)) {
1283
+ skippedLogged.add(t.id);
1284
+ log(`⊘ skipping "${t.title}" — assigned by ${t.assigned_by_member || t.assigned_by}, not in ${agent.name}'s local acceptFrom. Left open.`);
1285
+ }
1286
+ continue;
1287
+ }
1288
+
1289
+ // UI-managed delegation policy (allow / ask / off), enforced by Cookbook.
1290
+ // FAILS CLOSED: consent unknown = don't run this poll. resolveDelegation
1291
+ // returns "run" itself for genuinely-older servers (unknown tool), so the
1292
+ // catch here only fires on transient failures — the task stays open and
1293
+ // is re-checked next poll.
1294
+ let policy;
1295
+ try {
1296
+ policy = await resolveDelegation(cfg, t.id);
1297
+ } catch {
1298
+ if (!skippedLogged.has("warn:" + t.id)) {
1299
+ skippedLogged.add("warn:" + t.id);
1300
+ log(`! couldn't check delegation policy for "${t.title}" — holding it until the check succeeds.`);
1301
+ }
1302
+ continue;
1303
+ }
1304
+ if (policy.decision === "pending") {
1305
+ if (!skippedLogged.has("pend:" + t.id)) {
1306
+ skippedLogged.add("pend:" + t.id);
1307
+ log(`⏳ "${t.title}" (from ${t.assigned_by_member || t.assigned_by}) is waiting for your approval → /account/agents`);
1308
+ }
1309
+ continue;
1310
+ }
1311
+ if (policy.decision === "skip") {
1312
+ // A daily-cap hold is temporary — log it distinctly (re-logs if the cap changes).
1313
+ const capMsg = policy.reason === "daily_cap"
1314
+ ? `⛔ ${capHoldLine(t.assigned_by_member || t.assigned_by, policy)}`
1315
+ : `⊘ "${t.title}" blocked by your delegation policy. Left open.`;
1316
+ const key = policy.reason === "daily_cap" ? "cap:" + t.id : t.id;
1317
+ if (!skippedLogged.has(key)) {
1318
+ skippedLogged.add(key);
1319
+ log(capMsg);
1320
+ }
1321
+ continue;
1322
+ }
1323
+ queue.push({ ws, task: t, agent });
1324
+ }
1325
+ }
1326
+ // PARALLEL SLOTS + FAIRNESS (Phase 1). Launch up to maxConcurrentRuns without
1327
+ // blocking the poll loop (inFlight dedupes across polls; the pre-claim makes a
1328
+ // double-launch a no-op even across Bridges). Oldest task first — newest-first
1329
+ // starved old tasks under a steady stream of new ones (audit #3).
1330
+ queue.sort((a, b) => new Date(a.task.created_at ?? 0) - new Date(b.task.created_at ?? 0));
1331
+ const slots = Math.max(0, cfg.maxConcurrentRuns - inFlight.size);
1332
+ for (const item of queue.slice(0, slots)) {
1333
+ void processTask(cfg, item.ws, item.task, item.agent).catch((e) => {
1334
+ inFlight.delete(item.task.id);
1335
+ log(`! run crashed unexpectedly for "${item.task.title}": ${e.message}`);
1336
+ });
1337
+ }
1338
+ }
1339
+
1340
+ /** How often a RUNNING bridge re-checks the deploy manifest ("app updated → I update"). */
1341
+ const UPDATE_CHECK_MS = 6 * 60 * 60 * 1000;
1342
+
1343
+ /**
1344
+ * WHO OWNS THIS INSTALL'S VERSION.
1345
+ *
1346
+ * The Bridge shipped with a bespoke self-updater because it was the only channel: an
1347
+ * unpacked tar has no package manager behind it. Where a real channel DOES exist,
1348
+ * rewriting our own files is wrong and has bitten us (2026-08-22: writing inside the
1349
+ * signed .app broke its code seal and voided notarization). So:
1350
+ *
1351
+ * "app" — the desktop shell spawned us (COOKBOOK_DESKTOP=1). The app ships, signs
1352
+ * and notarizes the runtime with itself and re-seeds on upgrade. Never
1353
+ * self-update: that is what corrupted the bundle.
1354
+ * "npm" — installed as a package (node_modules, npx cache, or a package.json next
1355
+ * to us). npm is the channel; rewriting files under it fights the package
1356
+ * manager and can poison an npx cache. Nag with the update command.
1357
+ * "self" — a bare unpacked tar. No other channel exists, so keep the original
1358
+ * behavior: verify, back up, replace, re-exec.
1359
+ */
1360
+ function updateChannel() {
1361
+ if (IS_DESKTOP) return "app";
1362
+ if (HERE.includes(`${path.sep}node_modules${path.sep}`)) return "npm";
1363
+ if (fs.existsSync(path.join(HERE, "package.json"))) return "npm";
1364
+ return "self";
1365
+ }
1366
+
1367
+ let updateNagged = false;
1368
+
1369
+ /**
1370
+ * Self-update pass. Only the "self" channel applies anything (see updateChannel).
1371
+ * autoUpdate !== false (default ON): apply + re-exec so fleets track app deploys.
1372
+ * autoUpdate === false: loud nag only — the version is pinned by the user.
1373
+ * Check failures are non-fatal (offline is fine); a FAILED apply never breaks the
1374
+ * running code (verification happens before any write; originals in bridge.backup/).
1375
+ */
1376
+ async function selfUpdate(cfg, { reexec }) {
1377
+ let check;
1378
+ try {
1379
+ check = await checkForUpdate(cfg, HERE);
1380
+ } catch {
1381
+ return; // can't reach the manifest — never block on updates
1382
+ }
1383
+ if (check.changed.length === 0) return;
1384
+ const channel = updateChannel();
1385
+ if (channel !== "self") {
1386
+ // Say it once per process: drift should be visible, not noisy, and never silent.
1387
+ if (!updateNagged) {
1388
+ updateNagged = true;
1389
+ log(
1390
+ channel === "app"
1391
+ ? `⬆ A newer Bridge ships with the app (deploy ${check.version}). The Cookbook app manages this copy — update the app to pick it up.`
1392
+ : `⬆ A newer Bridge is available (deploy ${check.version}). Update it with: npx cookbook-bridge@latest connect`,
1393
+ );
1394
+ }
1395
+ return;
1396
+ }
1397
+ if (cfg.autoUpdate === false) {
1398
+ log(`⬆ Bridge update available (deploy ${check.version}; ${check.changed.length} file(s) changed) — run \`node bridge.mjs update\`. (autoUpdate is off.)`);
1399
+ return;
1400
+ }
1401
+ try {
1402
+ const replaced = await applyUpdate(cfg, HERE, check);
1403
+ log(`⬆ Bridge self-updated to deploy ${check.version} (${replaced.length} file(s), hash-verified; previous in bridge.backup/${check.version}/).`);
1404
+ if (reexec) {
1405
+ log("↻ restarting on the new code…");
1406
+ const { spawn } = await import("node:child_process");
1407
+ const child = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "inherit" });
1408
+ child.unref();
1409
+ process.exit(0);
1410
+ }
1411
+ } catch (e) {
1412
+ log(`! self-update failed safely (${e.message}) — still on the previous version.`);
1413
+ }
1414
+ }
1415
+
1416
+ async function main() {
1417
+ await loadRuntime();
1418
+ const cfg = loadConfig();
1419
+ log(`Cookbook Bridge started · ${cfg.cookbookUrl}`);
1420
+ log(`Managing: ${cfg.agents.map((a) => a.name).join(", ") || "(no agents enabled!)"} · polling every ${cfg.pollSeconds}s`);
1421
+
1422
+ // "When the app updates, so does the Bridge": check the deploy manifest now, then
1423
+ // every 6h while running. Set "autoUpdate": false in config to pin.
1424
+ await selfUpdate(cfg, { reexec: true });
1425
+ let lastUpdateCheck = Date.now();
1426
+
1427
+ // Loud warning if the default agent (the one that runs "any"-assigned tasks) isn't
1428
+ // actually installed — otherwise those tasks silently route to a missing CLI. Run
1429
+ // `node bridge.mjs doctor` for the full preflight.
1430
+ const dflt = agentFor(cfg, "any");
1431
+ if (!dflt) {
1432
+ log(`! no default agent — "any"-assigned tasks won't run. Set "default" in config to an enabled agent.`);
1433
+ } else {
1434
+ // agentFor falls back to agents[0] when "default" names a missing/disabled
1435
+ // agent — silent rerouting is exactly the surprise class; say it out loud.
1436
+ if (cfg.default && dflt.name !== cfg.default) {
1437
+ log(`! config "default" is "${cfg.default}" but no enabled agent has that name — "any"-assigned tasks route to ${dflt.name}.`);
1438
+ }
1439
+ if (!resolveBin(Array.isArray(dflt.command) ? dflt.command[0] : null)) {
1440
+ log(`! default agent "${dflt.name}" isn't installed/on PATH — "any"-assigned tasks will fail. Run \`node bridge.mjs doctor\`.`);
1441
+ }
1442
+ }
1443
+
1444
+ let stopped = false;
1445
+ process.on("SIGINT", () => {
1446
+ stopped = true;
1447
+ log("Stopping…");
1448
+ process.exit(0);
1449
+ });
1450
+
1451
+ // Billing protection: say what's being stripped from agent processes (once).
1452
+ const { stripped } = agentEnv(cfg);
1453
+ if (stripped.length) {
1454
+ log(`Billing protection: ${stripped.join(", ")} hidden from agent processes so tasks run on your SUBSCRIPTION, never your API account. (Opt out: "allowApiKeyBilling": true in config.)`);
1455
+ }
1456
+
1457
+ // Gemini version gate: refuse to run gemini agents below the RCE-fix version.
1458
+ // cfg.agents is pre-filtered at load and matching never re-checks `enabled`, so a
1459
+ // vulnerable agent must be REMOVED from the list, not just flagged.
1460
+ const vulnerableAgents = new Set();
1461
+ for (const agent of cfg.agents.filter((a) => isGeminiCommand(a.command?.[0]))) {
1462
+ const { version, vulnerable } = await checkGeminiVersion([agent.command[0]]);
1463
+ if (vulnerable) {
1464
+ vulnerableAgents.add(agent);
1465
+ console.error(
1466
+ `!! ${agent.name}: gemini-cli ${version} has a critical (CVSS 10.0) prompt-injection RCE — ` +
1467
+ `agent DISABLED for this run. Update to ≥ ${GEMINI_MIN_VERSION} (npm i -g @google/gemini-cli@latest) and restart.`,
1468
+ );
1469
+ } else if (!version) {
1470
+ log(`! ${agent.name}: couldn't read gemini version — make sure it's ≥ ${GEMINI_MIN_VERSION} (RCE fix).`);
1471
+ }
1472
+ }
1473
+ if (vulnerableAgents.size) cfg.agents = cfg.agents.filter((a) => !vulnerableAgents.has(a));
1474
+
1475
+ // Agy (Antigravity) version gate: below 1.1.1 headless -p can't call MCP tools —
1476
+ // the task "runs" but complete_task never lands, burning every attempt. Same
1477
+ // remove-don't-flag rule as the gemini gate (matching never re-checks enabled).
1478
+ const tooOldAgy = new Set();
1479
+ for (const agent of cfg.agents.filter((a) => isAgyCommand(a.command?.[0]))) {
1480
+ const { version, tooOld } = await checkAgyVersion([agent.command[0]]);
1481
+ if (tooOld) {
1482
+ tooOldAgy.add(agent);
1483
+ console.error(
1484
+ `!! ${agent.name}: agy ${version} can't call MCP tools headlessly (fixed in ${AGY_MIN_VERSION}) — ` +
1485
+ `agent DISABLED for this run. Run \`agy update\` and restart.`,
1486
+ );
1487
+ } else if (!version) {
1488
+ log(`! ${agent.name}: couldn't read agy version — make sure it's >= ${AGY_MIN_VERSION} (headless MCP fix).`);
1489
+ }
1490
+ }
1491
+ if (tooOldAgy.size) cfg.agents = cfg.agents.filter((a) => !tooOldAgy.has(a));
1492
+
1493
+ // BRIDGE LOCAL: the loopback control API (desktop app buttons: connect agents,
1494
+ // connect a folder, doctor, restart). Started BEFORE the token check on purpose:
1495
+ // the whole point of the connect UI is to FIX a broken/missing connection, so its
1496
+ // control plane must be up even when the Cookbook token is bad. Additive — a bind
1497
+ // failure never stops the Bridge from doing its real job.
1498
+ try {
1499
+ let version = "dev";
1500
+ try {
1501
+ const { createHash } = await import("node:crypto");
1502
+ version = createHash("sha256").update(fs.readFileSync(path.join(HERE, "bridge.mjs"))).digest("hex").slice(0, 8);
1503
+ } catch { /* keep dev */ }
1504
+ localServer = createLocalServer({
1505
+ cfg,
1506
+ cfgPath: CONFIG_PATH,
1507
+ version,
1508
+ log,
1509
+ doctor: () => doctorReport(["--config", CONFIG_PATH]),
1510
+ detectAgents: () => detectAgentsForStatus(cfg),
1511
+ startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
1512
+ applyConfig: () => applyConfigFromDisk(cfg),
1513
+ restart: () => reexecSelf(),
1514
+ hotWorkspaceIds: () => hotWorkspaceIds(),
1515
+ connected: () => tokenOk && consecutive401s === 0,
1516
+ lastError: () => lastRunError,
1517
+ });
1518
+ await localServer.start();
1519
+ for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { try { localServer.stop(); } catch { /* exiting */ } });
1520
+ process.on("exit", () => { try { localServer.stop(); } catch { /* exiting */ } });
1521
+ } catch (e) {
1522
+ localServer = null;
1523
+ log(`! Bridge Local couldn't start (${e.message}) — the desktop app's buttons won't work this session; tasks still run.`);
1524
+ }
1525
+
1526
+ // Confirm the token works before looping. A bad token is fatal for a HEADLESS
1527
+ // (terminal) Bridge — exit with the fix. But when Bridge Local is up (the desktop
1528
+ // app), stay alive in a degraded state so the user can fix the connection from the
1529
+ // app (connect-agents rewrites the token, applyConfig reloads it, the poll recovers).
1530
+ try {
1531
+ const ws = await listWorkspaces(cfg);
1532
+ tokenOk = true;
1533
+ log(`Connected — watching ${ws.length} workspace(s).`);
1534
+ if (cfg.persistentThreads) {
1535
+ for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { killAllRunners(); killCodexServer(); process.exit(0); });
1536
+ process.on("exit", () => { killAllRunners(); killCodexServer(); });
1537
+ }
1538
+ } catch (e) {
1539
+ lastRunError = e.message;
1540
+ if (IS_DESKTOP && localServer) {
1541
+ log(`! Not connected to Cookbook yet: ${e.message}`);
1542
+ log(` Fix it in the app (Connect your agents), or run \`node bridge.mjs login\`. The control API stays up so you can.`);
1543
+ } else {
1544
+ console.error(`\nCouldn't connect to Cookbook: ${e.message}`);
1545
+ process.exit(1);
1546
+ }
1547
+ }
1548
+
1549
+ let lastFullSweepAt = 0;
1550
+ let fastPath = true; // one-call dispatch until the server says it can't
1551
+ void socketLoop(cfg); // push channel: sub-second dispatch when the server has it
1552
+ while (!stopped) {
1553
+ try {
1554
+ // FAST PATH: push channel first (sub-second), 1s one-call poll as the net,
1555
+ // full sweep (volunteering, skip/hold messaging, stale-claim rescue) as the
1556
+ // 30s janitor. Falls back gracefully at every layer.
1557
+ const hotIds = hotWorkspaceIds();
1558
+ const pushHealthy = sse.connected && Date.now() - sse.aliveAt < 30_000;
1559
+ if (fastPath) {
1560
+ const dueFullSweep = Date.now() - lastFullSweepAt >= Math.max(cfg.pollSeconds * 1000, 30_000);
1561
+ if (dueFullSweep) {
1562
+ lastFullSweepAt = Date.now();
1563
+ await pollOnce(cfg);
1564
+ } else if (pushHealthy) {
1565
+ // The socket carries dispatch; nothing to poll between janitor sweeps.
1566
+ } else {
1567
+ fastPath = await quickScan(cfg);
1568
+ if (!fastPath) log("· server predates list_open_work — dispatching via workspace sweeps instead");
1569
+ }
1570
+ } else {
1571
+ const fullEvery = hotIds.size > 0 ? Math.max(cfg.pollSeconds * 1000, 30_000) : cfg.pollSeconds * 1000;
1572
+ const dueFullSweep = Date.now() - lastFullSweepAt >= fullEvery;
1573
+ if (hotIds.size > 0 && !dueFullSweep) {
1574
+ await pollOnce(cfg, hotIds);
1575
+ } else {
1576
+ lastFullSweepAt = Date.now();
1577
+ await pollOnce(cfg);
1578
+ }
1579
+ }
1580
+ // A clean poll means the token is good — clear any prior rejection so the app's
1581
+ // /status flips back to connected once the user fixes it.
1582
+ if (consecutive401s || !tokenOk) { consecutive401s = 0; tokenOk = true; lastRunError = null; }
1583
+ } catch (e) {
1584
+ log(`! poll error: ${e.message}`);
1585
+ // A revoked/expired token would otherwise zombie-loop forever (re-running
1586
+ // `login`/`connect-agents` revokes the prior token by design). Exit loudly
1587
+ // with the actual fix instead of logging 401s every poll until the heat
1588
+ // death of the laptop.
1589
+ if (/401/.test(e.message)) {
1590
+ consecutive401s++;
1591
+ if (consecutive401s >= 5) {
1592
+ if (IS_DESKTOP && localServer) {
1593
+ // Desktop: keep the control API up so the user can fix the token from the
1594
+ // app. Back off to the slow cadence and stop spamming; recovers on fix.
1595
+ if (consecutive401s === 5) log("! Cookbook keeps rejecting this token — waiting. Fix it in the app (Connect your agents) or re-run login.");
1596
+ await new Promise((r) => setTimeout(r, Math.max(cfg.pollSeconds * 1000, 15_000)));
1597
+ } else {
1598
+ log("✗ Cookbook has rejected this token 5 polls in a row — it was likely revoked (a new login replaces old tokens) or expired.");
1599
+ log(" Fix: node bridge/bridge.mjs login (then restart the Bridge)");
1600
+ process.exit(1);
1601
+ }
1602
+ }
1603
+ }
1604
+ }
1605
+ if (Date.now() - lastUpdateCheck > UPDATE_CHECK_MS) {
1606
+ lastUpdateCheck = Date.now();
1607
+ await selfUpdate(cfg, { reexec: true });
1608
+ }
1609
+ // HOT MODE (chat feel): while a conversation is active (a run started or
1610
+ // finished in the last 3 minutes), poll every second so a reply dispatches
1611
+ // near-instantly; decay back to the configured cadence when the room quiets.
1612
+ if (cfg.persistentThreads) { reapIdleRunners(log); reapCodexServer(log); }
1613
+ const hot = Date.now() - lastHotAt < HOT_WINDOW_MS;
1614
+ await new Promise((r) => setTimeout(r, fastPath || hot ? 1000 : cfg.pollSeconds * 1000));
1615
+ }
1616
+ }
1617
+
1618
+ /**
1619
+ * Preflight check — the fix for the #1 onboarding problem (every prerequisite fails
1620
+ * silently or with a misleading error). Prints a ✓/✗ checklist and, for each ✗, the
1621
+ * EXACT command to fix it: Node version, config + token, token actually works, and per
1622
+ * agent: binary on PATH, CLI logged in (a real 1-token probe), allowedTools naming, and
1623
+ * the default agent installed. One screen instead of a multi-hour debug session.
1624
+ */
1625
+ async function runDoctor(args) {
1626
+ const report = await doctorReport(args);
1627
+ const { rows, fails, warns } = report;
1628
+ if (args.includes("--json")) {
1629
+ console.log(JSON.stringify(report));
1630
+ process.exit(fails === 0 ? 0 : 1);
1631
+ }
1632
+ const C = { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", x: "\x1b[0m" };
1633
+ const lines = rows.map((r) =>
1634
+ r.level === "ok" ? ` ${C.g}✓${C.x} ${r.label}`
1635
+ : r.level === "bad" ? ` ${C.r}✗${C.x} ${r.label}${r.fix ? `\n ${C.r}↳ fix:${C.x} ${r.fix}` : ""}`
1636
+ : ` ${C.y}!${C.x} ${r.label}${r.fix ? `\n ↳ ${r.fix}` : ""}`,
1637
+ );
1638
+ console.log(`\nCookbook Bridge — doctor\n`);
1639
+ console.log(lines.join("\n"));
1640
+ const notes = warns ? ` ${C.y}(${warns} warning${warns > 1 ? "s" : ""} above worth a look)${C.x}` : "";
1641
+ console.log(
1642
+ fails === 0
1643
+ ? `\n${C.g}No blockers — the Bridge should run tasks end-to-end.${C.x}${notes}\n`
1644
+ : `\n${C.r}${fails} problem(s) above must be fixed before the Bridge can run tasks.${C.x}${notes}\n`,
1645
+ );
1646
+ process.exit(fails === 0 ? 0 : 1);
1647
+ }
1648
+
1649
+ /** The doctor's checks as data: { cfgPath, rows: [{ level, label, fix }], fails, warns }.
1650
+ * Shared by the CLI (`doctor`, `doctor --json`) and Bridge Local's POST /doctor. */
1651
+ async function doctorReport(args) {
1652
+ await loadRuntime();
1653
+ const rows = [];
1654
+ let fails = 0;
1655
+ let warns = 0;
1656
+ const ok = (s) => rows.push({ level: "ok", label: s });
1657
+ const bad = (s, fix) => { fails++; rows.push({ level: "bad", label: s, ...(fix ? { fix } : {}) }); };
1658
+ const warn = (s, note) => { warns++; rows.push({ level: "warn", label: s, ...(note ? { fix: note } : {}) }); };
1659
+
1660
+ // 1. Node version
1661
+ const major = Number(process.versions.node.split(".")[0]);
1662
+ if (major >= 18) ok(`Node ${process.version}`);
1663
+ else bad(`Node ${process.version} is too old (need ≥18)`, "install Node 18+ (e.g. `brew install node`)");
1664
+
1665
+ // 2. Config present + filled in
1666
+ const cfgPath = configPathFromArgs(args, path.join(HERE, "config.json"));
1667
+ let cfg = null;
1668
+ if (!fs.existsSync(cfgPath)) {
1669
+ bad(`No config at ${cfgPath}`, "run `node bridge/bridge.mjs login` (one-click connect) — or copy config.example.json → config.json and add a token");
1670
+ } else {
1671
+ try {
1672
+ cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
1673
+ cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
1674
+ cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
1675
+ if (!cfg.cookbookUrl || !cfg.token || String(cfg.token).startsWith("PASTE")) {
1676
+ bad("Config is missing cookbookUrl or a real token", "set both in config.json (token from your Cookbook → Tokens page)");
1677
+ cfg = null;
1678
+ } else {
1679
+ ok(`Config ${cfgPath}`);
1680
+ }
1681
+ } catch (e) {
1682
+ bad(`Config isn't valid JSON: ${e.message}`);
1683
+ cfg = null;
1684
+ }
1685
+ }
1686
+
1687
+ if (cfg) {
1688
+ ensureAgentPath();
1689
+
1690
+ // 3. Token actually works
1691
+ try {
1692
+ const ws = await listWorkspaces(cfg);
1693
+ ok(`Cookbook token works — ${ws.length} workspace(s) visible`);
1694
+ } catch (e) {
1695
+ bad(`Cookbook token rejected: ${e.message}`, "regenerate a token on your Cookbook → Tokens page and update config.json");
1696
+ }
1697
+
1698
+ // 4. Default agent is one of the enabled agents
1699
+ if (cfg.default && !cfg.agents.some((a) => a.name === cfg.default)) {
1700
+ bad(`default agent "${cfg.default}" isn't in your enabled agents`,
1701
+ `set "default" to one of: ${cfg.agents.map((a) => a.name).join(", ") || "(none enabled)"}`);
1702
+ }
1703
+
1704
+ if (!cfg.agents.length) warn("No agents enabled in config — nothing will run.");
1705
+
1706
+ // 5. Per agent: binary, allowedTools sanity, and a real login probe
1707
+ for (const agent of cfg.agents) {
1708
+ const cmd = Array.isArray(agent.command) ? agent.command[0] : null;
1709
+ const bin = resolveBin(cmd);
1710
+ if (!bin) {
1711
+ bad(`${agent.name}: binary \`${cmd}\` not found on PATH`,
1712
+ `install it, or put an absolute path in config (find it with \`which ${cmd}\`)`);
1713
+ continue;
1714
+ }
1715
+ ok(`${agent.name}: binary at ${bin}`);
1716
+
1717
+ // Gemini RCE gate (CVSS 10.0, fixed in 0.39.1): a confirmed-old version is a
1718
+ // hard failure — the Bridge will refuse to run this agent.
1719
+ if (isGeminiCommand(cmd)) {
1720
+ const { version, vulnerable } = await checkGeminiVersion([bin]);
1721
+ if (vulnerable) {
1722
+ bad(`${agent.name}: gemini-cli ${version} has a critical prompt-injection RCE (CVSS 10.0)`,
1723
+ `update to ≥ ${GEMINI_MIN_VERSION}: npm i -g @google/gemini-cli@latest`);
1724
+ } else if (version) {
1725
+ ok(`${agent.name}: gemini-cli ${version} (≥ ${GEMINI_MIN_VERSION}, RCE-safe)`);
1726
+ } else {
1727
+ warn(`${agent.name}: couldn't read gemini version`, `make sure it's ≥ ${GEMINI_MIN_VERSION} (RCE fix)`);
1728
+ }
1729
+ }
1730
+
1731
+ // Agy (Antigravity) checks: version >= 1.1.1 (headless-MCP fix), the MCP
1732
+ // config file (agy has no `mcp add` — connect-agents writes it), and login.
1733
+ if (isAgyCommand(cmd)) {
1734
+ const { version, tooOld } = await checkAgyVersion([bin]);
1735
+ if (tooOld) {
1736
+ bad(`${agent.name}: agy ${version} can't call MCP tools headlessly (fixed in ${AGY_MIN_VERSION})`,
1737
+ "run `agy update` — below 1.1.1 tasks run but never complete");
1738
+ } else if (version) {
1739
+ ok(`${agent.name}: agy ${version} (>= ${AGY_MIN_VERSION}, headless MCP works)`);
1740
+ } else {
1741
+ warn(`${agent.name}: couldn't read agy version`, `make sure it's >= ${AGY_MIN_VERSION} (headless MCP fix)`);
1742
+ }
1743
+ const agyMcpPath = path.join(process.env.HOME || "", ".gemini", "config", "mcp_config.json");
1744
+ try {
1745
+ const mc = JSON.parse(fs.readFileSync(agyMcpPath, "utf8"));
1746
+ const srv = mc?.mcpServers?.cookbook;
1747
+ if (srv?.serverUrl === `${cfg.cookbookUrl}/api/mcp` && srv?.headers?.Authorization) {
1748
+ ok(`${agent.name}: Cookbook MCP configured (${agyMcpPath})`);
1749
+ } else if (srv) {
1750
+ warn(`${agent.name}: MCP config points at ${srv.serverUrl || "(no url)"}`,
1751
+ `expected ${cfg.cookbookUrl}/api/mcp — re-run \`node bridge/bridge.mjs connect-agents\``);
1752
+ } else {
1753
+ bad(`${agent.name}: no 'cookbook' server in ${agyMcpPath}`,
1754
+ "run `node bridge/bridge.mjs connect-agents` (agy has no `mcp add`; the Bridge writes this file)");
1755
+ }
1756
+ } catch {
1757
+ bad(`${agent.name}: no agy MCP config at ${agyMcpPath}`,
1758
+ "run `node bridge/bridge.mjs connect-agents` (agy has no `mcp add`; the Bridge writes this file)");
1759
+ }
1760
+ if (!fs.existsSync(path.join(process.env.HOME || "", ".gemini", "oauth_creds.json"))) {
1761
+ warn(`${agent.name}: no Google login found (~/.gemini/oauth_creds.json)`,
1762
+ "run `agy` once interactively and sign in with Google");
1763
+ }
1764
+ }
1765
+
1766
+ if ((agent.command || []).join(" ").includes("mcp__claude_ai_Cookbook__")) {
1767
+ warn(`${agent.name}: allowedTools uses mcp__claude_ai_Cookbook__* — a CLI-added server is usually mcp__cookbook__*`,
1768
+ "if tasks 'run but never complete', switch allowedTools to mcp__cookbook__*");
1769
+ }
1770
+
1771
+ if (agent.runner === "app-server") { warn(`${agent.name}: app-server runner — start it to verify login/MCP (probe skipped)`); continue; }
1772
+ if (agent.runner === "robot") {
1773
+ const r = await spawnAgent(agent, "", 15, agentEnv(cfg).env);
1774
+ if (r.code === 0 && String(r.out).trim().endsWith("ok")) ok(`${agent.name}: robot agent responds (probe ok)`);
1775
+ else warn(`${agent.name}: robot agent probe inconclusive`, String(r.err || r.out).slice(0, 160));
1776
+ continue;
1777
+ }
1778
+
1779
+ try {
1780
+ const r = await spawnAgent(agent, "Reply with the single word: ok", 30, agentEnv(cfg).env);
1781
+ const text = `${r.out || ""}\n${r.err || ""}`.toLowerCase();
1782
+ if (text.includes("not logged in") || text.includes("/login") || text.includes("please log in")) {
1783
+ bad(`${agent.name}: CLI is NOT logged in`, `run \`${cmd} auth login\` (persists; setup-token does not)`);
1784
+ } else if (text.includes("no mcp") || text.includes("mcp server")) {
1785
+ bad(`${agent.name}: no Cookbook MCP connected`,
1786
+ `${cmd} mcp add --scope user --transport http cookbook ${cfg.cookbookUrl}/api/mcp --header "Authorization: Bearer <token>"`);
1787
+ } else if (r.code === 0) {
1788
+ ok(`${agent.name}: CLI responds (logged in)`);
1789
+ } else {
1790
+ const probeTail = `${r.err || r.out || ""}`.trim().split("\n").slice(-2).join(" ").slice(0, 200);
1791
+ // Account/tier lockouts (gemini's IneligibleTierError, quota exhaustion,
1792
+ // auth expiry) are REAL blockers, not inconclusive noise — a CLI that
1793
+ // starts but can't serve burns every attempt at runtime (2026-07-03).
1794
+ if (/ineligible|no longer supported|quota exceeded|not authenticated|login required|migrate to/i.test(probeTail)) {
1795
+ bad(`${agent.name}: the CLI refuses this account/tier — ${probeTail}`, "fix the account (or remove the agent from config.json — removal, not enabled:false)");
1796
+ } else {
1797
+ warn(`${agent.name}: probe exited ${r.code} — inconclusive`, probeTail);
1798
+ }
1799
+ }
1800
+ } catch (e) {
1801
+ warn(`${agent.name}: probe couldn't run (${e.message}) — inconclusive`);
1802
+ }
1803
+ }
1804
+ }
1805
+
1806
+ return { cfgPath, rows, fails, warns };
1807
+ }
1808
+
1809
+ // Dispatch ONLY when bridge.mjs is the entry script. Without this guard, any
1810
+ // test/tool that IMPORTS this module (for streamingCommand etc.) fell through
1811
+ // to main() and started a REAL polling Bridge on the importer's machine.
1812
+ const IS_MAIN = import.meta.main === true;
1813
+ const sub = process.argv[2];
1814
+ if (!IS_MAIN) {
1815
+ /* library import — export-only, no dispatch */
1816
+ } else if (sub === "doctor") {
1817
+ runDoctor(process.argv.slice(3)).catch((e) => {
1818
+ console.error(e.message);
1819
+ process.exit(1);
1820
+ });
1821
+ } else if (sub === "login") {
1822
+ import("./device.mjs")
1823
+ .then((m) => m.login(process.argv.slice(3)))
1824
+ .catch((e) => {
1825
+ console.error(e.message);
1826
+ process.exit(1);
1827
+ });
1828
+ } else if (sub === "connectors") {
1829
+ // Connect a tool once, every agent has it: survey/sync MCP connectors across
1830
+ // Claude, Codex, and Gemini (three files, three schemas, silent drift).
1831
+ import("./connectors.mjs")
1832
+ .then(async (m) => {
1833
+ const args = process.argv.slice(3);
1834
+ const doSync = args.includes("sync");
1835
+ const dryRun = args.includes("--dry-run");
1836
+ const rows = m.survey();
1837
+ const vendors = Object.keys(m.VENDORS);
1838
+ if (rows.length === 0) return console.log("No MCP connectors found for Claude, Codex, or Gemini.");
1839
+ console.log("\n " + "connector".padEnd(22) + vendors.map((v) => m.VENDORS[v].label.padEnd(9)).join(""));
1840
+ for (const r of rows) {
1841
+ const cells = vendors.map((v) => (r.present[v] ? " ✓ " : " · ")).join("");
1842
+ console.log(" " + r.name.padEnd(22) + cells + (r.drift ? "⚠ drift" : ""));
1843
+ }
1844
+ for (const r of rows.filter((x) => x.drift)) {
1845
+ console.log(`\n ⚠ "${r.name}" points somewhere different per vendor:`);
1846
+ for (const t of r.targets) console.log(` ${t}`);
1847
+ }
1848
+ if (!doSync) {
1849
+ const missing = rows.filter((r) => vendors.some((v) => !r.present[v])).length;
1850
+ console.log(`\n ${missing} connector(s) aren't on every agent.`);
1851
+ console.log(" Run `node bridge/bridge.mjs connectors sync` to give every agent the same tools.\n");
1852
+ return;
1853
+ }
1854
+ const only = args.filter((a) => !a.startsWith("--") && a !== "sync");
1855
+ const actions = m.sync({ only: only.length ? only : null, dryRun });
1856
+ if (actions.length === 0) return console.log("\n Already in sync — every agent has the same connectors.\n");
1857
+ console.log(`\n ${dryRun ? "Would apply" : "Applied"} ${actions.length} change(s):`);
1858
+ for (const a of actions) console.log(` ${a.action === "add" ? "+" : "~"} ${m.VENDORS[a.vendor].label.padEnd(7)} ${a.name}`);
1859
+ if (!dryRun) console.log("\n Backups written next to each config (.bak-<timestamp>). Restart your agents to pick them up.\n");
1860
+ else console.log("\n (dry run — nothing written)\n");
1861
+ })
1862
+ .catch((e) => {
1863
+ console.error(e.message);
1864
+ process.exit(1);
1865
+ });
1866
+ } else if (sub === "connect" || sub === "connect-agents") {
1867
+ // `connect` is the documented first command (the connect page and the npm bin both
1868
+ // say it); `connect-agents` is the original name, kept working forever.
1869
+ import("./device.mjs")
1870
+ .then((m) => m.connectAgents(process.argv.slice(3)))
1871
+ .catch((e) => {
1872
+ console.error(e.message);
1873
+ process.exit(1);
1874
+ });
1875
+ } else if (sub === "status") {
1876
+ import("./device.mjs")
1877
+ .then((m) => m.status(process.argv.slice(3)))
1878
+ .catch((e) => {
1879
+ console.error(e.message);
1880
+ process.exit(1);
1881
+ });
1882
+ } else if (sub === "update") {
1883
+ // One-shot: check the deploy manifest, verify + apply, report. Exit 0 either way
1884
+ // unless the apply itself failed.
1885
+ (async () => {
1886
+ // Deliberately import ONLY update.mjs — this path must work from a broken install.
1887
+ // Config is read minimally here (no loadConfig: that treats argv[2] as a path and
1888
+ // demands a token — updates need only the cookbookUrl, both endpoints are public).
1889
+ const upd = await import("./update.mjs");
1890
+ const cfgPath = configPathFromArgs(process.argv.slice(3), path.join(HERE, "config.json"));
1891
+ let cookbookUrl = "";
1892
+ try {
1893
+ cookbookUrl = String(JSON.parse(fs.readFileSync(cfgPath, "utf8")).cookbookUrl || "").replace(/\/$/, "");
1894
+ } catch { /* fall through to the error below */ }
1895
+ if (!cookbookUrl) {
1896
+ console.error(`Can't read cookbookUrl from ${cfgPath} — pass --config <path> or fix config.json.`);
1897
+ process.exit(1);
1898
+ }
1899
+ const cfg = { cookbookUrl };
1900
+ const check = await upd.checkForUpdate(cfg, HERE);
1901
+ if (check.changed.length === 0) {
1902
+ console.log(`Up to date with the app deploy (${check.version}).`);
1903
+ return;
1904
+ }
1905
+ console.log(`Update available (deploy ${check.version}) — ${check.changed.length} file(s): ${check.changed.join(", ")}`);
1906
+ const replaced = await upd.applyUpdate(cfg, HERE, check);
1907
+ console.log(`Updated ${replaced.length} file(s), hash-verified. Previous version in bridge.backup/${check.version}/. Restart the Bridge to run the new code.`);
1908
+ })().catch((e) => {
1909
+ console.error(`Update failed safely (nothing partially applied): ${e.message}`);
1910
+ process.exit(1);
1911
+ });
1912
+ } else {
1913
+ main();
1914
+ }