@nopeek/agent-bridge 0.1.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 ADDED
@@ -0,0 +1,195 @@
1
+ # @nopeek/agent-bridge
2
+
3
+ Run your own AI agents as **end-to-end-encrypted NoPeek bots** — from your Mac, a Linux box, or a Raspberry Pi.
4
+
5
+ The bridge is a small always-on process that:
6
+
7
+ 1. **Pairs** with your NoPeek account using a one-time pairing code (`npr_…`) from the NoPeek app ("Connect your computer").
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
+ 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
+
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
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ # Zero config: bots run in echo mode ("You said: …") — a smoke test
17
+ npx @nopeek/agent-bridge --pair npr_XXXXXXXX --app-id app_XXXXXXXX
18
+
19
+ # Plug in a real agent runtime (Hermes example):
20
+ npx @nopeek/agent-bridge \
21
+ --pair npr_XXXXXXXX \
22
+ --app-id app_XXXXXXXX \
23
+ --brain-cmd 'HERMES_HOME=/Volumes/x10drive/hermes hermes --profile nopeek chat -Q -q "$(cat)"'
24
+ ```
25
+
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.
27
+
28
+ ## How the brain works
29
+
30
+ The bridge is runtime-agnostic on purpose. A "brain" turns a message into a reply, and there are three flavors:
31
+
32
+ ### 1. Command brain (`--brain-cmd` / `BRAIN_CMD`)
33
+
34
+ The bridge spawns `bash -c "<your command>"`, writes the user's message text to **stdin**, and takes trimmed **stdout** as the reply. ANSI escape codes are stripped automatically (agent CLIs like Hermes colorize their output).
35
+
36
+ Context is passed as environment variables:
37
+
38
+ | Variable | Meaning |
39
+ | --- | --- |
40
+ | `NOPEEK_BOT_HANDLE` | handle of the bot answering |
41
+ | `NOPEEK_BOT_USER_ID` | user id of the bot |
42
+ | `NOPEEK_CHANNEL_ID` | channel the message arrived in |
43
+ | `NOPEEK_SENDER_USER_ID` | who sent the message |
44
+
45
+ Examples:
46
+
47
+ ```bash
48
+ # Hermes
49
+ --brain-cmd 'HERMES_HOME=/Volumes/x10drive/hermes hermes --profile nopeek chat -Q -q "$(cat)"'
50
+
51
+ # OpenClaw, Claude Code, llm, anything that reads a prompt and prints an answer
52
+ --brain-cmd 'openclaw ask --stdin'
53
+ --brain-cmd 'llm -m gpt-4o "$(cat)"'
54
+
55
+ # Route per channel inside your own script
56
+ --brain-cmd '/home/me/route.sh' # read stdin, inspect $NOPEEK_CHANNEL_ID, print reply
57
+ ```
58
+
59
+ On non-zero exit, timeout, or empty output the bridge logs the error and sends a short friendly fallback so the chat never goes silent.
60
+
61
+ ### 2. Webhook brain (`--brain-url` / `BRAIN_URL`)
62
+
63
+ The bridge POSTs JSON to your endpoint and expects `{ "text": "…" }` (or `{ "reply": "…" }`) back:
64
+
65
+ ```
66
+ POST <your url>
67
+ Content-Type: application/json
68
+
69
+ {
70
+ "text": "what's the weather?",
71
+ "botHandle": "weatherbot",
72
+ "botUserId": "usr_…",
73
+ "channelId": "ch_…",
74
+ "senderUserId": "usr_…"
75
+ }
76
+ ```
77
+
78
+ Minimal server:
79
+
80
+ ```js
81
+ // node server.mjs
82
+ import { createServer } from "node:http";
83
+ createServer((req, res) => {
84
+ let body = "";
85
+ req.on("data", (d) => (body += d));
86
+ req.on("end", async () => {
87
+ const { text } = JSON.parse(body);
88
+ res.setHeader("content-type", "application/json");
89
+ res.end(JSON.stringify({ text: `You asked: ${text}` }));
90
+ });
91
+ }).listen(9000);
92
+ ```
93
+
94
+ ```bash
95
+ npx @nopeek/agent-bridge --pair npr_… --app-id app_… --brain-url http://localhost:9000
96
+ ```
97
+
98
+ ### 3. Echo (default)
99
+
100
+ With no brain configured, every bot replies `You said: <text>`. Use it to verify pairing and E2EE end to end before wiring a real agent.
101
+
102
+ ### Per-bot brains (`BRAIN_MAP`)
103
+
104
+ Different bots, different brains. `BRAIN_MAP` is a JSON object keyed by bot handle; each entry is `{"cmd": "…"}` or `{"url": "…"}`:
105
+
106
+ ```bash
107
+ BRAIN_MAP='{
108
+ "hermesbot": {"cmd": "HERMES_HOME=/Volumes/x10drive/hermes hermes --profile nopeek chat -Q -q \"$(cat)\""},
109
+ "weatherbot": {"url": "http://localhost:9000/weather"}
110
+ }' npx @nopeek/agent-bridge --pair npr_… --app-id app_…
111
+ ```
112
+
113
+ Resolution order per bot: `BRAIN_MAP[handle]` → global `BRAIN_CMD` → global `BRAIN_URL` → echo.
114
+
115
+ ## Configuration
116
+
117
+ Everything can be set three ways, highest precedence first: **CLI flag → environment variable → `nopeek-bridge.config.json`** (same key names as the env vars, in the working directory or via `--config <path>`).
118
+
119
+ | Flag | Env var | Default | Meaning |
120
+ | --- | --- | --- | --- |
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) |
123
+ | `--api-url` | `NOPEEK_API_URL` | `https://d3qweh72vesa98.cloudfront.net` | NoPeek API base |
124
+ | `--brain-cmd` | `BRAIN_CMD` | — | Global command brain |
125
+ | `--brain-url` | `BRAIN_URL` | — | Global webhook brain |
126
+ | `--brain-map` | `BRAIN_MAP` | `{}` | Per-handle overrides (JSON) |
127
+ | `--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 |
130
+ | `--config` | — | `./nopeek-bridge.config.json` | Config file path |
131
+
132
+ Example config file:
133
+
134
+ ```json
135
+ {
136
+ "NOPEEK_PAIRING_CODE": "npr_XXXXXXXX",
137
+ "NOPEEK_APP_ID": "app_XXXXXXXX",
138
+ "BRAIN_CMD": "HERMES_HOME=/Volumes/x10drive/hermes hermes --profile nopeek chat -Q -q \"$(cat)\""
139
+ }
140
+ ```
141
+
142
+ ## Health endpoint
143
+
144
+ `GET http://localhost:8790/` →
145
+
146
+ ```json
147
+ {
148
+ "ok": true,
149
+ "runtime": "rt_…",
150
+ "bots": [
151
+ { "handle": "hermesbot", "userId": "usr_…", "connected": true, "handled": 12 }
152
+ ],
153
+ "uptime": 3600
154
+ }
155
+ ```
156
+
157
+ `ok` reflects the control connection; each bot reports its own connection and how many messages it has answered.
158
+
159
+ ## Behavior & reliability
160
+
161
+ - **Live adoption** — create a bot in the app and the bridge starts running it within seconds (control WebSocket push), plus a full re-sync on every reconnect so nothing is missed.
162
+ - **Isolation** — each bot runs independently with its own retry/backoff loop; one misbehaving bot never affects the others.
163
+ - **Reconnects** — the control socket and every bot connection reconnect forever with capped exponential backoff. Runtime sessions are refreshed before they expire.
164
+ - **Dedupe** — redelivered messages are never answered twice.
165
+ - **Crash guards** — unhandled rejections/exceptions are logged and survived; the process only exits on config errors (clearly, at startup) or a signal.
166
+ - **Typing indicator** — shown while the brain is thinking.
167
+
168
+ ## Security notes
169
+
170
+ - Messages are E2EE (MLS): decryption happens **on your machine**, inside this process. The NoPeek server only ever relays ciphertext.
171
+ - Your API keys, agent credentials, and brain commands are yours — the bridge never sends them anywhere. Brains run locally (or on the webhook host **you** choose).
172
+ - The only state written to disk is `./data/<botUserId>.json`: each bot's device identity and channel keys, needed so restarts keep the same device. Treat that directory like a private key (it is one) — it's `.gitignore`d, and you can move it with `--data-dir`.
173
+ - The pairing code is a bearer credential for *running your bots*, nothing more. Revoke it from the NoPeek app at any time; the bridge simply stops authenticating.
174
+
175
+ ## Running it long-term
176
+
177
+ Anything that keeps a process alive works. For example, on Linux:
178
+
179
+ ```ini
180
+ # /etc/systemd/system/nopeek-bridge.service
181
+ [Service]
182
+ Environment=NOPEEK_PAIRING_CODE=npr_…
183
+ Environment=NOPEEK_APP_ID=app_…
184
+ Environment=BRAIN_CMD=…
185
+ WorkingDirectory=/home/me/nopeek-bridge
186
+ ExecStart=/usr/bin/npx @nopeek/agent-bridge
187
+ Restart=always
188
+ ```
189
+
190
+ On macOS, a `launchd` plist or just `tmux` works fine.
191
+
192
+ ## Requirements
193
+
194
+ - Node.js **22+** (global `fetch`, `WebSocket`, WebCrypto — no polyfills needed).
195
+ - `bash` on PATH if you use a command brain.
package/dist/bot.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ export interface BotInfo {
3
+ userId: string;
4
+ handle: string;
5
+ nickname?: string;
6
+ ownerType?: string;
7
+ ownerId?: string;
8
+ }
9
+ export declare class BotRunner {
10
+ readonly info: BotInfo;
11
+ connected: boolean;
12
+ handled: number;
13
+ brainKind: string;
14
+ private cfg;
15
+ private brain;
16
+ private np;
17
+ private stopped;
18
+ private refreshTimer;
19
+ private seen;
20
+ private channelCache;
21
+ private log;
22
+ private logErr;
23
+ constructor(info: BotInfo, cfg: BridgeConfig);
24
+ /** Fire-and-forget: runs the connect loop in the background, isolated. */
25
+ start(): void;
26
+ stop(): void;
27
+ private mintSession;
28
+ private run;
29
+ private connectOnce;
30
+ /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
31
+ private scheduleRefresh;
32
+ private reconnect;
33
+ private getChannel;
34
+ /** Bounded dedupe so a redelivered frame is never answered twice. */
35
+ private remember;
36
+ private handleMessage;
37
+ }
package/dist/bot.js ADDED
@@ -0,0 +1,234 @@
1
+ // One running bot: mints a runtime session from the pairing token, connects to
2
+ // NoPeek AS the bot user (server device, MLS key packages published), listens
3
+ // for decrypted messages and answers through the resolved brain. Failures are
4
+ // isolated — a broken bot retries with backoff and never takes down its peers.
5
+ import { NoPeek } from "@nopeek/chat";
6
+ import { resolveBrain } from "./brain.js";
7
+ import { FileStore } from "./storage.js";
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ const MAX_BACKOFF_MS = 60_000;
10
+ // Refresh the bot session 5 min before it expires (clamped to a sane window).
11
+ const REFRESH_MARGIN_MS = 5 * 60_000;
12
+ const MAX_REFRESH_DELAY_MS = 6 * 24 * 60 * 60_000;
13
+ export class BotRunner {
14
+ info;
15
+ connected = false;
16
+ handled = 0;
17
+ brainKind;
18
+ cfg;
19
+ brain;
20
+ np = null;
21
+ stopped = false;
22
+ refreshTimer = null;
23
+ seen = new Set();
24
+ channelCache = new Map();
25
+ log;
26
+ logErr;
27
+ constructor(info, cfg) {
28
+ this.info = info;
29
+ this.cfg = cfg;
30
+ const resolved = resolveBrain(cfg, info.handle);
31
+ this.brain = resolved.brain;
32
+ this.brainKind = resolved.kind;
33
+ const tag = `[bot:@${info.handle}]`;
34
+ this.log = (m) => console.log(`${tag} ${m}`);
35
+ this.logErr = (m) => console.error(`${tag} ${m}`);
36
+ }
37
+ /** Fire-and-forget: runs the connect loop in the background, isolated. */
38
+ start() {
39
+ void this.run().catch((err) => {
40
+ this.logErr(`runner crashed (bot stays down until restart): ${err.message}`);
41
+ });
42
+ }
43
+ stop() {
44
+ this.stopped = true;
45
+ if (this.refreshTimer)
46
+ clearTimeout(this.refreshTimer);
47
+ try {
48
+ this.np?.close();
49
+ }
50
+ catch {
51
+ /* best-effort */
52
+ }
53
+ this.np = null;
54
+ this.connected = false;
55
+ }
56
+ async mintSession() {
57
+ const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/bots/${this.info.userId}/runtime-session`, {
58
+ method: "POST",
59
+ headers: {
60
+ authorization: `Bearer ${this.cfg.pairingCode}`,
61
+ "content-type": "application/json",
62
+ },
63
+ });
64
+ if (!res.ok) {
65
+ const body = await res.text().catch(() => "");
66
+ throw new Error(`runtime-session HTTP ${res.status}: ${body.slice(0, 300)}`);
67
+ }
68
+ return (await res.json());
69
+ }
70
+ async run() {
71
+ this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
72
+ let delay = 2_000;
73
+ while (!this.stopped) {
74
+ try {
75
+ await this.connectOnce();
76
+ return; // connected; SDK auto-reconnects, session refresh re-enters via reconnect()
77
+ }
78
+ catch (err) {
79
+ this.logErr(`connect failed: ${err.message} — retrying in ${delay / 1000}s`);
80
+ await sleep(delay);
81
+ delay = Math.min(delay * 2, MAX_BACKOFF_MS);
82
+ }
83
+ }
84
+ }
85
+ async connectOnce() {
86
+ const session = await this.mintSession();
87
+ const store = new FileStore(this.cfg.dataDir, this.info.userId);
88
+ // platform:'server' + NO deferDeviceRegistration: on first connect the SDK
89
+ // registers a fresh device and publishes MLS key packages, which is exactly
90
+ // what a bot wants — peers claim those packages to send it welcomes. The
91
+ // FileStore makes restarts reuse the same device instead of minting new ones.
92
+ const np = await NoPeek.connect({
93
+ apiUrl: this.cfg.apiUrl,
94
+ sessionToken: session.sessionToken,
95
+ appId: this.cfg.appId,
96
+ userId: this.info.userId,
97
+ platform: "server",
98
+ storage: store,
99
+ });
100
+ if (this.stopped) {
101
+ np.close();
102
+ return;
103
+ }
104
+ this.np = np;
105
+ this.connected = true;
106
+ this.channelCache.clear();
107
+ this.log(`connected, device ${np.deviceId} (platform=server), store ${store.file}`);
108
+ np.on("connected", (() => {
109
+ this.connected = true;
110
+ this.log(`ws connected`);
111
+ }));
112
+ np.on("disconnected", (() => {
113
+ this.connected = false;
114
+ this.log(`ws disconnected — SDK will auto-reconnect`);
115
+ }));
116
+ np.on("channelKeyReceived", ((p) => {
117
+ this.log(`received channel key for ${p.channelId} (welcome ceremony completed)`);
118
+ }));
119
+ np.on("message", ((m) => {
120
+ void this.handleMessage(m).catch((err) => {
121
+ this.logErr(`handler error for ${m.messageId}: ${err.message}`);
122
+ });
123
+ }));
124
+ this.scheduleRefresh(session.expiresAt);
125
+ }
126
+ /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
127
+ scheduleRefresh(expiresAt) {
128
+ if (this.refreshTimer)
129
+ clearTimeout(this.refreshTimer);
130
+ if (!expiresAt)
131
+ return;
132
+ const at = Date.parse(expiresAt);
133
+ if (!Number.isFinite(at))
134
+ return;
135
+ const delay = Math.min(Math.max(at - Date.now() - REFRESH_MARGIN_MS, 60_000), MAX_REFRESH_DELAY_MS);
136
+ this.refreshTimer = setTimeout(() => {
137
+ this.log(`runtime session nearing expiry — reconnecting with a fresh one`);
138
+ void this.reconnect();
139
+ }, delay);
140
+ this.refreshTimer.unref?.();
141
+ }
142
+ async reconnect() {
143
+ if (this.stopped)
144
+ return;
145
+ try {
146
+ this.np?.close();
147
+ }
148
+ catch {
149
+ /* best-effort */
150
+ }
151
+ this.np = null;
152
+ this.connected = false;
153
+ await this.run();
154
+ }
155
+ async getChannel(channelId) {
156
+ let ch = this.channelCache.get(channelId);
157
+ if (!ch) {
158
+ ch = await this.np.channels.get(channelId);
159
+ this.channelCache.set(channelId, ch);
160
+ }
161
+ return ch;
162
+ }
163
+ /** Bounded dedupe so a redelivered frame is never answered twice. */
164
+ remember(id) {
165
+ this.seen.add(id);
166
+ if (this.seen.size > 5_000) {
167
+ // Set iterates in insertion order — drop the oldest fifth.
168
+ let n = 1_000;
169
+ for (const k of this.seen) {
170
+ this.seen.delete(k);
171
+ if (--n === 0)
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ async handleMessage(m) {
177
+ if (this.seen.has(m.messageId))
178
+ return;
179
+ this.remember(m.messageId);
180
+ if (m.senderUserId === this.info.userId)
181
+ return; // never answer ourselves
182
+ if (m.decryptionFailed) {
183
+ // Likely a missed welcome (message arrived before our key). Sync the key
184
+ // so the NEXT message decrypts; this frame's plaintext is unrecoverable.
185
+ this.logErr(`could not decrypt ${m.messageId} in ${m.channelId} — syncing channel key`);
186
+ try {
187
+ await (await this.getChannel(m.channelId)).ensureKey();
188
+ }
189
+ catch (err) {
190
+ this.logErr(`ensureKey(${m.channelId}) failed: ${err.message}`);
191
+ }
192
+ return;
193
+ }
194
+ if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
195
+ 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.
199
+ const text = m.body.text;
200
+ this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
201
+ const ch = await this.getChannel(m.channelId);
202
+ ch.markRead(m.messageId).catch(() => { });
203
+ try {
204
+ ch.typing(true);
205
+ }
206
+ catch {
207
+ /* typing is best-effort */
208
+ }
209
+ let reply = "";
210
+ try {
211
+ reply = await this.brain(text, {
212
+ botHandle: this.info.handle,
213
+ botUserId: this.info.userId,
214
+ channelId: m.channelId,
215
+ senderUserId: m.senderUserId,
216
+ });
217
+ }
218
+ finally {
219
+ try {
220
+ ch.typing(false);
221
+ }
222
+ catch {
223
+ /* best-effort */
224
+ }
225
+ }
226
+ if (!reply || !reply.trim()) {
227
+ this.log(`brain returned empty reply — ignoring`);
228
+ return;
229
+ }
230
+ await ch.send({ text: reply.trim() });
231
+ this.handled++;
232
+ this.log(`${m.channelId} -> replied (${reply.trim().length} chars, handled=${this.handled})`);
233
+ }
234
+ }
@@ -0,0 +1,20 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ export interface BrainContext {
3
+ botHandle: string;
4
+ botUserId: string;
5
+ channelId: string;
6
+ senderUserId: string;
7
+ }
8
+ export type Brain = (text: string, ctx: BrainContext) => Promise<string>;
9
+ export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
10
+ export declare function stripAnsi(s: string): string;
11
+ export interface ResolvedBrain {
12
+ brain: Brain;
13
+ /** Human-readable description for logs/health, e.g. `cmd (per-bot)` or `echo`. */
14
+ kind: string;
15
+ }
16
+ /**
17
+ * Pick the brain for one bot:
18
+ * BRAIN_MAP[handle] (cmd beats url within an entry) > BRAIN_CMD > BRAIN_URL > echo.
19
+ */
20
+ export declare function resolveBrain(cfg: BridgeConfig, handle: string): ResolvedBrain;
package/dist/brain.js ADDED
@@ -0,0 +1,133 @@
1
+ // Generic brain contract: (text, ctx) -> reply string. Resolved PER BOT:
2
+ // 1. BRAIN_MAP["<handle>"] -> {"cmd": "…"} or {"url": "…"}
3
+ // 2. global BRAIN_CMD (shell, stdin -> stdout)
4
+ // 3. global BRAIN_URL (webhook, JSON in -> JSON out)
5
+ // 4. echo ("You said: …") — zero-config smoke test
6
+ // This is how ANY agentic runtime plugs in (Hermes, OpenClaw, a curl to your
7
+ // own service): the bridge never knows or cares what's on the other side.
8
+ import { spawn } from "node:child_process";
9
+ export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
10
+ // ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
11
+ // Hermes color their stdout; the chat must receive plain text.
12
+ // eslint-disable-next-line no-control-regex
13
+ const ANSI_RE =
14
+ // eslint-disable-next-line no-control-regex
15
+ /[\u001B\u009B](?:[\[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?(?:\u0007|\u001B\\)|(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
16
+ export function stripAnsi(s) {
17
+ return s.replace(ANSI_RE, "");
18
+ }
19
+ /**
20
+ * Shell brain: spawn `bash -c "<cmd>"`, write the user's message to stdin,
21
+ * take trimmed (ANSI-stripped) stdout as the reply. Context rides in env vars:
22
+ * NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID, NOPEEK_CHANNEL_ID, NOPEEK_SENDER_USER_ID.
23
+ */
24
+ function cmdBrain(cmd, timeoutMs) {
25
+ return (text, ctx) => new Promise((resolvePromise) => {
26
+ const child = spawn("bash", ["-c", cmd], {
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ env: {
29
+ ...process.env,
30
+ NOPEEK_BOT_HANDLE: ctx.botHandle,
31
+ NOPEEK_BOT_USER_ID: ctx.botUserId,
32
+ NOPEEK_CHANNEL_ID: ctx.channelId,
33
+ NOPEEK_SENDER_USER_ID: ctx.senderUserId,
34
+ },
35
+ });
36
+ let stdout = "";
37
+ let stderr = "";
38
+ let settled = false;
39
+ const finish = (reply) => {
40
+ if (!settled) {
41
+ settled = true;
42
+ resolvePromise(reply);
43
+ }
44
+ };
45
+ const timer = setTimeout(() => {
46
+ console.error(`[brain:cmd:@${ctx.botHandle}] timed out after ${timeoutMs / 1000}s, killing`);
47
+ child.kill("SIGKILL");
48
+ finish(stripAnsi(stdout).trim() || FALLBACK_REPLY);
49
+ }, timeoutMs);
50
+ child.stdout.on("data", (d) => (stdout += d.toString()));
51
+ child.stderr.on("data", (d) => (stderr += d.toString()));
52
+ child.on("error", (err) => {
53
+ clearTimeout(timer);
54
+ console.error(`[brain:cmd:@${ctx.botHandle}] spawn error: ${err.message}`);
55
+ finish(FALLBACK_REPLY);
56
+ });
57
+ child.on("close", (code) => {
58
+ clearTimeout(timer);
59
+ const reply = stripAnsi(stdout).trim();
60
+ if (code !== 0) {
61
+ console.error(`[brain:cmd:@${ctx.botHandle}] exit ${code}. stderr: ${stderr.slice(0, 2000)}`);
62
+ finish(reply || FALLBACK_REPLY);
63
+ return;
64
+ }
65
+ if (!reply) {
66
+ console.error(`[brain:cmd:@${ctx.botHandle}] command produced empty stdout`);
67
+ finish(FALLBACK_REPLY);
68
+ return;
69
+ }
70
+ finish(reply);
71
+ });
72
+ child.stdin.on("error", () => {
73
+ /* command may exit before reading stdin — close handler settles */
74
+ });
75
+ child.stdin.write(text);
76
+ child.stdin.end();
77
+ });
78
+ }
79
+ /**
80
+ * Webhook brain: POST {text, botHandle, botUserId, channelId, senderUserId}
81
+ * as JSON; accept {text: "…"} or {reply: "…"} back.
82
+ */
83
+ function urlBrain(url, timeoutMs) {
84
+ return async (text, ctx) => {
85
+ try {
86
+ const res = await fetch(url, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ signal: AbortSignal.timeout(timeoutMs),
90
+ body: JSON.stringify({
91
+ text,
92
+ botHandle: ctx.botHandle,
93
+ botUserId: ctx.botUserId,
94
+ channelId: ctx.channelId,
95
+ senderUserId: ctx.senderUserId,
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ console.error(`[brain:url:@${ctx.botHandle}] HTTP ${res.status} from ${url}`);
100
+ return FALLBACK_REPLY;
101
+ }
102
+ const json = (await res.json());
103
+ const reply = typeof json.text === "string" ? json.text : typeof json.reply === "string" ? json.reply : "";
104
+ if (!reply.trim()) {
105
+ console.error(`[brain:url:@${ctx.botHandle}] webhook returned no {text} or {reply}`);
106
+ return FALLBACK_REPLY;
107
+ }
108
+ return reply.trim();
109
+ }
110
+ catch (err) {
111
+ console.error(`[brain:url:@${ctx.botHandle}] ${err.message}`);
112
+ return FALLBACK_REPLY;
113
+ }
114
+ };
115
+ }
116
+ /** Zero-config smoke test. */
117
+ const echoBrain = async (text) => `You said: ${text}`;
118
+ /**
119
+ * Pick the brain for one bot:
120
+ * BRAIN_MAP[handle] (cmd beats url within an entry) > BRAIN_CMD > BRAIN_URL > echo.
121
+ */
122
+ export function resolveBrain(cfg, handle) {
123
+ const override = cfg.brainMap[handle.replace(/^@/, "")];
124
+ if (override?.cmd)
125
+ return { brain: cmdBrain(override.cmd, cfg.brainTimeoutMs), kind: "cmd (per-bot)" };
126
+ if (override?.url)
127
+ return { brain: urlBrain(override.url, cfg.brainTimeoutMs), kind: "url (per-bot)" };
128
+ if (cfg.brainCmd)
129
+ return { brain: cmdBrain(cfg.brainCmd, cfg.brainTimeoutMs), kind: "cmd" };
130
+ if (cfg.brainUrl)
131
+ return { brain: urlBrain(cfg.brainUrl, cfg.brainTimeoutMs), kind: "url" };
132
+ return { brain: echoBrain, kind: "echo" };
133
+ }
@@ -0,0 +1,5 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ export interface Bridge {
3
+ stop(): void;
4
+ }
5
+ export declare function runBridge(cfg: BridgeConfig): Promise<Bridge>;
package/dist/bridge.js ADDED
@@ -0,0 +1,84 @@
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";
5
+ import { BotRunner } from "./bot.js";
6
+ 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))
13
+ return; // already running
14
+ const runner = new BotRunner(info, cfg);
15
+ bots.set(info.userId, runner);
16
+ runner.start(); // background; failures are isolated inside the runner
17
+ };
18
+ /** 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}` },
22
+ });
23
+ if (!res.ok) {
24
+ const body = await res.text().catch(() => "");
25
+ throw new Error(`GET /runtime/bots HTTP ${res.status}: ${body.slice(0, 300)}`);
26
+ }
27
+ const { bots: list } = (await res.json());
28
+ console.log(`[bridge] runtime owns ${list.length} bot(s): ${list.map((b) => `@${b.handle}`).join(", ") || "(none yet)"}`);
29
+ 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
+ };
84
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
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 '…'
4
+ import { loadConfig, HELP } from "./config.js";
5
+ import { runBridge } from "./bridge.js";
6
+ // ---------------------------------------------------------------- guards ----
7
+ // The bridge must never die to a stray rejection deep inside a WS/crypto
8
+ // callback — one flaky bot cannot take the fleet down.
9
+ process.on("unhandledRejection", (reason) => {
10
+ console.error(`[fatal-guard] unhandled rejection (continuing):`, reason);
11
+ });
12
+ process.on("uncaughtException", (err) => {
13
+ console.error(`[fatal-guard] uncaught exception (continuing):`, err);
14
+ });
15
+ if (typeof WebSocket === "undefined" || !globalThis.crypto?.subtle) {
16
+ console.error(`@nopeek/agent-bridge needs Node >= 22 (global WebSocket + fetch + WebCrypto). Current: ${process.version}`);
17
+ process.exit(1);
18
+ }
19
+ // ---------------------------------------------------------------- config ----
20
+ // Config errors exit cleanly with guidance — never a crash loop.
21
+ let cfg;
22
+ try {
23
+ cfg = loadConfig();
24
+ }
25
+ catch (err) {
26
+ console.error(`[config] ${err.message}\n`);
27
+ console.error(HELP);
28
+ process.exit(1);
29
+ }
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)"}` +
33
+ (Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
34
+ const bridge = await runBridge(cfg);
35
+ const shutdown = (signal) => {
36
+ console.log(`[bridge] ${signal} — shutting down`);
37
+ bridge.stop();
38
+ // Give sockets a beat to close, then force-exit so systemd/launchd restarts cleanly.
39
+ setTimeout(() => process.exit(0), 500).unref();
40
+ };
41
+ process.on("SIGINT", () => shutdown("SIGINT"));
42
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -0,0 +1,29 @@
1
+ /** Per-bot brain override, keyed by bot handle in BRAIN_MAP. */
2
+ export interface BrainSpec {
3
+ cmd?: string;
4
+ url?: string;
5
+ }
6
+ export interface BridgeConfig {
7
+ /** NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
8
+ apiUrl: string;
9
+ appId: string;
10
+ /** Runtime pairing token from the NoPeek app ("Connect your computer"), npr_… */
11
+ pairingCode: string;
12
+ /** Global brain: shell command reading the message on stdin, printing the reply. */
13
+ brainCmd: string | null;
14
+ /** Global brain: webhook POSTed {text, botHandle, botUserId, channelId, senderUserId}. */
15
+ brainUrl: string | null;
16
+ /** Per-handle overrides: { "<handle>": {"cmd": "…"} | {"url": "…"} }. */
17
+ brainMap: Record<string, BrainSpec>;
18
+ brainTimeoutMs: number;
19
+ /** Health endpoint port. */
20
+ port: number;
21
+ /** Where per-bot device identity/key stores live (./data by default). */
22
+ dataDir: string;
23
+ }
24
+ export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
25
+ export declare const DEFAULT_PORT = 8790;
26
+ export declare const DEFAULT_BRAIN_TIMEOUT_MS = 180000;
27
+ export declare const HELP = "nopeek-agent-bridge \u2014 run your agents as E2EE NoPeek bots\n\nUsage:\n npx @nopeek/agent-bridge --pair npr_\u2026 --app-id app_\u2026 [--brain-cmd '\u2026' | --brain-url https://\u2026]\n\nOptions:\n --pair <code> One-time pairing code from the NoPeek app (env NOPEEK_PAIRING_CODE)\n --app-id <id> NoPeek app id (env NOPEEK_APP_ID)\n --api-url <url> API base, default https://d3qweh72vesa98.cloudfront.net (env NOPEEK_API_URL)\n --brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)\n --brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)\n --brain-map <json> Per-bot overrides {\"<handle>\":{\"cmd\":\"\u2026\"}|{\"url\":\"\u2026\"}} (env BRAIN_MAP)\n --brain-timeout-ms <ms> Brain timeout, default 180000 (env BRAIN_TIMEOUT_MS)\n --port <port> Health endpoint port, default 8790 (env NOPEEK_BRIDGE_PORT)\n --data-dir <dir> Device-key store dir, default ./data (env NOPEEK_BRIDGE_DATA_DIR)\n --config <path> Config file, default ./nopeek-bridge.config.json\n -h, --help Show this help\n\nWith no brain configured, bots run in echo mode (\"You said: \u2026\") \u2014 a zero-config smoke test.";
28
+ /** Load config from argv + env + config file. Throws on missing/invalid values. */
29
+ export declare function loadConfig(argv?: string[]): BridgeConfig;
package/dist/config.js ADDED
@@ -0,0 +1,153 @@
1
+ // Bridge config. Precedence: CLI flags > environment variables > optional
2
+ // ./nopeek-bridge.config.json (same key names as the env vars). Throws with a
3
+ // clear, actionable message when required values are missing — the CLI turns
4
+ // that into a clean exit, never a crash loop.
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ export const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
9
+ export const DEFAULT_PORT = 8790;
10
+ export const DEFAULT_BRAIN_TIMEOUT_MS = 180_000;
11
+ const CLI_OPTIONS = {
12
+ pair: { type: "string" },
13
+ "api-url": { type: "string" },
14
+ "app-id": { type: "string" },
15
+ "brain-cmd": { type: "string" },
16
+ "brain-url": { type: "string" },
17
+ "brain-map": { type: "string" },
18
+ "brain-timeout-ms": { type: "string" },
19
+ port: { type: "string" },
20
+ "data-dir": { type: "string" },
21
+ config: { type: "string" },
22
+ help: { type: "boolean", short: "h" },
23
+ };
24
+ // flag name -> env/config key
25
+ const FLAG_TO_KEY = {
26
+ pair: "NOPEEK_PAIRING_CODE",
27
+ "api-url": "NOPEEK_API_URL",
28
+ "app-id": "NOPEEK_APP_ID",
29
+ "brain-cmd": "BRAIN_CMD",
30
+ "brain-url": "BRAIN_URL",
31
+ "brain-map": "BRAIN_MAP",
32
+ "brain-timeout-ms": "BRAIN_TIMEOUT_MS",
33
+ port: "NOPEEK_BRIDGE_PORT",
34
+ "data-dir": "NOPEEK_BRIDGE_DATA_DIR",
35
+ };
36
+ export const HELP = `nopeek-agent-bridge — run your agents as E2EE NoPeek bots
37
+
38
+ Usage:
39
+ npx @nopeek/agent-bridge --pair npr_… --app-id app_… [--brain-cmd '…' | --brain-url https://…]
40
+
41
+ Options:
42
+ --pair <code> One-time pairing code from the NoPeek app (env NOPEEK_PAIRING_CODE)
43
+ --app-id <id> NoPeek app id (env NOPEEK_APP_ID)
44
+ --api-url <url> API base, default ${DEFAULT_API_URL} (env NOPEEK_API_URL)
45
+ --brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)
46
+ --brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)
47
+ --brain-map <json> Per-bot overrides {"<handle>":{"cmd":"…"}|{"url":"…"}} (env BRAIN_MAP)
48
+ --brain-timeout-ms <ms> Brain timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
49
+ --port <port> Health endpoint port, default ${DEFAULT_PORT} (env NOPEEK_BRIDGE_PORT)
50
+ --data-dir <dir> Device-key store dir, default ./data (env NOPEEK_BRIDGE_DATA_DIR)
51
+ --config <path> Config file, default ./nopeek-bridge.config.json
52
+ -h, --help Show this help
53
+
54
+ With no brain configured, bots run in echo mode ("You said: …") — a zero-config smoke test.`;
55
+ function readConfigFile(path, explicit) {
56
+ if (!existsSync(path)) {
57
+ if (explicit)
58
+ throw new Error(`config file not found: ${path}`);
59
+ return {};
60
+ }
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
63
+ const out = {};
64
+ for (const [k, v] of Object.entries(parsed)) {
65
+ if (v === null || v === undefined)
66
+ continue;
67
+ out[k] = typeof v === "string" ? v : JSON.stringify(v);
68
+ }
69
+ console.log(`[config] loaded ${path}`);
70
+ return out;
71
+ }
72
+ catch (err) {
73
+ throw new Error(`failed to parse ${path}: ${err.message}`);
74
+ }
75
+ }
76
+ function parseBrainMap(raw) {
77
+ if (!raw)
78
+ return {};
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ }
83
+ catch (err) {
84
+ throw new Error(`BRAIN_MAP is not valid JSON: ${err.message}`);
85
+ }
86
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
87
+ throw new Error(`BRAIN_MAP must be a JSON object like {"<handle>":{"cmd":"…"}}`);
88
+ }
89
+ const out = {};
90
+ for (const [handle, spec] of Object.entries(parsed)) {
91
+ if (typeof spec !== "object" || spec === null) {
92
+ throw new Error(`BRAIN_MAP["${handle}"] must be {"cmd":"…"} or {"url":"…"}`);
93
+ }
94
+ const { cmd, url } = spec;
95
+ if (typeof cmd !== "string" && typeof url !== "string") {
96
+ throw new Error(`BRAIN_MAP["${handle}"] needs a "cmd" or "url" string`);
97
+ }
98
+ out[handle.replace(/^@/, "")] = {
99
+ ...(typeof cmd === "string" ? { cmd } : {}),
100
+ ...(typeof url === "string" ? { url } : {}),
101
+ };
102
+ }
103
+ return out;
104
+ }
105
+ /** Load config from argv + env + config file. Throws on missing/invalid values. */
106
+ export function loadConfig(argv = process.argv.slice(2)) {
107
+ const { values: flags } = parseArgs({ args: argv, options: CLI_OPTIONS, strict: true });
108
+ if (flags.help) {
109
+ console.log(HELP);
110
+ process.exit(0);
111
+ }
112
+ const configPath = resolve(process.cwd(), flags.config ?? "nopeek-bridge.config.json");
113
+ const file = readConfigFile(configPath, flags.config !== undefined);
114
+ const get = (flagName) => {
115
+ const key = FLAG_TO_KEY[flagName];
116
+ const v = flags[flagName] ?? process.env[key] ?? file[key];
117
+ return v === undefined || v === "" ? undefined : v;
118
+ };
119
+ const pairingCode = get("pair");
120
+ if (!pairingCode) {
121
+ throw new Error(`missing pairing code.\n` +
122
+ `Get one from the NoPeek app ("Connect your computer"), then run:\n` +
123
+ ` npx @nopeek/agent-bridge --pair npr_…\n` +
124
+ `or set NOPEEK_PAIRING_CODE (env or nopeek-bridge.config.json).`);
125
+ }
126
+ if (!pairingCode.startsWith("npr_")) {
127
+ console.warn(`[config] pairing code does not start with "npr_" — double-check you pasted the runtime pairing code`);
128
+ }
129
+ const appId = get("app-id");
130
+ if (!appId) {
131
+ throw new Error(`missing app id.\n` +
132
+ `Pass --app-id app_… or set NOPEEK_APP_ID (shown alongside the pairing code in the NoPeek app).`);
133
+ }
134
+ const brainTimeoutMs = Number(get("brain-timeout-ms") ?? DEFAULT_BRAIN_TIMEOUT_MS);
135
+ if (!Number.isFinite(brainTimeoutMs) || brainTimeoutMs <= 0) {
136
+ throw new Error(`BRAIN_TIMEOUT_MS must be a positive number of milliseconds`);
137
+ }
138
+ const port = Number(get("port") ?? DEFAULT_PORT);
139
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
140
+ throw new Error(`NOPEEK_BRIDGE_PORT must be a valid port number`);
141
+ }
142
+ return {
143
+ apiUrl: (get("api-url") ?? DEFAULT_API_URL).replace(/\/+$/, ""),
144
+ appId,
145
+ pairingCode,
146
+ brainCmd: get("brain-cmd") ?? null,
147
+ brainUrl: get("brain-url") ?? null,
148
+ brainMap: parseBrainMap(get("brain-map")),
149
+ brainTimeoutMs,
150
+ port,
151
+ dataDir: resolve(process.cwd(), get("data-dir") ?? "data"),
152
+ };
153
+ }
@@ -0,0 +1,36 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ export interface AdoptBotFrame {
3
+ type: "adopt_bot";
4
+ botUserId: string;
5
+ appId: string;
6
+ ownerUserId: string;
7
+ handle: string;
8
+ }
9
+ export interface BotGrantChangedFrame {
10
+ type: "bot_grant_changed";
11
+ channelId: string;
12
+ botUserId: string;
13
+ granteeUserId: string;
14
+ action: string;
15
+ }
16
+ export interface ControlHandlers {
17
+ /** Fired on every successful auth (initial + each reconnect) — resync bots here. */
18
+ onAuthed: (runtimeId: string) => void;
19
+ onAdoptBot: (frame: AdoptBotFrame) => void;
20
+ onGrantChanged: (frame: BotGrantChangedFrame) => void;
21
+ }
22
+ export declare class ControlSocket {
23
+ connected: boolean;
24
+ runtimeId: string | null;
25
+ private cfg;
26
+ private handlers;
27
+ private ws;
28
+ private stopped;
29
+ private delay;
30
+ constructor(cfg: BridgeConfig, handlers: ControlHandlers);
31
+ start(): void;
32
+ stop(): void;
33
+ private connect;
34
+ private handleFrame;
35
+ private scheduleReconnect;
36
+ }
@@ -0,0 +1,113 @@
1
+ export class ControlSocket {
2
+ connected = false;
3
+ runtimeId = null;
4
+ cfg;
5
+ handlers;
6
+ ws = null;
7
+ stopped = false;
8
+ delay = 2_000;
9
+ constructor(cfg, handlers) {
10
+ this.cfg = cfg;
11
+ this.handlers = handlers;
12
+ }
13
+ start() {
14
+ this.connect();
15
+ }
16
+ stop() {
17
+ this.stopped = true;
18
+ try {
19
+ this.ws?.close();
20
+ }
21
+ catch {
22
+ /* best-effort */
23
+ }
24
+ this.ws = null;
25
+ this.connected = false;
26
+ }
27
+ connect() {
28
+ if (this.stopped)
29
+ return;
30
+ const url = `${this.cfg.apiUrl.replace(/^http/, "ws")}/v1/ws?runtimeToken=${encodeURIComponent(this.cfg.pairingCode)}`;
31
+ let ws;
32
+ try {
33
+ ws = new WebSocket(url);
34
+ }
35
+ catch (err) {
36
+ console.error(`[control] could not open socket: ${err.message}`);
37
+ this.scheduleReconnect();
38
+ return;
39
+ }
40
+ this.ws = ws;
41
+ ws.onopen = () => {
42
+ console.log(`[control] socket open, awaiting auth.ok`);
43
+ };
44
+ ws.onmessage = (ev) => {
45
+ let frame;
46
+ try {
47
+ frame = JSON.parse(String(ev.data));
48
+ }
49
+ catch {
50
+ console.error(`[control] non-JSON frame ignored: ${String(ev.data).slice(0, 200)}`);
51
+ return;
52
+ }
53
+ this.handleFrame(frame);
54
+ };
55
+ ws.onerror = () => {
56
+ // onclose always follows; log there.
57
+ };
58
+ ws.onclose = (ev) => {
59
+ const was = this.connected;
60
+ this.connected = false;
61
+ if (this.stopped)
62
+ return;
63
+ console.error(`[control] socket closed (code=${ev.code}${ev.reason ? `, reason=${ev.reason}` : ""})${was ? "" : " before auth.ok — is the pairing code valid?"}`);
64
+ this.scheduleReconnect();
65
+ };
66
+ }
67
+ handleFrame(frame) {
68
+ switch (frame.type) {
69
+ case "auth.ok": {
70
+ this.connected = true;
71
+ this.delay = 2_000; // reset backoff on a good auth
72
+ this.runtimeId = String(frame.runtimeId ?? "");
73
+ console.log(`[control] authenticated as runtime ${this.runtimeId} (control=${String(frame.control)})`);
74
+ this.handlers.onAuthed(this.runtimeId);
75
+ return;
76
+ }
77
+ case "adopt_bot": {
78
+ const f = frame;
79
+ console.log(`[control] adopt_bot @${f.handle} (${f.botUserId}) owner=${f.ownerUserId}`);
80
+ this.handlers.onAdoptBot(f);
81
+ return;
82
+ }
83
+ case "bot_grant_changed": {
84
+ const f = frame;
85
+ // TODO(grants): enforce these — for now bots answer everyone in their
86
+ // channels; we only log so operators can see grants flowing.
87
+ console.log(`[control] bot_grant_changed bot=${f.botUserId} channel=${f.channelId} grantee=${f.granteeUserId} action=${f.action} (logged only; enforcement TODO)`);
88
+ this.handlers.onGrantChanged(f);
89
+ return;
90
+ }
91
+ case "ping": {
92
+ try {
93
+ this.ws?.send(JSON.stringify({ type: "pong" }));
94
+ }
95
+ catch {
96
+ /* socket may be mid-close */
97
+ }
98
+ return;
99
+ }
100
+ default:
101
+ // Forward-compatible: unknown frames are logged, never fatal.
102
+ console.log(`[control] unhandled frame type "${String(frame.type)}"`);
103
+ }
104
+ }
105
+ scheduleReconnect() {
106
+ if (this.stopped)
107
+ return;
108
+ console.log(`[control] reconnecting in ${this.delay / 1000}s`);
109
+ const t = setTimeout(() => this.connect(), this.delay);
110
+ t.unref?.();
111
+ this.delay = Math.min(this.delay * 2, 30_000);
112
+ }
113
+ }
@@ -0,0 +1,8 @@
1
+ export declare class FileStore {
2
+ private path;
3
+ private map;
4
+ constructor(dataDir: string, userId: string);
5
+ get file(): string;
6
+ get(key: string): string | null;
7
+ set(key: string, value: string): void;
8
+ }
@@ -0,0 +1,36 @@
1
+ // File-backed KeyValueStore for the SDK's `storage` option, mirroring the
2
+ // localStorage default shape (get(key) -> string | null, set(key, value)).
3
+ // Persists each bot's device identity + channel keys to <dataDir>/<userId>.json
4
+ // so restarts reuse the same registered device instead of minting a new one.
5
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ export class FileStore {
8
+ path;
9
+ map;
10
+ constructor(dataDir, userId) {
11
+ this.path = join(dataDir, `${userId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
12
+ mkdirSync(dirname(this.path), { recursive: true });
13
+ this.map = {};
14
+ if (existsSync(this.path)) {
15
+ try {
16
+ this.map = JSON.parse(readFileSync(this.path, "utf8"));
17
+ }
18
+ catch (err) {
19
+ console.error(`[storage] could not parse ${this.path}, starting fresh: ${err.message}`);
20
+ }
21
+ }
22
+ }
23
+ get file() {
24
+ return this.path;
25
+ }
26
+ get(key) {
27
+ return Object.prototype.hasOwnProperty.call(this.map, key) ? this.map[key] : null;
28
+ }
29
+ set(key, value) {
30
+ this.map[key] = value;
31
+ // atomic-ish write: tmp file + rename, so a crash never truncates key material
32
+ const tmp = `${this.path}.tmp`;
33
+ writeFileSync(tmp, JSON.stringify(this.map, null, 2));
34
+ renameSync(tmp, this.path);
35
+ }
36
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@nopeek/agent-bridge",
3
+ "version": "0.1.0",
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.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "nopeek-agent-bridge": "./dist/cli.js"
9
+ },
10
+ "main": "./dist/bridge.js",
11
+ "types": "./dist/bridge.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/bridge.d.ts",
15
+ "default": "./dist/bridge.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "dependencies": {
26
+ "@nopeek/chat": "0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.10.0",
30
+ "tsx": "^4.19.0",
31
+ "typescript": "^5.8.0"
32
+ },
33
+ "keywords": [
34
+ "nopeek",
35
+ "e2ee",
36
+ "bot",
37
+ "agent",
38
+ "bridge",
39
+ "mls"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "start": "node dist/cli.js",
47
+ "dev": "tsx src/cli.ts",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit"
49
+ }
50
+ }