@nopeek/agent-bridge 0.7.4 → 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/bot.d.ts CHANGED
@@ -21,6 +21,9 @@ export declare class BotRunner {
21
21
  private np;
22
22
  private stopped;
23
23
  private refreshTimer;
24
+ private lastConnectedAt;
25
+ private lastForcedRestart;
26
+ private running;
24
27
  private seen;
25
28
  private channelCache;
26
29
  private allowed;
@@ -38,6 +41,19 @@ export declare class BotRunner {
38
41
  private log;
39
42
  private logErr;
40
43
  constructor(info: BotInfo, cfg: BridgeConfig, pairing: Pairing);
44
+ /** Watchdog liveness: 0 while currently connected, else ms since the bot was
45
+ * last connected (seeded at construction so a never-connected bot ages too). */
46
+ msSinceHealthy(): number;
47
+ /** When the watchdog last force-restarted this runner (null = never). */
48
+ get lastForcedRestartAt(): number | null;
49
+ /**
50
+ * Watchdog hard restart: tear the SDK client down completely and rebuild it
51
+ * from scratch (fresh session mint + NoPeek.connect) — the proven-working
52
+ * path when the SDK's own reconnect wedges. Unlike stop(), this does NOT
53
+ * permanently stop the runner; it clears `stopped` so the connect loop
54
+ * actually re-enters. Idempotent enough to call every watchdog tick.
55
+ */
56
+ forceRestart(): void;
41
57
  /** Re-read the effective brain (e.g. after a live server backend change) so
42
58
  * status reflects it immediately. The next message re-resolves regardless. */
43
59
  refreshBrainKind(): void;
package/dist/bot.js CHANGED
@@ -24,6 +24,15 @@ export class BotRunner {
24
24
  np = null;
25
25
  stopped = false;
26
26
  refreshTimer = null;
27
+ // Watchdog liveness. Seeded at construction so a bot that NEVER connects still
28
+ // ages from birth. Set to now() whenever the socket becomes connected; while
29
+ // disconnected it holds the last-healthy instant, so msSinceHealthy() reports
30
+ // how long the bot has been down.
31
+ lastConnectedAt = Date.now();
32
+ lastForcedRestart = null;
33
+ // Guards against two concurrent connect loops if forceRestart() lands while a
34
+ // backoff retry loop is still running.
35
+ running = false;
27
36
  seen = new Set();
28
37
  channelCache = new Map();
29
38
  // Access control: bots are PRIVATE by default. `allowed` is the set of user
@@ -76,6 +85,39 @@ export class BotRunner {
76
85
  this.log = (m) => console.log(`${tag} ${m}`);
77
86
  this.logErr = (m) => console.error(`${tag} ${m}`);
78
87
  }
88
+ /** Watchdog liveness: 0 while currently connected, else ms since the bot was
89
+ * last connected (seeded at construction so a never-connected bot ages too). */
90
+ msSinceHealthy() {
91
+ return this.connected ? 0 : Date.now() - this.lastConnectedAt;
92
+ }
93
+ /** When the watchdog last force-restarted this runner (null = never). */
94
+ get lastForcedRestartAt() {
95
+ return this.lastForcedRestart;
96
+ }
97
+ /**
98
+ * Watchdog hard restart: tear the SDK client down completely and rebuild it
99
+ * from scratch (fresh session mint + NoPeek.connect) — the proven-working
100
+ * path when the SDK's own reconnect wedges. Unlike stop(), this does NOT
101
+ * permanently stop the runner; it clears `stopped` so the connect loop
102
+ * actually re-enters. Idempotent enough to call every watchdog tick.
103
+ */
104
+ forceRestart() {
105
+ if (this.stopped)
106
+ return; // truly stopped (unpair) — never resurrect
107
+ this.lastForcedRestart = Date.now();
108
+ if (this.refreshTimer)
109
+ clearTimeout(this.refreshTimer);
110
+ this.refreshTimer = null;
111
+ try {
112
+ this.np?.close();
113
+ }
114
+ catch {
115
+ /* best-effort */
116
+ }
117
+ this.np = null;
118
+ this.connected = false;
119
+ this.start(); // run()'s `running` guard prevents a duplicate loop
120
+ }
79
121
  /** Re-read the effective brain (e.g. after a live server backend change) so
80
122
  * status reflects it immediately. The next message re-resolves regardless. */
81
123
  refreshBrainKind() {
@@ -251,19 +293,27 @@ export class BotRunner {
251
293
  }
252
294
  }
253
295
  async run() {
254
- this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
255
- let delay = 2_000;
256
- while (!this.stopped) {
257
- try {
258
- await this.connectOnce();
259
- return; // connected; SDK auto-reconnects, session refresh re-enters via reconnect()
260
- }
261
- catch (err) {
262
- this.logErr(`connect failed: ${err.message} — retrying in ${delay / 1000}s`);
263
- await sleep(delay);
264
- delay = Math.min(delay * 2, MAX_BACKOFF_MS);
296
+ if (this.running)
297
+ return; // a connect loop is already active — never double it
298
+ this.running = true;
299
+ try {
300
+ this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
301
+ let delay = 2_000;
302
+ while (!this.stopped) {
303
+ try {
304
+ await this.connectOnce();
305
+ return; // connected; SDK auto-reconnects, session refresh re-enters via reconnect()
306
+ }
307
+ catch (err) {
308
+ this.logErr(`connect failed: ${err.message} — retrying in ${delay / 1000}s`);
309
+ await sleep(delay);
310
+ delay = Math.min(delay * 2, MAX_BACKOFF_MS);
311
+ }
265
312
  }
266
313
  }
314
+ finally {
315
+ this.running = false;
316
+ }
267
317
  }
268
318
  async connectOnce() {
269
319
  const session = await this.mintSession();
@@ -286,11 +336,13 @@ export class BotRunner {
286
336
  }
287
337
  this.np = np;
288
338
  this.connected = true;
339
+ this.lastConnectedAt = Date.now();
289
340
  this.channelCache.clear();
290
341
  this.ownerPresence.clear();
291
342
  this.log(`connected, device ${np.deviceId} (platform=server), store ${store.file}`);
292
343
  np.on("connected", (() => {
293
344
  this.connected = true;
345
+ this.lastConnectedAt = Date.now();
294
346
  this.log(`ws connected`);
295
347
  }));
296
348
  np.on("disconnected", (() => {
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.4";
2
+ export declare const VERSION = "0.7.6";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
@@ -39,6 +39,9 @@ export declare class BridgeApp {
39
39
  readonly startedAt: number;
40
40
  private runtimes;
41
41
  private capabilitiesTimer;
42
+ private watchdogTimer;
43
+ private hardStaleStreak;
44
+ private lastWatchdogAt;
42
45
  private stopped;
43
46
  constructor(cfg: BridgeConfig);
44
47
  get paired(): boolean;
@@ -48,6 +51,12 @@ export declare class BridgeApp {
48
51
  start(): void;
49
52
  stop(): void;
50
53
  private startRuntime;
54
+ /** Reconnect watchdog. Sweeps every pairing's runners + control sockets on a
55
+ * fixed interval and force-recovers anything stuck; as a last resort, exits
56
+ * so the always-restart service relaunches a clean process. unref'd so it
57
+ * never keeps the process alive on its own. */
58
+ private ensureWatchdogTimer;
59
+ private watchdogTick;
51
60
  /** Re-probe + report brain availability every ~5 min (once per pairing) so a
52
61
  * login/logout on this computer surfaces in each account's picker without a
53
62
  * reconnect. onAuthed covers initial + reconnect reports; this covers drift.
package/dist/bridge.js CHANGED
@@ -17,9 +17,27 @@ 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.4";
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
+ // ---- Reconnect watchdog -----------------------------------------------------
24
+ // A layered safety net for the exact wedge seen in the field: after a network
25
+ // routing change all bot sockets closed with 1006 and the process's networking
26
+ // got stuck — the SDK's own reconnect never recovered, though a fresh process
27
+ // connected fine. So we don't trust internal reconnect: we (a) force a hard
28
+ // rebuild of any runner/control socket that's been down too long, and (b) as a
29
+ // last resort exit so the always-restart service relaunches a clean process.
30
+ /** How often the watchdog sweeps every pairing's runners + control sockets. */
31
+ const WATCHDOG_INTERVAL_MS = 30_000;
32
+ /** Down longer than this → force a hard restart (rebuild from scratch). */
33
+ const SOFT_STALE_MS = 90_000;
34
+ /** Every bot down longer than this (sustained) → exit for a clean relaunch. */
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;
23
41
  export class PairError extends Error {
24
42
  code;
25
43
  constructor(code, message) {
@@ -140,9 +158,48 @@ class PairingRuntime {
140
158
  userId: b.info.userId,
141
159
  connected: b.connected,
142
160
  handled: b.handled,
161
+ msSinceHealthy: b.msSinceHealthy(),
162
+ lastForcedRestart: b.lastForcedRestartAt,
143
163
  brain: resolveBrain(this.cfg, b.info.handle).kind,
144
164
  }));
145
165
  }
166
+ /**
167
+ * Watchdog soft pass for THIS pairing. Force-restart (full rebuild) any bot
168
+ * that's been disconnected longer than softMs, debounced so a single bot is
169
+ * restarted at most once per softMs window. Also force-recreate the control
170
+ * socket if it's been un-authed longer than softMs (its internal backoff can
171
+ * wedge too). Returns each bot's msSinceHealthy so the app can evaluate the
172
+ * hard, process-level catch-all across all pairings.
173
+ */
174
+ watchdogSoftPass(softMs) {
175
+ if (this.stopped)
176
+ return [];
177
+ const now = Date.now();
178
+ const liveness = [];
179
+ for (const b of this.bots.values()) {
180
+ const ms = b.msSinceHealthy();
181
+ liveness.push(ms);
182
+ if (ms <= softMs)
183
+ continue;
184
+ const last = b.lastForcedRestartAt;
185
+ if (last !== null && now - last < softMs)
186
+ continue; // debounce: one restart per window
187
+ console.log(`${this.tag} [watchdog] @${b.info.handle} disconnected ${Math.round(ms / 1000)}s — forcing hard restart`);
188
+ b.forceRestart();
189
+ }
190
+ const control = this.control;
191
+ if (control) {
192
+ const ms = control.msSinceHealthy();
193
+ if (ms > softMs) {
194
+ const last = control.lastForcedRestartAt;
195
+ if (last === null || now - last >= softMs) {
196
+ console.log(`${this.tag} [watchdog] control socket down ${Math.round(ms / 1000)}s — forcing a fresh socket`);
197
+ control.forceReconnect();
198
+ }
199
+ }
200
+ }
201
+ return liveness;
202
+ }
146
203
  // ---------------------------------------------------------------- core ----
147
204
  startBot(info) {
148
205
  if (this.bots.has(info.userId))
@@ -229,6 +286,11 @@ export class BridgeApp {
229
286
  startedAt = Date.now();
230
287
  runtimes = [];
231
288
  capabilitiesTimer = null;
289
+ watchdogTimer = null;
290
+ // Hard exit needs the all-bots-stale condition sustained across TWO
291
+ // consecutive checks — a single transient blip must never restart the process.
292
+ hardStaleStreak = 0;
293
+ lastWatchdogAt = 0;
232
294
  stopped = false;
233
295
  constructor(cfg) {
234
296
  this.cfg = cfg;
@@ -261,6 +323,10 @@ export class BridgeApp {
261
323
  clearInterval(this.capabilitiesTimer);
262
324
  this.capabilitiesTimer = null;
263
325
  }
326
+ if (this.watchdogTimer) {
327
+ clearInterval(this.watchdogTimer);
328
+ this.watchdogTimer = null;
329
+ }
264
330
  for (const r of this.runtimes)
265
331
  r.stop();
266
332
  this.runtimes = [];
@@ -274,6 +340,51 @@ export class BridgeApp {
274
340
  this.runtimes.push(rt);
275
341
  rt.start();
276
342
  this.ensureCapabilitiesTimer();
343
+ this.ensureWatchdogTimer();
344
+ }
345
+ /** Reconnect watchdog. Sweeps every pairing's runners + control sockets on a
346
+ * fixed interval and force-recovers anything stuck; as a last resort, exits
347
+ * so the always-restart service relaunches a clean process. unref'd so it
348
+ * never keeps the process alive on its own. */
349
+ ensureWatchdogTimer() {
350
+ if (this.watchdogTimer)
351
+ return;
352
+ this.watchdogTimer = setInterval(() => this.watchdogTick(), WATCHDOG_INTERVAL_MS);
353
+ this.watchdogTimer.unref?.();
354
+ }
355
+ watchdogTick() {
356
+ if (this.stopped || !this.paired) {
357
+ this.hardStaleStreak = 0;
358
+ return;
359
+ }
360
+ this.lastWatchdogAt = Date.now();
361
+ // Layers 2+3: soft force-recovery per pairing (bots + control socket).
362
+ let allLiveness = [];
363
+ for (const r of this.runtimes)
364
+ allLiveness = allLiveness.concat(r.watchdogSoftPass(SOFT_STALE_MS));
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.
372
+ const hasBots = allLiveness.length > 0;
373
+ const allHardStale = hasBots && allLiveness.every((ms) => ms > HARD_STALE_MS);
374
+ const anyUltraStale = hasBots && allLiveness.some((ms) => ms > ULTRA_STALE_MS);
375
+ if (this.paired && hasBots && (allHardStale || anyUltraStale)) {
376
+ this.hardStaleStreak++;
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`;
379
+ if (this.hardStaleStreak >= 2) {
380
+ console.error(`[watchdog] ${reason} — exiting for a clean relaunch`);
381
+ process.exit(1);
382
+ }
383
+ console.error(`[watchdog] ${reason} — will exit for a clean relaunch if still stuck next check`);
384
+ }
385
+ else {
386
+ this.hardStaleStreak = 0;
387
+ }
277
388
  }
278
389
  /** Re-probe + report brain availability every ~5 min (once per pairing) so a
279
390
  * login/logout on this computer surfaces in each account's picker without a
@@ -342,6 +453,7 @@ export class BridgeApp {
342
453
  * Returns false when a runtimeId was given but matches no pairing.
343
454
  */
344
455
  unpair(runtimeId) {
456
+ this.hardStaleStreak = 0; // never carry a stale streak across a topology change
345
457
  if (!runtimeId) {
346
458
  console.log(`[bridge] unpairing ALL ${this.cfg.pairings.length} account(s) — stopping bots and forgetting the pairing secrets`);
347
459
  for (const r of this.runtimes)
@@ -499,6 +611,15 @@ export class BridgeApp {
499
611
  },
500
612
  // Legacy flat list = every pairing's bots (pre-0.6 clients read this).
501
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
+ },
502
623
  };
503
624
  }
504
625
  }
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/control.d.ts CHANGED
@@ -42,12 +42,25 @@ export declare class ControlSocket {
42
42
  private ws;
43
43
  private stopped;
44
44
  private heartbeat;
45
+ private reconnectTimer;
45
46
  private delay;
47
+ private lastConnectedAt;
48
+ private lastForcedRestart;
46
49
  /** Log tag — with several sockets running, lines must say whose they are. */
47
50
  private tag;
48
51
  constructor(pairing: Pairing, handlers: ControlHandlers);
49
52
  start(): void;
50
53
  stop(): void;
54
+ /** Watchdog liveness: 0 while authenticated, else ms since the last auth.ok. */
55
+ msSinceHealthy(): number;
56
+ /** When the watchdog last force-recreated this socket (null = never). */
57
+ get lastForcedRestartAt(): number | null;
58
+ /**
59
+ * Watchdog force-recovery: cancel any pending backoff timer, tear down the
60
+ * current socket and open a brand-new one immediately — bypassing the
61
+ * internal backoff, which can itself wedge after a network event.
62
+ */
63
+ forceReconnect(): void;
51
64
  private connect;
52
65
  private handleFrame;
53
66
  private scheduleReconnect;
package/dist/control.js CHANGED
@@ -10,7 +10,13 @@ export class ControlSocket {
10
10
  ws = null;
11
11
  stopped = false;
12
12
  heartbeat = null;
13
+ reconnectTimer = null;
13
14
  delay = 2_000;
15
+ // Watchdog liveness. Seeded at construction; set to now() on every auth.ok
16
+ // (the only "healthy" moment for a control socket). While un-authed it holds
17
+ // the last-authed instant so msSinceHealthy() reports how long we've been out.
18
+ lastConnectedAt = Date.now();
19
+ lastForcedRestart = null;
14
20
  /** Log tag — with several sockets running, lines must say whose they are. */
15
21
  tag;
16
22
  constructor(pairing, handlers) {
@@ -23,6 +29,14 @@ export class ControlSocket {
23
29
  }
24
30
  stop() {
25
31
  this.stopped = true;
32
+ if (this.reconnectTimer) {
33
+ clearTimeout(this.reconnectTimer);
34
+ this.reconnectTimer = null;
35
+ }
36
+ if (this.heartbeat) {
37
+ clearInterval(this.heartbeat);
38
+ this.heartbeat = null;
39
+ }
26
40
  try {
27
41
  this.ws?.close();
28
42
  }
@@ -32,6 +46,42 @@ export class ControlSocket {
32
46
  this.ws = null;
33
47
  this.connected = false;
34
48
  }
49
+ /** Watchdog liveness: 0 while authenticated, else ms since the last auth.ok. */
50
+ msSinceHealthy() {
51
+ return this.connected ? 0 : Date.now() - this.lastConnectedAt;
52
+ }
53
+ /** When the watchdog last force-recreated this socket (null = never). */
54
+ get lastForcedRestartAt() {
55
+ return this.lastForcedRestart;
56
+ }
57
+ /**
58
+ * Watchdog force-recovery: cancel any pending backoff timer, tear down the
59
+ * current socket and open a brand-new one immediately — bypassing the
60
+ * internal backoff, which can itself wedge after a network event.
61
+ */
62
+ forceReconnect() {
63
+ if (this.stopped)
64
+ return;
65
+ this.lastForcedRestart = Date.now();
66
+ if (this.reconnectTimer) {
67
+ clearTimeout(this.reconnectTimer);
68
+ this.reconnectTimer = null;
69
+ }
70
+ if (this.heartbeat) {
71
+ clearInterval(this.heartbeat);
72
+ this.heartbeat = null;
73
+ }
74
+ try {
75
+ this.ws?.close();
76
+ }
77
+ catch {
78
+ /* best-effort */
79
+ }
80
+ this.ws = null;
81
+ this.connected = false;
82
+ this.delay = 2_000; // fresh start — reset backoff
83
+ this.connect();
84
+ }
35
85
  connect() {
36
86
  if (this.stopped)
37
87
  return;
@@ -47,6 +97,13 @@ export class ControlSocket {
47
97
  }
48
98
  this.ws = ws;
49
99
  ws.onopen = () => {
100
+ if (this.ws !== ws) {
101
+ try {
102
+ ws.close();
103
+ }
104
+ catch { /* stale */ }
105
+ return;
106
+ } // superseded
50
107
  console.log(`${this.tag} socket open, awaiting auth.ok`);
51
108
  // Heartbeat: the server closes sockets idle >60s; the SDK pings every
52
109
  // 25s and so do we — otherwise the control socket flaps once a minute
@@ -76,6 +133,10 @@ export class ControlSocket {
76
133
  // onclose always follows; log there.
77
134
  };
78
135
  ws.onclose = (ev) => {
136
+ // A newer socket (e.g. from a watchdog forceReconnect) already superseded
137
+ // this one — ignore this stale close so we never stack reconnects.
138
+ if (this.ws !== ws)
139
+ return;
79
140
  if (this.heartbeat) {
80
141
  clearInterval(this.heartbeat);
81
142
  this.heartbeat = null;
@@ -92,6 +153,7 @@ export class ControlSocket {
92
153
  switch (frame.type) {
93
154
  case "auth.ok": {
94
155
  this.connected = true;
156
+ this.lastConnectedAt = Date.now();
95
157
  this.delay = 2_000; // reset backoff on a good auth
96
158
  this.runtimeId = String(frame.runtimeId ?? "");
97
159
  console.log(`${this.tag} authenticated as runtime ${this.runtimeId} (control=${String(frame.control)})`);
@@ -147,9 +209,16 @@ export class ControlSocket {
147
209
  scheduleReconnect() {
148
210
  if (this.stopped)
149
211
  return;
212
+ // A watchdog forceReconnect() may have already opened a fresh socket; only
213
+ // one pending reconnect timer at a time (never stack sockets).
214
+ if (this.reconnectTimer)
215
+ return;
150
216
  console.log(`${this.tag} reconnecting in ${this.delay / 1000}s`);
151
- const t = setTimeout(() => this.connect(), this.delay);
152
- t.unref?.();
217
+ this.reconnectTimer = setTimeout(() => {
218
+ this.reconnectTimer = null;
219
+ this.connect();
220
+ }, this.delay);
221
+ this.reconnectTimer.unref?.();
153
222
  this.delay = Math.min(this.delay * 2, 30_000);
154
223
  }
155
224
  }
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.4",
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",