@nopeek/agent-bridge 0.7.3 → 0.7.5

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/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.2";
2
+ export declare const VERSION = "0.7.5";
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,22 @@ 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.2";
20
+ export const VERSION = "0.7.5";
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;
23
36
  export class PairError extends Error {
24
37
  code;
25
38
  constructor(code, message) {
@@ -140,9 +153,48 @@ class PairingRuntime {
140
153
  userId: b.info.userId,
141
154
  connected: b.connected,
142
155
  handled: b.handled,
156
+ msSinceHealthy: b.msSinceHealthy(),
157
+ lastForcedRestart: b.lastForcedRestartAt,
143
158
  brain: resolveBrain(this.cfg, b.info.handle).kind,
144
159
  }));
145
160
  }
161
+ /**
162
+ * Watchdog soft pass for THIS pairing. Force-restart (full rebuild) any bot
163
+ * that's been disconnected longer than softMs, debounced so a single bot is
164
+ * restarted at most once per softMs window. Also force-recreate the control
165
+ * socket if it's been un-authed longer than softMs (its internal backoff can
166
+ * wedge too). Returns each bot's msSinceHealthy so the app can evaluate the
167
+ * hard, process-level catch-all across all pairings.
168
+ */
169
+ watchdogSoftPass(softMs) {
170
+ if (this.stopped)
171
+ return [];
172
+ const now = Date.now();
173
+ const liveness = [];
174
+ for (const b of this.bots.values()) {
175
+ const ms = b.msSinceHealthy();
176
+ liveness.push(ms);
177
+ if (ms <= softMs)
178
+ continue;
179
+ const last = b.lastForcedRestartAt;
180
+ if (last !== null && now - last < softMs)
181
+ continue; // debounce: one restart per window
182
+ console.log(`${this.tag} [watchdog] @${b.info.handle} disconnected ${Math.round(ms / 1000)}s — forcing hard restart`);
183
+ b.forceRestart();
184
+ }
185
+ const control = this.control;
186
+ if (control) {
187
+ const ms = control.msSinceHealthy();
188
+ if (ms > softMs) {
189
+ const last = control.lastForcedRestartAt;
190
+ if (last === null || now - last >= softMs) {
191
+ console.log(`${this.tag} [watchdog] control socket down ${Math.round(ms / 1000)}s — forcing a fresh socket`);
192
+ control.forceReconnect();
193
+ }
194
+ }
195
+ }
196
+ return liveness;
197
+ }
146
198
  // ---------------------------------------------------------------- core ----
147
199
  startBot(info) {
148
200
  if (this.bots.has(info.userId))
@@ -229,6 +281,11 @@ export class BridgeApp {
229
281
  startedAt = Date.now();
230
282
  runtimes = [];
231
283
  capabilitiesTimer = null;
284
+ watchdogTimer = null;
285
+ // Hard exit needs the all-bots-stale condition sustained across TWO
286
+ // consecutive checks — a single transient blip must never restart the process.
287
+ hardStaleStreak = 0;
288
+ lastWatchdogAt = 0;
232
289
  stopped = false;
233
290
  constructor(cfg) {
234
291
  this.cfg = cfg;
@@ -261,6 +318,10 @@ export class BridgeApp {
261
318
  clearInterval(this.capabilitiesTimer);
262
319
  this.capabilitiesTimer = null;
263
320
  }
321
+ if (this.watchdogTimer) {
322
+ clearInterval(this.watchdogTimer);
323
+ this.watchdogTimer = null;
324
+ }
264
325
  for (const r of this.runtimes)
265
326
  r.stop();
266
327
  this.runtimes = [];
@@ -274,6 +335,46 @@ export class BridgeApp {
274
335
  this.runtimes.push(rt);
275
336
  rt.start();
276
337
  this.ensureCapabilitiesTimer();
338
+ this.ensureWatchdogTimer();
339
+ }
340
+ /** Reconnect watchdog. Sweeps every pairing's runners + control sockets on a
341
+ * fixed interval and force-recovers anything stuck; as a last resort, exits
342
+ * so the always-restart service relaunches a clean process. unref'd so it
343
+ * never keeps the process alive on its own. */
344
+ ensureWatchdogTimer() {
345
+ if (this.watchdogTimer)
346
+ return;
347
+ this.watchdogTimer = setInterval(() => this.watchdogTick(), WATCHDOG_INTERVAL_MS);
348
+ this.watchdogTimer.unref?.();
349
+ }
350
+ watchdogTick() {
351
+ if (this.stopped || !this.paired) {
352
+ this.hardStaleStreak = 0;
353
+ return;
354
+ }
355
+ this.lastWatchdogAt = Date.now();
356
+ // Layers 2+3: soft force-recovery per pairing (bots + control socket).
357
+ let allLiveness = [];
358
+ for (const r of this.runtimes)
359
+ 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.
364
+ const hasBots = allLiveness.length > 0;
365
+ const allHardStale = hasBots && allLiveness.every((ms) => ms > HARD_STALE_MS);
366
+ if (this.paired && hasBots && allHardStale) {
367
+ this.hardStaleStreak++;
368
+ const mins = Math.round(HARD_STALE_MS / 60_000);
369
+ if (this.hardStaleStreak >= 2) {
370
+ console.error(`[watchdog] no bot connected for ${mins}min across ${allLiveness.length} bot(s) — exiting for a clean relaunch`);
371
+ process.exit(1);
372
+ }
373
+ console.error(`[watchdog] all ${allLiveness.length} bot(s) down > ${mins}min — will exit for a clean relaunch if still down next check`);
374
+ }
375
+ else {
376
+ this.hardStaleStreak = 0;
377
+ }
277
378
  }
278
379
  /** Re-probe + report brain availability every ~5 min (once per pairing) so a
279
380
  * login/logout on this computer surfaces in each account's picker without a
@@ -342,6 +443,7 @@ export class BridgeApp {
342
443
  * Returns false when a runtimeId was given but matches no pairing.
343
444
  */
344
445
  unpair(runtimeId) {
446
+ this.hardStaleStreak = 0; // never carry a stale streak across a topology change
345
447
  if (!runtimeId) {
346
448
  console.log(`[bridge] unpairing ALL ${this.cfg.pairings.length} account(s) — stopping bots and forgetting the pairing secrets`);
347
449
  for (const r of this.runtimes)
@@ -499,6 +601,15 @@ export class BridgeApp {
499
601
  },
500
602
  // Legacy flat list = every pairing's bots (pre-0.6 clients read this).
501
603
  bots: this.runtimes.flatMap((r) => r.botStatuses()),
604
+ // Reconnect watchdog state (per-bot msSinceHealthy/lastForcedRestart ride
605
+ // each bot entry above; this summarizes the process-level guard).
606
+ watchdog: {
607
+ intervalMs: WATCHDOG_INTERVAL_MS,
608
+ softStaleMs: SOFT_STALE_MS,
609
+ hardStaleMs: HARD_STALE_MS,
610
+ hardStaleStreak: this.hardStaleStreak,
611
+ lastCheckAt: this.lastWatchdogAt || null,
612
+ },
502
613
  };
503
614
  }
504
615
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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",