thinkpool-pair 0.7.7 → 0.7.8

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
@@ -580,8 +582,8 @@ channel
580
582
  if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
581
583
  }
582
584
  if (wantStructured(payload.cmd)) {
583
- openStructured({ id: payload.id })
584
- process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session.\n`)
585
+ openStructured({ id: payload.id, mode: payload.mode })
586
+ process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}).\n`)
585
587
  return
586
588
  }
587
589
  openTerm({ id: payload.id, cmd: payload.cmd, args })
@@ -632,7 +634,7 @@ channel
632
634
  // ── structured-session control (Phase 2) ──
633
635
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
634
636
  if (payload?.host && payload.host !== name) return
635
- openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume })
637
+ openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
636
638
  })
637
639
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
638
640
  const s = payload?.term && sessions.get(payload.term)
@@ -686,7 +688,7 @@ channel
686
688
  })
687
689
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
688
690
  const s = payload?.term && sessions.get(payload.term)
689
- if (s) s.session.setMode(payload.mode)
691
+ if (s) { s.mode = payload.mode; s.session.setMode(payload.mode) } // track for persist/restore
690
692
  })
691
693
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
692
694
  const s = payload?.id && sessions.get(payload.id)
@@ -704,7 +706,7 @@ channel
704
706
  // replay its transcript + resume the live SDK context if recent enough.
705
707
  if (wantStructured(startCmd)) {
706
708
  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 })
709
+ 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
710
  else openStructured({ id: randomUUID() })
709
711
  }
710
712
  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.8",
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": {