thinkpool-pair 0.6.6 → 0.6.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
@@ -330,6 +330,7 @@ function printLocal(evt) {
330
330
  case 'pool': return w(evt.pending ? `${A.cyan}◌ pool synthesizing…${A.rst}` : `${A.cyan}◆ pool → ${evt.text}${A.rst}`)
331
331
  case 'note': return w(`${A.dim}— ${evt.text} —${A.rst}`)
332
332
  case 'clear': return w(`${A.dim}— context cleared —${A.rst}`)
333
+ case 'usage': { const c = evt.ctx; return c ? w(`${A.dim} ctx ${c.pct}% · ${Math.round(c.used / 1000)}k/${Math.round(c.max / 1000)}k${evt.costUsd != null ? ` · $${evt.costUsd.toFixed(3)}` : ''}${A.rst}`) : undefined }
333
334
  case 'error': return w(`${A.red}✗ ${evt.message}${A.rst}`)
334
335
  case 'tool_result': {
335
336
  const c = evt.content
@@ -370,18 +371,25 @@ function openStructured({ id, model, resume, log }) {
370
371
  openStructured({ id, model, log: entry.log })
371
372
  return
372
373
  }
373
- entry.log.push(evt)
374
- if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
374
+ // Chrome events (mode / usage / clear) are transient state, not transcript —
375
+ // broadcast + print them, but keep them out of the persisted/replayed log.
376
+ const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear'
377
+ if (!chrome) {
378
+ entry.log.push(evt)
379
+ if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
380
+ }
375
381
  bcast('code-event', { term: id, evt })
376
382
  printLocal(evt)
377
- persist()
383
+ if (!chrome) persist()
378
384
  },
379
385
  requestPermission: (req) => new Promise((resolve) => {
380
386
  entry.pending.set(req.id, resolve)
381
- bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan })
387
+ bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions })
382
388
  process.stderr.write(req.risk === 'plan'
383
389
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
384
- : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} approve in the room.${A.rst}\n`)
390
+ : req.risk === 'ask'
391
+ ? `\n ${A.cyan}● Claude is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
392
+ : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
385
393
  }),
386
394
  })
387
395
  announce()
@@ -91,6 +91,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
91
91
  let q = null // the live Query — control requests (interrupt /
92
92
  // setPermissionMode) route through it once streaming.
93
93
  let mode = 'default' // mirrors Claude Code's ⇧⇥ cycle
94
+ const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
94
95
 
95
96
  const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
96
97
 
@@ -119,16 +120,43 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
119
120
  }
120
121
  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
122
  }
123
+ // ── AskUserQuestion — the agent asks the user a multiple-choice question.
124
+ // It can't run its interactive dialog headless (allowing it errors), so we
125
+ // render the choice card in the room and feed the selection back as the tool
126
+ // outcome. PreToolUse can't inject a tool_result, but a deny's reason IS what
127
+ // the model receives — so we deny and put the answer in the reason.
128
+ if (toolName === 'AskUserQuestion') {
129
+ let decision = ''
130
+ try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'ask', questions: toolInput?.questions || [] }) ?? '' }
131
+ catch { decision = '' }
132
+ const ans = (typeof decision === 'string' && decision.startsWith('answer:')) ? decision.slice(7) : ''
133
+ return {
134
+ continue: true,
135
+ hookSpecificOutput: {
136
+ hookEventName: 'PreToolUse',
137
+ permissionDecision: 'deny',
138
+ permissionDecisionReason: ans
139
+ ? `The user answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
140
+ : 'The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default.',
141
+ },
142
+ }
143
+ }
122
144
  const risk = classifyRisk(toolName, toolInput)
145
+ // "Don't ask again" is keyed by tool + risk tier, so allowing medium Bash
146
+ // never silently allows a future destructive one (high always re-asks).
147
+ const sig = `${toolName}:${risk}`
123
148
  const auto =
124
149
  risk === 'low' ||
125
150
  mode === 'bypassPermissions' ||
126
- (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high')
151
+ (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high') ||
152
+ alwaysAllow.has(sig)
127
153
  let decision = 'allow'
128
154
  if (!auto) {
129
155
  try {
130
156
  decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
131
157
  } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
158
+ // "Allow & don't ask again" — remember the signature, then allow.
159
+ if (decision === 'always') { alwaysAllow.add(sig); decision = 'allow' }
132
160
  }
133
161
  // On deny, permissionDecisionReason IS what the model receives as the
134
162
  // tool error — make it a real instruction, not an opaque tag.
@@ -178,6 +206,17 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
178
206
  case 'result':
179
207
  if (m.session_id) sessionId = m.session_id
180
208
  emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns })
209
+ // Surface a usage/context meter (chrome, not a transcript line). The
210
+ // context window % comes from the control request; cost is cumulative.
211
+ ;(async () => {
212
+ let ctx = null
213
+ try {
214
+ const c = await q?.getContextUsage?.()
215
+ if (c) ctx = { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }
216
+ } catch { /* control req may be unavailable */ }
217
+ const u = m.usage || {}
218
+ emit({ kind: 'usage', costUsd: m.total_cost_usd ?? null, tokens: (u.input_tokens || 0) + (u.output_tokens || 0), ctx })
219
+ })()
181
220
  break
182
221
  default:
183
222
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.6",
3
+ "version": "0.6.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": {