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 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
@@ -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
- const input = makeInputStream()
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 && !stalledSent && Date.now() - lastEvtTs > STALL_MS) {
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: Date.now() - lastEvtTs })
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
- ;(async () => {
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 (!closed) emit({ kind: 'error', message: e?.message || String(e) })
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)) } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.41",
3
+ "version": "0.7.42",
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": {