@nopeek/agent-bridge 0.1.0 → 0.2.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,13 +4,27 @@ 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 using a one-time pairing code (`npr_…`) from the NoPeek app ("Connect your computer").
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
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.
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.
12
12
 
13
- ## Quick start
13
+ ## Quick start — one command, then the app
14
+
15
+ ```bash
16
+ npm install -g @nopeek/agent-bridge && nopeek-agent-bridge install
17
+ ```
18
+
19
+ That installs the bridge as a background service (launchd on macOS, `systemd --user` on Linux) that starts at login and waits, **unpaired**. Then, in the NoPeek app on the same computer:
20
+
21
+ 1. **Contacts → My Bots → Connect this computer** — one tap. The app mints the pairing secret and hands it straight to the local bridge; you never see or paste it.
22
+ 2. Create bots; each one starts on your computer within seconds.
23
+ 3. Per bot: **⋯ → Choose brain…** — pick a detected agent (Hermes, Claude Code, `llm`, Ollama), a custom command, a webhook, or echo. Changes apply to the next message, no restart.
24
+
25
+ Every bot you own answers in every chat it's a member of. Add a bot to a group in the NoPeek app and it just starts working.
26
+
27
+ ## Quick start — terminal-only (no app steps)
14
28
 
15
29
  ```bash
16
30
  # Zero config: bots run in echo mode ("You said: …") — a smoke test
@@ -23,7 +37,22 @@ npx @nopeek/agent-bridge \
23
37
  --brain-cmd 'HERMES_HOME=/Volumes/x10drive/hermes hermes --profile nopeek chat -Q -q "$(cat)"'
24
38
  ```
25
39
 
26
- That's it. Every bot you own answers in every chat it's a member of. Add a bot to a group in the NoPeek app and it just starts working.
40
+ The `npr_…` code comes from Settings Connect your computer (bots) in the app. `install` also accepts `--pair`/`--app-id` to pre-pair the service from the terminal.
41
+
42
+ ## Local control API
43
+
44
+ The bridge serves a loopback-only HTTP API on `127.0.0.1:8790` — this is what the NoPeek app uses; you can script it too.
45
+
46
+ | Endpoint | Auth | Purpose |
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 |
51
+ | `GET /detect` | `x-nopeek-runtime` | Agent runtimes found on PATH (hermes, claude, llm, ollama) with suggested commands |
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) |
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).
27
56
 
28
57
  ## How the brain works
29
58
 
@@ -118,17 +147,20 @@ Everything can be set three ways, highest precedence first: **CLI flag → envir
118
147
 
119
148
  | Flag | Env var | Default | Meaning |
120
149
  | --- | --- | --- | --- |
121
- | `--pair` | `NOPEEK_PAIRING_CODE` | *(required)* | One-time pairing code from the NoPeek app (`npr_…`) |
122
- | `--app-id` | `NOPEEK_APP_ID` | *(required)* | Your NoPeek app id (shown with the pairing code) |
150
+ | `--pair` | `NOPEEK_PAIRING_CODE` | — | Pairing code (`npr_…`); optional pairing from the app is easier |
151
+ | `--app-id` | `NOPEEK_APP_ID` | | Your NoPeek app id (set automatically when pairing from the app) |
123
152
  | `--api-url` | `NOPEEK_API_URL` | `https://d3qweh72vesa98.cloudfront.net` | NoPeek API base |
124
153
  | `--brain-cmd` | `BRAIN_CMD` | — | Global command brain |
125
154
  | `--brain-url` | `BRAIN_URL` | — | Global webhook brain |
126
155
  | `--brain-map` | `BRAIN_MAP` | `{}` | Per-handle overrides (JSON) |
127
156
  | `--brain-timeout-ms` | `BRAIN_TIMEOUT_MS` | `180000` | Brain timeout (generous: agent runtimes think slowly) |
128
- | `--port` | `NOPEEK_BRIDGE_PORT` | `8790` | Health endpoint port |
129
- | `--data-dir` | `NOPEEK_BRIDGE_DATA_DIR` | `./data` | Per-bot device-key store directory |
157
+ | `--port` | `NOPEEK_BRIDGE_PORT` | `8790` | Local control API port (loopback only) |
158
+ | `--data-dir` | `NOPEEK_BRIDGE_DATA_DIR` | `<home>/data` (legacy `./data` honored) | Per-bot device-key store directory |
159
+ | `--home` | `NOPEEK_BRIDGE_HOME` | `~/.nopeek-bridge` | Settings, default data dir, service logs |
130
160
  | `--config` | — | `./nopeek-bridge.config.json` | Config file path |
131
161
 
162
+ Precedence: CLI flag → env var → `nopeek-bridge.config.json` → `<home>/settings.json` (written when you pair or set brains from the app).
163
+
132
164
  Example config file:
133
165
 
134
166
  ```json
@@ -174,7 +206,7 @@ Example config file:
174
206
 
175
207
  ## Running it long-term
176
208
 
177
- Anything that keeps a process alive works. For example, on Linux:
209
+ `nopeek-agent-bridge install` does this for you (launchd LaunchAgent on macOS, systemd user unit on Linux; `uninstall` removes it, `status` queries the running bridge). If you'd rather manage the process yourself, anything that keeps it alive works. For example, on Linux:
178
210
 
179
211
  ```ini
180
212
  # /etc/systemd/system/nopeek-bridge.service
package/dist/bot.d.ts CHANGED
@@ -12,12 +12,14 @@ export declare class BotRunner {
12
12
  handled: number;
13
13
  brainKind: string;
14
14
  private cfg;
15
- private brain;
16
15
  private np;
17
16
  private stopped;
18
17
  private refreshTimer;
19
18
  private seen;
20
19
  private channelCache;
20
+ private allowed;
21
+ private accessLoaded;
22
+ private declined;
21
23
  private log;
22
24
  private logErr;
23
25
  constructor(info: BotInfo, cfg: BridgeConfig);
@@ -25,6 +27,13 @@ export declare class BotRunner {
25
27
  start(): void;
26
28
  stop(): void;
27
29
  private mintSession;
30
+ /** Fetch (or refresh) the set of users allowed to talk to this bot. Called on
31
+ * connect and whenever a grant changes. Best-effort: on failure we keep the
32
+ * last-known set, and if we've never loaded one we FAIL CLOSED (owner-only)
33
+ * rather than open. */
34
+ refreshAccess(): Promise<void>;
35
+ /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
36
+ private isAllowed;
28
37
  private run;
29
38
  private connectOnce;
30
39
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
package/dist/bot.js CHANGED
@@ -16,20 +16,24 @@ export class BotRunner {
16
16
  handled = 0;
17
17
  brainKind;
18
18
  cfg;
19
- brain;
20
19
  np = null;
21
20
  stopped = false;
22
21
  refreshTimer = null;
23
22
  seen = new Set();
24
23
  channelCache = new Map();
24
+ // Access control: bots are PRIVATE by default. `allowed` is the set of user
25
+ // ids permitted to talk to this bot (owner + workspace members + granted
26
+ // users), fetched from the server. A sender NOT in this set never reaches the
27
+ // brain — so a stranger who finds the @handle can't drive the owner's agent.
28
+ allowed = new Set();
29
+ accessLoaded = false;
30
+ declined = new Set(); // (senderId) already told "not authorized" once
25
31
  log;
26
32
  logErr;
27
33
  constructor(info, cfg) {
28
34
  this.info = info;
29
35
  this.cfg = cfg;
30
- const resolved = resolveBrain(cfg, info.handle);
31
- this.brain = resolved.brain;
32
- this.brainKind = resolved.kind;
36
+ this.brainKind = resolveBrain(cfg, info.handle).kind;
33
37
  const tag = `[bot:@${info.handle}]`;
34
38
  this.log = (m) => console.log(`${tag} ${m}`);
35
39
  this.logErr = (m) => console.error(`${tag} ${m}`);
@@ -67,6 +71,32 @@ export class BotRunner {
67
71
  }
68
72
  return (await res.json());
69
73
  }
74
+ /** Fetch (or refresh) the set of users allowed to talk to this bot. Called on
75
+ * connect and whenever a grant changes. Best-effort: on failure we keep the
76
+ * last-known set, and if we've never loaded one we FAIL CLOSED (owner-only)
77
+ * rather than open. */
78
+ async refreshAccess() {
79
+ try {
80
+ const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/bots/${this.info.userId}/access`, { headers: { authorization: `Bearer ${this.cfg.pairingCode}` } });
81
+ if (!res.ok)
82
+ throw new Error(`access HTTP ${res.status}`);
83
+ const j = (await res.json());
84
+ this.allowed = new Set(j.allowedUserIds ?? []);
85
+ this.accessLoaded = true;
86
+ this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s)`);
87
+ }
88
+ catch (err) {
89
+ this.logErr(`access refresh failed: ${err.message} (keeping last-known allow list)`);
90
+ }
91
+ }
92
+ /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
93
+ isAllowed(senderUserId) {
94
+ if (senderUserId === this.info.ownerId)
95
+ return true; // owner always
96
+ if (!this.accessLoaded)
97
+ return false; // fail closed until we know the list
98
+ return this.allowed.has(senderUserId);
99
+ }
70
100
  async run() {
71
101
  this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
72
102
  let delay = 2_000;
@@ -92,7 +122,7 @@ export class BotRunner {
92
122
  const np = await NoPeek.connect({
93
123
  apiUrl: this.cfg.apiUrl,
94
124
  sessionToken: session.sessionToken,
95
- appId: this.cfg.appId,
125
+ appId: this.cfg.appId ?? "", // runners only exist while paired
96
126
  userId: this.info.userId,
97
127
  platform: "server",
98
128
  storage: store,
@@ -121,6 +151,7 @@ export class BotRunner {
121
151
  this.logErr(`handler error for ${m.messageId}: ${err.message}`);
122
152
  });
123
153
  }));
154
+ await this.refreshAccess(); // load the allow list before we answer anyone
124
155
  this.scheduleRefresh(session.expiresAt);
125
156
  }
126
157
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
@@ -193,9 +224,22 @@ export class BotRunner {
193
224
  }
194
225
  if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
195
226
  return;
196
- // TODO(grants): enforce per-channel grants once bot_grant_changed carries
197
- // enough to build an allowlist. v1 answers everyone in any channel the bot
198
- // is a member of; the control socket already logs grant changes.
227
+ // ACCESS CONTROL: a bot is private. If the sender isn't authorized (owner,
228
+ // workspace member, or explicitly granted), the brain is NEVER invoked so
229
+ // a stranger who found the @handle can't make the owner's agent do anything.
230
+ if (!this.isAllowed(m.senderUserId)) {
231
+ this.log(`${m.channelId} <- ${m.senderUserId}: BLOCKED (not authorized) — brain not invoked`);
232
+ if (this.cfg.declineMessage && !this.declined.has(m.senderUserId)) {
233
+ this.declined.add(m.senderUserId);
234
+ try {
235
+ await (await this.getChannel(m.channelId)).send({ text: this.cfg.declineMessage });
236
+ }
237
+ catch {
238
+ /* best-effort decline notice */
239
+ }
240
+ }
241
+ return;
242
+ }
199
243
  const text = m.body.text;
200
244
  this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
201
245
  const ch = await this.getChannel(m.channelId);
@@ -206,9 +250,13 @@ export class BotRunner {
206
250
  catch {
207
251
  /* typing is best-effort */
208
252
  }
253
+ // Resolve the brain PER MESSAGE: the NoPeek app can change it live over the
254
+ // local API (PUT /brains) and the very next message uses the new one.
255
+ const resolved = resolveBrain(this.cfg, this.info.handle);
256
+ this.brainKind = resolved.kind;
209
257
  let reply = "";
210
258
  try {
211
- reply = await this.brain(text, {
259
+ reply = await resolved.brain(text, {
212
260
  botHandle: this.info.handle,
213
261
  botUserId: this.info.userId,
214
262
  channelId: m.channelId,
package/dist/brain.d.ts CHANGED
@@ -15,6 +15,6 @@ export interface ResolvedBrain {
15
15
  }
16
16
  /**
17
17
  * Pick the brain for one bot:
18
- * BRAIN_MAP[handle] (cmd beats url within an entry) > BRAIN_CMD > BRAIN_URL > echo.
18
+ * BRAIN_MAP[handle] (cmd beats url beats echo within an entry) > BRAIN_CMD > BRAIN_URL > echo.
19
19
  */
20
20
  export declare function resolveBrain(cfg: BridgeConfig, handle: string): ResolvedBrain;
package/dist/brain.js CHANGED
@@ -117,7 +117,7 @@ function urlBrain(url, timeoutMs) {
117
117
  const echoBrain = async (text) => `You said: ${text}`;
118
118
  /**
119
119
  * Pick the brain for one bot:
120
- * BRAIN_MAP[handle] (cmd beats url within an entry) > BRAIN_CMD > BRAIN_URL > echo.
120
+ * BRAIN_MAP[handle] (cmd beats url beats echo within an entry) > BRAIN_CMD > BRAIN_URL > echo.
121
121
  */
122
122
  export function resolveBrain(cfg, handle) {
123
123
  const override = cfg.brainMap[handle.replace(/^@/, "")];
@@ -125,6 +125,8 @@ export function resolveBrain(cfg, handle) {
125
125
  return { brain: cmdBrain(override.cmd, cfg.brainTimeoutMs), kind: "cmd (per-bot)" };
126
126
  if (override?.url)
127
127
  return { brain: urlBrain(override.url, cfg.brainTimeoutMs), kind: "url (per-bot)" };
128
+ if (override?.echo)
129
+ return { brain: echoBrain, kind: "echo (per-bot)" };
128
130
  if (cfg.brainCmd)
129
131
  return { brain: cmdBrain(cfg.brainCmd, cfg.brainTimeoutMs), kind: "cmd" };
130
132
  if (cfg.brainUrl)
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,50 @@
1
- import type { BridgeConfig } from "./config.js";
2
- export interface Bridge {
1
+ import type { BridgeConfig, BrainSpec } from "./config.js";
2
+ export declare const VERSION = "0.2.0";
3
+ export interface PairRequest {
4
+ pairingSecret: string;
5
+ appId: string;
6
+ apiUrl?: string;
7
+ }
8
+ export interface BrainsPatch {
9
+ /** Global command brain; null clears it. */
10
+ brainCmd?: string | null;
11
+ /** Global webhook brain; null clears it. */
12
+ brainUrl?: string | null;
13
+ /** Per-handle entries; a null value removes that bot's override. */
14
+ map?: Record<string, BrainSpec | null>;
15
+ }
16
+ export declare class PairError extends Error {
17
+ readonly code: "ALREADY_PAIRED" | "PAIR_REJECTED" | "PAIR_UNREACHABLE" | "BAD_REQUEST";
18
+ constructor(code: "ALREADY_PAIRED" | "PAIR_REJECTED" | "PAIR_UNREACHABLE" | "BAD_REQUEST", message: string);
19
+ }
20
+ export declare class BridgeApp {
21
+ readonly cfg: BridgeConfig;
22
+ readonly startedAt: number;
23
+ private bots;
24
+ private control;
25
+ private stopped;
26
+ constructor(cfg: BridgeConfig);
27
+ get paired(): boolean;
28
+ /** Known once the control socket has authenticated at least once. */
29
+ get runtimeId(): string | null;
30
+ get controlConnected(): boolean;
31
+ start(): void;
3
32
  stop(): void;
33
+ /**
34
+ * Pair this bridge from the NoPeek app. Only accepted while UNPAIRED (an
35
+ * attacker on a webpage can't silently re-point an already-working bridge).
36
+ * The secret is validated against the API before anything is persisted.
37
+ */
38
+ pair(req: PairRequest): Promise<void>;
39
+ /** Undo pairing: stop everything, forget the credential (device keys stay). */
40
+ unpair(): void;
41
+ /** Apply a brain change live (next message uses it) and persist it. */
42
+ setBrains(patch: BrainsPatch): void;
43
+ statusMinimal(): Record<string, unknown>;
44
+ statusFull(): Record<string, unknown>;
45
+ private startBot;
46
+ /** Fetch the authoritative bot list and start anything we're missing. */
47
+ private syncBots;
48
+ private startCore;
49
+ private stopCore;
4
50
  }
5
- export declare function runBridge(cfg: BridgeConfig): Promise<Bridge>;
package/dist/bridge.js CHANGED
@@ -1,24 +1,173 @@
1
- // Orchestrator: pairs this machine as a NoPeek runtime, discovers every bot it
2
- // owns (GET /runtime/bots), runs each as an isolated BotRunner, and keeps the
3
- // fleet current via the control WebSocket (adopt_bot arrives live — no restart).
4
- import { createServer } from "node:http";
1
+ // Orchestrator. A bridge has two states:
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.
6
+ // Pairing, unpairing and brain config all happen at runtime (from the app) and
7
+ // persist to <home>/settings.json — no restart, no terminal.
8
+ import { hostname } from "node:os";
9
+ import { saveSettings } from "./config.js";
5
10
  import { BotRunner } from "./bot.js";
6
11
  import { ControlSocket } from "./control.js";
7
- export async function runBridge(cfg) {
8
- const startedAt = Date.now();
9
- const bots = new Map(); // botUserId -> runner
10
- // ------------------------------------------------------------- bot fleet --
11
- const startBot = (info) => {
12
- if (bots.has(info.userId))
12
+ import { resolveBrain } from "./brain.js";
13
+ export const VERSION = "0.2.0";
14
+ export class PairError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.code = code;
19
+ }
20
+ }
21
+ export class BridgeApp {
22
+ cfg;
23
+ startedAt = Date.now();
24
+ bots = new Map(); // botUserId -> runner
25
+ control = null;
26
+ stopped = false;
27
+ constructor(cfg) {
28
+ this.cfg = cfg;
29
+ }
30
+ get paired() {
31
+ return Boolean(this.cfg.pairingCode && this.cfg.appId);
32
+ }
33
+ /** Known once the control socket has authenticated at least once. */
34
+ get runtimeId() {
35
+ return this.control?.runtimeId ?? null;
36
+ }
37
+ get controlConnected() {
38
+ return this.control?.connected ?? false;
39
+ }
40
+ start() {
41
+ if (this.paired)
42
+ this.startCore();
43
+ else
44
+ console.log(`[bridge] not paired yet — open the NoPeek app: Bots -> Connect this computer`);
45
+ }
46
+ stop() {
47
+ this.stopped = true;
48
+ this.stopCore();
49
+ }
50
+ // ------------------------------------------------------------- pairing ----
51
+ /**
52
+ * Pair this bridge from the NoPeek app. Only accepted while UNPAIRED (an
53
+ * attacker on a webpage can't silently re-point an already-working bridge).
54
+ * The secret is validated against the API before anything is persisted.
55
+ */
56
+ async pair(req) {
57
+ if (this.paired) {
58
+ throw new PairError("ALREADY_PAIRED", "This bridge is already paired. Unpair it first (from the NoPeek app) or revoke the runtime.");
59
+ }
60
+ const pairingSecret = typeof req.pairingSecret === "string" ? req.pairingSecret.trim() : "";
61
+ const appId = typeof req.appId === "string" ? req.appId.trim() : "";
62
+ if (!pairingSecret || !appId) {
63
+ throw new PairError("BAD_REQUEST", "pairingSecret and appId are required.");
64
+ }
65
+ const apiUrl = (typeof req.apiUrl === "string" && req.apiUrl.trim() ? req.apiUrl.trim() : this.cfg.apiUrl).replace(/\/+$/, "");
66
+ // Validate before persisting: the bot list doubles as an auth probe.
67
+ let res;
68
+ try {
69
+ res = await fetch(`${apiUrl}/v1/apps/${appId}/runtime/bots`, {
70
+ headers: { authorization: `Bearer ${pairingSecret}` },
71
+ signal: AbortSignal.timeout(15_000),
72
+ });
73
+ }
74
+ catch (err) {
75
+ throw new PairError("PAIR_UNREACHABLE", `could not reach ${apiUrl}: ${err.message}`);
76
+ }
77
+ if (!res.ok) {
78
+ throw new PairError("PAIR_REJECTED", `the API rejected this pairing secret (HTTP ${res.status}).`);
79
+ }
80
+ this.cfg.apiUrl = apiUrl;
81
+ this.cfg.appId = appId;
82
+ this.cfg.pairingCode = pairingSecret;
83
+ saveSettings(this.cfg);
84
+ console.log(`[bridge] paired with app ${appId} — starting bots`);
85
+ this.startCore();
86
+ }
87
+ /** Undo pairing: stop everything, forget the credential (device keys stay). */
88
+ unpair() {
89
+ console.log(`[bridge] unpairing — stopping bots and forgetting the pairing secret`);
90
+ this.stopCore();
91
+ this.cfg.pairingCode = null;
92
+ this.cfg.appId = null;
93
+ saveSettings(this.cfg);
94
+ }
95
+ // -------------------------------------------------------------- brains ----
96
+ /** Apply a brain change live (next message uses it) and persist it. */
97
+ setBrains(patch) {
98
+ if (patch.brainCmd !== undefined)
99
+ this.cfg.brainCmd = patch.brainCmd || null;
100
+ if (patch.brainUrl !== undefined)
101
+ this.cfg.brainUrl = patch.brainUrl || null;
102
+ if (patch.map) {
103
+ for (const [rawHandle, spec] of Object.entries(patch.map)) {
104
+ const handle = rawHandle.replace(/^@/, "");
105
+ if (spec === null) {
106
+ delete this.cfg.brainMap[handle];
107
+ }
108
+ else if (typeof spec.cmd === "string" && spec.cmd.trim()) {
109
+ this.cfg.brainMap[handle] = { cmd: spec.cmd.trim() };
110
+ }
111
+ else if (typeof spec.url === "string" && spec.url.trim()) {
112
+ this.cfg.brainMap[handle] = { url: spec.url.trim() };
113
+ }
114
+ else if (spec.echo === true) {
115
+ this.cfg.brainMap[handle] = { echo: true };
116
+ }
117
+ else {
118
+ delete this.cfg.brainMap[handle];
119
+ }
120
+ }
121
+ }
122
+ saveSettings(this.cfg);
123
+ console.log(`[bridge] brains updated: default=${this.cfg.brainCmd ? "cmd" : this.cfg.brainUrl ? "url" : "echo"}, ${Object.keys(this.cfg.brainMap).length} per-bot override(s)`);
124
+ }
125
+ // -------------------------------------------------------------- status ----
126
+ statusMinimal() {
127
+ return {
128
+ ok: this.paired ? this.controlConnected : true,
129
+ service: "nopeek-agent-bridge",
130
+ version: VERSION,
131
+ paired: this.paired,
132
+ machine: hostname(),
133
+ uptime: Math.round((Date.now() - this.startedAt) / 1000),
134
+ };
135
+ }
136
+ statusFull() {
137
+ return {
138
+ ...this.statusMinimal(),
139
+ runtime: this.runtimeId,
140
+ apiUrl: this.cfg.apiUrl,
141
+ appId: this.cfg.appId,
142
+ brains: {
143
+ default: this.cfg.brainCmd
144
+ ? { cmd: this.cfg.brainCmd }
145
+ : this.cfg.brainUrl
146
+ ? { url: this.cfg.brainUrl }
147
+ : { echo: true },
148
+ map: this.cfg.brainMap,
149
+ },
150
+ bots: [...this.bots.values()].map((b) => ({
151
+ handle: b.info.handle,
152
+ userId: b.info.userId,
153
+ connected: b.connected,
154
+ handled: b.handled,
155
+ brain: resolveBrain(this.cfg, b.info.handle).kind,
156
+ })),
157
+ };
158
+ }
159
+ // ---------------------------------------------------------------- core ----
160
+ startBot(info) {
161
+ if (this.bots.has(info.userId))
13
162
  return; // already running
14
- const runner = new BotRunner(info, cfg);
15
- bots.set(info.userId, runner);
163
+ const runner = new BotRunner(info, this.cfg);
164
+ this.bots.set(info.userId, runner);
16
165
  runner.start(); // background; failures are isolated inside the runner
17
- };
166
+ }
18
167
  /** Fetch the authoritative bot list and start anything we're missing. */
19
- const syncBots = async () => {
20
- const res = await fetch(`${cfg.apiUrl}/v1/apps/${cfg.appId}/runtime/bots`, {
21
- headers: { authorization: `Bearer ${cfg.pairingCode}` },
168
+ async syncBots() {
169
+ const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/runtime/bots`, {
170
+ headers: { authorization: `Bearer ${this.cfg.pairingCode}` },
22
171
  });
23
172
  if (!res.ok) {
24
173
  const body = await res.text().catch(() => "");
@@ -27,58 +176,42 @@ export async function runBridge(cfg) {
27
176
  const { bots: list } = (await res.json());
28
177
  console.log(`[bridge] runtime owns ${list.length} bot(s): ${list.map((b) => `@${b.handle}`).join(", ") || "(none yet)"}`);
29
178
  for (const info of list)
30
- startBot(info);
31
- };
32
- // ---------------------------------------------------------- control sock --
33
- const control = new ControlSocket(cfg, {
34
- onAuthed: () => {
35
- // Initial connect AND every reconnect: catch up on bots created while
36
- // we were away (adopt_bot frames we may have missed).
37
- void syncBots().catch((err) => {
38
- console.error(`[bridge] bot sync failed: ${err.message}`);
39
- });
40
- },
41
- onAdoptBot: (f) => {
42
- startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId });
43
- },
44
- onGrantChanged: () => {
45
- // v1: logged by ControlSocket. TODO(grants): pass down to the affected
46
- // BotRunner and enforce an allowlist before answering.
47
- },
48
- });
49
- // ---------------------------------------------------------------- health --
50
- const health = createServer((_req, res) => {
51
- res.setHeader("content-type", "application/json");
52
- res.end(JSON.stringify({
53
- ok: control.connected,
54
- runtime: control.runtimeId,
55
- bots: [...bots.values()].map((b) => ({
56
- handle: b.info.handle,
57
- userId: b.info.userId,
58
- connected: b.connected,
59
- handled: b.handled,
60
- })),
61
- uptime: Math.round((Date.now() - startedAt) / 1000),
62
- }));
63
- });
64
- health.listen(cfg.port, () => console.log(`[health] http://localhost:${cfg.port}/ -> {ok, runtime, bots, uptime}`));
65
- // ----------------------------------------------------------------- start --
66
- // Bots first (so a control-socket hiccup doesn't delay serving), then the
67
- // control socket, whose auth.ok triggers a redundant-but-safe re-sync.
68
- try {
69
- await syncBots();
70
- }
71
- catch (err) {
72
- // Non-fatal: the control socket's onAuthed will retry the sync.
73
- console.error(`[bridge] initial bot sync failed (will retry on control auth): ${err.message}`);
74
- }
75
- control.start();
76
- return {
77
- stop() {
78
- control.stop();
79
- for (const b of bots.values())
80
- b.stop();
81
- health.close();
82
- },
83
- };
179
+ this.startBot(info);
180
+ }
181
+ startCore() {
182
+ if (this.stopped || this.control)
183
+ return;
184
+ this.control = new ControlSocket(this.cfg, {
185
+ onAuthed: () => {
186
+ // Initial connect AND every reconnect: catch up on bots created while
187
+ // we were away (adopt_bot frames we may have missed).
188
+ void this.syncBots().catch((err) => {
189
+ console.error(`[bridge] bot sync failed: ${err.message}`);
190
+ });
191
+ },
192
+ onAdoptBot: (f) => {
193
+ this.startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId });
194
+ },
195
+ onGrantChanged: (f) => {
196
+ // Access changed for a bot — re-pull its allow list so enforcement is
197
+ // live (a revoked user stops being answered within seconds).
198
+ const runner = this.bots.get(f.botUserId);
199
+ if (runner)
200
+ void runner.refreshAccess();
201
+ },
202
+ });
203
+ // Bots first (so a control-socket hiccup doesn't delay serving), then the
204
+ // control socket, whose auth.ok triggers a redundant-but-safe re-sync.
205
+ void this.syncBots().catch((err) => {
206
+ console.error(`[bridge] initial bot sync failed (will retry on control auth): ${err.message}`);
207
+ });
208
+ this.control.start();
209
+ }
210
+ stopCore() {
211
+ this.control?.stop();
212
+ this.control = null;
213
+ for (const b of this.bots.values())
214
+ b.stop();
215
+ this.bots.clear();
216
+ }
84
217
  }