@nopeek/agent-bridge 0.5.6 → 0.6.0

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/dist/bridge.js CHANGED
@@ -1,10 +1,13 @@
1
1
  // Orchestrator. A bridge has two states:
2
2
  // unpaired — local control API up, waiting for the NoPeek app to POST /pair
3
- // paired — runs every bot the runtime owns (GET /runtime/bots), keeps the
4
- // fleet current via the control WebSocket (adopt_bot arrives live),
5
- // and still serves the local API for status + live brain changes.
3
+ // paired — one PairingRuntime PER PAIRED ACCOUNT, each with its own control
4
+ // WebSocket and bot fleet (GET /runtime/bots + live adopt_bot),
5
+ // all running simultaneously; the local API serves status + live
6
+ // brain changes across all of them.
6
7
  // Pairing, unpairing and brain config all happen at runtime (from the app) and
7
- // persist to <home>/settings.json — no restart, no terminal.
8
+ // persist to <home>/settings.json — no restart, no terminal. Brains
9
+ // (brainMap/serverBackends) are GLOBAL per machine: handles are globally
10
+ // unique, so a handle→brain map needs no per-account scoping.
8
11
  import { hostname } from "node:os";
9
12
  import { spawn } from "node:child_process";
10
13
  import { saveSettings } from "./config.js";
@@ -14,7 +17,7 @@ import { resolveBrain } from "./brain.js";
14
17
  import { provisionSoul, provisionHermesProfile } from "./backends.js";
15
18
  import { reportCapabilities } from "./capabilities.js";
16
19
  import { isBrainBackend } from "./config.js";
17
- export const VERSION = "0.5.6";
20
+ export const VERSION = "0.6.0";
18
21
  /** How often to re-probe + report brain availability to the server. */
19
22
  const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
20
23
  export class PairError extends Error {
@@ -24,51 +27,278 @@ export class PairError extends Error {
24
27
  this.code = code;
25
28
  }
26
29
  }
27
- export class BridgeApp {
30
+ /**
31
+ * Everything ONE pairing runs: its control WebSocket, its bot fleet, and its
32
+ * capabilities reports. This is exactly the pre-0.6 single-account core; the
33
+ * BridgeApp now manages a list of these, one per paired NoPeek account.
34
+ * Device-key stores are per bot USER id, so fleets never collide.
35
+ */
36
+ class PairingRuntime {
37
+ pairing;
38
+ app;
28
39
  cfg;
29
- startedAt = Date.now();
30
40
  bots = new Map(); // botUserId -> runner
31
41
  control = null;
32
- capabilitiesTimer = null;
33
42
  stopped = false;
34
- constructor(cfg) {
43
+ // A handle is provisioned at most once per process; the persisted BRAIN_MAP
44
+ // entry prevents re-provisioning across restarts.
45
+ provisioning = new Set();
46
+ tag;
47
+ constructor(app, cfg, pairing) {
48
+ this.app = app;
35
49
  this.cfg = cfg;
50
+ this.pairing = pairing;
51
+ this.tag = `[bridge:${pairing.label ?? pairing.appId}]`;
36
52
  }
37
- get paired() {
38
- return Boolean(this.cfg.pairingCode && this.cfg.appId);
39
- }
40
- /** Known once the control socket has authenticated at least once. */
53
+ /** Persisted (from pair time) or learned from the control socket's auth.ok. */
41
54
  get runtimeId() {
42
- return this.control?.runtimeId ?? null;
55
+ return this.control?.runtimeId ?? this.pairing.runtimeId ?? null;
43
56
  }
44
57
  get controlConnected() {
45
58
  return this.control?.connected ?? false;
46
59
  }
47
60
  start() {
48
- if (this.paired)
49
- this.startCore();
50
- else
61
+ if (this.stopped || this.control)
62
+ return;
63
+ this.control = new ControlSocket(this.pairing, {
64
+ onAuthed: (runtimeId) => {
65
+ // The server-issued runtime id is authoritative — persist it so the
66
+ // local API can keep authorizing this pairing across restarts.
67
+ if (runtimeId && this.pairing.runtimeId !== runtimeId) {
68
+ this.pairing.runtimeId = runtimeId;
69
+ saveSettings(this.cfg);
70
+ }
71
+ // Initial connect AND every reconnect: catch up on bots created while
72
+ // we were away (adopt_bot frames we may have missed).
73
+ void this.syncBots().catch((err) => {
74
+ console.error(`${this.tag} bot sync failed: ${err.message}`);
75
+ });
76
+ // Report which brains this computer can run so the phone's picker is
77
+ // current (initial connect + every reconnect). Non-fatal on failure.
78
+ void reportCapabilities(this.cfg, this.pairing);
79
+ },
80
+ onAdoptBot: (f) => {
81
+ if (f.backend)
82
+ this.app.applyServerBackend(f.handle, f.backend);
83
+ this.startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId, backend: f.backend });
84
+ },
85
+ onGrantChanged: (f) => {
86
+ // Access changed for a bot — re-pull its allow list so enforcement is
87
+ // live (a revoked user stops being answered within seconds).
88
+ const runner = this.bots.get(f.botUserId);
89
+ if (runner)
90
+ void runner.refreshAccess();
91
+ },
92
+ onBotConfig: (f) => {
93
+ // The owner changed this bot's brain from the phone. Record the server
94
+ // backend and re-resolve live — the runner resolves per message off the
95
+ // shared cfg, so the NEXT message already uses it; we also refresh the
96
+ // cached kind so status reflects it immediately. A running bot is keyed
97
+ // by userId; map it to a handle via its runner.
98
+ const runner = this.bots.get(f.botUserId);
99
+ if (!runner) {
100
+ console.log(`${this.tag} bot_config_changed for ${f.botUserId} not running yet — applied on next sync/adopt`);
101
+ return;
102
+ }
103
+ this.app.applyServerBackend(runner.info.handle, f.backend);
104
+ runner.refreshBrainKind();
105
+ },
106
+ });
107
+ // Bots first (so a control-socket hiccup doesn't delay serving), then the
108
+ // control socket, whose auth.ok triggers a redundant-but-safe re-sync.
109
+ void this.syncBots().catch((err) => {
110
+ console.error(`${this.tag} initial bot sync failed (will retry on control auth): ${err.message}`);
111
+ });
112
+ this.control.start();
113
+ }
114
+ stop() {
115
+ this.stopped = true;
116
+ this.control?.stop();
117
+ this.control = null;
118
+ for (const b of this.bots.values())
119
+ b.stop();
120
+ this.bots.clear();
121
+ }
122
+ status() {
123
+ return {
124
+ runtimeId: this.runtimeId,
125
+ appId: this.pairing.appId,
126
+ label: this.pairing.label ?? null,
127
+ connected: this.controlConnected,
128
+ bots: this.botStatuses(),
129
+ };
130
+ }
131
+ botStatuses() {
132
+ return [...this.bots.values()].map((b) => ({
133
+ handle: b.info.handle,
134
+ userId: b.info.userId,
135
+ connected: b.connected,
136
+ handled: b.handled,
137
+ brain: resolveBrain(this.cfg, b.info.handle).kind,
138
+ }));
139
+ }
140
+ // ---------------------------------------------------------------- core ----
141
+ startBot(info) {
142
+ if (this.bots.has(info.userId))
143
+ return; // already running
144
+ const runner = new BotRunner(info, this.cfg, this.pairing);
145
+ this.bots.set(info.userId, runner);
146
+ runner.start(); // background; failures are isolated inside the runner
147
+ void this.provisionBrain(info); // background; bot echoes until it lands
148
+ }
149
+ /**
150
+ * Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
151
+ * (e.g. "create a Hermes profile with its own soul + memory for this handle")
152
+ * and store its stdout as the bot's brain command. Best-effort — on any
153
+ * failure the bot simply keeps the default brain.
154
+ */
155
+ async provisionBrain(info) {
156
+ const cmd = this.cfg.brainProvisionCmd;
157
+ const handle = info.handle.replace(/^@/, "");
158
+ // A server-mediated backend (picked on the phone) makes auto-provisioning
159
+ // moot — provisioning would only produce a default that must lose anyway.
160
+ if (!cmd || this.cfg.brainMap[handle] || this.cfg.serverBackends[handle] || this.provisioning.has(handle))
161
+ return;
162
+ this.provisioning.add(handle);
163
+ console.log(`[provision:@${handle}] running brain provisioner`);
164
+ const out = await new Promise((resolvePromise) => {
165
+ const child = spawn("bash", ["-c", cmd], {
166
+ stdio: ["ignore", "pipe", "pipe"],
167
+ env: { ...process.env, NOPEEK_BOT_HANDLE: handle, NOPEEK_BOT_USER_ID: info.userId },
168
+ });
169
+ let stdout = "";
170
+ let stderr = "";
171
+ const timer = setTimeout(() => {
172
+ child.kill("SIGKILL");
173
+ resolvePromise(null);
174
+ }, 120_000);
175
+ child.stdout.on("data", (d) => (stdout += d.toString()));
176
+ child.stderr.on("data", (d) => (stderr += d.toString()));
177
+ child.on("error", () => {
178
+ clearTimeout(timer);
179
+ resolvePromise(null);
180
+ });
181
+ child.on("close", (code) => {
182
+ clearTimeout(timer);
183
+ if (code !== 0) {
184
+ console.error(`[provision:@${handle}] exit ${code}. stderr: ${stderr.slice(0, 1000)}`);
185
+ resolvePromise(null);
186
+ return;
187
+ }
188
+ resolvePromise(stdout.trim());
189
+ });
190
+ });
191
+ if (!out) {
192
+ console.error(`[provision:@${handle}] provisioner produced no brain command — bot keeps the default brain`);
193
+ return;
194
+ }
195
+ // Use the LAST non-empty stdout line: provisioners may log progress above it.
196
+ const brainCmd = out.split("\n").map((l) => l.trim()).filter(Boolean).pop();
197
+ this.app.setBrains({ map: { [handle]: { cmd: brainCmd, auto: true } } });
198
+ console.log(`[provision:@${handle}] brain provisioned`);
199
+ }
200
+ /** Fetch this pairing's authoritative bot list and start anything missing. */
201
+ async syncBots() {
202
+ const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/runtime/bots`, {
203
+ headers: { authorization: `Bearer ${this.pairing.pairingCode}` },
204
+ });
205
+ if (!res.ok) {
206
+ const body = await res.text().catch(() => "");
207
+ throw new Error(`GET /runtime/bots HTTP ${res.status}: ${body.slice(0, 300)}`);
208
+ }
209
+ const { bots: list } = (await res.json());
210
+ console.log(`${this.tag} runtime owns ${list.length} bot(s): ${list.map((b) => `@${b.handle}`).join(", ") || "(none yet)"}`);
211
+ for (const info of list) {
212
+ // Record any server-mediated backend BEFORE starting the runner so its
213
+ // first brain resolution already sees it (local overrides still win).
214
+ const serverBackend = info.backend ?? info.brainBackend;
215
+ if (serverBackend)
216
+ this.app.applyServerBackend(info.handle, serverBackend);
217
+ this.startBot(info);
218
+ }
219
+ }
220
+ }
221
+ export class BridgeApp {
222
+ cfg;
223
+ startedAt = Date.now();
224
+ runtimes = [];
225
+ capabilitiesTimer = null;
226
+ stopped = false;
227
+ constructor(cfg) {
228
+ this.cfg = cfg;
229
+ }
230
+ get paired() {
231
+ return this.cfg.pairings.length > 0;
232
+ }
233
+ /** Every runtime id this bridge answers for (persisted or live-auth'd).
234
+ * The local API accepts ANY of these as the x-nopeek-runtime capability. */
235
+ runtimeIds() {
236
+ const ids = [];
237
+ for (const r of this.runtimes) {
238
+ const id = r.runtimeId;
239
+ if (id && !ids.includes(id))
240
+ ids.push(id);
241
+ }
242
+ return ids;
243
+ }
244
+ start() {
245
+ if (!this.paired) {
51
246
  console.log(`[bridge] not paired yet — open the NoPeek app: Bots -> Connect this computer`);
247
+ return;
248
+ }
249
+ for (const pairing of this.cfg.pairings)
250
+ this.startRuntime(pairing);
52
251
  }
53
252
  stop() {
54
253
  this.stopped = true;
55
- this.stopCore();
254
+ if (this.capabilitiesTimer) {
255
+ clearInterval(this.capabilitiesTimer);
256
+ this.capabilitiesTimer = null;
257
+ }
258
+ for (const r of this.runtimes)
259
+ r.stop();
260
+ this.runtimes = [];
261
+ }
262
+ startRuntime(pairing) {
263
+ if (this.stopped)
264
+ return;
265
+ if (this.runtimes.some((r) => r.pairing.pairingCode === pairing.pairingCode))
266
+ return;
267
+ const rt = new PairingRuntime(this, this.cfg, pairing);
268
+ this.runtimes.push(rt);
269
+ rt.start();
270
+ this.ensureCapabilitiesTimer();
271
+ }
272
+ /** Re-probe + report brain availability every ~5 min (once per pairing) so a
273
+ * login/logout on this computer surfaces in each account's picker without a
274
+ * reconnect. onAuthed covers initial + reconnect reports; this covers drift.
275
+ * unref so a running interval never keeps the process alive on its own. */
276
+ ensureCapabilitiesTimer() {
277
+ if (this.capabilitiesTimer)
278
+ return;
279
+ this.capabilitiesTimer = setInterval(() => {
280
+ for (const r of this.runtimes)
281
+ void reportCapabilities(this.cfg, r.pairing);
282
+ }, CAPABILITIES_INTERVAL_MS);
283
+ this.capabilitiesTimer.unref?.();
56
284
  }
57
285
  // ------------------------------------------------------------- pairing ----
58
286
  /**
59
- * Pair this bridge from the NoPeek app. Only accepted while UNPAIRED (an
60
- * attacker on a webpage can't silently re-point an already-working bridge).
61
- * The secret is validated against the API before anything is persisted.
287
+ * Pair ANOTHER NoPeek account onto this bridge (or the first one same
288
+ * flow). Every request is validated against the API before anything is
289
+ * persisted: the npr_ secret is server-minted and unguessable, so possession
290
+ * of a VALID one is the proof. Only an exact duplicate (same secret, i.e.
291
+ * literally the same pairing) is rejected.
62
292
  */
63
293
  async pair(req) {
64
- if (this.paired) {
65
- throw new PairError("ALREADY_PAIRED", "This bridge is already paired. Unpair it first (from the NoPeek app) or revoke the runtime.");
66
- }
67
294
  const pairingSecret = typeof req.pairingSecret === "string" ? req.pairingSecret.trim() : "";
68
295
  const appId = typeof req.appId === "string" ? req.appId.trim() : "";
69
296
  if (!pairingSecret || !appId) {
70
297
  throw new PairError("BAD_REQUEST", "pairingSecret and appId are required.");
71
298
  }
299
+ if (this.cfg.pairings.some((p) => p.pairingCode === pairingSecret)) {
300
+ throw new PairError("ALREADY_PAIRED", "This exact pairing is already active on this bridge.");
301
+ }
72
302
  const apiUrl = (typeof req.apiUrl === "string" && req.apiUrl.trim() ? req.apiUrl.trim() : this.cfg.apiUrl).replace(/\/+$/, "");
73
303
  // Validate before persisting: the bot list doubles as an auth probe.
74
304
  let res;
@@ -84,23 +314,54 @@ export class BridgeApp {
84
314
  if (!res.ok) {
85
315
  throw new PairError("PAIR_REJECTED", `the API rejected this pairing secret (HTTP ${res.status}).`);
86
316
  }
87
- this.cfg.apiUrl = apiUrl;
88
- this.cfg.appId = appId;
89
- this.cfg.pairingCode = pairingSecret;
317
+ const label = typeof req.label === "string" && req.label.trim() ? req.label.trim().slice(0, 120) : undefined;
318
+ const runtimeId = typeof req.runtimeId === "string" && req.runtimeId.trim() ? req.runtimeId.trim() : undefined;
319
+ const pairing = {
320
+ appId,
321
+ pairingCode: pairingSecret,
322
+ apiUrl,
323
+ ...(label ? { label } : {}),
324
+ ...(runtimeId ? { runtimeId } : {}),
325
+ };
326
+ this.cfg.pairings.push(pairing);
90
327
  saveSettings(this.cfg);
91
- console.log(`[bridge] paired with app ${appId} — starting bots`);
92
- this.startCore();
328
+ console.log(`[bridge] paired with app ${appId}${label ? ` (${label})` : ""} ${this.cfg.pairings.length} account(s) now — starting bots`);
329
+ this.startRuntime(pairing);
330
+ return pairing;
93
331
  }
94
- /** Undo pairing: stop everything, forget the credential (device keys stay). */
95
- unpair() {
96
- console.log(`[bridge] unpairing stopping bots and forgetting the pairing secret`);
97
- this.stopCore();
98
- this.cfg.pairingCode = null;
99
- this.cfg.appId = null;
332
+ /**
333
+ * Undo pairing (device keys stay either way).
334
+ * unpair() legacy no-arg: remove EVERY pairing (pre-0.6 shape).
335
+ * unpair(runtimeId) — remove just that account's pairing; others keep running.
336
+ * Returns false when a runtimeId was given but matches no pairing.
337
+ */
338
+ unpair(runtimeId) {
339
+ if (!runtimeId) {
340
+ console.log(`[bridge] unpairing ALL ${this.cfg.pairings.length} account(s) — stopping bots and forgetting the pairing secrets`);
341
+ for (const r of this.runtimes)
342
+ r.stop();
343
+ this.runtimes = [];
344
+ this.cfg.pairings.length = 0;
345
+ saveSettings(this.cfg);
346
+ return true;
347
+ }
348
+ const rt = this.runtimes.find((r) => r.runtimeId === runtimeId);
349
+ const idx = this.cfg.pairings.findIndex((p) => p.runtimeId === runtimeId || (rt ? p.pairingCode === rt.pairing.pairingCode : false));
350
+ if (idx < 0 && !rt)
351
+ return false;
352
+ console.log(`[bridge] unpairing runtime ${runtimeId} — its bots stop; other accounts keep running`);
353
+ if (rt) {
354
+ rt.stop();
355
+ this.runtimes = this.runtimes.filter((r) => r !== rt);
356
+ }
357
+ if (idx >= 0)
358
+ this.cfg.pairings.splice(idx, 1);
100
359
  saveSettings(this.cfg);
360
+ return true;
101
361
  }
102
362
  // -------------------------------------------------------------- brains ----
103
- /** Apply a brain change live (next message uses it) and persist it. */
363
+ /** Apply a brain change live (next message uses it) and persist it.
364
+ * Brains are GLOBAL per machine — handles are globally unique. */
104
365
  setBrains(patch) {
105
366
  if (patch.brainCmd !== undefined)
106
367
  this.cfg.brainCmd = patch.brainCmd || null;
@@ -151,6 +412,7 @@ export class BridgeApp {
151
412
  * either way but is inert while a local override exists. When it IS the
152
413
  * effective brain (no local override), provision the soul/profile up front so
153
414
  * the very first message doesn't wait on it. Persisted for reconnects.
415
+ * (Internal — called by each PairingRuntime; the map is machine-global.)
154
416
  */
155
417
  applyServerBackend(rawHandle, backend) {
156
418
  const handle = rawHandle.replace(/^@/, "");
@@ -196,20 +458,29 @@ export class BridgeApp {
196
458
  // -------------------------------------------------------------- status ----
197
459
  statusMinimal() {
198
460
  return {
199
- ok: this.paired ? this.controlConnected : true,
461
+ ok: this.paired ? this.runtimes.length > 0 && this.runtimes.every((r) => r.controlConnected) : true,
200
462
  service: "nopeek-agent-bridge",
201
463
  version: VERSION,
202
464
  paired: this.paired,
465
+ // Count only — this endpoint is unauthenticated, and runtime ids are the
466
+ // local API's auth capability, so they must never appear here.
467
+ pairings: this.cfg.pairings.length,
203
468
  machine: hostname(),
204
469
  uptime: Math.round((Date.now() - this.startedAt) / 1000),
205
470
  };
206
471
  }
207
- statusFull() {
472
+ /** Full status (authenticated). `callerRuntimeId` — when known — keeps the
473
+ * legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
474
+ * pairing so pre-0.6 clients keep working unchanged. */
475
+ statusFull(callerRuntimeId) {
476
+ const own = (callerRuntimeId && this.runtimes.find((r) => r.runtimeId === callerRuntimeId)) || this.runtimes[0] || null;
208
477
  return {
209
478
  ...this.statusMinimal(),
210
- runtime: this.runtimeId,
211
- apiUrl: this.cfg.apiUrl,
212
- appId: this.cfg.appId,
479
+ runtime: own?.runtimeId ?? null,
480
+ apiUrl: own?.pairing.apiUrl ?? this.cfg.apiUrl,
481
+ appId: own?.pairing.appId ?? null,
482
+ // All paired accounts, each with its own runtime id + bot fleet.
483
+ pairings: this.runtimes.map((r) => r.status()),
213
484
  brains: {
214
485
  default: this.cfg.brainCmd
215
486
  ? { cmd: this.cfg.brainCmd }
@@ -220,163 +491,8 @@ export class BridgeApp {
220
491
  serverBackends: this.cfg.serverBackends,
221
492
  provisionCmd: this.cfg.brainProvisionCmd,
222
493
  },
223
- bots: [...this.bots.values()].map((b) => ({
224
- handle: b.info.handle,
225
- userId: b.info.userId,
226
- connected: b.connected,
227
- handled: b.handled,
228
- brain: resolveBrain(this.cfg, b.info.handle).kind,
229
- })),
494
+ // Legacy flat list = every pairing's bots (pre-0.6 clients read this).
495
+ bots: this.runtimes.flatMap((r) => r.botStatuses()),
230
496
  };
231
497
  }
232
- // ---------------------------------------------------------------- core ----
233
- startBot(info) {
234
- if (this.bots.has(info.userId))
235
- return; // already running
236
- const runner = new BotRunner(info, this.cfg);
237
- this.bots.set(info.userId, runner);
238
- runner.start(); // background; failures are isolated inside the runner
239
- void this.provisionBrain(info); // background; bot echoes until it lands
240
- }
241
- // A handle is provisioned at most once per process; the persisted BRAIN_MAP
242
- // entry prevents re-provisioning across restarts.
243
- provisioning = new Set();
244
- /**
245
- * Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
246
- * (e.g. "create a Hermes profile with its own soul + memory for this handle")
247
- * and store its stdout as the bot's brain command. Best-effort — on any
248
- * failure the bot simply keeps the default brain.
249
- */
250
- async provisionBrain(info) {
251
- const cmd = this.cfg.brainProvisionCmd;
252
- const handle = info.handle.replace(/^@/, "");
253
- // A server-mediated backend (picked on the phone) makes auto-provisioning
254
- // moot — provisioning would only produce a default that must lose anyway.
255
- if (!cmd || this.cfg.brainMap[handle] || this.cfg.serverBackends[handle] || this.provisioning.has(handle))
256
- return;
257
- this.provisioning.add(handle);
258
- console.log(`[provision:@${handle}] running brain provisioner`);
259
- const out = await new Promise((resolvePromise) => {
260
- const child = spawn("bash", ["-c", cmd], {
261
- stdio: ["ignore", "pipe", "pipe"],
262
- env: { ...process.env, NOPEEK_BOT_HANDLE: handle, NOPEEK_BOT_USER_ID: info.userId },
263
- });
264
- let stdout = "";
265
- let stderr = "";
266
- const timer = setTimeout(() => {
267
- child.kill("SIGKILL");
268
- resolvePromise(null);
269
- }, 120_000);
270
- child.stdout.on("data", (d) => (stdout += d.toString()));
271
- child.stderr.on("data", (d) => (stderr += d.toString()));
272
- child.on("error", () => {
273
- clearTimeout(timer);
274
- resolvePromise(null);
275
- });
276
- child.on("close", (code) => {
277
- clearTimeout(timer);
278
- if (code !== 0) {
279
- console.error(`[provision:@${handle}] exit ${code}. stderr: ${stderr.slice(0, 1000)}`);
280
- resolvePromise(null);
281
- return;
282
- }
283
- resolvePromise(stdout.trim());
284
- });
285
- });
286
- if (!out) {
287
- console.error(`[provision:@${handle}] provisioner produced no brain command — bot keeps the default brain`);
288
- return;
289
- }
290
- // Use the LAST non-empty stdout line: provisioners may log progress above it.
291
- const brainCmd = out.split("\n").map((l) => l.trim()).filter(Boolean).pop();
292
- this.setBrains({ map: { [handle]: { cmd: brainCmd, auto: true } } });
293
- console.log(`[provision:@${handle}] brain provisioned`);
294
- }
295
- /** Fetch the authoritative bot list and start anything we're missing. */
296
- async syncBots() {
297
- const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/runtime/bots`, {
298
- headers: { authorization: `Bearer ${this.cfg.pairingCode}` },
299
- });
300
- if (!res.ok) {
301
- const body = await res.text().catch(() => "");
302
- throw new Error(`GET /runtime/bots HTTP ${res.status}: ${body.slice(0, 300)}`);
303
- }
304
- const { bots: list } = (await res.json());
305
- console.log(`[bridge] runtime owns ${list.length} bot(s): ${list.map((b) => `@${b.handle}`).join(", ") || "(none yet)"}`);
306
- for (const info of list) {
307
- // Record any server-mediated backend BEFORE starting the runner so its
308
- // first brain resolution already sees it (local overrides still win).
309
- const serverBackend = info.backend ?? info.brainBackend;
310
- if (serverBackend)
311
- this.applyServerBackend(info.handle, serverBackend);
312
- this.startBot(info);
313
- }
314
- }
315
- startCore() {
316
- if (this.stopped || this.control)
317
- return;
318
- this.control = new ControlSocket(this.cfg, {
319
- onAuthed: () => {
320
- // Initial connect AND every reconnect: catch up on bots created while
321
- // we were away (adopt_bot frames we may have missed).
322
- void this.syncBots().catch((err) => {
323
- console.error(`[bridge] bot sync failed: ${err.message}`);
324
- });
325
- // Report which brains this computer can run so the phone's picker is
326
- // current (initial connect + every reconnect). Non-fatal on failure.
327
- void reportCapabilities(this.cfg);
328
- },
329
- onAdoptBot: (f) => {
330
- if (f.backend)
331
- this.applyServerBackend(f.handle, f.backend);
332
- this.startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId, backend: f.backend });
333
- },
334
- onGrantChanged: (f) => {
335
- // Access changed for a bot — re-pull its allow list so enforcement is
336
- // live (a revoked user stops being answered within seconds).
337
- const runner = this.bots.get(f.botUserId);
338
- if (runner)
339
- void runner.refreshAccess();
340
- },
341
- onBotConfig: (f) => {
342
- // The owner changed this bot's brain from the phone. Record the server
343
- // backend and re-resolve live — the runner resolves per message off the
344
- // shared cfg, so the NEXT message already uses it; we also refresh the
345
- // cached kind so status reflects it immediately. A running bot is keyed
346
- // by userId; map it to a handle via its runner.
347
- const runner = this.bots.get(f.botUserId);
348
- if (!runner) {
349
- console.log(`[bridge] bot_config_changed for ${f.botUserId} not running yet — applied on next sync/adopt`);
350
- return;
351
- }
352
- this.applyServerBackend(runner.info.handle, f.backend);
353
- runner.refreshBrainKind();
354
- },
355
- });
356
- // Bots first (so a control-socket hiccup doesn't delay serving), then the
357
- // control socket, whose auth.ok triggers a redundant-but-safe re-sync.
358
- void this.syncBots().catch((err) => {
359
- console.error(`[bridge] initial bot sync failed (will retry on control auth): ${err.message}`);
360
- });
361
- this.control.start();
362
- // Re-probe + report brain availability every ~5 min so a login/logout on
363
- // this computer surfaces in the phone's picker without a reconnect. The
364
- // onAuthed handler covers initial + reconnect reports; this covers drift.
365
- // unref so a running interval never keeps the process alive on its own.
366
- this.capabilitiesTimer = setInterval(() => {
367
- void reportCapabilities(this.cfg);
368
- }, CAPABILITIES_INTERVAL_MS);
369
- this.capabilitiesTimer.unref?.();
370
- }
371
- stopCore() {
372
- if (this.capabilitiesTimer) {
373
- clearInterval(this.capabilitiesTimer);
374
- this.capabilitiesTimer = null;
375
- }
376
- this.control?.stop();
377
- this.control = null;
378
- for (const b of this.bots.values())
379
- b.stop();
380
- this.bots.clear();
381
- }
382
498
  }
@@ -1,4 +1,4 @@
1
- import type { BridgeConfig } from "./config.js";
1
+ import type { BridgeConfig, Pairing } from "./config.js";
2
2
  /** One backend's readiness. `reason` is a short human string when unavailable. */
3
3
  export interface BackendCapability {
4
4
  available: boolean;
@@ -16,8 +16,10 @@ export interface Capabilities {
16
16
  export declare function probeCapabilities(_cfg: BridgeConfig): Promise<Capabilities>;
17
17
  /**
18
18
  * Probe, then PUT the result to the server so the phone's picker reflects it.
19
- * Authed with the runtime pairing token — the SAME Bearer auth the bridge uses
20
- * for GET /runtime/bots (see bridge.ts syncBots). Non-fatal: a failed report is
21
- * logged and retried on the next tick (control reconnect or the 5-min interval).
19
+ * Authed with ONE pairing's runtime token — the SAME Bearer auth the bridge
20
+ * uses for GET /runtime/bots; a multi-account bridge reports once per pairing
21
+ * (the probe result is machine-wide, but each account's server must hear it).
22
+ * Non-fatal: a failed report is logged and retried on the next tick (control
23
+ * reconnect or the 5-min interval).
22
24
  */
23
- export declare function reportCapabilities(cfg: BridgeConfig): Promise<void>;
25
+ export declare function reportCapabilities(cfg: BridgeConfig, pairing: Pairing): Promise<void>;
@@ -117,13 +117,13 @@ function summarize(c) {
117
117
  }
118
118
  /**
119
119
  * Probe, then PUT the result to the server so the phone's picker reflects it.
120
- * Authed with the runtime pairing token — the SAME Bearer auth the bridge uses
121
- * for GET /runtime/bots (see bridge.ts syncBots). Non-fatal: a failed report is
122
- * logged and retried on the next tick (control reconnect or the 5-min interval).
120
+ * Authed with ONE pairing's runtime token — the SAME Bearer auth the bridge
121
+ * uses for GET /runtime/bots; a multi-account bridge reports once per pairing
122
+ * (the probe result is machine-wide, but each account's server must hear it).
123
+ * Non-fatal: a failed report is logged and retried on the next tick (control
124
+ * reconnect or the 5-min interval).
123
125
  */
124
- export async function reportCapabilities(cfg) {
125
- if (!cfg.pairingCode)
126
- return; // unpaired — nothing to report to
126
+ export async function reportCapabilities(cfg, pairing) {
127
127
  let caps;
128
128
  try {
129
129
  caps = await probeCapabilities(cfg);
@@ -132,12 +132,12 @@ export async function reportCapabilities(cfg) {
132
132
  console.error(`[caps] probe failed: ${err.message}`);
133
133
  return;
134
134
  }
135
- console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)}`);
135
+ console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)} (reporting as ${pairing.label ?? pairing.appId})`);
136
136
  try {
137
- const res = await fetch(`${cfg.apiUrl}/v1/runtime/capabilities`, {
137
+ const res = await fetch(`${pairing.apiUrl}/v1/runtime/capabilities`, {
138
138
  method: "PUT",
139
139
  headers: {
140
- authorization: `Bearer ${cfg.pairingCode}`,
140
+ authorization: `Bearer ${pairing.pairingCode}`,
141
141
  "content-type": "application/json",
142
142
  },
143
143
  body: JSON.stringify({ backends: { claude: caps.claude, hermes: caps.hermes } }),