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.
Files changed (2) hide show
  1. package/claude-session.mjs +78 -8
  2. package/package.json +1 -1
@@ -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
- try { await q?.setModel?.(m) } catch { /* may reject unknown model */ }
883
- // setModel switches only the LIVE query object. runQuery() re-runs query() with
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.137",
3
+ "version": "0.7.138",
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": {