cookbook-bridge 0.1.12 → 0.1.16

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
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Changelog
4
4
 
5
+ **0.1.14** (2026-09-03)
6
+ - The AI-visibility probe rides the synthesis lane: a `probe` job asks one buyer prompt on your subscription, either with no tools (variant `model`) or with WebSearch only (variant `search`, passed as `--tools WebSearch --allowedTools WebSearch` because print mode refuses a tool nobody granted). Capped at 120s per prompt, logged as `probe: <group> #<n> (<variant>)`, never with the prompt text.
7
+
8
+ **0.1.13** (2026-09-02)
9
+ - Pre-flight: the moment a grant this Bridge hosts becomes active, it runs `env`, the doctor, `cli_versions` and the shape of its own config (tokens stripped) once, locally and read-only, and posts the result as a host-initiated `preflight` call, so the visiting agent starts with what the machine already knows. Remembered per grant in `bridge.state.json`; a failed post is retried once per process.
10
+ - One-click plans: a `plan` call carries `why` and an ordered list of steps. The Bridge recomputes the plan hash before running (a step added after the click is refused as `plan hash mismatch`), runs each step through the same authorization wall as a single call, with the plan's approval standing in for each write-class step's click, stops at the first failure, and reports every step with its duration. Plans cannot nest, cannot contain `preflight`, and run one at a time like every other call.
11
+ - Every result leaving the machine is capped at 64 KB, the same cap the server applies.
12
+
5
13
  **0.1.12** (2026-09-02)
6
14
  - Kimi Code is the fourth agent: `connect` finds `kimi`, mints a "Kimi" token and writes `~/.kimi-code/mcp.json` (owner-only, other servers kept). Runs use `kimi -p ... --output-format stream-json`; the board gets live text and the work log, resume by session id, and a duration-only receipt (kimi reports no token counts).
7
15
  - A headless kimi run approves every tool and has no `--allowedTools` flag, so the Bridge turns the config's `--allowedTools` into a per-run agent file (`--agent-file`, a 0600 temp file) whose tools allowlist is exactly that list. `doctor` fails a Kimi agent that has no `--allowedTools`.
package/bridge.mjs CHANGED
@@ -34,7 +34,7 @@ import path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { spawn } from "node:child_process";
36
36
  import { callsFromStreamLine, foldCallEvent, wireCalls, shortTool, argFor } from "./live.mjs";
37
- import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
37
+ import { planFromStreamLine, notePlan, planLine, noteAuth, isSignedOutError } from "./plan.mjs";
38
38
 
39
39
  // Node version guard: below 18 there is no global fetch and none of this runs. One
40
40
  // plain line beats a stack trace from the first `fetch(` call.
@@ -68,14 +68,14 @@ let hasCodexThread, reapCodexServer, killCodexServer;
68
68
  let checkForUpdate, applyUpdate;
69
69
  let createLocalServer, toolsForMode, modeForTools, vendorOf;
70
70
  let connectAgentsProgrammatic, detectClis;
71
- let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn, redactText, resolveCmdShim, killTree;
71
+ let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn, redactText, resolveCmdShim, killTree, runPreflight, grantsNeedingPreflight;
72
72
  let fetchHands, claimHandsCall, reportHandsResult;
73
73
 
74
74
  async function loadRuntime() {
75
75
  ({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
76
76
  ({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
77
77
  ({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
78
- ({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree } = await import("./hands.mjs"));
78
+ ({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree, runPreflight, grantsNeedingPreflight } = await import("./hands.mjs"));
79
79
  ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig,
80
80
  isKimiCommand, kimiFromLine, kimiResultEnvelope, kimiCommand, kimiLoginState, kimiMcpState, checkKimiVersion } = await import("./harden.mjs"));
81
81
  ({ extractUsage, displayText } = await import("./usage.mjs"));
@@ -739,8 +739,8 @@ function failureHint(result) {
739
739
  const infra = String(err || "").toLowerCase();
740
740
  const tailSrc = `${err || display || (brace < 0 ? rawOut : "")}`.trim();
741
741
  const tail = tailSrc.split("\n").slice(-2).join(" ").slice(0, 240);
742
- if (infra.includes("not logged in") || infra.includes("please log in"))
743
- return "the agent CLI isn't logged in → run `claude auth login`";
742
+ if (isSignedOutError(infra))
743
+ return "Claude on this machine is signed out → open a terminal, run `claude`, and sign in; this Bridge picks it up on its own";
744
744
  // Kimi's own error strings (stderr: "error: failed to run prompt: provider.connection_error: …").
745
745
  if (infra.includes("provider.connection_error") || infra.includes("connection error"))
746
746
  return "the agent CLI can't reach its API (a network or DNS block on the vendor's hosts) → check the connection, then try `kimi -p hi` by hand";
@@ -815,6 +815,7 @@ function loadRunState() {
815
815
  for (const [id, n] of Object.entries(raw.attempts ?? {})) attempts.set(id, Number(n) || 0);
816
816
  for (const id of raw.givenUp ?? []) givenUp.add(id);
817
817
  for (const [id, ctx] of Object.entries(raw.retryCtx ?? {})) retryCtx.set(id, ctx);
818
+ for (const id of raw.preflighted ?? []) if (typeof id === "string") preflighted.add(id);
818
819
  } catch { /* first run / unreadable — start clean */ }
819
820
  }
820
821
  function saveRunState() {
@@ -823,11 +824,15 @@ function saveRunState() {
823
824
  const attEntries = [...attempts.entries()].slice(-500);
824
825
  const given = [...givenUp].slice(-500);
825
826
  const retries = [...retryCtx.entries()].slice(-200);
826
- fs.writeFileSync(statePath(), JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries) }));
827
+ const flown = [...preflighted].slice(-500);
828
+ fs.writeFileSync(statePath(), JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries), preflighted: flown }));
827
829
  } catch { /* best-effort — never let state persistence break a run */ }
828
830
  }
829
831
 
830
832
  const attempts = new Map(); // taskId -> count
833
+ // Grants this host has already pre-flighted (hands section). Persisted: a restart
834
+ // must not post a second pre-flight for a grant the visitor already read.
835
+ const preflighted = new Set();
831
836
  // Retry context (Phase 1): what the LAST failed attempt knew — the claude session
832
837
  // to resume and the failure to feed back — so a retry continues instead of redoing.
833
838
  const retryCtx = new Map(); // taskId -> { sessionId, reason }
@@ -1434,6 +1439,10 @@ async function processTask(cfg, ws, task, agent) {
1434
1439
  // turns a multi-hour debug into a one-glance fix.
1435
1440
  const hint = failureHint(result);
1436
1441
  const why = hint ? ` — ${hint}` : "";
1442
+ if (/signed out/.test(hint) && isClaudeCommand && isClaudeCommand(agent.command)) {
1443
+ if (noteAuth("claude", false)) log("! Claude is signed out on this machine → run `claude` in a terminal and sign in. Until then Claude work here falls back or waits.");
1444
+ lastAuthCheck = Date.now() - AUTH_CHECK_MS + 60_000; // re-check in a minute
1445
+ }
1437
1446
  if (holdDuringRun()) {
1438
1447
  shelveForHold(hint || "plan limit reached", result?.sessionId ?? null);
1439
1448
  } else if (n >= cfg.maxAttempts) {
@@ -1607,30 +1616,36 @@ function noteAwaiting(awaiting) {
1607
1616
  if (announcedAwaiting.size > 500) announcedAwaiting.clear();
1608
1617
  }
1609
1618
 
1619
+ /** The host context every hands path shares: serveCalls (visitor calls, plans) and
1620
+ * runPreflight (the host's own first call on a new grant). */
1621
+ function handsContext(cfg) {
1622
+ return {
1623
+ cfg,
1624
+ cfgPath: CONFIG_PATH,
1625
+ home: os.homedir(),
1626
+ // The host's OWN folder list. A granted folder is honoured only if it is here
1627
+ // (or inside one), so the server can never hand a visitor a directory.
1628
+ hostFolders: cfg.hosting?.folders ?? [],
1629
+ doctor: () => doctorReport(["--config", CONFIG_PATH]),
1630
+ claim: (id) => claimHandsCall(cfg, id),
1631
+ report: (id, r) => reportHandsResult(cfg, id, r),
1632
+ // Repair templates reach the same machinery the desktop app's buttons use, so
1633
+ // a fix an agent performs is exactly the fix the host could have clicked.
1634
+ restart: () => reexecSelf(),
1635
+ startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
1636
+ applyConfig: () => applyConfigFromDisk(cfg),
1637
+ log,
1638
+ stopped: () => stopped,
1639
+ visitorLabel: (c) => c.visitor ?? "a visiting agent",
1640
+ onCall: emitHands,
1641
+ };
1642
+ }
1643
+
1610
1644
  async function serveHands(cfg, calls) {
1611
1645
  if (hostingMode(cfg) === "off" || handsBusy || !calls || calls.length === 0) return;
1612
1646
  handsBusy = true;
1613
1647
  try {
1614
- await serveCalls(calls, {
1615
- cfg,
1616
- cfgPath: CONFIG_PATH,
1617
- home: os.homedir(),
1618
- // The host's OWN folder list. A granted folder is honoured only if it is here
1619
- // (or inside one), so the server can never hand a visitor a directory.
1620
- hostFolders: cfg.hosting?.folders ?? [],
1621
- doctor: () => doctorReport(["--config", CONFIG_PATH]),
1622
- claim: (id) => claimHandsCall(cfg, id),
1623
- report: (id, r) => reportHandsResult(cfg, id, r),
1624
- // Repair templates reach the same machinery the desktop app's buttons use, so
1625
- // a fix an agent performs is exactly the fix the host could have clicked.
1626
- restart: () => reexecSelf(),
1627
- startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
1628
- applyConfig: () => applyConfigFromDisk(cfg),
1629
- log,
1630
- stopped: () => stopped,
1631
- visitorLabel: (c) => c.visitor ?? "a visiting agent",
1632
- onCall: emitHands,
1633
- });
1648
+ await serveCalls(calls, handsContext(cfg));
1634
1649
  } catch (e) {
1635
1650
  log(`! hands error: ${e.message}`);
1636
1651
  } finally {
@@ -1638,6 +1653,51 @@ async function serveHands(cfg, calls) {
1638
1653
  }
1639
1654
  }
1640
1655
 
1656
+ /** Open a HOST-initiated call row on a grant (the pre-flight). Same bearer, same
1657
+ * route the claim uses; the server answers { call_id }. */
1658
+ async function createHostCall(cfg, grantId, verb) {
1659
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
1660
+ method: "POST",
1661
+ headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
1662
+ body: JSON.stringify({ grant_id: grantId, verb, host_initiated: true }),
1663
+ });
1664
+ if (!res.ok) throw new Error(`hands ${res.status}`);
1665
+ const j = await res.json().catch(() => ({}));
1666
+ return typeof j.call_id === "string" ? j.call_id : typeof j.call?.id === "string" ? j.call.id : null;
1667
+ }
1668
+
1669
+ // ── PRE-FLIGHT: the first thing a new grant gets is what the machine already knows.
1670
+ // When a grant this Bridge hosts becomes active, the host runs env + doctor +
1671
+ // cli_versions + its own config's shape (tokens stripped) once, locally, read-only,
1672
+ // and posts it as a host-initiated `preflight` call. The visitor reads it from
1673
+ // grant_get before asking anything the machine already answered. Persisted per
1674
+ // grant (bridge.state.json); at most two tries per grant per process.
1675
+ const preflightTries = new Map(); // grantId -> attempts this process
1676
+ async function preflightNewGrants(cfg) {
1677
+ if (hostingMode(cfg) === "off" || handsBusy || !runPreflight) return;
1678
+ const fresh = grantsNeedingPreflight(hands.activeGrants, { preflighted, tried: preflightTries });
1679
+ if (fresh.length === 0) return;
1680
+ handsBusy = true; // never alongside a visitor's call
1681
+ try {
1682
+ for (const grantId of fresh) {
1683
+ if (stopped) break;
1684
+ const n = (preflightTries.get(grantId) ?? 0) + 1;
1685
+ preflightTries.set(grantId, n);
1686
+ log(`◇ pre-flight for grant ${grantId.slice(0, 8)}: env, doctor, CLI versions, config shape`);
1687
+ try {
1688
+ const { callId } = await runPreflight(grantId, { ...handsContext(cfg), create: (gid) => createHostCall(cfg, gid, "preflight") });
1689
+ preflighted.add(grantId);
1690
+ saveRunState();
1691
+ log(` ↳ pre-flight posted (call ${String(callId).slice(0, 8)})`);
1692
+ } catch (e) {
1693
+ log(`! pre-flight for grant ${grantId.slice(0, 8)} failed: ${e.message}${n >= 2 ? " (not retrying until the Bridge restarts)" : " (will retry once)"}`);
1694
+ }
1695
+ }
1696
+ } finally {
1697
+ handsBusy = false;
1698
+ }
1699
+ }
1700
+
1641
1701
  /** Poll for granted calls (the net under the push channel, and the whole story on a
1642
1702
  * server or network without SSE). No-ops entirely when not hosting. */
1643
1703
  async function pollHands(cfg) {
@@ -1651,6 +1711,7 @@ async function pollHands(cfg) {
1651
1711
  }
1652
1712
  hands.activeGrants = r.grants ?? [];
1653
1713
  noteAwaiting(r.awaiting ?? []);
1714
+ await preflightNewGrants(cfg);
1654
1715
  await serveHands(cfg, r.calls);
1655
1716
  } catch (e) {
1656
1717
  if (!/40[13]/.test(e.message)) log(`! couldn't check for granted work: ${e.message}`);
@@ -1688,7 +1749,7 @@ async function pullOnce(cfg, { boot = false } = {}) {
1688
1749
  if (j.hands && typeof j.hands === "object") {
1689
1750
  hands.activeGrants = j.hands.grants ?? hands.activeGrants;
1690
1751
  noteAwaiting(j.hands.awaiting ?? []);
1691
- if (hostingMode(cfg) !== "off") void serveHands(cfg, j.hands.calls ?? []);
1752
+ if (hostingMode(cfg) !== "off") void preflightNewGrants(cfg).then(() => serveHands(cfg, j.hands.calls ?? []));
1692
1753
  }
1693
1754
  // 0092: synthesis (summary, caption, vision, answer) thinks HERE, on this
1694
1755
  // member's subscription. Each job carries kind, model and image straight
@@ -1815,7 +1876,7 @@ async function socketLoop(cfg) {
1815
1876
  const j = JSON.parse(ev.data);
1816
1877
  hands.activeGrants = j.grants ?? hands.activeGrants;
1817
1878
  noteAwaiting(j.awaiting ?? []);
1818
- void serveHands(cfg, j.calls ?? []);
1879
+ void preflightNewGrants(cfg).then(() => serveHands(cfg, j.calls ?? []));
1819
1880
  } catch { /* malformed frame — the poll covers it */ }
1820
1881
  }
1821
1882
  }
@@ -2025,6 +2086,45 @@ async function pollOnce(cfg, onlyWorkspaceIds = null) {
2025
2086
 
2026
2087
  /** How often a RUNNING bridge re-checks the deploy manifest ("app updated → I update"). */
2027
2088
  const UPDATE_CHECK_MS = 6 * 60 * 60 * 1000;
2089
+ /** How often to ask the CLI whether it is still signed in (0100). */
2090
+ const AUTH_CHECK_MS = 10 * 60 * 1000;
2091
+ let lastAuthCheck = 0;
2092
+
2093
+ /**
2094
+ * Ask claude whether it is signed in (`claude auth status` prints JSON with
2095
+ * `loggedIn`). Only claude has this today; other vendors are learned from their
2096
+ * failures. Records the state (plan.mjs noteAuth) so the heartbeat advertises it,
2097
+ * and logs once per change. Never throws; an unreadable answer changes nothing.
2098
+ */
2099
+ async function checkClaudeAuth(cfg) {
2100
+ const agent = (cfg?.agents ?? []).find((a) => a && a.enabled !== false && isClaudeCommand && isClaudeCommand(a.command));
2101
+ if (!agent) return null;
2102
+ const cmd = agent.command[0];
2103
+ const env = typeof agentEnv === "function" ? agentEnv(cfg).env : process.env;
2104
+ const out = await new Promise((resolve) => {
2105
+ let text = "";
2106
+ let done = false;
2107
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
2108
+ try {
2109
+ const child = spawn(cmd, ["auth", "status"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
2110
+ child.stdout.on("data", (d) => { text += d; });
2111
+ child.stderr.on("data", (d) => { text += d; });
2112
+ child.on("error", () => finish(null));
2113
+ child.on("close", () => finish(text));
2114
+ setTimeout(() => { try { child.kill(); } catch { /* gone */ } finish(null); }, 15_000).unref();
2115
+ } catch { finish(null); }
2116
+ });
2117
+ if (out === null) return null;
2118
+ let loggedIn = null;
2119
+ const brace = out.indexOf("{");
2120
+ if (brace >= 0) { try { const j = JSON.parse(out.slice(brace)); if (typeof j.loggedIn === "boolean") loggedIn = j.loggedIn; } catch { /* not JSON */ } }
2121
+ if (loggedIn === null && isSignedOutError(out)) loggedIn = false;
2122
+ if (loggedIn === null) return null;
2123
+ const changed = noteAuth("claude", loggedIn);
2124
+ if (changed && !loggedIn) log("! Claude is signed out on this machine → open a terminal, run `claude`, and sign in. This Bridge re-checks every 10 minutes.");
2125
+ if (changed && loggedIn && lastAuthCheck > 0) log("✓ Claude is signed in again — Claude work resumes here.");
2126
+ return loggedIn;
2127
+ }
2028
2128
 
2029
2129
  /**
2030
2130
  * WHO OWNS THIS INSTALL'S VERSION.
@@ -2059,6 +2159,39 @@ let updateNagged = false;
2059
2159
  * Check failures are non-fatal (offline is fine); a FAILED apply never breaks the
2060
2160
  * running code (verification happens before any write; originals in bridge.backup/).
2061
2161
  */
2162
+ /**
2163
+ * Install the runtime + login service and wait for it to come up. Returns true
2164
+ * when a Bridge is running under the service; false (with the reason printed) so
2165
+ * the caller can fall back to a foreground run.
2166
+ */
2167
+ async function installAsService(svc, { cookbookUrl, cfgPath }) {
2168
+ const say = (m) => console.log(` ${m}`);
2169
+ try {
2170
+ console.log("\nInstalling the Bridge as a background service…");
2171
+ await svc.installRuntime({ cookbookUrl, log: say });
2172
+ svc.installService({ config: cfgPath, log: say });
2173
+ process.stdout.write(" Starting");
2174
+ const pid = await svc.waitForBridge(cfgPath, { timeoutMs: 30_000 });
2175
+ console.log("");
2176
+ const st = svc.serviceState({ config: cfgPath });
2177
+ if (pid) {
2178
+ console.log(`\n ✓ The Bridge is running in the background (pid ${pid}). It starts with your computer,`);
2179
+ console.log(" updates itself from cookbook.team, and this window can close.");
2180
+ } else {
2181
+ console.log(`\n ! The service is installed but the Bridge has not reported in yet. Its log: ${st.log}`);
2182
+ }
2183
+ console.log(`\n Config: ${cfgPath}`);
2184
+ console.log(` Log: ${st.log}`);
2185
+ console.log(` Check: ${cli("doctor")}`);
2186
+ console.log(` Restart: ${cli("restart")}`);
2187
+ console.log(` Remove: ${cli("uninstall")}\n`);
2188
+ return true;
2189
+ } catch (e) {
2190
+ console.log(`\n ! Could not install the service: ${e.message}`);
2191
+ return false;
2192
+ }
2193
+ }
2194
+
2062
2195
  async function selfUpdate(cfg, { reexec }) {
2063
2196
  let check;
2064
2197
  try {
@@ -2083,6 +2216,10 @@ async function selfUpdate(cfg, { reexec }) {
2083
2216
  try {
2084
2217
  const replaced = await applyUpdate(cfg, HERE, check);
2085
2218
  log(`⬆ Bridge self-updated to deploy ${check.version} (${replaced.length} file(s), hash-verified; previous in bridge.backup/${check.version}/).`);
2219
+ if (reexec && process.env.COOKBOOK_SERVICE === "1") {
2220
+ log("↻ restarting on the new code (the service brings it back)…");
2221
+ process.exit(0);
2222
+ }
2086
2223
  if (reexec) {
2087
2224
  log("↻ restarting on the new code…");
2088
2225
  const { spawn } = await import("node:child_process");
@@ -2100,6 +2237,9 @@ async function main() {
2100
2237
  const cfg = loadConfig();
2101
2238
  log(`Cookbook Bridge started · ${cfg.cookbookUrl}`);
2102
2239
  log(`Managing: ${cfg.agents.map((a) => a.name).join(", ") || "(no agents enabled!)"} · polling every ${cfg.pollSeconds}s`);
2240
+ // Is the CLI actually able to run? (0100) The answer rides the first heartbeat.
2241
+ await checkClaudeAuth(cfg).catch(() => null);
2242
+ lastAuthCheck = Date.now();
2103
2243
 
2104
2244
  // "When the app updates, so does the Bridge": check the deploy manifest now, then
2105
2245
  // every 6h while running. Set "autoUpdate": false in config to pin.
@@ -2276,6 +2416,13 @@ async function main() {
2276
2416
  log(` Fix it in the app (Connect your agents), or run \`${cli("connect")}\`. The control API stays up so you can.`);
2277
2417
  } else {
2278
2418
  console.error(`\nCouldn't connect to Cookbook: ${e.message}`);
2419
+ // Under a login service the supervisor restarts us at once; a dead token
2420
+ // would then hit the server every 15 seconds forever. Say the fix, then
2421
+ // wait before exiting so the loop is gentle (service.mjs, 2026-09-09).
2422
+ if (process.env.COOKBOOK_SERVICE === "1") {
2423
+ console.error(` Fix: ${cli("connect")} (reconnects; the service picks the new token up on its own). Retrying in 5 minutes.`);
2424
+ await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
2425
+ }
2279
2426
  process.exit(1);
2280
2427
  }
2281
2428
  }
@@ -2345,11 +2492,19 @@ async function main() {
2345
2492
  } else {
2346
2493
  log("✗ Cookbook has rejected this token 5 polls in a row — it was likely revoked (a new login replaces old tokens) or expired.");
2347
2494
  log(` Fix: ${cli("connect")} (reconnects and starts the Bridge)`);
2495
+ if (process.env.COOKBOOK_SERVICE === "1") {
2496
+ log(" Running as a service: retrying in 5 minutes.");
2497
+ await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
2498
+ }
2348
2499
  process.exit(1);
2349
2500
  }
2350
2501
  }
2351
2502
  }
2352
2503
  }
2504
+ if (Date.now() - lastAuthCheck > AUTH_CHECK_MS) {
2505
+ lastAuthCheck = Date.now();
2506
+ void checkClaudeAuth(cfg).catch(() => null);
2507
+ }
2353
2508
  if (Date.now() - lastUpdateCheck > UPDATE_CHECK_MS) {
2354
2509
  lastUpdateCheck = Date.now();
2355
2510
  await selfUpdate(cfg, { reexec: true });
@@ -2472,6 +2627,18 @@ async function doctorReport(args) {
2472
2627
  }
2473
2628
  }
2474
2629
 
2630
+ // 2a½. The login service (2026-09-09): installed? running?
2631
+ try {
2632
+ const svc = await import("./service.mjs");
2633
+ const st = svc.serviceState({ config: cfgPath });
2634
+ if (!st.kind) ok("Login service: not available on this platform (run the Bridge in a terminal)");
2635
+ else if (st.installed && st.pid) ok(`Login service: installed (${st.definition}) and running (pid ${st.pid})`);
2636
+ else if (st.installed) warn(`Login service: installed (${st.definition}) but no Bridge is reporting in`, `look at ${st.log}, or \`${cli("restart")}\``);
2637
+ else if (!IS_DESKTOP) warn("Login service: not installed, so the Bridge stops when this window closes", `\`${cli("install")}\` installs it and starts it now`);
2638
+ } catch (e) {
2639
+ ok(`Login service: could not check (${e.message})`);
2640
+ }
2641
+
2475
2642
  // 2b. Another Bridge on this machine? Two on one config fight over the same token
2476
2643
  // (a `connect` revokes the other's); one on a different config is the classic
2477
2644
  // "I connected but a stale Bridge is still running" trap.
@@ -2628,6 +2795,9 @@ async function doctorReport(args) {
2628
2795
  }
2629
2796
 
2630
2797
  if (isClaudeCommand && isClaudeCommand(agent.command)) {
2798
+ const signedIn = await checkClaudeAuth({ ...cfg, agents: [agent] }).catch(() => null);
2799
+ if (signedIn === false) bad(`${agent.name}: claude is SIGNED OUT on this machine`, "open a terminal, run `claude`, and sign in (or `claude auth login`)");
2800
+ else if (signedIn === true) ok(`${agent.name}: claude is signed in`);
2631
2801
  if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
2632
2802
  else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
2633
2803
  `run \`${cli("connect")}\` (mints a token for this agent) or add "token" to this agent in ${cfgPath}`);
@@ -2796,13 +2966,24 @@ if (!IS_MAIN) {
2796
2966
  .then(async (m) => {
2797
2967
  const args = process.argv.slice(3);
2798
2968
  const noRun = args.includes("--no-run");
2799
- const r = await m.connectAgents(args.filter((a) => a !== "--no-run"), { willRun: !noRun });
2969
+ // THE SERVICE (2026-09-09): after the approval the Bridge is installed as a
2970
+ // login service and started, so the window can close and it survives reboots.
2971
+ // `--no-service` keeps the foreground run; the desktop app supervises its own.
2972
+ const svc = await import("./service.mjs");
2973
+ const wantService = !noRun && !args.includes("--no-service") && !IS_DESKTOP && !!svc.serviceKind();
2974
+ const passArgs = args.filter((a) => a !== "--no-run" && a !== "--no-service" && a !== "--no-signin").concat(args.includes("--no-signin") ? ["--no-signin"] : []);
2975
+ const r = await m.connectAgents(passArgs, { willRun: !noRun, service: wantService });
2800
2976
  if (!r || !r.ok) {
2801
2977
  // Nothing to run (no agent CLI found): the doctor says what is missing and how to fix it.
2802
2978
  if (r && r.reason === "no-agents") await runDoctor(["--config", r.cfgPath]);
2803
2979
  return;
2804
2980
  }
2805
2981
  if (noRun || !r.startBridge) return;
2982
+ if (wantService) {
2983
+ const done = await installAsService(svc, { cookbookUrl: r.baseUrl, cfgPath: r.cfgPath });
2984
+ if (done) return;
2985
+ console.log("Falling back to running the Bridge here.\n");
2986
+ }
2806
2987
  console.log("Connected. Running the Bridge now; leave this window open. Ctrl-C stops it.\n");
2807
2988
  process.argv = [process.argv[0], process.argv[1], r.cfgPath];
2808
2989
  await main();
@@ -2839,6 +3020,33 @@ if (!IS_MAIN) {
2839
3020
  console.error(e.message);
2840
3021
  process.exit(1);
2841
3022
  });
3023
+ } else if (sub === "install") {
3024
+ // Runtime + login service + start, against an existing config (connect does this
3025
+ // for you; `install` is for a machine that already has a config).
3026
+ (async () => {
3027
+ const svc = await import("./service.mjs");
3028
+ const cfgPath = configPathFromArgs(process.argv.slice(3));
3029
+ let cookbookUrl = "";
3030
+ try { cookbookUrl = String(JSON.parse(fs.readFileSync(cfgPath, "utf8")).cookbookUrl || "").replace(/\/$/, ""); } catch { /* below */ }
3031
+ if (!cookbookUrl) { console.error(`No config at ${cfgPath}. Run \`${cli("connect")}\` first.`); process.exit(1); }
3032
+ const ok = await installAsService(svc, { cookbookUrl, cfgPath });
3033
+ process.exit(ok ? 0 : 1);
3034
+ })().catch((e) => { console.error(e.message); process.exit(1); });
3035
+ } else if (sub === "uninstall") {
3036
+ (async () => {
3037
+ const svc = await import("./service.mjs");
3038
+ const cfgPath = configPathFromArgs(process.argv.slice(3));
3039
+ const r = svc.uninstallService({ config: cfgPath, log: (m) => console.log(` ${m}`) });
3040
+ console.log(r.removed.length ? `Service removed (${r.removed.join(", ")}).` : "No service was installed.");
3041
+ console.log(`Your config and token are untouched at ${cfgPath}. To revoke the Bridge's access, remove it under Account > Connected apps.`);
3042
+ })().catch((e) => { console.error(e.message); process.exit(1); });
3043
+ } else if (sub === "restart") {
3044
+ (async () => {
3045
+ const svc = await import("./service.mjs");
3046
+ const cfgPath = configPathFromArgs(process.argv.slice(3));
3047
+ const ok = svc.restartService({ config: cfgPath });
3048
+ console.log(ok ? "Restarting the Bridge service." : `No running service found for ${cfgPath}. Start one with \`${cli("install")}\`.`);
3049
+ })().catch((e) => { console.error(e.message); process.exit(1); });
2842
3050
  } else if (sub === "status") {
2843
3051
  import("./device.mjs")
2844
3052
  .then((m) => m.status(process.argv.slice(3)))
package/chef-persona.md CHANGED
@@ -7,3 +7,5 @@ What you know: Cookbook is the shared brain a team's AI agents plug into. Every
7
7
  How you work: the message you receive carries the rules for this conversation (a grant id, whether you may ask for machine access, and Cookbook Help notes that match the question). Follow those rules exactly; they override anything here. Use the Cookbook tools only. When the answer depends on the person's machine, ask for access the way the rules describe. Never send them to a terminal.
8
8
 
9
9
  You are running on this person's own machine and subscription as their agent. You can see their workspaces. When you need to look at or change their setup, ask for a hands grant in the usual way; every action still waits for their click.
10
+
11
+ When a fix needs more than one change, do not ask for each one. Submit ONE hands_plan with a plain `why` and the steps in order, then wait for the person's single approval; the Bridge runs the steps one at a time and stops at the first failure. Before you ask about the machine, read the pre-flight the host posted on the grant (grant_get): what is installed, the doctor rows, CLI versions and the shape of the Bridge config are already there. Never ask for something the machine has already answered.
package/cookbook.mjs CHANGED
@@ -8,7 +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
+ import { planParam, signedOutParam } from "./plan.mjs";
12
12
 
13
13
  /** Call one Cookbook MCP tool. Returns the tool's body (structuredContent). */
14
14
  export async function callTool(cfg, name, args = {}) {
@@ -260,6 +260,9 @@ export function agentsQuery(cfg, plan = planParam) {
260
260
  // reported them to this Bridge (bridge/plan.mjs). Rides the same heartbeat.
261
261
  const p = typeof plan === "function" ? plan() : "";
262
262
  if (p) parts.push(p);
263
+ // `signed_out=…` — which of those agents' CLIs cannot run right now (0100).
264
+ const so = signedOutParam();
265
+ if (so) parts.push(so);
263
266
  return parts.length ? `?${parts.join("&")}` : "";
264
267
  }
265
268
 
package/device.mjs CHANGED
@@ -582,7 +582,7 @@ function probeAgent(cmd) {
582
582
  * Codex (ChatGPT app), Kimi and OpenClaw; mints one named token per agent in the same
583
583
  * approval as the Bridge token; configures each via its official path.
584
584
  */
585
- export async function connectAgents(argv, { willRun = false } = {}) {
585
+ export async function connectAgents(argv, { willRun = false, service = false } = {}) {
586
586
  const cfgPath = configPath(argv);
587
587
  const found = detectClis();
588
588
  if (found.length === 0) {
@@ -592,6 +592,13 @@ export async function connectAgents(argv, { willRun = false } = {}) {
592
592
  }
593
593
  console.log(`\nFound agent CLIs: ${found.map((c) => c.agent).join(", ")}`);
594
594
 
595
+ // ONE-CLICK SIGN-IN (2026-09-09). The Bridge can only run a CLI that is signed
596
+ // in, and "signed out" was the failure behind every dead task Texas Accelerate
597
+ // and Pierre hit. So before the Cookbook approval, sign the CLIs in right here:
598
+ // claude opens Anthropic's own login page; codex shows OpenAI's device code.
599
+ // Each token lands in that CLI's own store on this disk; Cookbook never sees it.
600
+ if (!argv.includes("--no-signin")) await ensureSignedIn(found);
601
+
595
602
  // One approval mints the bridge token + one named token per agent.
596
603
  const res = await login(argv, { agents: found.map((c) => c.agent), quietOutro: true });
597
604
  if (!res || !res.agentTokens) {
@@ -605,8 +612,77 @@ export async function connectAgents(argv, { willRun = false } = {}) {
605
612
  console.log(` ${mark} ${r.agent}: ${r.detail}${r.ok && !r.warn ? ` (work will be attributed "${r.agent} · via you")` : ""}`);
606
613
  }
607
614
  await reportStaleness(res.baseUrl);
608
- reportNextStep(res.cfgPath, { willRun });
609
- return { ok: true, startBridge: true, cfgPath: res.cfgPath, agentTokens: true };
615
+ if (!service) reportNextStep(res.cfgPath, { willRun });
616
+ return { ok: true, startBridge: true, cfgPath: res.cfgPath, agentTokens: true, baseUrl: res.baseUrl, found };
617
+ }
618
+
619
+ /** `claude auth status` → true / false / null (unknown). Never throws. */
620
+ export function claudeSignedIn(claudePath) {
621
+ try {
622
+ const argv = argvForSpawn([claudePath, "auth", "status"]);
623
+ const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 15_000, windowsHide: true });
624
+ const out = `${r.stdout || ""}${r.stderr || ""}`;
625
+ const brace = out.indexOf("{");
626
+ if (brace >= 0) { try { const j = JSON.parse(out.slice(brace)); if (typeof j.loggedIn === "boolean") return j.loggedIn; } catch { /* not JSON */ } }
627
+ if (/not logged in|please log in|expired|not authenticated/i.test(out)) return false;
628
+ return null;
629
+ } catch {
630
+ return null;
631
+ }
632
+ }
633
+
634
+ /** Does codex have a login on this machine? (`~/.codex/auth.json` is what `codex login` writes.) */
635
+ export function codexSignedIn(home = os.homedir()) {
636
+ try {
637
+ const j = JSON.parse(fs.readFileSync(path.join(home, ".codex", "auth.json"), "utf8"));
638
+ return !!(j && (j.tokens || j.OPENAI_API_KEY || j.access_token));
639
+ } catch {
640
+ return false;
641
+ }
642
+ }
643
+
644
+ /** Run a CLI's own interactive sign-in in this terminal and wait for it. */
645
+ function runInteractive(bin, args) {
646
+ return new Promise((resolve) => {
647
+ try {
648
+ const argv = argvForSpawn([bin, ...args]);
649
+ const child = spawn(argv[0], argv.slice(1), { stdio: "inherit" });
650
+ child.on("error", () => resolve(false));
651
+ child.on("close", (code) => resolve(code === 0));
652
+ } catch {
653
+ resolve(false);
654
+ }
655
+ });
656
+ }
657
+
658
+ /**
659
+ * Sign in every detected CLI that is signed out, in the terminal, before anything
660
+ * else. Claude: Anthropic's browser login (`claude auth login`). Codex: OpenAI's
661
+ * device code (`codex login --device-auth`). Skipped for CLIs we cannot check.
662
+ */
663
+ export async function ensureSignedIn(found, { log = console.log } = {}) {
664
+ const outcome = [];
665
+ for (const c of found) {
666
+ if (c.vendor === "claude") {
667
+ const state = claudeSignedIn(c.path);
668
+ if (state === false) {
669
+ log("\n Claude Code is signed out on this machine. Sign in with the account you use in the browser:\n");
670
+ const ok = await runInteractive(c.path, ["auth", "login"]);
671
+ const after = claudeSignedIn(c.path);
672
+ log(after ? "\n ✓ Claude Code is signed in.\n" : "\n ! Claude Code still looks signed out. Run `claude` in a terminal and sign in, then re-run connect.\n");
673
+ outcome.push({ agent: "Claude", signedIn: !!after, ran: ok });
674
+ } else outcome.push({ agent: "Claude", signedIn: state !== false, ran: false });
675
+ } else if (c.vendor === "codex" && c.kind === "codex" && !/ChatGPT\.app/i.test(String(c.path))) {
676
+ if (!codexSignedIn()) {
677
+ log("\n Codex has no ChatGPT login on this machine. Enter the code OpenAI shows you:\n");
678
+ const ok = await runInteractive(c.path, ["login", "--device-auth"]);
679
+ const after = codexSignedIn();
680
+ log(after ? "\n ✓ Codex is signed in with your ChatGPT account.\n" : "\n ! Codex still has no login. Run `codex login` in a terminal, then re-run connect.\n");
681
+ outcome.push({ agent: "Codex", signedIn: after, ran: ok });
682
+ } else outcome.push({ agent: "Codex", signedIn: true, ran: false });
683
+ }
684
+ }
685
+ return outcome;
610
686
  }
611
687
 
612
688
  /** The same manifest comparison the running Bridge does at startup, printed with the