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 +3 -0
- package/bridge.mjs +155 -4
- package/cookbook.mjs +4 -1
- package/device.mjs +79 -3
- package/package.json +5 -5
- package/plan.mjs +41 -0
- package/service.mjs +343 -0
- package/synthesis.mjs +63 -17
- package/update.mjs +3 -0
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 (
|
|
743
|
-
return "
|
|
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
|
-
|
|
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.
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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.
|
|
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
|
-
|
|
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)
|
|
101
|
-
*
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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?.(`!
|
|
327
|
+
log?.(`! ${head} refused: ${invalid}`);
|
|
282
328
|
return;
|
|
283
329
|
}
|
|
284
|
-
log?.(`◇
|
|
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?.(`◇
|
|
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?.(`!
|
|
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
|
|