@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/README.md +9 -7
- package/dist/bot.d.ts +3 -2
- package/dist/bot.js +9 -7
- package/dist/bridge.d.ts +47 -29
- package/dist/bridge.js +314 -198
- package/dist/capabilities.d.ts +7 -5
- package/dist/capabilities.js +9 -9
- package/dist/cli.js +7 -6
- package/dist/config.d.ts +24 -5
- package/dist/config.js +77 -6
- package/dist/control.d.ts +5 -3
- package/dist/control.js +18 -15
- package/dist/localapi.js +48 -23
- package/package.json +2 -2
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 —
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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:
|
|
211
|
-
apiUrl: this.cfg.apiUrl,
|
|
212
|
-
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
|
|
224
|
-
|
|
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
|
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -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
|
|
20
|
-
* for GET /runtime/bots
|
|
21
|
-
*
|
|
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>;
|
package/dist/capabilities.js
CHANGED
|
@@ -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
|
|
121
|
-
* for GET /runtime/bots
|
|
122
|
-
*
|
|
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(`${
|
|
137
|
+
const res = await fetch(`${pairing.apiUrl}/v1/runtime/capabilities`, {
|
|
138
138
|
method: "PUT",
|
|
139
139
|
headers: {
|
|
140
|
-
authorization: `Bearer ${
|
|
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 } }),
|