thinkpool-pair 0.7.364 → 0.7.366
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/hermes-session.mjs
CHANGED
|
@@ -1,683 +1 @@
|
|
|
1
|
-
// Hermes Agent structured runtime over the official ACP stdio transport.
|
|
2
|
-
// Implements the same narrow session contract as claude-session/codex-session.
|
|
3
|
-
import fs from 'node:fs'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { randomUUID } from 'node:crypto'
|
|
6
|
-
import { AcpClient } from './acp-client.mjs'
|
|
7
|
-
import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
|
|
8
|
-
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
9
|
-
import { HERMES_PLAN_SAFE_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
10
|
-
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
11
|
-
import { autoAllow, classifyRisk } from './claude-session.mjs'
|
|
12
|
-
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
13
|
-
import { buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
14
|
-
import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
|
|
15
|
-
|
|
16
|
-
export const HERMES_COMMAND = 'thinkpool'
|
|
17
|
-
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
18
|
-
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
19
|
-
export const HERMES_EFFORT_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
20
|
-
export const HERMES_EFFORT_CONFIG_ID = 'thinkpool_reasoning_effort'
|
|
21
|
-
const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
|
|
22
|
-
const PLAN_SAFE_MCP_TOOLS = new Set([...HERMES_PLAN_SAFE_MCP_TOOLS, 'read_review_file'])
|
|
23
|
-
|
|
24
|
-
const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
|
|
25
|
-
const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
|
|
26
|
-
export const hermesChildEnv = (env = {}) => Object.fromEntries(
|
|
27
|
-
Object.entries(env).filter(([key]) => !HERMES_SECRET_ENV_KEY.test(key)),
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
const imageBlock = (imagePath) => {
|
|
31
|
-
const ext = path.extname(imagePath).toLowerCase()
|
|
32
|
-
const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg'
|
|
33
|
-
return { type: 'image', mimeType, data: fs.readFileSync(imagePath).toString('base64') }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const modelList = (state) => (state?.availableModels || []).map((item) => ({ value: item.modelId, displayName: item.name || item.modelId, description: item.description || '' }))
|
|
37
|
-
|
|
38
|
-
const mcpDescriptor = (url) => ({ type: 'http', name: 'thinkpool', url, headers: [] })
|
|
39
|
-
const createAcpClient = (options) => new AcpClient(options)
|
|
40
|
-
|
|
41
|
-
export function buildHermesPromptText({ text, firstTurn = false, terminalRolePrompt, rolePrompt, roomContext, promptIndex = 0, forceFullReminder = false } = {}) {
|
|
42
|
-
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
43
|
-
const inThinkPoolRoom = !!(terminalRolePrompt || rolePrompt || context)
|
|
44
|
-
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: forceFullReminder })
|
|
45
|
-
const preamble = [
|
|
46
|
-
firstTurn || fullReminder ? terminalRolePrompt : '',
|
|
47
|
-
inThinkPoolRoom ? buildThinkPoolTurnGuidance({ text, promptIndex, forceFull: forceFullReminder }) : '',
|
|
48
|
-
firstTurn || fullReminder ? rolePrompt : '',
|
|
49
|
-
context,
|
|
50
|
-
].filter(Boolean).join('\n\n')
|
|
51
|
-
const body = String(text ?? '')
|
|
52
|
-
return preamble ? `${preamble}\n\n${body}` : body
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const permissionOutcome = (decision, options = []) => {
|
|
56
|
-
if (decision === 'deny' || !decision) return { outcome: 'cancelled' }
|
|
57
|
-
const preferred = decision === 'always'
|
|
58
|
-
? ['allow_always', 'allow_session', 'allow_once']
|
|
59
|
-
: ['allow_once', 'allow_session', 'allow_always']
|
|
60
|
-
const option = preferred.map((kind) => options.find((item) => item.kind === kind || item.optionId === kind)).find(Boolean)
|
|
61
|
-
|| options.find((item) => String(item.kind || '').startsWith('allow'))
|
|
62
|
-
return option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function startHermesSession({
|
|
66
|
-
cwd, model, resume, env = process.env, mode = 'default', onEvent, requestPermission,
|
|
67
|
-
roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
|
|
68
|
-
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
69
|
-
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
70
|
-
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
|
|
71
|
-
admitStart = null, onTurnStart = null,
|
|
72
|
-
} = {}) {
|
|
73
|
-
let activeCwd = cwd
|
|
74
|
-
const requestedModel = model || null
|
|
75
|
-
let activeModel = requestedModel
|
|
76
|
-
let activeMode = HERMES_SUPPORTED_MODES.has(mode) ? mode : 'default'
|
|
77
|
-
let sessionId = resume || null
|
|
78
|
-
let client = null
|
|
79
|
-
let mapper = null
|
|
80
|
-
let mcpHttp = null
|
|
81
|
-
let starting = null
|
|
82
|
-
let ended = false
|
|
83
|
-
let turnActive = false
|
|
84
|
-
let started = false
|
|
85
|
-
let crashed = false
|
|
86
|
-
let firstTurn = true
|
|
87
|
-
let userPromptNo = 0
|
|
88
|
-
let forceFullReminder = true
|
|
89
|
-
const selectRoomContext = createRoomContextSelector(roomContext)
|
|
90
|
-
let stderrTail = ''
|
|
91
|
-
let promptChain = Promise.resolve()
|
|
92
|
-
let activeTurnId = 0
|
|
93
|
-
const abortedTurns = new Set()
|
|
94
|
-
let retireClient = () => {}
|
|
95
|
-
let resuming = false
|
|
96
|
-
let inventoryProbe = null
|
|
97
|
-
let modelSwitchPending = false
|
|
98
|
-
let bootCancelled = false
|
|
99
|
-
let suppressNextSessionPublish = false
|
|
100
|
-
let activeEffort = effort === null ? null : (HERMES_EFFORT_LEVELS.has(effort) ? effort : 'high')
|
|
101
|
-
const queuedTurns = []
|
|
102
|
-
let drainingQueuedTurns = false
|
|
103
|
-
let queueDrainPending = false
|
|
104
|
-
const policyRole = hermesRole || hermesRoleFor({})
|
|
105
|
-
|
|
106
|
-
const effectivePolicyRole = () => activeMode === 'plan' ? 'plan' : policyRole
|
|
107
|
-
const effectiveMcpTools = () => activeMode === 'plan'
|
|
108
|
-
? requiredMcpTools.filter((name) => PLAN_SAFE_MCP_TOOLS.has(name))
|
|
109
|
-
: requiredMcpTools
|
|
110
|
-
const acpModeFor = (value) => value === 'acceptEdits'
|
|
111
|
-
? 'accept_edits'
|
|
112
|
-
: value === 'bypassPermissions'
|
|
113
|
-
? 'dont_ask'
|
|
114
|
-
: 'default'
|
|
115
|
-
|
|
116
|
-
const emit = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
|
|
117
|
-
|
|
118
|
-
const thinkpoolPeerTool = (name) => {
|
|
119
|
-
const lower = String(name || '').toLowerCase()
|
|
120
|
-
return [...HERMES_PLAN_SAFE_MCP_TOOLS, 'post_to_terminal', 'post_to_session', HERMES_QUESTION_MCP_TOOL]
|
|
121
|
-
.find((candidate) => lower === candidate || lower === `mcp__thinkpool__${candidate}` || lower === `mcp_thinkpool_${candidate}`) || null
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function onRequest(method, params, requestId) {
|
|
125
|
-
if (method !== 'session/request_permission') throw Object.assign(new Error(`Unsupported ACP client method: ${method}`), { code: -32601 })
|
|
126
|
-
const tool = hermesToolFor(params.toolCall || mapper?.tools.get(params.toolCallId) || {})
|
|
127
|
-
const peerTool = thinkpoolPeerTool(tool.name)
|
|
128
|
-
const card = {
|
|
129
|
-
id: `hermes-perm:${requestId}`,
|
|
130
|
-
toolName: peerTool ? `mcp__thinkpool__${peerTool}` : tool.name,
|
|
131
|
-
input: tool.input,
|
|
132
|
-
risk: peerTool?.startsWith('post_to_') ? 'high' : classifyRisk(tool.name, tool.input),
|
|
133
|
-
}
|
|
134
|
-
// ACP's native modes currently govern edit proposals only. ThinkPool's
|
|
135
|
-
// permission chip is the cross-runtime authority, so apply the same pure
|
|
136
|
-
// mode policy Claude uses before raising a durable room card. Structural
|
|
137
|
-
// role schemas still win: bypass can auto-allow a request, but it cannot
|
|
138
|
-
// restore a tool that the process-local Hermes policy never exposed.
|
|
139
|
-
// Match Claude/Codex peer semantics before the generic permission matrix.
|
|
140
|
-
// Paired-room reads are always read-only, including in Plan. Same-room posts
|
|
141
|
-
// honor bypass/ownership after their bridge gate. Cross-room posts always
|
|
142
|
-
// retain sender consent—even in bypass—because the target has a second card.
|
|
143
|
-
if (HERMES_PLAN_SAFE_MCP_TOOLS.includes(peerTool)) {
|
|
144
|
-
return { outcome: permissionOutcome('allow', params.options) }
|
|
145
|
-
}
|
|
146
|
-
// The question tool creates its own durable human-input card. Requiring a
|
|
147
|
-
// permission card before it can create that card deadlocks the interaction.
|
|
148
|
-
if (peerTool === HERMES_QUESTION_MCP_TOOL) {
|
|
149
|
-
return { outcome: permissionOutcome('allow', params.options) }
|
|
150
|
-
}
|
|
151
|
-
if (peerTool === 'post_to_terminal') {
|
|
152
|
-
const gate = crossPostGate ? crossPostGate() : { ok: true }
|
|
153
|
-
if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
|
|
154
|
-
const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(tool.input?.terminal) : false
|
|
155
|
-
if (!crossPostNeedsCard({ mode: activeMode, spawnedByMe })) {
|
|
156
|
-
return { outcome: permissionOutcome('allow', params.options) }
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (peerTool === 'post_to_session') {
|
|
160
|
-
const gate = crossRoomPostGate ? crossRoomPostGate() : { ok: true }
|
|
161
|
-
if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
|
|
162
|
-
let decision = 'deny'
|
|
163
|
-
try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
|
|
164
|
-
return { outcome: permissionOutcome(decision, params.options) }
|
|
165
|
-
}
|
|
166
|
-
if (activeMode === 'plan') return { outcome: { outcome: 'cancelled' } }
|
|
167
|
-
if (autoAllow({ toolName: card.toolName, input: card.input, mode: activeMode })) {
|
|
168
|
-
return { outcome: permissionOutcome('allow', params.options) }
|
|
169
|
-
}
|
|
170
|
-
let decision = 'deny'
|
|
171
|
-
try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
|
|
172
|
-
return { outcome: permissionOutcome(decision, params.options) }
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function onNotification(method, params) {
|
|
176
|
-
if (method !== 'session/update') return
|
|
177
|
-
const update = params?.update || params
|
|
178
|
-
if (inventoryProbe) {
|
|
179
|
-
if (update?.sessionUpdate === 'agent_message_chunk' && update?.content?.type === 'text') {
|
|
180
|
-
inventoryProbe.push(String(update.content.text || ''))
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
// `/tools` is a local readiness transaction, but Hermes can publish its
|
|
184
|
-
// command catalog (and other session chrome) concurrently with that
|
|
185
|
-
// response. Suppress only the probe's assistant text; dropping every
|
|
186
|
-
// notification here made the real native catalog disappear permanently.
|
|
187
|
-
}
|
|
188
|
-
if (resuming && HERMES_REPLAY_UPDATES.has(update?.sessionUpdate)) return
|
|
189
|
-
mapper?.push(params)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function probeMcpTools() {
|
|
193
|
-
const required = [...new Set((Array.isArray(effectiveMcpTools()) ? effectiveMcpTools() : [])
|
|
194
|
-
.map((name) => String(name || '').trim()).filter(Boolean))]
|
|
195
|
-
// Direct runtime tests and unscoped upstream callers have no bridge role
|
|
196
|
-
// contract to prove. Every bridge-created Hermes lane supplies its required
|
|
197
|
-
// MCP list; only those lanes enter the exact-inventory transaction.
|
|
198
|
-
if (!required.length && effectivePolicyRole() !== 'plan') return { inventory: '', missing: [], forbidden: [] }
|
|
199
|
-
const exact = hermesExactInventory(effectivePolicyRole(), { mcpTools: required })
|
|
200
|
-
const allowedBuiltins = exact.builtinTools
|
|
201
|
-
const requiredBuiltins = exact.requiredBuiltinTools
|
|
202
|
-
const expectedMcp = exact.mcpTools
|
|
203
|
-
const chunks = []
|
|
204
|
-
inventoryProbe = chunks
|
|
205
|
-
try {
|
|
206
|
-
await client.request('session/prompt', {
|
|
207
|
-
sessionId,
|
|
208
|
-
prompt: [{ type: 'text', text: '/tools' }],
|
|
209
|
-
messageId: randomUUID(),
|
|
210
|
-
}, 30_000)
|
|
211
|
-
} finally { inventoryProbe = null }
|
|
212
|
-
const inventory = chunks.join('')
|
|
213
|
-
const has = (name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(inventory)
|
|
214
|
-
const mcpNames = [...inventory.matchAll(/\bmcp(?:__|_)[a-z0-9_]+/gi)].map((item) => item[0])
|
|
215
|
-
const expectedMcpNames = new Set(expectedMcp.flatMap((name) => [`mcp__thinkpool__${name}`, `mcp_thinkpool_${name}`]))
|
|
216
|
-
const unexpectedMcp = [...new Set(mcpNames.filter((name) => !expectedMcpNames.has(name)))]
|
|
217
|
-
// This is a zero-inference ACP inventory command. It proves the complete
|
|
218
|
-
// role schema (required built-ins plus exactly this lane's ThinkPool MCP
|
|
219
|
-
// set). Optional provider/browser capabilities may be absent, but any
|
|
220
|
-
// presented builtin outside the role allowlist is still a hard failure.
|
|
221
|
-
return {
|
|
222
|
-
inventory,
|
|
223
|
-
missing: [
|
|
224
|
-
...requiredBuiltins.filter((name) => !has(name)),
|
|
225
|
-
...expectedMcp.filter((name) => !has(`mcp__thinkpool__${name}`) && !has(`mcp_thinkpool_${name}`)),
|
|
226
|
-
],
|
|
227
|
-
forbidden: [
|
|
228
|
-
...exact.allBuiltinTools.filter((name) => !allowedBuiltins.includes(name) && has(name)),
|
|
229
|
-
...unexpectedMcp,
|
|
230
|
-
],
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function assertMcpReadiness() {
|
|
235
|
-
const probe = await probeMcpTools()
|
|
236
|
-
if (!probe.missing.length && !probe.forbidden.length) return probe
|
|
237
|
-
const details = [probe.missing.length ? `missing ${probe.missing.join(', ')}` : '', probe.forbidden.length ? `forbidden ${probe.forbidden.join(', ')}` : ''].filter(Boolean).join('; ')
|
|
238
|
-
throw new Error(`Hermes ThinkPool MCP readiness failed; ${details}`)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function boot() {
|
|
242
|
-
if (ended) throw new Error('Hermes session is closed')
|
|
243
|
-
if (crashed) throw new Error('Hermes ACP process crashed; this lane must be restarted before sending another turn')
|
|
244
|
-
if (client?.alive && mapper) return
|
|
245
|
-
if (starting) return starting
|
|
246
|
-
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
247
|
-
if (gate?.ok === false) throw new Error(gate.reason || 'Host memory is critically low. This Hermes runtime was not started.')
|
|
248
|
-
starting = (async () => {
|
|
249
|
-
if (!started && prepareCwd) {
|
|
250
|
-
try { activeCwd = prepareCwd() || activeCwd } catch { /* retain original cwd */ }
|
|
251
|
-
}
|
|
252
|
-
const thinkpool = mcpServers?.thinkpool
|
|
253
|
-
if (thinkpool && !mcpHttp) mcpHttp = await mcpHttpFactory({ sdkServer: thinkpool })
|
|
254
|
-
let childEnv = hermesChildEnv(env)
|
|
255
|
-
let launchCommand = command
|
|
256
|
-
let launchArgs = args
|
|
257
|
-
// The bridge advertises Hermes only after this probe, but lazy lanes may
|
|
258
|
-
// boot much later. Re-check immediately before every real ACP process so
|
|
259
|
-
// a removed/changed delegation hook cannot ride a stale startup verdict.
|
|
260
|
-
if (command === HERMES_COMMAND && clientFactory === createAcpClient) {
|
|
261
|
-
const profile = probeHermesRuntime({ command, env: childEnv, strictBootstrap: true })
|
|
262
|
-
if (!profile.available) throw new Error(`Hermes profile safety check failed: ${profile.reason || 'runtime unavailable'}`)
|
|
263
|
-
// The executable is the installed venv Python, never the mutable
|
|
264
|
-
// profile wrapper. HERMES_HOME is the probe-verified isolated profile.
|
|
265
|
-
launchCommand = profile.python
|
|
266
|
-
launchArgs = [profile.bootstrap]
|
|
267
|
-
childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(effectivePolicyRole(), { mcpTools: effectiveMcpTools() }) }
|
|
268
|
-
}
|
|
269
|
-
let retired = false
|
|
270
|
-
retireClient = () => { retired = true }
|
|
271
|
-
const initializeParams = {
|
|
272
|
-
protocolVersion: HERMES_ACP_PROTOCOL_VERSION,
|
|
273
|
-
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
274
|
-
clientInfo: { name: 'thinkpool-pair', title: 'ThinkPool Code', version: '1' },
|
|
275
|
-
}
|
|
276
|
-
let initialized
|
|
277
|
-
// Cold Hermes imports take 6–9 seconds on the supported host, and the
|
|
278
|
-
// production ENOSPC incident pushed one child just beyond the old 10s
|
|
279
|
-
// cliff. Initialization is pre-session and pre-inference, so one fresh
|
|
280
|
-
// process retry is safe: no prompt or tool can have executed yet.
|
|
281
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
282
|
-
retired = false
|
|
283
|
-
retireClient = () => { retired = true }
|
|
284
|
-
client = clientFactory({
|
|
285
|
-
command: launchCommand, args: launchArgs, cwd: activeCwd, env: childEnv,
|
|
286
|
-
onNotification,
|
|
287
|
-
onRequest,
|
|
288
|
-
onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
|
|
289
|
-
onClose: (error) => {
|
|
290
|
-
if (ended || crashed || retired) return
|
|
291
|
-
crashed = true
|
|
292
|
-
turnActive = false
|
|
293
|
-
emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
|
|
294
|
-
},
|
|
295
|
-
})
|
|
296
|
-
client.start()
|
|
297
|
-
try {
|
|
298
|
-
initialized = await client.request('initialize', initializeParams, HERMES_INITIALIZE_TIMEOUT_MS)
|
|
299
|
-
break
|
|
300
|
-
} catch (error) {
|
|
301
|
-
const retryable = !bootCancelled && attempt === 0 && /^initialize timed out\b/i.test(String(error?.message || error))
|
|
302
|
-
if (!retryable) throw error
|
|
303
|
-
retireClient()
|
|
304
|
-
const timedOutClient = client
|
|
305
|
-
client = null
|
|
306
|
-
timedOutClient?.end()
|
|
307
|
-
stderrTail = ''
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (initialized?.protocolVersion !== HERMES_ACP_PROTOCOL_VERSION) {
|
|
311
|
-
throw new Error(`Unsupported Hermes ACP protocol ${initialized?.protocolVersion ?? 'unknown'}; expected ${HERMES_ACP_PROTOCOL_VERSION}`)
|
|
312
|
-
}
|
|
313
|
-
const params = { cwd: path.resolve(activeCwd), mcpServers: mcpHttp ? [mcpDescriptor(mcpHttp.url)] : [] }
|
|
314
|
-
mapper = new HermesEventMapper({ onEvent: emit, sessionId, model: activeModel })
|
|
315
|
-
let state
|
|
316
|
-
if (sessionId) {
|
|
317
|
-
if (!initialized?.agentCapabilities?.sessionCapabilities?.resume) {
|
|
318
|
-
throw new Error(`Hermes session ${sessionId} cannot be resumed by this ACP release; no fresh session was substituted`)
|
|
319
|
-
}
|
|
320
|
-
try {
|
|
321
|
-
resuming = true
|
|
322
|
-
state = await client.request('session/resume', { ...params, sessionId }, 30_000)
|
|
323
|
-
}
|
|
324
|
-
catch (error) {
|
|
325
|
-
throw new Error(`Hermes session ${sessionId} is not available; no fresh session was substituted (${error?.message || error})`)
|
|
326
|
-
}
|
|
327
|
-
finally { resuming = false }
|
|
328
|
-
} else state = await client.request('session/new', params, 30_000)
|
|
329
|
-
if (!sessionId) sessionId = state?.sessionId || null
|
|
330
|
-
if (!sessionId) throw new Error('Hermes ACP did not return a session id')
|
|
331
|
-
let toolProbe = await probeMcpTools()
|
|
332
|
-
let missingMcpTools = toolProbe.missing
|
|
333
|
-
// Hermes treats ACP-provided MCP registration as non-fatal. A transient
|
|
334
|
-
// first connection can therefore leave an otherwise healthy session with
|
|
335
|
-
// only built-in tools. Re-running the released resume lifecycle retries
|
|
336
|
-
// registration without inventing a new native identity. Bound this to one
|
|
337
|
-
// retry, then fail closed before any user prompt reaches a tool-less lane.
|
|
338
|
-
if (missingMcpTools.length && initialized?.agentCapabilities?.sessionCapabilities?.resume) {
|
|
339
|
-
try {
|
|
340
|
-
resuming = true
|
|
341
|
-
state = await client.request('session/resume', { ...params, sessionId }, 30_000)
|
|
342
|
-
} finally { resuming = false }
|
|
343
|
-
toolProbe = await probeMcpTools()
|
|
344
|
-
missingMcpTools = toolProbe.missing
|
|
345
|
-
}
|
|
346
|
-
if (missingMcpTools.length || toolProbe.forbidden.length) {
|
|
347
|
-
const details = [missingMcpTools.length ? `missing ${missingMcpTools.join(', ')}` : '', toolProbe.forbidden.length ? `forbidden ${toolProbe.forbidden.join(', ')}` : ''].filter(Boolean).join('; ')
|
|
348
|
-
throw new Error(`Hermes ThinkPool MCP readiness failed; ${details}`)
|
|
349
|
-
}
|
|
350
|
-
const serverModel = state?.models?.currentModelId || null
|
|
351
|
-
activeModel = requestedModel || serverModel || activeModel
|
|
352
|
-
// A spawned Hermes worker may be pinned to an exact model from its parent's
|
|
353
|
-
// ACP catalog. Acknowledge that model BEFORE publishing the initial system/
|
|
354
|
-
// models events; otherwise the worker runs the requested model while the UI
|
|
355
|
-
// and persisted resume record lie that it still uses the profile default.
|
|
356
|
-
if (requestedModel) {
|
|
357
|
-
await client.request('session/set_model', { sessionId, modelId: activeModel })
|
|
358
|
-
await assertMcpReadiness()
|
|
359
|
-
}
|
|
360
|
-
// The bootstrap owns this config id and applies it to the process-local
|
|
361
|
-
// agent. Send it on new, resume and reconstructed sessions; it never
|
|
362
|
-
// touches Hermes' shared profile/config.yaml.
|
|
363
|
-
if (activeEffort) await client.request('session/set_config_option', { sessionId, configId: HERMES_EFFORT_CONFIG_ID, value: activeEffort })
|
|
364
|
-
const publishedModels = state?.models
|
|
365
|
-
? { ...state.models, currentModelId: activeModel }
|
|
366
|
-
: activeModel ? { currentModelId: activeModel, availableModels: [] } : state?.models
|
|
367
|
-
if (!suppressNextSessionPublish) {
|
|
368
|
-
mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
|
|
369
|
-
} else {
|
|
370
|
-
// A Plan transition recreates only the ACP process so its tool schema
|
|
371
|
-
// can become structurally read-only. Keep the room transcript/catalog
|
|
372
|
-
// stable while the fresh mapper resumes the same native session.
|
|
373
|
-
mapper.sessionId = sessionId
|
|
374
|
-
mapper.model = activeModel
|
|
375
|
-
suppressNextSessionPublish = false
|
|
376
|
-
}
|
|
377
|
-
started = true
|
|
378
|
-
const acpMode = acpModeFor(activeMode)
|
|
379
|
-
if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
|
|
380
|
-
await client.request('session/set_mode', { sessionId, modeId: acpMode })
|
|
381
|
-
}
|
|
382
|
-
emit({ kind: 'capabilities', runtime: 'hermes', protocol: 'acp', protocolVersion: initialized?.protocolVersion, capabilities: initialized?.agentCapabilities || {}, models: modelList(state?.models), flow: command === HERMES_COMMAND && clientFactory === createAcpClient })
|
|
383
|
-
})().catch(async (error) => {
|
|
384
|
-
crashed = true
|
|
385
|
-
client?.end()
|
|
386
|
-
try { await mcpHttp?.close() } catch { /* startup cleanup */ }
|
|
387
|
-
mcpHttp = null
|
|
388
|
-
if (!bootCancelled) emit({ kind: 'error', message: `Hermes ACP startup failed: ${error?.message || error}${stderrTail ? `: ${stderrTail.trim().slice(-240)}` : ''}`, recoverable: true })
|
|
389
|
-
throw error
|
|
390
|
-
}).finally(() => { starting = null; bootCancelled = false })
|
|
391
|
-
return starting
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function reviveAfterExplicitRetry() {
|
|
395
|
-
if (!crashed || ended) return
|
|
396
|
-
retireClient()
|
|
397
|
-
client?.end()
|
|
398
|
-
client = null
|
|
399
|
-
mapper = null
|
|
400
|
-
crashed = false
|
|
401
|
-
stderrTail = ''
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function finishAbortedTurn() {
|
|
405
|
-
if (mapper) mapper.finishTurn({ stopReason: 'cancelled' })
|
|
406
|
-
else emit({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async function restartAfterCancelledResponseFailure() {
|
|
410
|
-
const oldClient = client
|
|
411
|
-
retireClient()
|
|
412
|
-
client = null
|
|
413
|
-
mapper = null
|
|
414
|
-
oldClient?.end()
|
|
415
|
-
await boot()
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function promptBlocks(text, options = {}, { promptIndex = 0, forceFull = false } = {}) {
|
|
419
|
-
const rawText = String(text)
|
|
420
|
-
// Hermes handles /steer, /queue, /compact and /reset only when the slash is
|
|
421
|
-
// the first character of a pure-text prompt.
|
|
422
|
-
if (/^\s*\//.test(rawText)) return [{ type: 'text', text: rawText }]
|
|
423
|
-
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull })
|
|
424
|
-
const blocks = [{ type: 'text', text: buildHermesPromptText({
|
|
425
|
-
text: rawText,
|
|
426
|
-
firstTurn,
|
|
427
|
-
terminalRolePrompt,
|
|
428
|
-
rolePrompt,
|
|
429
|
-
roomContext: () => selectRoomContext({ force: fullReminder }),
|
|
430
|
-
promptIndex,
|
|
431
|
-
forceFullReminder: forceFull,
|
|
432
|
-
}) }]
|
|
433
|
-
for (const imagePath of (Array.isArray(options.images) ? options.images : [])) {
|
|
434
|
-
try { blocks.push(imageBlock(imagePath)) } catch { /* quoted host path remains in text */ }
|
|
435
|
-
}
|
|
436
|
-
return blocks
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId, promptIndex = 0, forceFull = false } = {}) {
|
|
440
|
-
try { await boot() }
|
|
441
|
-
catch (error) {
|
|
442
|
-
if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
|
|
443
|
-
throw error
|
|
444
|
-
}
|
|
445
|
-
if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
|
|
446
|
-
const body = steering ? `/steer ${String(text)}` : String(text)
|
|
447
|
-
let result
|
|
448
|
-
try {
|
|
449
|
-
result = await client.request('session/prompt', {
|
|
450
|
-
sessionId,
|
|
451
|
-
prompt: promptBlocks(body, options, { promptIndex, forceFull }),
|
|
452
|
-
messageId: randomUUID(),
|
|
453
|
-
}, 0)
|
|
454
|
-
} catch (error) {
|
|
455
|
-
// Hermes 0.18.2 successfully interrupts the model/tool, then its ACP
|
|
456
|
-
// prompt handler can reject with Internal error while constructing the
|
|
457
|
-
// cancelled response. The bridge already owns the canonical aborted
|
|
458
|
-
// boundary below, so a late response failure for that exact turn is not a
|
|
459
|
-
// second turn failure. Process-close errors still surface through onClose.
|
|
460
|
-
if (abortedTurns.has(turnId)) {
|
|
461
|
-
// Hermes 0.18.2 throws before clearing its transient is_running flag.
|
|
462
|
-
// Resume the same persisted session in a fresh per-lane ACP process so
|
|
463
|
-
// the next queued prompt cannot disappear into that stale runtime queue.
|
|
464
|
-
if (!ended) await restartAfterCancelledResponseFailure()
|
|
465
|
-
return { stopReason: 'cancelled' }
|
|
466
|
-
}
|
|
467
|
-
throw error
|
|
468
|
-
}
|
|
469
|
-
if (!steering) {
|
|
470
|
-
if (abortedTurns.has(turnId)) return result
|
|
471
|
-
turnActive = false
|
|
472
|
-
firstTurn = false
|
|
473
|
-
const compact = /^\s*\/compact\b/i.test(String(text || '')) ? mapper.compactOutcome() : null
|
|
474
|
-
if (compact) emit({ kind: 'compaction', trigger: 'manual', ...compact })
|
|
475
|
-
const reasoning = String(text || '').match(/^\s*\/reasoning\s+(low|medium|high|xhigh|max|none|reset)\s*$/i)
|
|
476
|
-
if (reasoning) {
|
|
477
|
-
const requested = reasoning[1].toLowerCase()
|
|
478
|
-
const confirmed = requested === 'reset'
|
|
479
|
-
? /Session reasoning reset to model default/i.test(mapper.text)
|
|
480
|
-
: new RegExp(`Session reasoning set to ${requested}`, 'i').test(mapper.text)
|
|
481
|
-
if (confirmed) {
|
|
482
|
-
activeEffort = requested === 'reset' ? null : requested
|
|
483
|
-
emit({ kind: 'effort', level: activeEffort })
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
// Reserve the idle-looking result boundary for the existing FIFO before
|
|
487
|
-
// publishing it. An onEvent consumer can synchronously submit a new turn
|
|
488
|
-
// from the result callback; without this latch that turn starts ahead of
|
|
489
|
-
// already-queued work and violates /queue order.
|
|
490
|
-
queueDrainPending = queuedTurns.length > 0 && !ended && !abortedTurns.has(turnId)
|
|
491
|
-
mapper.finishTurn({ stopReason: result?.stopReason, usage: result?.usage })
|
|
492
|
-
if (queueDrainPending) void drainQueuedTurns()
|
|
493
|
-
}
|
|
494
|
-
return result
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
async function drainQueuedTurns() {
|
|
498
|
-
if (drainingQueuedTurns || ended || turnActive) return
|
|
499
|
-
drainingQueuedTurns = true
|
|
500
|
-
try {
|
|
501
|
-
while (queuedTurns.length && !ended && !turnActive) {
|
|
502
|
-
queueDrainPending = false
|
|
503
|
-
const next = queuedTurns.shift()
|
|
504
|
-
const turnId = ++activeTurnId
|
|
505
|
-
turnActive = true
|
|
506
|
-
try { onTurnStart?.(next.options || {}) } catch { /* observer cannot break the FIFO */ }
|
|
507
|
-
try { await runPrompt(next.text, next.options, { steering: false, turnId, promptIndex: next.promptIndex, forceFull: next.forceFull }) }
|
|
508
|
-
catch (error) { turnActive = false; emit({ kind: 'error', message: `Hermes queued turn failed: ${error?.message || error}`, recoverable: true }) }
|
|
509
|
-
}
|
|
510
|
-
} finally { drainingQueuedTurns = false; queueDrainPending = false }
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (!lazy) queueMicrotask(() => { void boot().catch(() => {}) })
|
|
514
|
-
|
|
515
|
-
return {
|
|
516
|
-
get sessionId() { return sessionId },
|
|
517
|
-
get turnActive() { return turnActive },
|
|
518
|
-
get queuedDepth() { return queuedTurns.length },
|
|
519
|
-
get canSteer() { return !!client?.alive },
|
|
520
|
-
get started() { return started },
|
|
521
|
-
get models() { return [] },
|
|
522
|
-
get effort() { return activeEffort },
|
|
523
|
-
sendTurn(text, options = {}) {
|
|
524
|
-
if (ended) return false
|
|
525
|
-
if (!client?.alive && !starting) {
|
|
526
|
-
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
527
|
-
if (gate?.ok === false) {
|
|
528
|
-
emit({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Hermes runtime was not started.', recoverable: true })
|
|
529
|
-
return false
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
const promptIndex = userPromptNo++
|
|
533
|
-
const thisTurnForceFull = forceFullReminder
|
|
534
|
-
forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
|
|
535
|
-
// A crash never replays work by itself. A later explicit turn is the
|
|
536
|
-
// authority to launch a fresh ACP process and resume the same native
|
|
537
|
-
// session id; this is the recovery path the old permanent latch blocked.
|
|
538
|
-
reviveAfterExplicitRetry()
|
|
539
|
-
const queued = String(text || '').match(/^\s*\/queue\s+([\s\S]+)$/i)
|
|
540
|
-
// A result callback may arrive after turnActive fell but before the FIFO
|
|
541
|
-
// drain claimed the next queued item. Keep any new human turn behind the
|
|
542
|
-
// already-visible queue instead of letting that micro-window reorder it.
|
|
543
|
-
if (queueDrainPending) {
|
|
544
|
-
queuedTurns.push({ text: queued ? queued[1] : String(text || ''), options, promptIndex, forceFull: thisTurnForceFull })
|
|
545
|
-
emit({ kind: 'queue-add', queued: true, depth: queuedTurns.length })
|
|
546
|
-
return true
|
|
547
|
-
}
|
|
548
|
-
// A busy prompt is a genuine ACP /steer call and may run concurrently.
|
|
549
|
-
if (turnActive) {
|
|
550
|
-
if (queued) {
|
|
551
|
-
queuedTurns.push({ text: queued[1], options, promptIndex, forceFull: thisTurnForceFull })
|
|
552
|
-
emit({ kind: 'queue-add', queued: true, depth: queuedTurns.length })
|
|
553
|
-
return true
|
|
554
|
-
}
|
|
555
|
-
const turnId = activeTurnId
|
|
556
|
-
void runPrompt(text, options, { steering: true, turnId, promptIndex, forceFull: thisTurnForceFull }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
|
|
557
|
-
return true
|
|
558
|
-
}
|
|
559
|
-
const turnId = ++activeTurnId
|
|
560
|
-
// Claim the turn synchronously, before the cold safety probe/import. The
|
|
561
|
-
// room can now show Thinking + Stop for the whole accepted lifecycle.
|
|
562
|
-
turnActive = true
|
|
563
|
-
promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId, promptIndex, forceFull: thisTurnForceFull })).catch((error) => {
|
|
564
|
-
turnActive = false
|
|
565
|
-
emit({ kind: 'error', message: `Hermes turn failed: ${error?.message || error}`, recoverable: true })
|
|
566
|
-
})
|
|
567
|
-
return true
|
|
568
|
-
},
|
|
569
|
-
async abort() {
|
|
570
|
-
if (!turnActive) return
|
|
571
|
-
queuedTurns.length = 0
|
|
572
|
-
queueDrainPending = false
|
|
573
|
-
const turnId = activeTurnId
|
|
574
|
-
abortedTurns.add(turnId)
|
|
575
|
-
// Bound retained turn ids while preserving any concurrent /steer request
|
|
576
|
-
// that may settle after the primary prompt response.
|
|
577
|
-
if (abortedTurns.size > 32) abortedTurns.delete(abortedTurns.values().next().value)
|
|
578
|
-
turnActive = false
|
|
579
|
-
firstTurn = false
|
|
580
|
-
if (starting) {
|
|
581
|
-
bootCancelled = true
|
|
582
|
-
retireClient()
|
|
583
|
-
client?.end()
|
|
584
|
-
finishAbortedTurn()
|
|
585
|
-
await promptChain.catch(() => {})
|
|
586
|
-
return
|
|
587
|
-
}
|
|
588
|
-
if (!sessionId || !client?.alive) {
|
|
589
|
-
finishAbortedTurn()
|
|
590
|
-
await promptChain.catch(() => {})
|
|
591
|
-
return
|
|
592
|
-
}
|
|
593
|
-
finishAbortedTurn()
|
|
594
|
-
await client.notify('session/cancel', { sessionId }).catch((error) => {
|
|
595
|
-
// Never display a stopped lane while the un-cancelled Hermes process
|
|
596
|
-
// might still be executing. A transport failure tears down the ACP
|
|
597
|
-
// process and makes the lane honestly non-resumable until restart.
|
|
598
|
-
crashed = true
|
|
599
|
-
client?.end()
|
|
600
|
-
emit({ kind: 'error', message: `Hermes cancellation delivery failed; ACP process stopped: ${error?.message || error}`, recoverable: true })
|
|
601
|
-
})
|
|
602
|
-
// The ACP cancellation notification is not itself the completion edge.
|
|
603
|
-
// Wait for the active prompt promise to unwind (including the 0.18.2
|
|
604
|
-
// cancelled-response recovery) before a bridge queue flush starts anew.
|
|
605
|
-
await promptChain.catch(() => {})
|
|
606
|
-
},
|
|
607
|
-
end() {
|
|
608
|
-
ended = true
|
|
609
|
-
turnActive = false
|
|
610
|
-
emit.cancel()
|
|
611
|
-
queuedTurns.length = 0
|
|
612
|
-
queueDrainPending = false
|
|
613
|
-
if (sessionId && client?.alive) void client.request('session/close', { sessionId }, 1500).catch(() => {}).finally(() => client?.end())
|
|
614
|
-
else client?.end()
|
|
615
|
-
void mcpHttp?.close().catch(() => {})
|
|
616
|
-
},
|
|
617
|
-
setModel(nextModel) {
|
|
618
|
-
if (!nextModel || turnActive || modelSwitchPending) return false
|
|
619
|
-
const requested = String(nextModel)
|
|
620
|
-
modelSwitchPending = true
|
|
621
|
-
void boot().then(() => client.request('session/set_model', { sessionId, modelId: requested })).then(async () => {
|
|
622
|
-
// 0.18.2 reconstructs state.agent during set_model. The bootstrap
|
|
623
|
-
// re-registers its MCP; this independent zero-inference /tools check is
|
|
624
|
-
// the transaction's acknowledgement boundary.
|
|
625
|
-
await assertMcpReadiness()
|
|
626
|
-
if (ended) return
|
|
627
|
-
activeModel = requested
|
|
628
|
-
if (mapper) mapper.model = activeModel
|
|
629
|
-
emit({ kind: 'model-switch', status: 'success', model: activeModel, requested })
|
|
630
|
-
}).catch((error) => { if (!ended) emit({ kind: 'model-switch', status: 'failed', model: activeModel, requested, message: `Hermes model switch failed: ${error?.message || error}` }) })
|
|
631
|
-
.finally(() => { modelSwitchPending = false })
|
|
632
|
-
return true
|
|
633
|
-
},
|
|
634
|
-
setMode(nextMode) {
|
|
635
|
-
if (turnActive || !HERMES_SUPPORTED_MODES.has(nextMode)) return false
|
|
636
|
-
const priorMode = activeMode
|
|
637
|
-
const changesPolicySchema = (priorMode === 'plan') !== (nextMode === 'plan')
|
|
638
|
-
if (changesPolicySchema) {
|
|
639
|
-
// Plan is stronger than an approval preference: it removes terminal,
|
|
640
|
-
// process, write, patch, browser, and execution schemas. ACP modes alone
|
|
641
|
-
// cannot do that, so resume the exact native session in a fresh process
|
|
642
|
-
// under the bridge-owned read-only policy.
|
|
643
|
-
activeMode = nextMode
|
|
644
|
-
suppressNextSessionPublish = started
|
|
645
|
-
if (started) {
|
|
646
|
-
retireClient()
|
|
647
|
-
const oldClient = client
|
|
648
|
-
client = null
|
|
649
|
-
mapper = null
|
|
650
|
-
started = false
|
|
651
|
-
crashed = false
|
|
652
|
-
stderrTail = ''
|
|
653
|
-
oldClient?.end()
|
|
654
|
-
}
|
|
655
|
-
void boot().then(() => emit({ kind: 'mode', mode: activeMode })).catch((error) => {
|
|
656
|
-
activeMode = priorMode
|
|
657
|
-
suppressNextSessionPublish = false
|
|
658
|
-
emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true })
|
|
659
|
-
})
|
|
660
|
-
return true
|
|
661
|
-
}
|
|
662
|
-
void boot().then(() => client.request('session/set_mode', { sessionId, modeId: acpModeFor(nextMode) })).then(() => {
|
|
663
|
-
activeMode = nextMode
|
|
664
|
-
emit({ kind: 'mode', mode: activeMode })
|
|
665
|
-
}).catch((error) => emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true }))
|
|
666
|
-
return true
|
|
667
|
-
},
|
|
668
|
-
setEffort(nextEffort) {
|
|
669
|
-
if (turnActive || !HERMES_EFFORT_LEVELS.has(nextEffort)) return false
|
|
670
|
-
const requested = String(nextEffort)
|
|
671
|
-
void boot().then(() => client.request('session/set_config_option', { sessionId, configId: HERMES_EFFORT_CONFIG_ID, value: requested })).then(() => {
|
|
672
|
-
activeEffort = requested
|
|
673
|
-
emit({ kind: 'effort', level: activeEffort })
|
|
674
|
-
}).catch((error) => emit({ kind: 'error', message: `Hermes reasoning change failed: ${error?.message || error}`, recoverable: true }))
|
|
675
|
-
return true
|
|
676
|
-
},
|
|
677
|
-
clearContext() {
|
|
678
|
-
if (turnActive) return false
|
|
679
|
-
return this.sendTurn('/reset')
|
|
680
|
-
},
|
|
681
|
-
warm() { void boot().catch(() => {}) },
|
|
682
|
-
}
|
|
683
|
-
}
|
|
1
|
+
import e from"node:fs";import t from"node:path";import{randomUUID as o}from"node:crypto";import{AcpClient as n}from"./acp-client.mjs";import{HermesEventMapper as s,hermesToolFor as r}from"./hermes-event-mapper.mjs";import{probeHermesRuntime as i}from"./hermes-probe.mjs";import{HERMES_PLAN_SAFE_MCP_TOOLS as l,HERMES_QUESTION_MCP_TOOL as a,hermesExactInventory as c,hermesPolicyEnv as d,hermesRoleFor as m}from"./hermes-policy.mjs";import{startCodexMcpHttp as u}from"./codex-mcp-http.mjs";import{autoAllow as p,classifyRisk as f}from"./claude-session.mjs";import{crossPostNeedsCard as h}from"./cross-terminal.mjs";import{buildThinkPoolTurnGuidance as g,createRoomContextSelector as w,usesFullThinkPoolReminder as _}from"./thinkpool-room-prompt.mjs";import{createCumulativeEventRelay as y}from"./cumulative-event-relay.mjs";export const HERMES_COMMAND="thinkpool";export const HERMES_ACP_PROTOCOL_VERSION=1;export const HERMES_SUPPORTED_MODES=new Set(["default","acceptEdits","plan","bypassPermissions"]);export const HERMES_EFFORT_LEVELS=new Set(["none","low","medium","high","xhigh","max"]);export const HERMES_EFFORT_CONFIG_ID="thinkpool_reasoning_effort";const E=new Set([...l,"read_review_file"]),I=/(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i,S=new Set(["agent_message_chunk","agent_thought_chunk","tool_call","tool_call_update","plan"]);export const hermesChildEnv=(e={})=>Object.fromEntries(Object.entries(e).filter(([e])=>!I.test(e)));const b=o=>{const n=t.extname(o).toLowerCase();return{type:"image",mimeType:".png"===n?"image/png":".webp"===n?"image/webp":".gif"===n?"image/gif":"image/jpeg",data:e.readFileSync(o).toString("base64")}},k=e=>(e?.availableModels||[]).map(e=>({value:e.modelId,displayName:e.name||e.modelId,description:e.description||""})),x=e=>new n(e);export function buildHermesPromptText({text:e,firstTurn:t=!1,terminalRolePrompt:o,rolePrompt:n,roomContext:s,promptIndex:r=0,forceFullReminder:i=!1}={}){const l="function"==typeof s?s():s,a=!!(o||n||l),c=_({promptIndex:r,forceFull:i}),d=[t||c?o:"",a?g({text:e,promptIndex:r,forceFull:i}):"",t||c?n:"",l].filter(Boolean).join("\n\n"),m=String(e??"");return d?`${d}\n\n${m}`:m}const v=(e,t=[])=>{if("deny"===e||!e)return{outcome:"cancelled"};const o=("always"===e?["allow_always","allow_session","allow_once"]:["allow_once","allow_session","allow_always"]).map(e=>t.find(t=>t.kind===e||t.optionId===e)).find(Boolean)||t.find(e=>String(e.kind||"").startsWith("allow"));return o?{outcome:"selected",optionId:o.optionId}:{outcome:"cancelled"}};export function startHermesSession({cwd:e,model:n,resume:g,env:I=process.env,mode:R="default",onEvent:C,requestPermission:H,roomContext:T,terminalRolePrompt:P,rolePrompt:$,mcpServers:M,requiredMcpTools:F=[],prepareCwd:O=null,command:q="thinkpool",args:A=["acp"],clientFactory:j=x,mcpHttpFactory:D=u,lazy:L=!1,hermesRole:N=null,crossPostGate:V=null,didSpawnTarget:B=null,crossRoomPostGate:U=null,effort:z="high",admitStart:G=null,onTurnStart:K=null}={}){let Y=e;const W=n||null;let J=W,Q=HERMES_SUPPORTED_MODES.has(R)?R:"default",X=g||null,Z=null,ee=null,te=null,oe=null,ne=!1,se=!1,re=!1,ie=!1,le=!0,ae=0,ce=!0;const de=w(T);let me="",ue=Promise.resolve(),pe=0;const fe=new Set;let he=()=>{},ge=!1,we=null,_e=!1,ye=!1,Ee=!1,Ie=null===z?null:HERMES_EFFORT_LEVELS.has(z)?z:"high";const Se=[];let be=!1,ke=!1;const xe=N||m({}),ve=()=>"plan"===Q?"plan":xe,Re=()=>"plan"===Q?F.filter(e=>E.has(e)):F,Ce=e=>"acceptEdits"===e?"accept_edits":"bypassPermissions"===e?"dont_ask":"default",He=y(e=>{try{C?.(e)}catch{}});async function Te(e,t,o){if("session/request_permission"!==e)throw Object.assign(new Error(`Unsupported ACP client method: ${e}`),{code:-32601});const n=r(t.toolCall||ee?.tools.get(t.toolCallId)||{}),s=(e=>{const t=String(e||"").toLowerCase();return[...l,"post_to_terminal","post_to_session",a].find(e=>t===e||t===`mcp__thinkpool__${e}`||t===`mcp_thinkpool_${e}`)||null})(n.name),i={id:`hermes-perm:${o}`,toolName:s?`mcp__thinkpool__${s}`:n.name,input:n.input,risk:s?.startsWith("post_to_")?"high":f(n.name,n.input)};if(l.includes(s))return{outcome:v("allow",t.options)};if(s===a)return{outcome:v("allow",t.options)};if("post_to_terminal"===s){if(!(V?V():{ok:!0}).ok)return{outcome:{outcome:"cancelled"}};const e=!!B&&!!B(n.input?.terminal);if(!h({mode:Q,spawnedByMe:e}))return{outcome:v("allow",t.options)}}if("post_to_session"===s){if(!(U?U():{ok:!0}).ok)return{outcome:{outcome:"cancelled"}};let e="deny";try{e=await(H?.(i))}catch{}return{outcome:v(e,t.options)}}if("plan"===Q)return{outcome:{outcome:"cancelled"}};if(p({toolName:i.toolName,input:i.input,mode:Q}))return{outcome:v("allow",t.options)};let c="deny";try{c=await(H?.(i))}catch{}return{outcome:v(c,t.options)}}function Pe(e,t){if("session/update"!==e)return;const o=t?.update||t;we&&"agent_message_chunk"===o?.sessionUpdate&&"text"===o?.content?.type?we.push(String(o.content.text||"")):ge&&S.has(o?.sessionUpdate)||ee?.push(t)}async function $e(){const e=[...new Set((Array.isArray(Re())?Re():[]).map(e=>String(e||"").trim()).filter(Boolean))];if(!e.length&&"plan"!==ve())return{inventory:"",missing:[],forbidden:[]};const t=c(ve(),{mcpTools:e}),n=t.builtinTools,s=t.requiredBuiltinTools,r=t.mcpTools,i=[];we=i;try{await Z.request("session/prompt",{sessionId:X,prompt:[{type:"text",text:"/tools"}],messageId:o()},3e4)}finally{we=null}const l=i.join(""),a=e=>new RegExp(`(?:^|[^a-z0-9_])${e}(?:$|[^a-z0-9_])`,"i").test(l),d=[...l.matchAll(/\bmcp(?:__|_)[a-z0-9_]+/gi)].map(e=>e[0]),m=new Set(r.flatMap(e=>[`mcp__thinkpool__${e}`,`mcp_thinkpool_${e}`])),u=[...new Set(d.filter(e=>!m.has(e)))];return{inventory:l,missing:[...s.filter(e=>!a(e)),...r.filter(e=>!a(`mcp__thinkpool__${e}`)&&!a(`mcp_thinkpool_${e}`))],forbidden:[...t.allBuiltinTools.filter(e=>!n.includes(e)&&a(e)),...u]}}async function Me(){const e=await $e();if(!e.missing.length&&!e.forbidden.length)return e;const t=[e.missing.length?`missing ${e.missing.join(", ")}`:"",e.forbidden.length?`forbidden ${e.forbidden.join(", ")}`:""].filter(Boolean).join("; ");throw new Error(`Hermes ThinkPool MCP readiness failed; ${t}`)}async function Fe(){if(ne)throw new Error("Hermes session is closed");if(ie)throw new Error("Hermes ACP process crashed; this lane must be restarted before sending another turn");if(Z?.alive&&ee)return;if(oe)return oe;const e="function"==typeof G?G():{ok:!0};if(!1===e?.ok)throw new Error(e.reason||"Host memory is critically low. This Hermes runtime was not started.");return oe=(async()=>{if(!re&&O)try{Y=O()||Y}catch{}const e=M?.thinkpool;e&&!te&&(te=await D({sdkServer:e}));let o=hermesChildEnv(I),n=q,r=A;if("thinkpool"===q&&j===x){const e=i({command:q,env:o,strictBootstrap:!0});if(!e.available)throw new Error(`Hermes profile safety check failed: ${e.reason||"runtime unavailable"}`);n=e.python,r=[e.bootstrap],o={...o,HERMES_HOME:e.profile,THINKPOOL_HERMES_ACP_POLICY:d(ve(),{mcpTools:Re()})}}let l=!1;he=()=>{l=!0};const a={protocolVersion:1,clientCapabilities:{fs:{readTextFile:!1,writeTextFile:!1},terminal:!1},clientInfo:{name:"thinkpool-pair",title:"ThinkPool Code",version:"1"}};let c;for(let e=0;e<2;e++){l=!1,he=()=>{l=!0},Z=j({command:n,args:r,cwd:Y,env:o,onNotification:Pe,onRequest:Te,onStderr:e=>{me=(me+e).slice(-2e3)},onClose:e=>{ne||ie||l||(ie=!0,se=!1,He({kind:"error",message:`Hermes ACP closed unexpectedly: ${e?.message||e}`,recoverable:!0}))}}),Z.start();try{c=await Z.request("initialize",a,15e3);break}catch(t){if(ye||0!==e||!/^initialize timed out\b/i.test(String(t?.message||t)))throw t;he();const o=Z;Z=null,o?.end(),me=""}}if(1!==c?.protocolVersion)throw new Error(`Unsupported Hermes ACP protocol ${c?.protocolVersion??"unknown"}; expected 1`);const m={cwd:t.resolve(Y),mcpServers:te?[(u=te.url,{type:"http",name:"thinkpool",url:u,headers:[]})]:[]};var u;let p;if(ee=new s({onEvent:He,sessionId:X,model:J}),X){if(!c?.agentCapabilities?.sessionCapabilities?.resume)throw new Error(`Hermes session ${X} cannot be resumed by this ACP release; no fresh session was substituted`);try{ge=!0,p=await Z.request("session/resume",{...m,sessionId:X},3e4)}catch(e){throw new Error(`Hermes session ${X} is not available; no fresh session was substituted (${e?.message||e})`)}finally{ge=!1}}else p=await Z.request("session/new",m,3e4);if(X||(X=p?.sessionId||null),!X)throw new Error("Hermes ACP did not return a session id");let f=await $e(),h=f.missing;if(h.length&&c?.agentCapabilities?.sessionCapabilities?.resume){try{ge=!0,p=await Z.request("session/resume",{...m,sessionId:X},3e4)}finally{ge=!1}f=await $e(),h=f.missing}if(h.length||f.forbidden.length){const e=[h.length?`missing ${h.join(", ")}`:"",f.forbidden.length?`forbidden ${f.forbidden.join(", ")}`:""].filter(Boolean).join("; ");throw new Error(`Hermes ThinkPool MCP readiness failed; ${e}`)}J=W||p?.models?.currentModelId||null||J,W&&(await Z.request("session/set_model",{sessionId:X,modelId:J}),await Me()),Ie&&await Z.request("session/set_config_option",{sessionId:X,configId:HERMES_EFFORT_CONFIG_ID,value:Ie});const g=p?.models?{...p.models,currentModelId:J}:J?{currentModelId:J,availableModels:[]}:p?.models;Ee?(ee.sessionId=X,ee.model=J,Ee=!1):ee.startSession({sessionId:X,models:g,modes:p?.modes,commands:[]}),re=!0;const w=Ce(Q);p?.modes?.availableModes?.some(e=>e.id===w)&&p.modes.currentModeId!==w&&await Z.request("session/set_mode",{sessionId:X,modeId:w}),He({kind:"capabilities",runtime:"hermes",protocol:"acp",protocolVersion:c?.protocolVersion,capabilities:c?.agentCapabilities||{},models:k(p?.models),flow:"thinkpool"===q&&j===x})})().catch(async e=>{ie=!0,Z?.end();try{await(te?.close())}catch{}throw te=null,ye||He({kind:"error",message:`Hermes ACP startup failed: ${e?.message||e}${me?`: ${me.trim().slice(-240)}`:""}`,recoverable:!0}),e}).finally(()=>{oe=null,ye=!1}),oe}function Oe(){ee?ee.finishTurn({stopReason:"cancelled"}):He({kind:"result",subtype:"aborted",sessionId:X,model:J,costUsd:null,usage:null,numTurns:1,durationMs:void 0,denials:0,resultText:null})}function qe(e,t={},{promptIndex:o=0,forceFull:n=!1}={}){const s=String(e);if(/^\s*\//.test(s))return[{type:"text",text:s}];const r=_({promptIndex:o,forceFull:n}),i=[{type:"text",text:buildHermesPromptText({text:s,firstTurn:le,terminalRolePrompt:P,rolePrompt:$,roomContext:()=>de({force:r}),promptIndex:o,forceFullReminder:n})}];for(const e of Array.isArray(t.images)?t.images:[])try{i.push(b(e))}catch{}return i}async function Ae(e,t={},{steering:n=!1,turnId:s=pe,promptIndex:r=0,forceFull:i=!1}={}){try{await Fe()}catch(e){if(fe.has(s))return{stopReason:"cancelled"};throw e}if(fe.has(s))return{stopReason:"cancelled"};const l=n?`/steer ${String(e)}`:String(e);let a;try{a=await Z.request("session/prompt",{sessionId:X,prompt:qe(l,t,{promptIndex:r,forceFull:i}),messageId:o()},0)}catch(e){if(fe.has(s))return ne||await async function(){const e=Z;he(),Z=null,ee=null,e?.end(),await Fe()}(),{stopReason:"cancelled"};throw e}if(!n){if(fe.has(s))return a;se=!1,le=!1;const t=/^\s*\/compact\b/i.test(String(e||""))?ee.compactOutcome():null;t&&He({kind:"compaction",trigger:"manual",...t});const o=String(e||"").match(/^\s*\/reasoning\s+(low|medium|high|xhigh|max|none|reset)\s*$/i);if(o){const e=o[1].toLowerCase();("reset"===e?/Session reasoning reset to model default/i.test(ee.text):new RegExp(`Session reasoning set to ${e}`,"i").test(ee.text))&&(Ie="reset"===e?null:e,He({kind:"effort",level:Ie}))}ke=Se.length>0&&!ne&&!fe.has(s),ee.finishTurn({stopReason:a?.stopReason,usage:a?.usage}),ke&&async function(){if(!(be||ne||se)){be=!0;try{for(;Se.length&&!ne&&!se;){ke=!1;const e=Se.shift(),t=++pe;se=!0;try{K?.(e.options||{})}catch{}try{await Ae(e.text,e.options,{steering:!1,turnId:t,promptIndex:e.promptIndex,forceFull:e.forceFull})}catch(e){se=!1,He({kind:"error",message:`Hermes queued turn failed: ${e?.message||e}`,recoverable:!0})}}}finally{be=!1,ke=!1}}}()}return a}return L||queueMicrotask(()=>{Fe().catch(()=>{})}),{get sessionId(){return X},get turnActive(){return se},get queuedDepth(){return Se.length},get canSteer(){return!!Z?.alive},get started(){return re},get models(){return[]},get effort(){return Ie},sendTurn(e,t={}){if(ne)return!1;if(!Z?.alive&&!oe){const e="function"==typeof G?G():{ok:!0};if(!1===e?.ok)return He({kind:"error",message:e.reason||"Host memory is critically low. This Hermes runtime was not started.",recoverable:!0}),!1}const o=ae++,n=ce;ce=/^\s*\/(?:compact|reset|clear)\b/i.test(String(e||"")),ie&&!ne&&(he(),Z?.end(),Z=null,ee=null,ie=!1,me="");const s=String(e||"").match(/^\s*\/queue\s+([\s\S]+)$/i);if(ke)return Se.push({text:s?s[1]:String(e||""),options:t,promptIndex:o,forceFull:n}),He({kind:"queue-add",queued:!0,depth:Se.length}),!0;if(se)return s?(Se.push({text:s[1],options:t,promptIndex:o,forceFull:n}),He({kind:"queue-add",queued:!0,depth:Se.length}),!0):(Ae(e,t,{steering:!0,turnId:pe,promptIndex:o,forceFull:n}).catch(e=>He({kind:"error",message:`Hermes steering failed: ${e?.message||e}`,recoverable:!0})),!0);const r=++pe;return se=!0,ue=ue.then(()=>Ae(e,t,{steering:!1,turnId:r,promptIndex:o,forceFull:n})).catch(e=>{se=!1,He({kind:"error",message:`Hermes turn failed: ${e?.message||e}`,recoverable:!0})}),!0},async abort(){if(!se)return;Se.length=0,ke=!1;const e=pe;return fe.add(e),fe.size>32&&fe.delete(fe.values().next().value),se=!1,le=!1,oe?(ye=!0,he(),Z?.end(),Oe(),void await ue.catch(()=>{})):X&&Z?.alive?(Oe(),await Z.notify("session/cancel",{sessionId:X}).catch(e=>{ie=!0,Z?.end(),He({kind:"error",message:`Hermes cancellation delivery failed; ACP process stopped: ${e?.message||e}`,recoverable:!0})}),void await ue.catch(()=>{})):(Oe(),void await ue.catch(()=>{}))},end(){ne=!0,se=!1,He.cancel(),Se.length=0,ke=!1,X&&Z?.alive?Z.request("session/close",{sessionId:X},1500).catch(()=>{}).finally(()=>Z?.end()):Z?.end(),te?.close().catch(()=>{})},setModel(e){if(!e||se||_e)return!1;const t=String(e);return _e=!0,Fe().then(()=>Z.request("session/set_model",{sessionId:X,modelId:t})).then(async()=>{await Me(),ne||(J=t,ee&&(ee.model=J),He({kind:"model-switch",status:"success",model:J,requested:t}))}).catch(e=>{ne||He({kind:"model-switch",status:"failed",model:J,requested:t,message:`Hermes model switch failed: ${e?.message||e}`})}).finally(()=>{_e=!1}),!0},setMode(e){if(se||!HERMES_SUPPORTED_MODES.has(e))return!1;const t=Q;if("plan"===t!=("plan"===e)){if(Q=e,Ee=re,re){he();const e=Z;Z=null,ee=null,re=!1,ie=!1,me="",e?.end()}return Fe().then(()=>He({kind:"mode",mode:Q})).catch(e=>{Q=t,Ee=!1,He({kind:"error",message:`Hermes mode switch failed: ${e?.message||e}`,recoverable:!0})}),!0}return Fe().then(()=>Z.request("session/set_mode",{sessionId:X,modeId:Ce(e)})).then(()=>{Q=e,He({kind:"mode",mode:Q})}).catch(e=>He({kind:"error",message:`Hermes mode switch failed: ${e?.message||e}`,recoverable:!0})),!0},setEffort(e){if(se||!HERMES_EFFORT_LEVELS.has(e))return!1;const t=String(e);return Fe().then(()=>Z.request("session/set_config_option",{sessionId:X,configId:HERMES_EFFORT_CONFIG_ID,value:t})).then(()=>{Ie=t,He({kind:"effort",level:Ie})}).catch(e=>He({kind:"error",message:`Hermes reasoning change failed: ${e?.message||e}`,recoverable:!0})),!0},clearContext(){return!se&&this.sendTurn("/reset")},warm(){Fe().catch(()=>{})}}}
|