thinkpool-pair 0.7.106 → 0.7.107

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
@@ -605,11 +605,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
605
605
  try { w.reply(m) } catch { /* reply path gone */ }
606
606
  }
607
607
  })
608
- child.on('exit', () => {
608
+ const dropRoom = () => {
609
609
  children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
610
610
  // Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
611
611
  // entries have askerRoom null and just time out on their own).
612
612
  for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
613
+ }
614
+ child.on('exit', dropRoom)
615
+ // A spawn that never starts (execPath/cwd vanished between the existsSync check and
616
+ // spawn) emits 'error', NOT 'exit'. Without this handler the stale children-map entry
617
+ // would block re-serving forever AND an unhandled 'error' would crash the supervisor.
618
+ child.on('error', (e) => {
619
+ dropRoom()
620
+ try { process.stderr.write(`\n ◇ ${room}: failed to launch its bridge (${e?.message || e}) — retrying next tick.\n`) } catch { /* noop */ }
613
621
  })
614
622
  // If an update is already pending, tell the freshly-served child immediately so
615
623
  // its room shows the "update ready" chip without waiting for the next poll.
@@ -634,6 +642,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
634
642
 
635
643
  await tick()
636
644
  const iv = setInterval(tick, 15000)
645
+ const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
637
646
  let stopping = false
638
647
  // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
639
648
  // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
@@ -641,6 +650,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
641
650
  if (stopping) return; stopping = true
642
651
  const code = restarting ? 1 : 0
643
652
  clearInterval(iv)
653
+ for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
644
654
  keepFresh.cancel()
645
655
  releaseSingleton()
646
656
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
@@ -703,9 +713,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
703
713
  applyIfIdle() // unattended fallback fires here when no room has a web peer
704
714
  }
705
715
  }
706
- setInterval(check, POLL_MS).unref()
707
- setInterval(applyIfIdle, 15000).unref()
708
- setTimeout(check, 60000).unref()
716
+ updateTimers.push(setInterval(check, POLL_MS).unref())
717
+ updateTimers.push(setInterval(applyIfIdle, 15000).unref())
718
+ updateTimers.push(setTimeout(check, 60000).unref())
709
719
  }
710
720
 
711
721
  await new Promise(() => {})
@@ -248,14 +248,18 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
248
248
  // Surface it to the room when it changes; absent on models without effort.
249
249
  const lvl = hookInput.effort?.level
250
250
  if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
251
- // Flow CONDUCTOR hard-stop: a conductor must DECOMPOSE, never fan out Task/Explore
252
- // subagents. The prompt asks it not to, but the model's parallelize instinct wins and
253
- // it spawns Explore agents anyway — which (in plan mode) raise permission cards on the
254
- // now-hidden conductor terminal and hang the whole flow. Deny the Task tool outright and
255
- // tell it to read with Read/Grep/Glob + ExitPlanMode. (blockSubagents is set only for
256
- // the conductor session — lanes are governed by their own one-hop prompt rule.)
257
- if (blockSubagents && (toolName === 'Task' || toolName === 'Agent')) {
258
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Flow conductors do NOT spawn subagents — it stalls the flow on a permission card. If you must inspect the codebase, read it yourself with Read/Grep/Glob (allowed here, no prompt). Then DECOMPOSE and call ExitPlanMode once. Do not call Task/Agent again.' } }
251
+ // Flow CONDUCTOR hard-stop: a conductor must DECOMPOSE using ONLY Read/Grep/Glob (which
252
+ // are auto-allowed, no card) then ExitPlanMode. Two things otherwise stall it on a
253
+ // permission card on the now-hidden conductor terminal: (1) Task/Agent subagents — the
254
+ // model's parallelize instinct fans out Explore agents despite the prompt; (2) Bash —
255
+ // the model reaches for `find`/`grep`/`wc`, and Bash is medium-risk → a card in plan
256
+ // mode. Deny BOTH and tell it to use Grep/Glob/Read instead. (blockSubagents is set only
257
+ // for the conductor session — lanes build under their own bypass/one-hop rules.)
258
+ if (blockSubagents && (toolName === 'Task' || toolName === 'Agent' || toolName === 'Bash')) {
259
+ const reason = (toolName === 'Bash')
260
+ ? 'Flow conductors do NOT run Bash — it pops a permission card and stalls the flow. Use the Grep tool (not `grep`/`rg`), the Glob tool (not `find`/`ls`), and Read (not `cat`/`head`) — those run with no prompt. Then DECOMPOSE and call ExitPlanMode once.'
261
+ : 'Flow conductors do NOT spawn subagents — it stalls the flow on a permission card. Inspect the codebase yourself with Read/Grep/Glob (no prompt). Then DECOMPOSE and call ExitPlanMode once. Do not call Task/Agent again.'
262
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
259
263
  }
260
264
  // ThinkPool cross-terminal READ (B2) is read-only + within-room — never
261
265
  // prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
@@ -388,7 +392,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
388
392
  let decision = 'allow'
389
393
  if (!auto) {
390
394
  try {
391
- decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
395
+ decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'deny'
392
396
  } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
393
397
  // "Allow & don't ask again" — remember the signature, then allow.
394
398
  if (decision === 'always') { alwaysAllow.add(sig); decision = 'allow' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.106",
3
+ "version": "0.7.107",
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": {