thinkpool-pair 0.7.41 → 0.7.42
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 +8 -3
- package/claude-session.mjs +58 -7
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -451,7 +451,7 @@ const flushAll = () => {
|
|
|
451
451
|
bcast('pty-out', { term: id, b64 })
|
|
452
452
|
}
|
|
453
453
|
}
|
|
454
|
-
const flushTimer = setInterval(flushAll, 35)
|
|
454
|
+
const flushTimer = setInterval(flushAll, 35); flushTimer.unref?.() // don't keep the event loop alive past shutdown
|
|
455
455
|
|
|
456
456
|
// ── mockup hand-off ────────────────────────────────────────────────────
|
|
457
457
|
// render.sh (mockup-iterate) drops a manifest JSON into TP_MOCKUP_OUTBOX after
|
|
@@ -548,6 +548,11 @@ const attachedDims = () => ({
|
|
|
548
548
|
|
|
549
549
|
function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
|
|
550
550
|
if (terms.has(id)) return
|
|
551
|
+
if (terms.size >= 8) { // cap live PTYs (matches session-store KEEP=8) — no unbounded host leak
|
|
552
|
+
process.stderr.write(`\n ⚠ can't open "${cmd}" — terminal cap (8) reached; close one first.\n`)
|
|
553
|
+
bcast('term-exit', { id })
|
|
554
|
+
return
|
|
555
|
+
}
|
|
551
556
|
if (!pty) { // raw-PTY path needs node-pty; structured Claude never lands here
|
|
552
557
|
process.stderr.write(`\n ⚠ can't open "${cmd}" — node-pty isn't built (PTY mode disabled).\n`)
|
|
553
558
|
bcast('term-exit', { id })
|
|
@@ -782,7 +787,7 @@ if (attachedCmd && process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
|
782
787
|
process.stdin.resume()
|
|
783
788
|
process.stdin.on('data', d => {
|
|
784
789
|
const t = attachedId && terms.get(attachedId)
|
|
785
|
-
if (t) t.term.write(d.toString('utf8'))
|
|
790
|
+
if (t) { try { t.term.write(d.toString('utf8')) } catch { /* term just exited */ } }
|
|
786
791
|
else if (d.includes(3)) shutdown() // Ctrl-C once detached/headless
|
|
787
792
|
})
|
|
788
793
|
// attached terminal follows the host's TTY size — but never below the
|
|
@@ -805,7 +810,7 @@ channel
|
|
|
805
810
|
if (!payload?.data) return
|
|
806
811
|
markActivity()
|
|
807
812
|
const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
|
|
808
|
-
if (t) t.term.write(payload.data)
|
|
813
|
+
if (t) { try { t.term.write(payload.data) } catch { /* term just exited */ } }
|
|
809
814
|
})
|
|
810
815
|
.on('broadcast', { event: 'resize' }, ({ payload }) => {
|
|
811
816
|
// headless terms are web-sized (last writer wins); the attached term is
|
package/claude-session.mjs
CHANGED
|
@@ -84,8 +84,10 @@ function makeInputStream() {
|
|
|
84
84
|
return {
|
|
85
85
|
stream: gen(),
|
|
86
86
|
push(content) {
|
|
87
|
+
if (ended) return false // session ended — caller (sendTurn) can surface this
|
|
87
88
|
queue.push({ type: 'user', message: { role: 'user', content } })
|
|
88
89
|
if (wake) { wake(); wake = null }
|
|
90
|
+
return true
|
|
89
91
|
},
|
|
90
92
|
end() { ended = true; if (wake) { wake(); wake = null } },
|
|
91
93
|
}
|
|
@@ -120,7 +122,7 @@ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
|
120
122
|
|
|
121
123
|
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission }) {
|
|
122
124
|
const ac = new AbortController()
|
|
123
|
-
|
|
125
|
+
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
124
126
|
let sessionId = resume || null
|
|
125
127
|
let closed = false
|
|
126
128
|
let q = null // the live Query — control requests (interrupt /
|
|
@@ -150,12 +152,39 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
150
152
|
let turnActive = false // true between a sent turn and its `result`
|
|
151
153
|
let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
|
|
152
154
|
let stalledSent = false // one `stalled` per stall, not a storm
|
|
155
|
+
// Auto-restart (item 1) — a transient upstream stream drop (e.g. Anthropic
|
|
156
|
+
// "Connection closed mid-response") used to leave the session half-dead:
|
|
157
|
+
// query loop gone, turnActive stuck, later sendTurns pushed into a dead
|
|
158
|
+
// stream (silent freeze). The loop now self-heals: a recoverable throw
|
|
159
|
+
// re-runs query() with resume, bounded by RESTART_MAX consecutive attempts
|
|
160
|
+
// (reset to 0 on every successful `result`). Exhaustion/non-recoverable →
|
|
161
|
+
// closed, so sendTurn stops silently queuing.
|
|
162
|
+
const RESTART_MAX = 3
|
|
163
|
+
const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
|
|
164
|
+
let restartCount = 0
|
|
165
|
+
// Force-stop a true wedge (item 3): no result, no error, just silence past
|
|
166
|
+
// FORCE_STOP_MS. Clears turnActive so between-turns updates unblock, and
|
|
167
|
+
// surfaces a recoverable error. The wedged loop is left in place (a hung
|
|
168
|
+
// native call can't be safely restarted mid-flight); if it later throws,
|
|
169
|
+
// the catch self-heals.
|
|
170
|
+
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
171
|
+
let forceStopped = false
|
|
153
172
|
|
|
154
173
|
const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
155
174
|
const stallTimer = setInterval(() => {
|
|
156
|
-
if (turnActive
|
|
175
|
+
if (!turnActive) return
|
|
176
|
+
const quiet = Date.now() - lastEvtTs
|
|
177
|
+
if (quiet > FORCE_STOP_MS && !forceStopped) {
|
|
178
|
+
// True wedge — no result, no error, just silence. Unblocks between-turns
|
|
179
|
+
// updates + surfaces a recoverable error. The wedged loop is left alone.
|
|
180
|
+
forceStopped = true
|
|
181
|
+
turnActive = false
|
|
182
|
+
emit({ kind: 'error', message: `agent went silent for ${Math.round(quiet / 1000)}s — force-stopped the turn; send again to resume`, recoverable: true })
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!stalledSent && quiet > STALL_MS) {
|
|
157
186
|
stalledSent = true
|
|
158
|
-
emit({ kind: 'stalled', sinceMs:
|
|
187
|
+
emit({ kind: 'stalled', sinceMs: quiet })
|
|
159
188
|
}
|
|
160
189
|
}, 5000)
|
|
161
190
|
stallTimer.unref?.()
|
|
@@ -317,9 +346,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
317
346
|
if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} cross-provider tool block(s) in the transcript before resume.\n`)
|
|
318
347
|
}
|
|
319
348
|
|
|
320
|
-
|
|
349
|
+
const runQuery = async () => {
|
|
321
350
|
try {
|
|
322
|
-
q = query({ prompt: input.stream, options: opts })
|
|
351
|
+
q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
323
352
|
for await (const m of q) {
|
|
324
353
|
if (closed) break
|
|
325
354
|
switch (m.type) {
|
|
@@ -388,6 +417,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
388
417
|
if (m.session_id) sessionId = m.session_id
|
|
389
418
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
390
419
|
turnActive = false // turn settled → stall watchdog stands down
|
|
420
|
+
restartCount = 0 // a successful turn refills the auto-restart budget
|
|
421
|
+
forceStopped = false
|
|
391
422
|
emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
|
|
392
423
|
// Surface a usage/context meter (chrome, not a transcript line). The
|
|
393
424
|
// context window % comes from the control request; cost is cumulative.
|
|
@@ -406,9 +437,29 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
406
437
|
}
|
|
407
438
|
}
|
|
408
439
|
} catch (e) {
|
|
409
|
-
if (
|
|
440
|
+
if (closed) return
|
|
441
|
+
const msg = e?.message || String(e)
|
|
442
|
+
emit({ kind: 'error', message: msg })
|
|
443
|
+
// Auto-restart a transient upstream stream drop: re-run query() with
|
|
444
|
+
// resume so the conversation continues. Bounded by RESTART_MAX (reset on
|
|
445
|
+
// each successful result) so a persistent outage can't loop forever; a
|
|
446
|
+
// fresh input stream is needed because the throw killed the old iterator.
|
|
447
|
+
if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
|
|
448
|
+
restartCount++
|
|
449
|
+
turnActive = false
|
|
450
|
+
emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
|
|
451
|
+
input = makeInputStream()
|
|
452
|
+
setTimeout(runQuery, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
// Non-recoverable, or budget exhausted: mark done so sendTurn stops
|
|
456
|
+
// silently queuing into a dead stream. The bridge's onEvent self-heal
|
|
457
|
+
// still fires for "No conversation found" (it reopens fresh).
|
|
458
|
+
closed = true
|
|
459
|
+
turnActive = false
|
|
410
460
|
}
|
|
411
|
-
}
|
|
461
|
+
}
|
|
462
|
+
runQuery()
|
|
412
463
|
|
|
413
464
|
return {
|
|
414
465
|
sendTurn(text) { if (!closed) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
|