cookbook-bridge 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/bridge.mjs +147 -19
- package/codex-runner.mjs +23 -4
- package/config.example.json +2 -5
- package/cookbook.mjs +15 -3
- package/device.mjs +11 -15
- package/hands.mjs +78 -11
- package/local.mjs +3 -2
- package/package.json +2 -1
- package/plan.mjs +125 -0
- package/thread-runner.mjs +16 -4
package/README.md
CHANGED
|
@@ -19,13 +19,16 @@ Trust model: your Cookbook's `/security` page.
|
|
|
19
19
|
## Quick start (2 minutes)
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npx cookbook-bridge connect # one-time: ONE approval connects the Bridge AND every
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
npx cookbook-bridge doctor # preflight: checks every prerequisite, with exact fixes
|
|
26
|
-
npx cookbook-bridge # run it (leave it running)
|
|
22
|
+
npx cookbook-bridge@latest connect # one-time: ONE approval connects the Bridge AND every
|
|
23
|
+
# installed agent CLI (claude, codex, agy, openclaw),
|
|
24
|
+
# each with its own attributed token
|
|
25
|
+
npx cookbook-bridge@latest doctor # preflight: checks every prerequisite, with exact fixes
|
|
26
|
+
npx cookbook-bridge@latest # run it (leave it running)
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
Always `@latest`: bare `npx cookbook-bridge` happily runs a weeks-old cached copy
|
|
30
|
+
that predates subcommands you need (`host` shipped in 0.1.1).
|
|
31
|
+
|
|
29
32
|
Node 18+. No dependencies, nothing to configure by hand: `connect` writes
|
|
30
33
|
`config.json` for you and never prints or stores a secret you have to copy.
|
|
31
34
|
|
|
@@ -154,7 +157,7 @@ Setting up agents is the one thing you can't ask your agents to do, because they
|
|
|
154
157
|
what's broken. So a **hardware grant** lets someone else's agent do it:
|
|
155
158
|
|
|
156
159
|
```bash
|
|
157
|
-
npx cookbook-bridge host # open the door (Node + one browser approval; no agents needed)
|
|
160
|
+
npx cookbook-bridge@latest host # open the door (Node + one browser approval; no agents needed)
|
|
158
161
|
```
|
|
159
162
|
|
|
160
163
|
Then invite one from Cookbook. What that actually means:
|
package/bridge.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import path from "node:path";
|
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { spawn } from "node:child_process";
|
|
33
33
|
import { callsFromStreamLine, foldCallEvent, wireCalls } from "./live.mjs";
|
|
34
|
+
import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
|
|
34
35
|
|
|
35
36
|
// LOCAL modules load LAZILY (loadRuntime below), not statically: a Bridge with a
|
|
36
37
|
// missing/corrupt module file must still be able to run `node bridge.mjs update` and
|
|
@@ -47,14 +48,14 @@ let hasCodexThread, reapCodexServer, killCodexServer;
|
|
|
47
48
|
let checkForUpdate, applyUpdate;
|
|
48
49
|
let createLocalServer, toolsForMode, modeForTools, vendorOf;
|
|
49
50
|
let connectAgentsProgrammatic, detectClis;
|
|
50
|
-
let serveCalls, describeCall, hostingMode;
|
|
51
|
+
let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn;
|
|
51
52
|
let fetchHands, claimHandsCall, reportHandsResult;
|
|
52
53
|
|
|
53
54
|
async function loadRuntime() {
|
|
54
55
|
({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
|
|
55
56
|
({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
|
|
56
57
|
({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
|
|
57
|
-
({ serveCalls, describeCall, hostingMode } = await import("./hands.mjs"));
|
|
58
|
+
({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn } = await import("./hands.mjs"));
|
|
58
59
|
({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand } = await import("./harden.mjs"));
|
|
59
60
|
({ extractUsage, displayText } = await import("./usage.mjs"));
|
|
60
61
|
({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
|
|
@@ -90,7 +91,7 @@ function log(msg) {
|
|
|
90
91
|
* find_node() trap fix (bridge.rs), applied to the binaries the Bridge itself spawns.
|
|
91
92
|
*/
|
|
92
93
|
function ensureAgentPath() {
|
|
93
|
-
const home = process.env.HOME || "";
|
|
94
|
+
const home = process.env.HOME || os.homedir() || ""; // Windows has no HOME
|
|
94
95
|
const dirs = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
|
95
96
|
if (home) {
|
|
96
97
|
dirs.push(path.join(home, ".claude/local")); // claude CLI local install
|
|
@@ -114,7 +115,7 @@ function ensureAgentPath() {
|
|
|
114
115
|
* not found — used by `doctor` and the default-agent startup warning. */
|
|
115
116
|
function resolveBin(cmd) {
|
|
116
117
|
if (!cmd) return null;
|
|
117
|
-
if (cmd
|
|
118
|
+
if (/[\\/]/.test(cmd)) { // a path on either platform
|
|
118
119
|
try { fs.accessSync(cmd, fs.constants.X_OK); return cmd; } catch { return null; }
|
|
119
120
|
}
|
|
120
121
|
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
@@ -175,7 +176,7 @@ function loadConfig() {
|
|
|
175
176
|
cfg.livenessTimeoutSeconds = cfg.livenessTimeoutSeconds ?? 300;
|
|
176
177
|
// Parallel slots: how many task runs may be in flight at once (audit: serial
|
|
177
178
|
// execution let one long run block every workspace's queue).
|
|
178
|
-
cfg.maxConcurrentRuns = Math.max(1, cfg.maxConcurrentRuns ??
|
|
179
|
+
cfg.maxConcurrentRuns = Math.max(1, cfg.maxConcurrentRuns ?? 3); // a Lead + its builders + a reviewer need three
|
|
179
180
|
cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
|
|
180
181
|
for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
|
|
181
182
|
ensureAgentPath(); // so bare `claude`/`gemini` commands resolve under the app's minimal PATH
|
|
@@ -317,6 +318,25 @@ export function foldStreamLine(line, acc) {
|
|
|
317
318
|
};
|
|
318
319
|
}
|
|
319
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The error a claude result envelope carries, or null when the run produced a real
|
|
323
|
+
* answer. `{"type":"result","subtype":"error_during_execution","is_error":true,
|
|
324
|
+
* "num_turns":0,…,"errors":["No conversation found with session ID: …"]}` is what a
|
|
325
|
+
* failed --resume looks like; the process still exits 0.
|
|
326
|
+
*/
|
|
327
|
+
export function resultError(out) {
|
|
328
|
+
const raw = String(out ?? "");
|
|
329
|
+
const brace = raw.indexOf("{");
|
|
330
|
+
if (brace < 0) return null;
|
|
331
|
+
let j;
|
|
332
|
+
try { j = JSON.parse(raw.slice(brace)); } catch { return null; }
|
|
333
|
+
if (!j || j.type !== "result") return null;
|
|
334
|
+
const errored = j.is_error === true || /^error/i.test(String(j.subtype ?? ""));
|
|
335
|
+
if (!errored) return null;
|
|
336
|
+
const errs = Array.isArray(j.errors) ? j.errors.filter((e) => typeof e === "string" && e).join("; ") : "";
|
|
337
|
+
return errs || String(j.subtype || "error");
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
/** Pull the CLI's session identity off any stream line (claude stream-json carries
|
|
321
341
|
* `session_id` on the init message AND the final result). Null when absent. */
|
|
322
342
|
export function sessionIdFrom(line) {
|
|
@@ -345,6 +365,26 @@ export function shouldKill({ streaming, startedAt, lastActivityAt, now, liveness
|
|
|
345
365
|
* failure fed back as the next message) instead of re-paying for a blank-context
|
|
346
366
|
* redo. Claude only; other CLIs fall back to a fresh run with the error fed
|
|
347
367
|
* forward in the prompt. Pure for tests. */
|
|
368
|
+
/**
|
|
369
|
+
* MODEL PER TASK (0079): a crew node can name the model its role runs on; the task
|
|
370
|
+
* carries it and the Bridge applies it here. Claude: `--model <id>` replaces any
|
|
371
|
+
* configured one. Codex runs through the app-server (thread/start.model — see
|
|
372
|
+
* codex-runner). Other CLIs: unchanged (their flags differ; the agent's config wins).
|
|
373
|
+
*/
|
|
374
|
+
export function withModel(command, model) {
|
|
375
|
+
if (!Array.isArray(command) || !model) return command;
|
|
376
|
+
const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
377
|
+
if (base !== "claude") return command;
|
|
378
|
+
const out = [];
|
|
379
|
+
for (let i = 0; i < command.length; i++) {
|
|
380
|
+
if (command[i] === "--model") { i++; continue; }
|
|
381
|
+
if (String(command[i]).startsWith("--model=")) continue;
|
|
382
|
+
out.push(command[i]);
|
|
383
|
+
}
|
|
384
|
+
out.push("--model", model);
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
348
388
|
export function resumeCommand(command, sessionId) {
|
|
349
389
|
if (!Array.isArray(command) || !sessionId) return { command, resumed: false };
|
|
350
390
|
const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
@@ -365,7 +405,11 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
365
405
|
const { command, streaming } = onProgress ? streamingCommand(baseCommand) : { command: baseCommand, streaming: false };
|
|
366
406
|
const [cmd, ...rawArgs] = command;
|
|
367
407
|
const args = rawArgs.map((a) => a.replaceAll("{prompt}", prompt));
|
|
368
|
-
|
|
408
|
+
// A bare name that resolves to a .cmd/.bat shim (npm-installed CLIs on Windows)
|
|
409
|
+
// needs PATHEXT resolution and cmd.exe — argvForSpawn is identity elsewhere.
|
|
410
|
+
const bare = cmd.includes("/") || cmd.includes("\\") ? cmd : (whichExec?.(cmd) ?? cmd);
|
|
411
|
+
const wrapped = argvForSpawn ? argvForSpawn([bare, ...args]) : [bare, ...args];
|
|
412
|
+
const child = spawn(wrapped[0], wrapped.slice(1), {
|
|
369
413
|
stdio: ["ignore", "pipe", "pipe"],
|
|
370
414
|
env: env ?? process.env,
|
|
371
415
|
// Local-access runs execute IN the mapped folder (terminal parity).
|
|
@@ -405,6 +449,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
405
449
|
let sessionId = null;
|
|
406
450
|
const startedAt = Date.now();
|
|
407
451
|
let lastActivityAt = startedAt;
|
|
452
|
+
try { opts.onChild?.(child); } catch { /* registration is best-effort */ }
|
|
408
453
|
child.stdout.on("data", (d) => {
|
|
409
454
|
lastActivityAt = Date.now();
|
|
410
455
|
if (!streaming) { out += d; return; }
|
|
@@ -427,6 +472,10 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
427
472
|
// text throttle (still ≥300ms apart so a burst of reads is one tick).
|
|
428
473
|
let touched = false;
|
|
429
474
|
for (const ev of callsFromStreamLine(line)) { calls = foldCallEvent(calls, ev); touched = true; }
|
|
475
|
+
// The CLI also says where the member's plan stands (claude: rate_limit_event).
|
|
476
|
+
// Remembered per vendor; the next heartbeat carries it (bridge/plan.mjs).
|
|
477
|
+
const plan = planFromStreamLine(line);
|
|
478
|
+
if (plan && notePlan(plan)) log(` ↳ plan ${planLine(plan.vendor, plan)}`);
|
|
430
479
|
const r = foldStreamLine(line, acc);
|
|
431
480
|
acc = r.acc;
|
|
432
481
|
if (r.resultLine) resultLine = r.resultLine;
|
|
@@ -479,12 +528,23 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
479
528
|
*/
|
|
480
529
|
async function runAgent(cfg, agent, prompt, onProgress, retry = null, taskCtx = {}) {
|
|
481
530
|
const { env } = agentEnv(cfg); // billing protection (see harden.mjs)
|
|
531
|
+
const model = taskCtx.task?.model || null;
|
|
532
|
+
if (model) agent = { ...agent, command: withModel(agent.command, model), model };
|
|
482
533
|
if (agent.runner === "app-server") {
|
|
483
534
|
const { runCodexTask } = await import("./codex-runner.mjs");
|
|
484
535
|
// Persistent codex server + thread mapping (v2): threadKey gives follow-ups
|
|
485
536
|
// real conversation continuity; onProgress streams agent-message deltas.
|
|
486
537
|
const threadKey = taskCtx.task ? taskCtx.task.thread_root_id ?? taskCtx.task.id : undefined;
|
|
487
|
-
|
|
538
|
+
// Codex reaches Cookbook ONLY through COOKBOOK_CODEX_TOKEN (~/.codex-bridge's
|
|
539
|
+
// mcp server has no login of its own). No per-agent token → fall back to the
|
|
540
|
+
// Bridge token rather than run blind: a Codex that can't read the workspace
|
|
541
|
+
// reviews summaries, not files. connect-agents mints the attributed one.
|
|
542
|
+
const codexToken = agent.token || cfg.codexToken || cfg.token;
|
|
543
|
+
if (!agent.token && !cfg.codexToken && !warnedCodexToken) {
|
|
544
|
+
warnedCodexToken = true;
|
|
545
|
+
log("! Codex has no agent token — running under your Bridge token. Run `cookbook-bridge connect-agents` so its work reads \"Codex · via you\".");
|
|
546
|
+
}
|
|
547
|
+
return runCodexTask(agent, prompt, cfg.taskTimeoutSeconds, codexToken, env, onProgress, { threadKey, log, model });
|
|
488
548
|
}
|
|
489
549
|
if (agent.runner === "openclaw") {
|
|
490
550
|
// The visiting-agent lane: one Gateway-backed turn, resumed by session id so a
|
|
@@ -512,6 +572,7 @@ async function runAgent(cfg, agent, prompt, onProgress, retry = null, taskCtx =
|
|
|
512
572
|
command,
|
|
513
573
|
resumed,
|
|
514
574
|
livenessSeconds: cfg.livenessTimeoutSeconds,
|
|
575
|
+
onChild: taskCtx.onChild,
|
|
515
576
|
});
|
|
516
577
|
}
|
|
517
578
|
|
|
@@ -604,7 +665,50 @@ const retryCtx = new Map(); // taskId -> { sessionId, reason }
|
|
|
604
665
|
// retries from this shelf instead. In-memory: a restart still strands the claim
|
|
605
666
|
// (visible on the board, cancel by hand) — deliberate v1 legibility over churn.
|
|
606
667
|
const volunteeredRetries = [];
|
|
668
|
+
let warnedCodexToken = false;
|
|
669
|
+
const BOOT_MS = Date.now();
|
|
607
670
|
const inFlight = new Set();
|
|
671
|
+
const inFlightWs = new Map(); // task id → workspace id, for release on shutdown
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* GRACEFUL STOP (2026-08-29): hand every in-flight task back to the board before
|
|
675
|
+
* dying (release_task: open, claim cleared, progress kept). Without this an update,
|
|
676
|
+
* a sleep, or a restart strands the run as `claimed` for an hour. Budgeted —
|
|
677
|
+
* launchd gives us seconds, not minutes.
|
|
678
|
+
*/
|
|
679
|
+
let releasing = null;
|
|
680
|
+
async function releaseInFlight(cfg, why) {
|
|
681
|
+
if (releasing) return releasing;
|
|
682
|
+
const ids = [...inFlight];
|
|
683
|
+
if (!ids.length) return Promise.resolve();
|
|
684
|
+
log(`⏏ ${why}: releasing ${ids.length} in-flight task(s) back to the board`);
|
|
685
|
+
releasing = Promise.race([
|
|
686
|
+
Promise.allSettled(ids.map(async (id) => {
|
|
687
|
+
try {
|
|
688
|
+
const { callTool } = await import("./cookbook.mjs");
|
|
689
|
+
await callTool(cfg, "release_task", { workspace_id: inFlightWs.get(id), task_id: id });
|
|
690
|
+
log(` ↳ released ${id.slice(0, 8)}`);
|
|
691
|
+
} catch (e) { log(` ↳ couldn't release ${id.slice(0, 8)}: ${e.message}`); }
|
|
692
|
+
})),
|
|
693
|
+
new Promise((r) => setTimeout(r, 4000)),
|
|
694
|
+
]);
|
|
695
|
+
return releasing;
|
|
696
|
+
}
|
|
697
|
+
// STOP (the Room, 2026-08-29): task id → a function that kills its run. Filled while
|
|
698
|
+
// a run is in flight; the push channel's `stops` list calls it. A stopped task is
|
|
699
|
+
// remembered so the failure path doesn't shelve it for retry.
|
|
700
|
+
const killers = new Map();
|
|
701
|
+
const stoppedRuns = new Set();
|
|
702
|
+
function stopRuns(ids) {
|
|
703
|
+
for (const id of ids ?? []) {
|
|
704
|
+
const kill = killers.get(id);
|
|
705
|
+
if (!kill) continue;
|
|
706
|
+
stoppedRuns.add(id);
|
|
707
|
+
try { kill(); } catch { /* already gone */ }
|
|
708
|
+
killers.delete(id);
|
|
709
|
+
log(`⏹ stopped "${id.slice(0, 8)}" — cancelled in Cookbook`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
608
712
|
|
|
609
713
|
// HOT MODE: while a conversation is active, the poll loop runs at 1s AND scopes
|
|
610
714
|
// its sweep to the hot workspace(s) so replies dispatch near-instantly; markHot()
|
|
@@ -845,6 +949,7 @@ async function considerVolunteering(cfg, ws, task, budget) {
|
|
|
845
949
|
|
|
846
950
|
async function processTask(cfg, ws, task, agent) {
|
|
847
951
|
inFlight.add(task.id);
|
|
952
|
+
inFlightWs.set(task.id, ws.id);
|
|
848
953
|
// PRE-CLAIM (Phase 0, audit #1): a dispatched task must be OURS before we spend
|
|
849
954
|
// quota on it. Without this, a to:'any' task — or the same member's Bridge on a
|
|
850
955
|
// second machine — ran N times and the losers found out at the 409 after paying
|
|
@@ -950,7 +1055,7 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
950
1055
|
}
|
|
951
1056
|
let thread = null;
|
|
952
1057
|
if (task.thread_root_id && !warmRunner) {
|
|
953
|
-
thread = await threadResumeContext(cfg, ws.id, task.thread_root_id).catch(() => null);
|
|
1058
|
+
thread = await threadResumeContext(cfg, ws.id, task.thread_root_id, myProfileId).catch(() => null);
|
|
954
1059
|
if (thread?.root?.status === "cancelled") {
|
|
955
1060
|
inFlight.delete(task.id);
|
|
956
1061
|
givenUp.add(task.id);
|
|
@@ -998,14 +1103,21 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
998
1103
|
// one-shot spawn below — the runner is an accelerator, never a dependency.
|
|
999
1104
|
try {
|
|
1000
1105
|
const live = cfg.liveTokens !== false && agent.liveTokens !== false;
|
|
1001
|
-
const
|
|
1106
|
+
const taskModel = task.model || null;
|
|
1107
|
+
const modelAgent = taskModel ? { ...agent, command: withModel(agent.command, taskModel), model: taskModel } : agent;
|
|
1108
|
+
const usable = warmRunner && (warmRunner.model ?? null) === taskModel ? warmRunner : null;
|
|
1109
|
+
if (warmRunner && !usable) log(` ↳ warm runner is on another model — starting one on ${taskModel}`);
|
|
1110
|
+
const r = usable ?? runnerFor({
|
|
1002
1111
|
threadId: threadKey,
|
|
1003
|
-
agent: pinnedAgent(cfg,
|
|
1112
|
+
agent: pinnedAgent(cfg, modelAgent),
|
|
1004
1113
|
env: agentEnv(cfg).env,
|
|
1005
1114
|
resumeSessionId: canResumeThread ? threadSession : null,
|
|
1006
1115
|
helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
|
|
1007
1116
|
log,
|
|
1117
|
+
model: taskModel,
|
|
1008
1118
|
});
|
|
1119
|
+
if (taskModel) log(` ↳ model: ${taskModel}`);
|
|
1120
|
+
killers.set(task.id, () => r.kill());
|
|
1009
1121
|
result = await r.send(prompt, {
|
|
1010
1122
|
onProgress: live ? onProgress : undefined,
|
|
1011
1123
|
timeoutMs: cfg.taskTimeoutSeconds * 1000,
|
|
@@ -1020,7 +1132,7 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
1020
1132
|
}
|
|
1021
1133
|
}
|
|
1022
1134
|
}
|
|
1023
|
-
if (!result) result = await runAgent(cfg, agent, prompt, onProgress, resume, { ws, task });
|
|
1135
|
+
if (!result) result = await runAgent(cfg, agent, prompt, onProgress, resume, { ws, task, onChild: (child) => killers.set(task.id, () => { try { child.kill("SIGTERM"); } catch { /* gone */ } }) });
|
|
1024
1136
|
if (result && result.sessionId) retryCtx.set(task.id, { sessionId: result.sessionId, reason: retryCtx.get(task.id)?.reason ?? null });
|
|
1025
1137
|
let after;
|
|
1026
1138
|
if (bridgeFiles) {
|
|
@@ -1030,7 +1142,12 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
1030
1142
|
// Success shape differs by runner: claude one-shots exit 0; the codex server
|
|
1031
1143
|
// resolves with a turn status (no exit code) — failed statuses fall through.
|
|
1032
1144
|
const cleanExit = result?.code === 0 || (result?.code === undefined && !/failed/i.test(String(result?.status ?? "")));
|
|
1033
|
-
|
|
1145
|
+
// claude exits 0 even when its result envelope says the run errored (zero
|
|
1146
|
+
// turns, is_error, "No conversation found…"). That envelope is not an answer;
|
|
1147
|
+
// filing it would mark the task done and hand a crew a fake verdict.
|
|
1148
|
+
const envelopeError = resultError(result?.out);
|
|
1149
|
+
if (envelopeError) log(` ↳ ${agent.name} returned an error envelope, not a result: ${envelopeError.slice(0, 160)}`);
|
|
1150
|
+
if (finalText && cleanExit && !envelopeError) {
|
|
1034
1151
|
try {
|
|
1035
1152
|
await completeTaskApi(cfg, ws.id, task.id, finalText);
|
|
1036
1153
|
after = { status: "done" };
|
|
@@ -1130,6 +1247,12 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
1130
1247
|
// A volunteered task is CLAIMED — invisible to the open scan — so the throw
|
|
1131
1248
|
// branch (timeouts are the COMMON failure here) must feed the retry shelf
|
|
1132
1249
|
// exactly like the ran-but-not-done branch, or the claim strands.
|
|
1250
|
+
else if (stoppedRuns.has(task.id)) {
|
|
1251
|
+
// A human pressed Stop: the task is already cancelled server-side; nothing to
|
|
1252
|
+
// retry, nothing to abandon.
|
|
1253
|
+
stoppedRuns.delete(task.id);
|
|
1254
|
+
givenUp.add(task.id);
|
|
1255
|
+
}
|
|
1133
1256
|
else {
|
|
1134
1257
|
// Same thread self-heal as the ran-not-done branch: a failed RESUMED thread
|
|
1135
1258
|
// attempt retries cold rather than back into the same session.
|
|
@@ -1143,6 +1266,8 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
1143
1266
|
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` : ""}`);
|
|
1144
1267
|
} finally {
|
|
1145
1268
|
inFlight.delete(task.id);
|
|
1269
|
+
inFlightWs.delete(task.id);
|
|
1270
|
+
killers.delete(task.id);
|
|
1146
1271
|
markHot(ws.id); // the reply usually lands right after a run finishes — stay fast for it
|
|
1147
1272
|
}
|
|
1148
1273
|
}
|
|
@@ -1302,7 +1427,9 @@ async function socketLoop(cfg) {
|
|
|
1302
1427
|
let announced = false;
|
|
1303
1428
|
while (!sseStopped && sse.supported) {
|
|
1304
1429
|
try {
|
|
1305
|
-
const
|
|
1430
|
+
const aq = agentsQuery(cfg);
|
|
1431
|
+
// `boot` lets the server re-open claims a previous process of ours left behind.
|
|
1432
|
+
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream${aq}${aq ? "&" : "?"}boot=${BOOT_MS}`, {
|
|
1306
1433
|
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
1307
1434
|
});
|
|
1308
1435
|
if (res.status === 404 || res.status === 405) {
|
|
@@ -1334,6 +1461,7 @@ async function socketLoop(cfg) {
|
|
|
1334
1461
|
if (ev.event === "work" && ev.data) {
|
|
1335
1462
|
try {
|
|
1336
1463
|
const j = JSON.parse(ev.data);
|
|
1464
|
+
if (Array.isArray(j.stops) && j.stops.length) stopRuns(j.stops);
|
|
1337
1465
|
void dispatchWork(cfg, j.work ?? [], j.warm_hints ?? []);
|
|
1338
1466
|
} catch { /* malformed frame — next snapshot covers */ }
|
|
1339
1467
|
}
|
|
@@ -1756,10 +1884,10 @@ async function main() {
|
|
|
1756
1884
|
else log("⌂ Hosting is OFF — no visiting agent can act on this machine. `cookbook-bridge host` opens it.");
|
|
1757
1885
|
if (mode !== "off") await pollHands(cfg);
|
|
1758
1886
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
}
|
|
1887
|
+
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => {
|
|
1888
|
+
releaseInFlight(cfg, sig).finally(() => { if (cfg.persistentThreads) { killAllRunners(); killCodexServer(); } process.exit(0); });
|
|
1889
|
+
});
|
|
1890
|
+
if (cfg.persistentThreads) process.on("exit", () => { killAllRunners(); killCodexServer(); });
|
|
1763
1891
|
} catch (e) {
|
|
1764
1892
|
lastRunError = e.message;
|
|
1765
1893
|
if (IS_DESKTOP && localServer) {
|
|
@@ -2007,7 +2135,7 @@ async function doctorReport(args) {
|
|
|
2007
2135
|
} else {
|
|
2008
2136
|
warn(`${agent.name}: couldn't read agy version`, `make sure it's >= ${AGY_MIN_VERSION} (headless MCP fix)`);
|
|
2009
2137
|
}
|
|
2010
|
-
const agyMcpPath = path.join(process.env.HOME ||
|
|
2138
|
+
const agyMcpPath = path.join(process.env.HOME || os.homedir(), ".gemini", "config", "mcp_config.json");
|
|
2011
2139
|
try {
|
|
2012
2140
|
const mc = JSON.parse(fs.readFileSync(agyMcpPath, "utf8"));
|
|
2013
2141
|
const srv = mc?.mcpServers?.cookbook;
|
|
@@ -2024,7 +2152,7 @@ async function doctorReport(args) {
|
|
|
2024
2152
|
bad(`${agent.name}: no agy MCP config at ${agyMcpPath}`,
|
|
2025
2153
|
"run `node bridge/bridge.mjs connect-agents` (agy has no `mcp add`; the Bridge writes this file)");
|
|
2026
2154
|
}
|
|
2027
|
-
if (!fs.existsSync(path.join(process.env.HOME ||
|
|
2155
|
+
if (!fs.existsSync(path.join(process.env.HOME || os.homedir(), ".gemini", "oauth_creds.json"))) {
|
|
2028
2156
|
warn(`${agent.name}: no Google login found (~/.gemini/oauth_creds.json)`,
|
|
2029
2157
|
"run `agy` once interactively and sign in with Google");
|
|
2030
2158
|
}
|
package/codex-runner.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import path from "node:path";
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
26
26
|
import { codexCallEvent, foldCallEvent, wireCalls } from "./live.mjs";
|
|
27
|
+
import { planFromCodexRateLimits, notePlan, planLine } from "./plan.mjs";
|
|
27
28
|
|
|
28
29
|
const IDLE_MS = 15 * 60_000;
|
|
29
30
|
const LIVE_TEXT_CAP = 1800;
|
|
@@ -77,7 +78,14 @@ class CodexServer {
|
|
|
77
78
|
this.ready = this.request("initialize", {
|
|
78
79
|
clientInfo: { name: "cookbook-bridge", title: "Cookbook Bridge", version: "0.2.0" },
|
|
79
80
|
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
|
80
|
-
}).then(() => {
|
|
81
|
+
}).then(() => {
|
|
82
|
+
this.notify("initialized");
|
|
83
|
+
// Where the member's ChatGPT plan stands (5h + weekly windows). Best effort:
|
|
84
|
+
// an older app-server without the method just doesn't report (bridge/plan.mjs).
|
|
85
|
+
this.request("account/rateLimits/read", {})
|
|
86
|
+
.then((res) => { const p = planFromCodexRateLimits(res); if (p && notePlan(p)) this.log?.(` ↳ plan ${planLine(p.vendor, p)}`); })
|
|
87
|
+
.catch(() => {});
|
|
88
|
+
});
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
#die(err) {
|
|
@@ -143,6 +151,12 @@ class CodexServer {
|
|
|
143
151
|
continue;
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
// Plan windows move as turns burn quota; the app-server pushes the new snapshot.
|
|
155
|
+
if (/account\/rateLimits\/updated$/.test(meth)) {
|
|
156
|
+
const p = planFromCodexRateLimits(m.params);
|
|
157
|
+
if (p && notePlan(p)) this.log?.(` ↳ plan ${planLine(p.vendor, p)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
// NOTIFICATIONS — routed to the single in-flight turn.
|
|
147
161
|
const t = this.turn;
|
|
148
162
|
if (!t) continue;
|
|
@@ -169,11 +183,13 @@ class CodexServer {
|
|
|
169
183
|
|
|
170
184
|
/** Run one turn (serialized). threadKey maps to a persistent Codex thread —
|
|
171
185
|
* reused when known, created otherwise. */
|
|
172
|
-
runTurn({ threadKey, prompt, timeoutSeconds, onProgress, cwd }) {
|
|
186
|
+
runTurn({ threadKey, prompt, timeoutSeconds, onProgress, cwd, model }) {
|
|
173
187
|
const exec = async () => {
|
|
174
188
|
if (this.dead) throw new Error("codex app-server is dead");
|
|
175
189
|
await this.ready;
|
|
176
|
-
|
|
190
|
+
// A model is fixed per Codex thread: a different one gets its own thread.
|
|
191
|
+
const mapKey = model ? `${threadKey}::${model}` : threadKey;
|
|
192
|
+
let threadId = codexThreads.get(mapKey);
|
|
177
193
|
if (!threadId) {
|
|
178
194
|
// Per-THREAD cwd: local-access threads live in the mapped folder (the
|
|
179
195
|
// workspace-write sandbox is the wall); jailed threads use the tmp dir.
|
|
@@ -181,10 +197,12 @@ class CodexServer {
|
|
|
181
197
|
cwd: cwd || this.cwd,
|
|
182
198
|
sandbox: this.agent.sandbox || "workspace-write",
|
|
183
199
|
approvalPolicy: "never",
|
|
200
|
+
...(model ? { model } : {}),
|
|
184
201
|
});
|
|
185
202
|
threadId = r && r.thread && r.thread.id;
|
|
186
203
|
if (!threadId) throw new Error("codex app-server: thread/start returned no thread id");
|
|
187
|
-
codexThreads.set(
|
|
204
|
+
codexThreads.set(mapKey, threadId);
|
|
205
|
+
if (model) this.log?.(` ↳ model: ${model}`);
|
|
188
206
|
} else {
|
|
189
207
|
this.log?.(` ↳ continuing the codex conversation (thread reuse)`);
|
|
190
208
|
}
|
|
@@ -241,5 +259,6 @@ export function runCodexTask(agent, prompt, timeoutSeconds, token, baseEnv, onPr
|
|
|
241
259
|
timeoutSeconds,
|
|
242
260
|
onProgress,
|
|
243
261
|
cwd: agent.cwd,
|
|
262
|
+
model: opts.model ?? null,
|
|
244
263
|
});
|
|
245
264
|
}
|
package/config.example.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"_timeouts": "taskTimeoutSeconds is the ABSOLUTE ceiling per run (cost backstop). livenessTimeoutSeconds kills a STALLED run — no output for this many seconds (streaming runs only). Healthy long work runs to the ceiling; silence dies fast.",
|
|
8
8
|
"livenessTimeoutSeconds": 300,
|
|
9
9
|
"_concurrency": "How many task runs may be in flight at once. Runs launch in parallel up to this cap; the atomic pre-claim keeps every task single-runner.",
|
|
10
|
-
"maxConcurrentRuns":
|
|
10
|
+
"maxConcurrentRuns": 3,
|
|
11
11
|
"_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
|
|
12
12
|
"allowApiKeyBilling": false,
|
|
13
13
|
"_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities — decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
|
|
@@ -95,8 +95,5 @@
|
|
|
95
95
|
"allowedTools": null
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
|
-
"_hosting": "HARDWARE GRANTS: may an agent someone else runs act on THIS machine?
|
|
99
|
-
"hosting": {
|
|
100
|
-
"enabled": false
|
|
101
|
-
}
|
|
98
|
+
"_hosting": "HARDWARE GRANTS: may an agent someone else runs act on THIS machine? Deliberately ABSENT here: with no \"hosting\" key the Bridge serves the grants YOU approve in your own account — the Allow click is the consent, no separate switch to find. Seeding enabled:false made that click dead-end into a terminal command (2026-08-29). `cookbook-bridge host` sets enabled:true (serve any grant addressed to you); `cookbook-bridge host --off` writes enabled:false to refuse all hosting until you turn it back on. Whatever the mode, nothing happens until you approve a grant in the browser: you set the scope, you watch every call in the log, and End in the UI (or host --off) closes the door immediately. The Bridge's LOCAL_CEILING still holds every write at a per-change click."
|
|
102
99
|
}
|
package/cookbook.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Node built-ins only (global fetch, Node 18+). No dependencies.
|
|
10
10
|
*/
|
|
11
|
+
import { planParam } from "./plan.mjs";
|
|
11
12
|
|
|
12
13
|
/** Call one Cookbook MCP tool. Returns the tool's body (structuredContent). */
|
|
13
14
|
export async function callTool(cfg, name, args = {}) {
|
|
@@ -73,12 +74,17 @@ export async function getTask(cfg, workspaceId, taskId) {
|
|
|
73
74
|
* mints a NEW session id each time, so "the thread's session" is always the newest
|
|
74
75
|
* one, never the root's. Returns { sessionRef, root } (either may be null).
|
|
75
76
|
*/
|
|
76
|
-
export async function threadResumeContext(cfg, workspaceId, rootId) {
|
|
77
|
+
export async function threadResumeContext(cfg, workspaceId, rootId, myProfileId = null) {
|
|
77
78
|
const tasks = await listTasks(cfg, workspaceId, "all");
|
|
78
79
|
const inThread = tasks.filter((t) => t.id === rootId || t.thread_root_id === rootId);
|
|
79
80
|
const root = inThread.find((t) => t.id === rootId) ?? null;
|
|
81
|
+
// A session only exists on the machine that created it. In a crew that spans
|
|
82
|
+
// people, the previous turn may have run on a TEAMMATE's Bridge: resuming that id
|
|
83
|
+
// here fails with "No conversation found" (Pierre's reviewer, 2026-08-30). Only
|
|
84
|
+
// runs this member claimed are resumable; anything else gets the cold baton.
|
|
80
85
|
const stamped = inThread
|
|
81
86
|
.filter((t) => t.progress && typeof t.progress.session_ref === "string" && t.progress.session_ref)
|
|
87
|
+
.filter((t) => !myProfileId || !t.claimed_by_profile || t.claimed_by_profile === myProfileId)
|
|
82
88
|
.sort((a, b) => String(b.progress.updated_at ?? "").localeCompare(String(a.progress.updated_at ?? "")));
|
|
83
89
|
return { sessionRef: stamped[0]?.progress.session_ref ?? null, root };
|
|
84
90
|
}
|
|
@@ -241,9 +247,15 @@ export async function recallAcrossWorkspaces(cfg, query, excludeWorkspaceId, lim
|
|
|
241
247
|
/** Pending calls (and the live grants they belong to) for THIS Bridge's token. */
|
|
242
248
|
/** `?agents=Claude,Gemini,Chef` — what this Bridge manages, so the server can say
|
|
243
249
|
* "your Bridge is running but doesn't run X" instead of "start a Bridge". */
|
|
244
|
-
export function agentsQuery(cfg) {
|
|
250
|
+
export function agentsQuery(cfg, plan = planParam) {
|
|
245
251
|
const names = (cfg?.agents ?? []).filter((a) => a && a.enabled !== false && a.name).map((a) => String(a.name));
|
|
246
|
-
|
|
252
|
+
const parts = [];
|
|
253
|
+
if (names.length) parts.push(`agents=${encodeURIComponent(names.join(","))}`);
|
|
254
|
+
// `plan=…` — the member's latest per-vendor plan windows, as their own CLIs
|
|
255
|
+
// reported them to this Bridge (bridge/plan.mjs). Rides the same heartbeat.
|
|
256
|
+
const p = typeof plan === "function" ? plan() : "";
|
|
257
|
+
if (p) parts.push(p);
|
|
258
|
+
return parts.length ? `?${parts.join("&")}` : "";
|
|
247
259
|
}
|
|
248
260
|
|
|
249
261
|
export async function fetchHands(cfg) {
|
package/device.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import path from "node:path";
|
|
|
30
30
|
import { spawn, spawnSync } from "node:child_process";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { listWorkspaces } from "./cookbook.mjs";
|
|
33
|
+
import { which, argvForSpawn } from "./hands.mjs";
|
|
33
34
|
|
|
34
35
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
36
|
const DEFAULT_URL = "https://cookbook.team";
|
|
@@ -162,17 +163,8 @@ export function saveLoginConfig(cfgPath, baseUrl, token) {
|
|
|
162
163
|
|
|
163
164
|
// ───────────────────────────── CLI detection + configuration ─────────────────────────────
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const home = process.env.HOME || os.homedir() || "";
|
|
168
|
-
if (home) dirs.push(path.join(home, ".claude/local"), path.join(home, ".bun/bin"), path.join(home, ".local/bin"), "/opt/homebrew/bin", "/usr/local/bin");
|
|
169
|
-
for (const d of dirs) {
|
|
170
|
-
if (!d) continue;
|
|
171
|
-
const p = path.join(d, cmd);
|
|
172
|
-
try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* keep looking */ }
|
|
173
|
-
}
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
166
|
+
// `which` is shared with the hands/grants module (./hands.mjs): PATHEXT-aware on
|
|
167
|
+
// Windows, home-dir and Homebrew fallbacks everywhere.
|
|
176
168
|
|
|
177
169
|
/** Codex ships inside the ChatGPT app (July 2026); older installs had Codex.app; a
|
|
178
170
|
* bare `codex` on PATH also works. First hit wins. */
|
|
@@ -251,7 +243,7 @@ export function openclawConfigure(url, token, { home = os.homedir() } = {}) {
|
|
|
251
243
|
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
252
244
|
fs.writeFileSync(cfgPath, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 });
|
|
253
245
|
const bin = which("openclaw");
|
|
254
|
-
if (bin)
|
|
246
|
+
if (bin) { const a = argvForSpawn([bin, "mcp", "reload"]); spawnSync(a[0], a.slice(1), { stdio: "ignore", timeout: 20_000 }); }
|
|
255
247
|
return cfgPath;
|
|
256
248
|
}
|
|
257
249
|
|
|
@@ -287,8 +279,10 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
|
|
|
287
279
|
try {
|
|
288
280
|
if (cli.kind === "cli-add") {
|
|
289
281
|
// Idempotency: drop any existing 'cookbook' server first (best-effort).
|
|
290
|
-
|
|
291
|
-
|
|
282
|
+
const rm = argvForSpawn([cli.path, "mcp", "remove", "--scope", "user", "cookbook"]);
|
|
283
|
+
spawnSync(rm[0], rm.slice(1), { stdio: "ignore", timeout: 20_000 });
|
|
284
|
+
const addArgv = argvForSpawn([cli.path, "mcp", "add", "--scope", "user", "--transport", "http", "cookbook", mcpUrl, "--header", `Authorization: Bearer ${token}`]);
|
|
285
|
+
const add = spawnSync(addArgv[0], addArgv.slice(1), { encoding: "utf8", timeout: 30_000 });
|
|
292
286
|
if (add.status === 0) results.push({ agent: cli.agent, ok: true, detail: "connected (server 'cookbook', user scope)" });
|
|
293
287
|
else results.push({ agent: cli.agent, ok: false, detail: String(add.stderr || add.stdout || "add failed").trim().slice(0, 200) });
|
|
294
288
|
// The Bridge's OWN runs must not depend on the CLI's global server: store the
|
|
@@ -477,7 +471,9 @@ function probeAgent(cmd) {
|
|
|
477
471
|
}
|
|
478
472
|
};
|
|
479
473
|
try {
|
|
480
|
-
|
|
474
|
+
// Bare names need PATH/PATHEXT resolution (Windows), then the .cmd wrapper.
|
|
475
|
+
const argv = argvForSpawn([cmd.includes(path.sep) ? cmd : which(cmd) || cmd, "--version"]);
|
|
476
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: "ignore" });
|
|
481
477
|
child.on("error", () => finish(false));
|
|
482
478
|
child.on("close", (code) => finish(code === 0 || code === null));
|
|
483
479
|
setTimeout(() => {
|
package/hands.mjs
CHANGED
|
@@ -147,12 +147,14 @@ export const SETUP_FILES = Object.freeze([
|
|
|
147
147
|
* design exists to keep out of a visiting agent's context. This list wins over the
|
|
148
148
|
* setup allowlist, over folder grants, and over any future verb.
|
|
149
149
|
*
|
|
150
|
-
* MATCHED CASE-INSENSITIVELY
|
|
150
|
+
* MATCHED CASE-INSENSITIVELY, UNICODE-NORMALIZED, AND SEPARATOR-NORMALIZED
|
|
151
|
+
* (a backslash counts as a separator), and that is not a nicety:
|
|
151
152
|
* macOS APFS/HFS+ are case-INSENSITIVE, and `realpath()` does NOT canonicalize case
|
|
152
153
|
* — it hands back whatever spelling the caller used. So a case-sensitive denylist
|
|
153
154
|
* simply does not see `~/.claude/.Credentials.json`, which opens the exact file it
|
|
154
155
|
* exists to protect. HFS+ also stores decomposed Unicode, so `.crede\u0301ntials`
|
|
155
|
-
* is a second spelling of the same name.
|
|
156
|
+
* is a second spelling of the same name. And on Windows every resolved path uses
|
|
157
|
+
* `\`, which would slip straight past patterns written with `/`.
|
|
156
158
|
*
|
|
157
159
|
* Directory entries end in `(\/|$)` so the DIRECTORY ITSELF is denied too: listing
|
|
158
160
|
* ~/.ssh tells a visitor which hosts you hold keys for, and ~/.aws which profiles
|
|
@@ -198,6 +200,14 @@ function pathVariants(p) {
|
|
|
198
200
|
out.add(lower.normalize("NFC"));
|
|
199
201
|
out.add(lower.normalize("NFD"));
|
|
200
202
|
} catch { /* normalize is available everywhere we run, but never fail closed-open */ }
|
|
203
|
+
// WINDOWS SPELLINGS. path.resolve() on win32 yields backslashes, and every
|
|
204
|
+
// NEVER_READ pattern is written with "/" — so C:\Users\me\.ssh\id_rsa matched
|
|
205
|
+
// NOTHING and the list that "wins over everything" silently protected nothing
|
|
206
|
+
// on Windows. On POSIX a backslash is a legal filename char; treating it as a
|
|
207
|
+
// separator here can only over-deny a bizarrely-named file, which errs safe.
|
|
208
|
+
for (const v of [...out]) {
|
|
209
|
+
if (v.includes("\\")) out.add(v.replace(/\\/g, "/"));
|
|
210
|
+
}
|
|
201
211
|
return [...out];
|
|
202
212
|
}
|
|
203
213
|
|
|
@@ -222,6 +232,14 @@ function isDenied(p) {
|
|
|
222
232
|
* `projected: true` so the visiting agent knows it is looking at a summary and does
|
|
223
233
|
* not go hunting for the rest.
|
|
224
234
|
*/
|
|
235
|
+
/** The PROJECTIONS key for a file: its path relative to the granted home, always with
|
|
236
|
+
* "/" — on Windows path.relative() answers with "\", and a key that misses means the
|
|
237
|
+
* file is returned in FULL instead of as a summary. (Kimi's 2026-08-28 review flagged
|
|
238
|
+
* the "/"-shaped guarantees; this one it didn't reach.) */
|
|
239
|
+
export function projectionKey(home, real, pathMod = path) {
|
|
240
|
+
return pathMod.relative(home, real).replace(/\\/g, "/");
|
|
241
|
+
}
|
|
242
|
+
|
|
225
243
|
const PROJECTIONS = Object.freeze({
|
|
226
244
|
".claude.json": (text) => {
|
|
227
245
|
let j;
|
|
@@ -462,15 +480,62 @@ function backupOf(file) {
|
|
|
462
480
|
return `${file}.bak-chef-${Date.now()}`;
|
|
463
481
|
}
|
|
464
482
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
483
|
+
/**
|
|
484
|
+
* Find an executable. POSIX: exact name, executable bit. Windows: try the exact
|
|
485
|
+
* name plus every PATHEXT extension (.EXE/.CMD/.BAT…), preferring a REAL executable
|
|
486
|
+
* over a shell shim, and plain existence over X_OK (NTFS ACLs don't map to it).
|
|
487
|
+
* Options exist so tests can force a platform; defaults are the live machine.
|
|
488
|
+
* Exported: the Bridge's other spawners (device.mjs) share it.
|
|
489
|
+
*/
|
|
490
|
+
export function which(bin, { platform = process.platform, pathEnv = process.env.PATH, pathext = process.env.PATHEXT, home = os.homedir() } = {}) {
|
|
491
|
+
const dirs = String(pathEnv || "").split(platform === "win32" ? ";" : ":");
|
|
492
|
+
if (home) dirs.push(path.join(home, ".claude/local"), path.join(home, ".bun/bin"), path.join(home, ".local/bin"));
|
|
493
|
+
if (platform !== "win32") dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
494
|
+
const exists = (p) => { try { fs.accessSync(p, platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK); return true; } catch { return false; } };
|
|
495
|
+
if (platform !== "win32") {
|
|
496
|
+
for (const dir of dirs) {
|
|
497
|
+
if (!dir) continue;
|
|
498
|
+
const p = path.join(dir, bin);
|
|
499
|
+
if (exists(p)) return p;
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
// PATHEXT is uppercase while shims are written lowercase (`claude.cmd`). Windows
|
|
504
|
+
// doesn't care; a case-sensitive filesystem (CI's Linux runner) does — try both.
|
|
505
|
+
const exts = [...new Set(String(pathext || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).flatMap((e) => [e, e.toLowerCase()]))];
|
|
506
|
+
// Pass 1: the exact name or a real executable. Pass 2: any shim PATHEXT allows.
|
|
507
|
+
for (const pass of [(e) => e === "" || /\.(exe|com)$/i.test(e), () => true]) {
|
|
508
|
+
for (const dir of dirs) {
|
|
509
|
+
if (!dir) continue;
|
|
510
|
+
const names = [bin, ...exts.map((e) => (bin.toLowerCase().endsWith(e.toLowerCase()) ? bin : `${bin}${e}`))];
|
|
511
|
+
for (const n of names) {
|
|
512
|
+
if (!pass(path.extname(n))) continue;
|
|
513
|
+
const p = path.join(dir, n);
|
|
514
|
+
if (exists(p)) return p;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
470
517
|
}
|
|
471
518
|
return null;
|
|
472
519
|
}
|
|
473
520
|
|
|
521
|
+
/**
|
|
522
|
+
* WINDOWS EXECUTION. A `.cmd`/`.bat` shim cannot be spawned directly on modern Node
|
|
523
|
+
* (EINVAL since the CVE-2024-27980 fix): it has to go through cmd.exe. Compose the
|
|
524
|
+
* one command line here. Every argv a template produces is a constant or a resolved
|
|
525
|
+
* binary path — never visitor-controlled — so this quoting is about paths with
|
|
526
|
+
* spaces, not injection. (A `%` in a path would still expand under cmd.exe; noted,
|
|
527
|
+
* accepted — no template value contains one, and usernames essentially never do.)
|
|
528
|
+
* Identity everywhere else, so callers can wrap unconditionally.
|
|
529
|
+
*/
|
|
530
|
+
export function argvForSpawn(argv, platform = process.platform) {
|
|
531
|
+
if (platform !== "win32") return argv;
|
|
532
|
+
const exe = String(argv[0] ?? "");
|
|
533
|
+
if (!/\.(cmd|bat)$/i.test(exe)) return argv;
|
|
534
|
+
const quote = (s) => `"${String(s).replace(/"/g, '\\"').replace(/(\\+)$/, "$1$1")}"`;
|
|
535
|
+
const line = [exe, ...argv.slice(1)].map(quote).join(" ");
|
|
536
|
+
return [process.env.ComSpec || "cmd.exe", "/d", "/s", "/c", line];
|
|
537
|
+
}
|
|
538
|
+
|
|
474
539
|
/**
|
|
475
540
|
* Each template returns { argv, timeoutMs }. Parameters are validated by the
|
|
476
541
|
* template itself and only ever land in a dedicated argv slot. A template that
|
|
@@ -492,8 +557,9 @@ export const RUN_TEMPLATES = Object.freeze({
|
|
|
492
557
|
return { local: () => ({
|
|
493
558
|
clis: found.map(({ bin, path: p }) => {
|
|
494
559
|
if (!p) return { cli: bin, installed: false };
|
|
495
|
-
const
|
|
496
|
-
|
|
560
|
+
const argv = argvForSpawn([p, "--version"]);
|
|
561
|
+
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 12_000 });
|
|
562
|
+
return { cli: bin, installed: true, version: String(r.stdout || r.stderr || r.error?.message || "").trim().split("\n")[0].slice(0, 80) };
|
|
497
563
|
}),
|
|
498
564
|
}) };
|
|
499
565
|
},
|
|
@@ -604,7 +670,8 @@ function runArgv(argv, timeoutMs) {
|
|
|
604
670
|
let err = "";
|
|
605
671
|
let child;
|
|
606
672
|
try {
|
|
607
|
-
|
|
673
|
+
const wrapped = argvForSpawn(argv); // .cmd/.bat need cmd.exe on Windows
|
|
674
|
+
child = spawn(wrapped[0], wrapped.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
|
|
608
675
|
} catch (e) {
|
|
609
676
|
resolve({ exit_code: null, stdout: "", stderr: String(e.message) });
|
|
610
677
|
return;
|
|
@@ -668,7 +735,7 @@ const VERBS = {
|
|
|
668
735
|
let text;
|
|
669
736
|
try { text = fs.readFileSync(r.real, "utf8"); } catch (e) { return { error: `can't read: ${e.code || e.message}` }; }
|
|
670
737
|
// Some setup files are needed for their SHAPE, not their contents (see PROJECTIONS).
|
|
671
|
-
const rel =
|
|
738
|
+
const rel = projectionKey(ctx.home, r.path);
|
|
672
739
|
const project = PROJECTIONS[rel];
|
|
673
740
|
if (project) return { path: args.path, exists: true, bytes: st.size, projected: true, summary: project(text) };
|
|
674
741
|
return { path: args.path, exists: true, bytes: st.size, text: text.slice(0, MAX_OUTPUT_CHARS) };
|
package/local.mjs
CHANGED
|
@@ -348,8 +348,9 @@ export function createLocalServer(deps) {
|
|
|
348
348
|
if (route === "GET /connect-agents") return json(res, 200, { ok: true, ...connect });
|
|
349
349
|
|
|
350
350
|
// Open or close the door. Deliberately a Bridge Local route rather than a
|
|
351
|
-
// config edit: the
|
|
352
|
-
// picks it up immediately (no restart, so a host can close
|
|
351
|
+
// config edit: the Chef widget flips it in place when the person agrees, and
|
|
352
|
+
// the running Bridge picks it up immediately (no restart, so a host can close
|
|
353
|
+
// the door NOW).
|
|
353
354
|
if (route === "POST /hosting") {
|
|
354
355
|
const body = (await readBody(req)) ?? {};
|
|
355
356
|
// A security toggle whose default is OFF must not fail open: an empty or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cookbook-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"hands.mjs",
|
|
20
20
|
"harden.mjs",
|
|
21
21
|
"live.mjs",
|
|
22
|
+
"plan.mjs",
|
|
22
23
|
"local.mjs",
|
|
23
24
|
"openclaw-runner.mjs",
|
|
24
25
|
"prompt.mjs",
|
package/plan.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLAN WINDOWS — "how much of my subscription have I used?" (Diego, 2026-08-29).
|
|
3
|
+
*
|
|
4
|
+
* The vendors' own CLIs already tell the Bridge, on every run, where the member's
|
|
5
|
+
* plan stands — nobody was catching it:
|
|
6
|
+
* - claude `-p --output-format stream-json --verbose` emits a `rate_limit_event`
|
|
7
|
+
* with `unifiedWindows.five_hour / seven_day { utilization, resetsAt }`.
|
|
8
|
+
* - codex app-server answers `account/rateLimits/read` (and pushes
|
|
9
|
+
* `account/rateLimits/updated`) with `primary / secondary { usedPercent,
|
|
10
|
+
* windowDurationMins, resetsAt }` + `planType`.
|
|
11
|
+
* - gemini-cli / agy report nothing about quota (verified 2026-08-29): honest
|
|
12
|
+
* absence, never a guess.
|
|
13
|
+
*
|
|
14
|
+
* This module is the one place that knows those shapes. It keeps the LATEST
|
|
15
|
+
* observation per vendor in memory and hands the Bridge a query fragment to ride
|
|
16
|
+
* on its heartbeat (`?plan=…`, next to `?agents=`). No credential leaves the
|
|
17
|
+
* machine — a window is two numbers the member's own CLI printed.
|
|
18
|
+
*
|
|
19
|
+
* Wire shape (compact, re-validated server-side in src/lib/bridge/plan.ts):
|
|
20
|
+
* { claude: { five_hour: { u: 0.66, r: 1788044400 }, seven_day: { u: 0.49, r: … }, at: <epoch s>, plan: "max" | null } }
|
|
21
|
+
* u = fraction used (0..1+), r = epoch SECONDS the window resets.
|
|
22
|
+
*
|
|
23
|
+
* Pure parsers + a tiny store, no network, no fs — testable from the outside.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const VENDORS = new Set(["claude", "codex", "gemini", "openclaw"]);
|
|
27
|
+
const latest = new Map(); // vendor -> { five_hour?, seven_day?, at, plan }
|
|
28
|
+
|
|
29
|
+
function num(x) {
|
|
30
|
+
const n = Number(x);
|
|
31
|
+
return Number.isFinite(n) ? n : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A window from (fraction-or-percent, reset). Accepts 0..1 fractions or 0..100 percents. */
|
|
35
|
+
function window(used, resetsAt, { percent = false } = {}) {
|
|
36
|
+
let u = num(used);
|
|
37
|
+
const r = num(resetsAt);
|
|
38
|
+
if (u === null) return null;
|
|
39
|
+
if (percent) u = u / 100;
|
|
40
|
+
if (u < 0) u = 0;
|
|
41
|
+
return { u: Math.round(u * 1000) / 1000, ...(r !== null && r > 0 ? { r: Math.round(r > 1e11 ? r / 1000 : r) } : {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* claude stream-json → observation, or null for every other line.
|
|
46
|
+
* {"type":"rate_limit_event","rate_limit_info":{"unifiedWindows":{"five_hour":{"utilization":0.66,"resetsAt":1788044400},"seven_day":{…}}}}
|
|
47
|
+
*/
|
|
48
|
+
export function planFromStreamLine(line) {
|
|
49
|
+
let j;
|
|
50
|
+
try { j = typeof line === "string" ? JSON.parse(line) : line; } catch { return null; }
|
|
51
|
+
if (!j || typeof j !== "object" || j.type !== "rate_limit_event") return null;
|
|
52
|
+
const info = j.rate_limit_info;
|
|
53
|
+
const w = info && typeof info === "object" ? info.unifiedWindows : null;
|
|
54
|
+
if (!w || typeof w !== "object") return null;
|
|
55
|
+
const five = w.five_hour && typeof w.five_hour === "object" ? window(w.five_hour.utilization, w.five_hour.resetsAt) : null;
|
|
56
|
+
const seven = w.seven_day && typeof w.seven_day === "object" ? window(w.seven_day.utilization, w.seven_day.resetsAt) : null;
|
|
57
|
+
if (!five && !seven) return null;
|
|
58
|
+
return { vendor: "claude", ...(five ? { five_hour: five } : {}), ...(seven ? { seven_day: seven } : {}), plan: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* codex app-server `account/rateLimits/read` result (or the `updated` notification's
|
|
63
|
+
* params) → observation. Windows are named by their length, not by position:
|
|
64
|
+
* primary is the short one only because that is how OpenAI orders them today.
|
|
65
|
+
*/
|
|
66
|
+
export function planFromCodexRateLimits(result) {
|
|
67
|
+
const rl = result && typeof result === "object" ? (result.rateLimits && typeof result.rateLimits === "object" ? result.rateLimits : result) : null;
|
|
68
|
+
if (!rl || typeof rl !== "object") return null;
|
|
69
|
+
const out = { vendor: "codex", plan: typeof rl.planType === "string" ? rl.planType : null };
|
|
70
|
+
let any = false;
|
|
71
|
+
for (const key of ["primary", "secondary"]) {
|
|
72
|
+
const w = rl[key];
|
|
73
|
+
if (!w || typeof w !== "object") continue;
|
|
74
|
+
const mins = num(w.windowDurationMins ?? w.window_minutes);
|
|
75
|
+
const win = window(w.usedPercent ?? w.used_percent, w.resetsAt ?? w.resets_at, { percent: true });
|
|
76
|
+
if (!win) continue;
|
|
77
|
+
const slot = mins !== null && mins > 600 ? "seven_day" : "five_hour";
|
|
78
|
+
if (!out[slot]) { out[slot] = win; any = true; }
|
|
79
|
+
}
|
|
80
|
+
return any ? out : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Record the newest observation for its vendor. Returns true when something changed. */
|
|
84
|
+
export function notePlan(obs, now = Date.now()) {
|
|
85
|
+
if (!obs || typeof obs !== "object" || !VENDORS.has(obs.vendor)) return false;
|
|
86
|
+
const entry = {
|
|
87
|
+
...(obs.five_hour ? { five_hour: obs.five_hour } : {}),
|
|
88
|
+
...(obs.seven_day ? { seven_day: obs.seven_day } : {}),
|
|
89
|
+
at: Math.round(now / 1000),
|
|
90
|
+
plan: typeof obs.plan === "string" && obs.plan ? obs.plan.slice(0, 32) : null,
|
|
91
|
+
};
|
|
92
|
+
const prev = latest.get(obs.vendor);
|
|
93
|
+
latest.set(obs.vendor, entry);
|
|
94
|
+
const same = prev && JSON.stringify({ ...prev, at: 0 }) === JSON.stringify({ ...entry, at: 0 });
|
|
95
|
+
return !same;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Everything observed this process lifetime, keyed by vendor. */
|
|
99
|
+
export function latestPlans() {
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [v, e] of latest) out[v] = e;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** For tests. */
|
|
106
|
+
export function resetPlans() {
|
|
107
|
+
latest.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `plan=<json>` (no leading `?`/`&`), or "" when nothing has been observed. */
|
|
111
|
+
export function planParam() {
|
|
112
|
+
if (!latest.size) return "";
|
|
113
|
+
return `plan=${encodeURIComponent(JSON.stringify(latestPlans()))}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One human line for the Bridge log: `claude 66% of 5h · 49% of week`. */
|
|
117
|
+
export function planLine(vendor, entry) {
|
|
118
|
+
const pct = (w) => (w && typeof w.u === "number" ? `${Math.round(w.u * 100)}%` : null);
|
|
119
|
+
const parts = [];
|
|
120
|
+
const f = pct(entry?.five_hour);
|
|
121
|
+
const s = pct(entry?.seven_day);
|
|
122
|
+
if (f) parts.push(`${f} of 5h`);
|
|
123
|
+
if (s) parts.push(`${s} of week`);
|
|
124
|
+
return `${vendor}${entry?.plan ? ` (${entry.plan})` : ""}: ${parts.join(" · ") || "no windows"}`;
|
|
125
|
+
}
|
package/thread-runner.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
21
|
import { callsFromStreamLine, foldCallEvent, wireCalls } from "./live.mjs";
|
|
22
|
+
import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
|
|
22
23
|
|
|
23
24
|
const IDLE_MS = 10 * 60_000;
|
|
24
25
|
const runners = new Map(); // threadRootId -> Runner
|
|
@@ -43,7 +44,8 @@ export function persistentCommand(command, resumeSessionId) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
class Runner {
|
|
46
|
-
constructor({ threadId, agent, env, resumeSessionId, helpers, log }) {
|
|
47
|
+
constructor({ threadId, agent, env, resumeSessionId, helpers, log, model = null }) {
|
|
48
|
+
this.model = model ?? agent.model ?? null;
|
|
47
49
|
this.threadId = threadId;
|
|
48
50
|
this.agent = agent;
|
|
49
51
|
this.helpers = helpers; // { fold, textFrom, sessionFrom }
|
|
@@ -94,6 +96,8 @@ class Runner {
|
|
|
94
96
|
}
|
|
95
97
|
let touched = false;
|
|
96
98
|
for (const ev of callsFromStreamLine(line)) { t.calls = foldCallEvent(t.calls, ev); touched = true; }
|
|
99
|
+
const plan = planFromStreamLine(line);
|
|
100
|
+
if (plan && notePlan(plan)) this.log?.(` ↳ plan ${planLine(plan.vendor, plan)}`);
|
|
97
101
|
const r = this.helpers.fold(line, t.acc);
|
|
98
102
|
t.acc = r.acc;
|
|
99
103
|
if (r.resultLine) {
|
|
@@ -196,11 +200,19 @@ export function adoptRunner(fromKey, toKey) {
|
|
|
196
200
|
|
|
197
201
|
/** Get the live runner for a thread, or create one (resuming a prior session when
|
|
198
202
|
* given). Throws when the agent isn't claude-shaped; callers fall back. */
|
|
199
|
-
export function runnerFor({ threadId, agent, env, resumeSessionId, helpers, log }) {
|
|
203
|
+
export function runnerFor({ threadId, agent, env, resumeSessionId, helpers, log, model = null }) {
|
|
200
204
|
const existing = runners.get(threadId);
|
|
201
|
-
|
|
205
|
+
const want = model ?? agent.model ?? null;
|
|
206
|
+
if (existing && !existing.dead && (existing.model ?? null) === want) return existing;
|
|
207
|
+
if (existing && !existing.dead) {
|
|
208
|
+
// Same conversation, different model: resume the session on the new one.
|
|
209
|
+
if (existing.busy) throw new Error("runner busy");
|
|
210
|
+
resumeSessionId = existing.sessionId ?? resumeSessionId;
|
|
211
|
+
existing.kill();
|
|
212
|
+
log(` ↳ thread runner for ${threadId.slice(0, 8)} restarted on ${want ?? "the default model"}`);
|
|
213
|
+
}
|
|
202
214
|
if (existing) runners.delete(threadId);
|
|
203
|
-
const r = new Runner({ threadId, agent, env, resumeSessionId, helpers, log });
|
|
215
|
+
const r = new Runner({ threadId, agent, env, resumeSessionId, helpers, log, model: want });
|
|
204
216
|
runners.set(threadId, r);
|
|
205
217
|
log(` ↳ thread runner started for ${threadId.slice(0, 8)}${resumeSessionId ? " (resuming session)" : ""}`);
|
|
206
218
|
return r;
|