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.
Files changed (2) hide show
  1. package/claude-session.mjs +111 -12
  2. package/package.json +1 -1
@@ -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
- if (resume) opts.resume = resume
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
- if (m.subtype === 'success') restartCount = 0 // ONLY a real success refills the
797
- // auto-restart budget — else a flapping connection that lands one aborted turn
798
- // between drops refills every cycle and loops far past RESTART_MAX.
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
- 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.
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.137",
3
+ "version": "0.7.139",
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": {