thinkpool-pair 0.7.119 → 0.7.121

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/account.mjs CHANGED
@@ -489,7 +489,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
489
489
  const busPeek = (targetRoom, terminal, lines) => busRequestFirst('peer-peek-req', { targetRoom, terminal, lines }, CROSSROOM_BUS.busTimeoutMs)
490
490
  const busPost = (targetRoom, terminal, text, fromRoom) => busRequestFirst('peer-post-req', { targetRoom, terminal, text, fromRoom, fromHost: machine }, 180_000)
491
491
 
492
+ // Re-entrancy guard: bind-room fires tick() AND a 15s interval fires tick(); tick
493
+ // awaits a code_sessions query before children.set(), so two overlapping runs can
494
+ // both see children.has(room) false and DOUBLE-SPAWN a 2nd bridge for one room
495
+ // (two bridges per room = the CASCADE-delete / ghost-terminal hazard). Serialize.
496
+ let ticking = false
492
497
  const tick = async () => {
498
+ if (ticking) return
499
+ ticking = true
500
+ try { await tickBody() } finally { ticking = false }
501
+ }
502
+ const tickBody = async () => {
493
503
  // Hold the account claim before serving anything (cross-machine guard). When
494
504
  // standing by, make sure we're serving nothing and wait for the next tick.
495
505
  if (!(await refreshClaim())) {
package/bridge.mjs CHANGED
@@ -274,12 +274,17 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
274
274
  restartUpdateService: ({ room = null } = {}) => {
275
275
  let target = null
276
276
  try { target = execSync('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* offline / no npm */ }
277
- if (!target) {
278
- target = PKG_VERSION
279
- process.stderr.write(`\n ⚠ couldn't reach npm to resolve the newest version — re-pinning this launcher's v${PKG_VERSION} (stale-proof) and restarting…\n`)
280
- } else {
281
- process.stderr.write(`\n ◆ updating the background service to v${target} (newest published) and restarting…\n`)
277
+ if (!/^\d+\.\d+\.\d+/.test(target || '')) {
278
+ // Can't reach npm to learn the newest version — DON'T silently re-pin this
279
+ // launcher's PKG_VERSION (npx may have run a stale cached launcher, so it could
280
+ // be OLDER than what's installed). Just RESTART the current pinned service (no
281
+ // version change) and say so honestly — never call a same-version restart an
282
+ // "update".
283
+ process.stderr.write(`\n ⚠ couldn't reach npm to check for a newer version — restarting the current version, NO update applied. Try again when online.\n`)
284
+ svc.restartService(room)
285
+ return
282
286
  }
287
+ process.stderr.write(`\n ◆ updating the background service to v${target} (newest published) and restarting…\n`)
283
288
  svc.installService(room, [], { version: target, staleProof: true }) // rewrites plist/unit at resolved version + reloads = update + restart
284
289
  },
285
290
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
@@ -1666,12 +1671,20 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1666
1671
  // 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
1667
1672
  // composer UI, broadcast to both clients but never logged/persisted/replayed.
1668
1673
  const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion'
1669
- // compact turn finished (or errored) → clear the pulsing indicator.
1670
- // No "done" ctl line — the SDK's own output in the transcript is the
1671
- // signal (compact summary on success, AbortError text on abort/too-small).
1674
+ // compact turn finished (or errored) → clear the pulsing indicator AND resolve the
1675
+ // "◈ compacting context…" line so it doesn't sit there forever looking stuck (Max asked
1676
+ // "is it compacted?" — 2026-07-02). On success post an explicit "◈ context compacted"
1677
+ // control line, paired with the start line; on error the SDK's AbortError/too-small
1678
+ // text is the signal, so no line. Same control-event shape as ctlLine (kind:'control').
1672
1679
  if ((evt.kind === 'result' || evt.kind === 'error') && entry.compacting) {
1673
1680
  entry.compacting = false
1674
1681
  bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
1682
+ if (evt.kind === 'result') {
1683
+ const doneEvt = { kind: 'control', text: '◈ context compacted', by: entry.compactBy || null }
1684
+ pushLog(entry, doneEvt)
1685
+ bcast('code-event', { term: id, evt: doneEvt })
1686
+ }
1687
+ entry.compactBy = null
1675
1688
  }
1676
1689
  // Single tail: stamp into the log (transcript kinds only), broadcast live,
1677
1690
  // print locally, persist. Shared so a deferred image event re-enters it once
@@ -1806,6 +1819,10 @@ function endStructured(id) {
1806
1819
  function cleanupFlowLanes(flowId, graceMs = 4000) {
1807
1820
  if (!flowId) return
1808
1821
  const marker = `flow:${flowId}`
1822
+ // Drop this flow's budget ledger — else the map grows unbounded over the bridge's
1823
+ // life AND a re-run reusing the flowId reads a stale spend already at the cap, so
1824
+ // canDispatch refuses the fresh wave (the "flow won't start again" bug).
1825
+ flowBudgets.delete(flowId)
1809
1826
  setTimeout(() => {
1810
1827
  for (const [sid, e] of [...sessions]) {
1811
1828
  if (e.flowSessionId === flowId || e.spawnedBy === marker) {
@@ -2610,7 +2627,11 @@ process.on('uncaughtException', (err) => {
2610
2627
  try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
2611
2628
  shutdown(1)
2612
2629
  })
2630
+ // A stray unhandledRejection is NOT fatal — this long-running process has many
2631
+ // fire-and-forget promises (best-effort fetches, supabase awaits) and one that
2632
+ // wasn't .catch()'d must not tear down EVERY room + force a resume-storm. Log it
2633
+ // loudly and keep serving; a rejection that truly wedges a session surfaces
2634
+ // through the stall watchdog or a synchronous throw (→ uncaughtException above).
2613
2635
  process.on('unhandledRejection', (reason) => {
2614
- try { process.stderr.write(`\n ⚠ unhandledRejection: ${reason?.stack || reason}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
2615
- shutdown(1)
2636
+ try { process.stderr.write(`\n ⚠ unhandledRejection (non-fatal, continuing): ${reason?.stack || reason}\n`) } catch { /* noop */ }
2616
2637
  })
@@ -193,6 +193,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
193
193
  const RESTART_MAX = 3
194
194
  const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
195
195
  let restartCount = 0
196
+ let restartTimer = null // the pending auto-restart backoff — cancelled by end()
196
197
  // Force-stop a true wedge (item 3): no result, no error, just silence past
197
198
  // FORCE_STOP_MS. Clears turnActive so between-turns updates unblock, and
198
199
  // surfaces a recoverable error. The wedged loop is left in place (a hung
@@ -655,6 +656,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
655
656
  // count before compaction. Emit a real recap card the room can pin.
656
657
  if (m.subtype === 'compact_boundary') {
657
658
  emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
659
+ // Refresh the ctx% meter RIGHT AFTER compaction — it otherwise only updates at
660
+ // turn-end, so the mode row kept showing the stale PRE-compaction window
661
+ // ("ctx 90% even though we just compacted", Max 2026-07-02). Re-query the live
662
+ // context usage so the meter drops to the compacted size immediately.
663
+ ;(async () => {
664
+ try {
665
+ const c = await q?.getContextUsage?.()
666
+ if (c) { emit({ kind: 'usage', ctx: { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model } }); if (c.model) curModel = c.model }
667
+ } catch { /* control req may be unavailable */ }
668
+ })()
658
669
  break
659
670
  }
660
671
  if (m.session_id) sessionId = m.session_id
@@ -733,7 +744,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
733
744
  if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
734
745
  turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
735
746
  turnActive = false // turn settled → stall watchdog stands down
736
- restartCount = 0 // a successful turn refills the auto-restart budget
747
+ if (m.subtype === 'success') restartCount = 0 // ONLY a real success refills the
748
+ // auto-restart budget — else a flapping connection that lands one aborted turn
749
+ // between drops refills every cycle and loops far past RESTART_MAX.
737
750
  forceStopped = false
738
751
  emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, 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 })
739
752
  // Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
@@ -775,8 +788,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
775
788
  restartCount++
776
789
  turnActive = false
777
790
  emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
791
+ // End the OLD input stream before swapping — else its generator leaks
792
+ // (suspended forever) and a turn pushed into it after the throw is silently
793
+ // lost (push returns false on an ended stream, so sendTurn can surface it).
794
+ const oldInput = input
778
795
  input = makeInputStream()
779
- setTimeout(runQuery, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
796
+ try { oldInput.end() } catch { /* noop */ }
797
+ // Track the timer so end() can cancel it, and re-check `closed` at fire time
798
+ // so a session torn down during the backoff doesn't spawn a zombie SDK child.
799
+ restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
780
800
  return
781
801
  }
782
802
  // Non-recoverable, or budget exhausted: mark done so sendTurn stops
@@ -839,7 +859,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
839
859
  // SDK already surfaced one for the interrupt. (restored from 0.7.49)
840
860
  if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
841
861
  },
842
- end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
862
+ end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
843
863
  get sessionId() { return sessionId },
844
864
  get mode() { return mode },
845
865
  get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.119",
3
+ "version": "0.7.121",
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/service.mjs CHANGED
@@ -24,7 +24,16 @@ import { execSync } from 'node:child_process'
24
24
 
25
25
  // Service identity. Account mode has no room → a single stable id so there's
26
26
  // exactly one account service per machine (a second install replaces it).
27
- const label = (room) => room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
27
+ // Room codes flow UNESCAPED into launchctl/systemd shell strings + plist paths, so
28
+ // this is the security chokepoint: reject anything that isn't a plain alnum code
29
+ // before it can inject shell (`A;rm -rf ~`) or corrupt the service label. Every
30
+ // room-interpolating path (buildArtifact, restart/uninstall/serviceActive) calls
31
+ // label(), so one guard here covers them all.
32
+ const ROOM_CODE_RE = /^[A-Za-z0-9]{2,16}$/
33
+ const label = (room) => {
34
+ if (room && !ROOM_CODE_RE.test(room)) throw new Error(`invalid room code: ${JSON.stringify(room)} (expected 2–16 alphanumerics)`)
35
+ return room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
36
+ }
28
37
  const slug = (room) => room || 'account'
29
38
  const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
39
  const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`