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 +24 -16
- package/claude-session.mjs +4 -1
- package/interrupted-resume.mjs +29 -0
- package/package.json +2 -1
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
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3224
|
-
//
|
|
3225
|
-
|
|
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)
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|