thinkpool-pair 0.7.172 → 0.7.173
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/account.mjs +50 -2
- package/launcher.mjs +31 -48
- package/package.json +1 -1
- package/presence.mjs +44 -3
package/account.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
-
import { makeThrottledTrack } from './presence.mjs'
|
|
18
|
+
import { makeThrottledTrack, presenceSelfEchoVerdict } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -525,7 +525,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
525
525
|
// per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
|
|
526
526
|
// → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
|
|
527
527
|
// tick + token-refresh callers are unaffected (their interval exceeds the window).
|
|
528
|
-
|
|
528
|
+
// lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
|
|
529
|
+
// 'error') so the presence self-echo watchdog can log WHY presence went dark.
|
|
530
|
+
let lastTrackStatus = 'init'
|
|
531
|
+
const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
|
|
529
532
|
// `service` lets the dashboard show whether this bridge is the durable installed
|
|
530
533
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
531
534
|
// terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
|
|
@@ -625,6 +628,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
625
628
|
// detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
|
|
626
629
|
// 564s-stale row because the tick had frozen and the heartbeat simply stopped.
|
|
627
630
|
let lastClaimOkAt = Date.now()
|
|
631
|
+
// Presence self-echo watchdog state (2026-07-08 recurrence). claim_bridge rides HTTP and
|
|
632
|
+
// stays healthy, so claimLoopWedged is blind to a zombie realtime channel where track()
|
|
633
|
+
// lands nowhere. The server's presence SYNC echoing OUR OWN `machine` key back into
|
|
634
|
+
// acct.presenceState() is the only proof a track() actually reached the server.
|
|
635
|
+
let selfEchoMissingSince = 0 // wall-clock we first saw our own key missing (0 = present/ok)
|
|
636
|
+
let presenceRecycledForMissing = 0 // de-dupe: the missing-since value we already forced a recycle for
|
|
628
637
|
const refreshClaim = async () => {
|
|
629
638
|
try {
|
|
630
639
|
// Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
|
|
@@ -981,6 +990,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
981
990
|
try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
|
|
982
991
|
}, 20_000)
|
|
983
992
|
heartbeatWatch.unref?.()
|
|
993
|
+
|
|
994
|
+
// ── presence self-echo watchdog (2026-07-08) ───────────────────────────────
|
|
995
|
+
// The claim heartbeat rides claim_bridge (plain HTTP) so it recovers even while the
|
|
996
|
+
// realtime socket is a zombie — which is exactly why claimLoopWedged can't see the case
|
|
997
|
+
// where the socket went CLOSED-then-half-open and channel.track() lands nowhere FOREVER,
|
|
998
|
+
// leaving the dashboard on "No bridge connected" while children serve rooms. The server's
|
|
999
|
+
// presence SYNC is the only proof: if our own `machine` key is absent from
|
|
1000
|
+
// acct.presenceState() while we hold the claim, the track never reached the server. Runs on
|
|
1001
|
+
// its OWN interval (like the claim watchdog) so a wedged tick can't starve it. Two-stage
|
|
1002
|
+
// escalation via the pure predicate:
|
|
1003
|
+
// ≥~60s missing → recycle the realtime socket (full disconnect+connect — the connect()-only
|
|
1004
|
+
// nudge already in the subscribe/CLOSED handlers provably didn't recover it)
|
|
1005
|
+
// ≥~90s more → exit(1); launchd KeepAlive / systemd reruns clean, children persist and
|
|
1006
|
+
// resume from the session-store (the same invisible bounce the dashboard
|
|
1007
|
+
// Restart button already relies on). Guarded: never while stopping, only
|
|
1008
|
+
// while claimHeld (a standby bridge doesn't track).
|
|
1009
|
+
const presenceWatch = setInterval(() => {
|
|
1010
|
+
if (stopping) return
|
|
1011
|
+
const now = Date.now()
|
|
1012
|
+
let present = false
|
|
1013
|
+
try { present = !!acct.presenceState?.()[machine] } catch { present = false }
|
|
1014
|
+
if (present) { selfEchoMissingSince = 0; presenceRecycledForMissing = 0; return }
|
|
1015
|
+
if (!claimHeld) { selfEchoMissingSince = 0; return } // a standby bridge doesn't announce — never escalate
|
|
1016
|
+
if (!selfEchoMissingSince) selfEchoMissingSince = now
|
|
1017
|
+
const verdict = presenceSelfEchoVerdict({ tracking: claimHeld, selfEchoMissingSince, now })
|
|
1018
|
+
if (verdict === 'recycle') {
|
|
1019
|
+
if (presenceRecycledForMissing === selfEchoMissingSince) return // already recycled this episode — wait out the respawn window
|
|
1020
|
+
presenceRecycledForMissing = selfEchoMissingSince
|
|
1021
|
+
console.log(`presence_watchdog sup=${SUP_ID} action=recycle missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — full realtime disconnect+reconnect`)
|
|
1022
|
+
try { sb.realtime?.disconnect?.() } catch { /* noop */ }
|
|
1023
|
+
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
1024
|
+
try { pushPresence() } catch { /* re-announced on the next SUBSCRIBED too */ }
|
|
1025
|
+
} else if (verdict === 'respawn') {
|
|
1026
|
+
console.log(`presence_watchdog sup=${SUP_ID} action=respawn missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — recycle failed, exiting for a clean supervisor respawn`)
|
|
1027
|
+
process.exit(1)
|
|
1028
|
+
}
|
|
1029
|
+
}, 15_000)
|
|
1030
|
+
presenceWatch.unref?.()
|
|
984
1031
|
// restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
|
|
985
1032
|
// we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
|
|
986
1033
|
const stop = (sig) => {
|
|
@@ -991,6 +1038,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
991
1038
|
console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
|
|
992
1039
|
clearInterval(iv)
|
|
993
1040
|
clearInterval(heartbeatWatch)
|
|
1041
|
+
clearInterval(presenceWatch)
|
|
994
1042
|
for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
|
|
995
1043
|
keepFresh.cancel()
|
|
996
1044
|
releaseSingleton()
|
package/launcher.mjs
CHANGED
|
@@ -119,6 +119,8 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
119
119
|
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// KEPT for the `npx thinkpool-pair <ROOM>` argv path (bridge.mjs) — no longer a main-menu
|
|
123
|
+
// item (single-session serve was retired), but the helper stays so nothing else breaks.
|
|
122
124
|
const pairRoom = async () => {
|
|
123
125
|
const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
124
126
|
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
@@ -135,39 +137,15 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
135
137
|
await actions.pairRoom({ room, agentCmd: agent.cmd, resumeArgs: cont ? agent.resume : [], headless })
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{ label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
|
|
148
|
-
{ label: '← Back to main menu', hint: '' },
|
|
149
|
-
])
|
|
150
|
-
if (how === 2) return
|
|
151
|
-
if (how === 1) { await actions.serveAccountForeground(); return }
|
|
152
|
-
// how === 0 — always-on durable OS service (launchd / systemd / Windows Startup).
|
|
153
|
-
await actions.installService({ room: null }); resync()
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const serviceMenu = async () => {
|
|
157
|
-
const what = await askChoice('install for', [
|
|
158
|
-
{ label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
|
|
159
|
-
{ label: 'One specific room', hint: 'no login needed' },
|
|
160
|
-
{ label: '← Back to main menu', hint: '' },
|
|
161
|
-
])
|
|
162
|
-
if (what === 2) return
|
|
163
|
-
if (what === 0) {
|
|
164
|
-
if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
|
|
165
|
-
await actions.installService({ room: null }); resync(); return
|
|
166
|
-
}
|
|
167
|
-
const room = (await io.ask(`\n Room code ${C.dim('(blank to go back)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
168
|
-
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
169
|
-
const agent = state.agents[0] || { cmd: 'claude' }
|
|
170
|
-
await actions.installService({ room, agentCmd: agent.cmd }); resync()
|
|
140
|
+
// Login gate shared by both "serve" paths (foreground + install service). Both serve
|
|
141
|
+
// EVERY hosted session, so both require an account link first. Returns true only once
|
|
142
|
+
// logged in — offers to link on the spot, re-reads disk, and refuses to serve if still not.
|
|
143
|
+
const ensureLoggedIn = async () => {
|
|
144
|
+
if (state.loggedIn) return true
|
|
145
|
+
io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
|
|
146
|
+
if (await askYesNo(' Link this device now?', true)) { await actions.login(); resync() }
|
|
147
|
+
if (!state.loggedIn) { io.print(' ' + C.yellow('login needed to serve your sessions — back to menu')); return false }
|
|
148
|
+
return true
|
|
171
149
|
}
|
|
172
150
|
|
|
173
151
|
const providerMenu = async () => {
|
|
@@ -249,29 +227,34 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
249
227
|
}
|
|
250
228
|
|
|
251
229
|
// ── menu loop ──
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
230
|
+
// account-foreground runs forever, so that choice never returns here. Everything else
|
|
231
|
+
// (install / restart / uninstall / settings / provider) does its work, prints its notes,
|
|
232
|
+
// and RETURNS — so the loop always drops you back at the menu. The trailing "press Enter"
|
|
233
|
+
// lands on every returning path. Slots 1+2 are the ONLY two things this device does:
|
|
234
|
+
// before a service exists → the two ways to start serving all your sessions; once the
|
|
235
|
+
// service exists → maintain it (update/restart or remove). No single-room / per-room
|
|
236
|
+
// serve is offered anymore (that was `npx thinkpool-pair <ROOM>`, still on the argv path).
|
|
257
237
|
for (;;) {
|
|
258
238
|
header()
|
|
259
239
|
io.print(' ' + C.bold('What do you want to do?') + '\n')
|
|
260
|
-
//
|
|
261
|
-
const items =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
240
|
+
// Slots 1+2 swap on whether the always-on service is already installed.
|
|
241
|
+
const items = state.accountSvc
|
|
242
|
+
? [
|
|
243
|
+
{ key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' },
|
|
244
|
+
{ key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
|
|
245
|
+
]
|
|
246
|
+
: [
|
|
247
|
+
{ key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
|
|
248
|
+
{ key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
|
|
249
|
+
]
|
|
267
250
|
items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
|
|
268
251
|
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
269
252
|
const pick = items[await askChoice('choose', items)]
|
|
270
253
|
if (pick.key === 'quit') return
|
|
271
|
-
if (pick.key === '
|
|
272
|
-
else if (pick.key === '
|
|
273
|
-
else if (pick.key === 'service') await serviceMenu()
|
|
254
|
+
if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
|
|
255
|
+
else if (pick.key === 'service') { if (await ensureLoggedIn()) { await actions.installService({ room: null }); resync() } }
|
|
274
256
|
else if (pick.key === 'restart') { await actions.restartUpdateService({ room: null }); resync() }
|
|
257
|
+
else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
|
|
275
258
|
else if (pick.key === 'settings') await settingsMenu()
|
|
276
259
|
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
|
277
260
|
}
|
package/package.json
CHANGED
package/presence.mjs
CHANGED
|
@@ -27,16 +27,23 @@
|
|
|
27
27
|
// channel: a supabase RealtimeChannel (stable across reconnects — supabase-js
|
|
28
28
|
// rejoins the same object, so channel.track stays valid). Returns a function you
|
|
29
29
|
// call with the presence payload anywhere you'd have called channel.track(payload).
|
|
30
|
-
|
|
30
|
+
// onStatus (optional): called with each track()'s server status ('ok' | 'timed out' |
|
|
31
|
+
// 'error') so a caller can retain the last one for a postmortem log line. Best-effort —
|
|
32
|
+
// a track that throws reports 'error' and never breaks the caller.
|
|
33
|
+
export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } = {}) {
|
|
31
34
|
let last = 0
|
|
32
35
|
let timer = null
|
|
33
36
|
let pending = null
|
|
34
|
-
const fire = () => {
|
|
37
|
+
const fire = async () => {
|
|
35
38
|
timer = null
|
|
36
39
|
last = Date.now()
|
|
37
40
|
const payload = pending
|
|
38
41
|
pending = null
|
|
39
|
-
|
|
42
|
+
// Await the track so we can retain its status for the next postmortem. fire() is never
|
|
43
|
+
// awaited by callers, so this stays fire-and-forget — the await only sequences the
|
|
44
|
+
// onStatus callback; it never blocks a caller and a rejection can't escape (caught here).
|
|
45
|
+
try { const status = await channel.track(payload); onStatus?.(status ?? 'ok') }
|
|
46
|
+
catch { onStatus?.('error') /* presence track is best-effort */ }
|
|
40
47
|
}
|
|
41
48
|
return (payload) => {
|
|
42
49
|
pending = payload
|
|
@@ -49,3 +56,37 @@ export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
|
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
}
|
|
59
|
+
|
|
60
|
+
/* ─────────────────────────────────────────────────────────────
|
|
61
|
+
presenceSelfEchoVerdict — the pure trigger for the account
|
|
62
|
+
supervisor's presence self-echo watchdog (2026-07-08 recurrence).
|
|
63
|
+
|
|
64
|
+
The failure it catches: the supervisor is alive, its claim is held
|
|
65
|
+
(claim_bridge is a plain HTTP RPC, so it stays healthy), children
|
|
66
|
+
serve rooms — yet an anonymous probe of tpacct shows ZERO presence
|
|
67
|
+
keys. After a `realtime CLOSED` blip the channel goes zombie and
|
|
68
|
+
channel.track() lands nowhere, forever. The claim watchdog
|
|
69
|
+
(claimLoopWedged) can't see this — the claim rides HTTP, not the
|
|
70
|
+
socket. The server's presence SYNC is the only proof: if OUR OWN
|
|
71
|
+
key is absent from acct.presenceState() while we're tracking and
|
|
72
|
+
hold the claim, the track never reached the server.
|
|
73
|
+
|
|
74
|
+
Given how long our key has been missing (now - selfEchoMissingSince),
|
|
75
|
+
escalate in two stages:
|
|
76
|
+
missing < graceMs → 'ok' (normal; a fresh track takes a beat to echo)
|
|
77
|
+
graceMs ≤ missing < graceMs+respawnMs → 'recycle' (force a full realtime disconnect+reconnect)
|
|
78
|
+
missing ≥ graceMs + respawnMs → 'respawn' (recycle didn't take → exit; supervisor reruns clean)
|
|
79
|
+
|
|
80
|
+
selfEchoMissingSince is 0/null whenever our key is present OR we're
|
|
81
|
+
not tracking; the caller stamps it the first tick the key goes
|
|
82
|
+
missing and clears it the first tick it returns. Extracted + exported
|
|
83
|
+
so the watchdog's trigger is unit-testable without a live socket
|
|
84
|
+
(mirrors claimLoopWedged). Deterministic in `now` — no Date.now here.
|
|
85
|
+
───────────────────────────────────────────────────────────── */
|
|
86
|
+
export function presenceSelfEchoVerdict({ tracking, selfEchoMissingSince, now, graceMs = 60_000, respawnMs = 90_000 }) {
|
|
87
|
+
if (!tracking || !selfEchoMissingSince) return 'ok'
|
|
88
|
+
const missingMs = now - selfEchoMissingSince
|
|
89
|
+
if (missingMs >= graceMs + respawnMs) return 'respawn'
|
|
90
|
+
if (missingMs >= graceMs) return 'recycle'
|
|
91
|
+
return 'ok'
|
|
92
|
+
}
|