thinkpool-pair 0.7.253 → 0.7.254

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
  }
@@ -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
@@ -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
@@ -21,6 +21,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
21
21
  ])
22
22
  const ROLE_REQUIRED = Object.freeze({
23
23
  ordinary: ['read_terminal', 'spawn_terminal', 'close_terminal'],
24
+ plan: [],
24
25
  conductor: ['submit_flow_plan'],
25
26
  builder: ['mark_flow_done'],
26
27
  reviewer: ['submit_flow_review', 'run_review_check', 'read_review_file'],
@@ -44,7 +45,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
44
45
  // non-delegating ordinary child without widening it.
45
46
  const minimum = role === 'ordinary' ? ['read_terminal'] : required
46
47
  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'
48
+ const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
48
49
  const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
49
50
  const requiredBuiltinTools = restricted ? READ_ONLY_TOOLS : ESSENTIAL_CODING_TOOLS
50
51
  return Object.freeze({
@@ -8,12 +8,13 @@ import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
8
8
  import { probeHermesRuntime } from './hermes-probe.mjs'
9
9
  import { 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
12
 
13
13
  export const HERMES_COMMAND = 'thinkpool'
14
14
  export const HERMES_ACP_PROTOCOL_VERSION = 1
15
- export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
15
+ export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
16
16
  const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
17
+ const PLAN_SAFE_MCP_TOOLS = new Set(['read_terminal', 'read_review_file'])
17
18
 
18
19
  const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
19
20
  const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
@@ -71,8 +72,19 @@ export function startHermesSession({
71
72
  let inventoryProbe = null
72
73
  let modelSwitchPending = false
73
74
  let bootCancelled = false
75
+ let suppressNextSessionPublish = false
74
76
  const policyRole = hermesRole || hermesRoleFor({})
75
77
 
78
+ const effectivePolicyRole = () => activeMode === 'plan' ? 'plan' : policyRole
79
+ const effectiveMcpTools = () => activeMode === 'plan'
80
+ ? requiredMcpTools.filter((name) => PLAN_SAFE_MCP_TOOLS.has(name))
81
+ : requiredMcpTools
82
+ const acpModeFor = (value) => value === 'acceptEdits'
83
+ ? 'accept_edits'
84
+ : value === 'bypassPermissions'
85
+ ? 'dont_ask'
86
+ : 'default'
87
+
76
88
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
77
89
 
78
90
  async function onRequest(method, params, requestId) {
@@ -84,6 +96,15 @@ export function startHermesSession({
84
96
  input: tool.input,
85
97
  risk: classifyRisk(tool.name, tool.input),
86
98
  }
99
+ // ACP's native modes currently govern edit proposals only. ThinkPool's
100
+ // permission chip is the cross-runtime authority, so apply the same pure
101
+ // mode policy Claude uses before raising a durable room card. Structural
102
+ // role schemas still win: bypass can auto-allow a request, but it cannot
103
+ // restore a tool that the process-local Hermes policy never exposed.
104
+ if (activeMode === 'plan') return { outcome: { outcome: 'cancelled' } }
105
+ if (autoAllow({ toolName: card.toolName, input: card.input, mode: activeMode })) {
106
+ return { outcome: permissionOutcome('allow', params.options) }
107
+ }
87
108
  let decision = 'deny'
88
109
  try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
89
110
  return { outcome: permissionOutcome(decision, params.options) }
@@ -103,13 +124,13 @@ export function startHermesSession({
103
124
  }
104
125
 
105
126
  async function probeMcpTools() {
106
- const required = [...new Set((Array.isArray(requiredMcpTools) ? requiredMcpTools : [])
127
+ const required = [...new Set((Array.isArray(effectiveMcpTools()) ? effectiveMcpTools() : [])
107
128
  .map((name) => String(name || '').trim()).filter(Boolean))]
108
129
  // Direct runtime tests and unscoped upstream callers have no bridge role
109
130
  // contract to prove. Every bridge-created Hermes lane supplies its required
110
131
  // 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 })
132
+ if (!required.length && effectivePolicyRole() !== 'plan') return { inventory: '', missing: [], forbidden: [] }
133
+ const exact = hermesExactInventory(effectivePolicyRole(), { mcpTools: required })
113
134
  const allowedBuiltins = exact.builtinTools
114
135
  const requiredBuiltins = exact.requiredBuiltinTools
115
136
  const expectedMcp = exact.mcpTools
@@ -175,7 +196,7 @@ export function startHermesSession({
175
196
  // profile wrapper. HERMES_HOME is the probe-verified isolated profile.
176
197
  launchCommand = profile.python
177
198
  launchArgs = [profile.bootstrap]
178
- childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(policyRole, { mcpTools: requiredMcpTools }) }
199
+ childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(effectivePolicyRole(), { mcpTools: effectiveMcpTools() }) }
179
200
  }
180
201
  let retired = false
181
202
  retireClient = () => { retired = true }
@@ -271,9 +292,18 @@ export function startHermesSession({
271
292
  const publishedModels = state?.models
272
293
  ? { ...state.models, currentModelId: activeModel }
273
294
  : activeModel ? { currentModelId: activeModel, availableModels: [] } : state?.models
274
- mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
295
+ if (!suppressNextSessionPublish) {
296
+ mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
297
+ } else {
298
+ // A Plan transition recreates only the ACP process so its tool schema
299
+ // can become structurally read-only. Keep the room transcript/catalog
300
+ // stable while the fresh mapper resumes the same native session.
301
+ mapper.sessionId = sessionId
302
+ mapper.model = activeModel
303
+ suppressNextSessionPublish = false
304
+ }
275
305
  started = true
276
- const acpMode = activeMode === 'acceptEdits' ? 'accept_edits' : 'default'
306
+ const acpMode = acpModeFor(activeMode)
277
307
  if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
278
308
  await client.request('session/set_mode', { sessionId, modeId: acpMode })
279
309
  }
@@ -452,7 +482,33 @@ export function startHermesSession({
452
482
  },
453
483
  setMode(nextMode) {
454
484
  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(() => {
485
+ const priorMode = activeMode
486
+ const changesPolicySchema = (priorMode === 'plan') !== (nextMode === 'plan')
487
+ if (changesPolicySchema) {
488
+ // Plan is stronger than an approval preference: it removes terminal,
489
+ // process, write, patch, browser, and execution schemas. ACP modes alone
490
+ // cannot do that, so resume the exact native session in a fresh process
491
+ // under the bridge-owned read-only policy.
492
+ activeMode = nextMode
493
+ suppressNextSessionPublish = started
494
+ if (started) {
495
+ retireClient()
496
+ const oldClient = client
497
+ client = null
498
+ mapper = null
499
+ started = false
500
+ crashed = false
501
+ stderrTail = ''
502
+ oldClient?.end()
503
+ }
504
+ void boot().then(() => emit({ kind: 'mode', mode: activeMode })).catch((error) => {
505
+ activeMode = priorMode
506
+ suppressNextSessionPublish = false
507
+ emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true })
508
+ })
509
+ return true
510
+ }
511
+ void boot().then(() => client.request('session/set_mode', { sessionId, modeId: acpModeFor(nextMode) })).then(() => {
456
512
  activeMode = nextMode
457
513
  emit({ kind: 'mode', mode: activeMode })
458
514
  }).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.254",
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
  })