thinkpool-pair 0.7.255 → 0.7.257

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
@@ -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: (req) => new Promise((resolve) => {
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
  }
@@ -145,6 +145,57 @@ def constrained_expand(toolsets_arg=None, mcp_server_names=None):
145
145
  acp_adapter.session._expand_acp_enabled_toolsets = constrained_expand
146
146
 
147
147
  import acp_adapter.server
148
+
149
+ # Hermes 0.18.2 emits the real result through ``tool.completed`` immediately,
150
+ # but its ACP callback ignores that event and waits for the next model-step
151
+ # summary. Parallel Read/search calls are not always present in that summary,
152
+ # leaving clients with a ToolCallStart and no ToolCallUpdate forever. Complete
153
+ # from the authoritative execution callback and consume the same per-name FIFO;
154
+ # the later step callback then sees an empty queue and cannot double-emit. If a
155
+ # future Hermes release handles completion itself, it consumes the queue before
156
+ # this wrapper runs and this compatibility branch becomes a no-op.
157
+ from collections import deque
158
+ import acp_adapter.events
159
+ import acp_adapter.tools
160
+
161
+ _tool_progress_factory = acp_adapter.server.make_tool_progress_cb
162
+ def completion_aware_tool_progress_factory(
163
+ conn, session_id, loop, tool_call_ids, tool_call_meta,
164
+ edit_approval_policy_getter=None,
165
+ ):
166
+ upstream = _tool_progress_factory(
167
+ conn, session_id, loop, tool_call_ids, tool_call_meta,
168
+ edit_approval_policy_getter=edit_approval_policy_getter,
169
+ )
170
+
171
+ def progress(event_type, name=None, preview=None, args=None, **kwargs):
172
+ upstream(event_type, name, preview, args, **kwargs)
173
+ if event_type != "tool.completed" or not name:
174
+ return
175
+ queue = tool_call_ids.get(name)
176
+ if isinstance(queue, str):
177
+ queue = deque([queue])
178
+ tool_call_ids[name] = queue
179
+ if not queue:
180
+ return
181
+ tool_call_id = queue.popleft()
182
+ meta = tool_call_meta.pop(tool_call_id, {})
183
+ result = kwargs.get("result")
184
+ update = acp_adapter.tools.build_tool_complete(
185
+ tool_call_id,
186
+ name,
187
+ result=str(result) if result is not None else None,
188
+ function_args=meta.get("args"),
189
+ snapshot=meta.get("snapshot"),
190
+ )
191
+ acp_adapter.events._send_update(conn, session_id, loop, update)
192
+ if not queue:
193
+ tool_call_ids.pop(name, None)
194
+
195
+ return progress
196
+
197
+ acp_adapter.server.make_tool_progress_cb = completion_aware_tool_progress_factory
198
+
148
199
  _register = acp_adapter.server.HermesACPAgent._register_session_mcp_servers
149
200
  async def constrained_register(self, state, mcp_servers):
150
201
  if any(getattr(server, "name", None) != "thinkpool" for server in (mcp_servers or [])):
@@ -128,16 +128,17 @@ export class HermesEventMapper {
128
128
  }
129
129
 
130
130
  finishTurn({ stopReason = 'end_turn', usage = null } = {}) {
131
- if (stopReason === 'cancelled') {
132
- for (const [toolCallId, tool] of this.tools) {
133
- this._emit({
134
- kind: 'tool_result', toolUseId: toolCallId,
135
- content: [{ type: 'text', text: '[cancelled]' }],
136
- isError: false,
137
- durationMs: tool.startedAt ? Date.now() - tool.startedAt : undefined,
138
- parentToolUseId: null,
139
- })
140
- }
131
+ for (const [toolCallId, tool] of this.tools) {
132
+ const cancelled = stopReason === 'cancelled'
133
+ this._emit({
134
+ kind: 'tool_result', toolUseId: toolCallId,
135
+ content: [{ type: 'text', text: cancelled ? '[cancelled]' : 'Hermes ended the turn without reporting this tool result.' }],
136
+ toolInput: tool.input || undefined,
137
+ isError: !cancelled && stopReason !== 'end_turn',
138
+ missing: !cancelled,
139
+ durationMs: tool.startedAt ? Date.now() - tool.startedAt : undefined,
140
+ parentToolUseId: null,
141
+ })
141
142
  }
142
143
  this.tools.clear()
143
144
  const blocks = []
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
 
@@ -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' } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.255",
3
+ "version": "0.7.257",
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": {