thinkpool-pair 0.7.200 → 0.7.202

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/bridge.mjs CHANGED
@@ -98,6 +98,7 @@ const flowRedispatch = new Map()
98
98
  // broadcasts; without persistent state the cap can never bite.
99
99
  const flowBudgets = new Map()
100
100
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint } from './cross-terminal.mjs'
101
+ import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
101
102
  import { turnInFlight } from './update-gate.mjs'
102
103
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
103
104
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
@@ -844,15 +845,16 @@ const sessions = new Map()
844
845
  let attachedId = null
845
846
  let shuttingDown = false
846
847
 
847
- // Background warmer (2026-07-02): after a restart, proactively cold-boot the lazily-
848
- // restored IDLE terminals — ready-gated to ≤WARM_CONCURRENCY at once so there's no
849
- // boot-storm (the contention that made restore take minutes) — so they're warm BEFORE
850
- // the user clicks them, erasing the lazy-boot first-turn penalty. A user turn to a
851
- // still-cold terminal boots it on demand as the fallback. warmComplete frees a slot when
852
- // a terminal's init `system` event proves it live (called from onEvent).
848
+ // Background warming is OPT-IN. Even a per-room cap multiplies under the account
849
+ // supervisor: four rooms at concurrency 3 launched twelve Claude SDK cold boots at once,
850
+ // stretching measured readiness from ~15s to 40–66s and leaving every idle process
851
+ // resident. Restored transcripts already paint immediately and sendTurn boots a cold lane
852
+ // on demand, so the safe default is zero speculative runtimes. TP_BACKGROUND_WARM=1 keeps
853
+ // the old behaviour available for a deliberately single-room host.
853
854
  const warmQueue = []
854
855
  let warmingCount = 0
855
856
  const WARM_CONCURRENCY = 3
857
+ const BACKGROUND_WARM_ENABLED = process.env.TP_BACKGROUND_WARM === '1'
856
858
  function pumpWarm() {
857
859
  while (warmingCount < WARM_CONCURRENCY && warmQueue.length) {
858
860
  const e = warmQueue.shift()
@@ -2270,9 +2272,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2270
2272
  // resume gets its single "continue" HERE — not on a blind timer that raced the ~40s
2271
2273
  // resume and dropped the turn into a dead input stream. Fires exactly once.
2272
2274
  if (evt.kind === 'system' && entry.pendingAutoResume) {
2273
- entry.pendingAutoResume = false
2274
- try { entry.session?.sendTurn('continue') } catch { /* not resumable — stays idle, user can continue manually */ }
2275
- process.stderr.write(`\n ◆ auto-resumed interrupted turn (${id.slice(0, 8)}) — sent continue.\n`)
2275
+ if (sendInterruptedContinue(entry)) process.stderr.write(`\n ◆ auto-resumed interrupted turn (${id.slice(0, 8)}) — sent continue.\n`)
2276
2276
  }
2277
2277
  // Context-carry (2026-07-08): a lane re-opened WITHOUT an SDK resume (provider switch,
2278
2278
  // non-resumable restart) starts blank. Fire the text-layer recap as its first turn the
@@ -2693,7 +2693,10 @@ channel
2693
2693
  // through to the SDK default when absent. Stamped on the entry so the announce
2694
2694
  // carries it at open (chip shows the model immediately, not the branch).
2695
2695
  const runtime = structuredRuntime(payload.cmd)
2696
- openStructured({ id: payload.id, runtime, mode: payload.mode, model: payload.model || undefined, provider: runtime === 'codex' ? undefined : payload.provider || undefined })
2696
+ // Opening a tab is not work. Paint and announce it immediately, then cold-boot the
2697
+ // expensive Claude/Codex runtime on its first real turn. This keeps a fresh terminal
2698
+ // instant and prevents abandoned tabs from becoming permanent resident processes.
2699
+ openStructured({ id: payload.id, runtime, mode: payload.mode, model: payload.model || undefined, provider: runtime === 'codex' ? undefined : payload.provider || undefined, defer: true })
2697
2700
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
2698
2701
  return
2699
2702
  }
@@ -2829,7 +2832,7 @@ channel
2829
2832
  // ── structured-session control (Phase 2) ──
2830
2833
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
2831
2834
  if (payload?.host && payload.host !== name) return
2832
- openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode })
2835
+ openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode, defer: true })
2833
2836
  })
2834
2837
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
2835
2838
  const s = payload?.term && sessions.get(payload.term)
@@ -2847,6 +2850,11 @@ channel
2847
2850
  s.hop = 0
2848
2851
  s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
2849
2852
  const text = String(payload.text)
2853
+ // A human turn supersedes a restart's still-pending auto-continue. Without this,
2854
+ // their message starts the cold runtime; its init event then queues a second stale
2855
+ // `continue` behind the request. Codex normally resumes immediately at restore, while
2856
+ // Claude holds this flag only until its input stream is live.
2857
+ supersedeInterruptedResume(s)
2850
2858
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
2851
2859
  // said what — UNLESS silent (an @pool fire, which renders its own line).
2852
2860
  const echoYou = () => {
@@ -3199,7 +3207,8 @@ channel
3199
3207
  // instant it's live. openStructured already closed the interrupted turn idle, so a
3200
3208
  // continue that can't be delivered just leaves it idle (recoverable) — never a hang.
3201
3209
  if (re && !rec.flowSessionId && canResume(rec) && restoredTurnOpen(rec.log || [])) {
3202
- re.pendingAutoResume = true
3210
+ const recovery = armInterruptedResume(re)
3211
+ if (recovery === 'sent') process.stderr.write(`\n ◆ auto-resumed interrupted Codex turn (${rec.id.slice(0, 8)}) — sent continue.\n`)
3203
3212
  }
3204
3213
  // Context-carry (2026-07-08): the SAME mid-turn case but the SDK context CANNOT
3205
3214
  // resume (canResume false — a stale/expired session). A bare "continue" would land
@@ -3220,10 +3229,9 @@ channel
3220
3229
  // saved-session restore above still runs unconditionally, so a bridge
3221
3230
  // restart resumes backgrounded sessions regardless of this guard.
3222
3231
  else if (attachedCmd) openStructured({ id: randomUUID(), runtime: structuredRuntime(attachedCmd) })
3223
- // Background-warm the lazily-restored idle terminals shortly after restore, so
3224
- // they're ready before the user clicks them (no first-turn penalty). Delayed so the
3225
- // eagerly-booted (mid-turn / auto-resumed) sessions get the machine to themselves first.
3226
- setTimeout(() => {
3232
+ // Optional compatibility lever for deliberately single-room hosts. Account-mode
3233
+ // and normal standalone launches leave restored idle lanes truly lazy by default.
3234
+ if (BACKGROUND_WARM_ENABLED) setTimeout(() => {
3227
3235
  for (const [, e] of sessions) if (e.deferred && !e.warmed && !e.warming && !e.session?.started) warmQueue.push(e)
3228
3236
  if (warmQueue.length) { process.stderr.write(`\n ◆ background-warming ${warmQueue.length} idle terminal(s) (≤${WARM_CONCURRENCY} at a time)…\n`); pumpWarm() }
3229
3237
  }, 6000)
@@ -239,7 +239,9 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
239
239
  // long time"; settingSources:['user','project','local'] boots every host MCP
240
240
  // server + SessionStart hook + plugin before init. This is the A-side baseline;
241
241
  // open a terminal with TP_MCP_STRICT=1 (below) for the B-side to isolate MCP's share.
242
- const spawnT0 = Date.now()
242
+ // A lazy lane may sit unopened for hours. Start the latency clock when query() really
243
+ // launches, not when the lightweight tab/session wrapper is created.
244
+ let spawnT0 = null
243
245
  let readyLogged = false
244
246
  let modelsSent = false // one-shot: emit the SDK's supported-model list on first init
245
247
  let sdkToolsChecked = false // one-shot: run the AskUserQuestion regression guard on the first init carrying tools[]
@@ -804,6 +806,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
804
806
  }
805
807
 
806
808
  const runQuery = async () => {
809
+ if (spawnT0 == null) spawnT0 = Date.now()
807
810
  started = true
808
811
  // Per-query AbortController (child of the session `ac`): lets a model switch kill
809
812
  // just this query. Resolve the previous qDone in case it's still pending.
@@ -0,0 +1,29 @@
1
+ // Runtime-specific bridge-restart recovery.
2
+ //
3
+ // Claude owns a long-lived input stream: its continue must wait for the init system
4
+ // event proving that stream is consumed. Codex is one process per turn and has no idle
5
+ // init event: sending the prompt IS what launches the resumed process.
6
+
7
+ export function sendInterruptedContinue(entry) {
8
+ if (!entry) return false
9
+ entry.pendingAutoResume = false
10
+ try { return entry.session?.sendTurn?.('continue') !== false }
11
+ catch { return false }
12
+ }
13
+
14
+ export function armInterruptedResume(entry) {
15
+ if (!entry?.session) return 'unavailable'
16
+ if (entry.runtime === 'codex') return sendInterruptedContinue(entry) ? 'sent' : 'unavailable'
17
+ entry.pendingAutoResume = true
18
+ return 'armed'
19
+ }
20
+
21
+ // A real person speaking always wins the race. Otherwise a human turn that starts a
22
+ // cold Claude/Codex runtime can trigger its init event and enqueue a stale second
23
+ // `continue` behind the person's actual request.
24
+ export function supersedeInterruptedResume(entry) {
25
+ if (!entry?.pendingAutoResume) return false
26
+ entry.pendingAutoResume = false
27
+ return true
28
+ }
29
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.200",
3
+ "version": "0.7.202",
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": {
@@ -27,6 +27,7 @@
27
27
  "transcript-sanitize.mjs",
28
28
  "session-store.mjs",
29
29
  "cross-terminal.mjs",
30
+ "interrupted-resume.mjs",
30
31
  "flow-conductor.mjs",
31
32
  "flow-worktree.mjs",
32
33
  "flow-task-graph.mjs",