thinkpool-pair 0.7.137 → 0.7.138
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 +78 -8
- package/package.json +1 -1
package/claude-session.mjs
CHANGED
|
@@ -222,6 +222,21 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
222
222
|
let curModel = model || null // latest model id seen (init/system + ctx); stamped on
|
|
223
223
|
// the result event so the bridge can attribute BYOK Code
|
|
224
224
|
// spend per-model (record_code_usage → chat_usage).
|
|
225
|
+
// A model switch on a RESUMED session can't be done live — q.setModel() RESOLVES without
|
|
226
|
+
// error but is a silent no-op there (the turn keeps running the old model). So a switch is
|
|
227
|
+
// applied by RE-CREATING the query resuming the same session on the new model (which
|
|
228
|
+
// query({resume, model}) honours). Root of Max's 2026-07-02 "reverts to Opus at start of
|
|
229
|
+
// turn": every terminal is a resumed session after a restart, so live setModel did nothing.
|
|
230
|
+
// qAc = the CURRENT query's own AbortController (child of the session `ac`) so the
|
|
231
|
+
// switch can kill JUST this query without ending the session; qDone resolves when the
|
|
232
|
+
// current runQuery loop has fully exited (its process dead) so the re-create doesn't
|
|
233
|
+
// race the old process on the session file lock (proven necessary 2026-07-02).
|
|
234
|
+
// pendingSwitch defers a mid-turn switch until the active turn finishes.
|
|
235
|
+
let qAc = null
|
|
236
|
+
let qDoneResolve = null
|
|
237
|
+
let qDone = Promise.resolve()
|
|
238
|
+
let pendingSwitch = false
|
|
239
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
225
240
|
// ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
|
|
226
241
|
// 3+ min) or abort silently with no `result`. Track turn liveness + last event
|
|
227
242
|
// time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
|
|
@@ -683,8 +698,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
683
698
|
|
|
684
699
|
const runQuery = async () => {
|
|
685
700
|
started = true
|
|
701
|
+
// Per-query AbortController (child of the session `ac`): lets a model switch kill
|
|
702
|
+
// just this query. Resolve the previous qDone in case it's still pending.
|
|
703
|
+
if (qDoneResolve) { try { qDoneResolve() } catch { /* noop */ } }
|
|
704
|
+
qDone = new Promise((res) => { qDoneResolve = res })
|
|
705
|
+
const myAc = new AbortController()
|
|
706
|
+
qAc = myAc
|
|
707
|
+
const onParentAbort = () => { try { myAc.abort() } catch { /* noop */ } }
|
|
708
|
+
ac.signal.addEventListener('abort', onParentAbort, { once: true })
|
|
686
709
|
try {
|
|
687
|
-
q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
710
|
+
q = query({ prompt: input.stream, options: { ...opts, abortController: myAc, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
688
711
|
for await (const m of q) {
|
|
689
712
|
if (closed) break
|
|
690
713
|
switch (m.type) {
|
|
@@ -793,10 +816,27 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
793
816
|
if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
|
|
794
817
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
795
818
|
turnActive = false // turn settled → stall watchdog stands down
|
|
819
|
+
// A model switch requested mid-turn was deferred — apply it now the turn is done.
|
|
820
|
+
// Defer to the next tick so recreateForSwitch (which awaits THIS loop's qDone)
|
|
821
|
+
// doesn't await itself; the setTimeout runs after this iterator yields.
|
|
822
|
+
if (pendingSwitch) { pendingSwitch = false; setTimeout(() => { if (!closed) recreateForSwitch() }, 0) }
|
|
796
823
|
if (m.subtype === 'success') restartCount = 0 // ONLY a real success refills the
|
|
797
824
|
// auto-restart budget — else a flapping connection that lands one aborted turn
|
|
798
825
|
// between drops refills every cycle and loops far past RESTART_MAX.
|
|
799
826
|
forceStopped = false
|
|
827
|
+
// Ground-truth the model from THIS turn's modelUsage (the models actually billed
|
|
828
|
+
// this turn), not the init/system messages — a RESUMED session REPLAYS the prior
|
|
829
|
+
// transcript's init messages, so curModel got polluted back to the pre-switch model
|
|
830
|
+
// even though the turn really ran on the switched one (Max 2026-07-02: chip showed
|
|
831
|
+
// Opus while inference was Fable). Pick the model that did the most generating.
|
|
832
|
+
if (m.modelUsage && typeof m.modelUsage === 'object') {
|
|
833
|
+
let best = null, bestOut = -1
|
|
834
|
+
for (const [mid, u] of Object.entries(m.modelUsage)) {
|
|
835
|
+
const out = (u?.outputTokens ?? 0)
|
|
836
|
+
if (out > bestOut) { bestOut = out; best = mid }
|
|
837
|
+
}
|
|
838
|
+
if (best) curModel = best
|
|
839
|
+
}
|
|
800
840
|
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
841
|
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
802
842
|
// the window, generate one from the last assistant reply. (Skip aborted turns.)
|
|
@@ -827,6 +867,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
827
867
|
}
|
|
828
868
|
} catch (e) {
|
|
829
869
|
if (closed) return
|
|
870
|
+
// This query was intentionally aborted (a model switch superseded it, or the
|
|
871
|
+
// session is ending) — not a real error. Stay silent; the re-create owns what's next.
|
|
872
|
+
if (myAc.signal.aborted) return
|
|
830
873
|
const msg = e?.message || String(e)
|
|
831
874
|
emit({ kind: 'error', message: msg })
|
|
832
875
|
// Auto-restart a transient upstream stream drop: re-run query() with
|
|
@@ -853,8 +896,32 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
853
896
|
// still fires for "No conversation found" (it reopens fresh).
|
|
854
897
|
closed = true
|
|
855
898
|
turnActive = false
|
|
899
|
+
} finally {
|
|
900
|
+
ac.signal.removeEventListener('abort', onParentAbort)
|
|
901
|
+
if (qAc === myAc) qAc = null
|
|
902
|
+
// Await the SDK query's disposal so the underlying CLI PROCESS is actually dead
|
|
903
|
+
// before qDone resolves — otherwise a model-switch re-create races the dying process
|
|
904
|
+
// on the session file lock and the resumed query keeps the OLD model (proven 2026-07-02;
|
|
905
|
+
// q.return() waits on the transport exit, bounded internally to ~2s).
|
|
906
|
+
try { await q?.return?.() } catch { /* already disposed */ }
|
|
907
|
+
if (qDoneResolve) { qDoneResolve(); qDoneResolve = null }
|
|
856
908
|
}
|
|
857
909
|
}
|
|
910
|
+
// Re-create the query resuming the SAME session on the switched model (opts.model).
|
|
911
|
+
// Used when a live setModel isn't possible (resumed session). Tears the current query
|
|
912
|
+
// fully down and AWAITS its exit before re-running, so the two processes never race on
|
|
913
|
+
// the session file lock (a concurrent re-create keeps the OLD model — proven 2026-07-02).
|
|
914
|
+
const recreateForSwitch = async () => {
|
|
915
|
+
if (closed || !started) return
|
|
916
|
+
const dying = qDone
|
|
917
|
+
const oldInput = input
|
|
918
|
+
input = makeInputStream() // fresh stream for the re-created query
|
|
919
|
+
if (qAc) { try { qAc.abort() } catch { /* noop */ } } // kill the current query's process
|
|
920
|
+
try { oldInput.end() } catch { /* noop */ }
|
|
921
|
+
try { await dying } catch { /* noop */ } // wait until it's actually dead
|
|
922
|
+
await sleep(300) // brief lock-release margin
|
|
923
|
+
if (!closed) runQuery() // relaunch on opts.model + resume
|
|
924
|
+
}
|
|
858
925
|
if (!lazy) runQuery()
|
|
859
926
|
|
|
860
927
|
return {
|
|
@@ -879,16 +946,19 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
879
946
|
// /model is disabled in headless SDK — switch via the setModel control
|
|
880
947
|
// instead, and echo a note line so both drivers see the change.
|
|
881
948
|
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.
|
|
949
|
+
// Pin the switch into opts + curModel so every subsequent query re-run (auto-restart
|
|
950
|
+
// OR the re-create below) carries it.
|
|
889
951
|
opts.model = m
|
|
890
952
|
curModel = m
|
|
891
953
|
emit({ kind: 'note', text: `model → ${m}` })
|
|
954
|
+
if (!started || closed) return // not launched yet → the first runQuery uses opts.model
|
|
955
|
+
// Do NOT trust the live q.setModel: on a RESUMED streaming session it RESOLVES without
|
|
956
|
+
// error but is a silent no-op (the turn keeps running the old model — Max 2026-07-02).
|
|
957
|
+
// The only reliable switch is to RE-CREATE the query resuming the same session on the
|
|
958
|
+
// new model (query({resume, model}) honours it — proven). Defer a mid-turn switch to
|
|
959
|
+
// turn-end so we don't rip out an in-flight turn.
|
|
960
|
+
if (turnActive) { pendingSwitch = true; return }
|
|
961
|
+
await recreateForSwitch()
|
|
892
962
|
},
|
|
893
963
|
async listModels() {
|
|
894
964
|
try {
|