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 +77 -1
- package/bridge.mjs +21 -3
- package/package.json +1 -1
- package/session-store.mjs +8 -0
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
|
-
|
|
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
|
-
|
|
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
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) {
|