@nopeek/agent-bridge 0.4.2 → 0.5.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.
@@ -8,6 +8,8 @@ export declare function soulPath(homeDir: string, handle: string): string;
8
8
  * Returns the soul path.
9
9
  */
10
10
  export declare function provisionSoul(handle: string, homeDir: string): string;
11
+ /** Resolve a binary: $<ENVVAR> > PATH (command -v) > common install dirs. */
12
+ export declare function resolveBin(name: string, envVar: string): string | null;
11
13
  /**
12
14
  * Deterministic per-(bot, channel) session UUID so each chat is one running
13
15
  * Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
@@ -23,6 +25,7 @@ export declare function sessionUuid(handle: string, channelId: string): string;
23
25
  * host powers from wherever the bridge happens to run.
24
26
  */
25
27
  export declare function claudeBrain(cfg: BridgeConfig): Brain;
28
+ export declare function hermesHome(): string;
26
29
  /**
27
30
  * Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
28
31
  * memories/, inheriting the main install's model config and provider auth
package/dist/backends.js CHANGED
@@ -62,7 +62,7 @@ export function provisionSoul(handle, homeDir) {
62
62
  }
63
63
  // ----------------------------------------------------------------- helpers ----
64
64
  /** Resolve a binary: $<ENVVAR> > PATH (command -v) > common install dirs. */
65
- function resolveBin(name, envVar) {
65
+ export function resolveBin(name, envVar) {
66
66
  const fromEnv = process.env[envVar];
67
67
  if (fromEnv)
68
68
  return fromEnv;
@@ -232,7 +232,7 @@ export function claudeBrain(cfg) {
232
232
  };
233
233
  }
234
234
  // ----------------------------------------------------------------- hermes ----
235
- function hermesHome() {
235
+ export function hermesHome() {
236
236
  return process.env.HERMES_HOME || "/Volumes/x10drive/hermes";
237
237
  }
238
238
  /** Hermes profile-local soul (mirrors tools/hermes/SOUL-template.md). */
package/dist/bot.d.ts CHANGED
@@ -1,10 +1,12 @@
1
- import type { BridgeConfig } from "./config.js";
1
+ import type { BridgeConfig, BrainBackend } from "./config.js";
2
2
  export interface BotInfo {
3
3
  userId: string;
4
4
  handle: string;
5
5
  nickname?: string;
6
6
  ownerType?: string;
7
7
  ownerId?: string;
8
+ /** Server-mediated backend from runtime/bots or adopt_bot (optional). */
9
+ backend?: BrainBackend;
8
10
  }
9
11
  export declare class BotRunner {
10
12
  readonly info: BotInfo;
@@ -23,6 +25,9 @@ export declare class BotRunner {
23
25
  private log;
24
26
  private logErr;
25
27
  constructor(info: BotInfo, cfg: BridgeConfig);
28
+ /** Re-read the effective brain (e.g. after a live server backend change) so
29
+ * status reflects it immediately. The next message re-resolves regardless. */
30
+ refreshBrainKind(): void;
26
31
  /** Fire-and-forget: runs the connect loop in the background, isolated. */
27
32
  start(): void;
28
33
  stop(): void;
package/dist/bot.js CHANGED
@@ -38,6 +38,12 @@ export class BotRunner {
38
38
  this.log = (m) => console.log(`${tag} ${m}`);
39
39
  this.logErr = (m) => console.error(`${tag} ${m}`);
40
40
  }
41
+ /** Re-read the effective brain (e.g. after a live server backend change) so
42
+ * status reflects it immediately. The next message re-resolves regardless. */
43
+ refreshBrainKind() {
44
+ this.brainKind = resolveBrain(this.cfg, this.info.handle).kind;
45
+ this.log(`brain re-resolved live: ${this.brainKind}`);
46
+ }
41
47
  /** Fire-and-forget: runs the connect loop in the background, isolated. */
42
48
  start() {
43
49
  void this.run().catch((err) => {
package/dist/brain.d.ts CHANGED
@@ -19,8 +19,12 @@ export interface ResolvedBrain {
19
19
  kind: string;
20
20
  }
21
21
  /**
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.
22
+ * Pick the brain for one bot. Precedence:
23
+ * 1. LOCAL per-bot override (BRAIN_MAP["<handle>"], set via the 127.0.0.1 API
24
+ * on this computer): cmd > url > backend (claude/hermes/echo) > echo. This
25
+ * ALWAYS wins — a same-computer power user's choice is never overridden.
26
+ * 2. SERVER backend (cfg.serverBackends["<handle>"], chosen from the phone and
27
+ * pushed down the control socket) — only when there's no local override.
28
+ * 3. Globals: BRAIN_CMD > BRAIN_URL > echo.
25
29
  */
26
30
  export declare function resolveBrain(cfg: BridgeConfig, handle: string): ResolvedBrain;
package/dist/brain.js CHANGED
@@ -128,12 +128,17 @@ function urlBrain(url, timeoutMs) {
128
128
  /** Zero-config smoke test. */
129
129
  const echoBrain = async (text) => `You said: ${text}`;
130
130
  /**
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.
131
+ * Pick the brain for one bot. Precedence:
132
+ * 1. LOCAL per-bot override (BRAIN_MAP["<handle>"], set via the 127.0.0.1 API
133
+ * on this computer): cmd > url > backend (claude/hermes/echo) > echo. This
134
+ * ALWAYS wins — a same-computer power user's choice is never overridden.
135
+ * 2. SERVER backend (cfg.serverBackends["<handle>"], chosen from the phone and
136
+ * pushed down the control socket) — only when there's no local override.
137
+ * 3. Globals: BRAIN_CMD > BRAIN_URL > echo.
134
138
  */
135
139
  export function resolveBrain(cfg, handle) {
136
- const override = cfg.brainMap[handle.replace(/^@/, "")];
140
+ const h = handle.replace(/^@/, "");
141
+ const override = cfg.brainMap[h];
137
142
  if (override?.cmd)
138
143
  return { brain: cmdBrain(override.cmd, cfg.brainTimeoutMs), kind: "cmd (per-bot)" };
139
144
  if (override?.url)
@@ -144,6 +149,14 @@ export function resolveBrain(cfg, handle) {
144
149
  return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
145
150
  if (override?.backend === "echo" || override?.echo)
146
151
  return { brain: echoBrain, kind: "echo (per-bot)" };
152
+ // No local override — honor the server-mediated backend if one was pushed.
153
+ const server = cfg.serverBackends[h];
154
+ if (server === "claude")
155
+ return { brain: claudeBrain(cfg), kind: "claude (server)" };
156
+ if (server === "hermes")
157
+ return { brain: hermesBrain(cfg), kind: "hermes (server)" };
158
+ if (server === "echo")
159
+ return { brain: echoBrain, kind: "echo (server)" };
147
160
  if (cfg.brainCmd)
148
161
  return { brain: cmdBrain(cfg.brainCmd, cfg.brainTimeoutMs), kind: "cmd" };
149
162
  if (cfg.brainUrl)
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.4.2";
2
+ export declare const VERSION = "0.5.0";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
@@ -25,6 +25,7 @@ export declare class BridgeApp {
25
25
  readonly startedAt: number;
26
26
  private bots;
27
27
  private control;
28
+ private capabilitiesTimer;
28
29
  private stopped;
29
30
  constructor(cfg: BridgeConfig);
30
31
  get paired(): boolean;
@@ -43,6 +44,15 @@ export declare class BridgeApp {
43
44
  unpair(): void;
44
45
  /** Apply a brain change live (next message uses it) and persist it. */
45
46
  setBrains(patch: BrainsPatch): void;
47
+ /**
48
+ * Record a SERVER-provided backend for a handle and make it take effect.
49
+ * Kept SEPARATE from brainMap (which holds local 127.0.0.1 overrides): a
50
+ * local override always wins in resolveBrain, so the server backend is stored
51
+ * either way but is inert while a local override exists. When it IS the
52
+ * effective brain (no local override), provision the soul/profile up front so
53
+ * the very first message doesn't wait on it. Persisted for reconnects.
54
+ */
55
+ private applyServerBackend;
46
56
  statusMinimal(): Record<string, unknown>;
47
57
  statusFull(): Record<string, unknown>;
48
58
  private startBot;
package/dist/bridge.js CHANGED
@@ -12,8 +12,11 @@ import { BotRunner } from "./bot.js";
12
12
  import { ControlSocket } from "./control.js";
13
13
  import { resolveBrain } from "./brain.js";
14
14
  import { provisionSoul } from "./backends.js";
15
+ import { reportCapabilities } from "./capabilities.js";
15
16
  import { isBrainBackend } from "./config.js";
16
- export const VERSION = "0.4.2";
17
+ export const VERSION = "0.5.0";
18
+ /** How often to re-probe + report brain availability to the server. */
19
+ const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
17
20
  export class PairError extends Error {
18
21
  code;
19
22
  constructor(code, message) {
@@ -26,6 +29,7 @@ export class BridgeApp {
26
29
  startedAt = Date.now();
27
30
  bots = new Map(); // botUserId -> runner
28
31
  control = null;
32
+ capabilitiesTimer = null;
29
33
  stopped = false;
30
34
  constructor(cfg) {
31
35
  this.cfg = cfg;
@@ -140,6 +144,37 @@ export class BridgeApp {
140
144
  saveSettings(this.cfg);
141
145
  console.log(`[bridge] brains updated: default=${this.cfg.brainCmd ? "cmd" : this.cfg.brainUrl ? "url" : "echo"}, ${Object.keys(this.cfg.brainMap).length} per-bot override(s)`);
142
146
  }
147
+ /**
148
+ * Record a SERVER-provided backend for a handle and make it take effect.
149
+ * Kept SEPARATE from brainMap (which holds local 127.0.0.1 overrides): a
150
+ * local override always wins in resolveBrain, so the server backend is stored
151
+ * either way but is inert while a local override exists. When it IS the
152
+ * effective brain (no local override), provision the soul/profile up front so
153
+ * the very first message doesn't wait on it. Persisted for reconnects.
154
+ */
155
+ applyServerBackend(rawHandle, backend) {
156
+ const handle = rawHandle.replace(/^@/, "");
157
+ const changed = this.cfg.serverBackends[handle] !== backend;
158
+ this.cfg.serverBackends[handle] = backend;
159
+ if (changed) {
160
+ saveSettings(this.cfg);
161
+ console.log(`[bridge] server backend for @${handle} = ${backend}`);
162
+ }
163
+ const hasLocalOverride = Boolean(this.cfg.brainMap[handle]);
164
+ if (hasLocalOverride) {
165
+ // Local override wins and is untouched; the server backend stays recorded
166
+ // but does not drive this bot until the local override is cleared.
167
+ return;
168
+ }
169
+ if (backend !== "echo") {
170
+ try {
171
+ provisionSoul(handle, this.cfg.homeDir);
172
+ }
173
+ catch (err) {
174
+ console.error(`[bridge] soul provisioning for @${handle} failed: ${err.message}`);
175
+ }
176
+ }
177
+ }
143
178
  // -------------------------------------------------------------- status ----
144
179
  statusMinimal() {
145
180
  return {
@@ -164,6 +199,7 @@ export class BridgeApp {
164
199
  ? { url: this.cfg.brainUrl }
165
200
  : { echo: true },
166
201
  map: this.cfg.brainMap,
202
+ serverBackends: this.cfg.serverBackends,
167
203
  provisionCmd: this.cfg.brainProvisionCmd,
168
204
  },
169
205
  bots: [...this.bots.values()].map((b) => ({
@@ -247,8 +283,13 @@ export class BridgeApp {
247
283
  }
248
284
  const { bots: list } = (await res.json());
249
285
  console.log(`[bridge] runtime owns ${list.length} bot(s): ${list.map((b) => `@${b.handle}`).join(", ") || "(none yet)"}`);
250
- for (const info of list)
286
+ for (const info of list) {
287
+ // Record any server-mediated backend BEFORE starting the runner so its
288
+ // first brain resolution already sees it (local overrides still win).
289
+ if (info.backend)
290
+ this.applyServerBackend(info.handle, info.backend);
251
291
  this.startBot(info);
292
+ }
252
293
  }
253
294
  startCore() {
254
295
  if (this.stopped || this.control)
@@ -260,9 +301,14 @@ export class BridgeApp {
260
301
  void this.syncBots().catch((err) => {
261
302
  console.error(`[bridge] bot sync failed: ${err.message}`);
262
303
  });
304
+ // Report which brains this computer can run so the phone's picker is
305
+ // current (initial connect + every reconnect). Non-fatal on failure.
306
+ void reportCapabilities(this.cfg);
263
307
  },
264
308
  onAdoptBot: (f) => {
265
- this.startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId });
309
+ if (f.backend)
310
+ this.applyServerBackend(f.handle, f.backend);
311
+ this.startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId, backend: f.backend });
266
312
  },
267
313
  onGrantChanged: (f) => {
268
314
  // Access changed for a bot — re-pull its allow list so enforcement is
@@ -271,6 +317,20 @@ export class BridgeApp {
271
317
  if (runner)
272
318
  void runner.refreshAccess();
273
319
  },
320
+ onBotConfig: (f) => {
321
+ // The owner changed this bot's brain from the phone. Record the server
322
+ // backend and re-resolve live — the runner resolves per message off the
323
+ // shared cfg, so the NEXT message already uses it; we also refresh the
324
+ // cached kind so status reflects it immediately. A running bot is keyed
325
+ // by userId; map it to a handle via its runner.
326
+ const runner = this.bots.get(f.botUserId);
327
+ if (!runner) {
328
+ console.log(`[bridge] bot_config_changed for ${f.botUserId} not running yet — applied on next sync/adopt`);
329
+ return;
330
+ }
331
+ this.applyServerBackend(runner.info.handle, f.backend);
332
+ runner.refreshBrainKind();
333
+ },
274
334
  });
275
335
  // Bots first (so a control-socket hiccup doesn't delay serving), then the
276
336
  // control socket, whose auth.ok triggers a redundant-but-safe re-sync.
@@ -278,8 +338,20 @@ export class BridgeApp {
278
338
  console.error(`[bridge] initial bot sync failed (will retry on control auth): ${err.message}`);
279
339
  });
280
340
  this.control.start();
341
+ // Re-probe + report brain availability every ~5 min so a login/logout on
342
+ // this computer surfaces in the phone's picker without a reconnect. The
343
+ // onAuthed handler covers initial + reconnect reports; this covers drift.
344
+ // unref so a running interval never keeps the process alive on its own.
345
+ this.capabilitiesTimer = setInterval(() => {
346
+ void reportCapabilities(this.cfg);
347
+ }, CAPABILITIES_INTERVAL_MS);
348
+ this.capabilitiesTimer.unref?.();
281
349
  }
282
350
  stopCore() {
351
+ if (this.capabilitiesTimer) {
352
+ clearInterval(this.capabilitiesTimer);
353
+ this.capabilitiesTimer = null;
354
+ }
283
355
  this.control?.stop();
284
356
  this.control = null;
285
357
  for (const b of this.bots.values())
@@ -0,0 +1,23 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ /** One backend's readiness. `reason` is a short human string when unavailable. */
3
+ export interface BackendCapability {
4
+ available: boolean;
5
+ reason?: string;
6
+ }
7
+ export interface Capabilities {
8
+ claude: BackendCapability;
9
+ hermes: BackendCapability;
10
+ }
11
+ /**
12
+ * Probe which native brains are ready on this computer. Best-effort and FREE:
13
+ * binary resolution + credential file/keychain/env presence only, never a paid
14
+ * model call. Echo is always available and isn't probed.
15
+ */
16
+ export declare function probeCapabilities(_cfg: BridgeConfig): Promise<Capabilities>;
17
+ /**
18
+ * Probe, then PUT the result to the server so the phone's picker reflects it.
19
+ * Authed with the runtime pairing token — the SAME Bearer auth the bridge uses
20
+ * for GET /runtime/bots (see bridge.ts syncBots). Non-fatal: a failed report is
21
+ * logged and retried on the next tick (control reconnect or the 5-min interval).
22
+ */
23
+ export declare function reportCapabilities(cfg: BridgeConfig): Promise<void>;
@@ -0,0 +1,153 @@
1
+ // Brain availability probe — the phone's "Create a bot" picker only offers the
2
+ // brains this computer can actually run. We probe locally (is the CLI installed?
3
+ // is it logged in?) and report up to the server over REST with the runtime
4
+ // pairing token; the server aggregates it into the per-app brain-options picker.
5
+ //
6
+ // Hard rule: the probe is FREE. It never makes a paid model call. It only looks
7
+ // at what's on disk / in env / in the keychain — binary resolution, credential
8
+ // files, env vars. Echo is always available (local, no login) so it isn't probed.
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { homedir, platform } from "node:os";
12
+ import { join } from "node:path";
13
+ import { resolveBin, hermesHome } from "./backends.js";
14
+ // Reasons are stable strings the app renders verbatim ("Claude — not logged in").
15
+ const NOT_INSTALLED = "not installed";
16
+ const NOT_LOGGED_IN = "not logged in";
17
+ // -------------------------------------------------------------- claude ----
18
+ /**
19
+ * Is Claude Code logged in on this host? Any ONE of these is enough — we never
20
+ * make a request, just check for the presence of a credential:
21
+ * - ~/.claude/.credentials.json exists (the file-based OAuth store)
22
+ * - $CLAUDE_CODE_OAUTH_TOKEN or $ANTHROPIC_API_KEY is set
23
+ * - (macOS) the Claude Code keychain item exists (best-effort; errors swallowed)
24
+ */
25
+ function claudeHasCredentials() {
26
+ if (existsSync(join(homedir(), ".claude", ".credentials.json")))
27
+ return true;
28
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY)
29
+ return true;
30
+ if (platform() === "darwin" && macKeychainHas("Claude Code-credentials"))
31
+ return true;
32
+ return false;
33
+ }
34
+ /** Best-effort macOS keychain lookup by service name. Never throws. */
35
+ function macKeychainHas(service) {
36
+ try {
37
+ const r = spawnSync("security", ["find-generic-password", "-s", service], {
38
+ encoding: "utf8",
39
+ timeout: 5_000,
40
+ });
41
+ return r.status === 0;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function probeClaude() {
48
+ // Binary first: no `claude` on PATH (or $CLAUDE_BIN) → nothing to log into.
49
+ const bin = resolveBin("claude", "CLAUDE_BIN");
50
+ if (!bin)
51
+ return { available: false, reason: NOT_INSTALLED };
52
+ if (!claudeHasCredentials())
53
+ return { available: false, reason: NOT_LOGGED_IN };
54
+ return { available: true };
55
+ }
56
+ // -------------------------------------------------------------- hermes ----
57
+ /**
58
+ * Read the base Hermes auth.json (the credential the per-bot souls' auth is
59
+ * symlinked to — see provisionHermesProfile) and decide if it's still valid.
60
+ * A revoked xAI OAuth login leaves the file in place but with empty tokens and
61
+ * a `last_auth_error` / `relogin_required` marker — that's "not logged in", not
62
+ * "not installed". FREE: file read only, no refresh attempt.
63
+ *
64
+ * Returns: "valid" | "revoked" | "missing".
65
+ */
66
+ function hermesCredentialState() {
67
+ const authPath = join(hermesHome(), "auth.json");
68
+ if (!existsSync(authPath))
69
+ return "missing";
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(readFileSync(authPath, "utf8"));
73
+ }
74
+ catch {
75
+ return "revoked"; // present but unparseable → treat as invalid, not missing
76
+ }
77
+ const providers = parsed?.providers;
78
+ if (!providers || typeof providers !== "object")
79
+ return "revoked";
80
+ // Valid if ANY provider has live tokens and isn't flagged for re-login.
81
+ for (const p of Object.values(providers)) {
82
+ const prov = p;
83
+ if (prov?.relogin_required === true)
84
+ continue;
85
+ const tokens = prov?.tokens;
86
+ const hasTokens = tokens != null && typeof tokens === "object" && Object.keys(tokens).length > 0;
87
+ if (hasTokens)
88
+ return "valid";
89
+ }
90
+ return "revoked";
91
+ }
92
+ function probeHermes() {
93
+ // Binary first: no `hermes` on PATH (or $HERMES_BIN) → not installed.
94
+ const bin = resolveBin("hermes", "HERMES_BIN");
95
+ if (!bin)
96
+ return { available: false, reason: NOT_INSTALLED };
97
+ // The base profile the per-bot souls are cloned from = $HERMES_HOME/config.yaml.
98
+ if (!existsSync(join(hermesHome(), "config.yaml")))
99
+ return { available: false, reason: NOT_INSTALLED };
100
+ const cred = hermesCredentialState();
101
+ if (cred === "valid")
102
+ return { available: true };
103
+ // Base profile is present but its credential is missing or revoked → login issue.
104
+ return { available: false, reason: NOT_LOGGED_IN };
105
+ }
106
+ // ------------------------------------------------------------- public ----
107
+ /**
108
+ * Probe which native brains are ready on this computer. Best-effort and FREE:
109
+ * binary resolution + credential file/keychain/env presence only, never a paid
110
+ * model call. Echo is always available and isn't probed.
111
+ */
112
+ export async function probeCapabilities(_cfg) {
113
+ return { claude: probeClaude(), hermes: probeHermes() };
114
+ }
115
+ function summarize(c) {
116
+ return c.available ? "ok" : (c.reason ?? "unavailable");
117
+ }
118
+ /**
119
+ * Probe, then PUT the result to the server so the phone's picker reflects it.
120
+ * Authed with the runtime pairing token — the SAME Bearer auth the bridge uses
121
+ * for GET /runtime/bots (see bridge.ts syncBots). Non-fatal: a failed report is
122
+ * logged and retried on the next tick (control reconnect or the 5-min interval).
123
+ */
124
+ export async function reportCapabilities(cfg) {
125
+ if (!cfg.pairingCode)
126
+ return; // unpaired — nothing to report to
127
+ let caps;
128
+ try {
129
+ caps = await probeCapabilities(cfg);
130
+ }
131
+ catch (err) {
132
+ console.error(`[caps] probe failed: ${err.message}`);
133
+ return;
134
+ }
135
+ console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)}`);
136
+ try {
137
+ const res = await fetch(`${cfg.apiUrl}/v1/runtime/capabilities`, {
138
+ method: "PUT",
139
+ headers: {
140
+ authorization: `Bearer ${cfg.pairingCode}`,
141
+ "content-type": "application/json",
142
+ },
143
+ body: JSON.stringify({ backends: { claude: caps.claude, hermes: caps.hermes } }),
144
+ signal: AbortSignal.timeout(15_000),
145
+ });
146
+ if (!res.ok) {
147
+ console.error(`[caps] report failed: PUT /v1/runtime/capabilities HTTP ${res.status}`);
148
+ }
149
+ }
150
+ catch (err) {
151
+ console.error(`[caps] report failed: ${err.message}`);
152
+ }
153
+ }
package/dist/cli.js CHANGED
File without changes
package/dist/config.d.ts CHANGED
@@ -22,8 +22,18 @@ export interface BridgeConfig {
22
22
  brainCmd: string | null;
23
23
  /** Global brain: webhook POSTed {text, botHandle, botUserId, channelId, senderUserId}. */
24
24
  brainUrl: string | null;
25
- /** Per-handle overrides: { "<handle>": {"cmd": "…"} | {"url": "…"} }. */
25
+ /** Per-handle overrides: { "<handle>": {"cmd": "…"} | {"url": "…"} }.
26
+ * These are LOCAL overrides set via the 127.0.0.1 API on this computer and
27
+ * ALWAYS win over a server-provided backend. */
26
28
  brainMap: Record<string, BrainSpec>;
29
+ /**
30
+ * Server-mediated backends, keyed by bot handle. The owner picks a bot's
31
+ * brain (claude|hermes|echo) FROM THE PHONE; the server pushes it down the
32
+ * control socket and it lands here. Consulted by resolveBrain ONLY when the
33
+ * handle has no local `brainMap` override — a local override always wins.
34
+ * Persisted so a reconnect keeps the choice even before the next sync.
35
+ */
36
+ serverBackends: Record<string, BrainBackend>;
27
37
  /**
28
38
  * Auto-provisioner: run ONCE for each adopted bot that has no BRAIN_MAP
29
39
  * entry (env: NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID). Its trimmed stdout
package/dist/config.js CHANGED
@@ -141,6 +141,27 @@ function parseBrainMap(raw) {
141
141
  }
142
142
  return out;
143
143
  }
144
+ /** Parse the persisted server-backend map ({"<handle>":"claude"|…}). Lenient:
145
+ * a corrupt/partial value never crashes the bridge — bad entries are dropped. */
146
+ function parseServerBackends(raw) {
147
+ if (!raw)
148
+ return {};
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(raw);
152
+ }
153
+ catch {
154
+ return {};
155
+ }
156
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
157
+ return {};
158
+ const out = {};
159
+ for (const [handle, backend] of Object.entries(parsed)) {
160
+ if (isBrainBackend(backend))
161
+ out[handle.replace(/^@/, "")] = backend;
162
+ }
163
+ return out;
164
+ }
144
165
  function settingsPath(homeDir) {
145
166
  return join(homeDir, "settings.json");
146
167
  }
@@ -199,6 +220,8 @@ export function loadConfig(argv = process.argv.slice(2)) {
199
220
  brainCmd: get("brain-cmd") ?? null,
200
221
  brainUrl: get("brain-url") ?? null,
201
222
  brainMap: parseBrainMap(get("brain-map")),
223
+ // Server-provided (no CLI flag): env/config/persisted-settings only.
224
+ serverBackends: parseServerBackends(process.env.NOPEEK_SERVER_BACKENDS ?? file.NOPEEK_SERVER_BACKENDS ?? saved.NOPEEK_SERVER_BACKENDS),
202
225
  brainProvisionCmd: get("brain-provision-cmd") ?? null,
203
226
  brainTimeoutMs,
204
227
  port,
@@ -222,6 +245,9 @@ export function saveSettings(cfg) {
222
245
  ...(cfg.brainCmd ? { BRAIN_CMD: cfg.brainCmd } : {}),
223
246
  ...(cfg.brainUrl ? { BRAIN_URL: cfg.brainUrl } : {}),
224
247
  ...(Object.keys(cfg.brainMap).length ? { BRAIN_MAP: JSON.stringify(cfg.brainMap) } : {}),
248
+ ...(Object.keys(cfg.serverBackends).length
249
+ ? { NOPEEK_SERVER_BACKENDS: JSON.stringify(cfg.serverBackends) }
250
+ : {}),
225
251
  ...(cfg.brainProvisionCmd ? { BRAIN_PROVISION_CMD: cfg.brainProvisionCmd } : {}),
226
252
  ...(cfg.declineMessage ? { NOPEEK_DECLINE_MESSAGE: cfg.declineMessage } : {}),
227
253
  };
package/dist/control.d.ts CHANGED
@@ -1,10 +1,17 @@
1
- import type { BridgeConfig } from "./config.js";
1
+ import type { BridgeConfig, BrainBackend } from "./config.js";
2
2
  export interface AdoptBotFrame {
3
3
  type: "adopt_bot";
4
4
  botUserId: string;
5
5
  appId: string;
6
6
  ownerUserId: string;
7
7
  handle: string;
8
+ /** Server-mediated backend picked from the phone (optional). */
9
+ backend?: BrainBackend;
10
+ }
11
+ export interface BotConfigChangedFrame {
12
+ type: "bot_config_changed";
13
+ botUserId: string;
14
+ backend: BrainBackend;
8
15
  }
9
16
  export interface BotGrantChangedFrame {
10
17
  type: "bot_grant_changed";
@@ -18,6 +25,8 @@ export interface ControlHandlers {
18
25
  onAuthed: (runtimeId: string) => void;
19
26
  onAdoptBot: (frame: AdoptBotFrame) => void;
20
27
  onGrantChanged: (frame: BotGrantChangedFrame) => void;
28
+ /** Server pushed a new backend for a running bot — re-resolve its brain live. */
29
+ onBotConfig: (frame: BotConfigChangedFrame) => void;
21
30
  }
22
31
  export declare class ControlSocket {
23
32
  connected: boolean;
package/dist/control.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isBrainBackend } from "./config.js";
1
2
  export class ControlSocket {
2
3
  connected = false;
3
4
  runtimeId = null;
@@ -80,6 +81,16 @@ export class ControlSocket {
80
81
  this.handlers.onAdoptBot(f);
81
82
  return;
82
83
  }
84
+ case "bot_config_changed": {
85
+ const f = frame;
86
+ if (!isBrainBackend(f.backend)) {
87
+ console.error(`[control] bot_config_changed with invalid backend "${String(f.backend)}" ignored`);
88
+ return;
89
+ }
90
+ console.log(`[control] bot_config_changed bot=${f.botUserId} backend=${f.backend}`);
91
+ this.handlers.onBotConfig(f);
92
+ return;
93
+ }
83
94
  case "bot_grant_changed": {
84
95
  const f = frame;
85
96
  // TODO(grants): enforce these — for now bots answer everyone in their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -22,8 +22,15 @@
22
22
  "engines": {
23
23
  "node": ">=22"
24
24
  },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "prepack": "tsc -p tsconfig.json",
28
+ "start": "node dist/cli.js",
29
+ "dev": "tsx src/cli.ts",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit"
31
+ },
25
32
  "dependencies": {
26
- "@nopeek/chat": "0.2.1"
33
+ "@nopeek/chat": "workspace:*"
27
34
  },
28
35
  "devDependencies": {
29
36
  "@types/node": "^22.10.0",
@@ -40,11 +47,5 @@
40
47
  ],
41
48
  "publishConfig": {
42
49
  "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
  }
50
- }
51
+ }