@nopeek/agent-bridge 0.7.8 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,15 @@ npx @nopeek/agent-bridge \
39
39
 
40
40
  The `npr_…` code comes from Settings → Connect your computer (bots) in the app. `install` also accepts `--pair`/`--app-id` to pre-pair the service from the terminal.
41
41
 
42
+ Hermes brains prefer the local agent API (`HERMES_API_URL`, default
43
+ `http://127.0.0.1:8642`) when `/health` is up, and fall back to `hermes chat`.
44
+ While Hermes is working, the bot posts Telegram-style tool lines a few at a
45
+ time (read file, patch, skill, memory). A quiet gap starts a new message.
46
+ The final reply is the answer plus a short recap when tools were used.
47
+ Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
48
+ `GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
49
+ and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
50
+
42
51
  ## Local control API
43
52
 
44
53
  The bridge serves a loopback-only HTTP API on `127.0.0.1:8790` — this is what the NoPeek app uses; you can script it too.
@@ -10,6 +10,12 @@ export declare function soulPath(homeDir: string, handle: string): string;
10
10
  export declare function provisionSoul(handle: string, homeDir: string): string;
11
11
  /** Resolve a binary: $<ENVVAR> > PATH (command -v) > common install dirs. */
12
12
  export declare function resolveBin(name: string, envVar: string): string | null;
13
+ /**
14
+ * Accumulated CPU seconds for a pid (`ps -o cputime=`). Used so a silent but
15
+ * working Hermes child (quiet mode + long tools) does not trip the idle kill.
16
+ * Returns null if the process is gone or `ps` fails.
17
+ */
18
+ export declare function cpuTimeSeconds(pid: number | undefined): number | null;
13
19
  /**
14
20
  * Deterministic per-(bot, channel) session UUID so each chat is one running
15
21
  * Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
@@ -33,4 +39,10 @@ export declare function hermesHome(): string;
33
39
  * stays authenticated from one login). Idempotent.
34
40
  */
35
41
  export declare function provisionHermesProfile(handle: string): string;
42
+ /**
43
+ * Hermes brain. Prefers the long-running agent API (`HERMES_API_URL`, default
44
+ * loopback :8642) when `/health` is up — that path does not shell out, so a
45
+ * CLI flag change cannot empty-reply the bot. Falls back to `hermes chat -Q`.
46
+ */
36
47
  export declare function hermesBrain(cfg: BridgeConfig): Brain;
48
+ export declare function hermesHttpOnlyBrain(cfg: BridgeConfig): Brain;
package/dist/backends.js CHANGED
@@ -16,7 +16,8 @@ import { createHash } from "node:crypto";
16
16
  import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
18
  import { basename, join } from "node:path";
19
- import { stripAnsi } from "./brain.js";
19
+ import { asHooks, stripAnsi } from "./brain.js";
20
+ import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
20
21
  const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
21
22
  // ------------------------------------------------------------------ souls ----
22
23
  /** Built-in personality template — personalized per bot by provisionSoul(). */
@@ -77,6 +78,41 @@ export function resolveBin(name, envVar) {
77
78
  }
78
79
  return null;
79
80
  }
81
+ /**
82
+ * Accumulated CPU seconds for a pid (`ps -o cputime=`). Used so a silent but
83
+ * working Hermes child (quiet mode + long tools) does not trip the idle kill.
84
+ * Returns null if the process is gone or `ps` fails.
85
+ */
86
+ export function cpuTimeSeconds(pid) {
87
+ if (pid == null || pid <= 0)
88
+ return null;
89
+ try {
90
+ const r = spawnSync("ps", ["-o", "cputime=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000 });
91
+ if (r.status !== 0)
92
+ return null;
93
+ const raw = (r.stdout || "").trim();
94
+ // Formats: "SS.ss", "MM:SS", "HH:MM:SS", "[D-]HH:MM:SS"
95
+ const daySplit = raw.split("-");
96
+ const clock = daySplit.length === 2 ? daySplit[1] : daySplit[0];
97
+ const days = daySplit.length === 2 ? Number(daySplit[0]) : 0;
98
+ const parts = clock.split(":").map(Number);
99
+ if (parts.some((n) => !Number.isFinite(n)))
100
+ return null;
101
+ let secs = days * 86400;
102
+ if (parts.length === 3)
103
+ secs += parts[0] * 3600 + parts[1] * 60 + parts[2];
104
+ else if (parts.length === 2)
105
+ secs += parts[0] * 60 + parts[1];
106
+ else if (parts.length === 1)
107
+ secs += parts[0];
108
+ else
109
+ return null;
110
+ return secs;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
80
116
  /**
81
117
  * Deterministic per-(bot, channel) session UUID so each chat is one running
82
118
  * Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
@@ -188,7 +224,8 @@ function runClaudeOnce(bin, args, text, cwd, timeoutMs, tag, onDelta) {
188
224
  * host powers from wherever the bridge happens to run.
189
225
  */
190
226
  export function claudeBrain(cfg) {
191
- return async (text, ctx, onChunk) => {
227
+ return async (text, ctx, hooks) => {
228
+ const { onChunk } = asHooks(hooks);
192
229
  const bin = resolveBin("claude", "CLAUDE_BIN");
193
230
  if (!bin) {
194
231
  console.error(`[brain:claude:@${ctx.botHandle}] claude not found on PATH`);
@@ -266,8 +303,15 @@ you reply. Your replies are chat messages — every word you print is sent verba
266
303
  ## Guardrails
267
304
  - Never expose secrets, keys, or tokens.
268
305
  - Confirm before destructive or outward-facing actions.
269
- - Report honestly if something failed, say so.
306
+ - Report honestly. If something failed, say so.
270
307
  - If a request is far outside your purpose, say so briefly and offer what you can do.
308
+
309
+ ## After tool work
310
+ When you used tools this turn (read or wrote files, ran commands, changed skills, saved memory), end with a short recap:
311
+ - What was going on (one line).
312
+ - What you did (one or two lines).
313
+ - Result (one line).
314
+ Skip the recap for simple conversation with no tools.
271
315
  `;
272
316
  /**
273
317
  * Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
@@ -349,7 +393,7 @@ function detectHermesProfileMode(bin) {
349
393
  console.log(`[hermes] profile selection mode: ${hermesProfileMode}`);
350
394
  return hermesProfileMode;
351
395
  }
352
- export function hermesBrain(cfg) {
396
+ function hermesCliBrain(cfg) {
353
397
  const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
354
398
  const bin = resolveBin("hermes", "HERMES_BIN");
355
399
  const mode = detectHermesProfileMode(bin);
@@ -418,6 +462,7 @@ export function hermesBrain(cfg) {
418
462
  if (settled)
419
463
  return;
420
464
  settled = true;
465
+ clearInterval(cpuPoll);
421
466
  if (lineBuf)
422
467
  handleLine(lineBuf);
423
468
  // The session footer lands on STDERR in -Q mode — scan there too.
@@ -443,13 +488,28 @@ export function hermesBrain(cfg) {
443
488
  const armIdleTimer = () => {
444
489
  clearTimeout(timer);
445
490
  timer = setTimeout(() => {
446
- console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output), killing`);
491
+ console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output and no CPU), killing`);
447
492
  timedOut = true;
448
493
  child.kill("SIGKILL");
449
494
  finish();
450
495
  }, cfg.brainTimeoutMs);
451
496
  };
452
497
  armIdleTimer();
498
+ // Hermes -Q is silent during long tool use. Reset the idle timer whenever
499
+ // the child's accumulated CPU time advances — that is real work, not a hang.
500
+ let lastCpu = cpuTimeSeconds(child.pid);
501
+ const cpuPoll = setInterval(() => {
502
+ if (settled || child.pid == null)
503
+ return;
504
+ const now = cpuTimeSeconds(child.pid);
505
+ if (now != null && lastCpu != null && now > lastCpu + 0.05) {
506
+ lastCpu = now;
507
+ armIdleTimer();
508
+ }
509
+ else if (now != null && lastCpu == null) {
510
+ lastCpu = now;
511
+ }
512
+ }, 10_000);
453
513
  child.stdout.on("data", (d) => {
454
514
  armIdleTimer();
455
515
  lineBuf += d.toString();
@@ -475,7 +535,8 @@ export function hermesBrain(cfg) {
475
535
  finish();
476
536
  });
477
537
  });
478
- return async (text, ctx, onChunk) => {
538
+ return async (text, ctx, hooks) => {
539
+ const { onChunk } = asHooks(hooks);
479
540
  const handle = ctx.botHandle.replace(/^@/, "");
480
541
  const tag = `[brain:hermes:@${handle}]`;
481
542
  if (!resolveBin("hermes", "HERMES_BIN")) {
@@ -546,3 +607,25 @@ export function hermesBrain(cfg) {
546
607
  return run.reply;
547
608
  };
548
609
  }
610
+ /**
611
+ * Hermes brain. Prefers the long-running agent API (`HERMES_API_URL`, default
612
+ * loopback :8642) when `/health` is up — that path does not shell out, so a
613
+ * CLI flag change cannot empty-reply the bot. Falls back to `hermes chat -Q`.
614
+ */
615
+ export function hermesBrain(cfg) {
616
+ const cli = hermesCliBrain(cfg);
617
+ const http = hermesHttpBrain(cfg);
618
+ return async (text, ctx, hooks) => {
619
+ const health = await probeHermesApi(cfg);
620
+ if (health.ok) {
621
+ const reply = await http(text, ctx, hooks);
622
+ if (reply)
623
+ return reply;
624
+ console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
625
+ }
626
+ return cli(text, ctx, hooks);
627
+ };
628
+ }
629
+ export function hermesHttpOnlyBrain(cfg) {
630
+ return hermesHttpBrain(cfg);
631
+ }
package/dist/bot.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { BridgeConfig, Pairing, BrainBackend } from "./config.js";
2
2
  import { type ChannelMode } from "./control.js";
3
+ import { type BrainTurn } from "./last-turn.js";
3
4
  export interface BotInfo {
4
5
  userId: string;
5
6
  handle: string;
@@ -46,6 +47,8 @@ export declare class BotRunner {
46
47
  msSinceHealthy(): number;
47
48
  /** When the watchdog last force-restarted this runner (null = never). */
48
49
  get lastForcedRestartAt(): number | null;
50
+ /** Most recent brain turn for this bot (shared last-turn store). */
51
+ lastTurn(): BrainTurn | null;
49
52
  /**
50
53
  * Watchdog hard restart: tear the SDK client down completely and rebuild it
51
54
  * from scratch (fresh session mint + NoPeek.connect) — the proven-working
package/dist/bot.js CHANGED
@@ -5,7 +5,9 @@
5
5
  import { NoPeek } from "@nopeek/chat";
6
6
  import { isChannelMode } from "./control.js";
7
7
  import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
8
+ import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
8
9
  import { FileStore } from "./storage.js";
10
+ import { ToolProgressFlusher } from "./tool-progress.js";
9
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
12
  const MAX_BACKOFF_MS = 60_000;
11
13
  // Owner-membership answers are cached per channel for a short window; a
@@ -94,6 +96,10 @@ export class BotRunner {
94
96
  get lastForcedRestartAt() {
95
97
  return this.lastForcedRestart;
96
98
  }
99
+ /** Most recent brain turn for this bot (shared last-turn store). */
100
+ lastTurn() {
101
+ return lastTurnFor(this.info.handle);
102
+ }
97
103
  /**
98
104
  * Watchdog hard restart: tear the SDK client down completely and rebuild it
99
105
  * from scratch (fresh session mint + NoPeek.connect) — the proven-working
@@ -546,16 +552,20 @@ export class BotRunner {
546
552
  // append onto the open promise keeps them ordered and loses none.
547
553
  // (Ref object rather than a `let`: TS can't see closure assignments.)
548
554
  const streamRef = { p: null };
555
+ const stopTyping = () => {
556
+ try {
557
+ ch.typing(false);
558
+ }
559
+ catch {
560
+ /* best-effort */
561
+ }
562
+ };
549
563
  const onChunk = (delta) => {
550
564
  if (!delta)
551
565
  return;
566
+ void progress.flush();
552
567
  if (!streamRef.p) {
553
- try {
554
- ch.typing(false); // the live bubble replaces the typing indicator
555
- }
556
- catch {
557
- /* best-effort */
558
- }
568
+ stopTyping();
559
569
  streamRef.p = ch.stream();
560
570
  streamRef.p.catch((err) => {
561
571
  this.notePostFailure(m.channelId, err);
@@ -564,18 +574,33 @@ export class BotRunner {
564
574
  }
565
575
  streamRef.p.then((s) => s.append(delta)).catch(() => { });
566
576
  };
577
+ const progress = new ToolProgressFlusher(async (body) => {
578
+ stopTyping();
579
+ await ch.send({ text: body });
580
+ });
567
581
  let reply = "";
582
+ const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
568
583
  try {
569
584
  reply = await resolved.brain(text, {
570
585
  botHandle: this.info.handle,
571
586
  botUserId: this.info.userId,
572
587
  channelId: m.channelId,
573
588
  senderUserId: m.senderUserId,
574
- }, onChunk);
589
+ }, { onChunk, onTool: (ev) => progress.push(ev) });
590
+ const trimmed = reply.trim();
591
+ const timedOut = /went quiet for over \d+s/i.test(trimmed);
592
+ finishTurn(turn, {
593
+ ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
594
+ chars: trimmed.length,
595
+ timedOut,
596
+ error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
597
+ });
575
598
  }
576
599
  catch (err) {
600
+ finishTurn(turn, { ok: false, error: err.message, timedOut: false });
577
601
  // Brain blew up mid-stream: finalize the partial bubble with an honest
578
602
  // error line instead of leaving a forever-blinking cursor.
603
+ await progress.flush();
579
604
  const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
580
605
  if (stream)
581
606
  await stream.fail(FALLBACK_REPLY).catch(() => { });
@@ -589,6 +614,7 @@ export class BotRunner {
589
614
  /* best-effort */
590
615
  }
591
616
  }
617
+ await progress.flush();
592
618
  const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
593
619
  if (stream) {
594
620
  await stream.done(reply.trim() || undefined);
package/dist/brain.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
+ import type { ToolProgressEvent } from "./tool-progress.js";
2
3
  export interface BrainContext {
3
4
  botHandle: string;
4
5
  botUserId: string;
@@ -9,8 +10,14 @@ export interface BrainContext {
9
10
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
10
11
  * text arrives (plain text, ANSI-stripped) and STILL returns the full reply —
11
12
  * the returned string is authoritative for the final message body.
13
+ * `onTool` is optional: Hermes HTTP uses it for live tool-progress lines.
12
14
  */
13
- export type Brain = (text: string, ctx: BrainContext, onChunk?: (delta: string) => void) => Promise<string>;
15
+ export type BrainHooks = {
16
+ onChunk?: (delta: string) => void;
17
+ onTool?: (ev: ToolProgressEvent) => void;
18
+ };
19
+ export type Brain = (text: string, ctx: BrainContext, hooks?: BrainHooks | ((delta: string) => void)) => Promise<string>;
20
+ export declare function asHooks(hooks?: BrainHooks | ((delta: string) => void)): BrainHooks;
14
21
  export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
15
22
  export declare function stripAnsi(s: string): string;
16
23
  export interface ResolvedBrain {
package/dist/brain.js CHANGED
@@ -6,7 +6,14 @@
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
+ import { claudeBrain, hermesBrain, hermesHttpOnlyBrain } from "./backends.js";
10
+ export function asHooks(hooks) {
11
+ if (!hooks)
12
+ return {};
13
+ if (typeof hooks === "function")
14
+ return { onChunk: hooks };
15
+ return hooks;
16
+ }
10
17
  export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
11
18
  // ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
12
19
  // Hermes color their stdout; the chat must receive plain text.
@@ -23,7 +30,8 @@ export function stripAnsi(s) {
23
30
  * NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID, NOPEEK_CHANNEL_ID, NOPEEK_SENDER_USER_ID.
24
31
  */
25
32
  function cmdBrain(cmd, timeoutMs) {
26
- return (text, ctx, onChunk) => new Promise((resolvePromise) => {
33
+ return (text, ctx, hooks) => new Promise((resolvePromise) => {
34
+ const { onChunk } = asHooks(hooks);
27
35
  const child = spawn("bash", ["-c", cmd], {
28
36
  stdio: ["pipe", "pipe", "pipe"],
29
37
  env: {
@@ -151,6 +159,8 @@ export function resolveBrain(cfg, handle) {
151
159
  return { brain: claudeBrain(cfg), kind: "claude (per-bot)" };
152
160
  if (override?.backend === "hermes")
153
161
  return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
162
+ if (override?.backend === "hermes-http")
163
+ return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (per-bot)" };
154
164
  if (override?.backend === "echo" || override?.echo)
155
165
  return { brain: echoBrain, kind: "echo (per-bot)" };
156
166
  // No local override — honor the server-mediated backend if one was pushed.
@@ -159,6 +169,8 @@ export function resolveBrain(cfg, handle) {
159
169
  return { brain: claudeBrain(cfg), kind: "claude (server)" };
160
170
  if (server === "hermes")
161
171
  return { brain: hermesBrain(cfg), kind: "hermes (server)" };
172
+ if (server === "hermes-http")
173
+ return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (server)" };
162
174
  if (server === "echo")
163
175
  return { brain: echoBrain, kind: "echo (server)" };
164
176
  if (cfg.brainCmd)
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.7.8";
2
+ export declare const VERSION = "0.7.10";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
@@ -94,5 +94,5 @@ export declare class BridgeApp {
94
94
  /** Full status (authenticated). `callerRuntimeId` — when known — keeps the
95
95
  * legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
96
96
  * pairing so pre-0.6 clients keep working unchanged. */
97
- statusFull(callerRuntimeId?: string): Record<string, unknown>;
97
+ statusFull(callerRuntimeId?: string): Promise<Record<string, unknown>>;
98
98
  }
package/dist/bridge.js CHANGED
@@ -14,10 +14,28 @@ import { saveSettings } from "./config.js";
14
14
  import { BotRunner } from "./bot.js";
15
15
  import { ControlSocket } from "./control.js";
16
16
  import { resolveBrain } from "./brain.js";
17
- import { provisionSoul, provisionHermesProfile } from "./backends.js";
17
+ import { provisionSoul, provisionHermesProfile, resolveBin } from "./backends.js";
18
18
  import { reportCapabilities } from "./capabilities.js";
19
+ import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
20
+ import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
19
21
  import { isBrainBackend } from "./config.js";
20
- export const VERSION = "0.7.8";
22
+ export const VERSION = "0.7.10";
23
+ function hermesApiStatus(cfg) {
24
+ const api = lastHermesApiHealth();
25
+ return {
26
+ url: cfg.hermesApiUrl,
27
+ healthy: api?.ok === true,
28
+ reason: api ? (api.reason ?? null) : "not probed yet",
29
+ checkedAt: api?.checkedAt ?? null,
30
+ };
31
+ }
32
+ function hermesHostStatus(cfg) {
33
+ return {
34
+ cli: Boolean(resolveBin("hermes", "HERMES_BIN")),
35
+ apiUrl: cfg.hermesApiUrl,
36
+ api: hermesApiStatus(cfg),
37
+ };
38
+ }
21
39
  /** How often to re-probe + report brain availability to the server. */
22
40
  const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
23
41
  // ---- Reconnect watchdog -----------------------------------------------------
@@ -161,6 +179,7 @@ class PairingRuntime {
161
179
  msSinceHealthy: b.msSinceHealthy(),
162
180
  lastForcedRestart: b.lastForcedRestartAt,
163
181
  brain: resolveBrain(this.cfg, b.info.handle).kind,
182
+ lastTurn: turnSnapshot(b.lastTurn()),
164
183
  }));
165
184
  }
166
185
  /**
@@ -590,8 +609,19 @@ export class BridgeApp {
590
609
  /** Full status (authenticated). `callerRuntimeId` — when known — keeps the
591
610
  * legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
592
611
  * pairing so pre-0.6 clients keep working unchanged. */
593
- statusFull(callerRuntimeId) {
612
+ async statusFull(callerRuntimeId) {
594
613
  const own = (callerRuntimeId && this.runtimes.find((r) => r.runtimeId === callerRuntimeId)) || this.runtimes[0] || null;
614
+ await probeHermesApi(this.cfg).catch(() => null);
615
+ const bots = this.runtimes.flatMap((r) => r.botStatuses());
616
+ const watchdog = {
617
+ intervalMs: WATCHDOG_INTERVAL_MS,
618
+ softStaleMs: SOFT_STALE_MS,
619
+ hardStaleMs: HARD_STALE_MS,
620
+ hardStaleStreak: this.hardStaleStreak,
621
+ lastCheckAt: this.lastWatchdogAt || null,
622
+ };
623
+ const hermesApi = hermesApiStatus(this.cfg);
624
+ const lastTurn = turnSnapshot(lastTurnGlobal());
595
625
  return {
596
626
  ...this.statusMinimal(),
597
627
  runtime: own?.runtimeId ?? null,
@@ -610,16 +640,13 @@ export class BridgeApp {
610
640
  provisionCmd: this.cfg.brainProvisionCmd,
611
641
  },
612
642
  // Legacy flat list = every pairing's bots (pre-0.6 clients read this).
613
- bots: this.runtimes.flatMap((r) => r.botStatuses()),
614
- // Reconnect watchdog state (per-bot msSinceHealthy/lastForcedRestart ride
615
- // each bot entry above; this summarizes the process-level guard).
616
- watchdog: {
617
- intervalMs: WATCHDOG_INTERVAL_MS,
618
- softStaleMs: SOFT_STALE_MS,
619
- hardStaleMs: HARD_STALE_MS,
620
- hardStaleStreak: this.hardStaleStreak,
621
- lastCheckAt: this.lastWatchdogAt || null,
622
- },
643
+ bots,
644
+ watchdog,
645
+ hermes: hermesHostStatus(this.cfg),
646
+ // Plan fields (docs/HERMES-BRAIN-PLAN.md) — additive, same payload.
647
+ bridge: { ...this.statusMinimal(), watchdog },
648
+ hermesApi,
649
+ lastTurn,
623
650
  };
624
651
  }
625
652
  }
@@ -7,13 +7,14 @@ export interface BackendCapability {
7
7
  export interface Capabilities {
8
8
  claude: BackendCapability;
9
9
  hermes: BackendCapability;
10
+ hermesHttp: BackendCapability;
10
11
  }
11
12
  /**
12
13
  * Probe which native brains are ready on this computer. Best-effort and FREE:
13
14
  * binary resolution + credential file/keychain/env presence only, never a paid
14
15
  * model call. Echo is always available and isn't probed.
15
16
  */
16
- export declare function probeCapabilities(_cfg: BridgeConfig): Promise<Capabilities>;
17
+ export declare function probeCapabilities(cfg: BridgeConfig): Promise<Capabilities>;
17
18
  /**
18
19
  * Probe, then PUT the result to the server so the phone's picker reflects it.
19
20
  * Authed with ONE pairing's runtime token — the SAME Bearer auth the bridge
@@ -11,6 +11,7 @@ import { existsSync, readFileSync } from "node:fs";
11
11
  import { homedir, platform } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { resolveBin, hermesHome } from "./backends.js";
14
+ import { probeHermesApi } from "./hermes-http.js";
14
15
  // Reasons are stable strings the app renders verbatim ("Claude — not logged in").
15
16
  const NOT_INSTALLED = "not installed";
16
17
  const NOT_LOGGED_IN = "not logged in";
@@ -109,8 +110,13 @@ function probeHermes() {
109
110
  * binary resolution + credential file/keychain/env presence only, never a paid
110
111
  * model call. Echo is always available and isn't probed.
111
112
  */
112
- export async function probeCapabilities(_cfg) {
113
- return { claude: probeClaude(), hermes: probeHermes() };
113
+ export async function probeCapabilities(cfg) {
114
+ const api = await probeHermesApi(cfg);
115
+ return {
116
+ claude: probeClaude(),
117
+ hermes: probeHermes(),
118
+ hermesHttp: api.ok ? { available: true } : { available: false, reason: api.reason ?? "unreachable" },
119
+ };
114
120
  }
115
121
  function summarize(c) {
116
122
  return c.available ? "ok" : (c.reason ?? "unavailable");
@@ -132,7 +138,7 @@ export async function reportCapabilities(cfg, pairing) {
132
138
  console.error(`[caps] probe failed: ${err.message}`);
133
139
  return;
134
140
  }
135
- console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)} (reporting as ${pairing.label ?? pairing.appId})`);
141
+ console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)} hermes-http=${summarize(caps.hermesHttp)} (reporting as ${pairing.label ?? pairing.appId})`);
136
142
  try {
137
143
  const res = await fetch(`${pairing.apiUrl}/v1/runtime/capabilities`, {
138
144
  method: "PUT",
@@ -140,7 +146,7 @@ export async function reportCapabilities(cfg, pairing) {
140
146
  authorization: `Bearer ${pairing.pairingCode}`,
141
147
  "content-type": "application/json",
142
148
  },
143
- body: JSON.stringify({ backends: { claude: caps.claude, hermes: caps.hermes } }),
149
+ body: JSON.stringify({ backends: { claude: caps.claude, hermes: caps.hermes, hermesHttp: caps.hermesHttp } }),
144
150
  signal: AbortSignal.timeout(15_000),
145
151
  });
146
152
  if (!res.ok) {
package/dist/cli.js CHANGED
@@ -92,6 +92,7 @@ console.log(`[bridge] NoPeek agent-bridge v${VERSION} starting`);
92
92
  console.log(`[bridge] api=${cfg.apiUrl} accounts=${cfg.pairings.length ? cfg.pairings.map((p) => p.label ?? p.appId).join(", ") : "(not paired)"} data=${cfg.dataDir} local-api=:${cfg.port} home=${cfg.homeDir}`);
93
93
  console.log(`[bridge] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (choose an agent in the NoPeek app, or set --brain-cmd)"}` +
94
94
  (Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
95
+ console.log(`[bridge] hermes-api=${cfg.hermesApiUrl} timeout=${cfg.brainTimeoutMs}ms`);
95
96
  const app = new BridgeApp(cfg);
96
97
  let localApi;
97
98
  try {
package/dist/config.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Built-in native backends (see backends.ts): claude = Claude Code headless,
2
2
  * hermes = per-bot Hermes profile, echo = smoke test. */
3
- export type BrainBackend = "claude" | "hermes" | "echo";
3
+ export type BrainBackend = "claude" | "hermes" | "hermes-http" | "echo";
4
4
  /** Per-bot brain override, keyed by bot handle in BRAIN_MAP.
5
5
  * `backend` selects a native built-in (claude/hermes/echo);
6
6
  * `echo: true` pins the bot to echo mode even when a global brain is set. */
@@ -69,6 +69,15 @@ export interface BridgeConfig {
69
69
  brainTimeoutMs: number;
70
70
  /** Where `install` sends the user to finish setup (opened in the browser). */
71
71
  appUrl: string;
72
+ /**
73
+ * Hermes agent API server (gateway `api_server` platform). When set, the
74
+ * hermes brain talks HTTP (`/v1/chat/completions` + `/health`) instead of
75
+ * shelling out to `hermes chat`. Flag changes in the CLI then cannot silently
76
+ * kill bots. Default: HERMES_API_URL or http://127.0.0.1:8642.
77
+ */
78
+ hermesApiUrl: string;
79
+ /** Optional bearer for the Hermes API server (`API_SERVER_KEY`). */
80
+ hermesApiKey: string | null;
72
81
  /** Local control API port (status + pairing + brain config, loopback only). */
73
82
  port: number;
74
83
  /** Where per-bot device identity/key stores live. */
@@ -82,9 +91,10 @@ export interface BridgeConfig {
82
91
  export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
83
92
  export declare const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
84
93
  export declare const DEFAULT_PORT = 8790;
85
- export declare const DEFAULT_BRAIN_TIMEOUT_MS = 300000;
94
+ export declare const DEFAULT_BRAIN_TIMEOUT_MS = 900000;
95
+ export declare const DEFAULT_HERMES_API_URL = "http://127.0.0.1:8642";
86
96
  export declare function defaultHomeDir(): string;
87
- 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 300000 (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.";
97
+ 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 idle timeout, default 900000 (env BRAIN_TIMEOUT_MS)\n --hermes-api-url <url> Hermes agent API (default http://127.0.0.1:8642) (env HERMES_API_URL)\n --hermes-api-key <key> Bearer for the Hermes API server (env HERMES_API_KEY)\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.";
88
98
  /** Load config from argv + env + cwd config file + home settings. Never
89
99
  * requires pairing — an unpaired bridge waits for the app to pair it. */
90
100
  export declare function loadConfig(argv?: string[]): BridgeConfig;
package/dist/config.js CHANGED
@@ -10,17 +10,17 @@ import { homedir } from "node:os";
10
10
  import { join, resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
12
  export function isBrainBackend(v) {
13
- return v === "claude" || v === "hermes" || v === "echo";
13
+ return v === "claude" || v === "hermes" || v === "hermes-http" || v === "echo";
14
14
  }
15
15
  export const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
16
16
  export const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
17
17
  export const DEFAULT_PORT = 8790;
18
- // Idle timeout (resets on any child-process output, not a flat wall-clock cap
19
- // see backends.ts). 300s of true SILENCE covers a long tool-use step (repo
20
- // indexing, slow provider call) on a genuinely agentic brain without needing
21
- // per-deployment tuning, while still killing a truly hung process reasonably
22
- // fast.
23
- export const DEFAULT_BRAIN_TIMEOUT_MS = 300_000;
18
+ // Idle timeout (resets on any child-process output OR CPU-time advance
19
+ // see backends.ts). Hermes -Q often does real tool work with zero stdout for
20
+ // many minutes; 15 min of *true* silence (no bytes AND no CPU) is the kill
21
+ // line. A hung process that is not scheduled still dies; a working agent does not.
22
+ export const DEFAULT_BRAIN_TIMEOUT_MS = 900_000;
23
+ export const DEFAULT_HERMES_API_URL = "http://127.0.0.1:8642";
24
24
  export function defaultHomeDir() {
25
25
  return process.env.NOPEEK_BRIDGE_HOME || join(homedir(), ".nopeek-bridge");
26
26
  }
@@ -33,6 +33,8 @@ const CLI_OPTIONS = {
33
33
  "brain-map": { type: "string" },
34
34
  "brain-provision-cmd": { type: "string" },
35
35
  "brain-timeout-ms": { type: "string" },
36
+ "hermes-api-url": { type: "string" },
37
+ "hermes-api-key": { type: "string" },
36
38
  "decline-message": { type: "string" },
37
39
  "app-url": { type: "string" },
38
40
  "no-open": { type: "boolean" },
@@ -52,6 +54,8 @@ const FLAG_TO_KEY = {
52
54
  "brain-map": "BRAIN_MAP",
53
55
  "brain-provision-cmd": "BRAIN_PROVISION_CMD",
54
56
  "brain-timeout-ms": "BRAIN_TIMEOUT_MS",
57
+ "hermes-api-url": "HERMES_API_URL",
58
+ "hermes-api-key": "HERMES_API_KEY",
55
59
  "decline-message": "NOPEEK_DECLINE_MESSAGE",
56
60
  "app-url": "NOPEEK_APP_URL",
57
61
  port: "NOPEEK_BRIDGE_PORT",
@@ -83,7 +87,9 @@ Options:
83
87
  fresh Hermes profile with its own soul + memory)
84
88
  --app-url <url> App opened after install (env NOPEEK_APP_URL)
85
89
  --no-open install: don't open the app in the browser
86
- --brain-timeout-ms <ms> Brain timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
90
+ --brain-timeout-ms <ms> Brain idle timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
91
+ --hermes-api-url <url> Hermes agent API (default ${DEFAULT_HERMES_API_URL}) (env HERMES_API_URL)
92
+ --hermes-api-key <key> Bearer for the Hermes API server (env HERMES_API_KEY)
87
93
  --port <port> Local control API port, default ${DEFAULT_PORT} (env NOPEEK_BRIDGE_PORT)
88
94
  --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)
89
95
  --home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)
@@ -132,7 +138,7 @@ function parseBrainMap(raw) {
132
138
  }
133
139
  const { cmd, url, backend, echo } = spec;
134
140
  if (backend !== undefined && !isBrainBackend(backend)) {
135
- throw new Error(`BRAIN_MAP["${handle}"].backend must be "claude", "hermes" or "echo"`);
141
+ throw new Error(`BRAIN_MAP["${handle}"].backend must be "claude", "hermes", "hermes-http" or "echo"`);
136
142
  }
137
143
  if (typeof cmd !== "string" && typeof url !== "string" && !isBrainBackend(backend) && echo !== true) {
138
144
  throw new Error(`BRAIN_MAP["${handle}"] needs a "cmd", "url" or "backend" ("claude"|"hermes"|"echo")`);
@@ -286,6 +292,8 @@ export function loadConfig(argv = process.argv.slice(2)) {
286
292
  serverBackends: parseServerBackends(process.env.NOPEEK_SERVER_BACKENDS ?? file.NOPEEK_SERVER_BACKENDS ?? saved.NOPEEK_SERVER_BACKENDS),
287
293
  brainProvisionCmd: get("brain-provision-cmd") ?? null,
288
294
  brainTimeoutMs,
295
+ hermesApiUrl: (get("hermes-api-url") ?? process.env.HERMES_API_URL ?? file.HERMES_API_URL ?? saved.HERMES_API_URL ?? DEFAULT_HERMES_API_URL).replace(/\/+$/, ""),
296
+ hermesApiKey: get("hermes-api-key") ?? process.env.HERMES_API_KEY ?? file.HERMES_API_KEY ?? saved.HERMES_API_KEY ?? null,
289
297
  port,
290
298
  dataDir,
291
299
  homeDir,
@@ -0,0 +1,15 @@
1
+ import type { BridgeConfig } from "./config.js";
2
+ import { type Brain } from "./brain.js";
3
+ export interface HermesApiHealth {
4
+ ok: boolean;
5
+ url: string;
6
+ reason?: string;
7
+ checkedAt: number;
8
+ }
9
+ export declare function lastHermesApiHealth(): HermesApiHealth | null;
10
+ export declare function probeHermesApi(cfg: BridgeConfig, force?: boolean): Promise<HermesApiHealth>;
11
+ /**
12
+ * Stream one turn through Hermes' OpenAI-compatible chat completions.
13
+ * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
14
+ */
15
+ export declare function hermesHttpBrain(cfg: BridgeConfig): Brain;
@@ -0,0 +1,209 @@
1
+ import { asHooks } from "./brain.js";
2
+ import { RECAP_HINT } from "./tool-progress.js";
3
+ let lastHealth = null;
4
+ const HEALTH_TTL_MS = 15_000;
5
+ export function lastHermesApiHealth() {
6
+ return lastHealth;
7
+ }
8
+ export async function probeHermesApi(cfg, force = false) {
9
+ const url = cfg.hermesApiUrl.replace(/\/+$/, "");
10
+ if (!force && lastHealth && Date.now() - lastHealth.checkedAt < HEALTH_TTL_MS && lastHealth.url === url) {
11
+ return lastHealth;
12
+ }
13
+ const headers = {};
14
+ if (cfg.hermesApiKey)
15
+ headers.authorization = `Bearer ${cfg.hermesApiKey}`;
16
+ try {
17
+ const res = await fetch(`${url}/health`, {
18
+ headers,
19
+ signal: AbortSignal.timeout(3_000),
20
+ });
21
+ lastHealth = res.ok
22
+ ? { ok: true, url, checkedAt: Date.now() }
23
+ : { ok: false, url, reason: `HTTP ${res.status}`, checkedAt: Date.now() };
24
+ }
25
+ catch (err) {
26
+ lastHealth = {
27
+ ok: false,
28
+ url,
29
+ reason: err.message || "unreachable",
30
+ checkedAt: Date.now(),
31
+ };
32
+ }
33
+ return lastHealth;
34
+ }
35
+ /**
36
+ * Stream one turn through Hermes' OpenAI-compatible chat completions.
37
+ * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
38
+ */
39
+ export function hermesHttpBrain(cfg) {
40
+ return async (text, ctx, hooks) => {
41
+ const { onChunk, onTool } = asHooks(hooks);
42
+ const handle = ctx.botHandle.replace(/^@/, "");
43
+ const tag = `[brain:hermes-http:@${handle}]`;
44
+ const url = cfg.hermesApiUrl.replace(/\/+$/, "");
45
+ const headers = {
46
+ "content-type": "application/json",
47
+ };
48
+ // Hermes rejects X-Hermes-Session-Key with 403 unless API_SERVER_KEY is set.
49
+ // Without a key, skip the header so the turn still runs (no session memory).
50
+ if (cfg.hermesApiKey) {
51
+ headers.authorization = `Bearer ${cfg.hermesApiKey}`;
52
+ headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
53
+ }
54
+ // Idle abort — same rule as the CLI path. A wall-clock timeout on the
55
+ // whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
56
+ // (token, keepalive, tool-progress) resets the idle timer.
57
+ const controller = new AbortController();
58
+ let idle;
59
+ const armIdle = () => {
60
+ clearTimeout(idle);
61
+ idle = setTimeout(() => {
62
+ console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no SSE), aborting`);
63
+ controller.abort();
64
+ }, cfg.brainTimeoutMs);
65
+ };
66
+ armIdle();
67
+ let res;
68
+ try {
69
+ res = await fetch(`${url}/v1/chat/completions`, {
70
+ method: "POST",
71
+ headers,
72
+ body: JSON.stringify({
73
+ model: "hermes-agent",
74
+ stream: true,
75
+ messages: [
76
+ { role: "system", content: RECAP_HINT },
77
+ { role: "user", content: text },
78
+ ],
79
+ }),
80
+ signal: controller.signal,
81
+ });
82
+ }
83
+ catch (err) {
84
+ clearTimeout(idle);
85
+ console.error(`${tag} request failed: ${err.message}`);
86
+ return "";
87
+ }
88
+ if (!res.ok) {
89
+ clearTimeout(idle);
90
+ const body = await res.text().catch(() => "");
91
+ console.error(`${tag} HTTP ${res.status}: ${body.slice(0, 300)}`);
92
+ return "";
93
+ }
94
+ if (!res.body) {
95
+ clearTimeout(idle);
96
+ console.error(`${tag} empty body`);
97
+ return "";
98
+ }
99
+ const reader = res.body.getReader();
100
+ const decoder = new TextDecoder();
101
+ let buf = "";
102
+ let reply = "";
103
+ try {
104
+ while (true) {
105
+ const { done, value } = await reader.read();
106
+ if (done)
107
+ break;
108
+ armIdle();
109
+ buf += decoder.decode(value, { stream: true });
110
+ const frames = buf.split("\n\n");
111
+ buf = frames.pop() ?? "";
112
+ for (const frame of frames) {
113
+ const ev = parseSseFrame(frame);
114
+ if (!ev)
115
+ continue;
116
+ if (ev.event === "hermes.tool.progress") {
117
+ const tool = asToolProgress(ev.data);
118
+ if (tool)
119
+ onTool?.(tool);
120
+ continue;
121
+ }
122
+ const delta = extractDelta(ev.data);
123
+ if (!delta)
124
+ continue;
125
+ reply += delta;
126
+ onChunk?.(delta);
127
+ }
128
+ }
129
+ if (buf.trim()) {
130
+ const ev = parseSseFrame(buf);
131
+ if (ev?.event === "hermes.tool.progress") {
132
+ const tool = asToolProgress(ev.data);
133
+ if (tool)
134
+ onTool?.(tool);
135
+ }
136
+ else if (ev) {
137
+ const delta = extractDelta(ev.data);
138
+ if (delta) {
139
+ reply += delta;
140
+ onChunk?.(delta);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ catch (err) {
146
+ const msg = err.message || "";
147
+ if (controller.signal.aborted) {
148
+ return `⚠️ My brain went quiet for over ${Math.round(cfg.brainTimeoutMs / 1000)}s with no output and I had to give up on that turn — a long tool-use step or a very slow provider response can trigger this. Try messaging me again.`;
149
+ }
150
+ console.error(`${tag} stream error: ${msg}`);
151
+ }
152
+ finally {
153
+ clearTimeout(idle);
154
+ }
155
+ return reply.trim();
156
+ };
157
+ }
158
+ function parseSseFrame(frame) {
159
+ let event = "message";
160
+ const dataLines = [];
161
+ for (const raw of frame.split("\n")) {
162
+ const line = raw.replace(/\r$/, "");
163
+ if (!line || line.startsWith(":"))
164
+ continue;
165
+ if (line.startsWith("event:"))
166
+ event = line.slice(6).trim();
167
+ else if (line.startsWith("data:"))
168
+ dataLines.push(line.slice(5).trimStart());
169
+ }
170
+ if (dataLines.length === 0)
171
+ return null;
172
+ const payload = dataLines.join("\n");
173
+ if (!payload || payload === "[DONE]")
174
+ return null;
175
+ try {
176
+ return { event, data: JSON.parse(payload) };
177
+ }
178
+ catch {
179
+ return { event, data: payload };
180
+ }
181
+ }
182
+ function asToolProgress(data) {
183
+ if (!data || typeof data !== "object")
184
+ return null;
185
+ const obj = data;
186
+ const tool = typeof obj.tool === "string" ? obj.tool : typeof obj.name === "string" ? obj.name : "";
187
+ if (!tool)
188
+ return null;
189
+ return {
190
+ tool,
191
+ emoji: typeof obj.emoji === "string" ? obj.emoji : undefined,
192
+ label: typeof obj.label === "string" ? obj.label : undefined,
193
+ status: typeof obj.status === "string" ? obj.status : "running",
194
+ };
195
+ }
196
+ function extractDelta(parsed) {
197
+ if (!parsed || typeof parsed !== "object")
198
+ return "";
199
+ const obj = parsed;
200
+ const choice = obj.choices?.[0];
201
+ const fromChoice = choice?.delta?.content ?? choice?.message?.content;
202
+ if (typeof fromChoice === "string")
203
+ return fromChoice;
204
+ if (typeof obj.data?.content === "string")
205
+ return obj.data.content;
206
+ if (typeof obj.data?.text === "string")
207
+ return obj.data.text;
208
+ return "";
209
+ }
@@ -0,0 +1,22 @@
1
+ export interface BrainTurn {
2
+ handle: string;
3
+ channelId: string;
4
+ via: string;
5
+ startedAt: number;
6
+ endedAt: number | null;
7
+ inFlight: boolean;
8
+ ok: boolean | null;
9
+ chars: number;
10
+ timedOut: boolean;
11
+ error: string | null;
12
+ }
13
+ export declare function beginTurn(handle: string, channelId: string, via: string): BrainTurn;
14
+ export declare function finishTurn(t: BrainTurn, result: {
15
+ ok: boolean;
16
+ chars?: number;
17
+ timedOut?: boolean;
18
+ error?: string | null;
19
+ }): void;
20
+ export declare function lastTurnFor(handle: string): BrainTurn | null;
21
+ export declare function lastTurnGlobal(): BrainTurn | null;
22
+ export declare function turnSnapshot(t: BrainTurn | null): Record<string, unknown> | null;
@@ -0,0 +1,50 @@
1
+ const byHandle = new Map();
2
+ let global = null;
3
+ export function beginTurn(handle, channelId, via) {
4
+ const t = {
5
+ handle: handle.replace(/^@/, ""),
6
+ channelId,
7
+ via,
8
+ startedAt: Date.now(),
9
+ endedAt: null,
10
+ inFlight: true,
11
+ ok: null,
12
+ chars: 0,
13
+ timedOut: false,
14
+ error: null,
15
+ };
16
+ byHandle.set(t.handle, t);
17
+ global = t;
18
+ return t;
19
+ }
20
+ export function finishTurn(t, result) {
21
+ t.endedAt = Date.now();
22
+ t.inFlight = false;
23
+ t.ok = result.ok;
24
+ t.chars = result.chars ?? 0;
25
+ t.timedOut = result.timedOut === true;
26
+ t.error = result.error ?? null;
27
+ }
28
+ export function lastTurnFor(handle) {
29
+ return byHandle.get(handle.replace(/^@/, "")) ?? null;
30
+ }
31
+ export function lastTurnGlobal() {
32
+ return global;
33
+ }
34
+ export function turnSnapshot(t) {
35
+ if (!t)
36
+ return null;
37
+ return {
38
+ handle: t.handle,
39
+ channelId: t.channelId,
40
+ via: t.via,
41
+ startedAt: t.startedAt,
42
+ endedAt: t.endedAt,
43
+ ms: t.endedAt != null ? t.endedAt - t.startedAt : Date.now() - t.startedAt,
44
+ inFlight: t.inFlight,
45
+ ok: t.ok,
46
+ chars: t.chars,
47
+ timedOut: t.timedOut,
48
+ error: t.error,
49
+ };
50
+ }
package/dist/localapi.js CHANGED
@@ -164,7 +164,7 @@ async function handle(app, req, res) {
164
164
  return;
165
165
  }
166
166
  if (method === "GET" && path === "/status") {
167
- json(res, 200, app.statusFull(auth.runtimeId));
167
+ json(res, 200, await app.statusFull(auth.runtimeId));
168
168
  return;
169
169
  }
170
170
  if (method === "GET" && path === "/detect") {
@@ -181,7 +181,7 @@ async function handle(app, req, res) {
181
181
  return;
182
182
  }
183
183
  app.setBrains(body);
184
- json(res, 200, { ok: true, status: app.statusFull(auth.runtimeId) });
184
+ json(res, 200, { ok: true, status: await app.statusFull(auth.runtimeId) });
185
185
  return;
186
186
  }
187
187
  // ?runtimeId=rt_… removes ONE account's pairing; no query = remove ALL
package/dist/service.d.ts CHANGED
@@ -1,15 +1,4 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
- /**
3
- * Periodic self-update: compares the running version against npm's published
4
- * `latest` and, if newer, re-installs using the SAME method this process is
5
- * actually running from (global npm root, or the private <home>/app copy —
6
- * see resolveEntrypoint above) and exits so the always-restart service
7
- * relaunches running the fresh code. Only acts on an install method it
8
- * recognizes — a pnpm-linked dev checkout or anything unrecognized is left
9
- * alone (safety over completeness). This is how a published fix (like a
10
- * reconnect-reliability bug) actually reaches already-installed bridges
11
- * instead of sitting unused until someone manually reinstalls.
12
- */
13
2
  export declare function checkForUpdate(currentVersion: string, homeDir: string): Promise<void>;
14
3
  export declare function installService(cfg: BridgeConfig, noOpen?: boolean): Promise<void>;
15
4
  export declare function uninstallService(): void;
package/dist/service.js CHANGED
@@ -43,7 +43,21 @@ function resolveEntrypoint(homeDir) {
43
43
  * reconnect-reliability bug) actually reaches already-installed bridges
44
44
  * instead of sitting unused until someone manually reinstalls.
45
45
  */
46
+ function isNewerVersion(latest, current) {
47
+ const a = latest.split(".").map((n) => Number.parseInt(n, 10) || 0);
48
+ const b = current.split(".").map((n) => Number.parseInt(n, 10) || 0);
49
+ const n = Math.max(a.length, b.length);
50
+ for (let i = 0; i < n; i++) {
51
+ if ((a[i] ?? 0) > (b[i] ?? 0))
52
+ return true;
53
+ if ((a[i] ?? 0) < (b[i] ?? 0))
54
+ return false;
55
+ }
56
+ return false;
57
+ }
46
58
  export async function checkForUpdate(currentVersion, homeDir) {
59
+ if (process.env.NOPEEK_BRIDGE_NO_AUTO_UPDATE === "1")
60
+ return;
47
61
  let latest;
48
62
  try {
49
63
  const res = await fetch("https://registry.npmjs.org/@nopeek/agent-bridge/latest", {
@@ -57,7 +71,10 @@ export async function checkForUpdate(currentVersion, homeDir) {
57
71
  catch {
58
72
  return; // offline / registry unreachable — try again next check
59
73
  }
60
- if (!latest || latest === currentVersion)
74
+ // Only UPGRADE. A local/unpublished build (0.7.10) must never be replaced
75
+ // by an older published latest (0.7.8) — that exact downgrade put the 300s
76
+ // idle kill back on this machine after we installed the fix.
77
+ if (!latest || !isNewerVersion(latest, currentVersion))
61
78
  return;
62
79
  const self = realpathSync(process.argv[1]);
63
80
  let installArgs = null;
@@ -123,6 +140,7 @@ export async function installService(cfg, noOpen = false) {
123
140
  <key>EnvironmentVariables</key>
124
141
  <dict>
125
142
  <key>NOPEEK_BRIDGE_HOME</key><string>${xmlEscape(cfg.homeDir)}</string>
143
+ <key>NOPEEK_BRIDGE_NO_AUTO_UPDATE</key><string>${xmlEscape(process.env.NOPEEK_BRIDGE_NO_AUTO_UPDATE ?? "0")}</string>
126
144
  <key>PATH</key><string>${xmlEscape(process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin")}</string>
127
145
  </dict>
128
146
  <key>RunAtLoad</key><true/>
@@ -0,0 +1,29 @@
1
+ export type ToolProgressEvent = {
2
+ tool: string;
3
+ emoji?: string;
4
+ label?: string;
5
+ status?: string;
6
+ };
7
+ /** One Telegram-style line. Running events only; completed is silent. */
8
+ export declare function formatToolLine(ev: ToolProgressEvent): string | null;
9
+ export type ProgressSender = (text: string) => Promise<void>;
10
+ /**
11
+ * Collect tool lines and flush them as short chat messages.
12
+ * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
13
+ */
14
+ export declare class ToolProgressFlusher {
15
+ private readonly send;
16
+ private readonly gapMs;
17
+ private readonly maxLines;
18
+ private lines;
19
+ private timer;
20
+ private chain;
21
+ private seen;
22
+ constructor(send: ProgressSender, gapMs?: number, maxLines?: number);
23
+ get pending(): number;
24
+ push(ev: ToolProgressEvent): void;
25
+ flush(): Promise<void>;
26
+ private arm;
27
+ }
28
+ /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
29
+ export declare const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
@@ -0,0 +1,105 @@
1
+ // Telegram-style tool progress for NoPeek bots.
2
+ //
3
+ // Hermes emits `hermes.tool.progress` SSE events while it works. Telegram
4
+ // already shows those as short status messages (a few tools, then a new
5
+ // message after a quiet gap). We do the same in the encrypted chat so the
6
+ // human can see the bot is actually taking action.
7
+ const FALLBACK_EMOJI = {
8
+ read_file: "📖",
9
+ write_file: "✍️",
10
+ patch: "🔧",
11
+ search_files: "🔎",
12
+ terminal: "💻",
13
+ web_search: "🔍",
14
+ web_extract: "📄",
15
+ web_crawl: "🕸️",
16
+ memory: "🧠",
17
+ skill_view: "📘",
18
+ skill_manage: "🧩",
19
+ skills_list: "📚",
20
+ todo: "✅",
21
+ execute_code: "🐍",
22
+ delegate_task: "👥",
23
+ cronjob: "⏰",
24
+ process: "⚙️",
25
+ };
26
+ /** One Telegram-style line. Running events only; completed is silent. */
27
+ export function formatToolLine(ev) {
28
+ if (ev.status && ev.status !== "running")
29
+ return null;
30
+ const tool = (ev.tool || "").trim();
31
+ if (!tool || tool.startsWith("_"))
32
+ return null;
33
+ const emoji = (ev.emoji || FALLBACK_EMOJI[tool] || "⚡").trim() || "⚡";
34
+ const label = tidyLabel(ev.label);
35
+ if (label)
36
+ return `${emoji} ${tool}: "${label}"`;
37
+ return `${emoji} ${tool}...`;
38
+ }
39
+ function tidyLabel(raw) {
40
+ if (!raw)
41
+ return "";
42
+ const s = raw.replace(/\s+/g, " ").trim();
43
+ if (!s)
44
+ return "";
45
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
46
+ }
47
+ /**
48
+ * Collect tool lines and flush them as short chat messages.
49
+ * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
50
+ */
51
+ export class ToolProgressFlusher {
52
+ send;
53
+ gapMs;
54
+ maxLines;
55
+ lines = [];
56
+ timer = null;
57
+ chain = Promise.resolve();
58
+ seen = new Set();
59
+ constructor(send, gapMs = 2200, maxLines = 4) {
60
+ this.send = send;
61
+ this.gapMs = gapMs;
62
+ this.maxLines = maxLines;
63
+ }
64
+ get pending() {
65
+ return this.lines.length;
66
+ }
67
+ push(ev) {
68
+ const line = formatToolLine(ev);
69
+ if (!line)
70
+ return;
71
+ if (this.lines[this.lines.length - 1] === line)
72
+ return;
73
+ if (this.seen.has(line) && this.lines.includes(line))
74
+ return;
75
+ this.seen.add(line);
76
+ this.lines.push(line);
77
+ if (this.lines.length >= this.maxLines) {
78
+ void this.flush();
79
+ return;
80
+ }
81
+ this.arm();
82
+ }
83
+ async flush() {
84
+ if (this.timer) {
85
+ clearTimeout(this.timer);
86
+ this.timer = null;
87
+ }
88
+ if (this.lines.length === 0)
89
+ return;
90
+ const text = this.lines.join("\n");
91
+ this.lines = [];
92
+ this.chain = this.chain.then(() => this.send(text).catch(() => { }));
93
+ await this.chain;
94
+ }
95
+ arm() {
96
+ if (this.timer)
97
+ clearTimeout(this.timer);
98
+ this.timer = setTimeout(() => {
99
+ this.timer = null;
100
+ void this.flush();
101
+ }, this.gapMs);
102
+ }
103
+ }
104
+ /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
105
+ export const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.8",
3
+ "version": "0.7.11",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, 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.4"
33
+ "@nopeek/chat": "workspace:^0.2.4"
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
+ }