thinkpool-pair 0.6.3 → 0.6.5

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
@@ -327,7 +327,7 @@ function openStructured({ id, model, resume }) {
327
327
  },
328
328
  requestPermission: (req) => new Promise((resolve) => {
329
329
  entry.pending.set(req.id, resolve)
330
- bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk })
330
+ bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan })
331
331
  }),
332
332
  })
333
333
  announce()
@@ -444,22 +444,36 @@ channel
444
444
  })
445
445
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
446
446
  const s = payload?.term && sessions.get(payload.term)
447
- if (s && payload.text != null) {
448
- s.session.sendTurn(payload.text)
449
- // Echo the turn so BOTH readers (and late joiners, via the log) show who
450
- // said what — UNLESS silent (an @pool synthesis, which renders as its own
451
- // 'pool' line). The sender rendered it optimistically; partner gets this.
452
- if (!payload.silent) {
453
- const evt = { kind: 'you', text: payload.text, cid: payload.cid, by: payload.by }
454
- s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
455
- bcast('code-event', { term: payload.term, evt })
456
- }
447
+ if (!s || payload.text == null) return
448
+ const text = String(payload.text)
449
+ // Echo the turn so BOTH readers (and late joiners, via the log) show who
450
+ // said what — UNLESS silent (an @pool synthesis, which renders its own line).
451
+ const echoYou = () => {
452
+ if (payload.silent) return
453
+ const evt = { kind: 'you', text, cid: payload.cid, by: payload.by }
454
+ s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
455
+ bcast('code-event', { term: payload.term, evt })
456
+ }
457
+ // Most slash commands (/compact, /clear, …) are processed by the SDK when
458
+ // sent as a turn. Two need special handling:
459
+ // • /model <name> is disabled headless → route to the setModel control.
460
+ // • /clear also wipes the on-screen transcript (parity with the CLI).
461
+ const mm = text.match(/^\/model\b\s*(\S+)?/)
462
+ if (mm) { echoYou(); if (mm[1]) s.session.setModel(mm[1]); else s.session.listModels(); return }
463
+ if (/^\/clear\s*$/.test(text)) {
464
+ echoYou(); s.session.sendTurn(text)
465
+ s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
466
+ return
457
467
  }
468
+ s.session.sendTurn(text)
469
+ echoYou()
458
470
  })
459
471
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
460
472
  const s = payload?.term && sessions.get(payload.term)
461
473
  const resolve = s && payload.id && s.pending.get(payload.id)
462
- if (resolve) { s.pending.delete(payload.id); resolve(payload.decision === 'deny' ? 'deny' : 'allow') }
474
+ // Pass the raw decision through normal tools use allow/deny, plan cards
475
+ // use run/accept/keep (claude-session interprets). Default deny on missing.
476
+ if (resolve) { s.pending.delete(payload.id); resolve(payload.decision || 'deny') }
463
477
  })
464
478
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
465
479
  const s = payload?.term && sessions.get(payload.term)
@@ -101,6 +101,24 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
101
101
  const preTool = async (hookInput) => {
102
102
  const toolName = hookInput.tool_name
103
103
  const toolInput = hookInput.tool_input
104
+ // ── Plan approval — ExitPlanMode is how the agent presents its plan in
105
+ // plan mode. Render a dedicated plan card (not the generic perm card) with
106
+ // three outcomes: run (exit → default), accept (exit → acceptEdits), keep
107
+ // (deny → stay planning). On approval we flip the SDK permission mode so
108
+ // subsequent tools actually execute.
109
+ if (toolName === 'ExitPlanMode') {
110
+ let choice = 'keep'
111
+ try { choice = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'plan', plan: toolInput?.plan || '' }) ?? 'keep' }
112
+ catch { choice = 'keep' }
113
+ if (choice === 'run' || choice === 'accept') {
114
+ const next = choice === 'accept' ? 'acceptEdits' : 'default'
115
+ mode = next
116
+ try { await q?.setPermissionMode?.(next) } catch { /* only valid mid-stream */ }
117
+ emit({ kind: 'mode', mode: next })
118
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Plan approved in the ThinkPool room — proceed (${next} mode).` } }
119
+ }
120
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'The user chose "keep planning" in the ThinkPool room. Do not exit plan mode — keep refining the plan, then call ExitPlanMode again when ready.' } }
121
+ }
104
122
  const risk = classifyRisk(toolName, toolInput)
105
123
  const auto =
106
124
  risk === 'low' ||
@@ -181,6 +199,19 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
181
199
  try { await q?.setPermissionMode?.(m) } catch { /* only valid mid-stream */ }
182
200
  emit({ kind: 'mode', mode })
183
201
  },
202
+ // /model is disabled in headless SDK — switch via the setModel control
203
+ // instead, and echo a note line so both drivers see the change.
204
+ async setModel(m) {
205
+ try { await q?.setModel?.(m) } catch { /* may reject unknown model */ }
206
+ emit({ kind: 'note', text: `model → ${m}` })
207
+ },
208
+ async listModels() {
209
+ try {
210
+ const ms = await q?.supportedModels?.()
211
+ const names = (ms || []).map((x) => x.model || x.id || x.name).filter(Boolean)
212
+ emit({ kind: 'note', text: names.length ? `models: ${names.join(', ')}` : 'no model list available' })
213
+ } catch { emit({ kind: 'note', text: 'usage: /model <name>' }) }
214
+ },
184
215
  // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
185
216
  // session alive for the next one. ac.abort() is teardown only (end()).
186
217
  async abort() { try { await q?.interrupt?.() } catch { /* noop */ } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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": {