thinkpool-pair 0.7.130 → 0.7.132
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 +54 -3
- package/claude-session.mjs +16 -3
- package/package.json +1 -1
- package/session-store.mjs +12 -1
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
|
|
@@ -1222,7 +1248,7 @@ function restoredTurnOpen(log) {
|
|
|
1222
1248
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1223
1249
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1224
1250
|
// 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 }) {
|
|
1251
|
+
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer }) {
|
|
1226
1252
|
if (sessions.has(id)) return
|
|
1227
1253
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1228
1254
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
@@ -1625,8 +1651,12 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1625
1651
|
return { allow: true, reason: 'no reviewed slice targeted' }
|
|
1626
1652
|
}
|
|
1627
1653
|
: null
|
|
1654
|
+
entry.deferred = !!defer // a lazily-booted idle terminal — the background warmer will boot it
|
|
1628
1655
|
entry.session = startClaudeSession({
|
|
1629
1656
|
cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
|
|
1657
|
+
// defer: a restored-idle terminal cold-boots its query only on first use — restore is
|
|
1658
|
+
// then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
|
|
1659
|
+
lazy: !!defer,
|
|
1630
1660
|
reviewGate,
|
|
1631
1661
|
// A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
|
|
1632
1662
|
// a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
|
|
@@ -1725,6 +1755,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1725
1755
|
try { entry.session?.sendTurn('continue') } catch { /* not resumable — stays idle, user can continue manually */ }
|
|
1726
1756
|
process.stderr.write(`\n ◆ auto-resumed interrupted turn (${id.slice(0, 8)}) — sent continue.\n`)
|
|
1727
1757
|
}
|
|
1758
|
+
// A background-warmed terminal is now live — free its warmer slot so the next queued
|
|
1759
|
+
// idle terminal starts booting.
|
|
1760
|
+
if (evt.kind === 'system' && entry.warming) warmComplete(entry)
|
|
1728
1761
|
// Chrome events (mode / usage / clear / compact) are transient state, not
|
|
1729
1762
|
// transcript — broadcast + print them but keep out of the persisted/replayed log.
|
|
1730
1763
|
// 'stalled' is transient turn-liveness state (would replay as a stale frozen
|
|
@@ -2309,13 +2342,17 @@ Output:`
|
|
|
2309
2342
|
// Restore in stable creation order (openedAt) so the tab strip comes back in the
|
|
2310
2343
|
// SAME order across a bridge restart — NOT readdir/filesystem order, which reshuffled
|
|
2311
2344
|
// the tabs (Max, 2026-07-02). Legacy recs fall back to their first event ts / savedAt.
|
|
2312
|
-
const okey = (r) => r.
|
|
2345
|
+
const okey = (r) => r._bt || r.openedAt || r.savedAt || 0
|
|
2313
2346
|
all.sort((a, b) => okey(a) - okey(b))
|
|
2314
2347
|
if (all.length) for (const rec of all) {
|
|
2315
2348
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
2316
2349
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
2317
2350
|
// 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
|
|
2351
|
+
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,
|
|
2352
|
+
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
2353
|
+
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
2354
|
+
// (mid-turn needs auto-resume; flow needs its lane live).
|
|
2355
|
+
defer: !rec.flowSessionId && !restoredTurnOpen(rec.log || []) })
|
|
2319
2356
|
const re = sessions.get(rec.id)
|
|
2320
2357
|
if (re && rec.flowReviewTarget) re.flowReviewTarget = rec.flowReviewTarget
|
|
2321
2358
|
// S4 — a resumed re-dispatch lane keeps its surviving revert target across a bridge restart.
|
|
@@ -2348,6 +2385,13 @@ Output:`
|
|
|
2348
2385
|
// saved-session restore above still runs unconditionally, so a bridge
|
|
2349
2386
|
// restart resumes backgrounded sessions regardless of this guard.
|
|
2350
2387
|
else if (attachedCmd) openStructured({ id: randomUUID() })
|
|
2388
|
+
// Background-warm the lazily-restored idle terminals shortly after restore, so
|
|
2389
|
+
// they're ready before the user clicks them (no first-turn penalty). Delayed so the
|
|
2390
|
+
// eagerly-booted (mid-turn / auto-resumed) sessions get the machine to themselves first.
|
|
2391
|
+
setTimeout(() => {
|
|
2392
|
+
for (const [, e] of sessions) if (e.deferred && !e.warmed && !e.warming && !e.session?.started) warmQueue.push(e)
|
|
2393
|
+
if (warmQueue.length) { process.stderr.write(`\n ◆ background-warming ${warmQueue.length} idle terminal(s) (≤${WARM_CONCURRENCY} at a time)…\n`); pumpWarm() }
|
|
2394
|
+
}, 6000)
|
|
2351
2395
|
}
|
|
2352
2396
|
else {
|
|
2353
2397
|
// Reuse the prior auto-opened PTY id across restarts so a reconnecting
|
|
@@ -2794,6 +2838,13 @@ process.on('SIGTERM', () => shutdown(0))
|
|
|
2794
2838
|
// Route both through shutdown() so children are reaped + presence left, then the
|
|
2795
2839
|
// supervisor / launchd / systemd respawns a clean process.
|
|
2796
2840
|
process.on('uncaughtException', (err) => {
|
|
2841
|
+
// Benign write-to-closed-pipe/socket (a broadcast or a child stream that closed
|
|
2842
|
+
// mid-restore) must NOT kill the bridge — that respawn triggered a SECOND full
|
|
2843
|
+
// restore, doubling an already-slow restore (Max, 2026-07-02). Log + keep running.
|
|
2844
|
+
if (err && (err.code === 'EPIPE' || err.code === 'ECONNRESET' || err.code === 'ERR_STREAM_WRITE_AFTER_END' || err.code === 'ERR_STREAM_DESTROYED')) {
|
|
2845
|
+
try { process.stderr.write(`\n ⚠ ignored benign ${err.code} (not fatal): ${err?.message || ''}\n`) } catch { /* noop */ }
|
|
2846
|
+
return
|
|
2847
|
+
}
|
|
2797
2848
|
try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
|
|
2798
2849
|
shutdown(1)
|
|
2799
2850
|
})
|
package/claude-session.mjs
CHANGED
|
@@ -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,16 @@ 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
|
-
|
|
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>` }]) } },
|
|
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 },
|
|
855
868
|
// Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
|
|
856
869
|
// streaming control request (drives plan-mode behaviour SDK-side); the local
|
|
857
870
|
// `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
|
package/package.json
CHANGED
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) => {
|
|
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
|
}
|