@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/cli.js CHANGED
@@ -58,17 +58,18 @@ if (sub === "install") {
58
58
  process.exit(1);
59
59
  }
60
60
  // Optional: `install --pair npr_… --app-id app_…` pre-pairs the fresh service
61
- // through its local API (same path the app uses).
62
- if (cfg.pairingCode && cfg.appId) {
61
+ // through its local API (same path the app uses). Multi-account: every
62
+ // pairing known to THIS invocation is offered; the bridge skips duplicates.
63
+ for (const pairing of cfg.pairings) {
63
64
  try {
64
65
  const res = await fetch(`http://127.0.0.1:${cfg.port}/pair`, {
65
66
  method: "POST",
66
67
  headers: { "content-type": "application/json" },
67
- body: JSON.stringify({ pairingSecret: cfg.pairingCode, appId: cfg.appId, apiUrl: cfg.apiUrl }),
68
+ body: JSON.stringify({ pairingSecret: pairing.pairingCode, appId: pairing.appId, apiUrl: pairing.apiUrl }),
68
69
  });
69
70
  const body = (await res.json().catch(() => ({})));
70
- if (res.ok)
71
- console.log(`[install] paired successfully`);
71
+ if (res.ok || body.error === "ALREADY_PAIRED")
72
+ console.log(`[install] paired with app ${pairing.appId}`);
72
73
  else
73
74
  console.error(`[install] pairing failed: ${body.message ?? `HTTP ${res.status}`}`);
74
75
  }
@@ -80,7 +81,7 @@ if (sub === "install") {
80
81
  }
81
82
  // ---------------------------------------------------------------- run -------
82
83
  console.log(`[bridge] NoPeek agent-bridge v${VERSION} starting`);
83
- console.log(`[bridge] api=${cfg.apiUrl} app=${cfg.appId ?? "(not paired)"} data=${cfg.dataDir} local-api=:${cfg.port} home=${cfg.homeDir}`);
84
+ console.log(`[bridge] api=${cfg.apiUrl} accounts=${cfg.pairings.length ? cfg.pairings.map((p) => p.label ?? p.appId).join(", ") : "(not paired)"} data=${cfg.dataDir} local-api=:${cfg.port} home=${cfg.homeDir}`);
84
85
  console.log(`[bridge] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (choose an agent in the NoPeek app, or set --brain-cmd)"}` +
85
86
  (Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
86
87
  const app = new BridgeApp(cfg);
package/dist/config.d.ts CHANGED
@@ -17,13 +17,32 @@ export interface BrainSpec {
17
17
  auto?: boolean;
18
18
  }
19
19
  export declare function isBrainBackend(v: unknown): v is BrainBackend;
20
+ /**
21
+ * One paired NoPeek account. A bridge holds MANY of these — one per account
22
+ * that connected this computer — and runs every pairing's bot fleet at once.
23
+ * `pairingCode` is the per-account runtime secret (npr_…), so it is what makes
24
+ * a pairing unique; `appId` is the white-label APP id and is shared by every
25
+ * account of the same app.
26
+ */
27
+ export interface Pairing {
28
+ appId: string;
29
+ /** Runtime pairing token (npr_…) — unique per pairing. */
30
+ pairingCode: string;
31
+ /** API base this pairing talks to (accounts on different white-label servers
32
+ * can coexist on one bridge). */
33
+ apiUrl: string;
34
+ /** Human label from the app ("Nabil's MacBook", account name, …). */
35
+ label?: string;
36
+ /** Runtime id (rt_…) — supplied by the app at pair time and/or learned from
37
+ * the control socket's auth.ok. Used to authorize local-API calls and to
38
+ * target DELETE /pair?runtimeId=…. */
39
+ runtimeId?: string;
40
+ }
20
41
  export interface BridgeConfig {
21
- /** NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
42
+ /** Default NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
22
43
  apiUrl: string;
23
- /** Null until paired (from the app or via --pair/--app-id). */
24
- appId: string | null;
25
- /** Runtime pairing token (npr_…). Null until paired. */
26
- pairingCode: string | null;
44
+ /** All paired accounts. Empty until the app pairs (or --pair/--app-id). */
45
+ pairings: Pairing[];
27
46
  /** Global brain: shell command reading the message on stdin, printing the reply. */
28
47
  brainCmd: string | null;
29
48
  /** Global brain: webhook POSTed {text, botHandle, botUserId, channelId, senderUserId}. */
package/dist/config.js CHANGED
@@ -162,6 +162,44 @@ function parseServerBackends(raw) {
162
162
  }
163
163
  return out;
164
164
  }
165
+ /**
166
+ * Parse the persisted pairings array (NOPEEK_PAIRINGS). Lenient like
167
+ * parseServerBackends: a corrupt value never crashes the bridge — bad entries
168
+ * are dropped, duplicates (same pairingCode) collapse to the first one.
169
+ */
170
+ function parsePairings(raw, fallbackApiUrl) {
171
+ if (!raw)
172
+ return [];
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(raw);
176
+ }
177
+ catch {
178
+ return [];
179
+ }
180
+ if (!Array.isArray(parsed))
181
+ return [];
182
+ const out = [];
183
+ for (const entry of parsed) {
184
+ if (typeof entry !== "object" || entry === null)
185
+ continue;
186
+ const { appId, pairingCode, apiUrl, label, runtimeId } = entry;
187
+ if (typeof appId !== "string" || !appId.trim())
188
+ continue;
189
+ if (typeof pairingCode !== "string" || !pairingCode.trim())
190
+ continue;
191
+ if (out.some((p) => p.pairingCode === pairingCode))
192
+ continue;
193
+ out.push({
194
+ appId: appId.trim(),
195
+ pairingCode: pairingCode.trim(),
196
+ apiUrl: (typeof apiUrl === "string" && apiUrl.trim() ? apiUrl.trim() : fallbackApiUrl).replace(/\/+$/, ""),
197
+ ...(typeof label === "string" && label.trim() ? { label: label.trim() } : {}),
198
+ ...(typeof runtimeId === "string" && runtimeId.trim() ? { runtimeId: runtimeId.trim() } : {}),
199
+ });
200
+ }
201
+ return out;
202
+ }
165
203
  function settingsPath(homeDir) {
166
204
  return join(homeDir, "settings.json");
167
205
  }
@@ -189,6 +227,12 @@ export function loadConfig(argv = process.argv.slice(2)) {
189
227
  saved[key];
190
228
  return v === undefined || v === "" ? undefined : v;
191
229
  };
230
+ const apiUrl = (get("api-url") ?? DEFAULT_API_URL).replace(/\/+$/, "");
231
+ // New format first: an explicit pairings array (env/config/persisted).
232
+ const pairings = parsePairings(process.env.NOPEEK_PAIRINGS ?? file.NOPEEK_PAIRINGS ?? saved.NOPEEK_PAIRINGS, apiUrl);
233
+ // LEGACY single pairing (pre-0.6): --pair/--app-id flags, env vars, or the
234
+ // old NOPEEK_PAIRING_CODE/NOPEEK_APP_ID keys in settings.json. Still read,
235
+ // migrated into the array; saveSettings writes ONLY the new format.
192
236
  const pairingCode = get("pair") ?? null;
193
237
  if (pairingCode && !pairingCode.startsWith("npr_")) {
194
238
  console.warn(`[config] pairing code does not start with "npr_" — double-check you pasted the runtime pairing code`);
@@ -197,6 +241,20 @@ export function loadConfig(argv = process.argv.slice(2)) {
197
241
  if (pairingCode && !appId) {
198
242
  throw new Error(`--pair was given without --app-id (or NOPEEK_APP_ID) — both are needed to connect.`);
199
243
  }
244
+ if (pairingCode && appId && !pairings.some((p) => p.pairingCode === pairingCode)) {
245
+ pairings.push({ appId, pairingCode, apiUrl });
246
+ }
247
+ // The flag/env value wins in get(); if the SAVED file also holds a (different)
248
+ // legacy pairing, merge it too so migration never drops an active account.
249
+ const savedLegacyCode = saved.NOPEEK_PAIRING_CODE;
250
+ const savedLegacyApp = saved.NOPEEK_APP_ID;
251
+ if (savedLegacyCode && savedLegacyApp && !pairings.some((p) => p.pairingCode === savedLegacyCode)) {
252
+ pairings.push({
253
+ appId: savedLegacyApp,
254
+ pairingCode: savedLegacyCode,
255
+ apiUrl: (saved.NOPEEK_API_URL ?? apiUrl).replace(/\/+$/, ""),
256
+ });
257
+ }
200
258
  const brainTimeoutMs = Number(get("brain-timeout-ms") ?? DEFAULT_BRAIN_TIMEOUT_MS);
201
259
  if (!Number.isFinite(brainTimeoutMs) || brainTimeoutMs <= 0) {
202
260
  throw new Error(`BRAIN_TIMEOUT_MS must be a positive number of milliseconds`);
@@ -213,10 +271,9 @@ export function loadConfig(argv = process.argv.slice(2)) {
213
271
  : existsSync(resolve(process.cwd(), "data"))
214
272
  ? resolve(process.cwd(), "data")
215
273
  : join(homeDir, "data");
216
- return {
217
- apiUrl: (get("api-url") ?? DEFAULT_API_URL).replace(/\/+$/, ""),
218
- appId,
219
- pairingCode,
274
+ const cfg = {
275
+ apiUrl,
276
+ pairings,
220
277
  brainCmd: get("brain-cmd") ?? null,
221
278
  brainUrl: get("brain-url") ?? null,
222
279
  brainMap: parseBrainMap(get("brain-map")),
@@ -230,6 +287,19 @@ export function loadConfig(argv = process.argv.slice(2)) {
230
287
  declineMessage: get("decline-message") ?? null,
231
288
  appUrl: get("app-url") ?? DEFAULT_APP_URL,
232
289
  };
290
+ // One-time migration: if settings.json still holds the legacy single-pairing
291
+ // keys, rewrite it in the new NOPEEK_PAIRINGS format right away (idempotent,
292
+ // atomic). Flag/env-only pairings stay ephemeral, exactly as before 0.6.
293
+ if (savedLegacyCode && savedLegacyApp) {
294
+ try {
295
+ saveSettings(cfg);
296
+ console.log(`[config] migrated legacy single pairing to the multi-account format (${settingsPath(homeDir)})`);
297
+ }
298
+ catch (err) {
299
+ console.warn(`[config] could not rewrite settings in the new format: ${err.message}`);
300
+ }
301
+ }
302
+ return cfg;
233
303
  }
234
304
  /**
235
305
  * Persist the app-manageable parts of the config (pairing + brains) to
@@ -240,8 +310,9 @@ export function saveSettings(cfg) {
240
310
  mkdirSync(cfg.homeDir, { recursive: true });
241
311
  const out = {
242
312
  NOPEEK_API_URL: cfg.apiUrl,
243
- ...(cfg.appId ? { NOPEEK_APP_ID: cfg.appId } : {}),
244
- ...(cfg.pairingCode ? { NOPEEK_PAIRING_CODE: cfg.pairingCode } : {}),
313
+ // New multi-account format only the legacy NOPEEK_APP_ID /
314
+ // NOPEEK_PAIRING_CODE keys are read on load but never written back.
315
+ ...(cfg.pairings.length ? { NOPEEK_PAIRINGS: JSON.stringify(cfg.pairings) } : {}),
245
316
  ...(cfg.brainCmd ? { BRAIN_CMD: cfg.brainCmd } : {}),
246
317
  ...(cfg.brainUrl ? { BRAIN_URL: cfg.brainUrl } : {}),
247
318
  ...(Object.keys(cfg.brainMap).length ? { BRAIN_MAP: JSON.stringify(cfg.brainMap) } : {}),
package/dist/control.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BridgeConfig, BrainBackend } from "./config.js";
1
+ import type { Pairing, BrainBackend } from "./config.js";
2
2
  export interface AdoptBotFrame {
3
3
  type: "adopt_bot";
4
4
  botUserId: string;
@@ -31,12 +31,14 @@ export interface ControlHandlers {
31
31
  export declare class ControlSocket {
32
32
  connected: boolean;
33
33
  runtimeId: string | null;
34
- private cfg;
34
+ private pairing;
35
35
  private handlers;
36
36
  private ws;
37
37
  private stopped;
38
38
  private delay;
39
- constructor(cfg: BridgeConfig, handlers: ControlHandlers);
39
+ /** Log tag — with several sockets running, lines must say whose they are. */
40
+ private tag;
41
+ constructor(pairing: Pairing, handlers: ControlHandlers);
40
42
  start(): void;
41
43
  stop(): void;
42
44
  private connect;
package/dist/control.js CHANGED
@@ -2,14 +2,17 @@ import { isBrainBackend } from "./config.js";
2
2
  export class ControlSocket {
3
3
  connected = false;
4
4
  runtimeId = null;
5
- cfg;
5
+ pairing;
6
6
  handlers;
7
7
  ws = null;
8
8
  stopped = false;
9
9
  delay = 2_000;
10
- constructor(cfg, handlers) {
11
- this.cfg = cfg;
10
+ /** Log tag — with several sockets running, lines must say whose they are. */
11
+ tag;
12
+ constructor(pairing, handlers) {
13
+ this.pairing = pairing;
12
14
  this.handlers = handlers;
15
+ this.tag = `[control:${pairing.label ?? pairing.runtimeId ?? pairing.appId}]`;
13
16
  }
14
17
  start() {
15
18
  this.connect();
@@ -28,19 +31,19 @@ export class ControlSocket {
28
31
  connect() {
29
32
  if (this.stopped)
30
33
  return;
31
- const url = `${this.cfg.apiUrl.replace(/^http/, "ws")}/v1/ws?runtimeToken=${encodeURIComponent(this.cfg.pairingCode ?? "")}`;
34
+ const url = `${this.pairing.apiUrl.replace(/^http/, "ws")}/v1/ws?runtimeToken=${encodeURIComponent(this.pairing.pairingCode)}`;
32
35
  let ws;
33
36
  try {
34
37
  ws = new WebSocket(url);
35
38
  }
36
39
  catch (err) {
37
- console.error(`[control] could not open socket: ${err.message}`);
40
+ console.error(`${this.tag} could not open socket: ${err.message}`);
38
41
  this.scheduleReconnect();
39
42
  return;
40
43
  }
41
44
  this.ws = ws;
42
45
  ws.onopen = () => {
43
- console.log(`[control] socket open, awaiting auth.ok`);
46
+ console.log(`${this.tag} socket open, awaiting auth.ok`);
44
47
  };
45
48
  ws.onmessage = (ev) => {
46
49
  let frame;
@@ -48,7 +51,7 @@ export class ControlSocket {
48
51
  frame = JSON.parse(String(ev.data));
49
52
  }
50
53
  catch {
51
- console.error(`[control] non-JSON frame ignored: ${String(ev.data).slice(0, 200)}`);
54
+ console.error(`${this.tag} non-JSON frame ignored: ${String(ev.data).slice(0, 200)}`);
52
55
  return;
53
56
  }
54
57
  this.handleFrame(frame);
@@ -61,7 +64,7 @@ export class ControlSocket {
61
64
  this.connected = false;
62
65
  if (this.stopped)
63
66
  return;
64
- console.error(`[control] socket closed (code=${ev.code}${ev.reason ? `, reason=${ev.reason}` : ""})${was ? "" : " before auth.ok — is the pairing code valid?"}`);
67
+ console.error(`${this.tag} socket closed (code=${ev.code}${ev.reason ? `, reason=${ev.reason}` : ""})${was ? "" : " before auth.ok — is the pairing code valid?"}`);
65
68
  this.scheduleReconnect();
66
69
  };
67
70
  }
@@ -71,23 +74,23 @@ export class ControlSocket {
71
74
  this.connected = true;
72
75
  this.delay = 2_000; // reset backoff on a good auth
73
76
  this.runtimeId = String(frame.runtimeId ?? "");
74
- console.log(`[control] authenticated as runtime ${this.runtimeId} (control=${String(frame.control)})`);
77
+ console.log(`${this.tag} authenticated as runtime ${this.runtimeId} (control=${String(frame.control)})`);
75
78
  this.handlers.onAuthed(this.runtimeId);
76
79
  return;
77
80
  }
78
81
  case "adopt_bot": {
79
82
  const f = frame;
80
- console.log(`[control] adopt_bot @${f.handle} (${f.botUserId}) owner=${f.ownerUserId}`);
83
+ console.log(`${this.tag} adopt_bot @${f.handle} (${f.botUserId}) owner=${f.ownerUserId}`);
81
84
  this.handlers.onAdoptBot(f);
82
85
  return;
83
86
  }
84
87
  case "bot_config_changed": {
85
88
  const f = frame;
86
89
  if (!isBrainBackend(f.backend)) {
87
- console.error(`[control] bot_config_changed with invalid backend "${String(f.backend)}" ignored`);
90
+ console.error(`${this.tag} bot_config_changed with invalid backend "${String(f.backend)}" ignored`);
88
91
  return;
89
92
  }
90
- console.log(`[control] bot_config_changed bot=${f.botUserId} backend=${f.backend}`);
93
+ console.log(`${this.tag} bot_config_changed bot=${f.botUserId} backend=${f.backend}`);
91
94
  this.handlers.onBotConfig(f);
92
95
  return;
93
96
  }
@@ -95,7 +98,7 @@ export class ControlSocket {
95
98
  const f = frame;
96
99
  // TODO(grants): enforce these — for now bots answer everyone in their
97
100
  // channels; we only log so operators can see grants flowing.
98
- console.log(`[control] bot_grant_changed bot=${f.botUserId} channel=${f.channelId} grantee=${f.granteeUserId} action=${f.action} (logged only; enforcement TODO)`);
101
+ console.log(`${this.tag} bot_grant_changed bot=${f.botUserId} channel=${f.channelId} grantee=${f.granteeUserId} action=${f.action} (logged only; enforcement TODO)`);
99
102
  this.handlers.onGrantChanged(f);
100
103
  return;
101
104
  }
@@ -110,13 +113,13 @@ export class ControlSocket {
110
113
  }
111
114
  default:
112
115
  // Forward-compatible: unknown frames are logged, never fatal.
113
- console.log(`[control] unhandled frame type "${String(frame.type)}"`);
116
+ console.log(`${this.tag} unhandled frame type "${String(frame.type)}"`);
114
117
  }
115
118
  }
116
119
  scheduleReconnect() {
117
120
  if (this.stopped)
118
121
  return;
119
- console.log(`[control] reconnecting in ${this.delay / 1000}s`);
122
+ console.log(`${this.tag} reconnecting in ${this.delay / 1000}s`);
120
123
  const t = setTimeout(() => this.connect(), this.delay);
121
124
  t.unref?.();
122
125
  this.delay = Math.min(this.delay * 2, 30_000);
package/dist/localapi.js CHANGED
@@ -3,13 +3,20 @@
3
3
  // computer calls it directly from the browser.
4
4
  //
5
5
  // Auth model:
6
- // GET / minimal, unauthenticated status (is a bridge here? paired?)
7
- // POST /pair unauthenticated BUT only accepted while unpaired, and the
8
- // npr_ secret itself is the (server-minted, unguessable) proof.
9
- // everything else requires header `x-nopeek-runtime: rt_…` the runtime id,
10
- // which only the OWNER's logged-in app can fetch from the NoPeek server
11
- // (GET /bot-runtimes). A random webpage can't know it, so drive-by requests
12
- // to 127.0.0.1 can't read bot lists or change brain commands.
6
+ // GET / minimal, unauthenticated status (is a bridge here? paired?
7
+ // how many accounts? never runtime ids, they're the capability)
8
+ // POST /pair unauthenticated; the npr_ secret itself is the (server-minted,
9
+ // unguessable) proof and is validated against the API before
10
+ // anything persists. A bridge holds MULTIPLE pairings one per
11
+ // NoPeek account so pairing while already paired ADDS one;
12
+ // only an exact duplicate (same secret) is rejected.
13
+ // everything else requires header `x-nopeek-runtime: rt_…` — a runtime id,
14
+ // which only an OWNER's logged-in app can fetch from the NoPeek server
15
+ // (GET /bot-runtimes). ANY of the paired accounts' runtime ids is accepted.
16
+ // A random webpage can't know one, so drive-by requests to 127.0.0.1 can't
17
+ // read bot lists or change brain commands.
18
+ // DELETE /pair?runtimeId=rt_… removes ONE pairing; no query = remove ALL
19
+ // (the legacy pre-0.6 shape keeps working).
13
20
  //
14
21
  // CORS reflects the caller origin (the app may be served from any white-label
15
22
  // domain) and answers Chrome's Private Network Access preflight.
@@ -70,15 +77,19 @@ function safeEq(a, b) {
70
77
  const hb = createHash("sha256").update(b).digest();
71
78
  return timingSafeEqual(ha, hb);
72
79
  }
80
+ /** ANY paired account's runtime id is a valid capability. */
73
81
  function authorized(app, req) {
74
82
  const header = req.headers["x-nopeek-runtime"];
75
83
  const given = Array.isArray(header) ? header[0] : header;
76
84
  if (!given)
77
- return "denied";
78
- const rt = app.runtimeId;
79
- if (!rt)
80
- return "no-runtime-yet"; // paired but control socket hasn't auth'd yet
81
- return safeEq(given, rt) ? "ok" : "denied";
85
+ return { kind: "denied" };
86
+ const ids = app.runtimeIds();
87
+ if (!ids.length)
88
+ return { kind: "no-runtime-yet" }; // paired but no control socket has auth'd yet
89
+ for (const id of ids)
90
+ if (safeEq(given, id))
91
+ return { kind: "ok", runtimeId: id };
92
+ return { kind: "denied" };
82
93
  }
83
94
  export function startLocalApi(app) {
84
95
  const server = createServer((req, res) => {
@@ -101,7 +112,9 @@ export function startLocalApi(app) {
101
112
  async function handle(app, req, res) {
102
113
  setCors(req, res);
103
114
  const method = req.method ?? "GET";
104
- const path = (req.url ?? "/").split("?")[0].replace(/\/+$/, "") || "/";
115
+ const rawUrl = req.url ?? "/";
116
+ const path = rawUrl.split("?")[0].replace(/\/+$/, "") || "/";
117
+ const query = new URL(rawUrl, "http://127.0.0.1").searchParams;
105
118
  if (method === "OPTIONS") {
106
119
  res.statusCode = 204;
107
120
  res.end();
@@ -113,7 +126,8 @@ async function handle(app, req, res) {
113
126
  json(res, 200, app.statusMinimal());
114
127
  return;
115
128
  }
116
- // Unauthenticated but self-proving (npr_ secret) and unpaired-only.
129
+ // Unauthenticated but self-proving (npr_ secret, validated against the API).
130
+ // Works whether this is the FIRST pairing or an additional account.
117
131
  if (method === "POST" && path === "/pair") {
118
132
  let body;
119
133
  try {
@@ -124,8 +138,8 @@ async function handle(app, req, res) {
124
138
  return;
125
139
  }
126
140
  try {
127
- await app.pair(body);
128
- json(res, 200, { ok: true, paired: true });
141
+ const pairing = await app.pair(body);
142
+ json(res, 200, { ok: true, paired: true, runtimeId: pairing.runtimeId ?? null, pairings: app.cfg.pairings.length });
129
143
  }
130
144
  catch (err) {
131
145
  if (err instanceof PairError) {
@@ -138,10 +152,10 @@ async function handle(app, req, res) {
138
152
  }
139
153
  return;
140
154
  }
141
- // Everything below requires the runtime id capability.
155
+ // Everything below requires a runtime id capability (any paired account's).
142
156
  const auth = authorized(app, req);
143
- if (auth !== "ok") {
144
- if (auth === "no-runtime-yet") {
157
+ if (auth.kind !== "ok") {
158
+ if (auth.kind === "no-runtime-yet") {
145
159
  json(res, 503, { error: "NOT_READY", message: "bridge is still connecting — retry in a moment" });
146
160
  }
147
161
  else {
@@ -150,7 +164,7 @@ async function handle(app, req, res) {
150
164
  return;
151
165
  }
152
166
  if (method === "GET" && path === "/status") {
153
- json(res, 200, app.statusFull());
167
+ json(res, 200, app.statusFull(auth.runtimeId));
154
168
  return;
155
169
  }
156
170
  if (method === "GET" && path === "/detect") {
@@ -167,12 +181,23 @@ async function handle(app, req, res) {
167
181
  return;
168
182
  }
169
183
  app.setBrains(body);
170
- json(res, 200, { ok: true, status: app.statusFull() });
184
+ json(res, 200, { ok: true, status: app.statusFull(auth.runtimeId) });
171
185
  return;
172
186
  }
187
+ // ?runtimeId=rt_… removes ONE account's pairing; no query = remove ALL
188
+ // (pre-0.6 clients send no query and expect a fully unpaired bridge).
173
189
  if (method === "DELETE" && path === "/pair") {
174
- app.unpair();
175
- json(res, 200, { ok: true, paired: false });
190
+ const runtimeId = query.get("runtimeId");
191
+ if (runtimeId) {
192
+ if (!app.unpair(runtimeId)) {
193
+ json(res, 404, { error: "NOT_FOUND", message: `no pairing for runtime ${runtimeId}` });
194
+ return;
195
+ }
196
+ }
197
+ else {
198
+ app.unpair();
199
+ }
200
+ json(res, 200, { ok: true, paired: app.paired, pairings: app.cfg.pairings.length });
176
201
  return;
177
202
  }
178
203
  json(res, 404, { error: "NOT_FOUND", message: `no ${method} ${path}` });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.5.6",
4
- "description": "Run your own agents as E2EE NoPeek bots. Pairs with a one-time code, runs every bot you own, and pipes messages to any command or webhook.",
3
+ "version": "0.6.0",
4
+ "description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {