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 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
- # installed agent CLI (claude, codex, agy, openclaw),
24
- # each with its own attributed token
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.includes("/")) {
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 ?? 2);
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
- const child = spawn(cmd, args, {
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
- return runCodexTask(agent, prompt, cfg.taskTimeoutSeconds, agent.token || cfg.codexToken, env, onProgress, { threadKey, log });
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 r = warmRunner ?? runnerFor({
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, agent),
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
- if (finalText && cleanExit) {
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 res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream${agentsQuery(cfg)}`, {
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
- if (cfg.persistentThreads) {
1760
- for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { killAllRunners(); killCodexServer(); process.exit(0); });
1761
- process.on("exit", () => { killAllRunners(); killCodexServer(); });
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 || "", ".gemini", "config", "mcp_config.json");
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 || "", ".gemini", "oauth_creds.json"))) {
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(() => { this.notify("initialized"); });
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
- let threadId = codexThreads.get(threadKey);
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(threadKey, threadId);
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
  }
@@ -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": 2,
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? Off by default. `cookbook-bridge host` turns it on; the desktop app has a switch. Even ON, nothing happens until YOU approve a grant in the browser: you set the scope, you watch every call in the log, and `cookbook-bridge host --off` (or End in the UI) closes the door immediately.",
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
- return names.length ? `?agents=${encodeURIComponent(names.join(","))}` : "";
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
- function which(cmd) {
166
- const dirs = (process.env.PATH || "").split(path.delimiter);
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) spawnSync(bin, ["mcp", "reload"], { stdio: "ignore", timeout: 20_000 });
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
- spawnSync(cli.path, ["mcp", "remove", "--scope", "user", "cookbook"], { stdio: "ignore", timeout: 20_000 });
291
- const add = spawnSync(cli.path, ["mcp", "add", "--scope", "user", "--transport", "http", "cookbook", mcpUrl, "--header", `Authorization: Bearer ${token}`], { encoding: "utf8", timeout: 30_000 });
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
- const child = spawn(cmd, ["--version"], { stdio: "ignore" });
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 AND UNICODE-NORMALIZED, and that is not a nicety:
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
- function which(bin) {
466
- for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
467
- if (!dir) continue;
468
- const p = path.join(dir, bin);
469
- try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* keep looking */ }
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 r = spawnSync(p, ["--version"], { encoding: "utf8", timeout: 12_000 });
496
- return { cli: bin, installed: true, version: String(r.stdout || r.stderr || "").trim().split("\n")[0].slice(0, 80) };
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
- child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
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 = path.relative(ctx.home, r.path);
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 desktop app flips it with a switch, and the running Bridge
352
- // picks it up immediately (no restart, so a host can close the door NOW).
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.7",
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
- if (existing && !existing.dead) return existing;
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;