thinkpool-pair 0.7.254 → 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 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',
@@ -2141,7 +2213,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2141
2213
  // (reset per human turn alongside the in-room budgets) + TP_CROSSROOM_OFF kill-switch.
2142
2214
  tool(
2143
2215
  'list_sessions',
2144
- 'List your OTHER ThinkPool Code sessions (rooms) open on this machine — beyond this one. Returns each room\'s code and name so you can read into one with read_session. This is Thinkpool Ensemble across SESSIONS, not just terminals: it lets you see and pick up work in your other rooms. Read-only.',
2216
+ 'List OTHER ThinkPool Code sessions (rooms) reachable from this room: your own rooms on this machine plus your partner\'s shared rooms over the pair bus. Returns each room\'s code, name, and partner host when remote so you can read into one with read_session. This is Thinkpool Ensemble across SESSIONS, not just terminals. Read-only.',
2145
2217
  {},
2146
2218
  async () => {
2147
2219
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
@@ -2153,7 +2225,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2153
2225
  ),
2154
2226
  tool(
2155
2227
  'read_session',
2156
- 'Read-only view INTO another of your ThinkPool Code sessions on this machine (a room code from list_sessions). Pass `session` (the room code) and optionally `terminal` (a ref/name in that room) to read its recent activity; omit `terminal` to list that room\'s terminals. It never changes the other session — reading only. Use it to check on or pick up work running in a sibling session.',
2228
+ 'Read-only view INTO another ThinkPool Code session returned by list_sessions—either your own room on this machine or your partner\'s shared room over the pair bus. Pass `session` (the room code) and optionally `terminal` (a ref/name in that room) to read its recent activity; omit `terminal` to list that room\'s terminals. It never changes the other session.',
2157
2229
  {
2158
2230
  session: z.string().describe('the room code of the other session (from list_sessions)'),
2159
2231
  terminal: z.string().optional().describe('ref, id, or name of a terminal in that room; omit to list that room\'s terminals'),
@@ -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
  }
@@ -606,6 +606,26 @@ export const CROSSROOM = {
606
606
  peekPerTurnCap: 5, // list_sessions + read_session calls allowed per turn (vs PEEK.perTurnCap=10 in-room)
607
607
  }
608
608
 
609
+ // Canonical ThinkPool peer capability names. Claude/Codex receive the SDK MCP
610
+ // server directly; Hermes reconstructs a process-local schema and therefore
611
+ // needs the same names explicitly. Keep the peer surface here beside its gates
612
+ // so a runtime cannot silently lose cross-room reach when the server grows.
613
+ export const PEER_READ_MCP_TOOLS = Object.freeze([
614
+ 'read_terminal',
615
+ 'list_sessions',
616
+ 'read_session',
617
+ ])
618
+
619
+ export const PEER_POST_MCP_TOOLS = Object.freeze([
620
+ 'post_to_terminal',
621
+ 'post_to_session',
622
+ ])
623
+
624
+ export const PEER_MCP_TOOLS = Object.freeze([
625
+ ...PEER_READ_MCP_TOOLS,
626
+ ...PEER_POST_MCP_TOOLS,
627
+ ])
628
+
609
629
  // Render the list_sessions roster from the supervisor's room list. `rooms` is
610
630
  // [{ code, name }]; `thisRoom` is excluded defensively (the supervisor already
611
631
  // drops it). Mirrors formatPeek's no-arg roster shape so the agent reads one voice.
package/hermes-policy.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  // Bridge-owned Hermes ACP schema policy. This is intentionally a small, pure
2
2
  // contract: Python receives only this JSON and fails closed for anything else.
3
+ import { PEER_MCP_TOOLS, PEER_READ_MCP_TOOLS } from './cross-terminal.mjs'
4
+
3
5
  export const HERMES_POLICY_VERSION = 1
6
+ export const HERMES_QUESTION_MCP_TOOL = 'request_user_input'
4
7
 
5
8
  export const CODING_TOOLS = Object.freeze([
6
9
  'web_search', 'web_extract', 'terminal', 'process', 'read_file', 'write_file',
@@ -20,7 +23,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
20
23
  'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
21
24
  ])
22
25
  const ROLE_REQUIRED = Object.freeze({
23
- ordinary: ['read_terminal', 'spawn_terminal', 'close_terminal'],
26
+ ordinary: [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, 'spawn_terminal', 'close_terminal'],
24
27
  plan: [],
25
28
  conductor: ['submit_flow_plan'],
26
29
  builder: ['mark_flow_done'],
@@ -43,7 +46,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
43
46
  // Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
44
47
  // enforced by requiredMcpTools at dispatch; keep this schema usable for a
45
48
  // non-delegating ordinary child without widening it.
46
- const minimum = role === 'ordinary' ? ['read_terminal'] : required
49
+ const minimum = role === 'ordinary' ? [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL] : required
47
50
  for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
48
51
  const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
49
52
  const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
@@ -61,10 +64,12 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
61
64
  }
62
65
 
63
66
  export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
64
- if (role === 'ordinary') return ['read_terminal', ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
67
+ if (role === 'ordinary') return [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
65
68
  return [...ROLE_REQUIRED[role]]
66
69
  }
67
70
 
71
+ export const HERMES_PLAN_SAFE_MCP_TOOLS = PEER_READ_MCP_TOOLS
72
+
68
73
  // Keep the bridge's local /tools proof on the same canonical inventory as the
69
74
  // process-local Python bootstrap. `allBuiltinTools` lets that proof reject a
70
75
  // known built-in which is not part of this role, rather than merely looking
@@ -6,15 +6,16 @@ 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 { 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
+ import { crossPostNeedsCard } from './cross-terminal.mjs'
12
13
 
13
14
  export const HERMES_COMMAND = 'thinkpool'
14
15
  export const HERMES_ACP_PROTOCOL_VERSION = 1
15
16
  export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
16
17
  const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
17
- const PLAN_SAFE_MCP_TOOLS = new Set(['read_terminal', 'read_review_file'])
18
+ const PLAN_SAFE_MCP_TOOLS = new Set([...HERMES_PLAN_SAFE_MCP_TOOLS, 'read_review_file'])
18
19
 
19
20
  const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
20
21
  const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
@@ -48,6 +49,7 @@ export function startHermesSession({
48
49
  roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
49
50
  command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
50
51
  mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
52
+ crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null,
51
53
  } = {}) {
52
54
  let activeCwd = cwd
53
55
  const requestedModel = model || null
@@ -87,20 +89,54 @@ export function startHermesSession({
87
89
 
88
90
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
89
91
 
92
+ const thinkpoolPeerTool = (name) => {
93
+ const lower = String(name || '').toLowerCase()
94
+ return [...HERMES_PLAN_SAFE_MCP_TOOLS, 'post_to_terminal', 'post_to_session', HERMES_QUESTION_MCP_TOOL]
95
+ .find((candidate) => lower === candidate || lower === `mcp__thinkpool__${candidate}` || lower === `mcp_thinkpool_${candidate}`) || null
96
+ }
97
+
90
98
  async function onRequest(method, params, requestId) {
91
99
  if (method !== 'session/request_permission') throw Object.assign(new Error(`Unsupported ACP client method: ${method}`), { code: -32601 })
92
100
  const tool = hermesToolFor(params.toolCall || mapper?.tools.get(params.toolCallId) || {})
101
+ const peerTool = thinkpoolPeerTool(tool.name)
93
102
  const card = {
94
103
  id: `hermes-perm:${requestId}`,
95
- toolName: tool.name,
104
+ toolName: peerTool ? `mcp__thinkpool__${peerTool}` : tool.name,
96
105
  input: tool.input,
97
- risk: classifyRisk(tool.name, tool.input),
106
+ risk: peerTool?.startsWith('post_to_') ? 'high' : classifyRisk(tool.name, tool.input),
98
107
  }
99
108
  // ACP's native modes currently govern edit proposals only. ThinkPool's
100
109
  // permission chip is the cross-runtime authority, so apply the same pure
101
110
  // mode policy Claude uses before raising a durable room card. Structural
102
111
  // role schemas still win: bypass can auto-allow a request, but it cannot
103
112
  // restore a tool that the process-local Hermes policy never exposed.
113
+ // Match Claude/Codex peer semantics before the generic permission matrix.
114
+ // Paired-room reads are always read-only, including in Plan. Same-room posts
115
+ // honor bypass/ownership after their bridge gate. Cross-room posts always
116
+ // retain sender consent—even in bypass—because the target has a second card.
117
+ if (HERMES_PLAN_SAFE_MCP_TOOLS.includes(peerTool)) {
118
+ return { outcome: permissionOutcome('allow', params.options) }
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
+ }
125
+ if (peerTool === 'post_to_terminal') {
126
+ const gate = crossPostGate ? crossPostGate() : { ok: true }
127
+ if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
128
+ const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(tool.input?.terminal) : false
129
+ if (!crossPostNeedsCard({ mode: activeMode, spawnedByMe })) {
130
+ return { outcome: permissionOutcome('allow', params.options) }
131
+ }
132
+ }
133
+ if (peerTool === 'post_to_session') {
134
+ const gate = crossRoomPostGate ? crossRoomPostGate() : { ok: true }
135
+ if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
136
+ let decision = 'deny'
137
+ try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
138
+ return { outcome: permissionOutcome(decision, params.options) }
139
+ }
104
140
  if (activeMode === 'plan') return { outcome: { outcome: 'cancelled' } }
105
141
  if (autoAllow({ toolName: card.toolName, input: card.input, mode: activeMode })) {
106
142
  return { outcome: permissionOutcome('allow', params.options) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.254",
3
+ "version": "0.7.256",
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": {