@nopeek/agent-bridge 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/backends.js CHANGED
@@ -139,19 +139,31 @@ function runClaudeOnce(bin, args, text, cwd, timeoutMs, tag, onDelta) {
139
139
  console.error(`${tag} stderr: ${stderr.slice(0, 1000)}`);
140
140
  resolvePromise({ reply, exitCode });
141
141
  };
142
- const timer = setTimeout(() => {
143
- console.error(`${tag} timed out after ${timeoutMs / 1000}s, killing`);
144
- child.kill("SIGKILL");
145
- finish(null);
146
- }, timeoutMs);
142
+ // IDLE timeout (see the identical fix in the hermes runOnce() above): reset
143
+ // on every byte of output so a genuinely agentic, tool-using turn can run as
144
+ // long as it keeps producing output, and only true silence kills it.
145
+ let timer;
146
+ const armIdleTimer = () => {
147
+ clearTimeout(timer);
148
+ timer = setTimeout(() => {
149
+ console.error(`${tag} idle ${timeoutMs / 1000}s (no output), killing`);
150
+ child.kill("SIGKILL");
151
+ finish(null);
152
+ }, timeoutMs);
153
+ };
154
+ armIdleTimer();
147
155
  child.stdout.on("data", (d) => {
156
+ armIdleTimer();
148
157
  lineBuf += d.toString();
149
158
  const lines = lineBuf.split("\n");
150
159
  lineBuf = lines.pop() ?? "";
151
160
  for (const line of lines)
152
161
  handleLine(line);
153
162
  });
154
- child.stderr.on("data", (d) => (stderr += d.toString()));
163
+ child.stderr.on("data", (d) => {
164
+ armIdleTimer();
165
+ stderr += d.toString();
166
+ });
155
167
  child.on("error", (err) => {
156
168
  clearTimeout(timer);
157
169
  console.error(`${tag} spawn error: ${err.message}`);
@@ -364,6 +376,7 @@ export function hermesBrain(cfg) {
364
376
  out += `${line}\n`;
365
377
  onChunk?.(`${line}\n`);
366
378
  };
379
+ let timedOut = false;
367
380
  const finish = () => {
368
381
  if (settled)
369
382
  return;
@@ -381,27 +394,43 @@ export function hermesBrain(cfg) {
381
394
  // "No session found" can land on stderr in some hermes builds.
382
395
  if (!out.trim() && HERMES_NO_SESSION.test(stderr.trim()))
383
396
  noSession = true;
384
- resolvePromise({ reply: out.trim(), noSession, sessionId, stderr });
397
+ resolvePromise({ reply: out.trim(), noSession, sessionId, stderr, timedOut });
385
398
  };
386
- const timer = setTimeout(() => {
387
- console.error(`${tag} timed out after ${cfg.brainTimeoutMs / 1000}s, killing`);
388
- child.kill("SIGKILL");
389
- finish();
390
- }, cfg.brainTimeoutMs);
399
+ // IDLE timeout, not a flat wall-clock one: reset on every byte of output
400
+ // (stdout OR stderr either is a sign the process is alive and working,
401
+ // not hung). A genuinely agentic turn (tool use, indexing a fresh repo on
402
+ // a brand-new profile) can legitimately run for minutes while still
403
+ // producing periodic status output — a flat timer kills that productive
404
+ // work indiscriminately. Only true SILENCE this long means hung.
405
+ let timer;
406
+ const armIdleTimer = () => {
407
+ clearTimeout(timer);
408
+ timer = setTimeout(() => {
409
+ console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output), killing`);
410
+ timedOut = true;
411
+ child.kill("SIGKILL");
412
+ finish();
413
+ }, cfg.brainTimeoutMs);
414
+ };
415
+ armIdleTimer();
391
416
  child.stdout.on("data", (d) => {
417
+ armIdleTimer();
392
418
  lineBuf += d.toString();
393
419
  const lines = lineBuf.split("\n");
394
420
  lineBuf = lines.pop() ?? "";
395
421
  for (const line of lines)
396
422
  handleLine(line);
397
423
  });
398
- child.stderr.on("data", (d) => (stderr += d.toString()));
424
+ child.stderr.on("data", (d) => {
425
+ armIdleTimer();
426
+ stderr += d.toString();
427
+ });
399
428
  child.on("error", (err) => {
400
429
  clearTimeout(timer);
401
430
  console.error(`${tag} spawn error: ${err.message}`);
402
431
  if (!settled) {
403
432
  settled = true;
404
- resolvePromise({ reply: "", noSession: false, sessionId: null, stderr: err.message });
433
+ resolvePromise({ reply: "", noSession: false, sessionId: null, stderr: err.message, timedOut: false });
405
434
  }
406
435
  });
407
436
  child.on("close", () => {
@@ -448,6 +477,14 @@ export function hermesBrain(cfg) {
448
477
  if (!run.reply) {
449
478
  if (run.stderr.trim())
450
479
  console.error(`${tag} stderr: ${run.stderr.slice(0, 1000)}`);
480
+ // Distinguish the actual failure instead of always blaming "no
481
+ // authenticated provider" — that message was previously hardcoded for
482
+ // EVERY empty-reply cause (including a plain timeout on a slow cold
483
+ // start), which misdirects the user to run a command that isn't the fix.
484
+ if (run.timedOut) {
485
+ const secs = Math.round(cfg.brainTimeoutMs / 1000);
486
+ return `⚠️ My brain went quiet for over ${secs}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.`;
487
+ }
451
488
  return "⚠️ My brain isn't reachable right now — Hermes has no authenticated provider on the host. Run 'hermes model' there, then message me again.";
452
489
  }
453
490
  return run.reply;
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.5";
2
+ export declare const VERSION = "0.7.7";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
package/dist/bridge.js CHANGED
@@ -17,7 +17,7 @@ import { resolveBrain } from "./brain.js";
17
17
  import { provisionSoul, provisionHermesProfile } from "./backends.js";
18
18
  import { reportCapabilities } from "./capabilities.js";
19
19
  import { isBrainBackend } from "./config.js";
20
- export const VERSION = "0.7.5";
20
+ export const VERSION = "0.7.7";
21
21
  /** How often to re-probe + report brain availability to the server. */
22
22
  const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
23
23
  // ---- Reconnect watchdog -----------------------------------------------------
@@ -33,6 +33,11 @@ const WATCHDOG_INTERVAL_MS = 30_000;
33
33
  const SOFT_STALE_MS = 90_000;
34
34
  /** Every bot down longer than this (sustained) → exit for a clean relaunch. */
35
35
  const HARD_STALE_MS = 5 * 60_000;
36
+ /** A SINGLE bot down this long despite repeated forced restarts → exit too.
37
+ * Covers a bot wedged for a reason forceRestart() can't fix from inside the
38
+ * process (e.g. a stuck OS-level DNS resolution) while OTHER bots are fine —
39
+ * the all-bots-down HARD_STALE_MS check alone would never catch that case. */
40
+ const ULTRA_STALE_MS = 10 * 60_000;
36
41
  export class PairError extends Error {
37
42
  code;
38
43
  constructor(code, message) {
@@ -357,20 +362,25 @@ export class BridgeApp {
357
362
  let allLiveness = [];
358
363
  for (const r of this.runtimes)
359
364
  allLiveness = allLiveness.concat(r.watchdogSoftPass(SOFT_STALE_MS));
360
- // Layer 4 (the guarantee): if EVERY bot across ALL pairings has been down
361
- // longer than HARD_STALE_MS and there is at least one bot, soft recovery
362
- // has failed the process networking is wedged. Require the condition
363
- // sustained over two consecutive checks, then exit for a clean relaunch.
365
+ // Layer 4 (the guarantee): exit for a clean relaunch when soft recovery has
366
+ // clearly failed either EVERY bot is down past HARD_STALE_MS, or a SINGLE
367
+ // bot has been down past ULTRA_STALE_MS despite repeated forceRestart()
368
+ // calls (a bot wedged for a reason forceRestart can't fix from inside the
369
+ // process — e.g. a stuck OS-level DNS resolution — while other bots are
370
+ // fine; the all-down check alone would never catch that). Require the
371
+ // condition sustained over two consecutive checks, then exit.
364
372
  const hasBots = allLiveness.length > 0;
365
373
  const allHardStale = hasBots && allLiveness.every((ms) => ms > HARD_STALE_MS);
366
- if (this.paired && hasBots && allHardStale) {
374
+ const anyUltraStale = hasBots && allLiveness.some((ms) => ms > ULTRA_STALE_MS);
375
+ if (this.paired && hasBots && (allHardStale || anyUltraStale)) {
367
376
  this.hardStaleStreak++;
368
- const mins = Math.round(HARD_STALE_MS / 60_000);
377
+ const mins = Math.round((allHardStale ? HARD_STALE_MS : ULTRA_STALE_MS) / 60_000);
378
+ const reason = allHardStale ? `no bot connected for ${mins}min across ${allLiveness.length} bot(s)` : `a bot stuck down > ${mins}min`;
369
379
  if (this.hardStaleStreak >= 2) {
370
- console.error(`[watchdog] no bot connected for ${mins}min across ${allLiveness.length} bot(s) — exiting for a clean relaunch`);
380
+ console.error(`[watchdog] ${reason} — exiting for a clean relaunch`);
371
381
  process.exit(1);
372
382
  }
373
- console.error(`[watchdog] all ${allLiveness.length} bot(s) down > ${mins}min — will exit for a clean relaunch if still down next check`);
383
+ console.error(`[watchdog] ${reason} — will exit for a clean relaunch if still stuck next check`);
374
384
  }
375
385
  else {
376
386
  this.hardStaleStreak = 0;
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { loadConfig, HELP } from "./config.js";
9
9
  import { BridgeApp, VERSION } from "./bridge.js";
10
10
  import { startLocalApi } from "./localapi.js";
11
- import { installService, uninstallService, printStatus } from "./service.js";
11
+ import { installService, uninstallService, printStatus, checkForUpdate } from "./service.js";
12
12
  // ---------------------------------------------------------------- guards ----
13
13
  // The bridge must never die to a stray rejection deep inside a WS/crypto
14
14
  // callback — one flaky bot cannot take the fleet down.
@@ -22,6 +22,14 @@ if (typeof WebSocket === "undefined" || !globalThis.crypto?.subtle) {
22
22
  console.error(`@nopeek/agent-bridge needs Node >= 22 (global WebSocket + fetch + WebCrypto). Current: ${process.version}`);
23
23
  process.exit(1);
24
24
  }
25
+ // Every log line gets an ISO timestamp. The service log otherwise has no time
26
+ // axis at all — investigating an incident (e.g. "which network blip closed
27
+ // every bot's socket, and when did the watchdog recover it") is only possible
28
+ // if log lines can be correlated against system events by clock time.
29
+ for (const level of ["log", "error", "warn"]) {
30
+ const orig = console[level].bind(console);
31
+ console[level] = (...args) => orig(`[${new Date().toISOString()}]`, ...args);
32
+ }
25
33
  // ----------------------------------------------------------- subcommands ----
26
34
  const argv = process.argv.slice(2);
27
35
  const SUBCOMMANDS = new Set(["install", "uninstall", "status", "run", "help"]);
@@ -100,6 +108,15 @@ catch (err) {
100
108
  process.exit(1);
101
109
  }
102
110
  app.start();
111
+ // Periodic self-update: a published fix (e.g. a reconnect-reliability bug)
112
+ // otherwise sits unused on already-installed bridges until someone manually
113
+ // reinstalls. Check shortly after startup, then every 6h; each check exits the
114
+ // process on a successful update so the always-restart service (KeepAlive)
115
+ // relaunches running the new code. No-op on install methods it doesn't
116
+ // recognize (see checkForUpdate) — never touches a dev checkout.
117
+ const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60_000;
118
+ setTimeout(() => void checkForUpdate(VERSION, cfg.homeDir), 60_000).unref();
119
+ setInterval(() => void checkForUpdate(VERSION, cfg.homeDir), UPDATE_CHECK_INTERVAL_MS).unref();
103
120
  const shutdown = (signal) => {
104
121
  console.log(`[bridge] ${signal} — shutting down`);
105
122
  app.stop();
package/dist/config.d.ts CHANGED
@@ -82,9 +82,9 @@ export interface BridgeConfig {
82
82
  export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
83
83
  export declare const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
84
84
  export declare const DEFAULT_PORT = 8790;
85
- export declare const DEFAULT_BRAIN_TIMEOUT_MS = 180000;
85
+ export declare const DEFAULT_BRAIN_TIMEOUT_MS = 300000;
86
86
  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 180000 (env BRAIN_TIMEOUT_MS)\n --port <port> Local control API port, default 8790 (env NOPEEK_BRIDGE_PORT)\n --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)\n --home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)\n --config <path> Config file, default ./nopeek-bridge.config.json\n -h, --help Show this help\n\nWith no brain configured, bots run in echo mode (\"You said: \u2026\") \u2014 a zero-config smoke test.\nPairing and brains can be managed entirely from the NoPeek app once the service is running.";
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.";
88
88
  /** Load config from argv + env + cwd config file + home settings. Never
89
89
  * requires pairing — an unpaired bridge waits for the app to pair it. */
90
90
  export declare function loadConfig(argv?: string[]): BridgeConfig;
package/dist/config.js CHANGED
@@ -15,7 +15,12 @@ export function isBrainBackend(v) {
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
- export const DEFAULT_BRAIN_TIMEOUT_MS = 180_000;
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;
19
24
  export function defaultHomeDir() {
20
25
  return process.env.NOPEEK_BRIDGE_HOME || join(homedir(), ".nopeek-bridge");
21
26
  }
package/dist/service.d.ts CHANGED
@@ -1,4 +1,16 @@
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
+ export declare function checkForUpdate(currentVersion: string, homeDir: string): Promise<void>;
2
14
  export declare function installService(cfg: BridgeConfig, noOpen?: boolean): Promise<void>;
3
15
  export declare function uninstallService(): void;
4
16
  export declare function printStatus(port: number): Promise<void>;
package/dist/service.js CHANGED
@@ -32,6 +32,61 @@ function resolveEntrypoint(homeDir) {
32
32
  throw new Error(`expected ${entry} after install — not found`);
33
33
  return entry;
34
34
  }
35
+ /**
36
+ * Periodic self-update: compares the running version against npm's published
37
+ * `latest` and, if newer, re-installs using the SAME method this process is
38
+ * actually running from (global npm root, or the private <home>/app copy —
39
+ * see resolveEntrypoint above) and exits so the always-restart service
40
+ * relaunches running the fresh code. Only acts on an install method it
41
+ * recognizes — a pnpm-linked dev checkout or anything unrecognized is left
42
+ * alone (safety over completeness). This is how a published fix (like a
43
+ * reconnect-reliability bug) actually reaches already-installed bridges
44
+ * instead of sitting unused until someone manually reinstalls.
45
+ */
46
+ export async function checkForUpdate(currentVersion, homeDir) {
47
+ let latest;
48
+ try {
49
+ const res = await fetch("https://registry.npmjs.org/@nopeek/agent-bridge/latest", {
50
+ signal: AbortSignal.timeout(10_000),
51
+ });
52
+ if (!res.ok)
53
+ return;
54
+ const body = (await res.json());
55
+ latest = body.version ?? "";
56
+ }
57
+ catch {
58
+ return; // offline / registry unreachable — try again next check
59
+ }
60
+ if (!latest || latest === currentVersion)
61
+ return;
62
+ const self = realpathSync(process.argv[1]);
63
+ let installArgs = null;
64
+ try {
65
+ const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim();
66
+ if (globalRoot && self.startsWith(globalRoot))
67
+ installArgs = ["install", "-g", "@nopeek/agent-bridge@latest"];
68
+ }
69
+ catch {
70
+ /* npm not on PATH / no permission to query — fall through to the private-copy check */
71
+ }
72
+ if (!installArgs) {
73
+ const privateHome = join(homeDir, "app");
74
+ if (self.startsWith(privateHome))
75
+ installArgs = ["install", "--prefix", privateHome, "@nopeek/agent-bridge@latest"];
76
+ }
77
+ if (!installArgs) {
78
+ console.log(`[update] v${latest} is available (running v${currentVersion}) but this install (${self}) isn't a recognized global/private copy — skipping auto-update`);
79
+ return;
80
+ }
81
+ console.log(`[update] v${latest} available (running v${currentVersion}) — updating via 'npm ${installArgs.join(" ")}'`);
82
+ const r = spawnSync("npm", installArgs, { stdio: "inherit" });
83
+ if (r.status !== 0) {
84
+ console.error(`[update] npm install failed (exit ${r.status}) — will retry next check`);
85
+ return;
86
+ }
87
+ console.log(`[update] updated to v${latest} — exiting for the service to relaunch with the new version`);
88
+ process.exit(0);
89
+ }
35
90
  function launchctl(args, ignoreFailure = false) {
36
91
  try {
37
92
  execFileSync("launchctl", args, { stdio: "pipe" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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",