@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 +12 -2
- package/dist/bot.d.ts +16 -0
- package/dist/bot.js +63 -11
- package/dist/bridge.d.ts +10 -1
- package/dist/bridge.js +122 -1
- package/dist/cli.js +18 -1
- package/dist/control.d.ts +13 -0
- package/dist/control.js +71 -2
- package/dist/service.d.ts +12 -0
- package/dist/service.js +55 -0
- package/package.json +1 -1
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
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.
|
|
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
|
-
|
|
152
|
-
|
|
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.
|
|
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",
|