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/LICENSE +21 -0
- package/README.md +156 -0
- package/bridge.mjs +1914 -0
- package/chat.mjs +0 -0
- package/codex-runner.mjs +241 -0
- package/config.example.json +83 -0
- package/connectors.mjs +157 -0
- package/cookbook.mjs +234 -0
- package/device.mjs +509 -0
- package/harden.mjs +117 -0
- package/local.mjs +388 -0
- package/package.json +57 -0
- package/prompt.mjs +232 -0
- package/robot-runner.mjs +55 -0
- package/thread-runner.mjs +221 -0
- package/update.mjs +151 -0
- package/usage.mjs +147 -0
- package/volunteer.mjs +130 -0
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
|
+
}
|