thinkpool-pair 0.7.7 → 0.7.9

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
@@ -457,14 +457,16 @@ function printLocal(evt) {
457
457
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
458
458
  // persist to the host file; tool calls round-trip through the perm card; the
459
459
  // rolling log replays to joiners and survives bridge restarts (session-store).
460
- function openStructured({ id, model, resume, log, commands }) {
460
+ function openStructured({ id, model, resume, log, commands, mode }) {
461
461
  if (sessions.has(id)) return
462
- const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined }
462
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode }
463
463
  sessions.set(id, entry)
464
464
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
465
- const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands })
465
+ // Persist the permission mode alongside the transcript so a bridge restart
466
+ // restores the session in the SAME mode (a bypass room stays bypass on resume).
467
+ const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
466
468
  entry.session = startClaudeSession({
467
- cwd: process.cwd(), model, resume,
469
+ cwd: process.cwd(), model, resume, mode,
468
470
  onEvent: (evt) => {
469
471
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
470
472
  // keeping the transcript (scrollback survives; live context is gone).
@@ -473,7 +475,7 @@ function openStructured({ id, model, resume, log, commands }) {
473
475
  process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
474
476
  try { entry.session?.end() } catch { /* noop */ }
475
477
  sessions.delete(id)
476
- openStructured({ id, model, log: entry.log, commands: entry.commands })
478
+ openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
477
479
  return
478
480
  }
479
481
  // Stamp a wall-clock ts on every transcript event so the web client can
@@ -497,8 +499,11 @@ function openStructured({ id, model, resume, log, commands }) {
497
499
  if (!chrome) persist()
498
500
  },
499
501
  requestPermission: (req) => new Promise((resolve) => {
500
- entry.pending.set(req.id, resolve)
501
- bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions })
502
+ // Keep the broadcast payload with the resolver so a reconnect can re-send it
503
+ // (replay-request handler) pending cards never enter the replayed event log.
504
+ const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
505
+ entry.pending.set(req.id, { resolve, payload })
506
+ bcast('code-perm-req', payload)
502
507
  process.stderr.write(req.risk === 'plan'
503
508
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
504
509
  : req.risk === 'ask'
@@ -580,8 +585,8 @@ channel
580
585
  if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
581
586
  }
582
587
  if (wantStructured(payload.cmd)) {
583
- openStructured({ id: payload.id })
584
- process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session.\n`)
588
+ openStructured({ id: payload.id, mode: payload.mode })
589
+ process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}).\n`)
585
590
  return
586
591
  }
587
592
  openTerm({ id: payload.id, cmd: payload.cmd, args })
@@ -607,6 +612,15 @@ channel
607
612
  if (!s.log.length) continue
608
613
  bcast('code-replay', { to: payload?.to ?? null, term: id, events: s.log })
609
614
  }
615
+ // Re-send any still-pending permission/question cards. They ride a one-shot
616
+ // code-perm-req broadcast (NOT the replayed event log), so a reconnect/refresh
617
+ // would otherwise lose the answerable card while the transcript still shows the
618
+ // (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
619
+ // truly-unresolved cards remain in `pending` (resolved ones are deleted), and
620
+ // the client dedupes code-perm-req by id, so this can't resurrect an answered one.
621
+ for (const [, s] of sessions) {
622
+ for (const [, p] of s.pending) bcast('code-perm-req', p.payload)
623
+ }
610
624
  announce()
611
625
  })
612
626
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
@@ -632,7 +646,7 @@ channel
632
646
  // ── structured-session control (Phase 2) ──
633
647
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
634
648
  if (payload?.host && payload.host !== name) return
635
- openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume })
649
+ openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
636
650
  })
637
651
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
638
652
  const s = payload?.term && sessions.get(payload.term)
@@ -675,10 +689,10 @@ channel
675
689
  })
676
690
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
677
691
  const s = payload?.term && sessions.get(payload.term)
678
- const resolve = s && payload.id && s.pending.get(payload.id)
692
+ const p = s && payload.id && s.pending.get(payload.id)
679
693
  // Pass the raw decision through — normal tools use allow/deny, plan cards
680
694
  // use run/accept/keep (claude-session interprets). Default deny on missing.
681
- if (resolve) { s.pending.delete(payload.id); resolve(payload.decision || 'deny') }
695
+ if (p) { s.pending.delete(payload.id); p.resolve(payload.decision || 'deny') }
682
696
  })
683
697
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
684
698
  const s = payload?.term && sessions.get(payload.term)
@@ -686,7 +700,7 @@ channel
686
700
  })
687
701
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
688
702
  const s = payload?.term && sessions.get(payload.term)
689
- if (s) s.session.setMode(payload.mode)
703
+ if (s) { s.mode = payload.mode; s.session.setMode(payload.mode) } // track for persist/restore
690
704
  })
691
705
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
692
706
  const s = payload?.id && sessions.get(payload.id)
@@ -704,7 +718,7 @@ channel
704
718
  // replay its transcript + resume the live SDK context if recent enough.
705
719
  if (wantStructured(startCmd)) {
706
720
  const prev = loadLatest(room)
707
- if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log, commands: prev.commands })
721
+ if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log, commands: prev.commands, mode: prev.mode })
708
722
  else openStructured({ id: randomUUID() })
709
723
  }
710
724
  else openTerm({ id: randomUUID(), cmd: startCmd, args: attachedArgs, attached: !autoAgent })
@@ -113,20 +113,37 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
113
113
  */
114
114
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
115
115
 
116
- export function startClaudeSession({ cwd, model, resume, onEvent, requestPermission }) {
116
+ export function startClaudeSession({ cwd, model, resume, mode: initialMode = 'default', onEvent, requestPermission }) {
117
117
  const ac = new AbortController()
118
118
  const input = makeInputStream()
119
119
  let sessionId = resume || null
120
120
  let closed = false
121
121
  let q = null // the live Query — control requests (interrupt /
122
122
  // setPermissionMode) route through it once streaming.
123
- let mode = 'default' // mirrors Claude Code's ⇧⇥ cycle
123
+ // The session is CREATED in the caller's chosen mode (not hard-coded default):
124
+ // a bypass session must start in bypass so the agent never enters plan mode and
125
+ // never throws an ExitPlanMode card. Falls back to 'default' for unknown values.
126
+ let mode = MODES.has(initialMode) ? initialMode : 'default' // mirrors ⇧⇥ cycle
124
127
  const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
125
128
  const toolStart = new Map() // tool_use id → start time, for the duration badge
126
129
  let effort = null // active reasoning effort for the turn (from the hook input)
127
130
 
128
131
  const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
129
132
 
133
+ // Apply a permission-mode change to the live SDK *outside* any hook callback.
134
+ // setPermissionMode is a streaming control request; awaiting it from INSIDE the
135
+ // PreToolUse hook (which the SDK is itself awaiting) jams the control channel —
136
+ // the call throws, and when that was silently swallowed the SDK stayed in plan
137
+ // mode, so the agent got bounced back to planning after every approved edit
138
+ // (the "PLAN READY every turn" loop). Deferring to the next tick lets the hook
139
+ // return first, freeing the channel. Errors are logged to the bridge, not eaten.
140
+ const scheduleSdkMode = (next) => {
141
+ setTimeout(() => {
142
+ Promise.resolve(q?.setPermissionMode?.(next)).catch((e) =>
143
+ console.error(`[claude-session] setPermissionMode(${next}) failed:`, e?.message || e))
144
+ }, 0)
145
+ }
146
+
130
147
  // PreToolUse — fires on EVERY tool call (the universal gate). The mode policy
131
148
  // mirrors Claude Code exactly: reads never prompt (any mode); Auto-accept
132
149
  // edits auto-approves non-destructive writes; Bash / network / destructive
@@ -150,8 +167,8 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
150
167
  catch { choice = 'keep' }
151
168
  if (choice === 'run' || choice === 'accept') {
152
169
  const next = choice === 'accept' ? 'acceptEdits' : 'default'
153
- mode = next
154
- try { await q?.setPermissionMode?.(next) } catch { /* only valid mid-stream */ }
170
+ mode = next // our auto-allow gate updates immediately
171
+ scheduleSdkMode(next) // flip the SDK AFTER this hook returns (see above)
155
172
  emit({ kind: 'mode', mode: next })
156
173
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Plan approved in the ThinkPool room — proceed (${next} mode).` } }
157
174
  }
@@ -211,7 +228,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
211
228
 
212
229
  const opts = {
213
230
  abortController: ac,
214
- permissionMode: 'default',
231
+ permissionMode: mode,
215
232
  hooks: { PreToolUse: [{ hooks: [preTool] }] },
216
233
  // Load the host's REAL Claude environment — user + project + local settings —
217
234
  // so custom slash commands (.claude/commands/*.md), CLAUDE.md and agents work
@@ -298,8 +315,10 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
298
315
  // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
299
316
  async setMode(m) {
300
317
  if (!MODES.has(m) || closed) return
301
- mode = m
302
- try { await q?.setPermissionMode?.(m) } catch { /* only valid mid-stream */ }
318
+ mode = m // gate is authoritative immediately
319
+ scheduleSdkMode(m) // defer the SDK control request a mode toggle can
320
+ // land while a perm card is pending (hook in flight),
321
+ // which is exactly the re-entrancy that jams it.
303
322
  emit({ kind: 'mode', mode })
304
323
  },
305
324
  // /model is disabled in headless SDK — switch via the setModel control
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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": {