thinkpool-pair 0.7.137 → 0.7.139
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/claude-session.mjs +111 -12
- package/package.json +1 -1
package/claude-session.mjs
CHANGED
|
@@ -185,6 +185,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
185
185
|
const ac = new AbortController()
|
|
186
186
|
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
187
187
|
let sessionId = resume || null
|
|
188
|
+
// persistedSessionId — the newest session id we KNOW is written to disk (the resume we
|
|
189
|
+
// were handed, then advanced on each successful result). A model-switch re-create must
|
|
190
|
+
// resume THIS, never the live `sessionId`: a resumed query can fork to a fresh session id
|
|
191
|
+
// that isn't persisted until a turn completes, so re-creating on it fails with
|
|
192
|
+
// "No conversation found" (Max 2026-07-02). This is always a resumable id.
|
|
193
|
+
let persistedSessionId = resume || null
|
|
188
194
|
let closed = false
|
|
189
195
|
// Lazy boot (2026-07-02): a RESTORED-IDLE terminal returns a full session object but
|
|
190
196
|
// defers the expensive query() cold-start (MCP + settingSources, ~50s each) until its
|
|
@@ -222,6 +228,21 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
222
228
|
let curModel = model || null // latest model id seen (init/system + ctx); stamped on
|
|
223
229
|
// the result event so the bridge can attribute BYOK Code
|
|
224
230
|
// spend per-model (record_code_usage → chat_usage).
|
|
231
|
+
// A model switch on a RESUMED session can't be done live — q.setModel() RESOLVES without
|
|
232
|
+
// error but is a silent no-op there (the turn keeps running the old model). So a switch is
|
|
233
|
+
// applied by RE-CREATING the query resuming the same session on the new model (which
|
|
234
|
+
// query({resume, model}) honours). Root of Max's 2026-07-02 "reverts to Opus at start of
|
|
235
|
+
// turn": every terminal is a resumed session after a restart, so live setModel did nothing.
|
|
236
|
+
// qAc = the CURRENT query's own AbortController (child of the session `ac`) so the
|
|
237
|
+
// switch can kill JUST this query without ending the session; qDone resolves when the
|
|
238
|
+
// current runQuery loop has fully exited (its process dead) so the re-create doesn't
|
|
239
|
+
// race the old process on the session file lock (proven necessary 2026-07-02).
|
|
240
|
+
// pendingSwitch defers a mid-turn switch until the active turn finishes.
|
|
241
|
+
let qAc = null
|
|
242
|
+
let qDoneResolve = null
|
|
243
|
+
let qDone = Promise.resolve()
|
|
244
|
+
let pendingSwitch = false
|
|
245
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
225
246
|
// ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
|
|
226
247
|
// 3+ min) or abort silently with no `result`. Track turn liveness + last event
|
|
227
248
|
// time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
|
|
@@ -625,7 +646,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
625
646
|
if (mcpServers && Object.keys(mcpServers).length) opts.mcpServers = { ...(opts.mcpServers || {}), ...mcpServers }
|
|
626
647
|
if (cwd) opts.cwd = cwd
|
|
627
648
|
if (model) opts.model = model
|
|
628
|
-
|
|
649
|
+
// NB: resume is NOT baked into opts — runQuery sets it per-run from the live `sessionId`
|
|
650
|
+
// (seeded from `resume`). That makes the resume target controllable: a re-create can point
|
|
651
|
+
// it at persistedSessionId, and the "No conversation found" recovery can DROP it (clearing
|
|
652
|
+
// sessionId) to re-run fresh. If it were in opts, the {...opts} spread would keep resuming
|
|
653
|
+
// the dead id even after we cleared sessionId (Max 2026-07-02 recovery loop).
|
|
629
654
|
// Force-enable Claude Code's predicted-next-prompt. `promptSuggestions: true`
|
|
630
655
|
// alone defers to the server-side `tengu_prompt_suggestion` feature gate, which
|
|
631
656
|
// Anthropic flips on/off during rollout — so the ghost-text chip silently died
|
|
@@ -683,8 +708,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
683
708
|
|
|
684
709
|
const runQuery = async () => {
|
|
685
710
|
started = true
|
|
711
|
+
// Per-query AbortController (child of the session `ac`): lets a model switch kill
|
|
712
|
+
// just this query. Resolve the previous qDone in case it's still pending.
|
|
713
|
+
if (qDoneResolve) { try { qDoneResolve() } catch { /* noop */ } }
|
|
714
|
+
qDone = new Promise((res) => { qDoneResolve = res })
|
|
715
|
+
const myAc = new AbortController()
|
|
716
|
+
qAc = myAc
|
|
717
|
+
const onParentAbort = () => { try { myAc.abort() } catch { /* noop */ } }
|
|
718
|
+
ac.signal.addEventListener('abort', onParentAbort, { once: true })
|
|
686
719
|
try {
|
|
687
|
-
q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
720
|
+
q = query({ prompt: input.stream, options: { ...opts, abortController: myAc, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
688
721
|
for await (const m of q) {
|
|
689
722
|
if (closed) break
|
|
690
723
|
switch (m.type) {
|
|
@@ -793,10 +826,28 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
793
826
|
if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
|
|
794
827
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
795
828
|
turnActive = false // turn settled → stall watchdog stands down
|
|
796
|
-
|
|
797
|
-
//
|
|
798
|
-
//
|
|
829
|
+
// A model switch requested mid-turn was deferred — apply it now the turn is done.
|
|
830
|
+
// Defer to the next tick so recreateForSwitch (which awaits THIS loop's qDone)
|
|
831
|
+
// doesn't await itself; the setTimeout runs after this iterator yields.
|
|
832
|
+
if (pendingSwitch) { pendingSwitch = false; setTimeout(() => { if (!closed) recreateForSwitch() }, 0) }
|
|
833
|
+
if (m.subtype === 'success') { restartCount = 0; if (sessionId) persistedSessionId = sessionId } // ONLY a real success refills the
|
|
834
|
+
// auto-restart budget (else a flapping connection that lands one aborted turn between
|
|
835
|
+
// drops refills every cycle past RESTART_MAX) — and marks this session id durably
|
|
836
|
+
// persisted so a later model-switch re-create can safely resume it.
|
|
799
837
|
forceStopped = false
|
|
838
|
+
// Ground-truth the model from THIS turn's modelUsage (the models actually billed
|
|
839
|
+
// this turn), not the init/system messages — a RESUMED session REPLAYS the prior
|
|
840
|
+
// transcript's init messages, so curModel got polluted back to the pre-switch model
|
|
841
|
+
// even though the turn really ran on the switched one (Max 2026-07-02: chip showed
|
|
842
|
+
// Opus while inference was Fable). Pick the model that did the most generating.
|
|
843
|
+
if (m.modelUsage && typeof m.modelUsage === 'object') {
|
|
844
|
+
let best = null, bestOut = -1
|
|
845
|
+
for (const [mid, u] of Object.entries(m.modelUsage)) {
|
|
846
|
+
const out = (u?.outputTokens ?? 0)
|
|
847
|
+
if (out > bestOut) { bestOut = out; best = mid }
|
|
848
|
+
}
|
|
849
|
+
if (best) curModel = best
|
|
850
|
+
}
|
|
800
851
|
emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
|
|
801
852
|
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
802
853
|
// the window, generate one from the last assistant reply. (Skip aborted turns.)
|
|
@@ -827,6 +878,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
827
878
|
}
|
|
828
879
|
} catch (e) {
|
|
829
880
|
if (closed) return
|
|
881
|
+
// This query was intentionally aborted (a model switch superseded it, or the
|
|
882
|
+
// session is ending) — not a real error. Stay silent; the re-create owns what's next.
|
|
883
|
+
if (myAc.signal.aborted) return
|
|
830
884
|
const msg = e?.message || String(e)
|
|
831
885
|
emit({ kind: 'error', message: msg })
|
|
832
886
|
// Auto-restart a transient upstream stream drop: re-run query() with
|
|
@@ -848,13 +902,55 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
848
902
|
restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
|
|
849
903
|
return
|
|
850
904
|
}
|
|
905
|
+
// Resume target vanished (a forked session id that was never persisted, or a pruned
|
|
906
|
+
// transcript): drop the resume and re-run FRESH so the turn/switch still lands instead
|
|
907
|
+
// of hard-failing. Bounded by RESTART_MAX. Loses only the resumed history, not the turn.
|
|
908
|
+
if (/no conversation found/i.test(msg) && sessionId && restartCount < RESTART_MAX) {
|
|
909
|
+
restartCount++
|
|
910
|
+
turnActive = false
|
|
911
|
+
sessionId = null; persistedSessionId = null
|
|
912
|
+
const oldInput = input
|
|
913
|
+
input = makeInputStream()
|
|
914
|
+
try { oldInput.end() } catch { /* noop */ }
|
|
915
|
+
restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, 300)
|
|
916
|
+
return
|
|
917
|
+
}
|
|
851
918
|
// Non-recoverable, or budget exhausted: mark done so sendTurn stops
|
|
852
919
|
// silently queuing into a dead stream. The bridge's onEvent self-heal
|
|
853
920
|
// still fires for "No conversation found" (it reopens fresh).
|
|
854
921
|
closed = true
|
|
855
922
|
turnActive = false
|
|
923
|
+
} finally {
|
|
924
|
+
ac.signal.removeEventListener('abort', onParentAbort)
|
|
925
|
+
if (qAc === myAc) qAc = null
|
|
926
|
+
// Await the SDK query's disposal so the underlying CLI PROCESS is actually dead
|
|
927
|
+
// before qDone resolves — otherwise a model-switch re-create races the dying process
|
|
928
|
+
// on the session file lock and the resumed query keeps the OLD model (proven 2026-07-02;
|
|
929
|
+
// q.return() waits on the transport exit, bounded internally to ~2s).
|
|
930
|
+
try { await q?.return?.() } catch { /* already disposed */ }
|
|
931
|
+
if (qDoneResolve) { qDoneResolve(); qDoneResolve = null }
|
|
856
932
|
}
|
|
857
933
|
}
|
|
934
|
+
// Re-create the query resuming the SAME session on the switched model (opts.model).
|
|
935
|
+
// Used when a live setModel isn't possible (resumed session). Tears the current query
|
|
936
|
+
// fully down and AWAITS its exit before re-running, so the two processes never race on
|
|
937
|
+
// the session file lock (a concurrent re-create keeps the OLD model — proven 2026-07-02).
|
|
938
|
+
const recreateForSwitch = async () => {
|
|
939
|
+
if (closed || !started) return
|
|
940
|
+
const dying = qDone
|
|
941
|
+
const oldInput = input
|
|
942
|
+
input = makeInputStream() // fresh stream for the re-created query
|
|
943
|
+
if (qAc) { try { qAc.abort() } catch { /* noop */ } } // kill the current query's process
|
|
944
|
+
try { oldInput.end() } catch { /* noop */ }
|
|
945
|
+
try { await dying } catch { /* noop */ } // wait until it's actually dead
|
|
946
|
+
await sleep(300) // brief lock-release margin
|
|
947
|
+
// Resume the last DURABLY-PERSISTED session, not the live one: the aborted query may
|
|
948
|
+
// have forked to a session id that was never written to disk, and resuming that fails
|
|
949
|
+
// with "No conversation found" (Max 2026-07-02, sid 6db6c1e3). persistedSessionId is
|
|
950
|
+
// always resumable (the restore id, advanced on each success).
|
|
951
|
+
sessionId = persistedSessionId
|
|
952
|
+
if (!closed) runQuery() // relaunch on opts.model + resume
|
|
953
|
+
}
|
|
858
954
|
if (!lazy) runQuery()
|
|
859
955
|
|
|
860
956
|
return {
|
|
@@ -879,16 +975,19 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
879
975
|
// /model is disabled in headless SDK — switch via the setModel control
|
|
880
976
|
// instead, and echo a note line so both drivers see the change.
|
|
881
977
|
async setModel(m) {
|
|
882
|
-
|
|
883
|
-
//
|
|
884
|
-
// `opts` on every auto-restart (transient stream drop / reconnect), so unless we
|
|
885
|
-
// also update opts.model the reconnect re-launches on the ORIGINAL model — which
|
|
886
|
-
// is why a /model switch kept snapping back to Opus after a few turns (Max
|
|
887
|
-
// 2026-07-02: 11× "init model=claude-opus-4-8" after switching to Fable). Pin the
|
|
888
|
-
// switch into opts + curModel so every subsequent re-run carries it.
|
|
978
|
+
// Pin the switch into opts + curModel so every subsequent query re-run (auto-restart
|
|
979
|
+
// OR the re-create below) carries it.
|
|
889
980
|
opts.model = m
|
|
890
981
|
curModel = m
|
|
891
982
|
emit({ kind: 'note', text: `model → ${m}` })
|
|
983
|
+
if (!started || closed) return // not launched yet → the first runQuery uses opts.model
|
|
984
|
+
// Do NOT trust the live q.setModel: on a RESUMED streaming session it RESOLVES without
|
|
985
|
+
// error but is a silent no-op (the turn keeps running the old model — Max 2026-07-02).
|
|
986
|
+
// The only reliable switch is to RE-CREATE the query resuming the same session on the
|
|
987
|
+
// new model (query({resume, model}) honours it — proven). Defer a mid-turn switch to
|
|
988
|
+
// turn-end so we don't rip out an in-flight turn.
|
|
989
|
+
if (turnActive) { pendingSwitch = true; return }
|
|
990
|
+
await recreateForSwitch()
|
|
892
991
|
},
|
|
893
992
|
async listModels() {
|
|
894
993
|
try {
|