@nopeek/agent-bridge 0.7.8 → 0.7.10

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,12 @@ 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
+ Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
45
+ `GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
46
+ and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
47
+
42
48
  ## Local control API
43
49
 
44
50
  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
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFi
17
17
  import { homedir } from "node:os";
18
18
  import { basename, join } from "node:path";
19
19
  import { 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) —
@@ -349,7 +385,7 @@ function detectHermesProfileMode(bin) {
349
385
  console.log(`[hermes] profile selection mode: ${hermesProfileMode}`);
350
386
  return hermesProfileMode;
351
387
  }
352
- export function hermesBrain(cfg) {
388
+ function hermesCliBrain(cfg) {
353
389
  const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
354
390
  const bin = resolveBin("hermes", "HERMES_BIN");
355
391
  const mode = detectHermesProfileMode(bin);
@@ -418,6 +454,7 @@ export function hermesBrain(cfg) {
418
454
  if (settled)
419
455
  return;
420
456
  settled = true;
457
+ clearInterval(cpuPoll);
421
458
  if (lineBuf)
422
459
  handleLine(lineBuf);
423
460
  // The session footer lands on STDERR in -Q mode — scan there too.
@@ -443,13 +480,28 @@ export function hermesBrain(cfg) {
443
480
  const armIdleTimer = () => {
444
481
  clearTimeout(timer);
445
482
  timer = setTimeout(() => {
446
- console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output), killing`);
483
+ console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output and no CPU), killing`);
447
484
  timedOut = true;
448
485
  child.kill("SIGKILL");
449
486
  finish();
450
487
  }, cfg.brainTimeoutMs);
451
488
  };
452
489
  armIdleTimer();
490
+ // Hermes -Q is silent during long tool use. Reset the idle timer whenever
491
+ // the child's accumulated CPU time advances — that is real work, not a hang.
492
+ let lastCpu = cpuTimeSeconds(child.pid);
493
+ const cpuPoll = setInterval(() => {
494
+ if (settled || child.pid == null)
495
+ return;
496
+ const now = cpuTimeSeconds(child.pid);
497
+ if (now != null && lastCpu != null && now > lastCpu + 0.05) {
498
+ lastCpu = now;
499
+ armIdleTimer();
500
+ }
501
+ else if (now != null && lastCpu == null) {
502
+ lastCpu = now;
503
+ }
504
+ }, 10_000);
453
505
  child.stdout.on("data", (d) => {
454
506
  armIdleTimer();
455
507
  lineBuf += d.toString();
@@ -546,3 +598,25 @@ export function hermesBrain(cfg) {
546
598
  return run.reply;
547
599
  };
548
600
  }
601
+ /**
602
+ * Hermes brain. Prefers the long-running agent API (`HERMES_API_URL`, default
603
+ * loopback :8642) when `/health` is up — that path does not shell out, so a
604
+ * CLI flag change cannot empty-reply the bot. Falls back to `hermes chat -Q`.
605
+ */
606
+ export function hermesBrain(cfg) {
607
+ const cli = hermesCliBrain(cfg);
608
+ const http = hermesHttpBrain(cfg);
609
+ return async (text, ctx, onChunk) => {
610
+ const health = await probeHermesApi(cfg);
611
+ if (health.ok) {
612
+ const reply = await http(text, ctx, onChunk);
613
+ if (reply)
614
+ return reply;
615
+ console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
616
+ }
617
+ return cli(text, ctx, onChunk);
618
+ };
619
+ }
620
+ export function hermesHttpOnlyBrain(cfg) {
621
+ return hermesHttpBrain(cfg);
622
+ }
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,6 +5,7 @@
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";
9
10
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
11
  const MAX_BACKOFF_MS = 60_000;
@@ -94,6 +95,10 @@ export class BotRunner {
94
95
  get lastForcedRestartAt() {
95
96
  return this.lastForcedRestart;
96
97
  }
98
+ /** Most recent brain turn for this bot (shared last-turn store). */
99
+ lastTurn() {
100
+ return lastTurnFor(this.info.handle);
101
+ }
97
102
  /**
98
103
  * Watchdog hard restart: tear the SDK client down completely and rebuild it
99
104
  * from scratch (fresh session mint + NoPeek.connect) — the proven-working
@@ -565,6 +570,7 @@ export class BotRunner {
565
570
  streamRef.p.then((s) => s.append(delta)).catch(() => { });
566
571
  };
567
572
  let reply = "";
573
+ const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
568
574
  try {
569
575
  reply = await resolved.brain(text, {
570
576
  botHandle: this.info.handle,
@@ -572,8 +578,17 @@ export class BotRunner {
572
578
  channelId: m.channelId,
573
579
  senderUserId: m.senderUserId,
574
580
  }, onChunk);
581
+ const trimmed = reply.trim();
582
+ const timedOut = /went quiet for over \d+s/i.test(trimmed);
583
+ finishTurn(turn, {
584
+ ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
585
+ chars: trimmed.length,
586
+ timedOut,
587
+ error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
588
+ });
575
589
  }
576
590
  catch (err) {
591
+ finishTurn(turn, { ok: false, error: err.message, timedOut: false });
577
592
  // Brain blew up mid-stream: finalize the partial bubble with an honest
578
593
  // error line instead of leaving a forever-blinking cursor.
579
594
  const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
package/dist/brain.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // This is how ANY agentic runtime plugs in (Hermes, OpenClaw, a curl to your
7
7
  // own service): the bridge never knows or cares what's on the other side.
8
8
  import { spawn } from "node:child_process";
9
- import { claudeBrain, hermesBrain } from "./backends.js";
9
+ import { claudeBrain, hermesBrain, hermesHttpOnlyBrain } from "./backends.js";
10
10
  export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
11
11
  // ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
12
12
  // Hermes color their stdout; the chat must receive plain text.
@@ -151,6 +151,8 @@ export function resolveBrain(cfg, handle) {
151
151
  return { brain: claudeBrain(cfg), kind: "claude (per-bot)" };
152
152
  if (override?.backend === "hermes")
153
153
  return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
154
+ if (override?.backend === "hermes-http")
155
+ return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (per-bot)" };
154
156
  if (override?.backend === "echo" || override?.echo)
155
157
  return { brain: echoBrain, kind: "echo (per-bot)" };
156
158
  // No local override — honor the server-mediated backend if one was pushed.
@@ -159,6 +161,8 @@ export function resolveBrain(cfg, handle) {
159
161
  return { brain: claudeBrain(cfg), kind: "claude (server)" };
160
162
  if (server === "hermes")
161
163
  return { brain: hermesBrain(cfg), kind: "hermes (server)" };
164
+ if (server === "hermes-http")
165
+ return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (server)" };
162
166
  if (server === "echo")
163
167
  return { brain: echoBrain, kind: "echo (server)" };
164
168
  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,153 @@
1
+ let lastHealth = null;
2
+ const HEALTH_TTL_MS = 15_000;
3
+ export function lastHermesApiHealth() {
4
+ return lastHealth;
5
+ }
6
+ export async function probeHermesApi(cfg, force = false) {
7
+ const url = cfg.hermesApiUrl.replace(/\/+$/, "");
8
+ if (!force && lastHealth && Date.now() - lastHealth.checkedAt < HEALTH_TTL_MS && lastHealth.url === url) {
9
+ return lastHealth;
10
+ }
11
+ const headers = {};
12
+ if (cfg.hermesApiKey)
13
+ headers.authorization = `Bearer ${cfg.hermesApiKey}`;
14
+ try {
15
+ const res = await fetch(`${url}/health`, {
16
+ headers,
17
+ signal: AbortSignal.timeout(3_000),
18
+ });
19
+ lastHealth = res.ok
20
+ ? { ok: true, url, checkedAt: Date.now() }
21
+ : { ok: false, url, reason: `HTTP ${res.status}`, checkedAt: Date.now() };
22
+ }
23
+ catch (err) {
24
+ lastHealth = {
25
+ ok: false,
26
+ url,
27
+ reason: err.message || "unreachable",
28
+ checkedAt: Date.now(),
29
+ };
30
+ }
31
+ return lastHealth;
32
+ }
33
+ /**
34
+ * Stream one turn through Hermes' OpenAI-compatible chat completions.
35
+ * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
36
+ */
37
+ export function hermesHttpBrain(cfg) {
38
+ return async (text, ctx, onChunk) => {
39
+ const handle = ctx.botHandle.replace(/^@/, "");
40
+ const tag = `[brain:hermes-http:@${handle}]`;
41
+ const url = cfg.hermesApiUrl.replace(/\/+$/, "");
42
+ const headers = {
43
+ "content-type": "application/json",
44
+ "X-Hermes-Session-Key": `nopeek-${ctx.channelId}`,
45
+ };
46
+ if (cfg.hermesApiKey)
47
+ headers.authorization = `Bearer ${cfg.hermesApiKey}`;
48
+ // Idle abort — same rule as the CLI path. A wall-clock timeout on the
49
+ // whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
50
+ // (token, keepalive, tool-progress) resets the idle timer.
51
+ const controller = new AbortController();
52
+ let idle;
53
+ const armIdle = () => {
54
+ clearTimeout(idle);
55
+ idle = setTimeout(() => {
56
+ console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no SSE), aborting`);
57
+ controller.abort();
58
+ }, cfg.brainTimeoutMs);
59
+ };
60
+ armIdle();
61
+ let res;
62
+ try {
63
+ res = await fetch(`${url}/v1/chat/completions`, {
64
+ method: "POST",
65
+ headers,
66
+ body: JSON.stringify({
67
+ model: "hermes-agent",
68
+ stream: true,
69
+ messages: [{ role: "user", content: text }],
70
+ }),
71
+ signal: controller.signal,
72
+ });
73
+ }
74
+ catch (err) {
75
+ clearTimeout(idle);
76
+ console.error(`${tag} request failed: ${err.message}`);
77
+ return "";
78
+ }
79
+ if (!res.ok) {
80
+ clearTimeout(idle);
81
+ const body = await res.text().catch(() => "");
82
+ console.error(`${tag} HTTP ${res.status}: ${body.slice(0, 300)}`);
83
+ return "";
84
+ }
85
+ if (!res.body) {
86
+ clearTimeout(idle);
87
+ console.error(`${tag} empty body`);
88
+ return "";
89
+ }
90
+ const reader = res.body.getReader();
91
+ const decoder = new TextDecoder();
92
+ let buf = "";
93
+ let reply = "";
94
+ try {
95
+ while (true) {
96
+ const { done, value } = await reader.read();
97
+ if (done)
98
+ break;
99
+ armIdle();
100
+ buf += decoder.decode(value, { stream: true });
101
+ const lines = buf.split("\n");
102
+ buf = lines.pop() ?? "";
103
+ for (const line of lines) {
104
+ const trimmed = line.trim();
105
+ if (!trimmed.startsWith("data:"))
106
+ continue;
107
+ const payload = trimmed.slice(5).trim();
108
+ if (!payload || payload === "[DONE]")
109
+ continue;
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(payload);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ const delta = extractDelta(parsed);
118
+ if (!delta)
119
+ continue;
120
+ reply += delta;
121
+ onChunk?.(delta);
122
+ }
123
+ }
124
+ }
125
+ catch (err) {
126
+ const msg = err.message || "";
127
+ if (controller.signal.aborted) {
128
+ 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.`;
129
+ }
130
+ console.error(`${tag} stream error: ${msg}`);
131
+ }
132
+ finally {
133
+ clearTimeout(idle);
134
+ }
135
+ return reply.trim();
136
+ };
137
+ }
138
+ function extractDelta(parsed) {
139
+ if (!parsed || typeof parsed !== "object")
140
+ return "";
141
+ const obj = parsed;
142
+ const choice = obj.choices?.[0];
143
+ const fromChoice = choice?.delta?.content ?? choice?.message?.content;
144
+ if (typeof fromChoice === "string")
145
+ return fromChoice;
146
+ // Hermes also emits custom SSE tool-progress events; ignore those for the
147
+ // chat body (they keep the HTTP connection alive, which is the point).
148
+ if (typeof obj.data?.content === "string")
149
+ return obj.data.content;
150
+ if (typeof obj.data?.text === "string")
151
+ return obj.data.text;
152
+ return "";
153
+ }
@@ -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/>
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.10",
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",