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 +3 -3
- package/hermes-acp-bootstrap.py +3 -3
- package/hermes-policy.mjs +2 -1
- package/hermes-session.mjs +65 -9
- package/package.json +1 -1
- package/runtime-registry.mjs +1 -1
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
|
package/hermes-acp-bootstrap.py
CHANGED
|
@@ -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({
|
package/hermes-session.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
package/runtime-registry.mjs
CHANGED
|
@@ -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
|
})
|