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 +13 -5
- package/claude-session.mjs +40 -1
- package/package.json +1 -1
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
|
-
|
|
374
|
-
|
|
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
|
-
:
|
|
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()
|
package/claude-session.mjs
CHANGED
|
@@ -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
|