thinkpool-pair 0.7.310 → 0.7.311
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 +68 -8
- package/auth-store.mjs +27 -13
- package/launcher.mjs +9 -4
- package/package.json +1 -1
package/account.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path'
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import { createClient } from '@supabase/supabase-js'
|
|
15
15
|
import os from 'node:os'
|
|
16
|
-
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
16
|
+
import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
@@ -252,16 +252,33 @@ export async function refreshAccountSession(sb, holdRefreshToken, deps = {}) {
|
|
|
252
252
|
if (adopted) return adopted
|
|
253
253
|
const rt = load()?.refresh_token || rt0
|
|
254
254
|
const { data, error } = await sb.auth.refreshSession({ refresh_token: rt })
|
|
255
|
-
|
|
255
|
+
// Preserve the server's structured Auth error. The caller must distinguish a
|
|
256
|
+
// retryable network/service failure from a refresh token that is definitively
|
|
257
|
+
// gone; collapsing both to null is what let a disconnected account keep painting
|
|
258
|
+
// as a healthy bridge until its presence JWT finally expired.
|
|
259
|
+
if (error || !data?.session) return { session: null, error: error || null }
|
|
256
260
|
save(data.session) // atomic (auth-store) — never a torn token file
|
|
257
261
|
return { session: data.session, adopted: false }
|
|
258
262
|
} finally { if (locked) release() }
|
|
259
263
|
}
|
|
260
264
|
|
|
265
|
+
// Supabase documents these as terminal session/refresh-token states. They require a
|
|
266
|
+
// fresh device-code login; request timeouts, rate limits and 5xx errors remain retryable.
|
|
267
|
+
export const ACCOUNT_RECONNECT_ERROR_CODES = new Set([
|
|
268
|
+
'refresh_token_not_found',
|
|
269
|
+
'refresh_token_already_used',
|
|
270
|
+
'session_not_found',
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
export function accountRefreshNeedsReconnect(error) {
|
|
274
|
+
return ACCOUNT_RECONNECT_ERROR_CODES.has(String(error?.code || ''))
|
|
275
|
+
}
|
|
276
|
+
|
|
261
277
|
// Exchange the stored credentials for a live session + authed client.
|
|
262
278
|
export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
|
|
263
279
|
const a = loadAuth()
|
|
264
280
|
if (!a?.refresh_token) return null
|
|
281
|
+
if (a.reconnect_required) return { reconnectRequired: true }
|
|
265
282
|
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
|
|
266
283
|
// F1: skip the refresh entirely when the saved access token is still comfortably valid
|
|
267
284
|
// (>5 min to expiry). Every gratuitous startup rotation is a race window — and, killed
|
|
@@ -273,7 +290,13 @@ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
273
290
|
}
|
|
274
291
|
// Near-expiry / missing / invalid access token → do a serialized rotation (F3).
|
|
275
292
|
const r = await refreshAccountSession(sb, a.refresh_token)
|
|
276
|
-
if (!r?.session)
|
|
293
|
+
if (!r?.session) {
|
|
294
|
+
if (accountRefreshNeedsReconnect(r?.error)) {
|
|
295
|
+
markAuthReconnectRequired(r.error.code)
|
|
296
|
+
return { reconnectRequired: true }
|
|
297
|
+
}
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
277
300
|
return { sb, session: r.session }
|
|
278
301
|
}
|
|
279
302
|
|
|
@@ -289,6 +312,7 @@ export async function authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, opts =
|
|
|
289
312
|
let delay = baseMs
|
|
290
313
|
for (let n = 1; n <= maxAttempts; n++) {
|
|
291
314
|
const a = await attempt(SUPABASE_URL, SUPABASE_ANON)
|
|
315
|
+
if (a?.reconnectRequired) return a
|
|
292
316
|
if (a) return a
|
|
293
317
|
if (!load()?.refresh_token) return null // not linked — caller says `login`, exits 0
|
|
294
318
|
if (n >= maxAttempts) break
|
|
@@ -320,10 +344,11 @@ export function claimLoopWedged({ lastClaimOkAt, now, quietMsThreshold = 40_000
|
|
|
320
344
|
// skewMs — refresh this far ahead of expiry (default 90s)
|
|
321
345
|
// forceMs — fixed interval override (test hook: TP_TOKEN_REFRESH_FORCE_MS)
|
|
322
346
|
// retryMs — backoff after a transient failure (offline / rotation race)
|
|
323
|
-
export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
|
|
347
|
+
export function scheduleTokenRefresh({ sb, session, onRefreshed, onReconnectRequired, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
|
|
324
348
|
let timer = null
|
|
325
349
|
let cur = session
|
|
326
350
|
let cancelled = false
|
|
351
|
+
let reconnectRequired = false
|
|
327
352
|
const arm = (delay) => {
|
|
328
353
|
if (cancelled) return
|
|
329
354
|
clearTimeout(timer)
|
|
@@ -343,12 +368,21 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000
|
|
|
343
368
|
// instead of blindly refreshing with our in-memory `cur.refresh_token` — the old
|
|
344
369
|
// tick retried with a stale token it held and could lose the family to a race.
|
|
345
370
|
const r = await refresh(sb, cur.refresh_token)
|
|
346
|
-
if (!r?.session)
|
|
371
|
+
if (!r?.session) {
|
|
372
|
+
if (accountRefreshNeedsReconnect(r?.error)) {
|
|
373
|
+
if (!reconnectRequired) {
|
|
374
|
+
reconnectRequired = true
|
|
375
|
+
try { await onReconnectRequired?.(r.error) } catch { /* reporting is best-effort; retry still owns recovery */ }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
throw r?.error || new Error('no session')
|
|
379
|
+
}
|
|
347
380
|
cur = r.session
|
|
348
381
|
// Hand the fresh token to the live realtime socket so the presence
|
|
349
382
|
// connection stays authed across the swap (no drop, no rejoin needed).
|
|
350
383
|
try { sb.realtime?.setAuth?.(cur.access_token) } catch { /* noop */ }
|
|
351
|
-
|
|
384
|
+
reconnectRequired = false
|
|
385
|
+
try { await onRefreshed?.(cur) } catch { /* noop */ }
|
|
352
386
|
arm(nextDelay())
|
|
353
387
|
} catch {
|
|
354
388
|
// Transient (offline, or a refresh-token rotation race with another login).
|
|
@@ -394,6 +428,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
394
428
|
console.error('\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: npx thinkpool-pair login\n')
|
|
395
429
|
process.exit(0)
|
|
396
430
|
}
|
|
431
|
+
if (auth.reconnectRequired) {
|
|
432
|
+
releaseStartupAnchor()
|
|
433
|
+
releaseSingleton()
|
|
434
|
+
console.error('\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n')
|
|
435
|
+
process.exit(0)
|
|
436
|
+
}
|
|
397
437
|
const { sb, session } = auth
|
|
398
438
|
const email = session.user?.email || 'your account'
|
|
399
439
|
// Current owner JWT, handed to each room-bridge child for authed web writes
|
|
@@ -548,6 +588,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
548
588
|
// lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
|
|
549
589
|
// 'error') so the presence self-echo watchdog can log WHY presence went dark.
|
|
550
590
|
let lastTrackStatus = 'init'
|
|
591
|
+
let accountAuthState = 'connected'
|
|
551
592
|
const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
|
|
552
593
|
// `service` lets the dashboard show whether this bridge is the durable installed
|
|
553
594
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
@@ -560,7 +601,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
560
601
|
// to spawn against — re-read each announce so an add/remove reflects immediately.
|
|
561
602
|
// Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
|
|
562
603
|
const PROVIDER_PUBKEY = publicKeyB64()
|
|
563
|
-
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), ts: Date.now() })
|
|
604
|
+
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), accountAuthState, ts: Date.now() })
|
|
605
|
+
|
|
606
|
+
// Persist account-auth truth separately from process presence. Presence will vanish
|
|
607
|
+
// when the old JWT expires; the claim row keeps the reconnect instruction visible on
|
|
608
|
+
// the dashboard until this same bridge refreshes successfully or another bridge takes
|
|
609
|
+
// over with a healthy login. Older databases simply reject this best-effort RPC.
|
|
610
|
+
const reportAccountAuthState = async (state) => {
|
|
611
|
+
try {
|
|
612
|
+
await withTimeout(sb.rpc('report_bridge_auth_state', { p_bridge_id: BRIDGE_ID, p_state: state }), 5000, 'report_bridge_auth_state')
|
|
613
|
+
} catch { /* migration not present yet, offline, or the old JWT already expired */ }
|
|
614
|
+
}
|
|
564
615
|
|
|
565
616
|
// ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
|
|
566
617
|
// AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
|
|
@@ -647,13 +698,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
647
698
|
const keepFresh = scheduleTokenRefresh({
|
|
648
699
|
sb,
|
|
649
700
|
session,
|
|
650
|
-
onRefreshed: (s) => {
|
|
701
|
+
onRefreshed: async (s) => {
|
|
702
|
+
accountAuthState = 'connected'
|
|
651
703
|
saveAuth(s); pushPresence()
|
|
704
|
+
await reportAccountAuthState('connected')
|
|
652
705
|
// Push the rotated owner token to every live child so their authed writes
|
|
653
706
|
// (code-mockup) never go stale on a long session (whip L16).
|
|
654
707
|
currentAccessToken = s.access_token || currentAccessToken
|
|
655
708
|
for (const c of children.values()) { try { c.send({ t: 'token', accessToken: currentAccessToken }) } catch { /* child not ready */ } }
|
|
656
709
|
},
|
|
710
|
+
onReconnectRequired: async (error) => {
|
|
711
|
+
accountAuthState = 'reconnect-required'
|
|
712
|
+
markAuthReconnectRequired(error?.code || 'refresh-token-invalid')
|
|
713
|
+
pushPresence()
|
|
714
|
+
await reportAccountAuthState('reconnect-required')
|
|
715
|
+
process.stderr.write('\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n')
|
|
716
|
+
},
|
|
657
717
|
forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
|
|
658
718
|
})
|
|
659
719
|
|
package/auth-store.mjs
CHANGED
|
@@ -19,19 +19,8 @@ const SERVED = path.join(DIR, 'served.json')
|
|
|
19
19
|
|
|
20
20
|
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
if (!session?.refresh_token) return
|
|
22
|
+
function writeAuthPayload(payload) {
|
|
24
23
|
ensureDir()
|
|
25
|
-
const payload = JSON.stringify({
|
|
26
|
-
refresh_token: session.refresh_token,
|
|
27
|
-
access_token: session.access_token || null,
|
|
28
|
-
// Unix SECONDS. Lets the bridge adopt the saved access token WITHOUT a refresh while
|
|
29
|
-
// it's still comfortably valid (account.mjs F1) — every skipped rotation is one fewer
|
|
30
|
-
// race window (and one fewer chance to be killed mid-rotation and brick the login).
|
|
31
|
-
expires_at: session.expires_at || null,
|
|
32
|
-
email: session.user?.email || session.email || null,
|
|
33
|
-
savedAt: Date.now(),
|
|
34
|
-
})
|
|
35
24
|
// ATOMIC write (2026-07-08): a crash/kill/kickstart mid-write must never leave a
|
|
36
25
|
// truncated auth.json — an unparseable token file reads as "not linked" and bricks the
|
|
37
26
|
// saved login (three bridge outages in two days, one from a restart landing mid-rotation).
|
|
@@ -43,13 +32,38 @@ export function saveAuth(session) {
|
|
|
43
32
|
const tmp = `${AUTH}.tmp.${process.pid}.${Date.now()}`
|
|
44
33
|
try {
|
|
45
34
|
const fd = fs.openSync(tmp, 'w', 0o600)
|
|
46
|
-
try { fs.writeSync(fd, payload); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
35
|
+
try { fs.writeSync(fd, JSON.stringify(payload)); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
47
36
|
fs.renameSync(tmp, AUTH)
|
|
48
37
|
} catch {
|
|
49
38
|
try { fs.rmSync(tmp, { force: true }) } catch { /* noop */ }
|
|
50
39
|
}
|
|
51
40
|
}
|
|
41
|
+
|
|
42
|
+
export function saveAuth(session) {
|
|
43
|
+
if (!session?.refresh_token) return
|
|
44
|
+
writeAuthPayload({
|
|
45
|
+
refresh_token: session.refresh_token,
|
|
46
|
+
access_token: session.access_token || null,
|
|
47
|
+
// Unix SECONDS. Lets the bridge adopt the saved access token WITHOUT a refresh while
|
|
48
|
+
// it's still comfortably valid (account.mjs F1) — every skipped rotation is one fewer
|
|
49
|
+
// race window (and one fewer chance to be killed mid-rotation and brick the login).
|
|
50
|
+
expires_at: session.expires_at || null,
|
|
51
|
+
email: session.user?.email || session.email || null,
|
|
52
|
+
savedAt: Date.now(),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
52
55
|
export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
|
|
56
|
+
export function markAuthReconnectRequired(errorCode = null) {
|
|
57
|
+
const current = loadAuth()
|
|
58
|
+
if (!current?.refresh_token) return false
|
|
59
|
+
writeAuthPayload({
|
|
60
|
+
...current,
|
|
61
|
+
reconnect_required: true,
|
|
62
|
+
reconnect_error_code: errorCode || null,
|
|
63
|
+
reconnect_required_at: Date.now(),
|
|
64
|
+
})
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
53
67
|
export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
|
|
54
68
|
|
|
55
69
|
export function loadDirs() { try { return JSON.parse(fs.readFileSync(DIRS, 'utf8')) } catch { return {} } }
|
package/launcher.mjs
CHANGED
|
@@ -75,8 +75,10 @@ export function detectState() {
|
|
|
75
75
|
const hermesInstalled = onPath('hermes')
|
|
76
76
|
const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
|
|
77
77
|
const accountSvc = serviceLoaded(null)
|
|
78
|
+
const reconnectRequired = !!auth?.reconnect_required
|
|
78
79
|
return {
|
|
79
|
-
loggedIn: !!auth?.refresh_token,
|
|
80
|
+
loggedIn: !!auth?.refresh_token && !reconnectRequired,
|
|
81
|
+
reconnectRequired,
|
|
80
82
|
email: auth?.email || auth?.user?.email || null,
|
|
81
83
|
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
82
84
|
providerModel: isCustom ? (provider.model || null) : null,
|
|
@@ -106,6 +108,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
106
108
|
...next,
|
|
107
109
|
agents: Array.isArray(next?.agents) ? next.agents : [],
|
|
108
110
|
loggedIn: !!next?.loggedIn,
|
|
111
|
+
reconnectRequired: !!next?.reconnectRequired,
|
|
109
112
|
accountSvc: !!next?.accountSvc,
|
|
110
113
|
serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
|
|
111
114
|
platform: next?.platform || process.platform,
|
|
@@ -130,7 +133,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
130
133
|
io.print('')
|
|
131
134
|
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
|
|
132
135
|
io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
|
|
133
|
-
io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
136
|
+
io.print(` ${C.dim('│ account ')} ${state.reconnectRequired ? C.yellow('reconnect required') : state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
134
137
|
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
|
|
135
138
|
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
136
139
|
const bridgeStatus = !state.accountSvc
|
|
@@ -165,7 +168,9 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
165
168
|
// logged in — offers to link on the spot, re-reads disk, and refuses to serve if still not.
|
|
166
169
|
const ensureLoggedIn = async () => {
|
|
167
170
|
if (state.loggedIn) return true
|
|
168
|
-
io.print('\n ' + C.yellow(
|
|
171
|
+
io.print('\n ' + C.yellow(state.reconnectRequired
|
|
172
|
+
? 'This bridge lost its Thinkpool account link.'
|
|
173
|
+
: 'You are not linked to a ThinkPool account yet.'))
|
|
169
174
|
if (await askYesNo(' Link this device now?', true)) { await actions.login(); resync() }
|
|
170
175
|
if (!state.loggedIn) { io.print(' ' + C.yellow('login needed to serve your sessions — back to menu')); return false }
|
|
171
176
|
return true
|
|
@@ -230,7 +235,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
230
235
|
for (;;) {
|
|
231
236
|
const options = [
|
|
232
237
|
{ key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
|
|
233
|
-
{ key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
238
|
+
{ key: 'account', label: 'Account', hint: state.reconnectRequired ? 'reconnect required' : state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
234
239
|
]
|
|
235
240
|
if (state.hermesInstalled) options.push({ key: 'hermes', label: state.hermesReady ? 'Hermes profile' : 'Set up Hermes', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
|
|
236
241
|
options.push({ key: 'back', label: 'Back to main menu', hint: '' })
|