@nopeek/agent-bridge 0.1.1 → 0.2.1

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,7 +12,6 @@ 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;
package/dist/bot.js CHANGED
@@ -16,7 +16,6 @@ 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;
@@ -34,9 +33,7 @@ export class BotRunner {
34
33
  constructor(info, cfg) {
35
34
  this.info = info;
36
35
  this.cfg = cfg;
37
- const resolved = resolveBrain(cfg, info.handle);
38
- this.brain = resolved.brain;
39
- this.brainKind = resolved.kind;
36
+ this.brainKind = resolveBrain(cfg, info.handle).kind;
40
37
  const tag = `[bot:@${info.handle}]`;
41
38
  this.log = (m) => console.log(`${tag} ${m}`);
42
39
  this.logErr = (m) => console.error(`${tag} ${m}`);
@@ -125,7 +122,7 @@ export class BotRunner {
125
122
  const np = await NoPeek.connect({
126
123
  apiUrl: this.cfg.apiUrl,
127
124
  sessionToken: session.sessionToken,
128
- appId: this.cfg.appId,
125
+ appId: this.cfg.appId ?? "", // runners only exist while paired
129
126
  userId: this.info.userId,
130
127
  platform: "server",
131
128
  storage: store,
@@ -253,9 +250,13 @@ export class BotRunner {
253
250
  catch {
254
251
  /* typing is best-effort */
255
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;
256
257
  let reply = "";
257
258
  try {
258
- reply = await this.brain(text, {
259
+ reply = await resolved.brain(text, {
259
260
  botHandle: this.info.handle,
260
261
  botUserId: this.info.userId,
261
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.1";
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.1";
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,61 +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: (f) => {
45
- // Access changed for a bot — re-pull its allow list so enforcement is live
46
- // (a revoked user stops being answered within seconds, no restart).
47
- const runner = bots.get(f.botUserId);
48
- if (runner)
49
- void runner.refreshAccess();
50
- },
51
- });
52
- // ---------------------------------------------------------------- health --
53
- const health = createServer((_req, res) => {
54
- res.setHeader("content-type", "application/json");
55
- res.end(JSON.stringify({
56
- ok: control.connected,
57
- runtime: control.runtimeId,
58
- bots: [...bots.values()].map((b) => ({
59
- handle: b.info.handle,
60
- userId: b.info.userId,
61
- connected: b.connected,
62
- handled: b.handled,
63
- })),
64
- uptime: Math.round((Date.now() - startedAt) / 1000),
65
- }));
66
- });
67
- health.listen(cfg.port, () => console.log(`[health] http://localhost:${cfg.port}/ -> {ok, runtime, bots, uptime}`));
68
- // ----------------------------------------------------------------- start --
69
- // Bots first (so a control-socket hiccup doesn't delay serving), then the
70
- // control socket, whose auth.ok triggers a redundant-but-safe re-sync.
71
- try {
72
- await syncBots();
73
- }
74
- catch (err) {
75
- // Non-fatal: the control socket's onAuthed will retry the sync.
76
- console.error(`[bridge] initial bot sync failed (will retry on control auth): ${err.message}`);
77
- }
78
- control.start();
79
- return {
80
- stop() {
81
- control.stop();
82
- for (const b of bots.values())
83
- b.stop();
84
- health.close();
85
- },
86
- };
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
+ }
87
217
  }
package/dist/cli.js CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // @nopeek/agent-bridge — connect your own agent runtime to NoPeek as E2EE bots.
3
- // npx @nopeek/agent-bridge --pair npr_… --app-id app_… --brain-cmd '…'
3
+ //
4
+ // nopeek-agent-bridge install one-time: background service, then pair FROM THE APP
5
+ // nopeek-agent-bridge [run] foreground (unpaired bridges wait for the app)
6
+ // nopeek-agent-bridge status ask the running bridge how it's doing
7
+ // nopeek-agent-bridge uninstall remove the service
4
8
  import { loadConfig, HELP } from "./config.js";
5
- import { runBridge } from "./bridge.js";
9
+ import { BridgeApp, VERSION } from "./bridge.js";
10
+ import { startLocalApi } from "./localapi.js";
11
+ import { installService, uninstallService, printStatus } from "./service.js";
6
12
  // ---------------------------------------------------------------- guards ----
7
13
  // The bridge must never die to a stray rejection deep inside a WS/crypto
8
14
  // callback — one flaky bot cannot take the fleet down.
@@ -16,25 +22,87 @@ if (typeof WebSocket === "undefined" || !globalThis.crypto?.subtle) {
16
22
  console.error(`@nopeek/agent-bridge needs Node >= 22 (global WebSocket + fetch + WebCrypto). Current: ${process.version}`);
17
23
  process.exit(1);
18
24
  }
19
- // ---------------------------------------------------------------- config ----
25
+ // ----------------------------------------------------------- subcommands ----
26
+ const argv = process.argv.slice(2);
27
+ const SUBCOMMANDS = new Set(["install", "uninstall", "status", "run", "help"]);
28
+ const sub = argv[0] && SUBCOMMANDS.has(argv[0]) ? argv[0] : "run";
29
+ const rest = sub === argv[0] ? argv.slice(1) : argv;
30
+ if (sub === "help") {
31
+ console.log(HELP);
32
+ process.exit(0);
33
+ }
20
34
  // Config errors exit cleanly with guidance — never a crash loop.
21
35
  let cfg;
22
36
  try {
23
- cfg = loadConfig();
37
+ cfg = loadConfig(rest);
24
38
  }
25
39
  catch (err) {
26
40
  console.error(`[config] ${err.message}\n`);
27
41
  console.error(HELP);
28
42
  process.exit(1);
29
43
  }
30
- console.log(`[bridge] NoPeek agent-bridge starting`);
31
- console.log(`[bridge] api=${cfg.apiUrl} app=${cfg.appId} data=${cfg.dataDir} health=:${cfg.port}`);
32
- console.log(`[bridge] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (set --brain-cmd or --brain-url to plug in your agent)"}` +
44
+ if (sub === "uninstall") {
45
+ uninstallService();
46
+ process.exit(0);
47
+ }
48
+ if (sub === "status") {
49
+ await printStatus(cfg.port);
50
+ process.exit(process.exitCode ?? 0);
51
+ }
52
+ if (sub === "install") {
53
+ try {
54
+ await installService(cfg);
55
+ }
56
+ catch (err) {
57
+ console.error(`[install] ${err.message}`);
58
+ process.exit(1);
59
+ }
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) {
63
+ try {
64
+ const res = await fetch(`http://127.0.0.1:${cfg.port}/pair`, {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: JSON.stringify({ pairingSecret: cfg.pairingCode, appId: cfg.appId, apiUrl: cfg.apiUrl }),
68
+ });
69
+ const body = (await res.json().catch(() => ({})));
70
+ if (res.ok)
71
+ console.log(`[install] paired successfully`);
72
+ else
73
+ console.error(`[install] pairing failed: ${body.message ?? `HTTP ${res.status}`}`);
74
+ }
75
+ catch (err) {
76
+ console.error(`[install] pairing failed: ${err.message}`);
77
+ }
78
+ }
79
+ process.exit(0);
80
+ }
81
+ // ---------------------------------------------------------------- run -------
82
+ 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] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (choose an agent in the NoPeek app, or set --brain-cmd)"}` +
33
85
  (Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
34
- const bridge = await runBridge(cfg);
86
+ const app = new BridgeApp(cfg);
87
+ let localApi;
88
+ try {
89
+ localApi = await startLocalApi(app);
90
+ }
91
+ catch (err) {
92
+ const e = err;
93
+ if (e.code === "EADDRINUSE") {
94
+ console.error(`[bridge] port ${cfg.port} is already in use — another bridge is probably running (try 'nopeek-agent-bridge status'). Exiting.`);
95
+ }
96
+ else {
97
+ console.error(`[bridge] could not start local API: ${e.message}`);
98
+ }
99
+ process.exit(1);
100
+ }
101
+ app.start();
35
102
  const shutdown = (signal) => {
36
103
  console.log(`[bridge] ${signal} — shutting down`);
37
- bridge.stop();
104
+ app.stop();
105
+ localApi.close();
38
106
  // Give sockets a beat to close, then force-exit so systemd/launchd restarts cleanly.
39
107
  setTimeout(() => process.exit(0), 500).unref();
40
108
  };