thinkpool-pair 0.7.170 → 0.7.172

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
@@ -116,17 +116,170 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
116
116
  await new Promise(() => {})
117
117
  }
118
118
 
119
- // Exchange the stored refresh token for a live session + authed client.
119
+ // A promise that resolves after ms, never keeping the loop alive on its own.
120
+ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.() })
121
+
122
+ // Reject a promise if it doesn't settle within ms — so a wedged realtime socket can't
123
+ // freeze a Supabase call (and the tick that awaits it) forever (the 2026-07-08 wedge:
124
+ // a tick hung on a half-open socket, the re-entrancy guard stuck true, the claim
125
+ // heartbeat stopped, and the bridge stood by against its own 564s-stale row).
126
+ export function withTimeout(promise, ms, label = 'op') {
127
+ return new Promise((resolve, reject) => {
128
+ const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); t.unref?.()
129
+ Promise.resolve(promise).then((v) => { clearTimeout(t); resolve(v) }, (e) => { clearTimeout(t); reject(e) })
130
+ })
131
+ }
132
+
133
+ // Decode a JWT's `exp` (unix seconds) WITHOUT verifying — used only to decide whether the
134
+ // saved access token is fresh enough to skip a refresh. 0 on any parse failure (→ refresh).
135
+ function jwtExpSeconds(token) {
136
+ try {
137
+ const seg = String(token).split('.')[1]
138
+ const json = Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
139
+ const exp = JSON.parse(json)?.exp
140
+ return typeof exp === 'number' ? exp : 0
141
+ } catch { return 0 }
142
+ }
143
+
144
+ // F1 predicate (pure, testable): is the saved access token still worth adopting WITHOUT a
145
+ // refresh? True iff we have an access token whose expiry (from the persisted expires_at, or
146
+ // decoded from the JWT) is more than marginSec ahead of now. Skipping the refresh here is
147
+ // one fewer rotation → one fewer race window / mid-rotation brick.
148
+ export function savedTokenStillValid(saved, nowSec = Math.floor(Date.now() / 1000), marginSec = 300) {
149
+ if (!saved?.access_token) return false
150
+ const expSec = saved.expires_at || jwtExpSeconds(saved.access_token)
151
+ return !!expSec && (expSec - nowSec) > marginSec
152
+ }
153
+
154
+ // ── cross-process refresh serialization (F3) ────────────────────────────────
155
+ // Two bridges refreshing ONE refresh token outside Supabase's short reuse window makes
156
+ // Supabase revoke the entire token FAMILY — every future refresh then fails permanently
157
+ // until a fresh login. A short-lived lock file serializes the actual network refresh
158
+ // across processes; a lock older than staleMs is stale (holder crashed mid-refresh) and
159
+ // is taken over. fs errors fail-OPEN (return true) — a lock we can't manage must never
160
+ // block a genuine refresh. Exported for the test harness.
161
+ const REFRESH_LOCK = path.join(os.homedir(), '.thinkpool-pair', 'refresh.lock')
162
+ export function acquireRefreshLock(lockPath = REFRESH_LOCK, staleMs = 30_000) {
163
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }) } catch { /* noop */ }
164
+ for (let i = 0; i < 2; i++) {
165
+ try {
166
+ const fd = fs.openSync(lockPath, 'wx') // atomic create-exclusive
167
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); fs.closeSync(fd)
168
+ return true
169
+ } catch (e) {
170
+ if (e.code !== 'EEXIST') return true // unmanageable → fail open
171
+ let age = Infinity
172
+ try { age = Date.now() - (JSON.parse(fs.readFileSync(lockPath, 'utf8'))?.at || 0) } catch { age = Infinity }
173
+ if (age < staleMs) return false // a live refresh holds it → back off
174
+ try { fs.rmSync(lockPath, { force: true }) } catch { return true } // stale → drop + retry
175
+ }
176
+ }
177
+ return false
178
+ }
179
+ export function releaseRefreshLock(lockPath = REFRESH_LOCK) { try { fs.rmSync(lockPath, { force: true }) } catch { /* noop */ } }
180
+
181
+ // Build an authed session from a saved {access_token, refresh_token} WITHOUT rotating the
182
+ // refresh token. setSession validates the access token (getUser — no rotation) when it is
183
+ // still valid; callers only ever adopt a FRESH token, so it never falls through to a refresh.
184
+ async function adoptSavedSession(sb, saved) {
185
+ if (!saved?.access_token || !saved?.refresh_token) return null
186
+ try {
187
+ const { data, error } = await sb.auth.setSession({ access_token: saved.access_token, refresh_token: saved.refresh_token })
188
+ if (error || !data?.session) return null
189
+ return data.session
190
+ } catch { return null }
191
+ }
192
+
193
+ // One refresh, serialized across processes + adopting a sibling's rotation (F3). Steps:
194
+ // • auth.json already holds a DIFFERENT refresh token than the one we carry → a sibling
195
+ // (or a re-mint) rotated: ADOPT the disk pair instead of refreshing again.
196
+ // • else take the refresh lock; if a sibling holds it, wait briefly and adopt THEIR
197
+ // rotated result rather than racing a second rotation (which revokes the family).
198
+ // • under the lock, re-read disk once more, then refreshSession → saveAuth.
199
+ // Deps are injectable for the test harness. Returns { session, adopted } or null.
200
+ export async function refreshAccountSession(sb, holdRefreshToken, deps = {}) {
201
+ const { load = loadAuth, save = saveAuth, acquire = acquireRefreshLock, release = releaseRefreshLock, wait = sleep } = deps
202
+ const adoptIfRotated = async (rt) => {
203
+ const d = load()
204
+ if (d?.refresh_token && rt && d.refresh_token !== rt) {
205
+ const s = await adoptSavedSession(sb, d)
206
+ if (s) return { session: s, adopted: true }
207
+ }
208
+ return null
209
+ }
210
+ let adopted = await adoptIfRotated(holdRefreshToken)
211
+ if (adopted) return adopted
212
+ const rt0 = load()?.refresh_token || holdRefreshToken
213
+ if (!rt0) return null
214
+ let locked = acquire()
215
+ if (!locked) {
216
+ // A sibling is mid-refresh. Wait for it to finish and adopt its rotated pair rather
217
+ // than firing a competing rotation (the family-revoke race).
218
+ for (let i = 0; i < 20; i++) {
219
+ await wait(250)
220
+ adopted = await adoptIfRotated(rt0)
221
+ if (adopted) return adopted
222
+ if (acquire()) { locked = true; break }
223
+ }
224
+ if (!locked) return null // couldn't get the lock or adopt — caller retries later
225
+ }
226
+ try {
227
+ adopted = await adoptIfRotated(rt0) // re-check under the lock (sibling may have just finished)
228
+ if (adopted) return adopted
229
+ const rt = load()?.refresh_token || rt0
230
+ const { data, error } = await sb.auth.refreshSession({ refresh_token: rt })
231
+ if (error || !data?.session) return null
232
+ save(data.session) // atomic (auth-store) — never a torn token file
233
+ return { session: data.session, adopted: false }
234
+ } finally { if (locked) release() }
235
+ }
236
+
237
+ // Exchange the stored credentials for a live session + authed client.
120
238
  export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
121
239
  const a = loadAuth()
122
240
  if (!a?.refresh_token) return null
123
241
  const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
124
- try {
125
- const { data, error } = await sb.auth.refreshSession({ refresh_token: a.refresh_token })
126
- if (error || !data?.session) return null
127
- saveAuth(data.session) // rotate the refresh token
128
- return { sb, session: data.session }
129
- } catch { return null }
242
+ // F1: skip the refresh entirely when the saved access token is still comfortably valid
243
+ // (>5 min to expiry). Every gratuitous startup rotation is a race window — and, killed
244
+ // mid-rotation, a login-brick. Adopt the saved pair; scheduleTokenRefresh rotates near
245
+ // expiry as it already does.
246
+ if (savedTokenStillValid(a)) {
247
+ const s = await adoptSavedSession(sb, a)
248
+ if (s) return { sb, session: s }
249
+ }
250
+ // Near-expiry / missing / invalid access token → do a serialized rotation (F3).
251
+ const r = await refreshAccountSession(sb, a.refresh_token)
252
+ if (!r?.session) return null
253
+ return { sb, session: r.session }
254
+ }
255
+
256
+ // F2: startup refresh can fail transiently (offline, a sibling mid-rotation, a wedged
257
+ // socket). Retry IN-PROCESS with backoff — NEVER exit(1), because launchd KeepAlive would
258
+ // respawn us within seconds and each respawn hammers the refresh path (the 16× crash
259
+ // storm). authedClient re-reads auth.json every attempt, so a re-mint or a sibling's
260
+ // rotation is picked up on the next try. Returns null ONLY when there is no saved login
261
+ // at all (genuinely not linked) — the caller then stands down CLEANLY (exit 0, no respawn).
262
+ // Deps injectable for the test harness.
263
+ export async function authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, opts = {}) {
264
+ const { attempt = authedClient, load = loadAuth, wait = sleep, onRetry = null, maxAttempts = Infinity, baseMs = 15_000, capMs = 300_000 } = opts
265
+ let delay = baseMs
266
+ for (let n = 1; n <= maxAttempts; n++) {
267
+ const a = await attempt(SUPABASE_URL, SUPABASE_ANON)
268
+ if (a) return a
269
+ if (!load()?.refresh_token) return null // not linked — caller says `login`, exits 0
270
+ if (n >= maxAttempts) break
271
+ onRetry?.(delay, n)
272
+ await wait(delay)
273
+ delay = Math.min(delay * 2, capMs)
274
+ }
275
+ return null
276
+ }
277
+
278
+ // Pure predicate (2026-07-08): the claim/heartbeat loop is "wedged" when no successful
279
+ // claim has landed in ≥ quietMsThreshold (default 40s = ~2 missed 15s ticks). Extracted so
280
+ // the watchdog's trigger is unit-testable without a live socket.
281
+ export function claimLoopWedged({ lastClaimOkAt, now, quietMsThreshold = 40_000 }) {
282
+ return (now - lastClaimOkAt) >= quietMsThreshold
130
283
  }
131
284
 
132
285
  // ── keep the account JWT fresh (presence keepalive) ─────────────────────────
@@ -143,7 +296,7 @@ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
143
296
  // skewMs — refresh this far ahead of expiry (default 90s)
144
297
  // forceMs — fixed interval override (test hook: TP_TOKEN_REFRESH_FORCE_MS)
145
298
  // retryMs — backoff after a transient failure (offline / rotation race)
146
- export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000 }) {
299
+ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
147
300
  let timer = null
148
301
  let cur = session
149
302
  let cancelled = false
@@ -162,9 +315,12 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000
162
315
  async function tick() {
163
316
  if (cancelled) return
164
317
  try {
165
- const { data, error } = await sb.auth.refreshSession({ refresh_token: cur.refresh_token })
166
- if (error || !data?.session) throw error || new Error('no session')
167
- cur = data.session
318
+ // Serialized refresh (F3): re-reads auth.json and adopts a sibling's rotated pair
319
+ // instead of blindly refreshing with our in-memory `cur.refresh_token` — the old
320
+ // tick retried with a stale token it held and could lose the family to a race.
321
+ const r = await refresh(sb, cur.refresh_token)
322
+ if (!r?.session) throw new Error('no session')
323
+ cur = r.session
168
324
  // Hand the fresh token to the live realtime socket so the presence
169
325
  // connection stays authed across the swap (no drop, no rejoin needed).
170
326
  try { sb.realtime?.setAuth?.(cur.access_token) } catch { /* noop */ }
@@ -196,17 +352,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
196
352
  console.error('\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n `npx thinkpool-pair <ROOM>`.\n')
197
353
  process.exit(0)
198
354
  }
199
- const auth = await authedClient(SUPABASE_URL, SUPABASE_ANON)
355
+ // F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
356
+ // KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
357
+ // worse, hammering the refresh path burns rotations and can revoke the token family).
358
+ // Instead retry IN-PROCESS with backoff, re-reading auth.json each time so a headless
359
+ // re-mint or a sibling's rotation is picked up without a restart. Do NOT delete
360
+ // auth.json — a transient failure must never wipe a still-valid login (2026-06-17).
361
+ const auth = await authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, {
362
+ onRetry: (delay) => process.stderr.write(`\n ◇ Couldn't refresh your account session yet (offline, or another bridge is mid-rotation) — retrying in ${Math.round(delay / 1000)}s. Saved login left in place.\n`),
363
+ })
200
364
  if (!auth) {
201
365
  releaseSingleton()
202
- // Do NOT delete auth.json here. A refresh can fail transiently (offline) or
203
- // lose a refresh-token rotation race with another bridge sharing this login —
204
- // wiping the file on that destroys a still-valid login. Leave it: only an
205
- // explicit `login` should ever replace the token (saveAuth overwrites it).
206
- // (2026-06-17: a second account supervisor raced the token and the old
207
- // clearAuth() here deleted a live Pro login mid-session.)
208
- console.error('\n ◇ Couldn\'t refresh your account session (offline, or another bridge\n may be using this login). Your saved login was left in place.\n If this keeps happening, re-link: npx thinkpool-pair login\n')
209
- process.exit(1)
366
+ // Reached ONLY when there is no saved login at all (not linked). Stand down CLEANLY
367
+ // with exit 0: launchd KeepAlive has SuccessfulExit=false, so exit 0 is NOT respawned
368
+ // — no crash storm for the "never logged in" case. The user links with `login`.
369
+ console.error('\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: npx thinkpool-pair login\n')
370
+ process.exit(0)
210
371
  }
211
372
  const { sb, session } = auth
212
373
  const email = session.user?.email || 'your account'
@@ -422,7 +583,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
422
583
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
423
584
  // (network blip, or a token swap mid-flight) rejoins the channel and must
424
585
  // re-announce, or the dashboard would read "no bridge" until the next restart.
425
- await new Promise((res) => acct.subscribe((st) => { if (st === 'SUBSCRIBED') { pushPresence(); res() } }))
586
+ await new Promise((res) => acct.subscribe((st) => {
587
+ if (st === 'SUBSCRIBED') { pushPresence(); res() }
588
+ else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
589
+ // Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
590
+ // auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
591
+ // this client — recover instead of the loop standing by against its own stale row.
592
+ // The callback stays wired for the channel's life, so later rejoins re-fire it.
593
+ try { sb.realtime?.connect?.() } catch { /* noop */ }
594
+ }
595
+ }))
426
596
 
427
597
  // Keep the account JWT fresh so the presence socket never gets deauthed at
428
598
  // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
@@ -451,9 +621,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
451
621
  const BRIDGE_ID = randomUUID()
452
622
  let claimHeld = false
453
623
  let standbyHost = null
624
+ // Wall-clock of the last SUCCESSFUL claim RPC. The heartbeat watchdog (below) uses it to
625
+ // detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
626
+ // 564s-stale row because the tick had frozen and the heartbeat simply stopped.
627
+ let lastClaimOkAt = Date.now()
454
628
  const refreshClaim = async () => {
455
629
  try {
456
- const { data } = await sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine })
630
+ // Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
631
+ // the tick that awaits it) forever. On timeout we throw → keep the prior held state.
632
+ const { data } = await withTimeout(sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine }), 10_000, 'claim_bridge')
633
+ lastClaimOkAt = Date.now()
457
634
  const c = Array.isArray(data) ? data[0] : data
458
635
  const held = !!c?.held
459
636
  const host = c?.holder_host || '?'
@@ -578,12 +755,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
578
755
  // awaits a code_sessions query before children.set(), so two overlapping runs can
579
756
  // both see children.has(room) false and DOUBLE-SPAWN a 2nd bridge for one room
580
757
  // (two bridges per room = the CASCADE-delete / ghost-terminal hazard). Serialize.
581
- let ticking = false
758
+ // SELF-HEALING guard (2026-07-08): a plain boolean stuck `true` was the wedge — a tick
759
+ // that hung on a half-open socket never ran its `finally`, so `ticking` stayed true and
760
+ // every later tick returned early forever (heartbeat dead, own row goes 564s stale). Use
761
+ // a start-timestamp instead: a prior run older than 60s is presumed wedged and we proceed
762
+ // anyway (the timed-out awaits inside now guarantee it eventually unwinds).
763
+ let tickingSince = 0
582
764
  const tick = async () => {
583
- if (ticking) return
584
- ticking = true
585
- try { await tickBody() } finally { ticking = false }
765
+ const now = Date.now()
766
+ if (tickingSince && now - tickingSince < 60_000) return // a genuine in-flight tick
767
+ tickingSince = now
768
+ try { await tickBody() } finally { if (tickingSince === now) tickingSince = 0 }
586
769
  }
770
+ // Expose for the watchdog to force-unwedge the guard after a reconnect.
771
+ const clearTickGuard = () => { tickingSince = 0 }
587
772
  const tickBody = async () => {
588
773
  // Hold the account claim before serving anything (cross-machine guard). When
589
774
  // standing by, make sure we're serving nothing and wait for the next tick.
@@ -778,6 +963,24 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
778
963
  const iv = setInterval(tick, 15000)
779
964
  const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
780
965
  let stopping = false
966
+
967
+ // ── claim/heartbeat watchdog (2026-07-08) ──────────────────────────────────
968
+ // The 15s tick carries the claim heartbeat. If anything inside a tick wedged (a half-open
969
+ // realtime socket), the heartbeat stopped, our own claim row went stale, and — because a
970
+ // same-host holder is never displaced by itself — the bridge stood by FOREVER against its
971
+ // own ghost (the 564s-stale-own-hostname outage). This timer runs on its OWN interval so a
972
+ // wedged tick can't starve it: when the claim has been quiet ≥2 ticks it nudges realtime
973
+ // to reconnect, force-clears the re-entrancy guard, and re-issues the heartbeat directly
974
+ // (claim_bridge is a plain HTTP RPC — it recovers even while the socket is still flapping).
975
+ const heartbeatWatch = setInterval(async () => {
976
+ if (stopping) return
977
+ if (!claimLoopWedged({ lastClaimOkAt, now: Date.now() })) return
978
+ console.log(`claim_watchdog sup=${SUP_ID} quiet_ms=${Date.now() - lastClaimOkAt} — reconnecting realtime + re-claiming`)
979
+ try { sb.realtime?.connect?.() } catch { /* noop */ }
980
+ clearTickGuard() // unwedge the guard so the next scheduled tick can run
981
+ try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
982
+ }, 20_000)
983
+ heartbeatWatch.unref?.()
781
984
  // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
782
985
  // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
783
986
  const stop = (sig) => {
@@ -787,6 +990,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
787
990
  // ── supervisor stand-down logging (cascade brg-instrument) ─────────────────
788
991
  console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
789
992
  clearInterval(iv)
993
+ clearInterval(heartbeatWatch)
790
994
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
791
995
  keepFresh.cancel()
792
996
  releaseSingleton()
package/auth-store.mjs CHANGED
@@ -22,14 +22,32 @@ function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /*
22
22
  export function saveAuth(session) {
23
23
  if (!session?.refresh_token) return
24
24
  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
+ // ATOMIC write (2026-07-08): a crash/kill/kickstart mid-write must never leave a
36
+ // truncated auth.json — an unparseable token file reads as "not linked" and bricks the
37
+ // saved login (three bridge outages in two days, one from a restart landing mid-rotation).
38
+ // Write a sibling temp file, fsync it to disk, then rename OVER auth.json — rename is
39
+ // atomic on one filesystem, so any concurrent reader sees the whole old file or the whole
40
+ // new one, never a half-written token. This does NOT undo Supabase's server-side rotation
41
+ // (that invalidates the old token the instant refreshSession returns) — the frequency of
42
+ // rotation is cut by F1/F3 in account.mjs; this guarantees the file itself is never torn.
43
+ const tmp = `${AUTH}.tmp.${process.pid}.${Date.now()}`
25
44
  try {
26
- fs.writeFileSync(AUTH, JSON.stringify({
27
- refresh_token: session.refresh_token,
28
- access_token: session.access_token || null,
29
- email: session.user?.email || session.email || null,
30
- savedAt: Date.now(),
31
- }), { mode: 0o600 })
32
- } catch { /* noop */ }
45
+ const fd = fs.openSync(tmp, 'w', 0o600)
46
+ try { fs.writeSync(fd, payload); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
47
+ fs.renameSync(tmp, AUTH)
48
+ } catch {
49
+ try { fs.rmSync(tmp, { force: true }) } catch { /* noop */ }
50
+ }
33
51
  }
34
52
  export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
35
53
  export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
package/bridge.mjs CHANGED
@@ -92,7 +92,7 @@ const flowBudgets = new Map()
92
92
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
93
93
  import { turnInFlight } from './update-gate.mjs'
94
94
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
95
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine } from './event-id.mjs'
95
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP } from './event-id.mjs'
96
96
  import { makeThrottledTrack } from './presence.mjs'
97
97
 
98
98
  // Public client creds (the same anon values the web app ships — safe to embed).
@@ -987,6 +987,13 @@ const announce = () => {
987
987
  // host (e.g. openrouter.ai). Older web clients ignore unknown fields.
988
988
  provider: PROVIDER_INFO.provider,
989
989
  providerBaseUrl: PROVIDER_INFO.baseUrl || undefined,
990
+ // ctxCarry: this bridge re-feeds a plain-text recap of the visible chat into the
991
+ // FRESH session after a provider switch (the 2026-07-08 context-carry work —
992
+ // openStructured carryRecap → pendingRecap → sendTurn). The UI reads it as
993
+ // bridgeInfo.ctxCarry to word the switch warning honestly: WITH carry the agent
994
+ // "rejoins from a recap of this chat"; WITHOUT it (older bridge, flag absent) it
995
+ // "starts blank". Additive top-level boolean; older clients ignore the unknown key.
996
+ ctxCarry: true,
990
997
  // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), used by the
991
998
  // dashboard to seal a provider's API key so it reaches the bridge without ever
992
999
  // crossing the wire in the clear. Computed once (the keypair is stable).
@@ -1222,8 +1229,19 @@ const handleManifest = async (box, file, term) => {
1222
1229
  let m
1223
1230
  try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
1224
1231
  if (!m?.slug) return
1225
- const readB64 = (p) => { try { return p && fs.readFileSync(p).toString('base64') } catch { return null } }
1226
- const readTxt = (p) => { try { return p && fs.readFileSync(p, 'utf8') } catch { return null } }
1232
+ // 2026-07-07: these used to swallow read errors silently — a transient
1233
+ // unreadable file (race with the render script, permissions, mid-write)
1234
+ // meant the manifest still POSTed with that field missing, and nothing
1235
+ // anywhere logged why. Surface it on stderr so a blank mockup pane in the
1236
+ // room has a breadcrumb on the host side instead of just vanishing.
1237
+ const readB64 = (p) => {
1238
+ try { return p && fs.readFileSync(p).toString('base64') }
1239
+ catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
1240
+ }
1241
+ const readTxt = (p) => {
1242
+ try { return p && fs.readFileSync(p, 'utf8') }
1243
+ catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
1244
+ }
1227
1245
  const cid = randomUUID()
1228
1246
  try {
1229
1247
  const mockupHeaders = { 'Content-Type': 'application/json' }
@@ -1505,7 +1523,7 @@ function worktreeSnapshot(cwd) {
1505
1523
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1506
1524
  // persist to the host file; tool calls round-trip through the perm card; the
1507
1525
  // rolling log replays to joiners and survives bridge restarts (session-store).
1508
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider }) {
1526
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap }) {
1509
1527
  if (sessions.has(id)) return
1510
1528
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1511
1529
  // session store so the Ensemble flag survives a bridge restart (else a respin
@@ -2019,6 +2037,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2019
2037
  }
2020
2038
  : null
2021
2039
  entry.deferred = !!defer // a lazily-booted idle terminal — the background warmer will boot it
2040
+ // Context-carry (2026-07-08): a non-resumable re-open (provider switch / non-resumable
2041
+ // restart) hands the FRESH session a plain-text recap of the visible log as its first
2042
+ // input, so the agent picks up the work instead of starting blank mid-conversation. Held
2043
+ // here (transient — NOT in sessionData, a re-restart re-derives it from the log) and fired
2044
+ // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2045
+ // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2046
+ entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2022
2047
  entry.session = startClaudeSession({
2023
2048
  cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
2024
2049
  // Live ROOM NOW tail appended to every turn's <system-reminder>: sibling lanes
@@ -2162,6 +2187,18 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2162
2187
  try { entry.session?.sendTurn('continue') } catch { /* not resumable — stays idle, user can continue manually */ }
2163
2188
  process.stderr.write(`\n ◆ auto-resumed interrupted turn (${id.slice(0, 8)}) — sent continue.\n`)
2164
2189
  }
2190
+ // Context-carry (2026-07-08): a lane re-opened WITHOUT an SDK resume (provider switch,
2191
+ // non-resumable restart) starts blank. Fire the text-layer recap as its first turn the
2192
+ // instant the session is live — same init-`system` gate as auto-resume above, for the
2193
+ // same reason (a sendTurn into an unconsumed input stream is lost). Fires once. If a
2194
+ // human turn arrived first, the code-turn handler already consumed + cleared it
2195
+ // (prepended to that turn), so this no-ops. Spec: docs/specs/2026-07-08-provider-switch-context-carry.md.
2196
+ if (evt.kind === 'system' && entry.pendingRecap) {
2197
+ const recap = entry.pendingRecap
2198
+ entry.pendingRecap = null
2199
+ try { entry.session?.sendTurn(recap) } catch { /* not live — stays idle, user can continue manually */ }
2200
+ process.stderr.write(`\n ◆ carried ${recap.length}c context recap into fresh session (${id.slice(0, 8)}) after provider/restore switch.\n`)
2201
+ }
2165
2202
  // A background-warmed terminal is now live — free its warmer slot so the next queued
2166
2203
  // idle terminal starts booting.
2167
2204
  if (evt.kind === 'system' && entry.warming) warmComplete(entry)
@@ -2331,6 +2368,13 @@ function respawnStructured(id, provider) {
2331
2368
  if (!s) return false
2332
2369
  // Capture the lane's identity for re-open (NOT resume — fresh SDK session).
2333
2370
  const { model, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2371
+ // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2372
+ // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2373
+ // (before teardown) and hand it to the fresh session as its first turn so the agent
2374
+ // continues the work instead of re-greeting (the 109d22e1 "first message in my lane" bug).
2375
+ // Flow lanes are exempt: they re-dispatch from their own artifacts + role prompt, not a
2376
+ // chat recap, and their protocol (FLOW_DONE / FLOW_REVIEW sentinels) must not be perturbed.
2377
+ const carryRecap = flowSessionId ? null : buildRecapFromLog(log, RECAP_CAP)
2334
2378
  // Teardown WITHOUT the close side effects. drainPending denies any in-flight
2335
2379
  // permission cards (the switch aborts the current turn, same as /model mid-turn).
2336
2380
  drainPending(s)
@@ -2341,7 +2385,7 @@ function respawnStructured(id, provider) {
2341
2385
  // sessionData() (provider included) synchronously on open, so a bridge restart
2342
2386
  // restores the lane on its CURRENT provider, not the original — and its next
2343
2387
  // announce carries the new provider badge (additive {id,name} projection).
2344
- openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt })
2388
+ openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2345
2389
  return true
2346
2390
  }
2347
2391
 
@@ -2643,6 +2687,7 @@ channel
2643
2687
  return
2644
2688
  }
2645
2689
  if (/^\/clear\s*$/.test(text)) {
2690
+ s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
2646
2691
  s.session.sendTurn(text)
2647
2692
  s.log = []; s.seq = makeSeqCounter(0); bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
2648
2693
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
@@ -2670,7 +2715,13 @@ channel
2670
2715
  // its cap. The slash controls above (/model, /clear, /compact) are intentionally exempt:
2671
2716
  // they let a user manage or REDUCE spend even when capped. Legible refusal, no silent stall.
2672
2717
  if (await budgetBlocked(s, payload.term)) return
2673
- s.session.sendTurn(text)
2718
+ // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
2719
+ // restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
2720
+ // — a separate recap turn would race + double-fire) and clear the flag so the init handler
2721
+ // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
2722
+ let sendText = text
2723
+ if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null }
2724
+ s.session.sendTurn(sendText)
2674
2725
  echoYou()
2675
2726
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
2676
2727
  // the @pool you-line, so it inherits the NEXT seq (seqable('pool') is true) and orders
@@ -2873,6 +2924,17 @@ channel
2873
2924
  if (re && !rec.flowSessionId && canResume(rec) && restoredTurnOpen(rec.log || [])) {
2874
2925
  re.pendingAutoResume = true
2875
2926
  }
2927
+ // Context-carry (2026-07-08): the SAME mid-turn case but the SDK context CANNOT
2928
+ // resume (canResume false — a stale/expired session). A bare "continue" would land
2929
+ // in a blank backend, so instead carry a plain-text recap of the visible log as the
2930
+ // first turn — the interrupted work resumes WITH its history. Mid-turn only
2931
+ // (restoredTurnOpen): we never arm an unprompted turn on an idle restored lane (its
2932
+ // transcript stays visible; the person starts their next request fresh). If the
2933
+ // person types before this recap fires, the code-turn handler prepends it instead.
2934
+ // Flow lanes re-dispatch separately (excluded above).
2935
+ else if (re && !rec.flowSessionId && !canResume(rec) && restoredTurnOpen(rec.log || [])) {
2936
+ re.pendingRecap = buildRecapFromLog(rec.log || [], RECAP_CAP)
2937
+ }
2876
2938
  }
2877
2939
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
2878
2940
  // (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
@@ -13,11 +13,30 @@
13
13
  ───────────────────────────────────────────────────────────── */
14
14
 
15
15
  import { randomUUID } from 'node:crypto'
16
+ import { createRequire } from 'node:module'
17
+ import { readFileSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
16
19
  import { query } from '@anthropic-ai/claude-agent-sdk'
17
20
  import { sanitizeSession } from './transcript-sanitize.mjs'
18
21
  import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
19
22
  import { crossPostNeedsCard } from './cross-terminal.mjs'
20
23
  import { correctContext } from './context-windows.mjs'
24
+ import { stallDecision, stallEvent } from './turn-stall.mjs'
25
+
26
+ // The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
27
+ // once at import by walking up from the package entry to its own package.json.
28
+ // Named in the [SDK-REGRESSION] guard below so a silent gate-change is attributable.
29
+ const SDK_VERSION = (() => {
30
+ try {
31
+ const req = createRequire(import.meta.url)
32
+ let d = dirname(req.resolve('@anthropic-ai/claude-agent-sdk'))
33
+ for (let i = 0; i < 8; i++) {
34
+ try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
35
+ const up = dirname(d); if (up === d) break; d = up
36
+ }
37
+ } catch { /* unresolved */ }
38
+ return 'unknown'
39
+ })()
21
40
 
22
41
  // ── risk classification — the accent/danger tier of the permission card ──
23
42
  // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
@@ -73,6 +92,44 @@ export function autoAllow({ toolName, input, mode = 'default', alwaysAllow = new
73
92
  )
74
93
  }
75
94
 
95
+ // AskUserQuestion answer path — pure mapping from the room's requestPermission
96
+ // result to the PreToolUse decision fed back to the model. The card handler
97
+ // denies (PreToolUse can't inject a tool_result) and puts the human's pick in
98
+ // the deny reason, which IS what the model receives. Exported so the feedback
99
+ // contract is locked in a unit test (mock requestPermission → this output).
100
+ // `decision` is the requestPermission return: 'answer:<pick>' when the person
101
+ // selected, anything else (including '' / dismissal / a broken path) is treated
102
+ // as "no selection".
103
+ export function askUserQuestionHookOutput(decision) {
104
+ const ans = (typeof decision === 'string' && decision.startsWith('answer:')) ? decision.slice(7) : ''
105
+ return {
106
+ continue: true,
107
+ hookSpecificOutput: {
108
+ hookEventName: 'PreToolUse',
109
+ permissionDecision: 'deny',
110
+ permissionDecisionReason: ans
111
+ ? `The user answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
112
+ : 'The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default.',
113
+ },
114
+ }
115
+ }
116
+
117
+ // Regression guard for the AskUserQuestion gate. SDK 0.3.173 enabled the tool
118
+ // unconditionally; a later bump in the 0.3.185→0.3.204 window flipped it to require
119
+ // a `canUseTool` callback, and the ^0.3.x caret silently killed the feature on a
120
+ // routine restart (2026-07-08). The bridge DOES pass canUseTool, so the SDK's init
121
+ // `tools[]` MUST advertise AskUserQuestion — checked against the real init message
122
+ // the live session already receives (no boot-time probe: cold-CLI init is flaky).
123
+ // If the message carries a tools[] that lacks AskUserQuestion → return the loud
124
+ // [SDK-REGRESSION] reason (logged + surfaced in the room). If it carries no tools[]
125
+ // (older/other SDK message shape) → fail OPEN (null), never a false alarm. Pure +
126
+ // exported so both branches are locked by a unit test.
127
+ export function askUserQuestionRegression(tools, version) {
128
+ if (!Array.isArray(tools)) return null
129
+ if (tools.includes('AskUserQuestion')) return null
130
+ return `[SDK-REGRESSION] agent SDK v${version || 'unknown'} init tools[] is missing 'AskUserQuestion' — the multiple-choice question card is disabled. A caret SDK bump likely changed the canUseTool gate; pin a known-good SDK in bridge/package.json + republish.`
131
+ }
132
+
76
133
  // ── input stream — a generator we keep open and feed turns into ──
77
134
  function makeInputStream() {
78
135
  const queue = []
@@ -183,6 +240,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
183
240
  const spawnT0 = Date.now()
184
241
  let readyLogged = false
185
242
  let modelsSent = false // one-shot: emit the SDK's supported-model list on first init
243
+ let sdkToolsChecked = false // one-shot: run the AskUserQuestion regression guard on the first init carrying tools[]
186
244
  let q = null // the live Query — control requests (interrupt /
187
245
  // setPermissionMode) route through it once streaming.
188
246
  // The session is CREATED in the caller's chosen mode (not hard-coded default):
@@ -223,8 +281,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
223
281
  // 3+ min) or abort silently with no `result`. Track turn liveness + last event
224
282
  // time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
225
283
  // event so the room can stop showing a frozen "working" forever. Cleared on the
226
- // next event / `result`. Refs: TS SDK #44, claude-code #38905.
227
- const STALL_MS = Math.max(30000, parseInt(process.env.TP_STALL_MS, 10) || 120000)
284
+ // next event / `result`. Refs: TS SDK #44, claude-code #38905. The decision layer
285
+ // (stallDecision/stallEvent, above) is pure + unit-tested; this owns the state +
286
+ // side effects. 90s status default (2026-07-08 hardening: the 529-overload hang).
287
+ const STALL_MS = Math.max(30000, parseInt(process.env.TP_STALL_MS, 10) || 90000)
228
288
  let turnActive = false // true between a sent turn and its `result`
229
289
  let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
230
290
  let stalledSent = false // one `stalled` per stall, not a storm
@@ -240,12 +300,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
240
300
  let restartCount = 0
241
301
  let restartTimer = null // the pending auto-restart backoff — cancelled by end()
242
302
  // Force-stop a true wedge (item 3): no result, no error, just silence past
243
- // FORCE_STOP_MS. Clears turnActive so between-turns updates unblock, and
244
- // surfaces a recoverable error. The wedged loop is left in place (a hung
245
- // native call can't be safely restarted mid-flight); if it later throws,
246
- // the catch self-heals.
303
+ // FORCE_STOP_MS. As of the 2026-07-08 hardening we no longer just surface an error
304
+ // and leave the hung loop in place — retryStalledTurn ABORTS the wedged SDK call (via
305
+ // its AbortController, the same unblock path abort()/recreateForSwitch use) and
306
+ // AUTO-RETRIES the turn ONCE, preserving the prompt. If that one retry ALSO stalls
307
+ // past FORCE_STOP_MS, we fall back to the old behavior: force-stop + "send again".
308
+ // stallRetried bounds it to a single auto-retry per turn (reset on each new turn /
309
+ // settled result), so a sustained upstream outage can't thundering-herd retries.
247
310
  const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
248
311
  let forceStopped = false
312
+ let stallRetried = false // one auto-retry per turn (the abort+re-run path below)
249
313
  // Waiting on a HUMAN decision (permission card / plan gate / AskUserQuestion)
250
314
  // is NOT a wedge — the turn is correctly idle until the person answers. Every
251
315
  // interactive gate goes through requestPermission, so wrap it once to bump a
@@ -288,22 +352,29 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
288
352
  let sugTimer = null // pending fallback timer
289
353
  const SUGGEST_FALLBACK_MS = Math.max(1200, parseInt(process.env.TP_SUGGEST_FALLBACK_MS, 10) || 2500)
290
354
 
291
- const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
355
+ // emitRaw forwards an event WITHOUT stamping lastEvtTs — used for the watchdog's OWN
356
+ // synthetic status/error events, which are not SDK activity and must not reset the
357
+ // silence clock (else the 5-min abort would measure from our 90s status emit, not from
358
+ // the last real SDK event). emit() is the normal path: it stamps liveness + clears the
359
+ // one-shot stall flag on any real event other than a `stalled` re-emit.
360
+ const emitRaw = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
361
+ const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; emitRaw(evt) }
292
362
  const stallTimer = setInterval(() => {
293
- if (!turnActive || awaitingUser > 0) return // awaiting a human decision ≠ wedged
294
363
  const quiet = Date.now() - lastEvtTs
295
- if (quiet > FORCE_STOP_MS && !forceStopped) {
296
- // True wedge — no result, no error, just silence. Unblocks between-turns
297
- // updates + surfaces a recoverable error. The wedged loop is left alone.
298
- forceStopped = true
299
- turnActive = false
300
- emit({ kind: 'error', message: `agent went silent for ${Math.round(quiet / 1000)}s — force-stopped the turn; send again to resume`, recoverable: true })
301
- return
302
- }
303
- if (!stalledSent && quiet > STALL_MS) {
304
- stalledSent = true
305
- emit({ kind: 'stalled', sinceMs: quiet })
306
- }
364
+ const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried })
365
+ if (action === 'none') return
366
+ // ALWAYS surface — a stall is never swallowed. emitRaw so this synthetic event
367
+ // doesn't reset the silence clock (see emitRaw above).
368
+ const ev = stallEvent(action, quiet)
369
+ if (ev) emitRaw(ev)
370
+ if (action === 'status') { stalledSent = true; return }
371
+ if (action === 'retry') { forceStopped = true; retryStalledTurn(quiet); return }
372
+ // 'giveup' — the one auto-retry ALSO stalled past FORCE_STOP_MS. Fall back to the
373
+ // pre-2026-07-08 behavior: force-stop the turn so between-turns updates unblock, and
374
+ // let the human resend. The wedged loop is left in place; if it later throws, the
375
+ // catch self-heals.
376
+ forceStopped = true
377
+ turnActive = false
307
378
  }, 5000)
308
379
  stallTimer.unref?.()
309
380
 
@@ -527,17 +598,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
527
598
  let decision = ''
528
599
  try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'ask', questions: toolInput?.questions || [] }) ?? '' }
529
600
  catch { decision = '' }
530
- const ans = (typeof decision === 'string' && decision.startsWith('answer:')) ? decision.slice(7) : ''
531
- return {
532
- continue: true,
533
- hookSpecificOutput: {
534
- hookEventName: 'PreToolUse',
535
- permissionDecision: 'deny',
536
- permissionDecisionReason: ans
537
- ? `The user answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
538
- : 'The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default.',
539
- },
540
- }
601
+ return askUserQuestionHookOutput(decision)
541
602
  }
542
603
  const risk = classifyRisk(toolName, toolInput)
543
604
  // "Don't ask again" is keyed by tool + risk tier, so allowing medium Bash
@@ -574,6 +635,21 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
574
635
  abortController: ac,
575
636
  permissionMode: mode,
576
637
  hooks: { PreToolUse: [{ hooks: [preTool] }] },
638
+ // Enable AskUserQuestion (the agent's multiple-choice card) in EVERY mode.
639
+ // The CLI only adds AskUserQuestion / EnterPlanMode / ExitPlanMode to a
640
+ // session's toolset when the host declares it can service an interactive
641
+ // permission — i.e. when a `canUseTool` callback is present. Without it the
642
+ // tool is absent and the model gets "AskUserQuestion exists but is not
643
+ // enabled in this context", so the room's card handler above was dead code.
644
+ // Its PRESENCE is the whole point: in bypassPermissions this callback is
645
+ // shadowed (never invoked — bypass auto-approves before it, per the SDK's
646
+ // CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning), and in gated modes the
647
+ // PreToolUse hook runs first and stays authoritative (a hook deny — which is
648
+ // exactly how the AskUserQuestion card feeds its answer back — short-circuits
649
+ // canUseTool). So the human-gate holds in all modes: an ask-card always waits
650
+ // for a person via requestPermission inside the hook. This allow is a no-op
651
+ // relative to the hook, never a second gate that could override a denial.
652
+ canUseTool: async (_toolName, input) => ({ behavior: 'allow', updatedInput: input }),
577
653
  // Load the host's REAL Claude environment — user + project + local settings —
578
654
  // so custom slash commands (.claude/commands/*.md), CLAUDE.md and agents work
579
655
  // in the room exactly as in the user's own CLI. The Agent SDK isolates by
@@ -780,6 +856,19 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
780
856
  // correct it). opts.model reflects what the session will actually run, so trust it.
781
857
  curModel = opts.model || m.model || model || curModel
782
858
  emit({ kind: 'system', sessionId, model: opts.model || m.model || model || null, commands: Array.isArray(m.slash_commands) ? m.slash_commands : undefined })
859
+ // AskUserQuestion regression guard — the init message carries the session's
860
+ // real tool list. If a caret SDK bump silently dropped AskUserQuestion from
861
+ // it (the 2026-07-08 outage), surface it loudly instead of a dead card. One-
862
+ // shot per session (resumed sessions replay init messages). Fails open when
863
+ // the message shape carries no tools[].
864
+ if (!sdkToolsChecked && Array.isArray(m.tools)) {
865
+ sdkToolsChecked = true
866
+ const regression = askUserQuestionRegression(m.tools, SDK_VERSION)
867
+ if (regression) {
868
+ process.stderr.write(`\n ⚠⚠ ${regression}\n`)
869
+ emit({ kind: 'error', message: regression })
870
+ }
871
+ }
783
872
  // Dynamic model list — ask the SDK for its OWN supported models and surface
784
873
  // them to the room so the /model picker renders live options (incl. new
785
874
  // models like Fable) instead of a hardcoded three-item list. Fire-and-forget:
@@ -875,6 +964,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
875
964
  // drops refills every cycle past RESTART_MAX) — and marks this session id durably
876
965
  // persisted so a later model-switch re-create can safely resume it.
877
966
  forceStopped = false
967
+ stallRetried = false // a turn settled → the next turn gets a fresh auto-retry budget
878
968
  // Ground-truth the model from THIS turn's modelUsage (the models actually billed
879
969
  // this turn), not the init/system messages — a RESUMED session REPLAYS the prior
880
970
  // transcript's init messages, so curModel got polluted back to the pre-switch model
@@ -991,12 +1081,43 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
991
1081
  sessionId = persistedSessionId
992
1082
  if (!closed) runQuery() // relaunch on opts.model + resume
993
1083
  }
1084
+ // Abort a wedged turn (no event, no throw — the SDK's `for await` blocked inside an
1085
+ // internal retry, e.g. an Anthropic 529-overload window) and re-run it ONCE, preserving
1086
+ // the prompt. Mirrors recreateForSwitch's teardown-then-relaunch (abort qAc → swap the
1087
+ // input stream → await the dying query's disposal so the two processes never race on the
1088
+ // session file lock → resume the durable id) and then RE-PUSHES lastTurnText so the turn
1089
+ // continues instead of being lost. The stall error was already surfaced by the watchdog
1090
+ // (emitRaw of stallEvent('retry', …)); this adds a 'note' once the fresh turn is armed.
1091
+ // Bounded to one call per turn by stallRetried (set in the interval before we're called).
1092
+ const retryStalledTurn = async (quietMs) => {
1093
+ if (closed) return
1094
+ stallRetried = true
1095
+ const wasActive = turnActive
1096
+ const replay = wasActive ? lastTurnText : null
1097
+ turnActive = false // unblock between-turns updates while we tear down
1098
+ const dying = qDone
1099
+ const oldInput = input
1100
+ input = makeInputStream() // fresh stream for the relaunched query
1101
+ if (qAc) { try { qAc.abort() } catch { /* noop */ } } // unblock the wedged for-await → its catch disposes the process
1102
+ try { oldInput.end() } catch { /* noop */ }
1103
+ try { await dying } catch { /* noop */ } // wait until the old query is actually dead (avoids the lock race)
1104
+ await sleep(300) // brief lock-release margin
1105
+ sessionId = persistedSessionId // resume the durably-persisted id (recreateForSwitch invariant)
1106
+ if (closed) return
1107
+ runQuery()
1108
+ if (replay != null) {
1109
+ turnActive = true; lastEvtTs = Date.now(); stalledSent = false // re-arm liveness for the retried turn
1110
+ // Match sendTurn's block shape: a slash command goes clean; a normal turn keeps the reminder.
1111
+ input.push(/^\s*\//.test(replay) ? [{ type: 'text', text: replay }] : [{ type: 'text', text: replay }, { type: 'text', text: roomReminder() }])
1112
+ emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
1113
+ }
1114
+ }
994
1115
  if (!lazy) runQuery()
995
1116
 
996
1117
  return {
997
1118
  // A lazy (restored-idle) session cold-boots the query on its first turn. input.push
998
1119
  // is queue-backed, so the pushed turn buffers and runs once the query is ready.
999
- sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false;
1120
+ sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
1000
1121
  const t = String(text)
1001
1122
  // SDK 0.3.198 only recognizes a slash command (/compact, /clear, custom /cmds) when the
1002
1123
  // user message is a SINGLE text block. Appending the per-turn ROOM NOW <system-reminder>
package/event-id.mjs CHANGED
@@ -248,3 +248,68 @@ export function usageReportLine (sessionModel, logEvents) {
248
248
  const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
249
249
  return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
250
250
  }
251
+
252
+ // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
253
+ export const RECAP_CAP = 20000
254
+
255
+ // Context-carry recap for a lane whose SDK memory CAN'T be resumed (a cross-provider
256
+ // switch, or a bridge-restart restore where canResume() is false). Such a lane re-opens
257
+ // under the SAME id with the visible transcript replayed but a FRESH, blank backend — so
258
+ // the agent starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the
259
+ // first message in my lane" incident). Raw SDK resume can't cross a provider (signed
260
+ // thinking blocks + provider-specific tool_use records get rejected by a foreign backend),
261
+ // so we carry the conversation at the TEXT layer instead: synthesize a plain-text recap
262
+ // from the saved log — the people's turns (`you`) + the assistant's own text replies
263
+ // (`assistant` text blocks; thinking + tool_use are dropped, they don't survive a backend
264
+ // hop and are noise) — newest-weighted under a hard char cap, framed so the agent RESUMES
265
+ // rather than re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
266
+ //
267
+ // capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
268
+ // bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
269
+ export function buildRecapFromLog(log, cap = RECAP_CAP) {
270
+ if (!Array.isArray(log) || !log.length) return ''
271
+ const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
272
+ if (!budget) return ''
273
+ // Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
274
+ // `assistant` = this agent's own earlier reply (its text blocks only).
275
+ const turns = []
276
+ for (const e of log) {
277
+ if (!e || typeof e !== 'object') continue
278
+ if (e.kind === 'you') {
279
+ const t = String(e.text || '').trim()
280
+ if (t) turns.push({ who: 'PERSON', text: t })
281
+ } else if (e.kind === 'assistant') {
282
+ const t = (Array.isArray(e.blocks) ? e.blocks : [])
283
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
284
+ .map((b) => b.text.trim())
285
+ .join('\n')
286
+ if (t) turns.push({ who: 'YOU', text: t })
287
+ }
288
+ }
289
+ if (!turns.length) return ''
290
+ // Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
291
+ // then restore chronological order. A single newest turn bigger than the whole budget is
292
+ // still kept (truncated) so the LIVE task is never dropped to the cap.
293
+ const kept = []
294
+ let used = 0
295
+ let omitted = false
296
+ for (let i = turns.length - 1; i >= 0; i--) {
297
+ let line = `${turns[i].who}: ${turns[i].text}`
298
+ if (used + line.length > budget) {
299
+ if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
300
+ line = line.slice(0, budget) // newest turn alone overflows → clip, still include
301
+ }
302
+ kept.push(line)
303
+ used += line.length
304
+ }
305
+ kept.reverse()
306
+ const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
307
+ return [
308
+ '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
309
+ "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
310
+ '',
311
+ '--- conversation so far (oldest → newest) ---',
312
+ body,
313
+ '--- end recap ---',
314
+ ].join('\n')
315
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.170",
3
+ "version": "0.7.172",
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": {
@@ -13,6 +13,7 @@
13
13
  "byok-detect.mjs",
14
14
  "context-windows.mjs",
15
15
  "claude-session.mjs",
16
+ "turn-stall.mjs",
16
17
  "update-gate.mjs",
17
18
  "event-id.mjs",
18
19
  "transcript-sanitize.mjs",
package/service.mjs CHANGED
@@ -232,6 +232,61 @@ function verifyDarwinReload(lbl, file, version) {
232
232
  return false
233
233
  }
234
234
 
235
+ // F4 — every io.thinkpool.pair.* label currently LOADED in the launchd/systemd domain,
236
+ // plus any label with a plist/unit still on disk (loaded or not). Pure enumeration so the
237
+ // sweep below is testable in isolation. `keepLabel` is the label we're about to (re)install
238
+ // — its own bootout+bootstrap is the caller's job, so the sweep leaves it alone.
239
+ export function existingServiceLabels(platform = process.platform, { fsMod = fs } = {}) {
240
+ const labels = new Set()
241
+ try {
242
+ if (platform === 'darwin') {
243
+ try {
244
+ const out = execSync(`launchctl print gui/$(id -u) 2>/dev/null | grep -oE 'io\\.thinkpool\\.pair\\.[A-Za-z0-9_]+' | sort -u`, { encoding: 'utf8', shell: '/bin/bash' })
245
+ for (const l of out.split('\n')) { const t = l.trim(); if (t) labels.add(t) }
246
+ } catch { /* nothing loaded */ }
247
+ try {
248
+ const dir = path.join(os.homedir(), 'Library', 'LaunchAgents')
249
+ for (const f of fsMod.readdirSync(dir)) { const m = f.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.plist$/); if (m) labels.add(m[1]) }
250
+ } catch { /* no dir */ }
251
+ } else if (platform === 'linux') {
252
+ try {
253
+ const out = execSync(`systemctl --user list-unit-files 'io.thinkpool.pair.*' --no-legend 2>/dev/null | awk '{print $1}'`, { encoding: 'utf8', shell: '/bin/bash' })
254
+ for (const l of out.split('\n')) { const t = l.trim().replace(/\.service$/, ''); if (t) labels.add(t) }
255
+ } catch { /* none */ }
256
+ try {
257
+ const dir = path.join(os.homedir(), '.config', 'systemd', 'user')
258
+ for (const f of fsMod.readdirSync(dir)) { const m = f.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.service$/); if (m) labels.add(m[1]) }
259
+ } catch { /* no dir */ }
260
+ }
261
+ } catch { /* best-effort */ }
262
+ return [...labels]
263
+ }
264
+
265
+ // F4 — before (re)installing, sweep EVERY pre-existing thinkpool-pair service EXCEPT the one
266
+ // we're about to install (which the install path bootout+bootstraps itself). Today's outage
267
+ // trigger was an uninstall/reinstall leaving TWO account services live, both heart-beating,
268
+ // racing the saved login. Idempotent + best-effort; never throws. Also reaps orphaned ROOM
269
+ // CHILD bridges (the proven verifyDarwinReload pattern — the uppercase-room guard never
270
+ // matches the no-arg menu or the supervisor, so the live account bridge is never touched).
271
+ export function sweepExistingServices(keepLabel, platform = process.platform) {
272
+ for (const lbl of existingServiceLabels(platform)) {
273
+ if (lbl === keepLabel) continue
274
+ try {
275
+ if (platform === 'darwin') {
276
+ execSync(`launchctl bootout gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' })
277
+ try { fs.rmSync(path.join(os.homedir(), 'Library', 'LaunchAgents', `${lbl}.plist`), { force: true }) } catch { /* noop */ }
278
+ } else if (platform === 'linux') {
279
+ execSync(`systemctl --user disable --now ${lbl}.service 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' })
280
+ }
281
+ process.stderr.write(` ◆ swept stale service ${lbl}\n`)
282
+ } catch { /* best-effort */ }
283
+ }
284
+ // Reap orphaned ROOM child bridges only — safe (never the supervisor / interactive menu).
285
+ if (platform !== 'win32') {
286
+ try { execSync(`pkill -9 -f 'thinkpool-pair/bridge.mjs [A-Z0-9]' 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' }) } catch { /* noop */ }
287
+ }
288
+ }
289
+
235
290
  // staleProof defaults ON for every real install: even a FIRST install can be launched from
236
291
  // a stale npx cache (the launcher itself is npx-resolved), so without the cache-wiping
237
292
  // wrapper a fresh `install-service` could pin a stale build just like the update path did.
@@ -240,6 +295,10 @@ function verifyDarwinReload(lbl, file, version) {
240
295
  // so its unit tests assert the pure pinned shape.
241
296
  export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
242
297
  const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof })
298
+ // F4: clear every OTHER pre-existing pair service first, so only the one we install remains
299
+ // (the "two account services live" storm trigger). Our own label is bootout+bootstrapped
300
+ // by a.post below, so keep it.
301
+ try { sweepExistingServices(label(room), process.platform) } catch { /* best-effort */ }
243
302
  fs.mkdirSync(a.logDir, { recursive: true })
244
303
  fs.mkdirSync(path.dirname(a.file), { recursive: true })
245
304
  fs.writeFileSync(a.file, a.content)
package/turn-stall.mjs ADDED
@@ -0,0 +1,43 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ turn-stall.mjs — the pure decision layer for the structured-turn stall watchdog.
3
+
4
+ A structured turn can stall with the SDK's `for await` blocked INSIDE an internal retry
5
+ — no event, no throw — so claude-session's catch never runs and nothing reaches the room.
6
+ The 2026-07-08 incident: an Anthropic 529-overload window hung a turn for 14 minutes with
7
+ zero output until it was aborted by hand. The watchdog in claude-session.mjs turns that
8
+ silent wedge into a visible status, then an abort + one auto-retry, then a force-stop.
9
+
10
+ These two functions are the DECISION layer (when to act / what to say). They're pure — no
11
+ SDK, no clock, no I/O — so the stateful loop injects state in and they unit-test against a
12
+ fake clock with zero deps (bridge/test-turn-stall-watchdog.mjs). Same shape + spirit as
13
+ account.mjs's claimLoopWedged / withTimeout. Kept in their OWN module (no SDK import) so the
14
+ test needs neither the agent SDK nor auth. The loop owns the flags + the abort/re-push side
15
+ effects; this only says WHAT to do.
16
+ ───────────────────────────────────────────────────────────── */
17
+
18
+ // Decide the watchdog action for an ACTIVE turn gone quiet (quietMs = ms since the last
19
+ // emitted SDK event). Idle (no turn) or awaiting a HUMAN decision (permission card / plan
20
+ // gate / AskUserQuestion) is never a wedge — the turn is correctly idle until the person
21
+ // answers. Timeline:
22
+ // quietMs > stallMs (90s) → 'status' — surface a "still retrying (Ns)" banner, ONCE
23
+ // quietMs > forceStopMs (5min) → 'retry' — abort the hung call, re-run the turn ONCE
24
+ // quietMs > forceStopMs again → 'giveup' — the one retry also stalled: force-stop, resend
25
+ export function stallDecision({ turnActive, awaitingUser, quietMs, stallMs, forceStopMs, stalledSent, stallRetried }) {
26
+ if (!turnActive || awaitingUser > 0) return 'none'
27
+ if (quietMs > forceStopMs) return stallRetried ? 'giveup' : 'retry'
28
+ if (!stalledSent && quietMs > stallMs) return 'status'
29
+ return 'none'
30
+ }
31
+
32
+ // The room-facing event for a watchdog action (pure → the exact wording is unit-tested).
33
+ // 'status' rides the transient `stalled` chrome event (broadcast, never persisted); 'retry'
34
+ // and 'giveup' ride `error` (recoverable) so the stall is ALWAYS surfaced, never swallowed.
35
+ export function stallEvent(action, quietMs) {
36
+ const s = Math.round(quietMs / 1000)
37
+ switch (action) {
38
+ case 'status': return { kind: 'stalled', sinceMs: quietMs, retrying: true, message: `model call stalled — still retrying (${s}s)` }
39
+ case 'retry': return { kind: 'error', recoverable: true, message: `model call stalled ${s}s with no response (upstream overload) — aborting the hung call and retrying the turn once` }
40
+ case 'giveup': return { kind: 'error', recoverable: true, message: `agent went silent for ${s}s — the retry also stalled; force-stopped the turn, send again to resume` }
41
+ default: return null
42
+ }
43
+ }