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 +50 -2
- package/claude-session.mjs +153 -32
- package/launcher.mjs +31 -48
- package/package.json +2 -1
- package/presence.mjs +44 -3
- package/turn-stall.mjs +43 -0
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
|
-
|
|
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()
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// the
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
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
|
-
//
|
|
261
|
-
const items =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 === '
|
|
272
|
-
else if (pick.key === '
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|