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