cookbook-bridge 0.1.13 → 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,9 @@
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
+
5
8
  **0.1.13** (2026-09-02)
6
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.
7
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.
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.
@@ -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";
@@ -1439,6 +1439,10 @@ async function processTask(cfg, ws, task, agent) {
1439
1439
  // turns a multi-hour debug into a one-glance fix.
1440
1440
  const hint = failureHint(result);
1441
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
+ }
1442
1446
  if (holdDuringRun()) {
1443
1447
  shelveForHold(hint || "plan limit reached", result?.sessionId ?? null);
1444
1448
  } else if (n >= cfg.maxAttempts) {
@@ -2082,6 +2086,45 @@ async function pollOnce(cfg, onlyWorkspaceIds = null) {
2082
2086
 
2083
2087
  /** How often a RUNNING bridge re-checks the deploy manifest ("app updated → I update"). */
2084
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
+ }
2085
2128
 
2086
2129
  /**
2087
2130
  * WHO OWNS THIS INSTALL'S VERSION.
@@ -2116,6 +2159,39 @@ let updateNagged = false;
2116
2159
  * Check failures are non-fatal (offline is fine); a FAILED apply never breaks the
2117
2160
  * running code (verification happens before any write; originals in bridge.backup/).
2118
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
+
2119
2195
  async function selfUpdate(cfg, { reexec }) {
2120
2196
  let check;
2121
2197
  try {
@@ -2140,6 +2216,10 @@ async function selfUpdate(cfg, { reexec }) {
2140
2216
  try {
2141
2217
  const replaced = await applyUpdate(cfg, HERE, check);
2142
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
+ }
2143
2223
  if (reexec) {
2144
2224
  log("↻ restarting on the new code…");
2145
2225
  const { spawn } = await import("node:child_process");
@@ -2157,6 +2237,9 @@ async function main() {
2157
2237
  const cfg = loadConfig();
2158
2238
  log(`Cookbook Bridge started · ${cfg.cookbookUrl}`);
2159
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();
2160
2243
 
2161
2244
  // "When the app updates, so does the Bridge": check the deploy manifest now, then
2162
2245
  // every 6h while running. Set "autoUpdate": false in config to pin.
@@ -2333,6 +2416,13 @@ async function main() {
2333
2416
  log(` Fix it in the app (Connect your agents), or run \`${cli("connect")}\`. The control API stays up so you can.`);
2334
2417
  } else {
2335
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
+ }
2336
2426
  process.exit(1);
2337
2427
  }
2338
2428
  }
@@ -2402,11 +2492,19 @@ async function main() {
2402
2492
  } else {
2403
2493
  log("✗ Cookbook has rejected this token 5 polls in a row — it was likely revoked (a new login replaces old tokens) or expired.");
2404
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
+ }
2405
2499
  process.exit(1);
2406
2500
  }
2407
2501
  }
2408
2502
  }
2409
2503
  }
2504
+ if (Date.now() - lastAuthCheck > AUTH_CHECK_MS) {
2505
+ lastAuthCheck = Date.now();
2506
+ void checkClaudeAuth(cfg).catch(() => null);
2507
+ }
2410
2508
  if (Date.now() - lastUpdateCheck > UPDATE_CHECK_MS) {
2411
2509
  lastUpdateCheck = Date.now();
2412
2510
  await selfUpdate(cfg, { reexec: true });
@@ -2529,6 +2627,18 @@ async function doctorReport(args) {
2529
2627
  }
2530
2628
  }
2531
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
+
2532
2642
  // 2b. Another Bridge on this machine? Two on one config fight over the same token
2533
2643
  // (a `connect` revokes the other's); one on a different config is the classic
2534
2644
  // "I connected but a stale Bridge is still running" trap.
@@ -2685,6 +2795,9 @@ async function doctorReport(args) {
2685
2795
  }
2686
2796
 
2687
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`);
2688
2801
  if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
2689
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`,
2690
2803
  `run \`${cli("connect")}\` (mints a token for this agent) or add "token" to this agent in ${cfgPath}`);
@@ -2853,13 +2966,24 @@ if (!IS_MAIN) {
2853
2966
  .then(async (m) => {
2854
2967
  const args = process.argv.slice(3);
2855
2968
  const noRun = args.includes("--no-run");
2856
- 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 });
2857
2976
  if (!r || !r.ok) {
2858
2977
  // Nothing to run (no agent CLI found): the doctor says what is missing and how to fix it.
2859
2978
  if (r && r.reason === "no-agents") await runDoctor(["--config", r.cfgPath]);
2860
2979
  return;
2861
2980
  }
2862
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
+ }
2863
2987
  console.log("Connected. Running the Bridge now; leave this window open. Ctrl-C stops it.\n");
2864
2988
  process.argv = [process.argv[0], process.argv[1], r.cfgPath];
2865
2989
  await main();
@@ -2896,6 +3020,33 @@ if (!IS_MAIN) {
2896
3020
  console.error(e.message);
2897
3021
  process.exit(1);
2898
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); });
2899
3050
  } else if (sub === "status") {
2900
3051
  import("./device.mjs")
2901
3052
  .then((m) => m.status(process.argv.slice(3)))
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cookbook-bridge",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
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": {
@@ -33,6 +33,7 @@
33
33
  "synthesis.mjs",
34
34
  "thread-runner.mjs",
35
35
  "update.mjs",
36
+ "service.mjs",
36
37
  "usage.mjs",
37
38
  "volunteer.mjs"
38
39
  ],
@@ -40,7 +41,7 @@
40
41
  "node": ">=18"
41
42
  },
42
43
  "scripts": {
43
- "test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs test/kimi.test.mjs test/hands.test.mjs"
44
+ "test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs test/kimi.test.mjs test/hands.test.mjs test/probe.test.mjs"
44
45
  },
45
46
  "keywords": [
46
47
  "cookbook",
@@ -55,11 +56,10 @@
55
56
  "homepage": "https://cookbook.team",
56
57
  "repository": {
57
58
  "type": "git",
58
- "url": "git+https://github.com/dpro10/cookbook.git",
59
- "directory": "bridge"
59
+ "url": "git+https://github.com/dpro10/cookbook-bridge.git"
60
60
  },
61
61
  "bugs": {
62
- "url": "https://cookbook.team"
62
+ "url": "https://github.com/dpro10/cookbook-bridge/issues"
63
63
  },
64
64
  "license": "MIT",
65
65
  "publishConfig": {
package/plan.mjs CHANGED
@@ -123,3 +123,44 @@ export function planLine(vendor, entry) {
123
123
  if (s) parts.push(`${s} of week`);
124
124
  return `${vendor}${entry?.plan ? ` (${entry.plan})` : ""}: ${parts.join(" · ") || "no windows"}`;
125
125
  }
126
+
127
+ // ── SIGNED OUT (0100, 2026-09-07) ─────────────────────────────────────────────
128
+ // A Bridge used to advertise an agent because its CLI was installed; whether the
129
+ // CLI could actually run was learned one failed task at a time ("OAuth session
130
+ // expired and could not be refreshed", every run, Pierre's Mac). The Bridge now
131
+ // asks the CLI (`claude auth status`) at boot, every ten minutes, and right after
132
+ // an auth failure, and rides the answer on the same heartbeat as `agents=`.
133
+ const auth = new Map(); // vendor -> { ok: boolean, at }
134
+ const SIGNED_OUT_NAME = { claude: "Claude", codex: "Codex", gemini: "Gemini", openclaw: "OpenClaw" };
135
+
136
+ /** Record a vendor's sign-in state. Returns true when it CHANGED (log once, not per beat). */
137
+ export function noteAuth(vendor, ok, now = Date.now()) {
138
+ if (!VENDORS.has(vendor)) return false;
139
+ const prev = auth.get(vendor);
140
+ auth.set(vendor, { ok: !!ok, at: now });
141
+ return !prev || prev.ok !== !!ok;
142
+ }
143
+
144
+ /** Vendors currently known to be signed out. */
145
+ export function signedOutVendors() {
146
+ return [...auth].filter(([, v]) => !v.ok).map(([k]) => k);
147
+ }
148
+
149
+ /** `signed_out=Claude` once auth has been checked ("signed_out=" when everything is
150
+ * signed in, so the server clears the flag the moment the member signs back in);
151
+ * "" before any check, so an unchecked Bridge leaves the column alone. */
152
+ export function signedOutParam() {
153
+ if (!auth.size) return "";
154
+ return `signed_out=${encodeURIComponent(signedOutVendors().map((v) => SIGNED_OUT_NAME[v] ?? v).join(","))}`;
155
+ }
156
+
157
+ /** For tests. */
158
+ export function resetAuth() {
159
+ auth.clear();
160
+ }
161
+
162
+ /** Does this CLI failure text mean "signed out"? Judged on stderr, never on the model's answer. */
163
+ export function isSignedOutError(text) {
164
+ const t = String(text ?? "").toLowerCase();
165
+ return /oauth session expired|could not be refreshed|failed to authenticate|not logged in|please log in|login required|not authenticated/.test(t);
166
+ }
package/service.mjs ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * THE BRIDGE AS A SERVICE (2026-09-09) — install once, runs from login, updates itself.
3
+ *
4
+ * Before this, the terminal path ended with "leave this window open": close the
5
+ * window or reboot and the Bridge was gone, and an npx-run copy could never update
6
+ * itself (npm owns those files). Texas Accelerate's first Bridge lived for ninety
7
+ * seconds. This module makes `connect` end differently:
8
+ *
9
+ * 1. RUNTIME the current Bridge is downloaded from the deploy into
10
+ * ~/.cookbook/bridge (hash-verified against /api/bridge/manifest by
11
+ * update.mjs). Nothing runs from the npx cache, so the running copy
12
+ * is on the "self" update channel and tracks every deploy on its own.
13
+ * 2. SERVICE a login service runs `node ~/.cookbook/bridge/bridge.mjs <config>`
14
+ * with COOKBOOK_SERVICE=1 and restarts it if it exits:
15
+ * macOS a LaunchAgent in ~/Library/LaunchAgents (KeepAlive)
16
+ * Windows a hidden launcher in the user's Startup folder that
17
+ * loops node until a stop file appears (no admin, no schtasks)
18
+ * Linux a systemd --user unit (Restart=always)
19
+ * 3. START it is started right away; `connect` waits for the Bridge's
20
+ * local.json to say it is up before printing the pid.
21
+ *
22
+ * Under a service, a self-update just exits: the supervisor restarts the new code
23
+ * (bridge.mjs selfUpdate checks COOKBOOK_SERVICE). The config and token are never
24
+ * touched by any of this. Everything that builds a file or a command is pure and
25
+ * exported for scripts/test-bridge-service.ts; the I/O sits in install/uninstall.
26
+ *
27
+ * Why ~/.cookbook and not the app folder or the Desktop: launchd agents pointed at
28
+ * a TCC-protected folder (Desktop, Documents) die with EX_CONFIG before spawning
29
+ * (25 silent crash-loops on 2026-08-24). The home dot-folder is always allowed.
30
+ */
31
+ import fs from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import { spawn, spawnSync } from "node:child_process";
35
+ import { configHome, defaultConfigPath, checkForUpdate, applyUpdate } from "./update.mjs";
36
+
37
+ export const SERVICE_LABEL = "team.cookbook.bridge";
38
+ export const WINDOWS_TASK_NAME = "Cookbook Bridge";
39
+
40
+ /** `~/.cookbook/bridge`: the runtime the service runs. */
41
+ export function runtimeDir(home = os.homedir()) {
42
+ return path.join(configHome(home), "bridge");
43
+ }
44
+ /** `~/.cookbook/bridge.log`: everything the service's Bridge prints. */
45
+ export function serviceLogPath(home = os.homedir()) {
46
+ return path.join(configHome(home), "bridge.log");
47
+ }
48
+ /** `~/.cookbook/service.stop`: Windows only, tells the launcher loop to end. */
49
+ export function stopFilePath(home = os.homedir()) {
50
+ return path.join(configHome(home), "service.stop");
51
+ }
52
+
53
+ // ── macOS ─────────────────────────────────────────────────────────────────────
54
+
55
+ export function launchdPlistPath(home = os.homedir()) {
56
+ return path.join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
57
+ }
58
+
59
+ function xmlEscape(s) {
60
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
61
+ }
62
+
63
+ /** The LaunchAgent. PATH carries node's own folder first so a version-managed node resolves. */
64
+ export function launchdPlist({ node, script, config, home, logPath, pathEnv = process.env.PATH || "" }) {
65
+ const pathParts = [path.dirname(node), ...String(pathEnv).split(":"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
66
+ .filter((p, i, arr) => p && arr.indexOf(p) === i);
67
+ return `<?xml version="1.0" encoding="UTF-8"?>
68
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
69
+ <plist version="1.0">
70
+ <dict>
71
+ <!-- Cookbook Bridge: runs your agents for your team, on your subscriptions.
72
+ Installed by \`npx cookbook-bridge@latest connect\`; remove with
73
+ \`npx cookbook-bridge@latest uninstall\`. It updates itself from cookbook.team. -->
74
+ <key>Label</key>
75
+ <string>${SERVICE_LABEL}</string>
76
+ <key>ProgramArguments</key>
77
+ <array>
78
+ <string>${xmlEscape(node)}</string>
79
+ <string>${xmlEscape(script)}</string>
80
+ <string>${xmlEscape(config)}</string>
81
+ </array>
82
+ <key>WorkingDirectory</key>
83
+ <string>${xmlEscape(path.dirname(script))}</string>
84
+ <key>EnvironmentVariables</key>
85
+ <dict>
86
+ <key>PATH</key>
87
+ <string>${xmlEscape(pathParts.join(":"))}</string>
88
+ <key>HOME</key>
89
+ <string>${xmlEscape(home)}</string>
90
+ <key>COOKBOOK_SERVICE</key>
91
+ <string>1</string>
92
+ </dict>
93
+ <key>RunAtLoad</key>
94
+ <true/>
95
+ <key>KeepAlive</key>
96
+ <true/>
97
+ <key>ThrottleInterval</key>
98
+ <integer>15</integer>
99
+ <key>StandardOutPath</key>
100
+ <string>${xmlEscape(logPath)}</string>
101
+ <key>StandardErrorPath</key>
102
+ <string>${xmlEscape(logPath)}</string>
103
+ </dict>
104
+ </plist>
105
+ `;
106
+ }
107
+
108
+ // ── Linux ─────────────────────────────────────────────────────────────────────
109
+
110
+ export function systemdUnitPath(home = os.homedir()) {
111
+ return path.join(home, ".config", "systemd", "user", "cookbook-bridge.service");
112
+ }
113
+
114
+ export function systemdUnit({ node, script, config, home, logPath, pathEnv = process.env.PATH || "" }) {
115
+ const pathParts = [path.dirname(node), ...String(pathEnv).split(":"), "/usr/local/bin", "/usr/bin", "/bin"]
116
+ .filter((p, i, arr) => p && arr.indexOf(p) === i);
117
+ const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
118
+ return `[Unit]
119
+ Description=Cookbook Bridge (runs your agents for your team, on your subscriptions)
120
+ After=network-online.target
121
+
122
+ [Service]
123
+ ExecStart=${q(node)} ${q(script)} ${q(config)}
124
+ WorkingDirectory=${path.dirname(script)}
125
+ Environment=COOKBOOK_SERVICE=1
126
+ Environment=HOME=${home}
127
+ Environment=PATH=${pathParts.join(":")}
128
+ Restart=always
129
+ RestartSec=15
130
+ StandardOutput=append:${logPath}
131
+ StandardError=append:${logPath}
132
+
133
+ [Install]
134
+ WantedBy=default.target
135
+ `;
136
+ }
137
+
138
+ // ── Windows ───────────────────────────────────────────────────────────────────
139
+
140
+ /** The Startup folder: anything here runs at logon for this user, no admin needed. */
141
+ export function windowsStartupDir(env = process.env) {
142
+ const appData = env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
143
+ return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
144
+ }
145
+ export function windowsLauncherPaths(home = os.homedir(), env = process.env) {
146
+ return {
147
+ cmd: path.join(configHome(home), "bridge-service.cmd"),
148
+ vbs: path.join(configHome(home), "bridge-service.vbs"),
149
+ startup: path.join(windowsStartupDir(env), `${WINDOWS_TASK_NAME}.vbs`),
150
+ };
151
+ }
152
+
153
+ /** The loop: run the Bridge, wait 15s, run again, until the stop file exists. */
154
+ export function windowsCmdScript({ node, script, config, logPath, stopFile }) {
155
+ return `@echo off
156
+ rem Cookbook Bridge service loop. Installed by "npx cookbook-bridge@latest connect";
157
+ rem remove with "npx cookbook-bridge@latest uninstall". The Bridge updates itself.
158
+ set COOKBOOK_SERVICE=1
159
+ :loop
160
+ if exist "${stopFile}" exit /b 0
161
+ "${node}" "${script}" "${config}" >> "${logPath}" 2>&1
162
+ if exist "${stopFile}" exit /b 0
163
+ timeout /t 15 /nobreak >nul
164
+ goto loop
165
+ `;
166
+ }
167
+
168
+ /** Runs the .cmd with no window. */
169
+ export function windowsVbsScript({ cmdPath }) {
170
+ return `Set sh = CreateObject("WScript.Shell")
171
+ sh.Run "cmd.exe /c """ & "${cmdPath.replace(/"/g, '""')}" & """", 0, False
172
+ `;
173
+ }
174
+
175
+ // ── what this machine has ─────────────────────────────────────────────────────
176
+
177
+ export function serviceKind(platform = process.platform) {
178
+ if (platform === "darwin") return "launchd";
179
+ if (platform === "win32") return "startup";
180
+ if (platform === "linux") return "systemd";
181
+ return null;
182
+ }
183
+
184
+ /** The files a service install would write, per platform. Pure. */
185
+ export function servicePaths({ platform = process.platform, home = os.homedir(), env = process.env } = {}) {
186
+ const kind = serviceKind(platform);
187
+ if (kind === "launchd") return { kind, definition: launchdPlistPath(home) };
188
+ if (kind === "systemd") return { kind, definition: systemdUnitPath(home) };
189
+ if (kind === "startup") return { kind, definition: windowsLauncherPaths(home, env).startup };
190
+ return { kind: null, definition: null };
191
+ }
192
+
193
+ /** pid from the running Bridge's local.json next to the config, if that pid is alive. */
194
+ export function runningPid(config) {
195
+ try {
196
+ const local = JSON.parse(fs.readFileSync(path.join(path.dirname(config), "local.json"), "utf8"));
197
+ const pid = Number(local.pid);
198
+ if (!pid) return null;
199
+ process.kill(pid, 0);
200
+ return pid;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /** Installed? Running? One object for `status`, `doctor` and `connect`. */
207
+ export function serviceState({ platform = process.platform, home = os.homedir(), env = process.env, config = defaultConfigPath(home) } = {}) {
208
+ const sp = servicePaths({ platform, home, env });
209
+ const installed = !!sp.definition && fs.existsSync(sp.definition);
210
+ return { kind: sp.kind, definition: sp.definition, installed, pid: runningPid(config), runtime: runtimeDir(home), log: serviceLogPath(home) };
211
+ }
212
+
213
+ // ── I/O ───────────────────────────────────────────────────────────────────────
214
+
215
+ function run(cmd, args, { allowFail = false } = {}) {
216
+ const r = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true });
217
+ if (r.error) { if (allowFail) return r; throw new Error(`${cmd} ${args.join(" ")}: ${r.error.message}`); }
218
+ if (r.status !== 0 && !allowFail) throw new Error(`${cmd} ${args.join(" ")} exited ${r.status}: ${(r.stderr || r.stdout || "").trim().slice(0, 300)}`);
219
+ return r;
220
+ }
221
+
222
+ /**
223
+ * Download (or refresh) the runtime into ~/.cookbook/bridge from the deploy. Uses
224
+ * the same manifest + tar + hash verification as a self-update, so a first install
225
+ * is just "every file is outdated". Returns { dir, version, replaced }.
226
+ */
227
+ export async function installRuntime({ cookbookUrl, home = os.homedir(), log = () => {} } = {}) {
228
+ const dir = runtimeDir(home);
229
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
230
+ const cfg = { cookbookUrl: String(cookbookUrl || "https://cookbook.team").replace(/\/$/, "") };
231
+ const check = await checkForUpdate(cfg, dir);
232
+ if (check.changed.length === 0) {
233
+ log(`Runtime is current (deploy ${check.version}) at ${dir}`);
234
+ return { dir, version: check.version, replaced: [] };
235
+ }
236
+ const replaced = await applyUpdate(cfg, dir, check);
237
+ log(`Runtime installed (deploy ${check.version}, ${replaced.length} file${replaced.length === 1 ? "" : "s"}, hash-verified) at ${dir}`);
238
+ return { dir, version: check.version, replaced };
239
+ }
240
+
241
+ /**
242
+ * Write the service definition, register it, and start it now. Throws with a
243
+ * one-line reason when the platform has no supported service; callers fall back
244
+ * to a foreground run. Never touches the config.
245
+ */
246
+ export function installService({ platform = process.platform, home = os.homedir(), env = process.env, node = process.execPath, config = defaultConfigPath(home), log = () => {} } = {}) {
247
+ const kind = serviceKind(platform);
248
+ if (!kind) throw new Error(`no login service for ${platform}; run the Bridge in a terminal instead`);
249
+ const script = path.join(runtimeDir(home), "bridge.mjs");
250
+ if (!fs.existsSync(script)) throw new Error(`runtime missing at ${script}; install it first`);
251
+ const logPath = serviceLogPath(home);
252
+ fs.mkdirSync(configHome(home), { recursive: true, mode: 0o700 });
253
+
254
+ if (kind === "launchd") {
255
+ const plistPath = launchdPlistPath(home);
256
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
257
+ fs.writeFileSync(plistPath, launchdPlist({ node, script, config, home, logPath, pathEnv: env.PATH }), { mode: 0o644 });
258
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 501}`;
259
+ run("launchctl", ["bootout", `${domain}/${SERVICE_LABEL}`], { allowFail: true }); // replace a previous install
260
+ run("launchctl", ["bootstrap", domain, plistPath]);
261
+ run("launchctl", ["kickstart", "-k", `${domain}/${SERVICE_LABEL}`], { allowFail: true });
262
+ log(`LaunchAgent installed: ${plistPath}`);
263
+ return { kind, definition: plistPath, log: logPath };
264
+ }
265
+ if (kind === "systemd") {
266
+ const unitPath = systemdUnitPath(home);
267
+ fs.mkdirSync(path.dirname(unitPath), { recursive: true });
268
+ fs.writeFileSync(unitPath, systemdUnit({ node, script, config, home, logPath, pathEnv: env.PATH }), { mode: 0o644 });
269
+ run("systemctl", ["--user", "daemon-reload"]);
270
+ run("systemctl", ["--user", "enable", "--now", "cookbook-bridge.service"]);
271
+ run("systemctl", ["--user", "restart", "cookbook-bridge.service"], { allowFail: true });
272
+ log(`systemd user unit installed: ${unitPath} (run \`loginctl enable-linger $USER\` once if this machine has no desktop session)`);
273
+ return { kind, definition: unitPath, log: logPath };
274
+ }
275
+ // Windows: a hidden launcher in the Startup folder. No admin, no Task Scheduler.
276
+ const p = windowsLauncherPaths(home, env);
277
+ try { fs.rmSync(stopFilePath(home), { force: true }); } catch { /* none */ }
278
+ fs.writeFileSync(p.cmd, windowsCmdScript({ node, script, config, logPath, stopFile: stopFilePath(home) }));
279
+ fs.writeFileSync(p.vbs, windowsVbsScript({ cmdPath: p.cmd }));
280
+ fs.mkdirSync(path.dirname(p.startup), { recursive: true });
281
+ fs.copyFileSync(p.vbs, p.startup);
282
+ // Start it now, detached and windowless, the same way the Startup folder will.
283
+ const child = spawn("wscript.exe", ["//B", p.vbs], { detached: true, stdio: "ignore", windowsHide: true });
284
+ child.on("error", () => {});
285
+ child.unref();
286
+ log(`Startup launcher installed: ${p.startup}`);
287
+ return { kind, definition: p.startup, log: logPath };
288
+ }
289
+
290
+ /** Stop the service and remove its definition. Leaves the runtime, config and log. */
291
+ export function uninstallService({ platform = process.platform, home = os.homedir(), env = process.env, config = defaultConfigPath(home), log = () => {} } = {}) {
292
+ const kind = serviceKind(platform);
293
+ const removed = [];
294
+ if (kind === "launchd") {
295
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 501}`;
296
+ run("launchctl", ["bootout", `${domain}/${SERVICE_LABEL}`], { allowFail: true });
297
+ const plistPath = launchdPlistPath(home);
298
+ if (fs.existsSync(plistPath)) { fs.rmSync(plistPath, { force: true }); removed.push(plistPath); }
299
+ } else if (kind === "systemd") {
300
+ run("systemctl", ["--user", "disable", "--now", "cookbook-bridge.service"], { allowFail: true });
301
+ const unitPath = systemdUnitPath(home);
302
+ if (fs.existsSync(unitPath)) { fs.rmSync(unitPath, { force: true }); removed.push(unitPath); }
303
+ run("systemctl", ["--user", "daemon-reload"], { allowFail: true });
304
+ } else if (kind === "startup") {
305
+ const p = windowsLauncherPaths(home, env);
306
+ fs.writeFileSync(stopFilePath(home), String(Date.now())); // the loop exits on its next turn
307
+ for (const f of [p.startup, p.vbs, p.cmd]) if (fs.existsSync(f)) { fs.rmSync(f, { force: true }); removed.push(f); }
308
+ }
309
+ const pid = runningPid(config);
310
+ if (pid) {
311
+ try {
312
+ if (platform === "win32") run("taskkill", ["/PID", String(pid), "/T", "/F"], { allowFail: true });
313
+ else process.kill(pid, "SIGTERM");
314
+ log(`Stopped the running Bridge (pid ${pid}).`);
315
+ } catch { /* already gone */ }
316
+ }
317
+ return { kind, removed };
318
+ }
319
+
320
+ /** Restart the supervised Bridge (the supervisor brings it back on the new code/config). */
321
+ export function restartService({ platform = process.platform, home = os.homedir(), config = defaultConfigPath(home) } = {}) {
322
+ const kind = serviceKind(platform);
323
+ if (kind === "launchd") {
324
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 501}`;
325
+ run("launchctl", ["kickstart", "-k", `${domain}/${SERVICE_LABEL}`]);
326
+ return true;
327
+ }
328
+ if (kind === "systemd") { run("systemctl", ["--user", "restart", "cookbook-bridge.service"]); return true; }
329
+ const pid = runningPid(config);
330
+ if (pid) { run("taskkill", ["/PID", String(pid), "/T", "/F"], { allowFail: true }); return true; }
331
+ return false;
332
+ }
333
+
334
+ /** Wait for the service's Bridge to write local.json with a live pid. */
335
+ export async function waitForBridge(config, { timeoutMs = 25_000, everyMs = 500 } = {}) {
336
+ const until = Date.now() + timeoutMs;
337
+ while (Date.now() < until) {
338
+ const pid = runningPid(config);
339
+ if (pid) return pid;
340
+ await new Promise((r) => setTimeout(r, everyMs));
341
+ }
342
+ return null;
343
+ }
package/synthesis.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The server composes a prompt and queues it; this Bridge claimed it on a pull
5
5
  * and now runs it through the claude CLI (your plan, your machine) and posts
6
- * the text back. Four kinds share one lane:
6
+ * the text back. Five kinds share one lane:
7
7
  *
8
8
  * summary workspace summary text in, text out, no tools
9
9
  * caption file captions text in, text out, no tools
@@ -12,6 +12,10 @@
12
12
  * folder, claude runs with cwd = that folder
13
13
  * and ONLY the Read tool, the folder is
14
14
  * deleted afterwards (always, even on timeout)
15
+ * probe one AI-visibility sample a buyer prompt; variant "model" runs with
16
+ * no tools, variant "search" with WebSearch
17
+ * only (allowed up front: print mode denies
18
+ * any tool that is not on --allowedTools)
15
19
  *
16
20
  * `--strict-mcp-config` with an empty set removes every MCP server; `--tools ""`
17
21
  * removes every built-in tool (Read, Bash, Write, WebFetch...) for the text
@@ -23,6 +27,7 @@
23
27
  * Jobs are queued FIFO and drained one at a time. A pull that lands while a run
24
28
  * is in progress only enqueues; nothing is dropped. Zero dependencies.
25
29
  */
30
+ import { isSignedOutError } from "./plan.mjs";
26
31
  import { spawn } from "node:child_process";
27
32
  import fsp from "node:fs/promises";
28
33
  import os from "node:os";
@@ -30,9 +35,11 @@ import path from "node:path";
30
35
 
31
36
  export const SYNTHESIS_TIMEOUT_MS = 180_000;
32
37
  export const VISION_TIMEOUT_MS = 240_000;
38
+ export const PROBE_TIMEOUT_MS = 120_000;
33
39
  export const RESULT_MAX_CHARS = 8_000;
34
40
  export const IMAGE_MAX_BYTES_DEFAULT = 5 * 1024 * 1024;
35
- export const KINDS = ["summary", "caption", "vision", "answer"];
41
+ export const KINDS = ["summary", "caption", "vision", "answer", "probe"];
42
+ export const PROBE_VARIANTS = ["model", "search"];
36
43
  export const MODELS = ["haiku", "sonnet"];
37
44
  export const DEFAULT_MODEL = "haiku";
38
45
  export const IMAGE_PLACEHOLDER = "{{IMAGE_FILE}}";
@@ -68,9 +75,16 @@ export function modelFor(job) {
68
75
  return MODELS.includes(job?.model) ? job.model : DEFAULT_MODEL;
69
76
  }
70
77
 
71
- /** Wall clock per kind: a picture takes longer to read. Pure. */
78
+ /** Wall clock per kind: a picture takes longer to read, a probe is capped short. Pure. */
72
79
  export function timeoutForKind(kind) {
73
- return kind === "vision" ? VISION_TIMEOUT_MS : SYNTHESIS_TIMEOUT_MS;
80
+ if (kind === "vision") return VISION_TIMEOUT_MS;
81
+ if (kind === "probe") return PROBE_TIMEOUT_MS;
82
+ return SYNTHESIS_TIMEOUT_MS;
83
+ }
84
+
85
+ /** A probe job's variant, or null when it is not one of the two lanes. Pure. */
86
+ export function variantOf(job) {
87
+ return PROBE_VARIANTS.includes(job?.variant) ? job.variant : null;
74
88
  }
75
89
 
76
90
  /** File extension for an image MIME type; null for anything we will not write to disk. Pure. */
@@ -97,11 +111,15 @@ export function scrubUrls(text) {
97
111
  }
98
112
 
99
113
  /** The argv a synthesis run gets: print mode, text out, no MCP, and either no
100
- * built-in tools (text kinds) or Read only (vision). The prompt travels on
101
- * stdin (never argv: no length limit, no shell quoting). Pure. */
102
- export function argsForKind(kind, { model } = {}) {
114
+ * built-in tools (text kinds), Read only (vision), or WebSearch only (a probe
115
+ * in its "search" variant; `--allowedTools` too, because print mode refuses a
116
+ * tool nobody granted). The prompt travels on stdin (never argv: no length
117
+ * limit, no shell quoting). Pure. */
118
+ export function argsForKind(kind, { model, variant } = {}) {
103
119
  const args = ["-p", "--output-format", "text", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}'];
104
- args.push("--tools", kind === "vision" ? "Read" : "");
120
+ if (kind === "vision") args.push("--tools", "Read");
121
+ else if (kind === "probe" && variant === "search") args.push("--tools", "WebSearch", "--allowedTools", "WebSearch");
122
+ else args.push("--tools", "");
105
123
  if (model) args.push("--model", model);
106
124
  return args;
107
125
  }
@@ -118,6 +136,7 @@ export function validateJob(job) {
118
136
  if (typeof job.prompt !== "string" || !job.prompt) return "job has no prompt";
119
137
  const kind = kindOf(job);
120
138
  if (!KINDS.includes(kind)) return `unsupported synthesis kind: ${kind}`;
139
+ if (kind === "probe") return variantOf(job) ? null : `probe job has no variant (model or search)`;
121
140
  if (kind !== "vision") return null;
122
141
  const img = job.image;
123
142
  if (!img || typeof img !== "object") return "vision job has no image";
@@ -138,6 +157,11 @@ export function imageMaxBytes(image) {
138
157
  /** One log line per job. Never includes the prompt or the image URL. Pure. */
139
158
  export function describeJob(job) {
140
159
  const kind = kindOf(job);
160
+ if (kind === "probe") {
161
+ const group = String(job?.group ?? "?").slice(0, 8);
162
+ const id = Number.isInteger(Number(job?.prompt_id)) ? Number(job.prompt_id) : "?";
163
+ return `probe: ${group} #${id} (${variantOf(job) ?? "?"})`;
164
+ }
141
165
  const n = Number(job?.files);
142
166
  const files = Number.isInteger(n) && n > 0 ? ` (${n} file${n === 1 ? "" : "s"})` : "";
143
167
  return `${kind}${files}`;
@@ -162,6 +186,7 @@ function runClaude(bin, prompt, { args, env, cwd, timeoutMs } = {}) {
162
186
  const text = trimResult(out);
163
187
  if (timedOut) resolve({ ok: false, timedOut: true, error: `timed out after ${Math.round((timeoutMs ?? SYNTHESIS_TIMEOUT_MS) / 1000)}s` });
164
188
  else if (code === 0 && text) resolve({ ok: true, text });
189
+ else if (isSignedOutError(err)) resolve({ ok: false, error: "Claude is signed out on this machine: open a terminal, run `claude`, and sign in" });
165
190
  else resolve({ ok: false, error: `exit ${code}: ${err.trim().slice(0, 300) || "(no stderr)"}` });
166
191
  });
167
192
  child.stdin.write(String(prompt));
@@ -172,11 +197,29 @@ function runClaude(bin, prompt, { args, env, cwd, timeoutMs } = {}) {
172
197
  /** Run with the requested model alias; if the CLI rejects it (non-zero exit,
173
198
  * e.g. an older CLI that does not know the alias) run once more without
174
199
  * --model. A timeout is not a rejected alias, so it is not retried. */
175
- async function runWithFallback(bin, prompt, { kind, model, env, cwd }) {
200
+ async function runWithFallback(bin, prompt, { kind, model, variant, env, cwd }) {
176
201
  const timeoutMs = timeoutForKind(kind);
177
- let r = await runClaude(bin, prompt, { args: argsForKind(kind, { model }), env, cwd, timeoutMs });
178
- if (!r.ok && !r.timedOut && model) r = await runClaude(bin, prompt, { args: argsForKind(kind), env, cwd, timeoutMs });
179
- return r;
202
+ // A probe measures what a STRANGER's Claude would say, so it must run in a
203
+ // fresh empty directory: Claude Code loads its per-directory auto-memory and
204
+ // any CLAUDE.md from the cwd, and the Bridge's own cwd is full of Cookbook.
205
+ // (The 2026-09-03 baseline run leaked "your existing Cookbook setup" this way.)
206
+ let scratch = null;
207
+ if (kind === "probe" && !cwd) {
208
+ scratch = await fsp.mkdtemp(path.join(os.tmpdir(), "cookbook-probe-"));
209
+ cwd = scratch;
210
+ }
211
+ try {
212
+ let r = await runClaude(bin, prompt, { args: argsForKind(kind, { model, variant }), env, cwd, timeoutMs });
213
+ if (!r.ok && !r.timedOut && model) r = await runClaude(bin, prompt, { args: argsForKind(kind, { variant }), env, cwd, timeoutMs });
214
+ return r;
215
+ } finally {
216
+ if (scratch) await fsp.rm(scratch, { recursive: true, force: true }).catch(() => { /* best effort */ });
217
+ }
218
+ }
219
+
220
+ /** Where a probe runs: a fresh temp dir, never the Bridge's own cwd. Pure. */
221
+ export function probeNeedsScratchCwd(kind, cwd) {
222
+ return kind === "probe" && !cwd;
180
223
  }
181
224
 
182
225
  /** Download the job's image into `dir` as image.<ext>. Refuses non-https, unknown
@@ -225,7 +268,8 @@ async function defaultRun(job, { cfg, env }) {
225
268
  if (!bin) return { ok: false, error: "no Claude CLI configured on this Bridge" };
226
269
  const kind = kindOf(job);
227
270
  const model = modelFor(job);
228
- if (kind !== "vision") return runWithFallback(bin, job.prompt, { kind, model, env });
271
+ const variant = variantOf(job);
272
+ if (kind !== "vision") return runWithFallback(bin, job.prompt, { kind, model, variant, env });
229
273
  let dir = null;
230
274
  try {
231
275
  dir = await fsp.mkdtemp(path.join(os.tmpdir(), "cookbook-vision-"));
@@ -275,13 +319,15 @@ async function runOne(entry) {
275
319
  const run = opts.run ?? defaultRun;
276
320
  const report = opts.report ?? defaultReport;
277
321
  const label = describeJob(job);
322
+ // A probe announces itself as "probe: A #3 (search)"; the rest as "synthesis: <kind>".
323
+ const head = kindOf(job) === "probe" ? label : `synthesis: ${label}`;
278
324
  const invalid = validateJob(job);
279
325
  if (invalid) {
280
326
  await report(cfg, job.id, { error: invalid });
281
- log?.(`! synthesis ${label} refused: ${invalid}`);
327
+ log?.(`! ${head} refused: ${invalid}`);
282
328
  return;
283
329
  }
284
- log?.(`◇ synthesis: ${label} on your subscription`);
330
+ log?.(`◇ ${head} on your subscription`);
285
331
  const t0 = Date.now();
286
332
  let r;
287
333
  try {
@@ -292,11 +338,11 @@ async function runOne(entry) {
292
338
  const secs = Math.round((Date.now() - t0) / 1000);
293
339
  if (r?.ok) {
294
340
  const posted = await report(cfg, job.id, { result: trimResult(r.text) });
295
- log?.(`◇ synthesis: ${label} done in ${secs}s${posted ? "" : " (post failed)"}`);
341
+ log?.(`◇ ${head} done in ${secs}s${posted ? "" : " (post failed)"}`);
296
342
  } else {
297
343
  const error = scrubUrls(r?.error ?? "unknown error");
298
344
  await report(cfg, job.id, { error });
299
- log?.(`! synthesis ${label} failed after ${secs}s: ${error}`);
345
+ log?.(`! ${head} failed after ${secs}s: ${error}`);
300
346
  }
301
347
  }
302
348
 
package/update.mjs CHANGED
@@ -117,6 +117,9 @@ export function locateConfig(args = [], opts = {}) {
117
117
  export function installLayout(here = HERE) {
118
118
  const n = String(here).replace(/\\/g, "/") + "/";
119
119
  if (n.includes("/_npx/") || n.includes("/node_modules/cookbook-bridge/")) return "npm";
120
+ // The service runtime (~/.cookbook/bridge, service.mjs) is reached through npx too:
121
+ // its hints must read `npx cookbook-bridge@latest …`, never `node bridge/bridge.mjs`.
122
+ if (n.endsWith("/.cookbook/bridge/")) return "npm";
120
123
  return "tarball";
121
124
  }
122
125