thinkpool-pair 0.7.119 → 0.7.120

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) },
@@ -1806,6 +1811,10 @@ function endStructured(id) {
1806
1811
  function cleanupFlowLanes(flowId, graceMs = 4000) {
1807
1812
  if (!flowId) return
1808
1813
  const marker = `flow:${flowId}`
1814
+ // Drop this flow's budget ledger — else the map grows unbounded over the bridge's
1815
+ // life AND a re-run reusing the flowId reads a stale spend already at the cap, so
1816
+ // canDispatch refuses the fresh wave (the "flow won't start again" bug).
1817
+ flowBudgets.delete(flowId)
1809
1818
  setTimeout(() => {
1810
1819
  for (const [sid, e] of [...sessions]) {
1811
1820
  if (e.flowSessionId === flowId || e.spawnedBy === marker) {
@@ -2610,7 +2619,11 @@ process.on('uncaughtException', (err) => {
2610
2619
  try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
2611
2620
  shutdown(1)
2612
2621
  })
2622
+ // A stray unhandledRejection is NOT fatal — this long-running process has many
2623
+ // fire-and-forget promises (best-effort fetches, supabase awaits) and one that
2624
+ // wasn't .catch()'d must not tear down EVERY room + force a resume-storm. Log it
2625
+ // loudly and keep serving; a rejection that truly wedges a session surfaces
2626
+ // through the stall watchdog or a synchronous throw (→ uncaughtException above).
2613
2627
  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)
2628
+ try { process.stderr.write(`\n ⚠ unhandledRejection (non-fatal, continuing): ${reason?.stack || reason}\n`) } catch { /* noop */ }
2616
2629
  })
@@ -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
@@ -733,7 +734,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
733
734
  if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
734
735
  turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
735
736
  turnActive = false // turn settled → stall watchdog stands down
736
- restartCount = 0 // a successful turn refills the auto-restart budget
737
+ if (m.subtype === 'success') restartCount = 0 // ONLY a real success refills the
738
+ // auto-restart budget — else a flapping connection that lands one aborted turn
739
+ // between drops refills every cycle and loops far past RESTART_MAX.
737
740
  forceStopped = false
738
741
  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
742
  // Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
@@ -775,8 +778,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
775
778
  restartCount++
776
779
  turnActive = false
777
780
  emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
781
+ // End the OLD input stream before swapping — else its generator leaks
782
+ // (suspended forever) and a turn pushed into it after the throw is silently
783
+ // lost (push returns false on an ended stream, so sendTurn can surface it).
784
+ const oldInput = input
778
785
  input = makeInputStream()
779
- setTimeout(runQuery, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
786
+ try { oldInput.end() } catch { /* noop */ }
787
+ // Track the timer so end() can cancel it, and re-check `closed` at fire time
788
+ // so a session torn down during the backoff doesn't spawn a zombie SDK child.
789
+ restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
780
790
  return
781
791
  }
782
792
  // Non-recoverable, or budget exhausted: mark done so sendTurn stops
@@ -839,7 +849,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
839
849
  // SDK already surfaced one for the interrupt. (restored from 0.7.49)
840
850
  if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
841
851
  },
842
- end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
852
+ end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
843
853
  get sessionId() { return sessionId },
844
854
  get mode() { return mode },
845
855
  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.120",
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, `'\\''`)}'`