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 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
- const trackPresence = makeThrottledTrack(acct, { minMs: 5000 })
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
- const serveAccount = async () => {
139
- if (!state.loggedIn) {
140
- io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
141
- if (await askYesNo(' Link this device now?', true)) await actions.login()
142
- return
143
- }
144
- io.print(`\n Serving every session you host, from ${C.dim(state.cwd)}.\n`)
145
- const how = await askChoice('run it', [
146
- { label: 'As an always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
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
- // pairRoom re-execs + waits, and account-foreground runs forever, so those choices
253
- // never return here. Everything else (install / restart / uninstall / settings /
254
- // provider) does its work, prints its notes, and RETURNS — so the loop always drops
255
- // you back at the menu. Submenus carry a "← Back" so you can bail at any point. The
256
- // trailing "press Enter" lands on every returning path.
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
- // Built per-render so the contextual "Restart" only shows once a service exists.
261
- const items = [
262
- { key: 'pair', label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
263
- { key: 'serve', label: 'Serve all my sessions', hint: 'account mode — needs login' },
264
- { key: 'service', label: 'Always-on background service', hint: 'survives reboot, restarts on crash' },
265
- ]
266
- if (state.accountSvc) items.push({ key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' })
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 === 'pair') await pairRoom()
272
- else if (pick.key === 'serve') await serveAccount()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.172",
3
+ "version": "0.7.173",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
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
- export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
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
- try { channel.track(payload) } catch { /* presence track is best-effort */ }
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
+ }