thinkpool-pair 0.7.130 → 0.7.131

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
@@ -1222,7 +1222,7 @@ function restoredTurnOpen(log) {
1222
1222
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1223
1223
  // persist to the host file; tool calls round-trip through the perm card; the
1224
1224
  // rolling log replays to joiners and survives bridge restarts (session-store).
1225
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt }) {
1225
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer }) {
1226
1226
  if (sessions.has(id)) return
1227
1227
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1228
1228
  // session store so the Ensemble flag survives a bridge restart (else a respin
@@ -1627,6 +1627,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1627
1627
  : null
1628
1628
  entry.session = startClaudeSession({
1629
1629
  cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1630
+ // defer: a restored-idle terminal cold-boots its query only on first use — restore is
1631
+ // then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
1632
+ lazy: !!defer,
1630
1633
  reviewGate,
1631
1634
  // A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
1632
1635
  // a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
@@ -2309,13 +2312,17 @@ Output:`
2309
2312
  // Restore in stable creation order (openedAt) so the tab strip comes back in the
2310
2313
  // SAME order across a bridge restart — NOT readdir/filesystem order, which reshuffled
2311
2314
  // the tabs (Max, 2026-07-02). Legacy recs fall back to their first event ts / savedAt.
2312
- const okey = (r) => r.openedAt || (Array.isArray(r.log) ? (r.log.find((e) => e?.ts)?.ts || 0) : 0) || r.savedAt || 0
2315
+ const okey = (r) => r._bt || r.openedAt || r.savedAt || 0
2313
2316
  all.sort((a, b) => okey(a) - okey(b))
2314
2317
  if (all.length) for (const rec of all) {
2315
2318
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
2316
2319
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
2317
2320
  // lanes keep their worktree cwd + the ability to mark done.
2318
- 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 })
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,
2322
+ // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
2323
+ // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
2324
+ // (mid-turn needs auto-resume; flow needs its lane live).
2325
+ defer: !rec.flowSessionId && !restoredTurnOpen(rec.log || []) })
2319
2326
  const re = sessions.get(rec.id)
2320
2327
  if (re && rec.flowReviewTarget) re.flowReviewTarget = rec.flowReviewTarget
2321
2328
  // S4 — a resumed re-dispatch lane keeps its surviving revert target across a bridge restart.
@@ -2794,6 +2801,13 @@ process.on('SIGTERM', () => shutdown(0))
2794
2801
  // Route both through shutdown() so children are reaped + presence left, then the
2795
2802
  // supervisor / launchd / systemd respawns a clean process.
2796
2803
  process.on('uncaughtException', (err) => {
2804
+ // Benign write-to-closed-pipe/socket (a broadcast or a child stream that closed
2805
+ // mid-restore) must NOT kill the bridge — that respawn triggered a SECOND full
2806
+ // restore, doubling an already-slow restore (Max, 2026-07-02). Log + keep running.
2807
+ if (err && (err.code === 'EPIPE' || err.code === 'ECONNRESET' || err.code === 'ERR_STREAM_WRITE_AFTER_END' || err.code === 'ERR_STREAM_DESTROYED')) {
2808
+ try { process.stderr.write(`\n ⚠ ignored benign ${err.code} (not fatal): ${err?.message || ''}\n`) } catch { /* noop */ }
2809
+ return
2810
+ }
2797
2811
  try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
2798
2812
  shutdown(1)
2799
2813
  })
@@ -181,11 +181,17 @@ export async function oneShotSummary({ prompt, env, cwd, timeoutMs = 25000 }) {
181
181
  return null
182
182
  }
183
183
 
184
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null }) {
184
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false }) {
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
188
  let closed = false
189
+ // Lazy boot (2026-07-02): a RESTORED-IDLE terminal returns a full session object but
190
+ // defers the expensive query() cold-start (MCP + settingSources, ~50s each) until its
191
+ // FIRST turn — so a bridge restart restores every tab's transcript instantly and only
192
+ // the terminals a person actually uses pay the boot. Mid-turn terminals boot eagerly
193
+ // (lazy:false) so auto-resume still works.
194
+ let started = false
189
195
  // Cold-start measurement (2026-06-21, spec 2026-06-21-code-session-deploy-stability):
190
196
  // stamp when the open begins so we can log spawn→ready (the SDK's first `init`
191
197
  // system message — when the terminal becomes usable). New room terminals "take a
@@ -676,6 +682,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
676
682
  }
677
683
 
678
684
  const runQuery = async () => {
685
+ started = true
679
686
  try {
680
687
  q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
681
688
  for await (const m of q) {
@@ -848,10 +855,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
848
855
  turnActive = false
849
856
  }
850
857
  }
851
- runQuery()
858
+ if (!lazy) runQuery()
852
859
 
853
860
  return {
854
- sendTurn(text) { if (!closed) { 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>` }]) } },
861
+ // A lazy (restored-idle) session cold-boots the query on its first turn. input.push
862
+ // is queue-backed, so the pushed turn buffers and runs once the query is ready.
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>` }]) } },
855
864
  // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
856
865
  // streaming control request (drives plan-mode behaviour SDK-side); the local
857
866
  // `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.130",
3
+ "version": "0.7.131",
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": {
package/session-store.mjs CHANGED
@@ -42,7 +42,18 @@ function archive(room, file) {
42
42
  function listRecs(room) {
43
43
  try {
44
44
  return fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
45
- .map((f) => { try { return JSON.parse(fs.readFileSync(path.join(dir(room), f), 'utf8')) } catch { return null } })
45
+ .map((f) => {
46
+ const p = path.join(dir(room), f)
47
+ try {
48
+ const r = JSON.parse(fs.readFileSync(p, 'utf8'))
49
+ // _bt: the rec FILE's creation time — a reliable, monotonic proxy for when the
50
+ // terminal was first opened (overwrites on save don't change it). Used to restore
51
+ // tabs in stable creation order; the persisted openedAt is unreliable because a
52
+ // truncated log's first event ts drifts far from real creation. Load-time only.
53
+ try { const s = fs.statSync(p); r._bt = s.birthtimeMs || s.mtimeMs || 0 } catch { r._bt = 0 }
54
+ return r
55
+ } catch { return null }
56
+ })
46
57
  .filter(Boolean)
47
58
  } catch { return [] }
48
59
  }