@nopeek/agent-bridge 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backends.d.ts +3 -0
- package/dist/backends.js +2 -2
- package/dist/bot.d.ts +6 -1
- package/dist/bot.js +6 -0
- package/dist/brain.d.ts +7 -3
- package/dist/brain.js +17 -4
- package/dist/bridge.d.ts +11 -1
- package/dist/bridge.js +75 -3
- package/dist/capabilities.d.ts +23 -0
- package/dist/capabilities.js +153 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +26 -0
- package/dist/control.d.ts +10 -1
- package/dist/control.js +11 -0
- package/package.json +1 -1
package/dist/backends.d.ts
CHANGED
|
@@ -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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
|
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.
|
|
2
|
+
export declare const VERSION = "0.5.1";
|
|
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.
|
|
17
|
+
export const VERSION = "0.5.1";
|
|
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
|
-
|
|
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/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.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|