thinkpool-pair 0.7.255 → 0.7.256
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 +74 -44
- package/hermes-policy.mjs +4 -3
- package/hermes-session.mjs +7 -2
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -2103,6 +2103,43 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2103
2103
|
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > now - SPAWN.windowMs), now]
|
|
2104
2104
|
return gate
|
|
2105
2105
|
}
|
|
2106
|
+
const requestRoomDecision = (req) => new Promise((resolve) => {
|
|
2107
|
+
// FLOW conductor plan → intercept (no generic card). The plan JSON rides
|
|
2108
|
+
// ExitPlanMode's `plan` arg. Broadcast to the room; its useFlow persists the
|
|
2109
|
+
// task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
|
|
2110
|
+
// the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
|
|
2111
|
+
if (entry.flowSessionId && req.risk === 'plan') {
|
|
2112
|
+
bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' }, flowChannel)
|
|
2113
|
+
process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
|
|
2114
|
+
resolve('keep')
|
|
2115
|
+
return
|
|
2116
|
+
}
|
|
2117
|
+
// Keep the broadcast payload with the resolver so a reconnect can re-send it
|
|
2118
|
+
// (replay-request handler) — pending cards never enter the replayed event log.
|
|
2119
|
+
const payload = attachDurablePermissionSource(entry, { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions, asker: req.asker, answerFormat: req.answerFormat, autoResolutionMs: req.autoResolutionMs })
|
|
2120
|
+
const pending = { resolve, payload, timer: null }
|
|
2121
|
+
entry.pending.set(req.id, pending)
|
|
2122
|
+
announce()
|
|
2123
|
+
bcast('code-perm-req', payload)
|
|
2124
|
+
if (isUserFacingLane(entry)) entry.permNotifier?.arm(req.id, permissionSummary(payload))
|
|
2125
|
+
process.stderr.write(req.risk === 'plan'
|
|
2126
|
+
? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
|
|
2127
|
+
: req.risk === 'ask'
|
|
2128
|
+
? `\n ${A.cyan}● ${req.asker || structuredRuntimeMetadata(runtime)?.label || 'Agent'} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
|
|
2129
|
+
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
2130
|
+
const autoResolutionMs = Number(req.autoResolutionMs)
|
|
2131
|
+
if (req.risk === 'ask' && Number.isFinite(autoResolutionMs) && autoResolutionMs > 0) {
|
|
2132
|
+
pending.timer = setTimeout(() => {
|
|
2133
|
+
if (entry.pending.get(req.id) !== pending) return
|
|
2134
|
+
entry.pending.delete(req.id)
|
|
2135
|
+
entry.permNotifier?.resolve(req.id)
|
|
2136
|
+
bcast('code-perm', { term: id, id: req.id, decision: 'timeout', name: req.asker || 'agent' })
|
|
2137
|
+
try { resolve(pendingResolution(pending, { decision: 'timeout', answers: {} })) } catch { /* noop */ }
|
|
2138
|
+
announce()
|
|
2139
|
+
}, autoResolutionMs)
|
|
2140
|
+
pending.timer.unref?.()
|
|
2141
|
+
}
|
|
2142
|
+
})
|
|
2106
2143
|
// B2 cross-terminal peek — an in-process, READ-ONLY tool the agent can call to
|
|
2107
2144
|
// see a SIBLING terminal's recent output (vs B1's always-on @pool digest). The
|
|
2108
2145
|
// handler closes over the module-scope `sessions`/`terms` Maps + this session's
|
|
@@ -2113,6 +2150,41 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2113
2150
|
name: 'thinkpool',
|
|
2114
2151
|
version: '1.0.0',
|
|
2115
2152
|
tools: [
|
|
2153
|
+
...(runtime === 'hermes' && hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }) === 'ordinary' ? [tool(
|
|
2154
|
+
'request_user_input',
|
|
2155
|
+
'Ask the people in this ThinkPool room one to three multiple-choice questions and wait for their answer. Use this for choices that genuinely block useful progress. Each question needs two or three mutually exclusive options; a free-text answer remains available in the room card.',
|
|
2156
|
+
{
|
|
2157
|
+
questions: z.array(z.object({
|
|
2158
|
+
id: z.string().min(1).max(80),
|
|
2159
|
+
header: z.string().min(1).max(12),
|
|
2160
|
+
question: z.string().min(1).max(500),
|
|
2161
|
+
options: z.array(z.object({
|
|
2162
|
+
label: z.string().min(1).max(80),
|
|
2163
|
+
description: z.string().min(1).max(240),
|
|
2164
|
+
})).min(2).max(3),
|
|
2165
|
+
multiSelect: z.boolean().optional(),
|
|
2166
|
+
isSecret: z.boolean().optional(),
|
|
2167
|
+
})).min(1).max(3),
|
|
2168
|
+
autoResolutionMs: z.number().int().min(60_000).max(240_000).optional(),
|
|
2169
|
+
},
|
|
2170
|
+
async (args) => {
|
|
2171
|
+
const questions = args.questions.map((question) => ({ ...question, options: question.options.map((option) => ({ ...option })) }))
|
|
2172
|
+
const result = await requestRoomDecision({
|
|
2173
|
+
id: `hermes-question:${randomUUID()}`,
|
|
2174
|
+
toolName: 'AskUserQuestion',
|
|
2175
|
+
input: { questions },
|
|
2176
|
+
risk: 'ask',
|
|
2177
|
+
questions,
|
|
2178
|
+
asker: 'Hermes',
|
|
2179
|
+
answerFormat: 'hermes',
|
|
2180
|
+
autoResolutionMs: args.autoResolutionMs,
|
|
2181
|
+
})
|
|
2182
|
+
const decision = result && typeof result === 'object' ? result.decision : result
|
|
2183
|
+
const answers = result && typeof result === 'object' && result.answers && typeof result.answers === 'object' ? result.answers : {}
|
|
2184
|
+
const status = String(decision || '').startsWith('answer:') ? 'answered' : decision === 'timeout' ? 'timed_out' : 'dismissed'
|
|
2185
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status, answers }) }] }
|
|
2186
|
+
},
|
|
2187
|
+
)] : []),
|
|
2116
2188
|
...createViewportTools({ tool, z, manager: entry.viewport }),
|
|
2117
2189
|
tool(
|
|
2118
2190
|
'read_terminal',
|
|
@@ -2963,49 +3035,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2963
3035
|
const imgs = inlineImageBlocks(evt)
|
|
2964
3036
|
deferImageEvent(entry, id, evt, imgs, emitTail)
|
|
2965
3037
|
},
|
|
2966
|
-
requestPermission:
|
|
2967
|
-
// FLOW conductor plan → intercept (no generic card). The plan JSON rides
|
|
2968
|
-
// ExitPlanMode's `plan` arg. Broadcast to the room; its useFlow persists the
|
|
2969
|
-
// task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
|
|
2970
|
-
// the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
|
|
2971
|
-
if (entry.flowSessionId && req.risk === 'plan') {
|
|
2972
|
-
bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' }, flowChannel)
|
|
2973
|
-
process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
|
|
2974
|
-
resolve('keep')
|
|
2975
|
-
return
|
|
2976
|
-
}
|
|
2977
|
-
// Keep the broadcast payload with the resolver so a reconnect can re-send it
|
|
2978
|
-
// (replay-request handler) — pending cards never enter the replayed event log.
|
|
2979
|
-
const payload = attachDurablePermissionSource(entry, { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions, asker: req.asker, answerFormat: req.answerFormat, autoResolutionMs: req.autoResolutionMs })
|
|
2980
|
-
const pending = { resolve, payload, timer: null }
|
|
2981
|
-
entry.pending.set(req.id, pending)
|
|
2982
|
-
announce()
|
|
2983
|
-
bcast('code-perm-req', payload)
|
|
2984
|
-
// Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
|
|
2985
|
-
// STILL unanswered 20s from now (you're not in the room). Every resolve path —
|
|
2986
|
-
// the code-perm broadcast, drainPending, and the bypass/acceptEdits mode-flip
|
|
2987
|
-
// auto-approvals — calls permNotifier.resolve(id), which retracts the banner
|
|
2988
|
-
// if it had fired. Worker + flow-slice lanes are skipped: their cards belong to
|
|
2989
|
-
// an agent's fan-out, and a 12-lane flow would fan 12 banners at one person.
|
|
2990
|
-
if (isUserFacingLane(entry)) entry.permNotifier?.arm(req.id, permissionSummary(payload))
|
|
2991
|
-
process.stderr.write(req.risk === 'plan'
|
|
2992
|
-
? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
|
|
2993
|
-
: req.risk === 'ask'
|
|
2994
|
-
? `\n ${A.cyan}● ${req.asker || structuredRuntimeMetadata(runtime)?.label || 'Agent'} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
|
|
2995
|
-
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
2996
|
-
const autoResolutionMs = Number(req.autoResolutionMs)
|
|
2997
|
-
if (req.risk === 'ask' && Number.isFinite(autoResolutionMs) && autoResolutionMs > 0) {
|
|
2998
|
-
pending.timer = setTimeout(() => {
|
|
2999
|
-
if (entry.pending.get(req.id) !== pending) return
|
|
3000
|
-
entry.pending.delete(req.id)
|
|
3001
|
-
entry.permNotifier?.resolve(req.id)
|
|
3002
|
-
bcast('code-perm', { term: id, id: req.id, decision: 'timeout', name: req.asker || 'agent' })
|
|
3003
|
-
try { resolve(pendingResolution(pending, { decision: 'timeout', answers: {} })) } catch { /* noop */ }
|
|
3004
|
-
announce()
|
|
3005
|
-
}, autoResolutionMs)
|
|
3006
|
-
pending.timer.unref?.()
|
|
3007
|
-
}
|
|
3008
|
-
}),
|
|
3038
|
+
requestPermission: requestRoomDecision,
|
|
3009
3039
|
})
|
|
3010
3040
|
// A restored Codex lane may have a stale pre-0.7.206 meter persisted from the
|
|
3011
3041
|
// cumulative `turn.completed` bug. Replace it synchronously from the native
|
|
@@ -3043,7 +3073,7 @@ function pendingResolution(pending, payload = {}) {
|
|
|
3043
3073
|
const decision = payload.decision || 'deny'
|
|
3044
3074
|
return pending?.payload?.answerFormat === 'dispatch'
|
|
3045
3075
|
? { decision, dispatchFingerprint: payload.dispatchFingerprint || null, controlItemId: payload.controlItemId || null }
|
|
3046
|
-
: pending?.payload?.answerFormat === 'codex'
|
|
3076
|
+
: (pending?.payload?.answerFormat === 'codex' || pending?.payload?.answerFormat === 'hermes')
|
|
3047
3077
|
? { decision, answers: payload.answers || {} }
|
|
3048
3078
|
: decision
|
|
3049
3079
|
}
|
package/hermes-policy.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { PEER_MCP_TOOLS, PEER_READ_MCP_TOOLS } from './cross-terminal.mjs'
|
|
4
4
|
|
|
5
5
|
export const HERMES_POLICY_VERSION = 1
|
|
6
|
+
export const HERMES_QUESTION_MCP_TOOL = 'request_user_input'
|
|
6
7
|
|
|
7
8
|
export const CODING_TOOLS = Object.freeze([
|
|
8
9
|
'web_search', 'web_extract', 'terminal', 'process', 'read_file', 'write_file',
|
|
@@ -22,7 +23,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
|
|
|
22
23
|
'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
|
|
23
24
|
])
|
|
24
25
|
const ROLE_REQUIRED = Object.freeze({
|
|
25
|
-
ordinary: [...PEER_MCP_TOOLS, 'spawn_terminal', 'close_terminal'],
|
|
26
|
+
ordinary: [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, 'spawn_terminal', 'close_terminal'],
|
|
26
27
|
plan: [],
|
|
27
28
|
conductor: ['submit_flow_plan'],
|
|
28
29
|
builder: ['mark_flow_done'],
|
|
@@ -45,7 +46,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
|
|
|
45
46
|
// Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
|
|
46
47
|
// enforced by requiredMcpTools at dispatch; keep this schema usable for a
|
|
47
48
|
// non-delegating ordinary child without widening it.
|
|
48
|
-
const minimum = role === 'ordinary' ? PEER_MCP_TOOLS : required
|
|
49
|
+
const minimum = role === 'ordinary' ? [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL] : required
|
|
49
50
|
for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
|
|
50
51
|
const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
|
|
51
52
|
const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
|
|
@@ -63,7 +64,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
|
|
66
|
-
if (role === 'ordinary') return [...PEER_MCP_TOOLS, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
|
|
67
|
+
if (role === 'ordinary') return [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
|
|
67
68
|
return [...ROLE_REQUIRED[role]]
|
|
68
69
|
}
|
|
69
70
|
|
package/hermes-session.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
6
6
|
import { AcpClient } from './acp-client.mjs'
|
|
7
7
|
import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
|
|
8
8
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
9
|
-
import { HERMES_PLAN_SAFE_MCP_TOOLS, hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
9
|
+
import { HERMES_PLAN_SAFE_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
10
10
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
11
11
|
import { autoAllow, classifyRisk } from './claude-session.mjs'
|
|
12
12
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
@@ -91,7 +91,7 @@ export function startHermesSession({
|
|
|
91
91
|
|
|
92
92
|
const thinkpoolPeerTool = (name) => {
|
|
93
93
|
const lower = String(name || '').toLowerCase()
|
|
94
|
-
return [...HERMES_PLAN_SAFE_MCP_TOOLS, 'post_to_terminal', 'post_to_session']
|
|
94
|
+
return [...HERMES_PLAN_SAFE_MCP_TOOLS, 'post_to_terminal', 'post_to_session', HERMES_QUESTION_MCP_TOOL]
|
|
95
95
|
.find((candidate) => lower === candidate || lower === `mcp__thinkpool__${candidate}` || lower === `mcp_thinkpool_${candidate}`) || null
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -117,6 +117,11 @@ export function startHermesSession({
|
|
|
117
117
|
if (HERMES_PLAN_SAFE_MCP_TOOLS.includes(peerTool)) {
|
|
118
118
|
return { outcome: permissionOutcome('allow', params.options) }
|
|
119
119
|
}
|
|
120
|
+
// The question tool creates its own durable human-input card. Requiring a
|
|
121
|
+
// permission card before it can create that card deadlocks the interaction.
|
|
122
|
+
if (peerTool === HERMES_QUESTION_MCP_TOOL) {
|
|
123
|
+
return { outcome: permissionOutcome('allow', params.options) }
|
|
124
|
+
}
|
|
120
125
|
if (peerTool === 'post_to_terminal') {
|
|
121
126
|
const gate = crossPostGate ? crossPostGate() : { ok: true }
|
|
122
127
|
if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
|