thinkpool-pair 0.7.17 → 0.7.19

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
@@ -104,6 +104,57 @@ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
104
104
  } catch { return null }
105
105
  }
106
106
 
107
+ // ── keep the account JWT fresh (presence keepalive) ─────────────────────────
108
+ // The account supervisor authenticates its realtime PRESENCE connection with the
109
+ // account access token. Supabase Realtime enforces the JWT `exp` (~1h): once it
110
+ // lapses the server deauths the socket, presence vanishes, and the dashboard
111
+ // flips to "No bridge connected" — while sessions keep running, because the room
112
+ // children are SEPARATE anon-keyed processes. The supervisor used to refresh the
113
+ // token exactly once at startup (autoRefreshToken:false), so presence silently
114
+ // died ~1h in and never came back until a restart. This refreshes BEFORE expiry,
115
+ // pushes the new token to the realtime socket (setAuth) so it never drops, and
116
+ // re-announces presence. onRefreshed persists the rotated refresh token + re-tracks.
117
+ // Returns { cancel } and exposes _tick for the deterministic test harness.
118
+ // skewMs — refresh this far ahead of expiry (default 90s)
119
+ // forceMs — fixed interval override (test hook: TP_TOKEN_REFRESH_FORCE_MS)
120
+ // retryMs — backoff after a transient failure (offline / rotation race)
121
+ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000 }) {
122
+ let timer = null
123
+ let cur = session
124
+ let cancelled = false
125
+ const arm = (delay) => {
126
+ if (cancelled) return
127
+ clearTimeout(timer)
128
+ timer = setTimeout(tick, Math.max(1_000, delay))
129
+ if (timer?.unref) timer.unref()
130
+ }
131
+ // expires_at is unix SECONDS; fall back to a 1h horizon if absent.
132
+ const nextDelay = () => {
133
+ if (forceMs) return forceMs
134
+ const expMs = cur?.expires_at ? cur.expires_at * 1000 : Date.now() + 3600_000
135
+ return Math.max(30_000, expMs - Date.now() - skewMs)
136
+ }
137
+ async function tick() {
138
+ if (cancelled) return
139
+ try {
140
+ const { data, error } = await sb.auth.refreshSession({ refresh_token: cur.refresh_token })
141
+ if (error || !data?.session) throw error || new Error('no session')
142
+ cur = data.session
143
+ // Hand the fresh token to the live realtime socket so the presence
144
+ // connection stays authed across the swap (no drop, no rejoin needed).
145
+ try { sb.realtime?.setAuth?.(cur.access_token) } catch { /* noop */ }
146
+ try { onRefreshed?.(cur) } catch { /* noop */ }
147
+ arm(nextDelay())
148
+ } catch {
149
+ // Transient (offline, or a refresh-token rotation race with another login).
150
+ // Retry soon; NEVER crash the bridge and NEVER clearAuth (see runAccount).
151
+ arm(retryMs)
152
+ }
153
+ }
154
+ arm(nextDelay())
155
+ return { cancel: () => { cancelled = true; clearTimeout(timer) }, _tick: tick }
156
+ }
157
+
107
158
  export function runBind(room, dir) {
108
159
  const r = (room || '').toUpperCase().trim()
109
160
  if (!r) { console.error('usage: npx thinkpool-pair bind <ROOM> <directory>'); process.exit(1) }
@@ -165,10 +216,34 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
165
216
  const machine = os.hostname().replace(/\.local$/, '')
166
217
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
167
218
  // Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
168
- acct.on('broadcast', { event: 'shutdown' }, () => { process.stderr.write('\n ◇ disconnect requested from the dashboard — stopping bridge.\n'); process.kill(process.pid, 'SIGTERM') })
219
+ // If we're the auto-restarting background service (launchd KeepAlive / systemd
220
+ // Restart=always sets AUTOUPDATE=1), a bare exit would be respawned ~2s later —
221
+ // so the toggle would appear to do nothing (presence leaves, then rejoins).
222
+ // Uninstall the account service FIRST so the supervisor won't restart us, then
223
+ // stop. Mirrors the per-room session-deleted guard in bridge.mjs.
224
+ acct.on('broadcast', { event: 'shutdown' }, async () => {
225
+ process.stderr.write('\n ◇ disconnect requested from the dashboard — stopping bridge.\n')
226
+ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1') {
227
+ try { const svc = await import('./service.mjs'); svc.uninstallService(null) } catch { /* noop */ }
228
+ }
229
+ process.kill(process.pid, 'SIGTERM')
230
+ })
169
231
  const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
232
+ // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
233
+ // (network blip, or a token swap mid-flight) rejoins the channel and must
234
+ // re-announce, or the dashboard would read "no bridge" until the next restart.
170
235
  await new Promise((res) => acct.subscribe((st) => { if (st === 'SUBSCRIBED') { pushPresence(); res() } }))
171
236
 
237
+ // Keep the account JWT fresh so the presence socket never gets deauthed at
238
+ // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
239
+ // refresh: persist the rotated refresh token + re-announce presence.
240
+ const keepFresh = scheduleTokenRefresh({
241
+ sb,
242
+ session,
243
+ onRefreshed: (s) => { saveAuth(s); pushPresence() },
244
+ forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
245
+ })
246
+
172
247
  const tick = async () => {
173
248
  let rooms = []
174
249
  try {
@@ -206,6 +281,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
206
281
  const stop = (sig) => {
207
282
  if (stopping) return; stopping = true
208
283
  clearInterval(iv)
284
+ keepFresh.cancel()
209
285
  releaseSingleton()
210
286
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
211
287
  // Flush the presence LEAVE before exiting so the dashboard flips to
package/bridge.mjs CHANGED
@@ -36,7 +36,7 @@ import { spawn } from 'node:child_process'
36
36
  import { randomUUID } from 'node:crypto'
37
37
  import { createClient } from '@supabase/supabase-js'
38
38
  import { startClaudeSession } from './claude-session.mjs'
39
- import { saveSession, flushSession, loadAll, canResume, loadPtyId, savePtyId } from './session-store.mjs'
39
+ import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId } from './session-store.mjs'
40
40
 
41
41
  // Public client creds (the same anon values the web app ships — safe to embed).
42
42
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -582,6 +582,22 @@ function openStructured({ id, model, resume, log, commands, mode }) {
582
582
  return entry
583
583
  }
584
584
 
585
+ // Authoritative close for a structured session: end the live SDK session, drop it
586
+ // from memory, delete its on-disk record (so a restart can't resurrect it), and tell
587
+ // every client it's gone. Idempotent + id-scoped — no-ops for ids that aren't a
588
+ // structured session, so it's safe to call from the PTY-oriented term-close path too.
589
+ function endStructured(id) {
590
+ if (!id) return
591
+ const s = sessions.get(id)
592
+ if (s) {
593
+ try { s.session?.end() } catch { /* noop */ }
594
+ sessions.delete(id)
595
+ bcast('term-exit', { id })
596
+ }
597
+ deleteSession(room, id) // remove <id>.json so loadAll() won't bring it back
598
+ if (s) announce()
599
+ }
600
+
585
601
  // After the attached CLI exits, the host's stdin stops feeding a PTY —
586
602
  // restore the cooked terminal so Ctrl-C reaches the bridge itself.
587
603
  const detachLocal = () => {
@@ -661,6 +677,9 @@ channel
661
677
  .on('broadcast', { event: 'term-close' }, ({ payload }) => {
662
678
  const t = payload?.id && terms.get(payload.id)
663
679
  if (t && !t.attached) { try { t.term.kill() } catch { /* noop */ } }
680
+ // Insurance: a client (or older build) that sends term-close for a STRUCTURED
681
+ // terminal still gets it closed — endStructured no-ops if it's not a session.
682
+ else if (!t) endStructured(payload?.id)
664
683
  })
665
684
  .on('broadcast', { event: 'replay-request' }, ({ payload }) => {
666
685
  // Flush pending bytes first so the replay is complete up to "now"; both
@@ -769,8 +788,7 @@ channel
769
788
  if (s) { s.mode = payload.mode; s.session.setMode(payload.mode) } // track for persist/restore
770
789
  })
771
790
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
772
- const s = payload?.id && sessions.get(payload.id)
773
- if (s) { try { s.session.end() } catch { /* noop */ } ; sessions.delete(payload.id); bcast('term-exit', { id: payload.id }); announce() }
791
+ endStructured(payload?.id)
774
792
  })
775
793
  .on('broadcast', { event: 'who' }, announce)
776
794
  .subscribe(status => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
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/session-store.mjs CHANGED
@@ -46,6 +46,14 @@ export function saveSession(room, id, data) {
46
46
  timers.set(id, setTimeout(() => write(room, id, data), 1500))
47
47
  }
48
48
  export function flushSession(room, id, data) { clearTimeout(timers.get(id)); write(room, id, data) }
49
+ // User-initiated close: cancel any pending debounced save and remove the on-disk
50
+ // record so a later loadAll() (bridge restart) can't resurrect a closed terminal.
51
+ // PTY ids never have an <id>.json (they use the .pty-id dotfile), so this no-ops
52
+ // harmlessly for them.
53
+ export function deleteSession(room, id) {
54
+ clearTimeout(timers.get(id)); timers.delete(id)
55
+ try { fs.unlinkSync(path.join(dir(room), `${id}.json`)) } catch { /* noop */ }
56
+ }
49
57
 
50
58
  // Most-recently-saved structured session for the room (drives attached restore).
51
59
  export function loadLatest(room) {