thinkpool-pair 0.7.309 → 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.
@@ -0,0 +1,23 @@
1
+ // Realtime delivers code-abort and the browser's newly-flushed code-turn through
2
+ // independent handlers. Keep that next human turn behind the runtime's real abort
3
+ // completion so a late aborted boundary cannot close the new turn.
4
+
5
+ export function queueAbortBarrier(entry, runAbort = () => entry?.session?.abort?.()) {
6
+ if (!entry) return Promise.resolve()
7
+ const prior = entry._abortBarrier || Promise.resolve()
8
+ let barrier
9
+ barrier = Promise.resolve(prior)
10
+ .catch(() => {})
11
+ .then(() => runAbort())
12
+ .catch(() => {})
13
+ .finally(() => {
14
+ if (entry._abortBarrier === barrier) entry._abortBarrier = null
15
+ })
16
+ entry._abortBarrier = barrier
17
+ return barrier
18
+ }
19
+
20
+ export async function waitForAbortBarrier(entry) {
21
+ const barrier = entry?._abortBarrier
22
+ if (barrier) await barrier
23
+ }
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
- if (error || !data?.session) return null
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) return null
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) throw new Error('no 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
- try { onRefreshed?.(cur) } catch { /* noop */ }
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
- export function saveAuth(session) {
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/bridge.mjs CHANGED
@@ -65,6 +65,7 @@ import { commandOnPath } from './agent-detect.mjs'
65
65
  import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
66
66
  import { hermesUserInputResponse } from './question-response.mjs'
67
67
  import { hostMemoryAdmission } from './host-memory.mjs'
68
+ import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
68
69
 
69
70
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
70
71
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -3996,6 +3997,12 @@ channel
3996
3997
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
3997
3998
  const s = payload?.term && sessions.get(payload.term)
3998
3999
  if (!s || payload.text == null) return
4000
+ // Stop and the browser's queue flush are separate realtime frames. Codex and
4001
+ // Hermes need their native cancellation to settle before the flushed turn is
4002
+ // accepted; otherwise the old aborted result can land after the new `you` line
4003
+ // and close it. Re-check identity after the await in case the lane was closed.
4004
+ await waitForAbortBarrier(s)
4005
+ if (sessions.get(payload.term) !== s || !s.session) return
3999
4006
  markActivity()
4000
4007
  // B2/C: a real human turn is hop 0 with fresh cross-terminal budgets. This is
4001
4008
  // the loop-breaker reset point — an injected (cross-posted) turn does NOT come
@@ -4290,7 +4297,11 @@ channel
4290
4297
  })
4291
4298
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
4292
4299
  const s = payload?.term && sessions.get(payload.term)
4293
- if (s) { drainPending(s); s.session.abort(); announce() } // settle any open permission card so the hook doesn't hang
4300
+ if (s) {
4301
+ drainPending(s) // settle any open permission card so the hook doesn't hang
4302
+ queueAbortBarrier(s).finally(() => announce())
4303
+ announce()
4304
+ }
4294
4305
  })
4295
4306
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
4296
4307
  const s = payload?.term && sessions.get(payload.term)
package/codex-session.mjs CHANGED
@@ -589,6 +589,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
589
589
  }
590
590
 
591
591
  async function runAppServer(prompt, options = {}) {
592
+ // Stop can win before the queued pump enters native startup. Do not launch a
593
+ // fresh App Server merely to discover the accepted room turn was cancelled.
594
+ if (ended || aborted) return true
592
595
  if (!await ensureAppServer()) return false
593
596
  // Stop can land while the cold App Server / MCP bootstrap is still awaiting.
594
597
  // The accepted turn was already closed at the room boundary; never start it
@@ -651,6 +654,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
651
654
  }
652
655
 
653
656
  async function runAppServerReview(commandText) {
657
+ if (ended || aborted) return true
654
658
  if (!await ensureAppServer()) return false
655
659
  if (ended || aborted) return true
656
660
  mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
@@ -869,11 +873,13 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
869
873
  return true
870
874
  },
871
875
  abort() {
876
+ const activeChain = chain
872
877
  const bootstrapOnly = turnActive && !activeTurnId && !child
873
878
  aborted = true
874
879
  queue.length = 0
875
880
  closeAppServerTurn(activeTurnId)
876
- if (appServer && activeTurnId) { void appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {}) ; return }
881
+ let interrupt = Promise.resolve()
882
+ if (appServer && activeTurnId) interrupt = appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {})
877
883
  if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
878
884
  else if (bootstrapOnly) {
879
885
  // No native turn exists to produce a boundary. Close the optimistic
@@ -881,6 +887,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
881
887
  // aborted check discard the delayed startup.
882
888
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 0, durationMs: undefined, denials: 0, resultText: null })
883
889
  }
890
+ // `code-turn` may already be queued behind Stop in Realtime. Resolve only
891
+ // after the interrupted native turn's chain has fully unwound; accepting a
892
+ // new turn earlier lets the old finally/result close the new lifecycle.
893
+ return interrupt.then(() => activeChain).catch(() => activeChain).then(() => {})
884
894
  },
885
895
  end() {
886
896
  ended = true
@@ -566,7 +566,7 @@ export function startHermesSession({
566
566
  })
567
567
  return true
568
568
  },
569
- abort() {
569
+ async abort() {
570
570
  if (!turnActive) return
571
571
  queuedTurns.length = 0
572
572
  queueDrainPending = false
@@ -582,14 +582,16 @@ export function startHermesSession({
582
582
  retireClient()
583
583
  client?.end()
584
584
  finishAbortedTurn()
585
+ await promptChain.catch(() => {})
585
586
  return
586
587
  }
587
588
  if (!sessionId || !client?.alive) {
588
589
  finishAbortedTurn()
590
+ await promptChain.catch(() => {})
589
591
  return
590
592
  }
591
593
  finishAbortedTurn()
592
- void client.notify('session/cancel', { sessionId }).catch((error) => {
594
+ await client.notify('session/cancel', { sessionId }).catch((error) => {
593
595
  // Never display a stopped lane while the un-cancelled Hermes process
594
596
  // might still be executing. A transport failure tears down the ACP
595
597
  // process and makes the lane honestly non-resumable until restart.
@@ -597,6 +599,10 @@ export function startHermesSession({
597
599
  client?.end()
598
600
  emit({ kind: 'error', message: `Hermes cancellation delivery failed; ACP process stopped: ${error?.message || error}`, recoverable: true })
599
601
  })
602
+ // The ACP cancellation notification is not itself the completion edge.
603
+ // Wait for the active prompt promise to unwind (including the 0.18.2
604
+ // cancelled-response recovery) before a bridge queue flush starts anew.
605
+ await promptChain.catch(() => {})
600
606
  },
601
607
  end() {
602
608
  ended = true
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('You are not linked to a ThinkPool account yet.'))
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: '' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.309",
3
+ "version": "0.7.311",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "abort-turn-barrier.mjs",
11
12
  "host-memory.mjs",
12
13
  "sdk-smoke.mjs",
13
14
  "sdk-admission.mjs",