@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 CHANGED
@@ -4,8 +4,8 @@ Run your own AI agents as **end-to-end-encrypted NoPeek bots** — from your Mac
4
4
 
5
5
  The bridge is a small always-on process that:
6
6
 
7
- 1. **Pairs** with your NoPeek account — one tap in the NoPeek app ("Connect this computer"), or a one-time pairing code (`npr_…`) if you prefer the terminal.
8
- 2. **Runs every bot you own** — each bot connects as a real NoPeek user with its own server device, publishes MLS key packages, and decrypts messages locally like any other client. The server never sees plaintext.
7
+ 1. **Pairs** with your NoPeek account — one tap in the NoPeek app ("Connect this computer"), or a one-time pairing code (`npr_…`) if you prefer the terminal. **Multi-account**: one installed bridge holds any number of pairings — each NoPeek account on this computer taps "Add this account" and its bots run alongside everyone else's.
8
+ 2. **Runs every bot each paired account owns** — each bot connects as a real NoPeek user with its own server device, publishes MLS key packages, and decrypts messages locally like any other client. The server never sees plaintext.
9
9
  3. **Pipes each incoming message to your "brain"** — any shell command (message on stdin, reply on stdout) or any HTTP webhook — and sends the reply back into the encrypted channel.
10
10
 
11
11
  When you create a new bot in the NoPeek app, the bridge adopts it live over its control connection. No restart, no redeploy.
@@ -45,14 +45,14 @@ The bridge serves a loopback-only HTTP API on `127.0.0.1:8790` — this is what
45
45
 
46
46
  | Endpoint | Auth | Purpose |
47
47
  | --- | --- | --- |
48
- | `GET /` | none | Minimal status: `{ok, service, version, paired, machine, uptime}` |
49
- | `POST /pair` | the `npr_…` secret itself; only accepted while **unpaired** | `{pairingSecret, appId, apiUrl?}` — validate, persist, start bots |
50
- | `GET /status` | `x-nopeek-runtime: rt_…` | Full status: bots, brains, runtime id |
48
+ | `GET /` | none | Minimal status: `{ok, service, version, paired, pairings, machine, uptime}` (`pairings` = account count only — never ids) |
49
+ | `POST /pair` | the `npr_…` secret itself (validated against the API) | `{pairingSecret, appId, apiUrl?, label?, runtimeId?}` — ADDS a pairing; only an exact duplicate (same secret) is rejected (`409 ALREADY_PAIRED`) |
50
+ | `GET /status` | `x-nopeek-runtime: rt_…` | Full status: `pairings: [{runtimeId, appId, label, connected, bots}]` per account, plus brains and a flattened `bots` list |
51
51
  | `GET /detect` | `x-nopeek-runtime` | Agent runtimes found on PATH (hermes, claude, llm, ollama) with suggested commands |
52
52
  | `PUT /brains` | `x-nopeek-runtime` | `{brainCmd?, brainUrl?, map?: {"<handle>": {cmd|url|echo}|null}}` — applies live, persists |
53
- | `DELETE /pair` | `x-nopeek-runtime` | Unpair: stop bots, forget the secret (device keys kept) |
53
+ | `DELETE /pair?runtimeId=rt_…` | `x-nopeek-runtime` | Remove ONE account's pairing (its bots stop; others keep running). Without the query: remove ALL pairings (the pre-0.6 behavior) |
54
54
 
55
- The `x-nopeek-runtime` header is the runtime id — a capability only the owner's logged-in app can fetch from the NoPeek server, so a random webpage poking `127.0.0.1` can't read your bot list or change brain commands. Everything the app configures persists to `~/.nopeek-bridge/settings.json` (mode 600).
55
+ The `x-nopeek-runtime` header is a runtime id — a capability only an owner's logged-in app can fetch from the NoPeek server, so a random webpage poking `127.0.0.1` can't read your bot list or change brain commands. ANY paired account's runtime id is accepted. Everything the app configures persists to `~/.nopeek-bridge/settings.json` (mode 600); pre-0.6 single-pairing settings are migrated to the multi-account format automatically on first start.
56
56
 
57
57
  ## How the brain works
58
58
 
@@ -171,6 +171,8 @@ Example config file:
171
171
  }
172
172
  ```
173
173
 
174
+ (The single `NOPEEK_PAIRING_CODE`/`NOPEEK_APP_ID` pair is still read everywhere for convenience; the bridge's own `settings.json` stores pairings as a `NOPEEK_PAIRINGS` array — one entry per paired account — and migrates the legacy keys automatically.)
175
+
174
176
  ## Health endpoint
175
177
 
176
178
  `GET http://localhost:8790/` →
package/dist/bot.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BridgeConfig, BrainBackend } from "./config.js";
1
+ import type { BridgeConfig, Pairing, BrainBackend } from "./config.js";
2
2
  export interface BotInfo {
3
3
  userId: string;
4
4
  handle: string;
@@ -16,6 +16,7 @@ export declare class BotRunner {
16
16
  handled: number;
17
17
  brainKind: string;
18
18
  private cfg;
19
+ private pairing;
19
20
  private np;
20
21
  private stopped;
21
22
  private refreshTimer;
@@ -28,7 +29,7 @@ export declare class BotRunner {
28
29
  private cantPost;
29
30
  private log;
30
31
  private logErr;
31
- constructor(info: BotInfo, cfg: BridgeConfig);
32
+ constructor(info: BotInfo, cfg: BridgeConfig, pairing: Pairing);
32
33
  /** Re-read the effective brain (e.g. after a live server backend change) so
33
34
  * status reflects it immediately. The next message re-resolves regardless. */
34
35
  refreshBrainKind(): void;
package/dist/bot.js CHANGED
@@ -16,6 +16,7 @@ export class BotRunner {
16
16
  handled = 0;
17
17
  brainKind;
18
18
  cfg;
19
+ pairing;
19
20
  np = null;
20
21
  stopped = false;
21
22
  refreshTimer = null;
@@ -37,9 +38,10 @@ export class BotRunner {
37
38
  cantPost = new Set();
38
39
  log;
39
40
  logErr;
40
- constructor(info, cfg) {
41
+ constructor(info, cfg, pairing) {
41
42
  this.info = info;
42
- this.cfg = cfg;
43
+ this.cfg = cfg; // global brain/data config (shared, mutated live by setBrains)
44
+ this.pairing = pairing; // this bot's account: apiUrl + appId + runtime secret
43
45
  this.brainKind = resolveBrain(cfg, info.handle).kind;
44
46
  const tag = `[bot:@${info.handle}]`;
45
47
  this.log = (m) => console.log(`${tag} ${m}`);
@@ -71,10 +73,10 @@ export class BotRunner {
71
73
  this.connected = false;
72
74
  }
73
75
  async mintSession() {
74
- const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/bots/${this.info.userId}/runtime-session`, {
76
+ const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/bots/${this.info.userId}/runtime-session`, {
75
77
  method: "POST",
76
78
  headers: {
77
- authorization: `Bearer ${this.cfg.pairingCode}`,
79
+ authorization: `Bearer ${this.pairing.pairingCode}`,
78
80
  "content-type": "application/json",
79
81
  },
80
82
  });
@@ -90,7 +92,7 @@ export class BotRunner {
90
92
  * rather than open. */
91
93
  async refreshAccess() {
92
94
  try {
93
- const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/bots/${this.info.userId}/access`, { headers: { authorization: `Bearer ${this.cfg.pairingCode}` } });
95
+ const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/bots/${this.info.userId}/access`, { headers: { authorization: `Bearer ${this.pairing.pairingCode}` } });
94
96
  if (!res.ok)
95
97
  throw new Error(`access HTTP ${res.status}`);
96
98
  const j = (await res.json());
@@ -133,9 +135,9 @@ export class BotRunner {
133
135
  // what a bot wants — peers claim those packages to send it welcomes. The
134
136
  // FileStore makes restarts reuse the same device instead of minting new ones.
135
137
  const np = await NoPeek.connect({
136
- apiUrl: this.cfg.apiUrl,
138
+ apiUrl: this.pairing.apiUrl,
137
139
  sessionToken: session.sessionToken,
138
- appId: this.cfg.appId ?? "", // runners only exist while paired
140
+ appId: this.pairing.appId,
139
141
  userId: this.info.userId,
140
142
  platform: "server",
141
143
  storage: store,
package/dist/bridge.d.ts CHANGED
@@ -1,9 +1,15 @@
1
- import type { BridgeConfig, BrainSpec } from "./config.js";
2
- export declare const VERSION = "0.5.6";
1
+ import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
+ export declare const VERSION = "0.6.0";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
6
6
  apiUrl?: string;
7
+ /** Human label for this pairing (shown in status; e.g. the account name). */
8
+ label?: string;
9
+ /** The runtime id the app just minted alongside the secret. Stored so the
10
+ * local API can authorize this pairing's calls before the control socket
11
+ * has authenticated (auth.ok remains authoritative and overwrites it). */
12
+ runtimeId?: string;
7
13
  }
8
14
  export interface BrainsPatch {
9
15
  /** Global command brain; null clears it. */
@@ -20,29 +26,50 @@ export declare class PairError extends Error {
20
26
  readonly code: "ALREADY_PAIRED" | "PAIR_REJECTED" | "PAIR_UNREACHABLE" | "BAD_REQUEST";
21
27
  constructor(code: "ALREADY_PAIRED" | "PAIR_REJECTED" | "PAIR_UNREACHABLE" | "BAD_REQUEST", message: string);
22
28
  }
29
+ /** One paired account's status as reported over the local API. */
30
+ export interface PairingStatus {
31
+ runtimeId: string | null;
32
+ appId: string;
33
+ label: string | null;
34
+ connected: boolean;
35
+ bots: Array<Record<string, unknown>>;
36
+ }
23
37
  export declare class BridgeApp {
24
38
  readonly cfg: BridgeConfig;
25
39
  readonly startedAt: number;
26
- private bots;
27
- private control;
40
+ private runtimes;
28
41
  private capabilitiesTimer;
29
42
  private stopped;
30
43
  constructor(cfg: BridgeConfig);
31
44
  get paired(): boolean;
32
- /** Known once the control socket has authenticated at least once. */
33
- get runtimeId(): string | null;
34
- get controlConnected(): boolean;
45
+ /** Every runtime id this bridge answers for (persisted or live-auth'd).
46
+ * The local API accepts ANY of these as the x-nopeek-runtime capability. */
47
+ runtimeIds(): string[];
35
48
  start(): void;
36
49
  stop(): void;
50
+ private startRuntime;
51
+ /** Re-probe + report brain availability every ~5 min (once per pairing) so a
52
+ * login/logout on this computer surfaces in each account's picker without a
53
+ * reconnect. onAuthed covers initial + reconnect reports; this covers drift.
54
+ * unref so a running interval never keeps the process alive on its own. */
55
+ private ensureCapabilitiesTimer;
56
+ /**
57
+ * Pair ANOTHER NoPeek account onto this bridge (or the first one — same
58
+ * flow). Every request is validated against the API before anything is
59
+ * persisted: the npr_ secret is server-minted and unguessable, so possession
60
+ * of a VALID one is the proof. Only an exact duplicate (same secret, i.e.
61
+ * literally the same pairing) is rejected.
62
+ */
63
+ pair(req: PairRequest): Promise<Pairing>;
37
64
  /**
38
- * Pair this bridge from the NoPeek app. Only accepted while UNPAIRED (an
39
- * attacker on a webpage can't silently re-point an already-working bridge).
40
- * The secret is validated against the API before anything is persisted.
65
+ * Undo pairing (device keys stay either way).
66
+ * unpair() — legacy no-arg: remove EVERY pairing (pre-0.6 shape).
67
+ * unpair(runtimeId) — remove just that account's pairing; others keep running.
68
+ * Returns false when a runtimeId was given but matches no pairing.
41
69
  */
42
- pair(req: PairRequest): Promise<void>;
43
- /** Undo pairing: stop everything, forget the credential (device keys stay). */
44
- unpair(): void;
45
- /** Apply a brain change live (next message uses it) and persist it. */
70
+ unpair(runtimeId?: string): boolean;
71
+ /** Apply a brain change live (next message uses it) and persist it.
72
+ * Brains are GLOBAL per machine — handles are globally unique. */
46
73
  setBrains(patch: BrainsPatch): void;
47
74
  /**
48
75
  * Record a SERVER-provided backend for a handle and make it take effect.
@@ -51,21 +78,12 @@ export declare class BridgeApp {
51
78
  * either way but is inert while a local override exists. When it IS the
52
79
  * effective brain (no local override), provision the soul/profile up front so
53
80
  * the very first message doesn't wait on it. Persisted for reconnects.
81
+ * (Internal — called by each PairingRuntime; the map is machine-global.)
54
82
  */
55
- private applyServerBackend;
83
+ applyServerBackend(rawHandle: string, backend: BrainBackend): void;
56
84
  statusMinimal(): Record<string, unknown>;
57
- statusFull(): Record<string, unknown>;
58
- private startBot;
59
- private provisioning;
60
- /**
61
- * Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
62
- * (e.g. "create a Hermes profile with its own soul + memory for this handle")
63
- * and store its stdout as the bot's brain command. Best-effort — on any
64
- * failure the bot simply keeps the default brain.
65
- */
66
- private provisionBrain;
67
- /** Fetch the authoritative bot list and start anything we're missing. */
68
- private syncBots;
69
- private startCore;
70
- private stopCore;
85
+ /** Full status (authenticated). `callerRuntimeId` — when known — keeps the
86
+ * legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
87
+ * pairing so pre-0.6 clients keep working unchanged. */
88
+ statusFull(callerRuntimeId?: string): Record<string, unknown>;
71
89
  }