@nopeek/agent-bridge 0.7.5 → 0.7.6

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
@@ -364,6 +364,7 @@ export function hermesBrain(cfg) {
364
364
  out += `${line}\n`;
365
365
  onChunk?.(`${line}\n`);
366
366
  };
367
+ let timedOut = false;
367
368
  const finish = () => {
368
369
  if (settled)
369
370
  return;
@@ -381,10 +382,11 @@ export function hermesBrain(cfg) {
381
382
  // "No session found" can land on stderr in some hermes builds.
382
383
  if (!out.trim() && HERMES_NO_SESSION.test(stderr.trim()))
383
384
  noSession = true;
384
- resolvePromise({ reply: out.trim(), noSession, sessionId, stderr });
385
+ resolvePromise({ reply: out.trim(), noSession, sessionId, stderr, timedOut });
385
386
  };
386
387
  const timer = setTimeout(() => {
387
388
  console.error(`${tag} timed out after ${cfg.brainTimeoutMs / 1000}s, killing`);
389
+ timedOut = true;
388
390
  child.kill("SIGKILL");
389
391
  finish();
390
392
  }, cfg.brainTimeoutMs);
@@ -401,7 +403,7 @@ export function hermesBrain(cfg) {
401
403
  console.error(`${tag} spawn error: ${err.message}`);
402
404
  if (!settled) {
403
405
  settled = true;
404
- resolvePromise({ reply: "", noSession: false, sessionId: null, stderr: err.message });
406
+ resolvePromise({ reply: "", noSession: false, sessionId: null, stderr: err.message, timedOut: false });
405
407
  }
406
408
  });
407
409
  child.on("close", () => {
@@ -448,6 +450,14 @@ export function hermesBrain(cfg) {
448
450
  if (!run.reply) {
449
451
  if (run.stderr.trim())
450
452
  console.error(`${tag} stderr: ${run.stderr.slice(0, 1000)}`);
453
+ // Distinguish the actual failure instead of always blaming "no
454
+ // authenticated provider" — that message was previously hardcoded for
455
+ // EVERY empty-reply cause (including a plain timeout on a slow cold
456
+ // start), which misdirects the user to run a command that isn't the fix.
457
+ if (run.timedOut) {
458
+ const secs = Math.round(cfg.brainTimeoutMs / 1000);
459
+ return `⚠️ My brain took longer than ${secs}s to respond and I had to give up — this can happen on the very first message while a session/provider is warming up. Try messaging me again.`;
460
+ }
451
461
  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
462
  }
453
463
  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.6";
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.6";
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/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.6",
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",