@nopeek/agent-bridge 0.2.1 → 0.4.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.
@@ -0,0 +1,38 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ import { type Brain } from "./brain.js";
3
+ export declare function soulsDir(homeDir: string): string;
4
+ export declare function soulPath(homeDir: string, handle: string): string;
5
+ /**
6
+ * Write <home>/agent-souls/<handle>.md from the built-in template if absent
7
+ * (idempotent — an existing, possibly hand-edited soul is never overwritten).
8
+ * Returns the soul path.
9
+ */
10
+ export declare function provisionSoul(handle: string, homeDir: string): string;
11
+ /**
12
+ * Deterministic per-(bot, channel) session UUID so each chat is one running
13
+ * Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
14
+ * byte-for-byte identical to tools/agent/bot-brain.sh so existing sessions
15
+ * keep their history when a bot moves from the shell brain to the native one.
16
+ */
17
+ export declare function sessionUuid(handle: string, channelId: string): string;
18
+ /**
19
+ * Claude Code backend: `claude -p` headless (reliable — no OAuth flakiness),
20
+ * one continuous session per (bot, channel). Streams assistant text deltas
21
+ * only. Runs in a dedicated empty cwd with --strict-mcp-config and a tool
22
+ * denylist, so a chat bot never inherits repo CLAUDE.md, MCP servers, or
23
+ * host powers from wherever the bridge happens to run.
24
+ */
25
+ export declare function claudeBrain(cfg: BridgeConfig): Brain;
26
+ /**
27
+ * Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
28
+ * memories/, inheriting the main install's model config and provider auth
29
+ * (auth.json symlink — OAuth tokens refresh in place, so every bot profile
30
+ * stays authenticated from one login). Idempotent.
31
+ */
32
+ export declare function provisionHermesProfile(handle: string): string;
33
+ /**
34
+ * Hermes backend: per-bot profile (own soul + memories), one persistent
35
+ * session per channel (`--continue nopeek-<channel>`), streaming cleaned
36
+ * stdout lines to onChunk.
37
+ */
38
+ export declare function hermesBrain(cfg: BridgeConfig): Brain;
@@ -0,0 +1,384 @@
1
+ // Native brain backends — no external shell scripts required. Two batteries-
2
+ // included agent runtimes the bridge can drive directly:
3
+ //
4
+ // claude — Claude Code headless (`claude -p`), one continuous session per
5
+ // (bot, channel) via a deterministic session UUID, streaming
6
+ // assistant text deltas (stream-json) to onChunk.
7
+ // hermes — a per-bot Hermes profile (own soul + memories) under
8
+ // $HERMES_HOME/profiles/bot-<handle>, one persistent chat session
9
+ // per channel (--continue), streaming stdout lines to onChunk.
10
+ //
11
+ // Both are Brains: (text, ctx, onChunk?) => Promise<fullReply>. The returned
12
+ // string is authoritative for the final message body; onChunk deltas are
13
+ // display-only. Selected per bot via BRAIN_MAP entries like {"backend":"claude"}.
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { stripAnsi } from "./brain.js";
20
+ const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
21
+ // ------------------------------------------------------------------ souls ----
22
+ /** Built-in personality template — personalized per bot by provisionSoul(). */
23
+ const SOUL_TEMPLATE = `You are {{NAME}} (handle @{{HANDLE}}), a personal AI agent reachable through the
24
+ NoPeek end-to-end-encrypted messenger. A human messages you in a chat; you help;
25
+ you reply. Everything you output is sent verbatim as a chat message.
26
+
27
+ Voice: friendly, sharp, concise. This is a chat, not a report — short paragraphs
28
+ or brief lists, no markdown headers. Answer the question directly.
29
+
30
+ You have your own memory of past conversations in this chat (each chat is a
31
+ separate ongoing thread). Remember what the humans tell you.
32
+
33
+ Guardrails: never expose secrets or tokens; confirm before anything destructive
34
+ or outward-facing; report honestly when something failed; if a request is far
35
+ outside what you can do, say so briefly and offer what you can.
36
+ `;
37
+ export function soulsDir(homeDir) {
38
+ return join(homeDir, "agent-souls");
39
+ }
40
+ export function soulPath(homeDir, handle) {
41
+ return join(soulsDir(homeDir), `${handle.replace(/^@/, "")}.md`);
42
+ }
43
+ /** "weather_bot" -> "weather", "ops_helper" -> "ops helper". */
44
+ function displayName(handle) {
45
+ return handle.replace(/^@/, "").replace(/_bot$/, "").replace(/_/g, " ") || handle;
46
+ }
47
+ /**
48
+ * Write <home>/agent-souls/<handle>.md from the built-in template if absent
49
+ * (idempotent — an existing, possibly hand-edited soul is never overwritten).
50
+ * Returns the soul path.
51
+ */
52
+ export function provisionSoul(handle, homeDir) {
53
+ const h = handle.replace(/^@/, "");
54
+ const path = soulPath(homeDir, h);
55
+ if (!existsSync(path)) {
56
+ mkdirSync(soulsDir(homeDir), { recursive: true });
57
+ const soul = SOUL_TEMPLATE.replace(/\{\{HANDLE\}\}/g, h).replace(/\{\{NAME\}\}/g, displayName(h));
58
+ writeFileSync(path, soul);
59
+ console.log(`[soul:@${h}] provisioned ${path}`);
60
+ }
61
+ return path;
62
+ }
63
+ // ----------------------------------------------------------------- helpers ----
64
+ /** Resolve a binary: $<ENVVAR> > PATH (command -v) > common install dirs. */
65
+ function resolveBin(name, envVar) {
66
+ const fromEnv = process.env[envVar];
67
+ if (fromEnv)
68
+ return fromEnv;
69
+ const r = spawnSync("bash", ["-lc", `command -v ${name}`], { encoding: "utf8" });
70
+ const found = (r.stdout || "").trim().split("\n")[0];
71
+ if (r.status === 0 && found)
72
+ return found;
73
+ for (const dir of [join(homedir(), ".local", "bin"), "/usr/local/bin", "/opt/homebrew/bin"]) {
74
+ const p = join(dir, name);
75
+ if (existsSync(p))
76
+ return p;
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Deterministic per-(bot, channel) session UUID so each chat is one running
82
+ * Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
83
+ * byte-for-byte identical to tools/agent/bot-brain.sh so existing sessions
84
+ * keep their history when a bot moves from the shell brain to the native one.
85
+ */
86
+ export function sessionUuid(handle, channelId) {
87
+ const h = createHash("sha1").update(`nopeek:${handle}:${channelId}`).digest("hex").slice(0, 32);
88
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
89
+ }
90
+ function runClaudeOnce(bin, args, text, cwd, timeoutMs, tag, onDelta) {
91
+ return new Promise((resolvePromise) => {
92
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], cwd });
93
+ let deltas = "";
94
+ let resultText = "";
95
+ let assistantText = "";
96
+ let lineBuf = "";
97
+ let stderr = "";
98
+ let settled = false;
99
+ const handleLine = (line) => {
100
+ if (!line.trim())
101
+ return;
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(line);
105
+ }
106
+ catch {
107
+ return; // non-JSON noise
108
+ }
109
+ if (parsed.type === "stream_event" &&
110
+ parsed.event?.type === "content_block_delta" &&
111
+ parsed.event.delta?.type === "text_delta" &&
112
+ typeof parsed.event.delta.text === "string") {
113
+ deltas += parsed.event.delta.text;
114
+ onDelta(parsed.event.delta.text);
115
+ return;
116
+ }
117
+ // Fallbacks if no text_delta ever arrives (older CLIs / odd runs):
118
+ if (parsed.type === "result" && typeof parsed.result === "string") {
119
+ resultText = parsed.result;
120
+ return;
121
+ }
122
+ if (parsed.type === "assistant" && Array.isArray(parsed.message?.content)) {
123
+ const t = parsed.message.content
124
+ .filter((b) => b.type === "text" && typeof b.text === "string")
125
+ .map((b) => b.text)
126
+ .join("");
127
+ if (t)
128
+ assistantText = t;
129
+ }
130
+ };
131
+ const finish = (exitCode) => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ if (lineBuf)
136
+ handleLine(lineBuf);
137
+ const reply = (deltas.trim() || resultText.trim() || assistantText.trim());
138
+ if (!reply && stderr.trim())
139
+ console.error(`${tag} stderr: ${stderr.slice(0, 1000)}`);
140
+ resolvePromise({ reply, exitCode });
141
+ };
142
+ const timer = setTimeout(() => {
143
+ console.error(`${tag} timed out after ${timeoutMs / 1000}s, killing`);
144
+ child.kill("SIGKILL");
145
+ finish(null);
146
+ }, timeoutMs);
147
+ child.stdout.on("data", (d) => {
148
+ lineBuf += d.toString();
149
+ const lines = lineBuf.split("\n");
150
+ lineBuf = lines.pop() ?? "";
151
+ for (const line of lines)
152
+ handleLine(line);
153
+ });
154
+ child.stderr.on("data", (d) => (stderr += d.toString()));
155
+ child.on("error", (err) => {
156
+ clearTimeout(timer);
157
+ console.error(`${tag} spawn error: ${err.message}`);
158
+ finish(null);
159
+ });
160
+ child.on("close", (code) => {
161
+ clearTimeout(timer);
162
+ finish(code);
163
+ });
164
+ child.stdin.on("error", () => {
165
+ /* claude may exit before reading stdin — close handler settles */
166
+ });
167
+ child.stdin.write(text);
168
+ child.stdin.end();
169
+ });
170
+ }
171
+ /**
172
+ * Claude Code backend: `claude -p` headless (reliable — no OAuth flakiness),
173
+ * one continuous session per (bot, channel). Streams assistant text deltas
174
+ * only. Runs in a dedicated empty cwd with --strict-mcp-config and a tool
175
+ * denylist, so a chat bot never inherits repo CLAUDE.md, MCP servers, or
176
+ * host powers from wherever the bridge happens to run.
177
+ */
178
+ export function claudeBrain(cfg) {
179
+ return async (text, ctx, onChunk) => {
180
+ const bin = resolveBin("claude", "CLAUDE_BIN");
181
+ if (!bin) {
182
+ console.error(`[brain:claude:@${ctx.botHandle}] claude not found on PATH`);
183
+ return BRAIN_UNREACHABLE;
184
+ }
185
+ const handle = ctx.botHandle.replace(/^@/, "");
186
+ const soulFile = provisionSoul(handle, cfg.homeDir);
187
+ let soul = "";
188
+ try {
189
+ soul = readFileSync(soulFile, "utf8");
190
+ }
191
+ catch {
192
+ /* missing/unreadable soul — run with base behavior */
193
+ }
194
+ const sid = sessionUuid(handle, ctx.channelId);
195
+ const runDir = join(cfg.homeDir, "agent-run");
196
+ mkdirSync(runDir, { recursive: true });
197
+ const tag = `[brain:claude:@${handle}]`;
198
+ const baseArgs = [
199
+ "-p",
200
+ "--verbose", // required by -p + --output-format stream-json
201
+ "--append-system-prompt",
202
+ soul,
203
+ "--disallowedTools",
204
+ "Bash Edit Write NotebookEdit MultiEdit Task WebFetch",
205
+ "--strict-mcp-config", // never inherit the host's MCP servers
206
+ "--output-format",
207
+ "stream-json",
208
+ "--include-partial-messages",
209
+ ];
210
+ // Only forward deltas from the attempt that actually produces text, so a
211
+ // failed --resume can't leak a half-answer before the retry streams.
212
+ let emitted = false;
213
+ const emit = (delta) => {
214
+ emitted = true;
215
+ onChunk?.(delta);
216
+ };
217
+ // Resume this chat's session; if it doesn't exist yet, create it with that id.
218
+ const first = await runClaudeOnce(bin, [...baseArgs, "--resume", sid], text, runDir, cfg.brainTimeoutMs, tag, emit);
219
+ if (first.reply)
220
+ return first.reply;
221
+ if (emitted) {
222
+ // Deltas reached the chat but the run died before a final — don't retry
223
+ // into a duplicate answer; tell the user honestly.
224
+ return BRAIN_UNREACHABLE;
225
+ }
226
+ console.error(`${tag} --resume ${sid} yielded nothing (exit ${first.exitCode}) — retrying with --session-id`);
227
+ const second = await runClaudeOnce(bin, [...baseArgs, "--session-id", sid], text, runDir, cfg.brainTimeoutMs, tag, emit);
228
+ if (second.reply)
229
+ return second.reply;
230
+ console.error(`${tag} both attempts yielded nothing (exit ${second.exitCode})`);
231
+ return BRAIN_UNREACHABLE;
232
+ };
233
+ }
234
+ // ----------------------------------------------------------------- hermes ----
235
+ function hermesHome() {
236
+ return process.env.HERMES_HOME || "/Volumes/x10drive/hermes";
237
+ }
238
+ /** Hermes profile-local soul (mirrors tools/hermes/SOUL-template.md). */
239
+ const HERMES_SOUL_TEMPLATE = `# {{NAME}} — Hermes profile for a NoPeek bot
240
+
241
+ You are **{{NAME}}** (handle \`@{{HANDLE}}\`), a personal AI agent reachable through the
242
+ **NoPeek** end-to-end-encrypted messenger. A human messages you in the app; you help;
243
+ you reply. Your replies are chat messages — every word you print is sent verbatim.
244
+
245
+ ## Voice
246
+ - Friendly, sharp, practical. Concise by default — this is a chat, not a report.
247
+ - Plain language. No markdown headers in replies; short paragraphs or brief lists only.
248
+
249
+ ## Memory
250
+ - This profile is yours alone: your soul (this file) and your memories
251
+ (\`memories/\` in this profile) belong to \`@{{HANDLE}}\` and no other bot.
252
+ - Remember durable facts the humans tell you; forget nothing they'd expect you to keep.
253
+
254
+ ## Guardrails
255
+ - Never expose secrets, keys, or tokens.
256
+ - Confirm before destructive or outward-facing actions.
257
+ - Report honestly — if something failed, say so.
258
+ - If a request is far outside your purpose, say so briefly and offer what you can do.
259
+ `;
260
+ /**
261
+ * Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
262
+ * memories/, inheriting the main install's model config and provider auth
263
+ * (auth.json symlink — OAuth tokens refresh in place, so every bot profile
264
+ * stays authenticated from one login). Idempotent.
265
+ */
266
+ export function provisionHermesProfile(handle) {
267
+ const h = handle.replace(/^@/, "");
268
+ const home = hermesHome();
269
+ const profile = `bot-${h}`;
270
+ const pdir = join(home, "profiles", profile);
271
+ if (!existsSync(pdir)) {
272
+ mkdirSync(join(pdir, "memories"), { recursive: true });
273
+ mkdirSync(join(pdir, "home"), { recursive: true });
274
+ const soul = HERMES_SOUL_TEMPLATE.replace(/\{\{HANDLE\}\}/g, h).replace(/\{\{NAME\}\}/g, displayName(h));
275
+ writeFileSync(join(pdir, "SOUL.md"), soul);
276
+ writeFileSync(join(pdir, "memories", "MEMORY.md"), "");
277
+ const mainConfig = join(home, "config.yaml");
278
+ if (existsSync(mainConfig))
279
+ copyFileSync(mainConfig, join(pdir, "config.yaml"));
280
+ const mainAuth = join(home, "auth.json");
281
+ if (existsSync(mainAuth) && !existsSync(join(pdir, "auth.json"))) {
282
+ try {
283
+ symlinkSync(mainAuth, join(pdir, "auth.json"));
284
+ }
285
+ catch {
286
+ /* best-effort — hermes still runs, just unauthenticated */
287
+ }
288
+ }
289
+ console.log(`[hermes:@${h}] provisioned profile ${pdir}`);
290
+ }
291
+ return profile;
292
+ }
293
+ // Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
294
+ // bubble (● …), the goodbye line, and session-info footers.
295
+ const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session( id)?:/i];
296
+ function isHermesChrome(line) {
297
+ return HERMES_CHROME.some((re) => re.test(line));
298
+ }
299
+ /**
300
+ * Hermes backend: per-bot profile (own soul + memories), one persistent
301
+ * session per channel (`--continue nopeek-<channel>`), streaming cleaned
302
+ * stdout lines to onChunk.
303
+ */
304
+ export function hermesBrain(cfg) {
305
+ return (text, ctx, onChunk) => new Promise((resolvePromise) => {
306
+ const handle = ctx.botHandle.replace(/^@/, "");
307
+ const tag = `[brain:hermes:@${handle}]`;
308
+ const bin = resolveBin("hermes", "HERMES_BIN");
309
+ if (!bin) {
310
+ console.error(`${tag} hermes not found on PATH`);
311
+ resolvePromise(BRAIN_UNREACHABLE);
312
+ return;
313
+ }
314
+ let profile;
315
+ try {
316
+ profile = provisionHermesProfile(handle);
317
+ }
318
+ catch (err) {
319
+ console.error(`${tag} profile provisioning failed: ${err.message}`);
320
+ resolvePromise(BRAIN_UNREACHABLE);
321
+ return;
322
+ }
323
+ const child = spawn(bin, ["--profile", profile, "chat", "-Q", "--continue", `nopeek-${ctx.channelId}`, "-q", text], {
324
+ stdio: ["ignore", "pipe", "pipe"],
325
+ // Hermes keys everything off $HOME; point it at the Hermes install.
326
+ env: { ...process.env, HOME: hermesHome() },
327
+ });
328
+ let out = "";
329
+ let lineBuf = "";
330
+ let sawContent = false; // suppress leading blank lines
331
+ let stderr = "";
332
+ let settled = false;
333
+ const handleLine = (raw) => {
334
+ const line = stripAnsi(raw);
335
+ if (isHermesChrome(line))
336
+ return;
337
+ if (!sawContent && !line.trim())
338
+ return;
339
+ sawContent = true;
340
+ out += `${line}\n`;
341
+ onChunk?.(`${line}\n`);
342
+ };
343
+ const finish = () => {
344
+ if (settled)
345
+ return;
346
+ settled = true;
347
+ if (lineBuf)
348
+ handleLine(lineBuf);
349
+ const reply = out.trim();
350
+ if (!reply) {
351
+ if (stderr.trim())
352
+ console.error(`${tag} stderr: ${stderr.slice(0, 1000)}`);
353
+ resolvePromise("⚠️ My brain isn't reachable right now — Hermes has no authenticated provider on the host. Run 'hermes model' there, then message me again.");
354
+ return;
355
+ }
356
+ resolvePromise(reply);
357
+ };
358
+ const timer = setTimeout(() => {
359
+ console.error(`${tag} timed out after ${cfg.brainTimeoutMs / 1000}s, killing`);
360
+ child.kill("SIGKILL");
361
+ finish();
362
+ }, cfg.brainTimeoutMs);
363
+ child.stdout.on("data", (d) => {
364
+ lineBuf += d.toString();
365
+ const lines = lineBuf.split("\n");
366
+ lineBuf = lines.pop() ?? "";
367
+ for (const line of lines)
368
+ handleLine(line);
369
+ });
370
+ child.stderr.on("data", (d) => (stderr += d.toString()));
371
+ child.on("error", (err) => {
372
+ clearTimeout(timer);
373
+ console.error(`${tag} spawn error: ${err.message}`);
374
+ if (!settled) {
375
+ settled = true;
376
+ resolvePromise(BRAIN_UNREACHABLE);
377
+ }
378
+ });
379
+ child.on("close", () => {
380
+ clearTimeout(timer);
381
+ finish();
382
+ });
383
+ });
384
+ }
package/dist/bot.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // for decrypted messages and answers through the resolved brain. Failures are
4
4
  // isolated — a broken bot retries with backoff and never takes down its peers.
5
5
  import { NoPeek } from "@nopeek/chat";
6
- import { resolveBrain } from "./brain.js";
6
+ import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
7
7
  import { FileStore } from "./storage.js";
8
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
9
  const MAX_BACKOFF_MS = 60_000;
@@ -254,6 +254,29 @@ export class BotRunner {
254
254
  // local API (PUT /brains) and the very next message uses the new one.
255
255
  const resolved = resolveBrain(this.cfg, this.info.handle);
256
256
  this.brainKind = resolved.kind;
257
+ // Streaming: if the brain emits chunks, open a streaming message on the
258
+ // FIRST chunk and forward deltas into it (typing indicator stays on until
259
+ // then). Deltas can arrive before the placeholder lands — chaining every
260
+ // append onto the open promise keeps them ordered and loses none.
261
+ // (Ref object rather than a `let`: TS can't see closure assignments.)
262
+ const streamRef = { p: null };
263
+ const onChunk = (delta) => {
264
+ if (!delta)
265
+ return;
266
+ if (!streamRef.p) {
267
+ try {
268
+ ch.typing(false); // the live bubble replaces the typing indicator
269
+ }
270
+ catch {
271
+ /* best-effort */
272
+ }
273
+ streamRef.p = ch.stream();
274
+ streamRef.p.catch((err) => {
275
+ this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
276
+ });
277
+ }
278
+ streamRef.p.then((s) => s.append(delta)).catch(() => { });
279
+ };
257
280
  let reply = "";
258
281
  try {
259
282
  reply = await resolved.brain(text, {
@@ -261,7 +284,15 @@ export class BotRunner {
261
284
  botUserId: this.info.userId,
262
285
  channelId: m.channelId,
263
286
  senderUserId: m.senderUserId,
264
- });
287
+ }, onChunk);
288
+ }
289
+ catch (err) {
290
+ // Brain blew up mid-stream: finalize the partial bubble with an honest
291
+ // error line instead of leaving a forever-blinking cursor.
292
+ const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
293
+ if (stream)
294
+ await stream.fail(FALLBACK_REPLY).catch(() => { });
295
+ throw err;
265
296
  }
266
297
  finally {
267
298
  try {
@@ -271,6 +302,13 @@ export class BotRunner {
271
302
  /* best-effort */
272
303
  }
273
304
  }
305
+ const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
306
+ if (stream) {
307
+ await stream.done(reply.trim() || undefined);
308
+ this.handled++;
309
+ this.log(`${m.channelId} -> streamed reply (${reply.trim().length} chars, handled=${this.handled})`);
310
+ return;
311
+ }
274
312
  if (!reply || !reply.trim()) {
275
313
  this.log(`brain returned empty reply — ignoring`);
276
314
  return;
package/dist/brain.d.ts CHANGED
@@ -5,7 +5,12 @@ export interface BrainContext {
5
5
  channelId: string;
6
6
  senderUserId: string;
7
7
  }
8
- export type Brain = (text: string, ctx: BrainContext) => Promise<string>;
8
+ /**
9
+ * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
10
+ * text arrives (plain text, ANSI-stripped) and STILL returns the full reply —
11
+ * the returned string is authoritative for the final message body.
12
+ */
13
+ export type Brain = (text: string, ctx: BrainContext, onChunk?: (delta: string) => void) => Promise<string>;
9
14
  export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
10
15
  export declare function stripAnsi(s: string): string;
11
16
  export interface ResolvedBrain {
@@ -14,7 +19,8 @@ export interface ResolvedBrain {
14
19
  kind: string;
15
20
  }
16
21
  /**
17
- * Pick the brain for one bot:
18
- * BRAIN_MAP[handle] (cmd beats url beats echo within an entry) > BRAIN_CMD > BRAIN_URL > echo.
22
+ * Pick the brain for one bot. Per-bot precedence within a BRAIN_MAP entry:
23
+ * cmd > url > backend (native claude/hermes/echo) > echo; then the globals:
24
+ * BRAIN_CMD > BRAIN_URL > echo.
19
25
  */
20
26
  export declare function resolveBrain(cfg: BridgeConfig, handle: string): ResolvedBrain;
package/dist/brain.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // This is how ANY agentic runtime plugs in (Hermes, OpenClaw, a curl to your
7
7
  // own service): the bridge never knows or cares what's on the other side.
8
8
  import { spawn } from "node:child_process";
9
+ import { claudeBrain, hermesBrain } from "./backends.js";
9
10
  export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
10
11
  // ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
11
12
  // Hermes color their stdout; the chat must receive plain text.
@@ -22,7 +23,7 @@ export function stripAnsi(s) {
22
23
  * NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID, NOPEEK_CHANNEL_ID, NOPEEK_SENDER_USER_ID.
23
24
  */
24
25
  function cmdBrain(cmd, timeoutMs) {
25
- return (text, ctx) => new Promise((resolvePromise) => {
26
+ return (text, ctx, onChunk) => new Promise((resolvePromise) => {
26
27
  const child = spawn("bash", ["-c", cmd], {
27
28
  stdio: ["pipe", "pipe", "pipe"],
28
29
  env: {
@@ -47,7 +48,18 @@ function cmdBrain(cmd, timeoutMs) {
47
48
  child.kill("SIGKILL");
48
49
  finish(stripAnsi(stdout).trim() || FALLBACK_REPLY);
49
50
  }, timeoutMs);
50
- child.stdout.on("data", (d) => (stdout += d.toString()));
51
+ child.stdout.on("data", (d) => {
52
+ const chunk = d.toString();
53
+ stdout += chunk;
54
+ if (onChunk) {
55
+ // Stream deltas as they arrive, ANSI-stripped. (A sequence split
56
+ // across chunk boundaries can slip through; the final reply is
57
+ // stripped over the WHOLE buffer, so the finalized text is clean.)
58
+ const clean = stripAnsi(chunk);
59
+ if (clean)
60
+ onChunk(clean);
61
+ }
62
+ });
51
63
  child.stderr.on("data", (d) => (stderr += d.toString()));
52
64
  child.on("error", (err) => {
53
65
  clearTimeout(timer);
@@ -116,8 +128,9 @@ function urlBrain(url, timeoutMs) {
116
128
  /** Zero-config smoke test. */
117
129
  const echoBrain = async (text) => `You said: ${text}`;
118
130
  /**
119
- * Pick the brain for one bot:
120
- * BRAIN_MAP[handle] (cmd beats url beats echo within an entry) > BRAIN_CMD > BRAIN_URL > echo.
131
+ * Pick the brain for one bot. Per-bot precedence within a BRAIN_MAP entry:
132
+ * cmd > url > backend (native claude/hermes/echo) > echo; then the globals:
133
+ * BRAIN_CMD > BRAIN_URL > echo.
121
134
  */
122
135
  export function resolveBrain(cfg, handle) {
123
136
  const override = cfg.brainMap[handle.replace(/^@/, "")];
@@ -125,7 +138,11 @@ export function resolveBrain(cfg, handle) {
125
138
  return { brain: cmdBrain(override.cmd, cfg.brainTimeoutMs), kind: "cmd (per-bot)" };
126
139
  if (override?.url)
127
140
  return { brain: urlBrain(override.url, cfg.brainTimeoutMs), kind: "url (per-bot)" };
128
- if (override?.echo)
141
+ if (override?.backend === "claude")
142
+ return { brain: claudeBrain(cfg), kind: "claude (per-bot)" };
143
+ if (override?.backend === "hermes")
144
+ return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
145
+ if (override?.backend === "echo" || override?.echo)
129
146
  return { brain: echoBrain, kind: "echo (per-bot)" };
130
147
  if (cfg.brainCmd)
131
148
  return { brain: cmdBrain(cfg.brainCmd, cfg.brainTimeoutMs), kind: "cmd" };
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, BrainSpec } from "./config.js";
2
- export declare const VERSION = "0.2.1";
2
+ export declare const VERSION = "0.4.0";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
@@ -10,7 +10,10 @@ export interface BrainsPatch {
10
10
  brainCmd?: string | null;
11
11
  /** Global webhook brain; null clears it. */
12
12
  brainUrl?: string | null;
13
- /** Per-handle entries; a null value removes that bot's override. */
13
+ /** Auto-provisioner command for newly adopted bots; null clears it. */
14
+ brainProvisionCmd?: string | null;
15
+ /** Per-handle entries — {"cmd"}, {"url"}, {"backend":"claude"|"hermes"|"echo"}
16
+ * or {"echo":true}; a null value removes that bot's override. */
14
17
  map?: Record<string, BrainSpec | null>;
15
18
  }
16
19
  export declare class PairError extends Error {
@@ -43,6 +46,14 @@ export declare class BridgeApp {
43
46
  statusMinimal(): Record<string, unknown>;
44
47
  statusFull(): Record<string, unknown>;
45
48
  private startBot;
49
+ private provisioning;
50
+ /**
51
+ * Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
52
+ * (e.g. "create a Hermes profile with its own soul + memory for this handle")
53
+ * and store its stdout as the bot's brain command. Best-effort — on any
54
+ * failure the bot simply keeps the default brain.
55
+ */
56
+ private provisionBrain;
46
57
  /** Fetch the authoritative bot list and start anything we're missing. */
47
58
  private syncBots;
48
59
  private startCore;
package/dist/bridge.js CHANGED
@@ -6,11 +6,14 @@
6
6
  // Pairing, unpairing and brain config all happen at runtime (from the app) and
7
7
  // persist to <home>/settings.json — no restart, no terminal.
8
8
  import { hostname } from "node:os";
9
+ import { spawn } from "node:child_process";
9
10
  import { saveSettings } from "./config.js";
10
11
  import { BotRunner } from "./bot.js";
11
12
  import { ControlSocket } from "./control.js";
12
13
  import { resolveBrain } from "./brain.js";
13
- export const VERSION = "0.2.1";
14
+ import { provisionSoul } from "./backends.js";
15
+ import { isBrainBackend } from "./config.js";
16
+ export const VERSION = "0.4.0";
14
17
  export class PairError extends Error {
15
18
  code;
16
19
  constructor(code, message) {
@@ -99,6 +102,8 @@ export class BridgeApp {
99
102
  this.cfg.brainCmd = patch.brainCmd || null;
100
103
  if (patch.brainUrl !== undefined)
101
104
  this.cfg.brainUrl = patch.brainUrl || null;
105
+ if (patch.brainProvisionCmd !== undefined)
106
+ this.cfg.brainProvisionCmd = patch.brainProvisionCmd || null;
102
107
  if (patch.map) {
103
108
  for (const [rawHandle, spec] of Object.entries(patch.map)) {
104
109
  const handle = rawHandle.replace(/^@/, "");
@@ -111,6 +116,19 @@ export class BridgeApp {
111
116
  else if (typeof spec.url === "string" && spec.url.trim()) {
112
117
  this.cfg.brainMap[handle] = { url: spec.url.trim() };
113
118
  }
119
+ else if (isBrainBackend(spec.backend)) {
120
+ this.cfg.brainMap[handle] = { backend: spec.backend };
121
+ // Native backends personify through a soul file — make sure it
122
+ // exists the moment the backend is chosen (idempotent).
123
+ if (spec.backend !== "echo") {
124
+ try {
125
+ provisionSoul(handle, this.cfg.homeDir);
126
+ }
127
+ catch (err) {
128
+ console.error(`[bridge] soul provisioning for @${handle} failed: ${err.message}`);
129
+ }
130
+ }
131
+ }
114
132
  else if (spec.echo === true) {
115
133
  this.cfg.brainMap[handle] = { echo: true };
116
134
  }
@@ -146,6 +164,7 @@ export class BridgeApp {
146
164
  ? { url: this.cfg.brainUrl }
147
165
  : { echo: true },
148
166
  map: this.cfg.brainMap,
167
+ provisionCmd: this.cfg.brainProvisionCmd,
149
168
  },
150
169
  bots: [...this.bots.values()].map((b) => ({
151
170
  handle: b.info.handle,
@@ -163,6 +182,59 @@ export class BridgeApp {
163
182
  const runner = new BotRunner(info, this.cfg);
164
183
  this.bots.set(info.userId, runner);
165
184
  runner.start(); // background; failures are isolated inside the runner
185
+ void this.provisionBrain(info); // background; bot echoes until it lands
186
+ }
187
+ // A handle is provisioned at most once per process; the persisted BRAIN_MAP
188
+ // entry prevents re-provisioning across restarts.
189
+ provisioning = new Set();
190
+ /**
191
+ * Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
192
+ * (e.g. "create a Hermes profile with its own soul + memory for this handle")
193
+ * and store its stdout as the bot's brain command. Best-effort — on any
194
+ * failure the bot simply keeps the default brain.
195
+ */
196
+ async provisionBrain(info) {
197
+ const cmd = this.cfg.brainProvisionCmd;
198
+ const handle = info.handle.replace(/^@/, "");
199
+ if (!cmd || this.cfg.brainMap[handle] || this.provisioning.has(handle))
200
+ return;
201
+ this.provisioning.add(handle);
202
+ console.log(`[provision:@${handle}] running brain provisioner`);
203
+ const out = await new Promise((resolvePromise) => {
204
+ const child = spawn("bash", ["-c", cmd], {
205
+ stdio: ["ignore", "pipe", "pipe"],
206
+ env: { ...process.env, NOPEEK_BOT_HANDLE: handle, NOPEEK_BOT_USER_ID: info.userId },
207
+ });
208
+ let stdout = "";
209
+ let stderr = "";
210
+ const timer = setTimeout(() => {
211
+ child.kill("SIGKILL");
212
+ resolvePromise(null);
213
+ }, 120_000);
214
+ child.stdout.on("data", (d) => (stdout += d.toString()));
215
+ child.stderr.on("data", (d) => (stderr += d.toString()));
216
+ child.on("error", () => {
217
+ clearTimeout(timer);
218
+ resolvePromise(null);
219
+ });
220
+ child.on("close", (code) => {
221
+ clearTimeout(timer);
222
+ if (code !== 0) {
223
+ console.error(`[provision:@${handle}] exit ${code}. stderr: ${stderr.slice(0, 1000)}`);
224
+ resolvePromise(null);
225
+ return;
226
+ }
227
+ resolvePromise(stdout.trim());
228
+ });
229
+ });
230
+ if (!out) {
231
+ console.error(`[provision:@${handle}] provisioner produced no brain command — bot keeps the default brain`);
232
+ return;
233
+ }
234
+ // Use the LAST non-empty stdout line: provisioners may log progress above it.
235
+ const brainCmd = out.split("\n").map((l) => l.trim()).filter(Boolean).pop();
236
+ this.setBrains({ map: { [handle]: { cmd: brainCmd } } });
237
+ console.log(`[provision:@${handle}] brain provisioned`);
166
238
  }
167
239
  /** Fetch the authoritative bot list and start anything we're missing. */
168
240
  async syncBots() {
package/dist/cli.js CHANGED
@@ -51,7 +51,7 @@ if (sub === "status") {
51
51
  }
52
52
  if (sub === "install") {
53
53
  try {
54
- await installService(cfg);
54
+ await installService(cfg, rest.includes("--no-open"));
55
55
  }
56
56
  catch (err) {
57
57
  console.error(`[install] ${err.message}`);
package/dist/config.d.ts CHANGED
@@ -1,10 +1,16 @@
1
+ /** Built-in native backends (see backends.ts): claude = Claude Code headless,
2
+ * hermes = per-bot Hermes profile, echo = smoke test. */
3
+ export type BrainBackend = "claude" | "hermes" | "echo";
1
4
  /** Per-bot brain override, keyed by bot handle in BRAIN_MAP.
5
+ * `backend` selects a native built-in (claude/hermes/echo);
2
6
  * `echo: true` pins the bot to echo mode even when a global brain is set. */
3
7
  export interface BrainSpec {
4
8
  cmd?: string;
5
9
  url?: string;
10
+ backend?: BrainBackend;
6
11
  echo?: boolean;
7
12
  }
13
+ export declare function isBrainBackend(v: unknown): v is BrainBackend;
8
14
  export interface BridgeConfig {
9
15
  /** NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
10
16
  apiUrl: string;
@@ -18,7 +24,16 @@ export interface BridgeConfig {
18
24
  brainUrl: string | null;
19
25
  /** Per-handle overrides: { "<handle>": {"cmd": "…"} | {"url": "…"} }. */
20
26
  brainMap: Record<string, BrainSpec>;
27
+ /**
28
+ * Auto-provisioner: run ONCE for each adopted bot that has no BRAIN_MAP
29
+ * entry (env: NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID). Its trimmed stdout
30
+ * becomes that bot's brain command — e.g. a script that creates a fresh
31
+ * Hermes profile (own soul + memory) per bot and prints how to invoke it.
32
+ */
33
+ brainProvisionCmd: string | null;
21
34
  brainTimeoutMs: number;
35
+ /** Where `install` sends the user to finish setup (opened in the browser). */
36
+ appUrl: string;
22
37
  /** Local control API port (status + pairing + brain config, loopback only). */
23
38
  port: number;
24
39
  /** Where per-bot device identity/key stores live. */
@@ -30,10 +45,11 @@ export interface BridgeConfig {
30
45
  declineMessage: string | null;
31
46
  }
32
47
  export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
48
+ export declare const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
33
49
  export declare const DEFAULT_PORT = 8790;
34
50
  export declare const DEFAULT_BRAIN_TIMEOUT_MS = 180000;
35
51
  export declare function defaultHomeDir(): string;
36
- export declare const HELP = "nopeek-agent-bridge \u2014 run your agents as E2EE NoPeek bots\n\nUsage:\n nopeek-agent-bridge install Install as a background service (launchd/systemd),\n then finish setup from the NoPeek app:\n Bots -> Connect this computer.\n nopeek-agent-bridge uninstall Remove the background service (keeps data/settings).\n nopeek-agent-bridge status Show the running bridge's status.\n nopeek-agent-bridge [run] Run in the foreground. Unpaired bridges wait to be\n paired from the NoPeek app; --pair still works:\n npx @nopeek/agent-bridge --pair npr_\u2026 --app-id app_\u2026\n\nOptions:\n --pair <code> 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> Local control API port, default 8790 (env NOPEEK_BRIDGE_PORT)\n --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)\n --home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)\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.\nPairing and brains can be managed entirely from the NoPeek app once the service is running.";
52
+ export declare const HELP = "nopeek-agent-bridge \u2014 run your agents as E2EE NoPeek bots\n\nUsage:\n nopeek-agent-bridge install Install as a background service (launchd/systemd),\n then finish setup from the NoPeek app:\n Bots -> Connect this computer.\n nopeek-agent-bridge uninstall Remove the background service (keeps data/settings).\n nopeek-agent-bridge status Show the running bridge's status.\n nopeek-agent-bridge [run] Run in the foreground. Unpaired bridges wait to be\n paired from the NoPeek app; --pair still works:\n npx @nopeek/agent-bridge --pair npr_\u2026 --app-id app_\u2026\n\nOptions:\n --pair <code> 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\"}|\n {\"backend\":\"claude\"|\"hermes\"|\"echo\"}} (env BRAIN_MAP)\n --brain-provision-cmd <cmd> Run once per new bot; stdout becomes its brain command\n (env BRAIN_PROVISION_CMD \u2014 e.g. a script that creates a\n fresh Hermes profile with its own soul + memory)\n --app-url <url> App opened after install (env NOPEEK_APP_URL)\n --no-open install: don't open the app in the browser\n --brain-timeout-ms <ms> Brain timeout, default 180000 (env BRAIN_TIMEOUT_MS)\n --port <port> Local control API port, default 8790 (env NOPEEK_BRIDGE_PORT)\n --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)\n --home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)\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.\nPairing and brains can be managed entirely from the NoPeek app once the service is running.";
37
53
  /** Load config from argv + env + cwd config file + home settings. Never
38
54
  * requires pairing — an unpaired bridge waits for the app to pair it. */
39
55
  export declare function loadConfig(argv?: string[]): BridgeConfig;
package/dist/config.js CHANGED
@@ -9,7 +9,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
9
9
  import { homedir } from "node:os";
10
10
  import { join, resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
+ export function isBrainBackend(v) {
13
+ return v === "claude" || v === "hermes" || v === "echo";
14
+ }
12
15
  export const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
16
+ export const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
13
17
  export const DEFAULT_PORT = 8790;
14
18
  export const DEFAULT_BRAIN_TIMEOUT_MS = 180_000;
15
19
  export function defaultHomeDir() {
@@ -22,8 +26,11 @@ const CLI_OPTIONS = {
22
26
  "brain-cmd": { type: "string" },
23
27
  "brain-url": { type: "string" },
24
28
  "brain-map": { type: "string" },
29
+ "brain-provision-cmd": { type: "string" },
25
30
  "brain-timeout-ms": { type: "string" },
26
31
  "decline-message": { type: "string" },
32
+ "app-url": { type: "string" },
33
+ "no-open": { type: "boolean" },
27
34
  port: { type: "string" },
28
35
  "data-dir": { type: "string" },
29
36
  home: { type: "string" },
@@ -38,8 +45,10 @@ const FLAG_TO_KEY = {
38
45
  "brain-cmd": "BRAIN_CMD",
39
46
  "brain-url": "BRAIN_URL",
40
47
  "brain-map": "BRAIN_MAP",
48
+ "brain-provision-cmd": "BRAIN_PROVISION_CMD",
41
49
  "brain-timeout-ms": "BRAIN_TIMEOUT_MS",
42
50
  "decline-message": "NOPEEK_DECLINE_MESSAGE",
51
+ "app-url": "NOPEEK_APP_URL",
43
52
  port: "NOPEEK_BRIDGE_PORT",
44
53
  "data-dir": "NOPEEK_BRIDGE_DATA_DIR",
45
54
  home: "NOPEEK_BRIDGE_HOME",
@@ -62,7 +71,13 @@ Options:
62
71
  --api-url <url> API base, default ${DEFAULT_API_URL} (env NOPEEK_API_URL)
63
72
  --brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)
64
73
  --brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)
65
- --brain-map <json> Per-bot overrides {"<handle>":{"cmd":"…"}|{"url":"…"}} (env BRAIN_MAP)
74
+ --brain-map <json> Per-bot overrides {"<handle>":{"cmd":"…"}|{"url":"…"}|
75
+ {"backend":"claude"|"hermes"|"echo"}} (env BRAIN_MAP)
76
+ --brain-provision-cmd <cmd> Run once per new bot; stdout becomes its brain command
77
+ (env BRAIN_PROVISION_CMD — e.g. a script that creates a
78
+ fresh Hermes profile with its own soul + memory)
79
+ --app-url <url> App opened after install (env NOPEEK_APP_URL)
80
+ --no-open install: don't open the app in the browser
66
81
  --brain-timeout-ms <ms> Brain timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
67
82
  --port <port> Local control API port, default ${DEFAULT_PORT} (env NOPEEK_BRIDGE_PORT)
68
83
  --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)
@@ -110,13 +125,18 @@ function parseBrainMap(raw) {
110
125
  if (typeof spec !== "object" || spec === null) {
111
126
  throw new Error(`BRAIN_MAP["${handle}"] must be {"cmd":"…"} or {"url":"…"}`);
112
127
  }
113
- const { cmd, url } = spec;
114
- if (typeof cmd !== "string" && typeof url !== "string") {
115
- throw new Error(`BRAIN_MAP["${handle}"] needs a "cmd" or "url" string`);
128
+ const { cmd, url, backend, echo } = spec;
129
+ if (backend !== undefined && !isBrainBackend(backend)) {
130
+ throw new Error(`BRAIN_MAP["${handle}"].backend must be "claude", "hermes" or "echo"`);
131
+ }
132
+ if (typeof cmd !== "string" && typeof url !== "string" && !isBrainBackend(backend) && echo !== true) {
133
+ throw new Error(`BRAIN_MAP["${handle}"] needs a "cmd", "url" or "backend" ("claude"|"hermes"|"echo")`);
116
134
  }
117
135
  out[handle.replace(/^@/, "")] = {
118
136
  ...(typeof cmd === "string" ? { cmd } : {}),
119
137
  ...(typeof url === "string" ? { url } : {}),
138
+ ...(isBrainBackend(backend) ? { backend } : {}),
139
+ ...(echo === true ? { echo: true } : {}),
120
140
  };
121
141
  }
122
142
  return out;
@@ -179,11 +199,13 @@ export function loadConfig(argv = process.argv.slice(2)) {
179
199
  brainCmd: get("brain-cmd") ?? null,
180
200
  brainUrl: get("brain-url") ?? null,
181
201
  brainMap: parseBrainMap(get("brain-map")),
202
+ brainProvisionCmd: get("brain-provision-cmd") ?? null,
182
203
  brainTimeoutMs,
183
204
  port,
184
205
  dataDir,
185
206
  homeDir,
186
207
  declineMessage: get("decline-message") ?? null,
208
+ appUrl: get("app-url") ?? DEFAULT_APP_URL,
187
209
  };
188
210
  }
189
211
  /**
@@ -200,6 +222,7 @@ export function saveSettings(cfg) {
200
222
  ...(cfg.brainCmd ? { BRAIN_CMD: cfg.brainCmd } : {}),
201
223
  ...(cfg.brainUrl ? { BRAIN_URL: cfg.brainUrl } : {}),
202
224
  ...(Object.keys(cfg.brainMap).length ? { BRAIN_MAP: JSON.stringify(cfg.brainMap) } : {}),
225
+ ...(cfg.brainProvisionCmd ? { BRAIN_PROVISION_CMD: cfg.brainProvisionCmd } : {}),
203
226
  ...(cfg.declineMessage ? { NOPEEK_DECLINE_MESSAGE: cfg.declineMessage } : {}),
204
227
  };
205
228
  const path = settingsPath(cfg.homeDir);
package/dist/service.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
- export declare function installService(cfg: BridgeConfig): Promise<void>;
2
+ export declare function installService(cfg: BridgeConfig, noOpen?: boolean): Promise<void>;
3
3
  export declare function uninstallService(): void;
4
4
  export declare function printStatus(port: number): Promise<void>;
package/dist/service.js CHANGED
@@ -41,7 +41,7 @@ function launchctl(args, ignoreFailure = false) {
41
41
  throw err;
42
42
  }
43
43
  }
44
- export async function installService(cfg) {
44
+ export async function installService(cfg, noOpen = false) {
45
45
  if (process.platform !== "darwin" && process.platform !== "linux") {
46
46
  throw new Error(`automatic service install supports macOS and Linux. On this platform, run the bridge with any process manager:\n nopeek-agent-bridge run`);
47
47
  }
@@ -122,9 +122,21 @@ WantedBy=default.target
122
122
  }
123
123
  console.log(`
124
124
  Done. Next step — in the NoPeek app on THIS computer:
125
- Contacts -> My Bots -> Connect this computer
125
+ Contacts -> My Bots -> "Run your bots on this computer" -> Connect
126
126
  Pairing, choosing your agent (Hermes, …) and everything else happens in the app.
127
127
  Logs: ${logFile}`);
128
+ // Take the user straight to the app: sign in once, tap Connect, done.
129
+ // (Sessions persist, so next time this is automatic.)
130
+ if (!noOpen) {
131
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
132
+ try {
133
+ execFileSync(opener, [cfg.appUrl], { stdio: "ignore" });
134
+ console.log(`[install] opened ${cfg.appUrl}`);
135
+ }
136
+ catch {
137
+ console.log(`[install] open ${cfg.appUrl} in your browser to finish setup`);
138
+ }
139
+ }
128
140
  }
129
141
  export function uninstallService() {
130
142
  if (process.platform === "darwin") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",