thinkpool-pair 0.7.6 → 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/account.mjs CHANGED
@@ -132,7 +132,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
132
132
  }
133
133
  warned.delete(room)
134
134
  process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}${dirs[room] ? '' : ' (default)'}\n`)
135
- const child = spawn(process.execPath, [BRIDGE, room, '--headless'], { cwd: dir, stdio: 'inherit' })
135
+ const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: 'inherit' })
136
136
  children.set(room, child)
137
137
  child.on('exit', () => { children.delete(room) }) // re-served on the next tick
138
138
  }
package/bridge.mjs CHANGED
@@ -175,6 +175,9 @@ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
175
175
  }
176
176
 
177
177
  const headless = argv.includes('--headless')
178
+ // Account-mode children pass --auto=<agent> so a served session opens a live
179
+ // terminal automatically (headless, no TTY/picker) instead of an empty room.
180
+ const autoAgent = (argv.find(a => a.startsWith('--auto=')) || '').slice(7) || null
178
181
  // Structured mode (Phase 2, opt-in): Claude Code runs through the Agent SDK
179
182
  // (structured events + risk-tiered permission gate) instead of the PTY byte
180
183
  // relay. Default OFF — the PTY path is untouched. Only applies to `claude`.
@@ -454,14 +457,16 @@ function printLocal(evt) {
454
457
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
455
458
  // persist to the host file; tool calls round-trip through the perm card; the
456
459
  // rolling log replays to joiners and survives bridge restarts (session-store).
457
- function openStructured({ id, model, resume, log, commands }) {
460
+ function openStructured({ id, model, resume, log, commands, mode }) {
458
461
  if (sessions.has(id)) return
459
- 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 }
460
463
  sessions.set(id, entry)
461
464
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
462
- 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 })
463
468
  entry.session = startClaudeSession({
464
- cwd: process.cwd(), model, resume,
469
+ cwd: process.cwd(), model, resume, mode,
465
470
  onEvent: (evt) => {
466
471
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
467
472
  // keeping the transcript (scrollback survives; live context is gone).
@@ -470,7 +475,7 @@ function openStructured({ id, model, resume, log, commands }) {
470
475
  process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
471
476
  try { entry.session?.end() } catch { /* noop */ }
472
477
  sessions.delete(id)
473
- openStructured({ id, model, log: entry.log, commands: entry.commands })
478
+ openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
474
479
  return
475
480
  }
476
481
  // Stamp a wall-clock ts on every transcript event so the web client can
@@ -577,8 +582,8 @@ channel
577
582
  if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
578
583
  }
579
584
  if (wantStructured(payload.cmd)) {
580
- openStructured({ id: payload.id })
581
- 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`)
582
587
  return
583
588
  }
584
589
  openTerm({ id: payload.id, cmd: payload.cmd, args })
@@ -629,7 +634,7 @@ channel
629
634
  // ── structured-session control (Phase 2) ──
630
635
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
631
636
  if (payload?.host && payload.host !== name) return
632
- 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 })
633
638
  })
634
639
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
635
640
  const s = payload?.term && sessions.get(payload.term)
@@ -683,7 +688,7 @@ channel
683
688
  })
684
689
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
685
690
  const s = payload?.term && sessions.get(payload.term)
686
- if (s) s.session.setMode(payload.mode)
691
+ if (s) { s.mode = payload.mode; s.session.setMode(payload.mode) } // track for persist/restore
687
692
  })
688
693
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
689
694
  const s = payload?.id && sessions.get(payload.id)
@@ -694,16 +699,17 @@ channel
694
699
  if (status === 'SUBSCRIBED') {
695
700
  realtimeHealthy = true; brokenSince = 0
696
701
  channel.track({ name, role: 'bridge' })
697
- if (attachedCmd && !terms.size && !sessions.size) {
702
+ const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
703
+ if (startCmd && !terms.size && !sessions.size) {
698
704
  // Claude + structured mode → Agent SDK session; everything else → PTY.
699
705
  // On restart, restore the latest saved structured session for this room:
700
706
  // replay its transcript + resume the live SDK context if recent enough.
701
- if (wantStructured(attachedCmd)) {
707
+ if (wantStructured(startCmd)) {
702
708
  const prev = loadLatest(room)
703
- 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 })
704
710
  else openStructured({ id: randomUUID() })
705
711
  }
706
- else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
712
+ else openTerm({ id: randomUUID(), cmd: startCmd, args: attachedArgs, attached: !autoAgent })
707
713
  }
708
714
  announce()
709
715
  process.stderr.write(headless
@@ -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.6",
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": {