thinkpool-pair 0.7.253 → 0.7.255

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
@@ -2060,9 +2060,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2060
2060
  if (runtime === 'hermes' && !modelCatalogValues(catalog).has(args.model)) {
2061
2061
  return { error: `Could not open a Hermes worker on ${JSON.stringify(args.model)} — that exact model is not in this parent session's ACP catalog.` }
2062
2062
  }
2063
- if (runtime === 'hermes' && args?.mode && !['default', 'acceptEdits'].includes(args.mode)) {
2064
- return { error: `Hermes ACP only exposes default/acceptEdits as user modes; Flow roles use a bridge-owned process-local tool policy.` }
2065
- }
2066
2063
  if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
2067
2064
  return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
2068
2065
  }
@@ -2144,7 +2141,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2144
2141
  // (reset per human turn alongside the in-room budgets) + TP_CROSSROOM_OFF kill-switch.
2145
2142
  tool(
2146
2143
  'list_sessions',
2147
- '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.',
2144
+ '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.',
2148
2145
  {},
2149
2146
  async () => {
2150
2147
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
@@ -2156,7 +2153,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2156
2153
  ),
2157
2154
  tool(
2158
2155
  'read_session',
2159
- '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.',
2156
+ '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.',
2160
2157
  {
2161
2158
  session: z.string().describe('the room code of the other session (from list_sessions)'),
2162
2159
  terminal: z.string().optional().describe('ref, id, or name of a terminal in that room; omit to list that room\'s terminals'),
@@ -3831,6 +3828,9 @@ channel
3831
3828
  return
3832
3829
  }
3833
3830
  s.mode = payload.mode
3831
+ if (payload.mode === 'bypassPermissions') allowPending(s)
3832
+ else if (payload.mode === 'acceptEdits') acceptEditsPending(s)
3833
+ else if (payload.mode === 'plan') drainPending(s)
3834
3834
  s.flush?.()
3835
3835
  announce()
3836
3836
  return
@@ -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.
@@ -39,7 +39,7 @@ def policy():
39
39
  builtin = value.get("builtinTools")
40
40
  required_builtin = value.get("requiredBuiltinTools")
41
41
  tools = value.get("mcpTools")
42
- if role not in {"ordinary", "builder", "conductor", "reviewer", "manual-review"}:
42
+ if role not in {"ordinary", "plan", "builder", "conductor", "reviewer", "manual-review"}:
43
43
  die("unknown role")
44
44
  if value.get("mcpServer") != "thinkpool" or not isinstance(builtin, list) or not isinstance(required_builtin, list) or not isinstance(tools, list):
45
45
  die("invalid tool policy")
@@ -49,14 +49,14 @@ def policy():
49
49
  if forbidden.intersection(builtin) or forbidden.intersection(required_builtin) or forbidden.intersection(tools):
50
50
  die("delegation and session search are forbidden")
51
51
  required_mcp = {
52
- "ordinary": {"read_terminal"}, "builder": {"mark_flow_done"},
52
+ "ordinary": {"read_terminal"}, "plan": set(), "builder": {"mark_flow_done"},
53
53
  "conductor": {"submit_flow_plan"},
54
54
  "reviewer": {"submit_flow_review", "run_review_check", "read_review_file"},
55
55
  "manual-review": {"run_review_check", "read_review_file"},
56
56
  }
57
57
  if not required_mcp[role].issubset(tools):
58
58
  die("incomplete role MCP policy")
59
- restricted = role in {"conductor", "reviewer", "manual-review"}
59
+ restricted = role in {"plan", "conductor", "reviewer", "manual-review"}
60
60
  allowed_builtin = READ_ONLY_TOOLS if restricted else CODING_TOOLS
61
61
  required = READ_ONLY_TOOLS if restricted else ESSENTIAL_CODING_TOOLS
62
62
  if set(builtin) != allowed_builtin:
package/hermes-policy.mjs CHANGED
@@ -1,5 +1,7 @@
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
4
6
 
5
7
  export const CODING_TOOLS = Object.freeze([
@@ -20,7 +22,8 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
20
22
  'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
21
23
  ])
22
24
  const ROLE_REQUIRED = Object.freeze({
23
- ordinary: ['read_terminal', 'spawn_terminal', 'close_terminal'],
25
+ ordinary: [...PEER_MCP_TOOLS, 'spawn_terminal', 'close_terminal'],
26
+ plan: [],
24
27
  conductor: ['submit_flow_plan'],
25
28
  builder: ['mark_flow_done'],
26
29
  reviewer: ['submit_flow_review', 'run_review_check', 'read_review_file'],
@@ -42,9 +45,9 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
42
45
  // Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
43
46
  // enforced by requiredMcpTools at dispatch; keep this schema usable for a
44
47
  // non-delegating ordinary child without widening it.
45
- const minimum = role === 'ordinary' ? ['read_terminal'] : required
48
+ const minimum = role === 'ordinary' ? PEER_MCP_TOOLS : required
46
49
  for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
47
- const restricted = role === 'conductor' || role === 'reviewer' || role === 'manual-review'
50
+ const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
48
51
  const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
49
52
  const requiredBuiltinTools = restricted ? READ_ONLY_TOOLS : ESSENTIAL_CODING_TOOLS
50
53
  return Object.freeze({
@@ -60,10 +63,12 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
60
63
  }
61
64
 
62
65
  export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
63
- if (role === 'ordinary') return ['read_terminal', ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
66
+ if (role === 'ordinary') return [...PEER_MCP_TOOLS, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
64
67
  return [...ROLE_REQUIRED[role]]
65
68
  }
66
69
 
70
+ export const HERMES_PLAN_SAFE_MCP_TOOLS = PEER_READ_MCP_TOOLS
71
+
67
72
  // Keep the bridge's local /tools proof on the same canonical inventory as the
68
73
  // process-local Python bootstrap. `allBuiltinTools` lets that proof reject a
69
74
  // known built-in which is not part of this role, rather than merely looking
@@ -6,14 +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, hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
10
10
  import { startCodexMcpHttp } from './codex-mcp-http.mjs'
11
- import { classifyRisk } from './claude-session.mjs'
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
- export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
16
+ export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
16
17
  const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
18
+ const PLAN_SAFE_MCP_TOOLS = new Set([...HERMES_PLAN_SAFE_MCP_TOOLS, 'read_review_file'])
17
19
 
18
20
  const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
19
21
  const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
@@ -47,6 +49,7 @@ export function startHermesSession({
47
49
  roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
48
50
  command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
49
51
  mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
52
+ crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null,
50
53
  } = {}) {
51
54
  let activeCwd = cwd
52
55
  const requestedModel = model || null
@@ -71,18 +74,67 @@ export function startHermesSession({
71
74
  let inventoryProbe = null
72
75
  let modelSwitchPending = false
73
76
  let bootCancelled = false
77
+ let suppressNextSessionPublish = false
74
78
  const policyRole = hermesRole || hermesRoleFor({})
75
79
 
80
+ const effectivePolicyRole = () => activeMode === 'plan' ? 'plan' : policyRole
81
+ const effectiveMcpTools = () => activeMode === 'plan'
82
+ ? requiredMcpTools.filter((name) => PLAN_SAFE_MCP_TOOLS.has(name))
83
+ : requiredMcpTools
84
+ const acpModeFor = (value) => value === 'acceptEdits'
85
+ ? 'accept_edits'
86
+ : value === 'bypassPermissions'
87
+ ? 'dont_ask'
88
+ : 'default'
89
+
76
90
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
77
91
 
92
+ const thinkpoolPeerTool = (name) => {
93
+ const lower = String(name || '').toLowerCase()
94
+ return [...HERMES_PLAN_SAFE_MCP_TOOLS, 'post_to_terminal', 'post_to_session']
95
+ .find((candidate) => lower === candidate || lower === `mcp__thinkpool__${candidate}` || lower === `mcp_thinkpool_${candidate}`) || null
96
+ }
97
+
78
98
  async function onRequest(method, params, requestId) {
79
99
  if (method !== 'session/request_permission') throw Object.assign(new Error(`Unsupported ACP client method: ${method}`), { code: -32601 })
80
100
  const tool = hermesToolFor(params.toolCall || mapper?.tools.get(params.toolCallId) || {})
101
+ const peerTool = thinkpoolPeerTool(tool.name)
81
102
  const card = {
82
103
  id: `hermes-perm:${requestId}`,
83
- toolName: tool.name,
104
+ toolName: peerTool ? `mcp__thinkpool__${peerTool}` : tool.name,
84
105
  input: tool.input,
85
- risk: classifyRisk(tool.name, tool.input),
106
+ risk: peerTool?.startsWith('post_to_') ? 'high' : classifyRisk(tool.name, tool.input),
107
+ }
108
+ // ACP's native modes currently govern edit proposals only. ThinkPool's
109
+ // permission chip is the cross-runtime authority, so apply the same pure
110
+ // mode policy Claude uses before raising a durable room card. Structural
111
+ // role schemas still win: bypass can auto-allow a request, but it cannot
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
+ if (peerTool === 'post_to_terminal') {
121
+ const gate = crossPostGate ? crossPostGate() : { ok: true }
122
+ if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
123
+ const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(tool.input?.terminal) : false
124
+ if (!crossPostNeedsCard({ mode: activeMode, spawnedByMe })) {
125
+ return { outcome: permissionOutcome('allow', params.options) }
126
+ }
127
+ }
128
+ if (peerTool === 'post_to_session') {
129
+ const gate = crossRoomPostGate ? crossRoomPostGate() : { ok: true }
130
+ if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
131
+ let decision = 'deny'
132
+ try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
133
+ return { outcome: permissionOutcome(decision, params.options) }
134
+ }
135
+ if (activeMode === 'plan') return { outcome: { outcome: 'cancelled' } }
136
+ if (autoAllow({ toolName: card.toolName, input: card.input, mode: activeMode })) {
137
+ return { outcome: permissionOutcome('allow', params.options) }
86
138
  }
87
139
  let decision = 'deny'
88
140
  try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
@@ -103,13 +155,13 @@ export function startHermesSession({
103
155
  }
104
156
 
105
157
  async function probeMcpTools() {
106
- const required = [...new Set((Array.isArray(requiredMcpTools) ? requiredMcpTools : [])
158
+ const required = [...new Set((Array.isArray(effectiveMcpTools()) ? effectiveMcpTools() : [])
107
159
  .map((name) => String(name || '').trim()).filter(Boolean))]
108
160
  // Direct runtime tests and unscoped upstream callers have no bridge role
109
161
  // contract to prove. Every bridge-created Hermes lane supplies its required
110
162
  // MCP list; only those lanes enter the exact-inventory transaction.
111
- if (!required.length) return { inventory: '', missing: [], forbidden: [] }
112
- const exact = hermesExactInventory(policyRole, { mcpTools: required })
163
+ if (!required.length && effectivePolicyRole() !== 'plan') return { inventory: '', missing: [], forbidden: [] }
164
+ const exact = hermesExactInventory(effectivePolicyRole(), { mcpTools: required })
113
165
  const allowedBuiltins = exact.builtinTools
114
166
  const requiredBuiltins = exact.requiredBuiltinTools
115
167
  const expectedMcp = exact.mcpTools
@@ -175,7 +227,7 @@ export function startHermesSession({
175
227
  // profile wrapper. HERMES_HOME is the probe-verified isolated profile.
176
228
  launchCommand = profile.python
177
229
  launchArgs = [profile.bootstrap]
178
- childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(policyRole, { mcpTools: requiredMcpTools }) }
230
+ childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(effectivePolicyRole(), { mcpTools: effectiveMcpTools() }) }
179
231
  }
180
232
  let retired = false
181
233
  retireClient = () => { retired = true }
@@ -271,9 +323,18 @@ export function startHermesSession({
271
323
  const publishedModels = state?.models
272
324
  ? { ...state.models, currentModelId: activeModel }
273
325
  : activeModel ? { currentModelId: activeModel, availableModels: [] } : state?.models
274
- mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
326
+ if (!suppressNextSessionPublish) {
327
+ mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
328
+ } else {
329
+ // A Plan transition recreates only the ACP process so its tool schema
330
+ // can become structurally read-only. Keep the room transcript/catalog
331
+ // stable while the fresh mapper resumes the same native session.
332
+ mapper.sessionId = sessionId
333
+ mapper.model = activeModel
334
+ suppressNextSessionPublish = false
335
+ }
275
336
  started = true
276
- const acpMode = activeMode === 'acceptEdits' ? 'accept_edits' : 'default'
337
+ const acpMode = acpModeFor(activeMode)
277
338
  if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
278
339
  await client.request('session/set_mode', { sessionId, modeId: acpMode })
279
340
  }
@@ -452,7 +513,33 @@ export function startHermesSession({
452
513
  },
453
514
  setMode(nextMode) {
454
515
  if (turnActive || !HERMES_SUPPORTED_MODES.has(nextMode)) return false
455
- void boot().then(() => client.request('session/set_mode', { sessionId, modeId: nextMode === 'acceptEdits' ? 'accept_edits' : 'default' })).then(() => {
516
+ const priorMode = activeMode
517
+ const changesPolicySchema = (priorMode === 'plan') !== (nextMode === 'plan')
518
+ if (changesPolicySchema) {
519
+ // Plan is stronger than an approval preference: it removes terminal,
520
+ // process, write, patch, browser, and execution schemas. ACP modes alone
521
+ // cannot do that, so resume the exact native session in a fresh process
522
+ // under the bridge-owned read-only policy.
523
+ activeMode = nextMode
524
+ suppressNextSessionPublish = started
525
+ if (started) {
526
+ retireClient()
527
+ const oldClient = client
528
+ client = null
529
+ mapper = null
530
+ started = false
531
+ crashed = false
532
+ stderrTail = ''
533
+ oldClient?.end()
534
+ }
535
+ void boot().then(() => emit({ kind: 'mode', mode: activeMode })).catch((error) => {
536
+ activeMode = priorMode
537
+ suppressNextSessionPublish = false
538
+ emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true })
539
+ })
540
+ return true
541
+ }
542
+ void boot().then(() => client.request('session/set_mode', { sessionId, modeId: acpModeFor(nextMode) })).then(() => {
456
543
  activeMode = nextMode
457
544
  emit({ kind: 'mode', mode: activeMode })
458
545
  }).catch((error) => emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.253",
3
+ "version": "0.7.255",
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": {
@@ -14,7 +14,7 @@ const RUNTIMES = Object.freeze({
14
14
  hermes: Object.freeze({
15
15
  id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
16
16
  structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl: false, defaultMode: 'default',
17
- modes: Object.freeze(['default', 'acceptEdits']),
17
+ modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
18
18
  beta: true,
19
19
  }),
20
20
  })