thinkpool-pair 0.7.131 → 0.7.133

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
@@ -656,6 +656,32 @@ const sessions = new Map()
656
656
  let attachedId = null
657
657
  let shuttingDown = false
658
658
 
659
+ // Background warmer (2026-07-02): after a restart, proactively cold-boot the lazily-
660
+ // restored IDLE terminals — ready-gated to ≤WARM_CONCURRENCY at once so there's no
661
+ // boot-storm (the contention that made restore take minutes) — so they're warm BEFORE
662
+ // the user clicks them, erasing the lazy-boot first-turn penalty. A user turn to a
663
+ // still-cold terminal boots it on demand as the fallback. warmComplete frees a slot when
664
+ // a terminal's init `system` event proves it live (called from onEvent).
665
+ const warmQueue = []
666
+ let warmingCount = 0
667
+ const WARM_CONCURRENCY = 3
668
+ function pumpWarm() {
669
+ while (warmingCount < WARM_CONCURRENCY && warmQueue.length) {
670
+ const e = warmQueue.shift()
671
+ if (!e || e.warmed || e.warming || !e.session || e.session.started) continue
672
+ e.warming = true; warmingCount++
673
+ try { e.session.warm?.() } catch { e.warming = false; warmingCount--; continue }
674
+ // Free the slot if a warm never signals ready (stuck boot) so the queue still drains.
675
+ e.warmTimer = setTimeout(() => { if (e.warming) { e.warming = false; warmingCount = Math.max(0, warmingCount - 1); pumpWarm() } }, 100000)
676
+ }
677
+ }
678
+ const warmComplete = (e) => {
679
+ if (!e || !e.warming) return
680
+ e.warming = false; e.warmed = true; warmingCount = Math.max(0, warmingCount - 1)
681
+ try { clearTimeout(e.warmTimer) } catch { /* noop */ }
682
+ pumpWarm()
683
+ }
684
+
659
685
  // One send door for every broadcast. supabase-js ≥2.10x deprecation-warns
660
686
  // (console.warn) whenever send() silently falls back to REST because the
661
687
  // socket can't push — at the flush timer's 28×/s that's a wall of warnings
@@ -1344,7 +1370,11 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1344
1370
  // subagent-block; without cwd it runs outside its worktree; without rolePrompt it loses its
1345
1371
  // Flow role. blockSubagents / onLaneDone / onReviewVerdict / onSubmitPlan all derive from
1346
1372
  // these, so restoring them restores the whole behavior.
1347
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
1373
+ // model: persist the terminal's active model so a /model switch SURVIVES a bridge
1374
+ // restart. Without this, sessionData omitted it → on restart the resumed session
1375
+ // re-launched on the host default (Opus) regardless of the last switch, and the
1376
+ // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1377
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
1348
1378
  const persist = () => saveSession(room, id, sessionData())
1349
1379
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1350
1380
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -1625,6 +1655,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1625
1655
  return { allow: true, reason: 'no reviewed slice targeted' }
1626
1656
  }
1627
1657
  : null
1658
+ entry.deferred = !!defer // a lazily-booted idle terminal — the background warmer will boot it
1628
1659
  entry.session = startClaudeSession({
1629
1660
  cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1630
1661
  // defer: a restored-idle terminal cold-boots its query only on first use — restore is
@@ -1728,6 +1759,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1728
1759
  try { entry.session?.sendTurn('continue') } catch { /* not resumable — stays idle, user can continue manually */ }
1729
1760
  process.stderr.write(`\n ◆ auto-resumed interrupted turn (${id.slice(0, 8)}) — sent continue.\n`)
1730
1761
  }
1762
+ // A background-warmed terminal is now live — free its warmer slot so the next queued
1763
+ // idle terminal starts booting.
1764
+ if (evt.kind === 'system' && entry.warming) warmComplete(entry)
1731
1765
  // Chrome events (mode / usage / clear / compact) are transient state, not
1732
1766
  // transcript — broadcast + print them but keep out of the persisted/replayed log.
1733
1767
  // 'stalled' is transient turn-liveness state (would replay as a stale frozen
@@ -2318,7 +2352,7 @@ Output:`
2318
2352
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
2319
2353
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
2320
2354
  // lanes keep their worktree cwd + the ability to mark done.
2321
- openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
2355
+ openStructured({ id: rec.id, model: rec.model || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
2322
2356
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
2323
2357
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
2324
2358
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -2355,6 +2389,13 @@ Output:`
2355
2389
  // saved-session restore above still runs unconditionally, so a bridge
2356
2390
  // restart resumes backgrounded sessions regardless of this guard.
2357
2391
  else if (attachedCmd) openStructured({ id: randomUUID() })
2392
+ // Background-warm the lazily-restored idle terminals shortly after restore, so
2393
+ // they're ready before the user clicks them (no first-turn penalty). Delayed so the
2394
+ // eagerly-booted (mid-turn / auto-resumed) sessions get the machine to themselves first.
2395
+ setTimeout(() => {
2396
+ for (const [, e] of sessions) if (e.deferred && !e.warmed && !e.warming && !e.session?.started) warmQueue.push(e)
2397
+ if (warmQueue.length) { process.stderr.write(`\n ◆ background-warming ${warmQueue.length} idle terminal(s) (≤${WARM_CONCURRENCY} at a time)…\n`); pumpWarm() }
2398
+ }, 6000)
2358
2399
  }
2359
2400
  else {
2360
2401
  // Reuse the prior auto-opened PTY id across restarts so a reconnecting
@@ -861,6 +861,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
861
861
  // A lazy (restored-idle) session cold-boots the query on its first turn. input.push
862
862
  // is queue-backed, so the pushed turn buffers and runs once the query is ready.
863
863
  sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: String(text) }, { type: 'text', text: `<system-reminder>\n${TP_ROOM_REMINDER}\n</system-reminder>` }]) } },
864
+ // Cold-boot the query WITHOUT sending a turn — the background warmer calls this on
865
+ // lazily-restored idle terminals so they're ready before the user clicks them.
866
+ warm() { if (!started && !closed) runQuery() },
867
+ get started() { return started },
864
868
  // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
865
869
  // streaming control request (drives plan-mode behaviour SDK-side); the local
866
870
  // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.131",
3
+ "version": "0.7.133",
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": {