thinkpool-pair 0.7.172 → 0.7.174

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'
@@ -386,9 +386,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
386
386
  // ThinkPool nothing; there's no cost reason to refuse a free host.
387
387
 
388
388
  const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
389
+ // Ground truth for "am I the managed service": ask the service manager, not the
390
+ // THINKPOOL_PAIR_AUTOUPDATE env proxy. Since pinned installs became the default,
391
+ // the plist/unit sets AUTOUPDATE only when --auto-update was passed — the env var
392
+ // now means "track @latest", NOT "I'm the service". Testing the env alone made a
393
+ // pinned managed service (a) announce service:false → dashboard says "foreground",
394
+ // and (b) skip the uninstall on a dashboard Kill → launchd respawns the "killed"
395
+ // bridge ~2s later (the 2026-06-18 zombie-toggle, reintroduced by pinning).
396
+ let isManagedService = false
397
+ try { const svc = await import('./service.mjs'); isManagedService = svc.serviceActive(null) } catch { /* not installed */ }
398
+ const runsAsService = () => process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' || isManagedService
389
399
  const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
390
400
  ? ' (auto-updating background service — survives reboot, tracks @latest)\n'
391
- : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
401
+ : isManagedService
402
+ ? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
403
+ : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
392
404
  const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
393
405
  process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
394
406
 
@@ -463,7 +475,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
463
475
  // restart), force the process down and release the lock so a fresh bridge
464
476
  // can take over. NOT unref'd: it must keep the loop alive until it fires.
465
477
  setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(0) }, 2500)
466
- if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1') {
478
+ if (runsAsService()) {
467
479
  try { const svc = await import('./service.mjs'); svc.uninstallService(null) } catch { /* noop */ }
468
480
  }
469
481
  process.kill(process.pid, 'SIGTERM')
@@ -493,7 +505,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
493
505
  // Unsupervised FOREGROUND bridge (no launchd/systemd) → re-spawn ourselves DETACHED
494
506
  // after releasing the lock, so "Restart" isn't a footgun that never comes back
495
507
  // (2026-06-25). A supervised bridge is respawned by its supervisor on exit(1).
496
- if (process.env.THINKPOOL_PAIR_AUTOUPDATE !== '1') {
508
+ if (!runsAsService()) {
497
509
  try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
498
510
  }
499
511
  process.exit(1)
@@ -525,11 +537,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
525
537
  // per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
526
538
  // → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
527
539
  // tick + token-refresh callers are unaffected (their interval exceeds the window).
528
- const trackPresence = makeThrottledTrack(acct, { minMs: 5000 })
540
+ // lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
541
+ // 'error') so the presence self-echo watchdog can log WHY presence went dark.
542
+ let lastTrackStatus = 'init'
543
+ const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
529
544
  // `service` lets the dashboard show whether this bridge is the durable installed
530
545
  // service (always-on, survives reboot) vs a foreground `npx` run that dies with its
531
- // terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
532
- // (service.mjs), so it's the reliable proxy for "running as the managed service".
546
+ // terminal — runsAsService() above (service-manager ground truth, not the env proxy).
533
547
  // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), generated +
534
548
  // persisted 0600 on first read. The dashboard imports it to SEAL a provider's API
535
549
  // key so it reaches the bridge without ever crossing the wire in the clear. Computed
@@ -538,7 +552,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
538
552
  // to spawn against — re-read each announce so an add/remove reflects immediately.
539
553
  // Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
540
554
  const PROVIDER_PUBKEY = publicKeyB64()
541
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
555
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
542
556
 
543
557
  // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
544
558
  // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
@@ -625,6 +639,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
625
639
  // detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
626
640
  // 564s-stale row because the tick had frozen and the heartbeat simply stopped.
627
641
  let lastClaimOkAt = Date.now()
642
+ // Presence self-echo watchdog state (2026-07-08 recurrence). claim_bridge rides HTTP and
643
+ // stays healthy, so claimLoopWedged is blind to a zombie realtime channel where track()
644
+ // lands nowhere. The server's presence SYNC echoing OUR OWN `machine` key back into
645
+ // acct.presenceState() is the only proof a track() actually reached the server.
646
+ let selfEchoMissingSince = 0 // wall-clock we first saw our own key missing (0 = present/ok)
647
+ let presenceRecycledForMissing = 0 // de-dupe: the missing-since value we already forced a recycle for
628
648
  const refreshClaim = async () => {
629
649
  try {
630
650
  // Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
@@ -981,6 +1001,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
981
1001
  try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
982
1002
  }, 20_000)
983
1003
  heartbeatWatch.unref?.()
1004
+
1005
+ // ── presence self-echo watchdog (2026-07-08) ───────────────────────────────
1006
+ // The claim heartbeat rides claim_bridge (plain HTTP) so it recovers even while the
1007
+ // realtime socket is a zombie — which is exactly why claimLoopWedged can't see the case
1008
+ // where the socket went CLOSED-then-half-open and channel.track() lands nowhere FOREVER,
1009
+ // leaving the dashboard on "No bridge connected" while children serve rooms. The server's
1010
+ // presence SYNC is the only proof: if our own `machine` key is absent from
1011
+ // acct.presenceState() while we hold the claim, the track never reached the server. Runs on
1012
+ // its OWN interval (like the claim watchdog) so a wedged tick can't starve it. Two-stage
1013
+ // escalation via the pure predicate:
1014
+ // ≥~60s missing → recycle the realtime socket (full disconnect+connect — the connect()-only
1015
+ // nudge already in the subscribe/CLOSED handlers provably didn't recover it)
1016
+ // ≥~90s more → exit(1); launchd KeepAlive / systemd reruns clean, children persist and
1017
+ // resume from the session-store (the same invisible bounce the dashboard
1018
+ // Restart button already relies on). Guarded: never while stopping, only
1019
+ // while claimHeld (a standby bridge doesn't track).
1020
+ const presenceWatch = setInterval(() => {
1021
+ if (stopping) return
1022
+ const now = Date.now()
1023
+ let present = false
1024
+ try { present = !!acct.presenceState?.()[machine] } catch { present = false }
1025
+ if (present) { selfEchoMissingSince = 0; presenceRecycledForMissing = 0; return }
1026
+ if (!claimHeld) { selfEchoMissingSince = 0; return } // a standby bridge doesn't announce — never escalate
1027
+ if (!selfEchoMissingSince) selfEchoMissingSince = now
1028
+ const verdict = presenceSelfEchoVerdict({ tracking: claimHeld, selfEchoMissingSince, now })
1029
+ if (verdict === 'recycle') {
1030
+ if (presenceRecycledForMissing === selfEchoMissingSince) return // already recycled this episode — wait out the respawn window
1031
+ presenceRecycledForMissing = selfEchoMissingSince
1032
+ console.log(`presence_watchdog sup=${SUP_ID} action=recycle missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — full realtime disconnect+reconnect`)
1033
+ try { sb.realtime?.disconnect?.() } catch { /* noop */ }
1034
+ try { sb.realtime?.connect?.() } catch { /* noop */ }
1035
+ try { pushPresence() } catch { /* re-announced on the next SUBSCRIBED too */ }
1036
+ } else if (verdict === 'respawn') {
1037
+ console.log(`presence_watchdog sup=${SUP_ID} action=respawn missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — recycle failed, exiting for a clean supervisor respawn`)
1038
+ process.exit(1)
1039
+ }
1040
+ }, 15_000)
1041
+ presenceWatch.unref?.()
984
1042
  // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
985
1043
  // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
986
1044
  const stop = (sig) => {
@@ -991,6 +1049,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
991
1049
  console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
992
1050
  clearInterval(iv)
993
1051
  clearInterval(heartbeatWatch)
1052
+ clearInterval(presenceWatch)
994
1053
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
995
1054
  keepFresh.cancel()
996
1055
  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.174",
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
+ }