cookbook-bridge 0.1.10 → 0.1.11
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 +63 -35
- package/bridge.mjs +133 -54
- package/chef-persona.md +9 -0
- package/chef.mjs +140 -0
- package/config.example.json +3 -3
- package/connectors.mjs +2 -1
- package/device.mjs +108 -34
- package/hands.mjs +6 -1
- package/package.json +4 -2
- package/update.mjs +114 -0
package/README.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Changelog
|
|
4
4
|
|
|
5
|
+
**0.1.11** (2026-09-02)
|
|
6
|
+
- The Bridge has a home: `~/.cookbook/config.json` (file 0600, folder 0700) for every command. `bridge.state.json`, `local.json` and `bridge.log` sit next to it. Before, the config lived next to `bridge.mjs`, so every `npx cookbook-bridge@latest` landed in a fresh cache folder and lost it. A config found next to `bridge.mjs` is copied to the home once (the old file stays) and the move is announced in one line. `--config <path>` and `COOKBOOK_CONFIG` still win.
|
|
7
|
+
- `connect` runs the Bridge right after the approval (pass `--no-run` to stop at "connected"). With no agent CLI found it prints the doctor instead.
|
|
8
|
+
- Every hint is a command that works where you are: `npx cookbook-bridge@latest <cmd>` from an npm install, `node bridge/bridge.mjs <cmd>` from a tarball.
|
|
9
|
+
- `doctor` gained rows for the config home (path, exists, mode), other Bridge processes on this machine (pids and config paths), Bridge Local (does the port answer), and whether the local files are behind the app deploy, with the same update line the running Bridge prints. `connect` prints that line too.
|
|
10
|
+
- Visiting agents can read `~/.cookbook/bridge.state.json` as a projection; `~/.cookbook/local.json` is never readable, like the desktop app's copy.
|
|
11
|
+
|
|
5
12
|
**0.1.10** (2026-09-02)
|
|
6
13
|
- Works on every Node from 18 up. Before, on Node older than 22.18 every command exited silently, doing nothing.
|
|
7
14
|
- The npm package and the desktop app now ship every runtime file (realtime, sessions, synthesis, the approval relay, the hook reporter). Install-time crashes on import are gone, and a test now fails the build if a file is left out.
|
|
@@ -37,49 +44,49 @@ Trust model: your Cookbook's `/security` page.
|
|
|
37
44
|
## Quick start (2 minutes)
|
|
38
45
|
|
|
39
46
|
```bash
|
|
40
|
-
npx cookbook-bridge@latest connect #
|
|
41
|
-
#
|
|
42
|
-
#
|
|
47
|
+
npx cookbook-bridge@latest connect # ONE approval connects the Bridge AND every installed
|
|
48
|
+
# agent CLI (claude, codex, agy, openclaw), each with its
|
|
49
|
+
# own attributed token, then RUNS the Bridge. Leave it open.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That is the whole setup. Later:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx cookbook-bridge@latest # run it again (config is remembered in ~/.cookbook)
|
|
43
56
|
npx cookbook-bridge@latest doctor # preflight: checks every prerequisite, with exact fixes
|
|
44
|
-
npx cookbook-bridge@latest # run it (leave it running)
|
|
45
57
|
```
|
|
46
58
|
|
|
47
59
|
Always `@latest`: bare `npx cookbook-bridge` happily runs a weeks-old cached copy
|
|
48
|
-
that predates subcommands you need (`host` shipped in 0.1.1).
|
|
60
|
+
that predates subcommands you need (`host` shipped in 0.1.1). Your config is not in
|
|
61
|
+
that cache, so `@latest` never loses it.
|
|
49
62
|
|
|
50
63
|
Node 18+. No dependencies, nothing to configure by hand: `connect` writes
|
|
51
|
-
|
|
64
|
+
`~/.cookbook/config.json` for you and never prints or stores a secret you have to copy.
|
|
65
|
+
Pass `--no-run` to stop at "connected" without starting the Bridge.
|
|
52
66
|
|
|
53
67
|
<details>
|
|
54
68
|
<summary>Prefer no package manager? Download the tarball instead.</summary>
|
|
55
69
|
|
|
56
70
|
```bash
|
|
57
71
|
curl -fsSL https://cookbook.team/api/bridge/download | tar xz
|
|
58
|
-
node bridge/bridge.mjs connect
|
|
72
|
+
node bridge/bridge.mjs connect # same flow: one approval, then it runs
|
|
59
73
|
```
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
From a tarball every command is `node bridge/bridge.mjs <command>` (run it from the
|
|
76
|
+
folder you unpacked, the one that contains `bridge/`); with the npm install it is
|
|
77
|
+
`npx cookbook-bridge@latest <command>`. The Bridge knows which layout it runs from and
|
|
78
|
+
prints the right one in every hint. The config lives in `~/.cookbook` either way.
|
|
63
79
|
|
|
64
80
|
</details>
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
No hand-pasting tokens: `connect-agents` is the intended path (device flow, like a TV
|
|
77
|
-
app; the code expires in ~10 minutes — just re-run it if it lapses). One browser click
|
|
78
|
-
authorizes the Bridge **and** mints a named token per detected agent CLI — the name is
|
|
79
|
-
the attribution label ("Claude · via you") — then configures each CLI via its own
|
|
80
|
-
`mcp add`. Prefer just the Bridge? `login` does the device flow without touching your
|
|
81
|
-
CLIs. Fully manual: copy `config.example.json` → `config.json` and paste a token from
|
|
82
|
-
your Cookbook **Account → Tokens** page.
|
|
82
|
+
No hand-pasting tokens: `connect` is the intended path (device flow, like a TV app; the
|
|
83
|
+
code expires in ~10 minutes, just re-run it if it lapses). One browser click authorizes
|
|
84
|
+
the Bridge **and** mints a named token per detected agent CLI (the name is the
|
|
85
|
+
attribution label, "Claude · via you"), then configures each CLI via its own `mcp add`.
|
|
86
|
+
`connect-agents` is the same command under its original name. Prefer just the Bridge?
|
|
87
|
+
`login` does the device flow without touching your CLIs. Fully manual: copy
|
|
88
|
+
`config.example.json` to `~/.cookbook/config.json` and paste a token from your Cookbook
|
|
89
|
+
**Account → Tokens** page.
|
|
83
90
|
|
|
84
91
|
Requires **Node 18+** (built-in `fetch`, no npm install) and at least one agent CLI
|
|
85
92
|
installed and logged in (`claude`, `agy` — the Antigravity CLI for Gemini — or the Codex app). Each agent must also be
|
|
@@ -88,15 +95,33 @@ you exactly which parts are ready and how to fix the rest.
|
|
|
88
95
|
|
|
89
96
|
## Commands
|
|
90
97
|
|
|
98
|
+
Written as `npx cookbook-bridge@latest <command>`; from a tarball, `node bridge/bridge.mjs <command>`.
|
|
99
|
+
|
|
91
100
|
| Command | What it does |
|
|
92
101
|
|---|---|
|
|
93
|
-
|
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
102
|
+
| (none) | Run the Bridge (uses `~/.cookbook/config.json`; or pass a path) |
|
|
103
|
+
| `connect` | One approval connects the Bridge + every installed agent CLI (attributed tokens), then runs the Bridge. `--no-run` stops at connected. `connect-agents` is the same command. |
|
|
104
|
+
| `login` | Device-flow auth for the Bridge only, writes your config |
|
|
105
|
+
| `doctor` | Preflight every prerequisite with exact fixes: config home, token, agents, other Bridge processes, Bridge Local, and whether the files are behind the app |
|
|
106
|
+
| `status` | Liveness + agent readiness |
|
|
107
|
+
| `update` | Update the Bridge to match the app (see Self-updating) |
|
|
108
|
+
| `host` | Open the door: let an agent someone else runs help you set this machine up, inside a grant you approve. `--off` closes it. |
|
|
109
|
+
| `connectors` | Survey MCP connectors across Claude, Codex and Gemini; `sync` gives every agent the same tools; `approve <name>` installs a team stdio connector |
|
|
110
|
+
|
|
111
|
+
## Where things live (the config home)
|
|
112
|
+
|
|
113
|
+
Every command resolves the config the same way, first match wins:
|
|
114
|
+
|
|
115
|
+
1. `--config <path>` (or, for the run command, a positional path)
|
|
116
|
+
2. `COOKBOOK_CONFIG` in the environment
|
|
117
|
+
3. `~/.cookbook/config.json` (folder 0700, file 0600)
|
|
118
|
+
4. A `config.json` next to `bridge.mjs`, only when 3 does not exist yet. It is copied to
|
|
119
|
+
the home once, the old file is left in place, and one line says where the config
|
|
120
|
+
now lives (pre-0.1.11 installs kept it next to the code).
|
|
121
|
+
|
|
122
|
+
`bridge.state.json` (attempt counters), `local.json` (the Bridge Local loopback port and
|
|
123
|
+
token) and `bridge.log` sit in the same folder as whichever config is in use. The desktop
|
|
124
|
+
app passes its own `--config` path and keeps its files in its data folder.
|
|
100
125
|
|
|
101
126
|
## Self-updating
|
|
102
127
|
|
|
@@ -104,8 +129,11 @@ you exactly which parts are ready and how to fix the rest.
|
|
|
104
129
|
to the deploy's manifest (`/api/bridge/manifest`) and — with `"autoUpdate": true`, the
|
|
105
130
|
default — replaces them (every file hash-verified first, originals kept in
|
|
106
131
|
a per-version `bridge.backup/<deploy>/` dir, your `config.json` and token never touched) and restarts itself.
|
|
107
|
-
Set `"autoUpdate": false` to pin your version; `node bridge.mjs update` updates
|
|
108
|
-
and works even from a broken install.
|
|
132
|
+
Set `"autoUpdate": false` to pin your version; `node bridge/bridge.mjs update` updates
|
|
133
|
+
manually and works even from a broken install. An npm install is updated by npm: re-run
|
|
134
|
+
`npx cookbook-bridge@latest`, and your config in `~/.cookbook` comes along untouched.
|
|
135
|
+
`doctor` and `connect` run the same comparison and print the same update line when the
|
|
136
|
+
local files are behind.
|
|
109
137
|
|
|
110
138
|
## Agents (config.json)
|
|
111
139
|
|
package/bridge.mjs
CHANGED
|
@@ -13,15 +13,18 @@
|
|
|
13
13
|
* runs their own Bridge for their own agents — this is the seed of the shippable
|
|
14
14
|
* "Cookbook Bridge" client).
|
|
15
15
|
*
|
|
16
|
-
* Run: node bridge/bridge.mjs (uses
|
|
16
|
+
* Run: node bridge/bridge.mjs (uses ~/.cookbook/config.json)
|
|
17
17
|
* node bridge/bridge.mjs ./my.json (explicit config path)
|
|
18
|
-
* node bridge/bridge.mjs
|
|
18
|
+
* node bridge/bridge.mjs connect (one approval connects + runs)
|
|
19
19
|
* node bridge/bridge.mjs status (liveness + agent readiness)
|
|
20
20
|
* node bridge/bridge.mjs doctor (preflight: check every prerequisite,
|
|
21
21
|
* print the exact fix for each ✗)
|
|
22
22
|
*
|
|
23
23
|
* "login", "status", and "doctor" are reserved first-args; pass a config path to
|
|
24
24
|
* those subcommands with --config <path>. Plain `node bridge.mjs [path]` is unchanged.
|
|
25
|
+
* Config home (0.1.11): explicit path/--config, else COOKBOOK_CONFIG, else
|
|
26
|
+
* ~/.cookbook/config.json; a legacy config next to this file is copied there once.
|
|
27
|
+
* bridge.state.json, local.json and bridge.log sit next to whichever config is used.
|
|
25
28
|
*
|
|
26
29
|
* Node built-ins only. No dependencies.
|
|
27
30
|
*/
|
|
@@ -50,6 +53,10 @@ import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
|
|
|
50
53
|
// the updater when these were static imports.
|
|
51
54
|
import { deriveWakeTopic, wakeSocketSupported, connectWakeSocket } from "./realtime.mjs";
|
|
52
55
|
import { createSessionReporter } from "./sessions.mjs";
|
|
56
|
+
import { resolveAgentForTask } from "./chef.mjs";
|
|
57
|
+
// The config home + command phrasing live in update.mjs (node built-ins only), so the
|
|
58
|
+
// broken-install `update` path and every other command agree on both.
|
|
59
|
+
import { locateConfig, cli, updateLine, configHome } from "./update.mjs";
|
|
53
60
|
let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, agentsQuery;
|
|
54
61
|
let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig;
|
|
55
62
|
let extractUsage, displayText;
|
|
@@ -150,22 +157,20 @@ function resolveBin(cmd) {
|
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
/** Pick a config path from a subcommand's args: `--config <path>`, else first
|
|
153
|
-
* positional, else the
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const positional = (args || []).find((a) => a && !a.startsWith("-"));
|
|
158
|
-
return positional ? path.resolve(positional) : fallback;
|
|
160
|
+
* positional, else the config home (update.mjs locateConfig: COOKBOOK_CONFIG,
|
|
161
|
+
* ~/.cookbook/config.json, one-time copy of a legacy config next to this file). */
|
|
162
|
+
function configPathFromArgs(args, { positional = true } = {}) {
|
|
163
|
+
return locateConfig(args || [], { here: HERE, positional, log: (m) => console.error(m) });
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
/** Where the running Bridge's config lives (Bridge Local writes local.json next to it). */
|
|
162
167
|
let CONFIG_PATH = null;
|
|
163
168
|
|
|
164
169
|
function loadConfig() {
|
|
165
|
-
const p = process.argv
|
|
170
|
+
const p = configPathFromArgs(process.argv.slice(2));
|
|
166
171
|
CONFIG_PATH = p;
|
|
167
172
|
if (!fs.existsSync(p)) {
|
|
168
|
-
console.error(`No config at ${p}.\nEasiest: run
|
|
173
|
+
console.error(`No config at ${p}.\nEasiest: run \`${cli("connect")}\`: one approval, no token to paste, and the Bridge starts right after.\n(Manual alternative: copy config.example.json to ${p} and add a token from Account > Tokens.)`);
|
|
169
174
|
process.exit(1);
|
|
170
175
|
}
|
|
171
176
|
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
@@ -609,7 +614,7 @@ async function runAgent(cfg, agent, prompt, onProgress, retry = null, taskCtx =
|
|
|
609
614
|
const codexToken = agent.token || cfg.codexToken || cfg.token;
|
|
610
615
|
if (!agent.token && !cfg.codexToken && !warnedCodexToken) {
|
|
611
616
|
warnedCodexToken = true;
|
|
612
|
-
log(
|
|
617
|
+
log(`! Codex has no agent token, so it runs under your Bridge token. Run \`${cli("connect")}\` so its work reads "Codex · via you".`);
|
|
613
618
|
}
|
|
614
619
|
return runCodexTask(agent, prompt, cfg.taskTimeoutSeconds, codexToken, env, onProgress, { threadKey, log, model });
|
|
615
620
|
}
|
|
@@ -690,7 +695,7 @@ function failureHint(result) {
|
|
|
690
695
|
if (infra.includes("not logged in") || infra.includes("please log in"))
|
|
691
696
|
return "the agent CLI isn't logged in → run `claude auth login`";
|
|
692
697
|
if (infra.includes("no mcp") || infra.includes("requires authentication"))
|
|
693
|
-
return
|
|
698
|
+
return `the agent can't reach the Cookbook MCP → run \`${cli("doctor")}\``;
|
|
694
699
|
if (infra.includes("not allowed") || infra.includes("allowedtools"))
|
|
695
700
|
return "a tool was blocked → check `allowedTools` matches the agent's MCP server name (CLI-added = mcp__cookbook__*)";
|
|
696
701
|
if (typeof code === "number" && code !== 0) return `agent exited ${code}${tail ? `: ${tail}` : ""}`;
|
|
@@ -744,11 +749,17 @@ function agentHeld(agent) {
|
|
|
744
749
|
// re-exec, manual bounce) reset the counters — and the Bridge resumes its own
|
|
745
750
|
// claimed tasks, so a restart granted every stuck task a fresh pair of attempts
|
|
746
751
|
// (2026-07-13: a doomed goal would have re-burned 6x900s across restarts).
|
|
747
|
-
// Persisted next to config; trimmed so it can't grow unbounded.
|
|
748
|
-
|
|
752
|
+
// Persisted next to the config (the config home); trimmed so it can't grow unbounded.
|
|
753
|
+
// A pre-0.1.11 state file next to bridge.mjs is read once when the new one is absent.
|
|
754
|
+
function statePath() {
|
|
755
|
+
return path.join(path.dirname(CONFIG_PATH || path.join(HERE, "config.json")), "bridge.state.json");
|
|
756
|
+
}
|
|
749
757
|
function loadRunState() {
|
|
750
758
|
try {
|
|
751
|
-
const
|
|
759
|
+
const p = statePath();
|
|
760
|
+
const legacy = path.join(HERE, "bridge.state.json");
|
|
761
|
+
const src = fs.existsSync(p) ? p : legacy;
|
|
762
|
+
const raw = JSON.parse(fs.readFileSync(src, "utf8"));
|
|
752
763
|
for (const [id, n] of Object.entries(raw.attempts ?? {})) attempts.set(id, Number(n) || 0);
|
|
753
764
|
for (const id of raw.givenUp ?? []) givenUp.add(id);
|
|
754
765
|
for (const [id, ctx] of Object.entries(raw.retryCtx ?? {})) retryCtx.set(id, ctx);
|
|
@@ -760,7 +771,7 @@ function saveRunState() {
|
|
|
760
771
|
const attEntries = [...attempts.entries()].slice(-500);
|
|
761
772
|
const given = [...givenUp].slice(-500);
|
|
762
773
|
const retries = [...retryCtx.entries()].slice(-200);
|
|
763
|
-
fs.writeFileSync(
|
|
774
|
+
fs.writeFileSync(statePath(), JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries) }));
|
|
764
775
|
} catch { /* best-effort — never let state persistence break a run */ }
|
|
765
776
|
}
|
|
766
777
|
|
|
@@ -1473,7 +1484,7 @@ function warnIfTokenExpiring(expiresAt) {
|
|
|
1473
1484
|
const today = new Date().toISOString().slice(0, 10);
|
|
1474
1485
|
if (lastExpiryWarnDay === today) return;
|
|
1475
1486
|
lastExpiryWarnDay = today;
|
|
1476
|
-
log(`! this Bridge's token expires in ${Math.max(daysLeft, 0)} day(s)
|
|
1487
|
+
log(`! this Bridge's token expires in ${Math.max(daysLeft, 0)} day(s). Re-run \`${cli("connect")}\` before it does.`);
|
|
1477
1488
|
}
|
|
1478
1489
|
|
|
1479
1490
|
let wsCursor = 0;
|
|
@@ -1817,7 +1828,10 @@ async function dispatchWorkInner(cfg, work, warmHints) {
|
|
|
1817
1828
|
if ((t.assigned_to || "").toLowerCase() === "goal") continue;
|
|
1818
1829
|
if (inFlight.has(t.id) || givenUp.has(t.id)) continue;
|
|
1819
1830
|
if ((attempts.get(t.id) ?? 0) >= cfg.maxAttempts) continue;
|
|
1820
|
-
|
|
1831
|
+
// Round two: no configured agent for "Chef" + a support task addressed to Chef +
|
|
1832
|
+
// chef-persona.md shipped next to this file = a Chef synthesized from this
|
|
1833
|
+
// Bridge's own Claude (bridge/chef.mjs). Configured agents always win.
|
|
1834
|
+
const agent = agentFor(cfg, t.assigned_to) ?? resolveAgentForTask(cfg.agents, t, cfg);
|
|
1821
1835
|
if (!agent) continue;
|
|
1822
1836
|
if (agentHeld(agent)) continue;
|
|
1823
1837
|
if (!allowedByPolicy(cfg, agent, t)) continue;
|
|
@@ -1889,7 +1903,10 @@ async function pollOnce(cfg, onlyWorkspaceIds = null) {
|
|
|
1889
1903
|
continue;
|
|
1890
1904
|
}
|
|
1891
1905
|
|
|
1892
|
-
|
|
1906
|
+
// Round two: no configured agent for "Chef" + a support task addressed to Chef +
|
|
1907
|
+
// chef-persona.md shipped next to this file = a Chef synthesized from this
|
|
1908
|
+
// Bridge's own Claude (bridge/chef.mjs). Configured agents always win.
|
|
1909
|
+
const agent = agentFor(cfg, t.assigned_to) ?? resolveAgentForTask(cfg.agents, t, cfg);
|
|
1893
1910
|
if (!agent) continue; // no local agent handles this assignee
|
|
1894
1911
|
if (agentHeld(agent)) continue; // plan window closed: wait for the reset, keep the attempt
|
|
1895
1912
|
|
|
@@ -2002,16 +2019,12 @@ async function selfUpdate(cfg, { reexec }) {
|
|
|
2002
2019
|
// Say it once per process: drift should be visible, not noisy, and never silent.
|
|
2003
2020
|
if (!updateNagged) {
|
|
2004
2021
|
updateNagged = true;
|
|
2005
|
-
log(
|
|
2006
|
-
channel === "app"
|
|
2007
|
-
? `⬆ A newer Bridge ships with the app (deploy ${check.version}). The Cookbook app manages this copy — update the app to pick it up.`
|
|
2008
|
-
: `⬆ A newer Bridge is available (deploy ${check.version}). Update it with: npx cookbook-bridge@latest connect`,
|
|
2009
|
-
);
|
|
2022
|
+
log(`⬆ ${updateLine(check.version, { here: HERE, desktop: channel === "app" })}`);
|
|
2010
2023
|
}
|
|
2011
2024
|
return;
|
|
2012
2025
|
}
|
|
2013
2026
|
if (cfg.autoUpdate === false) {
|
|
2014
|
-
log(`⬆
|
|
2027
|
+
log(`⬆ ${updateLine(check.version, { here: HERE, desktop: false })} (${check.changed.length} file(s) changed; autoUpdate is off.)`);
|
|
2015
2028
|
return;
|
|
2016
2029
|
}
|
|
2017
2030
|
try {
|
|
@@ -2061,7 +2074,7 @@ async function main() {
|
|
|
2061
2074
|
? (dflt.bin || "openclaw")
|
|
2062
2075
|
: null;
|
|
2063
2076
|
if (!resolveBin(dfltBin)) {
|
|
2064
|
-
log(`! default agent "${dflt.name}" isn't installed/on PATH
|
|
2077
|
+
log(`! default agent "${dflt.name}" isn't installed/on PATH, so "any"-assigned tasks will fail. Run \`${cli("doctor")}\`.`);
|
|
2065
2078
|
}
|
|
2066
2079
|
}
|
|
2067
2080
|
|
|
@@ -2196,9 +2209,9 @@ async function main() {
|
|
|
2196
2209
|
}
|
|
2197
2210
|
{
|
|
2198
2211
|
const mode = hostingMode(cfg);
|
|
2199
|
-
if (mode === "always") log(
|
|
2200
|
-
else if (mode === "grants") log(
|
|
2201
|
-
else log(
|
|
2212
|
+
if (mode === "always") log(`⌂ Hosting is ON: an agent you invite can run granted checks on this machine. You'll see every step; \`${cli("host --off")}\` closes the door.`);
|
|
2213
|
+
else if (mode === "grants") log(`⌂ Hosting: grants you approve in Cookbook run here (every change still waits for your click). \`${cli("host --off")}\` refuses all.`);
|
|
2214
|
+
else log(`⌂ Hosting is OFF: no visiting agent can act on this machine. \`${cli("host")}\` opens it.`);
|
|
2202
2215
|
if (mode !== "off") await pollHands(cfg);
|
|
2203
2216
|
}
|
|
2204
2217
|
// Signals are handled by installShutdown (registered before connecting).
|
|
@@ -2207,7 +2220,7 @@ async function main() {
|
|
|
2207
2220
|
lastRunError = e.message;
|
|
2208
2221
|
if (IS_DESKTOP && localServer) {
|
|
2209
2222
|
log(`! Not connected to Cookbook yet: ${e.message}`);
|
|
2210
|
-
log(` Fix it in the app (Connect your agents), or run
|
|
2223
|
+
log(` Fix it in the app (Connect your agents), or run \`${cli("connect")}\`. The control API stays up so you can.`);
|
|
2211
2224
|
} else {
|
|
2212
2225
|
console.error(`\nCouldn't connect to Cookbook: ${e.message}`);
|
|
2213
2226
|
process.exit(1);
|
|
@@ -2278,7 +2291,7 @@ async function main() {
|
|
|
2278
2291
|
await new Promise((r) => setTimeout(r, Math.max(cfg.pollSeconds * 1000, 15_000)));
|
|
2279
2292
|
} else {
|
|
2280
2293
|
log("✗ Cookbook has rejected this token 5 polls in a row — it was likely revoked (a new login replaces old tokens) or expired.");
|
|
2281
|
-
log(
|
|
2294
|
+
log(` Fix: ${cli("connect")} (reconnects and starts the Bridge)`);
|
|
2282
2295
|
process.exit(1);
|
|
2283
2296
|
}
|
|
2284
2297
|
}
|
|
@@ -2373,22 +2386,32 @@ async function doctorReport(args) {
|
|
|
2373
2386
|
if (major >= 18) ok(`Node ${process.version}`);
|
|
2374
2387
|
else bad(`Node ${process.version} is too old (need ≥18)`, "install Node 18+ (e.g. `brew install node`)");
|
|
2375
2388
|
|
|
2376
|
-
// 2. Config
|
|
2377
|
-
const cfgPath = configPathFromArgs(args
|
|
2389
|
+
// 2. Config home (0.1.11): where the config lives, whether it exists, how tight it is.
|
|
2390
|
+
const cfgPath = configPathFromArgs(args);
|
|
2391
|
+
const home = configHome();
|
|
2392
|
+
const modeOf = (p) => { try { return (fs.statSync(p).mode & 0o777).toString(8); } catch { return null; } };
|
|
2393
|
+
{
|
|
2394
|
+
const homeMode = modeOf(home);
|
|
2395
|
+
if (homeMode === null) ok(`Config home ${home} (not created yet; \`${cli("connect")}\` creates it)`);
|
|
2396
|
+
else if (process.platform === "win32" || homeMode === "700") ok(`Config home ${home} (exists, mode ${homeMode})`);
|
|
2397
|
+
else warn(`Config home ${home} is mode ${homeMode}; it holds tokens and should be owner-only`, `chmod 700 "${home}"`);
|
|
2398
|
+
}
|
|
2378
2399
|
let cfg = null;
|
|
2379
2400
|
if (!fs.existsSync(cfgPath)) {
|
|
2380
|
-
bad(`No config at ${cfgPath}`,
|
|
2401
|
+
bad(`No config at ${cfgPath}`, `run \`${cli("connect")}\` (one approval; it writes the config and starts the Bridge)`);
|
|
2381
2402
|
} else {
|
|
2382
2403
|
try {
|
|
2383
2404
|
cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
2384
2405
|
cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
|
|
2385
2406
|
cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
|
|
2386
|
-
|
|
2407
|
+
for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
|
|
2387
2408
|
if (!cfg.cookbookUrl || !cfg.token || String(cfg.token).startsWith("PASTE")) {
|
|
2388
|
-
bad("Config is missing cookbookUrl or a real token", "set both in
|
|
2409
|
+
bad("Config is missing cookbookUrl or a real token", `run \`${cli("connect")}\`, or set both in ${cfgPath} (token from your Cookbook > Tokens page)`);
|
|
2389
2410
|
cfg = null;
|
|
2390
2411
|
} else {
|
|
2391
|
-
|
|
2412
|
+
const m = modeOf(cfgPath);
|
|
2413
|
+
if (process.platform === "win32" || m === "600") ok(`Config ${cfgPath} (mode ${m ?? "n/a"})`);
|
|
2414
|
+
else warn(`Config ${cfgPath} is mode ${m}; it carries your token and should be owner-only`, `chmod 600 "${cfgPath}"`);
|
|
2392
2415
|
}
|
|
2393
2416
|
} catch (e) {
|
|
2394
2417
|
bad(`Config isn't valid JSON: ${e.message}`);
|
|
@@ -2396,6 +2419,50 @@ async function doctorReport(args) {
|
|
|
2396
2419
|
}
|
|
2397
2420
|
}
|
|
2398
2421
|
|
|
2422
|
+
// 2b. Another Bridge on this machine? Two on one config fight over the same token
|
|
2423
|
+
// (a `connect` revokes the other's); one on a different config is the classic
|
|
2424
|
+
// "I connected but a stale Bridge is still running" trap.
|
|
2425
|
+
try {
|
|
2426
|
+
const { scanBridgeProcesses } = await import("./device.mjs");
|
|
2427
|
+
const others = scanBridgeProcesses({ selfPid: process.pid });
|
|
2428
|
+
if (others === null) ok("Another Bridge process on this machine: could not scan (ps/tasklist unavailable)");
|
|
2429
|
+
else if (!others.length) ok("No other Bridge process on this machine");
|
|
2430
|
+
else {
|
|
2431
|
+
const desc = others.map((o) => `pid ${o.pid}${o.configPath ? ` (config ${o.configPath})` : o.unknownCommand ? " (node.exe; command line not visible)" : " (default config)"}`).join(", ");
|
|
2432
|
+
warn(`Another Bridge process on this machine: ${desc}`, `stop it if you meant to run only one (kill ${others.map((o) => o.pid).join(" ")}); two Bridges on one config fight over the same token`);
|
|
2433
|
+
}
|
|
2434
|
+
} catch (e) {
|
|
2435
|
+
ok(`Another Bridge process on this machine: could not scan (${e.message})`);
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// 2c. Bridge Local: the loopback control API a running Bridge writes local.json for.
|
|
2439
|
+
{
|
|
2440
|
+
const localPath = path.join(path.dirname(cfgPath), "local.json");
|
|
2441
|
+
let local = null;
|
|
2442
|
+
try { local = JSON.parse(fs.readFileSync(localPath, "utf8")); } catch { /* not running */ }
|
|
2443
|
+
if (!local || !local.port) ok(`Bridge Local: not running (no ${localPath})`);
|
|
2444
|
+
else {
|
|
2445
|
+
let answered = false;
|
|
2446
|
+
try {
|
|
2447
|
+
const res = await fetch(`http://127.0.0.1:${local.port}/status`, { headers: { "X-Bridge-Token": String(local.token || "") }, signal: AbortSignal.timeout(2000) });
|
|
2448
|
+
answered = res.ok;
|
|
2449
|
+
} catch { answered = false; }
|
|
2450
|
+
if (answered) ok(`Bridge Local answering on 127.0.0.1:${local.port} (pid ${local.pid ?? "?"})`);
|
|
2451
|
+
else warn(`Bridge Local: ${localPath} says port ${local.port} (pid ${local.pid ?? "?"}) but nothing answers there`, `that Bridge is gone or stuck; start one with \`${cli()}\` (it rewrites local.json)`);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// 2d. Staleness: the same manifest comparison startup does, with the same fix line.
|
|
2456
|
+
if (cfg && cfg.cookbookUrl && checkForUpdate) {
|
|
2457
|
+
try {
|
|
2458
|
+
const check = await checkForUpdate({ cookbookUrl: cfg.cookbookUrl }, HERE);
|
|
2459
|
+
if (check.changed.length === 0) ok(`Bridge files match the app deploy (${check.version})`);
|
|
2460
|
+
else warn(`Bridge files are behind the app deploy (${check.version}; ${check.changed.length} file(s) differ)`, updateLine(check.version, { here: HERE, desktop: IS_DESKTOP }));
|
|
2461
|
+
} catch (e) {
|
|
2462
|
+
warn(`Couldn't compare the Bridge to the app deploy (${e.message})`, "offline is fine; the running Bridge re-checks every 6 hours");
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2399
2466
|
if (cfg) {
|
|
2400
2467
|
ensureAgentPath();
|
|
2401
2468
|
|
|
@@ -2404,7 +2471,7 @@ async function doctorReport(args) {
|
|
|
2404
2471
|
const ws = await listWorkspaces(cfg);
|
|
2405
2472
|
ok(`Cookbook token works — ${ws.length} workspace(s) visible`);
|
|
2406
2473
|
} catch (e) {
|
|
2407
|
-
bad(`Cookbook token rejected: ${e.message}`, "
|
|
2474
|
+
bad(`Cookbook token rejected: ${e.message}`, `run \`${cli("connect")}\` to mint a fresh one (or paste a token from your Cookbook > Tokens page into ${cfgPath})`);
|
|
2408
2475
|
}
|
|
2409
2476
|
|
|
2410
2477
|
// 4. Default agent is one of the enabled agents
|
|
@@ -2468,14 +2535,14 @@ async function doctorReport(args) {
|
|
|
2468
2535
|
ok(`${agent.name}: Cookbook MCP configured (${agyMcpPath})`);
|
|
2469
2536
|
} else if (srv) {
|
|
2470
2537
|
warn(`${agent.name}: MCP config points at ${srv.serverUrl || "(no url)"}`,
|
|
2471
|
-
`expected ${cfg.cookbookUrl}/api/mcp
|
|
2538
|
+
`expected ${cfg.cookbookUrl}/api/mcp; re-run \`${cli("connect")}\``);
|
|
2472
2539
|
} else {
|
|
2473
2540
|
bad(`${agent.name}: no 'cookbook' server in ${agyMcpPath}`,
|
|
2474
|
-
|
|
2541
|
+
`run \`${cli("connect")}\` (agy has no \`mcp add\`; the Bridge writes this file)`);
|
|
2475
2542
|
}
|
|
2476
2543
|
} catch {
|
|
2477
2544
|
bad(`${agent.name}: no agy MCP config at ${agyMcpPath}`,
|
|
2478
|
-
|
|
2545
|
+
`run \`${cli("connect")}\` (agy has no \`mcp add\`; the Bridge writes this file)`);
|
|
2479
2546
|
}
|
|
2480
2547
|
if (!fs.existsSync(path.join(process.env.HOME || os.homedir(), ".gemini", "oauth_creds.json"))) {
|
|
2481
2548
|
warn(`${agent.name}: no Google login found (~/.gemini/oauth_creds.json)`,
|
|
@@ -2486,7 +2553,7 @@ async function doctorReport(args) {
|
|
|
2486
2553
|
if (isClaudeCommand && isClaudeCommand(agent.command)) {
|
|
2487
2554
|
if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
|
|
2488
2555
|
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`,
|
|
2489
|
-
|
|
2556
|
+
`run \`${cli("connect")}\` (mints a token for this agent) or add "token" to this agent in ${cfgPath}`);
|
|
2490
2557
|
}
|
|
2491
2558
|
if ((agent.command || []).join(" ").includes("mcp__claude_ai_Cookbook__")) {
|
|
2492
2559
|
warn(`${agent.name}: allowedTools uses mcp__claude_ai_Cookbook__* — a CLI-added server is usually mcp__cookbook__*`,
|
|
@@ -2579,13 +2646,12 @@ if (!IS_MAIN) {
|
|
|
2579
2646
|
import("./connectors.mjs")
|
|
2580
2647
|
.then(async (m) => {
|
|
2581
2648
|
const args = process.argv.slice(3);
|
|
2582
|
-
const
|
|
2583
|
-
const cfgPath = ci >= 0 && args[ci + 1] ? path.resolve(args[ci + 1]) : path.join(HERE, "config.json");
|
|
2649
|
+
const cfgPath = configPathFromArgs(args, { positional: false });
|
|
2584
2650
|
if (args[0] === "approve") {
|
|
2585
2651
|
// A team STDIO connector runs a command on this machine; it is recorded on
|
|
2586
2652
|
// sync and written only after this explicit approval (connectors.mjs).
|
|
2587
2653
|
const name = args[1];
|
|
2588
|
-
if (!name) { console.error(
|
|
2654
|
+
if (!name) { console.error(`Usage: ${cli("connectors approve <name>")}`); process.exit(1); }
|
|
2589
2655
|
const lines = m.approveConnector(name, { cfgPath });
|
|
2590
2656
|
console.log(`\n Approved "${name}". Written to your agent CLIs:`);
|
|
2591
2657
|
for (const l of lines) console.log(l);
|
|
@@ -2597,7 +2663,7 @@ if (!IS_MAIN) {
|
|
|
2597
2663
|
if (!waiting.length) return console.log("\n No team connectors are waiting for your approval.\n");
|
|
2598
2664
|
console.log("\n Team connectors waiting for your approval (each runs a command on this machine):");
|
|
2599
2665
|
for (const [n, e] of waiting) console.log(` ${n.padEnd(22)} ${[e.command, ...(e.args ?? [])].join(" ").slice(0, 80)}`);
|
|
2600
|
-
console.log(
|
|
2666
|
+
console.log(`\n Approve one with: ${cli("connectors approve <name>")}\n`);
|
|
2601
2667
|
return;
|
|
2602
2668
|
}
|
|
2603
2669
|
const doSync = args.includes("sync");
|
|
@@ -2617,7 +2683,7 @@ if (!IS_MAIN) {
|
|
|
2617
2683
|
if (!doSync) {
|
|
2618
2684
|
const missing = rows.filter((r) => vendors.some((v) => !r.present[v])).length;
|
|
2619
2685
|
console.log(`\n ${missing} connector(s) aren't on every agent.`);
|
|
2620
|
-
console.log(
|
|
2686
|
+
console.log(` Run \`${cli("connectors sync")}\` to give every agent the same tools.\n`);
|
|
2621
2687
|
return;
|
|
2622
2688
|
}
|
|
2623
2689
|
const only = args.filter((a) => !a.startsWith("--") && a !== "sync");
|
|
@@ -2646,8 +2712,24 @@ if (!IS_MAIN) {
|
|
|
2646
2712
|
} else if (sub === "connect" || sub === "connect-agents") {
|
|
2647
2713
|
// `connect` is the documented first command (the connect page and the npm bin both
|
|
2648
2714
|
// say it); `connect-agents` is the original name, kept working forever.
|
|
2715
|
+
// 0.1.11: after the approval the Bridge RUNS, right here, unless --no-run. Stopping
|
|
2716
|
+
// at "connected" was the single most confusing moment (nothing polls, and the
|
|
2717
|
+
// approval just revoked whatever older Bridge was polling).
|
|
2649
2718
|
import("./device.mjs")
|
|
2650
|
-
.then((m) =>
|
|
2719
|
+
.then(async (m) => {
|
|
2720
|
+
const args = process.argv.slice(3);
|
|
2721
|
+
const noRun = args.includes("--no-run");
|
|
2722
|
+
const r = await m.connectAgents(args.filter((a) => a !== "--no-run"), { willRun: !noRun });
|
|
2723
|
+
if (!r || !r.ok) {
|
|
2724
|
+
// Nothing to run (no agent CLI found): the doctor says what is missing and how to fix it.
|
|
2725
|
+
if (r && r.reason === "no-agents") await runDoctor(["--config", r.cfgPath]);
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
if (noRun || !r.startBridge) return;
|
|
2729
|
+
console.log("Connected. Running the Bridge now; leave this window open. Ctrl-C stops it.\n");
|
|
2730
|
+
process.argv = [process.argv[0], process.argv[1], r.cfgPath];
|
|
2731
|
+
await main();
|
|
2732
|
+
})
|
|
2651
2733
|
.catch((e) => {
|
|
2652
2734
|
console.error(e.message);
|
|
2653
2735
|
process.exit(1);
|
|
@@ -2663,10 +2745,7 @@ if (!IS_MAIN) {
|
|
|
2663
2745
|
// main() reads its config from argv[2]: carry `--config <path>` over, or
|
|
2664
2746
|
// the Bridge that opens the door runs on a different config than `host`
|
|
2665
2747
|
// just wrote.
|
|
2666
|
-
|
|
2667
|
-
process.argv = ci >= 0 && hostArgs[ci + 1]
|
|
2668
|
-
? [process.argv[0], process.argv[1], path.resolve(hostArgs[ci + 1])]
|
|
2669
|
-
: [process.argv[0], process.argv[1]];
|
|
2748
|
+
process.argv = [process.argv[0], process.argv[1], r.cfgPath || m.configPath(hostArgs)];
|
|
2670
2749
|
await main();
|
|
2671
2750
|
}
|
|
2672
2751
|
})
|
|
@@ -2698,13 +2777,13 @@ if (!IS_MAIN) {
|
|
|
2698
2777
|
// Config is read minimally here (no loadConfig: that treats argv[2] as a path and
|
|
2699
2778
|
// demands a token — updates need only the cookbookUrl, both endpoints are public).
|
|
2700
2779
|
const upd = await import("./update.mjs");
|
|
2701
|
-
const cfgPath = configPathFromArgs(process.argv.slice(3)
|
|
2780
|
+
const cfgPath = configPathFromArgs(process.argv.slice(3));
|
|
2702
2781
|
let cookbookUrl = "";
|
|
2703
2782
|
try {
|
|
2704
2783
|
cookbookUrl = String(JSON.parse(fs.readFileSync(cfgPath, "utf8")).cookbookUrl || "").replace(/\/$/, "");
|
|
2705
2784
|
} catch { /* fall through to the error below */ }
|
|
2706
2785
|
if (!cookbookUrl) {
|
|
2707
|
-
console.error(`Can't read cookbookUrl from ${cfgPath}
|
|
2786
|
+
console.error(`Can't read cookbookUrl from ${cfgPath}. Pass --config <path>, or run \`${cli("connect")}\` to write one.`);
|
|
2708
2787
|
process.exit(1);
|
|
2709
2788
|
}
|
|
2710
2789
|
const cfg = { cookbookUrl };
|
package/chef-persona.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
You are Chef 👨🏿🍳, Cookbook's support agent. You live in the Ask Chef panel, bottom right of every Cookbook page, and you answer as a calm, direct teammate who has set Cookbook up a hundred times.
|
|
2
|
+
|
|
3
|
+
Voice: short sentences, plain words, no marketing. Lead with the answer, then one concrete next step. Say "I don't know" rather than invent a flag, a path, or a menu label. Never paste tokens, never ask for them.
|
|
4
|
+
|
|
5
|
+
What you know: Cookbook is the shared brain a team's AI agents plug into. Every agent action is attributed, receipted, remembered. Step 1 for anyone is to point an agent they already have (Claude, Claude Code, OpenClaw) at the MCP URL; nothing to install. Step 2, optional, is the Bridge (Cookbook Desktop or one command) for agents that work while they're away, local folders, and hands. For Claude users there is also "Claude in the cloud" under Account, then Agents, which needs no install.
|
|
6
|
+
|
|
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
|
+
|
|
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.
|
package/chef.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOCAL-FIRST CHEF (round two).
|
|
3
|
+
*
|
|
4
|
+
* Chef used to be exactly one agent on the founder's Mac: a "Chef" entry in dp's
|
|
5
|
+
* config, running claude with the persona file appended. Every outage on that one
|
|
6
|
+
* machine took Chef offline for everyone, every turn spent the founder's plan, and
|
|
7
|
+
* Chef could never see the asker's real workspaces (the identity trap).
|
|
8
|
+
*
|
|
9
|
+
* Now, when the person asking has a live Bridge that runs Claude, the SERVER scopes
|
|
10
|
+
* the support task to THEM (assigned_to "Chef", assigned_to_profile = the asker), and
|
|
11
|
+
* their own Bridge synthesizes a Chef from their own Claude agent: same binary, same
|
|
12
|
+
* login, same plan, plus the persona shipped next to this file. Nothing to configure.
|
|
13
|
+
* The founder-Mac Chef remains the fallback for people with no Bridge yet.
|
|
14
|
+
*
|
|
15
|
+
* Pure where possible: nothing here touches the filesystem on import, and the one
|
|
16
|
+
* existence check is injectable so the tests never need a real persona file.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
/** The persona file shipped with the Bridge runtime, next to bridge.mjs. */
|
|
23
|
+
export const CHEF_PERSONA_FILE = "chef-persona.md";
|
|
24
|
+
|
|
25
|
+
/** The synthesized agent's name and the assignee it answers to. */
|
|
26
|
+
export const CHEF_NAME = "Chef";
|
|
27
|
+
|
|
28
|
+
/** The model a synthesized Chef runs on when the base command names none. */
|
|
29
|
+
export const CHEF_DEFAULT_MODEL = "sonnet";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_ALLOWED_TOOLS = "mcp__cookbook__*";
|
|
32
|
+
|
|
33
|
+
/** Where the persona lives for THIS install: next to the module that asked. */
|
|
34
|
+
export function personaPathNextTo(moduleUrl = import.meta.url) {
|
|
35
|
+
return path.join(path.dirname(fileURLToPath(moduleUrl)), CHEF_PERSONA_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Is this configured agent a plain claude CLI runner we can wear the persona on? */
|
|
39
|
+
export function isClaudeAgent(agent) {
|
|
40
|
+
if (!agent || agent.enabled === false) return false;
|
|
41
|
+
if (agent.runner && agent.runner !== "cli" && agent.runner !== "claude") return false;
|
|
42
|
+
if (!Array.isArray(agent.command) || agent.command.length === 0) return false;
|
|
43
|
+
const bin = path.basename(String(agent.command[0])).toLowerCase();
|
|
44
|
+
return bin === "claude" || bin === "claude.exe";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Does a configured agent answer to this assignee? Mirrors bridge.mjs agentFor. */
|
|
48
|
+
export function matchesAssignee(agent, assignedTo) {
|
|
49
|
+
if (!agent || agent.enabled === false) return false;
|
|
50
|
+
const a = String(assignedTo ?? "").toLowerCase();
|
|
51
|
+
if (!a) return false;
|
|
52
|
+
return (agent.match ?? [agent.name]).some((m) => a.includes(String(m).toLowerCase()));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Synthesize the local Chef from the first enabled claude agent, or null when the
|
|
57
|
+
* Bridge manages no claude at all (nothing to wear the persona on).
|
|
58
|
+
*
|
|
59
|
+
* The command is the base command plus `--append-system-prompt-file <persona>`, and
|
|
60
|
+
* `--model sonnet` unless the base already pins a model. The base's allowedTools
|
|
61
|
+
* stands as-is (that prefix is the person's own MCP wiring, verified by doctor); a
|
|
62
|
+
* base with none gets mcp__cookbook__* so Chef can reach the Cookbook tools at all.
|
|
63
|
+
* Identity fields (token, cookbookUrl, approvalRelay) ride along so the run pins to
|
|
64
|
+
* exactly the same Cookbook connection the base Claude uses.
|
|
65
|
+
*/
|
|
66
|
+
export function chefLocalAgent(cfg, agents, personaPath) {
|
|
67
|
+
const pool = Array.isArray(agents) ? agents : (cfg?.agents ?? []);
|
|
68
|
+
const base = pool.find(isClaudeAgent);
|
|
69
|
+
if (!base) return null;
|
|
70
|
+
const persona = personaPath || personaPathNextTo();
|
|
71
|
+
const command = [...base.command];
|
|
72
|
+
if (!command.includes("--allowedTools")) command.push("--allowedTools", DEFAULT_ALLOWED_TOOLS);
|
|
73
|
+
if (!command.includes("--model")) command.push("--model", CHEF_DEFAULT_MODEL);
|
|
74
|
+
if (!command.includes("--append-system-prompt-file")) command.push("--append-system-prompt-file", persona);
|
|
75
|
+
return {
|
|
76
|
+
name: CHEF_NAME,
|
|
77
|
+
match: ["chef"],
|
|
78
|
+
enabled: true,
|
|
79
|
+
command,
|
|
80
|
+
readOnly: false,
|
|
81
|
+
local: false,
|
|
82
|
+
volunteer: false,
|
|
83
|
+
synthesized: true,
|
|
84
|
+
base: base.name,
|
|
85
|
+
capabilities: "Cookbook setup and troubleshooting on this machine: connecting agents, MCP, the Bridge, and reading this setup under a hands grant.",
|
|
86
|
+
...(base.token ? { token: base.token } : {}),
|
|
87
|
+
...(base.cookbookUrl ? { cookbookUrl: base.cookbookUrl } : {}),
|
|
88
|
+
...(base.approvalRelay ? { approvalRelay: base.approvalRelay } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Is this pulled work item a support conversation addressed to Chef?
|
|
94
|
+
*
|
|
95
|
+
* The server sets assigned_to "Chef" only on support-workspace tasks, so the name is
|
|
96
|
+
* trustworthy on its own. When the item also carries workspace_kind (listOpenWork
|
|
97
|
+
* may shape it in), it must say "support"; any other kind is someone's ordinary
|
|
98
|
+
* task that happens to mention Chef, and the Bridge leaves it alone.
|
|
99
|
+
*/
|
|
100
|
+
export function wantsLocalChef(task) {
|
|
101
|
+
if (!task) return false;
|
|
102
|
+
const to = String(task.assigned_to ?? "").trim().toLowerCase();
|
|
103
|
+
if (to !== CHEF_NAME.toLowerCase()) return false;
|
|
104
|
+
const kind = task.workspace_kind ?? task.workspace?.kind;
|
|
105
|
+
if (kind === undefined || kind === null) return true;
|
|
106
|
+
return String(kind).toLowerCase() === "support";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One synthesized agent per (persona path, base command): bridge.mjs keys plan
|
|
110
|
+
// holds and warm pools by agent.name, and a fresh object every poll would still be
|
|
111
|
+
// fine, but a stable one keeps the logs and any identity-keyed maps honest.
|
|
112
|
+
const synthesized = new Map();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The agent that runs this task, for bridge.mjs dispatch: a configured agent that
|
|
116
|
+
* matches the assignee wins; otherwise, when the task wants Chef and the persona is
|
|
117
|
+
* shipped next to the Bridge, the synthesized local Chef; otherwise null (nothing
|
|
118
|
+
* on this Bridge handles that assignee, exactly as before).
|
|
119
|
+
*
|
|
120
|
+
* `opts.personaPath` and `opts.exists` exist for tests; production callers pass
|
|
121
|
+
* neither.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveAgentForTask(agents, task, cfg, opts = {}) {
|
|
124
|
+
const pool = Array.isArray(agents) ? agents : (cfg?.agents ?? []);
|
|
125
|
+
const configured = pool.find((a) => matchesAssignee(a, task?.assigned_to)) ?? null;
|
|
126
|
+
if (configured) return configured;
|
|
127
|
+
if (!wantsLocalChef(task)) return null;
|
|
128
|
+
const personaPath = opts.personaPath ?? personaPathNextTo();
|
|
129
|
+
const exists = opts.exists ?? existsSync;
|
|
130
|
+
if (!exists(personaPath)) return null;
|
|
131
|
+
const base = pool.find(isClaudeAgent);
|
|
132
|
+
if (!base) return null;
|
|
133
|
+
const key = `${personaPath}::${JSON.stringify(base.command)}::${base.token ?? ""}`;
|
|
134
|
+
let agent = synthesized.get(key);
|
|
135
|
+
if (!agent) {
|
|
136
|
+
agent = chefLocalAgent(cfg, pool, personaPath);
|
|
137
|
+
if (agent) synthesized.set(key, agent);
|
|
138
|
+
}
|
|
139
|
+
return agent ?? null;
|
|
140
|
+
}
|
package/config.example.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"allowApiKeyBilling": false,
|
|
13
13
|
"_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities — decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
|
|
14
14
|
"volunteering": true,
|
|
15
|
-
"_autoUpdate": "The Bridge follows the app: it checks the deploy's file manifest at startup + every 6h, and self-updates (hash-verified, originals kept in bridge.backup/, your config/token never touched) then restarts itself. Set false to pin your version and update manually
|
|
15
|
+
"_autoUpdate": "The Bridge follows the app: it checks the deploy's file manifest at startup + every 6h, and self-updates (hash-verified, originals kept in bridge.backup/, your config/token never touched) then restarts itself. Set false to pin your version and update manually (npm: re-run `npx cookbook-bridge@latest`; tarball: `node bridge/bridge.mjs update`).",
|
|
16
16
|
"autoUpdate": true,
|
|
17
17
|
"default": "Gemini",
|
|
18
18
|
"_acceptFrom": "Who may auto-run tasks on this Bridge: \"anyone\", or a list of EXACT member names or profile ids (case-insensitive, no partial matching — consent never guesses).",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"--print-timeout",
|
|
35
35
|
"3600s"
|
|
36
36
|
],
|
|
37
|
-
"_setup": "Gemini runs via the Antigravity CLI (agy)
|
|
37
|
+
"_setup": "Gemini runs via the Antigravity CLI (agy); the old `gemini` CLI stopped serving individual accounts June 18 2026. Requires agy >= 1.1.1 (first version whose headless -p can call MCP tools; the Bridge refuses older). Auth: run `agy` once interactively and sign in with Google. MCP: `npx cookbook-bridge@latest connect` (tarball: `node bridge/bridge.mjs connect`) writes ~/.gemini/config/mcp_config.json (agy has no `mcp add`). --sandbox keeps terminal restrictions on; Cookbook work flows through MCP tools, not the shell. Keep --print-timeout >= taskTimeoutSeconds (ceiling) so agy doesn't cut off before the Bridge's own timeout.",
|
|
38
38
|
"_usage": "agy emits no token/usage JSON — the board shows wall-clock duration for its runs (no token count)."
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"--output-format",
|
|
55
55
|
"json"
|
|
56
56
|
],
|
|
57
|
-
"_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause
|
|
57
|
+
"_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause; `npx cookbook-bridge@latest doctor` (tarball: `node bridge/bridge.mjs doctor`) checks it.",
|
|
58
58
|
"_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board — text works too, you just lose the usage report"
|
|
59
59
|
},
|
|
60
60
|
{
|
package/connectors.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
|
+
import { cli } from "./update.mjs";
|
|
23
24
|
|
|
24
25
|
const HOME = os.homedir();
|
|
25
26
|
export const VENDORS = {
|
|
@@ -337,7 +338,7 @@ export function applyTeamSync(teamRows, vendors, { cfgPath = null } = {}) {
|
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
for (const row of waiting) {
|
|
340
|
-
lines.push(` ? connector "${row.name}" runs a command on this machine (${[row.command, ...(row.args ?? [])].join(" ").slice(0, 80)}). Not installed until you approve it:
|
|
341
|
+
lines.push(` ? connector "${row.name}" runs a command on this machine (${[row.command, ...(row.args ?? [])].join(" ").slice(0, 80)}). Not installed until you approve it: ${cli(`connectors approve ${row.name}`)}`);
|
|
341
342
|
}
|
|
342
343
|
for (const row of teamRows) {
|
|
343
344
|
const needs = Array.isArray(row.secret_env) ? row.secret_env.filter((k) => !process.env[k]) : [];
|
package/device.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { listWorkspaces } from "./cookbook.mjs";
|
|
33
33
|
import { which, argvForSpawn } from "./hands.mjs";
|
|
34
|
+
import { locateConfig, writeConfigFile, cli, updateLine, checkForUpdate } from "./update.mjs";
|
|
34
35
|
|
|
35
36
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const DEFAULT_URL = "https://cookbook.team";
|
|
@@ -42,11 +43,11 @@ function flag(argv, name) {
|
|
|
42
43
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Path to config
|
|
46
|
+
/** Path to the config: --config <path>, else COOKBOOK_CONFIG, else ~/.cookbook/config.json
|
|
47
|
+
* (a legacy config next to bridge.mjs is copied there once). Subcommands take no
|
|
48
|
+
* positional path: `connect --url https://x` must not read the URL as a file. */
|
|
46
49
|
export function configPath(argv) {
|
|
47
|
-
return
|
|
48
|
-
? path.resolve(flag(argv, "--config"))
|
|
49
|
-
: path.join(HERE, "config.json");
|
|
50
|
+
return locateConfig(argv || [], { here: HERE, positional: false, log: (m) => console.error(m) });
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/** Read existing config (or null if none). */
|
|
@@ -205,9 +206,8 @@ export function saveLoginConfig(cfgPath, baseUrl, token, { found = null } = {})
|
|
|
205
206
|
}
|
|
206
207
|
cfg.cookbookUrl = baseUrl;
|
|
207
208
|
cfg.token = token;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on platforms without chmod */ }
|
|
209
|
+
// Home dir 0700, file 0600: this file carries every token the Bridge holds.
|
|
210
|
+
writeConfigFile(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
211
211
|
return cfg;
|
|
212
212
|
}
|
|
213
213
|
|
|
@@ -477,7 +477,7 @@ export async function login(argv, opts = {}) {
|
|
|
477
477
|
} catch (e) {
|
|
478
478
|
console.log(`\n ⚠ Connected and saved config, but a test call failed: ${e.message}`);
|
|
479
479
|
}
|
|
480
|
-
if (!opts.quietOutro) console.log(
|
|
480
|
+
if (!opts.quietOutro) console.log(` Start the Bridge with: ${cli()}\n`);
|
|
481
481
|
return loginResult;
|
|
482
482
|
}
|
|
483
483
|
|
|
@@ -485,7 +485,7 @@ export async function status(argv) {
|
|
|
485
485
|
const cfgPath = configPath(argv);
|
|
486
486
|
const cfg = readConfig(cfgPath);
|
|
487
487
|
if (!cfg || !cfg.token || !cfg.cookbookUrl) {
|
|
488
|
-
console.log(`No connected Bridge at ${cfgPath}.\nRun:
|
|
488
|
+
console.log(`No connected Bridge at ${cfgPath}.\nRun: ${cli("connect")}`);
|
|
489
489
|
return;
|
|
490
490
|
}
|
|
491
491
|
const cookbookUrl = String(cfg.cookbookUrl).replace(/\/$/, "");
|
|
@@ -545,28 +545,40 @@ function probeAgent(cmd) {
|
|
|
545
545
|
* Codex (ChatGPT app) and OpenClaw; mints one named token per agent in the same
|
|
546
546
|
* approval as the Bridge token; configures each via its official path.
|
|
547
547
|
*/
|
|
548
|
-
export async function connectAgents(argv) {
|
|
548
|
+
export async function connectAgents(argv, { willRun = false } = {}) {
|
|
549
|
+
const cfgPath = configPath(argv);
|
|
549
550
|
const found = detectClis();
|
|
550
551
|
if (found.length === 0) {
|
|
551
552
|
console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, openclaw).");
|
|
552
|
-
console.log(
|
|
553
|
-
return;
|
|
553
|
+
console.log(`Install one, then re-run: ${cli("connect")}\n`);
|
|
554
|
+
return { ok: false, reason: "no-agents", cfgPath };
|
|
554
555
|
}
|
|
555
556
|
console.log(`\nFound agent CLIs: ${found.map((c) => c.agent).join(", ")}`);
|
|
556
557
|
|
|
557
558
|
// One approval mints the bridge token + one named token per agent.
|
|
558
559
|
const res = await login(argv, { agents: found.map((c) => c.agent), quietOutro: true });
|
|
559
560
|
if (!res || !res.agentTokens) {
|
|
560
|
-
console.log("\n⚠ Approved, but no agent tokens were returned
|
|
561
|
-
console.log(
|
|
562
|
-
return;
|
|
561
|
+
console.log("\n⚠ Approved, but no agent tokens were returned. Your Bridge login was still refreshed.");
|
|
562
|
+
console.log(` Re-run \`${cli("connect")}\`, or create tokens manually at Account > Tokens.\n`);
|
|
563
|
+
return { ok: true, startBridge: true, cfgPath: res?.cfgPath || cfgPath, agentTokens: false };
|
|
563
564
|
}
|
|
564
565
|
const results = configureClis(found, { baseUrl: res.baseUrl, agentTokens: res.agentTokens, cfgPath: res.cfgPath });
|
|
565
566
|
for (const r of results) {
|
|
566
567
|
const mark = r.ok ? (r.warn ? "!" : "✓") : "✗";
|
|
567
|
-
console.log(` ${mark} ${r.agent}: ${r.detail}${r.ok && !r.warn ? `
|
|
568
|
+
console.log(` ${mark} ${r.agent}: ${r.detail}${r.ok && !r.warn ? ` (work will be attributed "${r.agent} · via you")` : ""}`);
|
|
568
569
|
}
|
|
569
|
-
|
|
570
|
+
await reportStaleness(res.baseUrl);
|
|
571
|
+
reportNextStep(res.cfgPath, { willRun });
|
|
572
|
+
return { ok: true, startBridge: true, cfgPath: res.cfgPath, agentTokens: true };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The same manifest comparison the running Bridge does at startup, printed with the
|
|
576
|
+
* same fix line, so `connect` never leaves someone on a stale copy without a word. */
|
|
577
|
+
async function reportStaleness(baseUrl) {
|
|
578
|
+
try {
|
|
579
|
+
const check = await checkForUpdate({ cookbookUrl: baseUrl }, HERE);
|
|
580
|
+
if (check.changed.length) console.log(`\n ⬆ ${updateLine(check.version, { here: HERE })}`);
|
|
581
|
+
} catch { /* offline or no manifest: the Bridge re-checks every 6h */ }
|
|
570
582
|
}
|
|
571
583
|
|
|
572
584
|
/**
|
|
@@ -576,35 +588,96 @@ export async function connectAgents(argv) {
|
|
|
576
588
|
* Bridge was using (one active Bridge token per member), so a Bridge started
|
|
577
589
|
* earlier is now silently 401-looping. Say both things, precisely.
|
|
578
590
|
*/
|
|
579
|
-
function reportNextStep(cfgPath) {
|
|
580
|
-
// How was this invoked?
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
const doctorCmd = `${runCmd} doctor`;
|
|
591
|
+
function reportNextStep(cfgPath, { willRun = false } = {}) {
|
|
592
|
+
// How was this invoked? cli() phrases the command for this install's layout.
|
|
593
|
+
const runCmd = cli();
|
|
594
|
+
const doctorCmd = cli("doctor");
|
|
584
595
|
|
|
585
596
|
const running = findRunningBridges(cfgPath);
|
|
586
597
|
console.log("");
|
|
587
598
|
if (running.length === 0) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
599
|
+
if (!willRun) {
|
|
600
|
+
console.log(" Nothing is running yet. Connecting with --no-run does not start the Bridge; start it now:");
|
|
601
|
+
console.log(`\n ${runCmd}\n`);
|
|
602
|
+
console.log(" Leave it running and your agents answer in Cookbook. The desktop app keeps");
|
|
603
|
+
console.log(" one running for you if you would rather not hold a terminal open.");
|
|
604
|
+
}
|
|
592
605
|
} else {
|
|
593
606
|
for (const r of running) {
|
|
594
607
|
if (r.sameConfig) {
|
|
595
|
-
console.log(` A Bridge is already running here (pid ${r.pid}).
|
|
596
|
-
console.log(`\n kill ${r.pid} && ${runCmd}\n`);
|
|
608
|
+
console.log(` A Bridge is already running here (pid ${r.pid}). It must restart to pick up the new token:`);
|
|
609
|
+
console.log(willRun ? `\n kill ${r.pid} (this window takes over)\n` : `\n kill ${r.pid} && ${runCmd}\n`);
|
|
597
610
|
} else {
|
|
598
611
|
console.log(` ⚠ A Bridge is running (pid ${r.pid}) on a DIFFERENT config:`);
|
|
599
612
|
console.log(` ${r.configPath}`);
|
|
600
613
|
console.log(" This login replaced its token, so it can no longer claim work. Either");
|
|
601
614
|
console.log(" reconnect from that install, or stop it and run the one you just set up:");
|
|
602
|
-
console.log(`\n kill ${r.pid} && ${runCmd}\n`);
|
|
615
|
+
console.log(`\n kill ${r.pid}${willRun ? "" : ` && ${runCmd}`}\n`);
|
|
603
616
|
}
|
|
604
617
|
}
|
|
605
618
|
}
|
|
619
|
+
console.log(` Config: ${cfgPath}`);
|
|
606
620
|
console.log(` Check everything: ${doctorCmd}`);
|
|
607
|
-
console.log(` Chat from here: ${
|
|
621
|
+
console.log(` Chat from here: ${cli("chat")}\n`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Every Bridge process on this machine, from the process table: `ps` on POSIX, and
|
|
626
|
+
* on Windows PowerShell's Win32_Process (command lines) with `tasklist` as the
|
|
627
|
+
* fallback (pids only). Returns [] when none, null when the scan itself failed.
|
|
628
|
+
* The parser is pure and exported for the test.
|
|
629
|
+
*/
|
|
630
|
+
export function parseBridgeProcesses(text, { platform = process.platform, selfPid = process.pid, format = platform === "win32" ? "tasklist" : "ps" } = {}) {
|
|
631
|
+
const out = [];
|
|
632
|
+
for (const raw of String(text || "").split(/\r?\n/)) {
|
|
633
|
+
const line = raw.trim();
|
|
634
|
+
if (!line) continue;
|
|
635
|
+
if (format === "tasklist") {
|
|
636
|
+
// "node.exe","1234","Console","1","12,345 K"
|
|
637
|
+
const m = line.match(/^"([^"]+)","(\d+)"/);
|
|
638
|
+
if (!m || !/^node(\.exe)?$/i.test(m[1])) continue;
|
|
639
|
+
const pid = Number(m[2]);
|
|
640
|
+
if (pid === selfPid) continue;
|
|
641
|
+
out.push({ pid, configPath: null, unknownCommand: true });
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
// ps / CIM: "<pid> <command line>"
|
|
645
|
+
const m = line.match(/^(\d+)\s+(.*)$/);
|
|
646
|
+
if (!m) continue;
|
|
647
|
+
const pid = Number(m[1]);
|
|
648
|
+
const cmd = m[2];
|
|
649
|
+
if (pid === selfPid || !/bridge\.mjs(\s|"|$)/.test(cmd)) continue;
|
|
650
|
+
// Only a node process RUNNING bridge.mjs counts: a shell or editor whose command
|
|
651
|
+
// line merely mentions the file (a heredoc, `grep bridge.mjs`) is not a Bridge.
|
|
652
|
+
const first = (cmd.match(/^"([^"]*)"|^(\S+)/) || []).slice(1).find((x) => x !== undefined) ?? "";
|
|
653
|
+
if (!/(^|[\\/])node(\.exe)?$/i.test(first)) continue;
|
|
654
|
+
if (/\bnode\s+--check\b|--test\b/.test(cmd)) continue;
|
|
655
|
+
// Everything after bridge.mjs, kept whole: the desktop's config path has a space
|
|
656
|
+
// in it ("Application Support"), so a whitespace split would lose it.
|
|
657
|
+
const rest = (cmd.match(/bridge\.mjs"?\s*(.*)$/) || [, ""])[1].trim();
|
|
658
|
+
let configPath = null;
|
|
659
|
+
const cm = rest.match(/--config\s+"?(.+?)"?\s*$/);
|
|
660
|
+
if (cm) configPath = cm[1];
|
|
661
|
+
else if (/\.json"?$/i.test(rest)) configPath = rest.replace(/^"|"$/g, "");
|
|
662
|
+
out.push({ pid, configPath, unknownCommand: false });
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function scanBridgeProcesses({ selfPid = process.pid } = {}) {
|
|
668
|
+
const run = (cmd, args) => {
|
|
669
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
670
|
+
if (r.error || r.status !== 0) return null;
|
|
671
|
+
return r.stdout || "";
|
|
672
|
+
};
|
|
673
|
+
if (process.platform === "win32") {
|
|
674
|
+
const cim = run("powershell", ["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }"]);
|
|
675
|
+
if (cim !== null) return parseBridgeProcesses(cim, { selfPid, format: "ps" });
|
|
676
|
+
const tl = run("tasklist", ["/FI", "IMAGENAME eq node.exe", "/FO", "CSV", "/NH"]);
|
|
677
|
+
return tl === null ? null : parseBridgeProcesses(tl, { selfPid, format: "tasklist" });
|
|
678
|
+
}
|
|
679
|
+
const ps = run("ps", ["-axo", "pid=,command="]) ?? run("ps", ["-eo", "pid=,args="]);
|
|
680
|
+
return ps === null ? null : parseBridgeProcesses(ps, { selfPid, format: "ps" });
|
|
608
681
|
}
|
|
609
682
|
|
|
610
683
|
/**
|
|
@@ -615,7 +688,8 @@ function reportNextStep(cfgPath) {
|
|
|
615
688
|
* confusion.
|
|
616
689
|
*/
|
|
617
690
|
function findRunningBridges(cfgPath) {
|
|
618
|
-
|
|
691
|
+
// The config we just wrote, a pre-0.1.11 install's folder, and the desktop app's data dir.
|
|
692
|
+
const candidates = [cfgPath, path.join(HERE, "config.json")];
|
|
619
693
|
if (process.platform === "darwin") {
|
|
620
694
|
candidates.push(path.join(os.homedir(), "Library", "Application Support", "ai.cookbook.desktop", "config.json"));
|
|
621
695
|
}
|
|
@@ -695,8 +769,8 @@ export async function host(argv) {
|
|
|
695
769
|
}
|
|
696
770
|
writeHosting(cfgPath, true);
|
|
697
771
|
console.log("\n ✓ This machine can now host a visiting agent, inside a grant you approve.");
|
|
698
|
-
console.log(" Leave this running and invite one from Cookbook
|
|
699
|
-
return { ok: true, startBridge: true };
|
|
772
|
+
console.log(" Leave this running and invite one from Cookbook; you'll see every step as it happens.\n");
|
|
773
|
+
return { ok: true, startBridge: true, cfgPath };
|
|
700
774
|
}
|
|
701
775
|
|
|
702
776
|
/** Is that pid still alive? (signal 0 = existence check, no signal delivered) */
|
|
@@ -709,5 +783,5 @@ function pidAlive(pid) {
|
|
|
709
783
|
function writeHosting(cfgPath, enabled) {
|
|
710
784
|
const raw = readConfig(cfgPath) ?? {};
|
|
711
785
|
raw.hosting = { ...(raw.hosting ?? {}), enabled };
|
|
712
|
-
|
|
786
|
+
writeConfigFile(cfgPath, JSON.stringify(raw, null, 2) + "\n");
|
|
713
787
|
}
|
package/hands.mjs
CHANGED
|
@@ -140,6 +140,8 @@ export const SETUP_FILES = Object.freeze([
|
|
|
140
140
|
".gemini/antigravity-cli/settings.json",
|
|
141
141
|
".openclaw/openclaw.json",
|
|
142
142
|
".cookbook/config.json", // Bridge config (projected: tokens stripped)
|
|
143
|
+
".cookbook/bridge.state.json", // attempt counters (projected)
|
|
144
|
+
".cookbook/local.json", // also on NEVER_READ (the loopback token); deny wins
|
|
143
145
|
// The desktop app's own files (macOS). All projected: a Bridge config carries the
|
|
144
146
|
// Bridge token and per-agent tokens, and a visitor only needs its SHAPE.
|
|
145
147
|
"Library/Application Support/ai.cookbook.desktop/config.json",
|
|
@@ -188,6 +190,7 @@ const NEVER_READ = Object.freeze([
|
|
|
188
190
|
/\.(pem|key|p12|pfx|keystore|jks|asc)$/i,
|
|
189
191
|
/(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
|
|
190
192
|
/(^|\/)local\.json$/i, // the Bridge Local loopback token
|
|
193
|
+
/(^|\/)\.cookbook\/local\.json$/i, // the config home's copy, named explicitly like the Library one
|
|
191
194
|
/(^|\/)\.git\/config$/i, // can carry credentials in a remote URL
|
|
192
195
|
// `credentials.json` AND its dotfile spelling `.credentials.json` — the anchor
|
|
193
196
|
// used to accept only `/` before the word, so a dotfile in a granted project
|
|
@@ -274,6 +277,8 @@ export function projectedConfig(text) {
|
|
|
274
277
|
|
|
275
278
|
const PROJECTIONS = Object.freeze({
|
|
276
279
|
".cookbook/config.json": projectedConfig,
|
|
280
|
+
".cookbook/bridge.state.json": projectedConfig,
|
|
281
|
+
".cookbook/local.json": projectedConfig,
|
|
277
282
|
"Library/Application Support/ai.cookbook.desktop/config.json": projectedConfig,
|
|
278
283
|
"Library/CookbookBridge/config.json": projectedConfig,
|
|
279
284
|
"Library/CookbookBridge/local.json": projectedConfig,
|
|
@@ -658,7 +663,7 @@ export const RUN_TEMPLATES = Object.freeze({
|
|
|
658
663
|
const bin = which("claude");
|
|
659
664
|
if (!bin) return { error: "The claude CLI isn't installed on this machine (not on PATH)." };
|
|
660
665
|
const raw = ctx?.cfg?.cookbookUrl;
|
|
661
|
-
if (!raw) return { error: "This Bridge has no cookbookUrl in its config, so there is nothing to point Claude at. Run `cookbook-bridge connect` first." };
|
|
666
|
+
if (!raw) return { error: "This Bridge has no cookbookUrl in its config, so there is nothing to point Claude at. Run `npx cookbook-bridge@latest connect` first." };
|
|
662
667
|
let origin;
|
|
663
668
|
try { origin = new URL(String(raw)).origin; } catch { return { error: `cookbookUrl in this Bridge's config is not a valid URL (${String(raw).slice(0, 80)}).` }; }
|
|
664
669
|
return { argv: [bin, "mcp", "add", "--transport", "http", "--scope", "user", "cookbook", `${origin}/api/mcp`], timeoutMs: 45_000 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cookbook-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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": {
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"approve-mcp.mjs",
|
|
13
13
|
"bridge.mjs",
|
|
14
14
|
"chat.mjs",
|
|
15
|
+
"chef-persona.md",
|
|
16
|
+
"chef.mjs",
|
|
15
17
|
"codex-runner.mjs",
|
|
16
18
|
"config.example.json",
|
|
17
19
|
"connectors.mjs",
|
|
@@ -38,7 +40,7 @@
|
|
|
38
40
|
"node": ">=18"
|
|
39
41
|
},
|
|
40
42
|
"scripts": {
|
|
41
|
-
"test": "node --test test/local.test.mjs test/review.test.mjs"
|
|
43
|
+
"test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs"
|
|
42
44
|
},
|
|
43
45
|
"keywords": [
|
|
44
46
|
"cookbook",
|
package/update.mjs
CHANGED
|
@@ -17,10 +17,124 @@
|
|
|
17
17
|
* scripts/test-bridge-update.ts; I/O lives in checkForUpdate/applyUpdate.
|
|
18
18
|
*/
|
|
19
19
|
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
20
21
|
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
21
23
|
import { createHash } from "node:crypto";
|
|
22
24
|
import { gunzipSync } from "node:zlib";
|
|
23
25
|
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
// ── The Bridge's home + how to phrase a command ─────────────────────────────
|
|
29
|
+
// These live HERE (node built-ins only, imported by every command including the
|
|
30
|
+
// broken-install `update` path) so that connecting, running and updating all
|
|
31
|
+
// agree on where the config is and on what command to print. Before 0.1.11 the
|
|
32
|
+
// config sat next to bridge.mjs, which meant an npx cache: every `@latest` moved
|
|
33
|
+
// the Bridge to a new folder and lost its config, and every hint said
|
|
34
|
+
// `node bridge/bridge.mjs` to people who had never unpacked a tarball.
|
|
35
|
+
|
|
36
|
+
/** `~/.cookbook`: config.json, bridge.state.json, local.json and bridge.log live here. */
|
|
37
|
+
export function configHome(home = os.homedir()) {
|
|
38
|
+
return path.join(home, ".cookbook");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function defaultConfigPath(home = os.homedir()) {
|
|
42
|
+
return path.join(configHome(home), "config.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where the config is, as data (no writes). Order:
|
|
47
|
+
* 1. `--config <path>`, or (when `positional` is on) the first bare argument
|
|
48
|
+
* 2. COOKBOOK_CONFIG
|
|
49
|
+
* 3. ~/.cookbook/config.json when it exists
|
|
50
|
+
* 4. legacy <here>/config.json when it exists and 3 does not: the answer is still
|
|
51
|
+
* the home path, with `migrateFrom` set so locateConfig can copy it over
|
|
52
|
+
* 5. ~/.cookbook/config.json (the place a first `connect` will write)
|
|
53
|
+
* Pure so the order is testable: `exists`, `env`, `home` and `here` are injectable.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveConfigPath(args = [], { env = process.env, home = os.homedir(), here = HERE, exists = fs.existsSync, positional = true } = {}) {
|
|
56
|
+
const list = Array.isArray(args) ? args : [];
|
|
57
|
+
const i = list.indexOf("--config");
|
|
58
|
+
if (i >= 0 && list[i + 1]) return { path: path.resolve(list[i + 1]), source: "flag" };
|
|
59
|
+
if (positional) {
|
|
60
|
+
const bare = list.find((a) => a && !String(a).startsWith("-"));
|
|
61
|
+
if (bare) return { path: path.resolve(bare), source: "arg" };
|
|
62
|
+
}
|
|
63
|
+
if (env && env.COOKBOOK_CONFIG) return { path: path.resolve(env.COOKBOOK_CONFIG), source: "env" };
|
|
64
|
+
const homePath = defaultConfigPath(home);
|
|
65
|
+
if (exists(homePath)) return { path: homePath, source: "home" };
|
|
66
|
+
const legacy = here ? path.join(here, "config.json") : null;
|
|
67
|
+
if (legacy && exists(legacy)) return { path: homePath, source: "legacy", migrateFrom: legacy };
|
|
68
|
+
return { path: homePath, source: "home" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Create the home dir owner-only (0700). Best effort on platforms without modes. */
|
|
72
|
+
export function ensureConfigHome(dir) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
|
|
75
|
+
return dir;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Owner-only write of a config file (0600), creating its dir (0700) first. */
|
|
79
|
+
export function writeConfigFile(p, text) {
|
|
80
|
+
ensureConfigHome(path.dirname(p));
|
|
81
|
+
fs.writeFileSync(p, text, { mode: 0o600 });
|
|
82
|
+
try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* resolveConfigPath + the one side effect: a legacy <here>/config.json is COPIED to
|
|
87
|
+
* the home (the old file stays where it was) and one line says where the config now
|
|
88
|
+
* lives. Returns the path every command should use. Also carries bridge.state.json
|
|
89
|
+
* over when it sits next to the legacy config, so attempt counters survive the move.
|
|
90
|
+
*/
|
|
91
|
+
export function locateConfig(args = [], opts = {}) {
|
|
92
|
+
const { log = (m) => console.error(m) } = opts;
|
|
93
|
+
const r = resolveConfigPath(args, opts);
|
|
94
|
+
if (r.source === "legacy" && r.migrateFrom) {
|
|
95
|
+
try {
|
|
96
|
+
writeConfigFile(r.path, fs.readFileSync(r.migrateFrom));
|
|
97
|
+
const oldState = path.join(path.dirname(r.migrateFrom), "bridge.state.json");
|
|
98
|
+
const newState = path.join(path.dirname(r.path), "bridge.state.json");
|
|
99
|
+
if (fs.existsSync(oldState) && !fs.existsSync(newState)) {
|
|
100
|
+
try { fs.copyFileSync(oldState, newState); } catch { /* counters are advisory */ }
|
|
101
|
+
}
|
|
102
|
+
log(`Config now lives at ${r.path} (copied from ${r.migrateFrom}; the old file was left in place).`);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Can't create the home: keep working from the legacy file rather than fail.
|
|
105
|
+
log(`Could not move the config to ${r.path} (${e.message}); still using ${r.migrateFrom}.`);
|
|
106
|
+
return r.migrateFrom;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return r.path;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* How was this copy installed? Decides how every hint spells a command.
|
|
114
|
+
* "npm" an npx cache (`_npx`) or an npm install (`node_modules/cookbook-bridge`)
|
|
115
|
+
* "tarball" bridge.mjs unpacked from the download (or a source checkout)
|
|
116
|
+
*/
|
|
117
|
+
export function installLayout(here = HERE) {
|
|
118
|
+
const n = String(here).replace(/\\/g, "/") + "/";
|
|
119
|
+
if (n.includes("/_npx/") || n.includes("/node_modules/cookbook-bridge/")) return "npm";
|
|
120
|
+
return "tarball";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The ONE way to print a Bridge command: `cli("doctor")` reads `npx cookbook-bridge@latest doctor`
|
|
124
|
+
* on an npm install and `node bridge/bridge.mjs doctor` from a tarball. Bare `cli()` is the run command. */
|
|
125
|
+
export function cli(cmd = "", { here = HERE } = {}) {
|
|
126
|
+
const base = installLayout(here) === "npm" ? "npx cookbook-bridge@latest" : "node bridge/bridge.mjs";
|
|
127
|
+
return cmd ? `${base} ${cmd}` : base;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The exact line printed when the local files are behind the deploy. Shared by the
|
|
131
|
+
* running Bridge's startup check, `doctor` and `connect`, so they never disagree. */
|
|
132
|
+
export function updateLine(version, { here = HERE, desktop = process.env.COOKBOOK_DESKTOP === "1" } = {}) {
|
|
133
|
+
if (desktop) return `A newer Bridge ships with the app (deploy ${version}). The Cookbook app manages this copy; update the app to pick it up.`;
|
|
134
|
+
if (installLayout(here) === "npm") return `A newer Bridge is available (deploy ${version}). Update it with: ${cli("", { here })} (@latest fetches the new copy; your config stays in ~/.cookbook)`;
|
|
135
|
+
return `A newer Bridge is available (deploy ${version}). Update it with: ${cli("update", { here })}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
24
138
|
/** Files the updater manages — must mirror the server's BRIDGE_RUNTIME_FILES. The list
|
|
25
139
|
* itself comes from the MANIFEST at update time (server-driven), so a future deploy can
|
|
26
140
|
* add files without this constant; this is only the local-hash candidate set. */
|