thinkpool-pair 0.7.171 → 0.7.173

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
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
16
  import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
- import { makeThrottledTrack } from './presence.mjs'
18
+ import { makeThrottledTrack, presenceSelfEchoVerdict } from './presence.mjs'
19
19
  import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
20
20
  import { supervisorServes } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
@@ -525,7 +525,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
525
525
  // per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
526
526
  // → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
527
527
  // tick + token-refresh callers are unaffected (their interval exceeds the window).
528
- const trackPresence = makeThrottledTrack(acct, { minMs: 5000 })
528
+ // lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
529
+ // 'error') so the presence self-echo watchdog can log WHY presence went dark.
530
+ let lastTrackStatus = 'init'
531
+ const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
529
532
  // `service` lets the dashboard show whether this bridge is the durable installed
530
533
  // service (always-on, survives reboot) vs a foreground `npx` run that dies with its
531
534
  // terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
@@ -625,6 +628,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
625
628
  // detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
626
629
  // 564s-stale row because the tick had frozen and the heartbeat simply stopped.
627
630
  let lastClaimOkAt = Date.now()
631
+ // Presence self-echo watchdog state (2026-07-08 recurrence). claim_bridge rides HTTP and
632
+ // stays healthy, so claimLoopWedged is blind to a zombie realtime channel where track()
633
+ // lands nowhere. The server's presence SYNC echoing OUR OWN `machine` key back into
634
+ // acct.presenceState() is the only proof a track() actually reached the server.
635
+ let selfEchoMissingSince = 0 // wall-clock we first saw our own key missing (0 = present/ok)
636
+ let presenceRecycledForMissing = 0 // de-dupe: the missing-since value we already forced a recycle for
628
637
  const refreshClaim = async () => {
629
638
  try {
630
639
  // Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
@@ -981,6 +990,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
981
990
  try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
982
991
  }, 20_000)
983
992
  heartbeatWatch.unref?.()
993
+
994
+ // ── presence self-echo watchdog (2026-07-08) ───────────────────────────────
995
+ // The claim heartbeat rides claim_bridge (plain HTTP) so it recovers even while the
996
+ // realtime socket is a zombie — which is exactly why claimLoopWedged can't see the case
997
+ // where the socket went CLOSED-then-half-open and channel.track() lands nowhere FOREVER,
998
+ // leaving the dashboard on "No bridge connected" while children serve rooms. The server's
999
+ // presence SYNC is the only proof: if our own `machine` key is absent from
1000
+ // acct.presenceState() while we hold the claim, the track never reached the server. Runs on
1001
+ // its OWN interval (like the claim watchdog) so a wedged tick can't starve it. Two-stage
1002
+ // escalation via the pure predicate:
1003
+ // ≥~60s missing → recycle the realtime socket (full disconnect+connect — the connect()-only
1004
+ // nudge already in the subscribe/CLOSED handlers provably didn't recover it)
1005
+ // ≥~90s more → exit(1); launchd KeepAlive / systemd reruns clean, children persist and
1006
+ // resume from the session-store (the same invisible bounce the dashboard
1007
+ // Restart button already relies on). Guarded: never while stopping, only
1008
+ // while claimHeld (a standby bridge doesn't track).
1009
+ const presenceWatch = setInterval(() => {
1010
+ if (stopping) return
1011
+ const now = Date.now()
1012
+ let present = false
1013
+ try { present = !!acct.presenceState?.()[machine] } catch { present = false }
1014
+ if (present) { selfEchoMissingSince = 0; presenceRecycledForMissing = 0; return }
1015
+ if (!claimHeld) { selfEchoMissingSince = 0; return } // a standby bridge doesn't announce — never escalate
1016
+ if (!selfEchoMissingSince) selfEchoMissingSince = now
1017
+ const verdict = presenceSelfEchoVerdict({ tracking: claimHeld, selfEchoMissingSince, now })
1018
+ if (verdict === 'recycle') {
1019
+ if (presenceRecycledForMissing === selfEchoMissingSince) return // already recycled this episode — wait out the respawn window
1020
+ presenceRecycledForMissing = selfEchoMissingSince
1021
+ console.log(`presence_watchdog sup=${SUP_ID} action=recycle missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — full realtime disconnect+reconnect`)
1022
+ try { sb.realtime?.disconnect?.() } catch { /* noop */ }
1023
+ try { sb.realtime?.connect?.() } catch { /* noop */ }
1024
+ try { pushPresence() } catch { /* re-announced on the next SUBSCRIBED too */ }
1025
+ } else if (verdict === 'respawn') {
1026
+ console.log(`presence_watchdog sup=${SUP_ID} action=respawn missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — recycle failed, exiting for a clean supervisor respawn`)
1027
+ process.exit(1)
1028
+ }
1029
+ }, 15_000)
1030
+ presenceWatch.unref?.()
984
1031
  // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
985
1032
  // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
986
1033
  const stop = (sig) => {
@@ -991,6 +1038,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
991
1038
  console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
992
1039
  clearInterval(iv)
993
1040
  clearInterval(heartbeatWatch)
1041
+ clearInterval(presenceWatch)
994
1042
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
995
1043
  keepFresh.cancel()
996
1044
  releaseSingleton()
@@ -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/launcher.mjs CHANGED
@@ -119,6 +119,8 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
119
119
  io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
120
120
  }
121
121
 
122
+ // KEPT for the `npx thinkpool-pair <ROOM>` argv path (bridge.mjs) — no longer a main-menu
123
+ // item (single-session serve was retired), but the helper stays so nothing else breaks.
122
124
  const pairRoom = async () => {
123
125
  const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
124
126
  if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
@@ -135,39 +137,15 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
135
137
  await actions.pairRoom({ room, agentCmd: agent.cmd, resumeArgs: cont ? agent.resume : [], headless })
136
138
  }
137
139
 
138
- const serveAccount = async () => {
139
- if (!state.loggedIn) {
140
- io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
141
- if (await askYesNo(' Link this device now?', true)) await actions.login()
142
- return
143
- }
144
- io.print(`\n Serving every session you host, from ${C.dim(state.cwd)}.\n`)
145
- const how = await askChoice('run it', [
146
- { label: 'As an always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
147
- { label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
148
- { label: '← Back to main menu', hint: '' },
149
- ])
150
- if (how === 2) return
151
- if (how === 1) { await actions.serveAccountForeground(); return }
152
- // how === 0 — always-on durable OS service (launchd / systemd / Windows Startup).
153
- await actions.installService({ room: null }); resync()
154
- }
155
-
156
- const serviceMenu = async () => {
157
- const what = await askChoice('install for', [
158
- { label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
159
- { label: 'One specific room', hint: 'no login needed' },
160
- { label: '← Back to main menu', hint: '' },
161
- ])
162
- if (what === 2) return
163
- if (what === 0) {
164
- if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
165
- await actions.installService({ room: null }); resync(); return
166
- }
167
- const room = (await io.ask(`\n Room code ${C.dim('(blank to go back)')} ${C.cyan('▸')} `)).toUpperCase().trim()
168
- if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
169
- const agent = state.agents[0] || { cmd: 'claude' }
170
- await actions.installService({ room, agentCmd: agent.cmd }); resync()
140
+ // Login gate shared by both "serve" paths (foreground + install service). Both serve
141
+ // EVERY hosted session, so both require an account link first. Returns true only once
142
+ // logged in — offers to link on the spot, re-reads disk, and refuses to serve if still not.
143
+ const ensureLoggedIn = async () => {
144
+ if (state.loggedIn) return true
145
+ io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
146
+ if (await askYesNo(' Link this device now?', true)) { await actions.login(); resync() }
147
+ if (!state.loggedIn) { io.print(' ' + C.yellow('login needed to serve your sessions — back to menu')); return false }
148
+ return true
171
149
  }
172
150
 
173
151
  const providerMenu = async () => {
@@ -249,29 +227,34 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
249
227
  }
250
228
 
251
229
  // ── menu loop ──
252
- // pairRoom re-execs + waits, and account-foreground runs forever, so those choices
253
- // never return here. Everything else (install / restart / uninstall / settings /
254
- // provider) does its work, prints its notes, and RETURNS — so the loop always drops
255
- // you back at the menu. Submenus carry a "← Back" so you can bail at any point. The
256
- // trailing "press Enter" lands on every returning path.
230
+ // account-foreground runs forever, so that choice never returns here. Everything else
231
+ // (install / restart / uninstall / settings / provider) does its work, prints its notes,
232
+ // and RETURNS — so the loop always drops you back at the menu. The trailing "press Enter"
233
+ // lands on every returning path. Slots 1+2 are the ONLY two things this device does:
234
+ // before a service exists → the two ways to start serving all your sessions; once the
235
+ // service exists → maintain it (update/restart or remove). No single-room / per-room
236
+ // serve is offered anymore (that was `npx thinkpool-pair <ROOM>`, still on the argv path).
257
237
  for (;;) {
258
238
  header()
259
239
  io.print(' ' + C.bold('What do you want to do?') + '\n')
260
- // Built per-render so the contextual "Restart" only shows once a service exists.
261
- const items = [
262
- { key: 'pair', label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
263
- { key: 'serve', label: 'Serve all my sessions', hint: 'account mode — needs login' },
264
- { key: 'service', label: 'Always-on background service', hint: 'survives reboot, restarts on crash' },
265
- ]
266
- if (state.accountSvc) items.push({ key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' })
240
+ // Slots 1+2 swap on whether the always-on service is already installed.
241
+ const items = state.accountSvc
242
+ ? [
243
+ { key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' },
244
+ { key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
245
+ ]
246
+ : [
247
+ { key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
248
+ { key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
249
+ ]
267
250
  items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
268
251
  items.push({ key: 'quit', label: 'Quit', hint: '' })
269
252
  const pick = items[await askChoice('choose', items)]
270
253
  if (pick.key === 'quit') return
271
- if (pick.key === 'pair') await pairRoom()
272
- else if (pick.key === 'serve') await serveAccount()
273
- else if (pick.key === 'service') await serviceMenu()
254
+ if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
255
+ else if (pick.key === 'service') { if (await ensureLoggedIn()) { await actions.installService({ room: null }); resync() } }
274
256
  else if (pick.key === 'restart') { await actions.restartUpdateService({ room: null }); resync() }
257
+ else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
275
258
  else if (pick.key === 'settings') await settingsMenu()
276
259
  await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
277
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.171",
3
+ "version": "0.7.173",
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/presence.mjs CHANGED
@@ -27,16 +27,23 @@
27
27
  // channel: a supabase RealtimeChannel (stable across reconnects — supabase-js
28
28
  // rejoins the same object, so channel.track stays valid). Returns a function you
29
29
  // call with the presence payload anywhere you'd have called channel.track(payload).
30
- export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
30
+ // onStatus (optional): called with each track()'s server status ('ok' | 'timed out' |
31
+ // 'error') so a caller can retain the last one for a postmortem log line. Best-effort —
32
+ // a track that throws reports 'error' and never breaks the caller.
33
+ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } = {}) {
31
34
  let last = 0
32
35
  let timer = null
33
36
  let pending = null
34
- const fire = () => {
37
+ const fire = async () => {
35
38
  timer = null
36
39
  last = Date.now()
37
40
  const payload = pending
38
41
  pending = null
39
- try { channel.track(payload) } catch { /* presence track is best-effort */ }
42
+ // Await the track so we can retain its status for the next postmortem. fire() is never
43
+ // awaited by callers, so this stays fire-and-forget — the await only sequences the
44
+ // onStatus callback; it never blocks a caller and a rejection can't escape (caught here).
45
+ try { const status = await channel.track(payload); onStatus?.(status ?? 'ok') }
46
+ catch { onStatus?.('error') /* presence track is best-effort */ }
40
47
  }
41
48
  return (payload) => {
42
49
  pending = payload
@@ -49,3 +56,37 @@ export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
49
56
  }
50
57
  }
51
58
  }
59
+
60
+ /* ─────────────────────────────────────────────────────────────
61
+ presenceSelfEchoVerdict — the pure trigger for the account
62
+ supervisor's presence self-echo watchdog (2026-07-08 recurrence).
63
+
64
+ The failure it catches: the supervisor is alive, its claim is held
65
+ (claim_bridge is a plain HTTP RPC, so it stays healthy), children
66
+ serve rooms — yet an anonymous probe of tpacct shows ZERO presence
67
+ keys. After a `realtime CLOSED` blip the channel goes zombie and
68
+ channel.track() lands nowhere, forever. The claim watchdog
69
+ (claimLoopWedged) can't see this — the claim rides HTTP, not the
70
+ socket. The server's presence SYNC is the only proof: if OUR OWN
71
+ key is absent from acct.presenceState() while we're tracking and
72
+ hold the claim, the track never reached the server.
73
+
74
+ Given how long our key has been missing (now - selfEchoMissingSince),
75
+ escalate in two stages:
76
+ missing < graceMs → 'ok' (normal; a fresh track takes a beat to echo)
77
+ graceMs ≤ missing < graceMs+respawnMs → 'recycle' (force a full realtime disconnect+reconnect)
78
+ missing ≥ graceMs + respawnMs → 'respawn' (recycle didn't take → exit; supervisor reruns clean)
79
+
80
+ selfEchoMissingSince is 0/null whenever our key is present OR we're
81
+ not tracking; the caller stamps it the first tick the key goes
82
+ missing and clears it the first tick it returns. Extracted + exported
83
+ so the watchdog's trigger is unit-testable without a live socket
84
+ (mirrors claimLoopWedged). Deterministic in `now` — no Date.now here.
85
+ ───────────────────────────────────────────────────────────── */
86
+ export function presenceSelfEchoVerdict({ tracking, selfEchoMissingSince, now, graceMs = 60_000, respawnMs = 90_000 }) {
87
+ if (!tracking || !selfEchoMissingSince) return 'ok'
88
+ const missingMs = now - selfEchoMissingSince
89
+ if (missingMs >= graceMs + respawnMs) return 'respawn'
90
+ if (missingMs >= graceMs) return 'recycle'
91
+ return 'ok'
92
+ }
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
+ }