thinkpool-pair 0.7.245 → 0.7.247

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/event-id.mjs CHANGED
@@ -210,6 +210,237 @@ export function inlineImageBlocks(evt) {
210
210
  return out
211
211
  }
212
212
 
213
+ // One ordered emission boundary for structured-session events while agent screenshots
214
+ // are lifted out of inline base64. This stays import-safe: bridge.mjs owns room/auth
215
+ // state; these definitions own deadlines, bounded reservations, ordering, and teardown.
216
+ export const SCREENSHOT_OMITTED = '[screenshot omitted — upload failed]'
217
+ export const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS = 15_000
218
+ export const DEFAULT_IMAGE_QUEUE_MAX_PENDING = 4
219
+ export const DEFAULT_IMAGE_QUEUE_MAX_BYTES = 16_000_000
220
+
221
+ const sanePositiveInt = (value, fallback) => {
222
+ const n = Number(value)
223
+ return Number.isSafeInteger(n) && n > 0 ? n : fallback
224
+ }
225
+
226
+ export function imageQueueConfig(env = process.env) {
227
+ return {
228
+ uploadTimeoutMs: sanePositiveInt(env.TP_CODE_IMAGE_UPLOAD_TIMEOUT_MS, DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS),
229
+ maxPending: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_PENDING, DEFAULT_IMAGE_QUEUE_MAX_PENDING),
230
+ maxBytes: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_BYTES, DEFAULT_IMAGE_QUEUE_MAX_BYTES),
231
+ }
232
+ }
233
+
234
+ // Deadline covers fetch AND response parsing. Promise.race is deliberate: AbortSignal is
235
+ // still passed to real fetch, but an injected/broken fetch that ignores it cannot pin the
236
+ // ordered queue forever. activeUploads lets session teardown cancel the deadline now.
237
+ export async function uploadCodeImage({
238
+ webBase,
239
+ room,
240
+ authToken,
241
+ term,
242
+ cid,
243
+ image,
244
+ fetchImpl = globalThis.fetch,
245
+ timeoutMs = DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS,
246
+ activeUploads = new Set(),
247
+ }) {
248
+ const controller = new AbortController()
249
+ let timer = null
250
+ let rejectCancel = null
251
+ let settled = false
252
+
253
+ const cancelled = new Promise((_, reject) => { rejectCancel = reject })
254
+ const handle = {
255
+ cancel(reason = new Error('code-image upload cancelled')) {
256
+ if (settled) return
257
+ if (timer) { clearTimeout(timer); timer = null }
258
+ try { controller.abort(reason) } catch { /* AbortController is best-effort */ }
259
+ rejectCancel(reason instanceof Error ? reason : new Error(String(reason)))
260
+ },
261
+ }
262
+ activeUploads.add(handle)
263
+ timer = setTimeout(() => handle.cancel(new Error(`code-image upload timed out after ${timeoutMs}ms`)), timeoutMs)
264
+ timer.unref?.()
265
+
266
+ const headers = { 'Content-Type': 'application/json' }
267
+ if (authToken) headers.Authorization = `Bearer ${authToken}`
268
+ const request = (async () => {
269
+ const res = await fetchImpl(`${webBase}/api/code-image`, {
270
+ method: 'POST',
271
+ headers,
272
+ signal: controller.signal,
273
+ body: JSON.stringify({ code: room, term, cid, idx: image.idx, mediaType: image.mediaType, b64: image.b64 }),
274
+ })
275
+ if (!res.ok) throw new Error(`code-image ${res.status}`)
276
+ const { path } = await res.json()
277
+ if (!path) throw new Error('code-image: no path')
278
+ return path
279
+ })()
280
+
281
+ try {
282
+ return await Promise.race([request, cancelled])
283
+ } finally {
284
+ settled = true
285
+ if (timer) clearTimeout(timer)
286
+ activeUploads.delete(handle)
287
+ }
288
+ }
289
+
290
+ const encodedImageBytes = (image) => Buffer.byteLength(String(image?.b64 || ''), 'ascii')
291
+
292
+ function stripRawImages(evt, images, placeholder) {
293
+ for (const image of images) {
294
+ const block = Array.isArray(evt?.content) ? evt.content[image.idx] : null
295
+ if (block?.source?.type === 'base64' || image.b64) {
296
+ evt.content[image.idx] = { type: 'text', text: placeholder }
297
+ }
298
+ image.b64 = ''
299
+ }
300
+ }
301
+
302
+ export class ImageEventQueue {
303
+ constructor({
304
+ upload,
305
+ maxPending = DEFAULT_IMAGE_QUEUE_MAX_PENDING,
306
+ maxBytes = DEFAULT_IMAGE_QUEUE_MAX_BYTES,
307
+ placeholder = SCREENSHOT_OMITTED,
308
+ onUploadError = () => {},
309
+ onOverload = () => {},
310
+ }) {
311
+ if (typeof upload !== 'function') throw new TypeError('ImageEventQueue requires upload')
312
+ this.upload = upload
313
+ this.maxPending = sanePositiveInt(maxPending, DEFAULT_IMAGE_QUEUE_MAX_PENDING)
314
+ this.maxBytes = sanePositiveInt(maxBytes, DEFAULT_IMAGE_QUEUE_MAX_BYTES)
315
+ this.placeholder = placeholder
316
+ this.onUploadError = onUploadError
317
+ this.onOverload = onOverload
318
+ this.pendingCount = 0
319
+ this.pendingBytes = 0
320
+ this.peakPendingCount = 0
321
+ this.peakPendingBytes = 0
322
+ this.activeUploads = new Set()
323
+ this.tasks = new Set()
324
+ this.tail = Promise.resolve()
325
+ this.closed = false
326
+ this.overloadNotified = false
327
+ }
328
+
329
+ stats() {
330
+ return {
331
+ pendingCount: this.pendingCount,
332
+ pendingBytes: this.pendingBytes,
333
+ peakPendingCount: this.peakPendingCount,
334
+ peakPendingBytes: this.peakPendingBytes,
335
+ activeUploads: this.activeUploads.size,
336
+ queuedEvents: this.tasks.size,
337
+ }
338
+ }
339
+
340
+ enqueue(evt, images, emitTail) {
341
+ if (this.closed || typeof emitTail !== 'function') return false
342
+ const imgs = Array.isArray(images) ? images : []
343
+ const count = imgs.length
344
+ const bytes = imgs.reduce((sum, image) => sum + encodedImageBytes(image), 0)
345
+ const accepted = count > 0 &&
346
+ this.pendingCount + count <= this.maxPending &&
347
+ this.pendingBytes + bytes <= this.maxBytes
348
+
349
+ if (count && accepted) {
350
+ for (const image of imgs) {
351
+ image._queueBytes = encodedImageBytes(image)
352
+ image._queueReserved = true
353
+ }
354
+ this.pendingCount += count
355
+ this.pendingBytes += bytes
356
+ this.peakPendingCount = Math.max(this.peakPendingCount, this.pendingCount)
357
+ this.peakPendingBytes = Math.max(this.peakPendingBytes, this.pendingBytes)
358
+ } else if (count) {
359
+ // Critical memory boundary: discard raw base64 BEFORE this event can join a
360
+ // chain blocked behind an earlier upload.
361
+ stripRawImages(evt, imgs, this.placeholder)
362
+ if (!this.overloadNotified) {
363
+ this.overloadNotified = true
364
+ this.onOverload({ count, bytes, maxPending: this.maxPending, maxBytes: this.maxBytes })
365
+ }
366
+ }
367
+
368
+ // Preserve the old synchronous fast path when no image upload is ahead of us.
369
+ if (!accepted && this.tasks.size === 0) {
370
+ emitTail(evt)
371
+ return true
372
+ }
373
+
374
+ const task = { evt, images: imgs, emitTail, accepted, emitted: false, done: false }
375
+ this.tasks.add(task)
376
+ this.tail = this.tail.catch(() => {}).then(() => this._run(task))
377
+ return true
378
+ }
379
+
380
+ async _run(task) {
381
+ if (task.done || this.closed) return
382
+ try {
383
+ if (task.accepted) {
384
+ for (const image of task.images) {
385
+ if (!image._queueReserved || this.closed) continue
386
+ try {
387
+ const path = await this.upload(task.evt, image, this.activeUploads)
388
+ task.evt.content[image.idx] = { type: 'image', path, mediaType: image.mediaType }
389
+ } catch (error) {
390
+ task.evt.content[image.idx] = { type: 'text', text: this.placeholder }
391
+ if (!this.closed) this.onUploadError(error)
392
+ } finally {
393
+ image.b64 = ''
394
+ this._release(image)
395
+ }
396
+ }
397
+ }
398
+ if (!this.closed && !task.emitted) {
399
+ task.emitTail(task.evt)
400
+ task.emitted = true
401
+ }
402
+ } finally {
403
+ task.done = true
404
+ this.tasks.delete(task)
405
+ }
406
+ }
407
+
408
+ _release(image) {
409
+ if (!image?._queueReserved) return
410
+ image._queueReserved = false
411
+ this.pendingCount = Math.max(0, this.pendingCount - 1)
412
+ this.pendingBytes = Math.max(0, this.pendingBytes - (image._queueBytes || 0))
413
+ image._queueBytes = 0
414
+ }
415
+
416
+ async idle() {
417
+ await this.tail.catch(() => {})
418
+ }
419
+
420
+ // Session close/provider switch/shutdown is synchronous. Cancel active deadlines,
421
+ // replace any still-raw image, and flush queued tails in insertion order before the
422
+ // owner persists/deletes the entry. Later promise continuations see done/closed.
423
+ close({ flush = true } = {}) {
424
+ if (this.closed) return
425
+ this.closed = true
426
+ const reason = new Error('code-image queue closed')
427
+ for (const upload of this.activeUploads) upload.cancel(reason)
428
+ this.activeUploads.clear()
429
+ for (const task of this.tasks) {
430
+ stripRawImages(task.evt, task.images, this.placeholder)
431
+ for (const image of task.images) this._release(image)
432
+ if (flush && !task.emitted) {
433
+ try { task.emitTail(task.evt) } catch { /* teardown must continue */ }
434
+ task.emitted = true
435
+ }
436
+ task.done = true
437
+ }
438
+ this.tasks.clear()
439
+ this.pendingCount = 0
440
+ this.pendingBytes = 0
441
+ }
442
+ }
443
+
213
444
  // Pure aggregation for the /usage report — replay a session's retained log events and
214
445
  // fold every completed turn (kind:'result' with usage) into per-model + total token/cost
215
446
  // sums. No side effects, import-safe (bridge/test-usage-summary.mjs exercises it), so the
package/flow-models.mjs CHANGED
@@ -11,10 +11,11 @@ const CLAUDE_TIERS = {
11
11
 
12
12
  const CODEX_SCAFFOLD = ['gpt-5.6-luna', 'gpt-5.4-mini', 'gpt-5.3-codex-spark']
13
13
  const CODEX_BALANCED = ['gpt-5.6-terra', 'gpt-5.4']
14
+ const HERMES_REVIEW = ['nous:anthropic/claude-sonnet-4.6', 'nous:anthropic/claude-sonnet-4.5', 'nous:anthropic/claude-sonnet-4']
14
15
 
15
16
  export function normalizeFlowRuntime(runtime, fallback = null) {
16
- if (runtime === 'claude' || runtime === 'codex') return runtime
17
- return fallback === 'claude' || fallback === 'codex' ? fallback : null
17
+ if (runtime === 'claude' || runtime === 'codex' || runtime === 'hermes') return runtime
18
+ return fallback === 'claude' || fallback === 'codex' || fallback === 'hermes' ? fallback : null
18
19
  }
19
20
 
20
21
  export function modelCatalogValues(catalog = []) {
@@ -32,6 +33,12 @@ function firstVisible(candidates, catalog) {
32
33
  return undefined
33
34
  }
34
35
 
36
+ function firstVisibleMatching(catalog, patterns) {
37
+ const visible = modelCatalogValues(catalog)
38
+ for (const pattern of patterns) for (const model of visible) if (pattern.test(model)) return model
39
+ return undefined
40
+ }
41
+
35
42
  export function flowLaneModelFor({ sliceType, runtime = 'claude', catalog = [], env = process.env } = {}) {
36
43
  if (normalizeFlowRuntime(runtime, 'claude') === 'codex') {
37
44
  const override = env.TP_FLOW_CODEX_LANE_MODEL
@@ -39,6 +46,16 @@ export function flowLaneModelFor({ sliceType, runtime = 'claude', catalog = [],
39
46
  if (override) return modelCatalogValues(catalog).has(override) ? override : undefined
40
47
  return firstVisible(sliceType === 'scaffold' ? CODEX_SCAFFOLD : CODEX_BALANCED, catalog)
41
48
  }
49
+ if (normalizeFlowRuntime(runtime, 'claude') === 'hermes') {
50
+ const override = env.TP_FLOW_HERMES_LANE_MODEL
51
+ const visible = modelCatalogValues(catalog)
52
+ if (override === 'inherit') return undefined
53
+ if (override) return visible.has(override) ? override : undefined
54
+ if (sliceType === 'review') return firstVisible(HERMES_REVIEW, catalog)
55
+ || firstVisibleMatching(catalog, [/terra/i, /sonnet/i, /balanced/i])
56
+ if (sliceType === 'scaffold') return firstVisibleMatching(catalog, [/(?:luna|mini)/i])
57
+ return firstVisibleMatching(catalog, [/terra/i, /(?:luna|mini)/i])
58
+ }
42
59
  const override = env.TP_FLOW_CLAUDE_LANE_MODEL || env.TP_FLOW_LANE_MODEL
43
60
  if (override === 'inherit') return undefined
44
61
  if (override) return override
@@ -53,6 +70,13 @@ export function flowConductorModelFor({ runtime = 'claude', originModel, catalog
53
70
  if (override) return visible.has(override) ? override : undefined
54
71
  return originModel && visible.has(originModel) ? originModel : undefined
55
72
  }
73
+ if (normalizeFlowRuntime(runtime, 'claude') === 'hermes') {
74
+ const override = env.TP_FLOW_HERMES_CONDUCTOR_MODEL
75
+ const visible = modelCatalogValues(catalog)
76
+ if (override === 'inherit') return undefined
77
+ if (override) return visible.has(override) ? override : undefined
78
+ return originModel && visible.has(originModel) ? originModel : undefined
79
+ }
56
80
  const override = env.TP_FLOW_CLAUDE_CONDUCTOR_MODEL || env.TP_FLOW_CONDUCTOR_MODEL
57
81
  if (override === 'inherit') return undefined
58
82
  // Claude non-regression: the conductor historically inherited the host default,
@@ -176,10 +176,10 @@ export function normalizePlanOutput (raw) {
176
176
  // Codex review protocol deliberately maps one reviewer lane to one builder target.
177
177
  // Claude's legacy Flow plans may review several deps and remain unchanged.
178
178
  export function validatePlanForRuntime (plan, runtime = 'claude') {
179
- if (runtime !== 'codex') return plan
179
+ if (runtime !== 'codex' && runtime !== 'hermes') return plan
180
180
  for (const task of plan?.tasks || []) {
181
181
  if (task.sliceType === SLICE_TYPE.review && task.deps.length !== 1) {
182
- throw new Error(`Codex review task "${task.key}" must depend on exactly one builder task`)
182
+ throw new Error(`${runtime === 'hermes' ? 'Hermes' : 'Codex'} review task "${task.key}" must depend on exactly one builder task`)
183
183
  }
184
184
  }
185
185
  return plan
@@ -187,7 +187,7 @@ export function validatePlanForRuntime (plan, runtime = 'claude') {
187
187
 
188
188
  export function validReviewTargetShape (task, runtime = 'claude') {
189
189
  if (!task || task.slice_type !== SLICE_TYPE.review) return true
190
- return runtime !== 'codex' || (Array.isArray(task.deps) && task.deps.length === 1)
190
+ return (runtime !== 'codex' && runtime !== 'hermes') || (Array.isArray(task.deps) && task.deps.length === 1)
191
191
  }
192
192
 
193
193
  export function legacyBuilderCompletionAllowed ({ runtime, flowRole, eventKind, eventSubtype, interrupted = false } = {}) {
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ """Process-local ThinkPool Hermes ACP tool policy.
3
+
4
+ Never import this through the user profile. The bridge starts this file with
5
+ Hermes' installed venv interpreter and passes a validated role policy in env.
6
+ """
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ POLICY_ENV = "THINKPOOL_HERMES_ACP_POLICY"
12
+
13
+ CODING_TOOLS = frozenset({
14
+ "web_search", "web_extract", "terminal", "process", "read_file", "write_file",
15
+ "patch", "search_files", "vision_analyze", "skills_list", "skill_view",
16
+ "skill_manage", "browser_navigate", "browser_snapshot", "browser_click",
17
+ "browser_type", "browser_scroll", "browser_back", "browser_press",
18
+ "browser_get_images", "browser_vision", "browser_console", "browser_cdp",
19
+ "browser_dialog", "todo", "memory", "execute_code",
20
+ })
21
+ READ_ONLY_TOOLS = frozenset({"read_file", "search_files"})
22
+ ESSENTIAL_CODING_TOOLS = frozenset({
23
+ "terminal", "process", "read_file", "write_file", "patch", "search_files",
24
+ })
25
+
26
+ def die(message):
27
+ print("ThinkPool Hermes ACP policy error: " + message, file=sys.stderr)
28
+ raise SystemExit(78)
29
+
30
+ def policy():
31
+ raw = os.environ.get(POLICY_ENV)
32
+ try:
33
+ value = json.loads(raw)
34
+ except Exception:
35
+ die("missing or malformed policy")
36
+ if not isinstance(value, dict) or value.get("version") != 1:
37
+ die("unsupported policy")
38
+ role = value.get("role")
39
+ builtin = value.get("builtinTools")
40
+ required_builtin = value.get("requiredBuiltinTools")
41
+ tools = value.get("mcpTools")
42
+ if role not in {"ordinary", "builder", "conductor", "reviewer", "manual-review"}:
43
+ die("unknown role")
44
+ if value.get("mcpServer") != "thinkpool" or not isinstance(builtin, list) or not isinstance(required_builtin, list) or not isinstance(tools, list):
45
+ die("invalid tool policy")
46
+ if not all(isinstance(x, str) and x and x.replace("_", "").isalnum() for x in builtin + required_builtin + tools):
47
+ die("invalid tool name")
48
+ forbidden = {"delegate_task", "session_search"}
49
+ if forbidden.intersection(builtin) or forbidden.intersection(required_builtin) or forbidden.intersection(tools):
50
+ die("delegation and session search are forbidden")
51
+ required_mcp = {
52
+ "ordinary": {"read_terminal"}, "builder": {"mark_flow_done"},
53
+ "conductor": {"submit_flow_plan"},
54
+ "reviewer": {"submit_flow_review", "run_review_check", "read_review_file"},
55
+ "manual-review": {"run_review_check", "read_review_file"},
56
+ }
57
+ if not required_mcp[role].issubset(tools):
58
+ die("incomplete role MCP policy")
59
+ restricted = role in {"conductor", "reviewer", "manual-review"}
60
+ allowed_builtin = READ_ONLY_TOOLS if restricted else CODING_TOOLS
61
+ required = READ_ONLY_TOOLS if restricted else ESSENTIAL_CODING_TOOLS
62
+ if set(builtin) != allowed_builtin:
63
+ die("restricted role requires exact read-only builtins" if restricted else "coding role requires the full approved builtin allowlist")
64
+ if set(required_builtin) != required:
65
+ die("invalid required builtin policy")
66
+ return role, tuple(dict.fromkeys(builtin)), tuple(dict.fromkeys(required_builtin)), tuple(dict.fromkeys(tools))
67
+
68
+ ROLE, BUILTIN, REQUIRED_BUILTIN, MCP_TOOLS = policy()
69
+ ALLOWED = frozenset(BUILTIN) | frozenset("mcp__thinkpool__" + x for x in MCP_TOOLS) | frozenset("mcp_thinkpool_" + x for x in MCP_TOOLS)
70
+
71
+ def name_of(schema):
72
+ if not isinstance(schema, dict): return ""
73
+ fn = schema.get("function")
74
+ return fn.get("name", "") if isinstance(fn, dict) else schema.get("name", "")
75
+
76
+ def filter_schemas(items):
77
+ return [item for item in (items or []) if name_of(item) in ALLOWED]
78
+
79
+ def assert_exact_inventory(agent):
80
+ """Reject an ACP lifecycle that lost the bridge-owned MCP surface.
81
+
82
+ Hermes treats registration errors as non-fatal. That is acceptable for a
83
+ standalone CLI, but never for a ThinkPool role: the bridge must not let a
84
+ reconstructed agent accept a prompt with a partial policy.
85
+ """
86
+ tools = list(getattr(agent, "tools", []) or [])
87
+ names = {name_of(item) for item in tools}
88
+ # Allowed is deliberately broader than required for ordinary/builder:
89
+ # browser/provider/vision integrations are availability-gated upstream.
90
+ missing_builtin = set(REQUIRED_BUILTIN) - names
91
+ missing_mcp = [name for name in MCP_TOOLS if not ({"mcp__thinkpool__" + name, "mcp_thinkpool_" + name} & names)]
92
+ extras = names - ALLOWED
93
+ if missing_builtin or missing_mcp or extras:
94
+ details = []
95
+ if missing_builtin: details.append("missing builtins " + ", ".join(sorted(missing_builtin)))
96
+ if missing_mcp: details.append("missing MCP " + ", ".join(sorted(missing_mcp)))
97
+ if extras: details.append("forbidden extras " + ", ".join(sorted(extras)))
98
+ raise RuntimeError("ThinkPool exact inventory is incomplete: " + "; ".join(details))
99
+ agent.tools = tools
100
+ agent.valid_tool_names = names
101
+
102
+ # Patch before importing ACP server. Each new/resumed/reset ACP process reloads
103
+ # this exact policy; no mutable shell alias or profile config participates.
104
+ import toolsets
105
+ toolsets.TOOLSETS["hermes-acp"] = {
106
+ "description": "ThinkPool process-local ACP policy",
107
+ "tools": list(BUILTIN), "includes": []
108
+ }
109
+
110
+ # entry.main() normally discovers profile-configured MCP servers before it
111
+ # creates the ACP server. ThinkPool ACP never inherits those profile servers;
112
+ # the only permitted registration is the bridge's per-session `thinkpool` one.
113
+ import tools.mcp_tool
114
+ tools.mcp_tool.discover_mcp_tools = lambda *args, **kwargs: []
115
+
116
+ import model_tools
117
+ _get_definitions = model_tools.get_tool_definitions
118
+ def constrained_definitions(*args, **kwargs):
119
+ return filter_schemas(_get_definitions(*args, **kwargs))
120
+ model_tools.get_tool_definitions = constrained_definitions
121
+
122
+ import agent.memory_manager
123
+ _inject_memory = agent.memory_manager.inject_memory_provider_tools
124
+ def constrained_memory(agent):
125
+ result = _inject_memory(agent)
126
+ if hasattr(agent, "tools"):
127
+ agent.tools = filter_schemas(agent.tools)
128
+ agent.valid_tool_names = {name_of(x) for x in agent.tools}
129
+ return result
130
+ agent.memory_manager.inject_memory_provider_tools = constrained_memory
131
+
132
+ import acp_adapter.session
133
+ _expand = acp_adapter.session._expand_acp_enabled_toolsets
134
+ def constrained_expand(toolsets_arg=None, mcp_server_names=None):
135
+ requested = list(toolsets_arg or ["hermes-acp"])
136
+ if any(name not in {"hermes-acp", "mcp-thinkpool"} for name in requested):
137
+ raise RuntimeError("ThinkPool ACP only permits hermes-acp and mcp-thinkpool toolsets")
138
+ names = list(mcp_server_names or [])
139
+ if any(name != "thinkpool" for name in names):
140
+ raise RuntimeError("ThinkPool ACP only permits dynamic MCP server thinkpool")
141
+ # Hermes 0.18.2 calls this from /tools with an already-expanded
142
+ # ["hermes-acp", "mcp-thinkpool"] list and no mcp_server_names. Preserve
143
+ # that exact legal expansion; otherwise /tools silently omits ThinkPool.
144
+ return ["hermes-acp"] + (["mcp-thinkpool"] if names or "mcp-thinkpool" in requested else [])
145
+ acp_adapter.session._expand_acp_enabled_toolsets = constrained_expand
146
+
147
+ import acp_adapter.server
148
+ _register = acp_adapter.server.HermesACPAgent._register_session_mcp_servers
149
+ async def constrained_register(self, state, mcp_servers):
150
+ if any(getattr(server, "name", None) != "thinkpool" for server in (mcp_servers or [])):
151
+ raise RuntimeError("ThinkPool ACP only permits dynamic MCP server thinkpool")
152
+ if mcp_servers:
153
+ # SessionState is process-local. Keep only the validated descriptors so
154
+ # a subsequent set_model reconstruction can re-register the same MCP.
155
+ state._thinkpool_mcp_servers = tuple(mcp_servers)
156
+ await _register(self, state, mcp_servers)
157
+ assert_exact_inventory(state.agent)
158
+ acp_adapter.server.HermesACPAgent._register_session_mcp_servers = constrained_register
159
+
160
+ # Hermes 0.18.2's session/set_model creates a fresh state.agent. Upstream
161
+ # does not re-run ACP MCP registration, so the new agent can expose only its
162
+ # built-ins while the request still returns success. Keep the old state until
163
+ # the replacement has re-registered and passed the same exact policy check.
164
+ _set_model = acp_adapter.server.HermesACPAgent.set_session_model
165
+ async def constrained_set_model(self, model_id, session_id, **kwargs):
166
+ state = self.session_manager.get_session(session_id)
167
+ if state is None:
168
+ return await _set_model(self, model_id, session_id, **kwargs)
169
+ old_agent, old_model = state.agent, getattr(state, "model", None)
170
+ try:
171
+ result = await _set_model(self, model_id, session_id, **kwargs)
172
+ if result is None:
173
+ raise RuntimeError("Hermes did not acknowledge model switch")
174
+ servers = getattr(state, "_thinkpool_mcp_servers", ())
175
+ if MCP_TOOLS and not servers:
176
+ raise RuntimeError("ThinkPool MCP registration is unavailable after model switch")
177
+ await constrained_register(self, state, list(servers))
178
+ assert_exact_inventory(state.agent)
179
+ self.session_manager.save_session(session_id)
180
+ return result
181
+ except Exception:
182
+ # Fail closed and restore the prior usable agent/model. A later bridge
183
+ # /tools probe is the external acknowledgement before UI persistence.
184
+ state.agent, state.model = old_agent, old_model
185
+ try:
186
+ servers = getattr(state, "_thinkpool_mcp_servers", ())
187
+ if servers:
188
+ await constrained_register(self, state, list(servers))
189
+ self.session_manager.save_session(session_id)
190
+ except Exception:
191
+ pass
192
+ raise
193
+ acp_adapter.server.HermesACPAgent.set_session_model = constrained_set_model
194
+
195
+ from acp_adapter.entry import main
196
+ main()
@@ -0,0 +1,85 @@
1
+ // Bridge-owned Hermes ACP schema policy. This is intentionally a small, pure
2
+ // contract: Python receives only this JSON and fails closed for anything else.
3
+ export const HERMES_POLICY_VERSION = 1
4
+
5
+ export const CODING_TOOLS = Object.freeze([
6
+ 'web_search', 'web_extract', 'terminal', 'process', 'read_file', 'write_file',
7
+ 'patch', 'search_files', 'vision_analyze', 'skills_list', 'skill_view',
8
+ 'skill_manage', 'browser_navigate', 'browser_snapshot', 'browser_click',
9
+ 'browser_type', 'browser_scroll', 'browser_back', 'browser_press',
10
+ 'browser_get_images', 'browser_vision', 'browser_console', 'browser_cdp',
11
+ 'browser_dialog', 'todo', 'memory', 'execute_code',
12
+ ])
13
+
14
+ const READ_ONLY_TOOLS = Object.freeze(['read_file', 'search_files'])
15
+ // Hermes providers advertise capabilities conditionally (for example browser
16
+ // CDP/dialog support depends on the installed browser integration). These are
17
+ // the only builtins an ordinary or builder lane must have to do useful coding
18
+ // work; the complete coding allowlist above remains permitted when available.
19
+ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
20
+ 'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
21
+ ])
22
+ const ROLE_REQUIRED = Object.freeze({
23
+ ordinary: ['read_terminal', 'spawn_terminal', 'close_terminal'],
24
+ conductor: ['submit_flow_plan'],
25
+ builder: ['mark_flow_done'],
26
+ reviewer: ['submit_flow_review', 'run_review_check', 'read_review_file'],
27
+ 'manual-review': ['run_review_check', 'read_review_file'],
28
+ })
29
+
30
+ export function hermesRoleFor({ flowRole, sliceType } = {}) {
31
+ if (flowRole === 'conductor') return 'conductor'
32
+ if (flowRole === 'reviewer') return 'reviewer'
33
+ if (sliceType === 'review') return 'manual-review'
34
+ if (flowRole === 'builder') return 'builder'
35
+ return 'ordinary'
36
+ }
37
+
38
+ export function hermesPolicyForRole(role, { mcpTools } = {}) {
39
+ if (!Object.hasOwn(ROLE_REQUIRED, role)) throw new Error(`Unknown Hermes role policy: ${role}`)
40
+ const supplied = Array.isArray(mcpTools) ? mcpTools.map(String).filter(Boolean) : ROLE_REQUIRED[role]
41
+ const required = ROLE_REQUIRED[role]
42
+ // Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
43
+ // enforced by requiredMcpTools at dispatch; keep this schema usable for a
44
+ // non-delegating ordinary child without widening it.
45
+ const minimum = role === 'ordinary' ? ['read_terminal'] : required
46
+ 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 builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
49
+ const requiredBuiltinTools = restricted ? READ_ONLY_TOOLS : ESSENTIAL_CODING_TOOLS
50
+ return Object.freeze({
51
+ version: HERMES_POLICY_VERSION,
52
+ role,
53
+ // `builtinTools` is the role's complete approved surface, not a promise
54
+ // that an availability-gated upstream provider implements every tool.
55
+ builtinTools,
56
+ requiredBuiltinTools,
57
+ mcpServer: 'thinkpool',
58
+ mcpTools: [...new Set(supplied)].sort(),
59
+ })
60
+ }
61
+
62
+ export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
63
+ if (role === 'ordinary') return ['read_terminal', ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
64
+ return [...ROLE_REQUIRED[role]]
65
+ }
66
+
67
+ // Keep the bridge's local /tools proof on the same canonical inventory as the
68
+ // process-local Python bootstrap. `allBuiltinTools` lets that proof reject a
69
+ // known built-in which is not part of this role, rather than merely looking
70
+ // for a couple of required MCP names.
71
+ export function hermesExactInventory(role, options = {}) {
72
+ const policy = hermesPolicyForRole(role, options)
73
+ return Object.freeze({
74
+ builtinTools: [...policy.builtinTools].sort(),
75
+ requiredBuiltinTools: [...policy.requiredBuiltinTools].sort(),
76
+ mcpTools: [...policy.mcpTools].sort(),
77
+ // The two excluded upstream capabilities must be checked as forbidden too;
78
+ // they are intentionally absent from CODING_TOOLS, not unknown to policy.
79
+ allBuiltinTools: [...CODING_TOOLS, 'delegate_task', 'session_search'].sort(),
80
+ })
81
+ }
82
+
83
+ export function hermesPolicyEnv(role, options = {}) {
84
+ return JSON.stringify(hermesPolicyForRole(role, options))
85
+ }
package/hermes-probe.mjs CHANGED
@@ -1,8 +1,36 @@
1
1
  import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
2
4
 
3
5
  const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
4
6
 
5
- export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], execFile = execFileSync, env = process.env } = {}) {
7
+ const INSTALL = /Install directory:\s*(.+?)(?:\r?\n|$)/i
8
+ const PROFILE = /Config:\s*(.+?)(?:\r?\n|$)/i
9
+
10
+ // Resolve the installed venv and isolated profile once, then launch ACP through
11
+ // bridge-owned code. `thinkpool` is only queried for inventory; it is never the
12
+ // executable that serves an ACP lane.
13
+ export function resolveHermesAcpRuntime({ command = 'thinkpool', execFile = execFileSync, exists = fs.existsSync, bootstrap = new URL('./hermes-acp-bootstrap.py', import.meta.url) } = {}) {
14
+ try {
15
+ const versionOutput = execFile(command, ['--version'], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
16
+ const install = clean(String(versionOutput).match(INSTALL)?.[1])
17
+ if (!install || !path.isAbsolute(install)) throw new Error('Hermes did not report an absolute install directory')
18
+ const python = path.join(install, 'venv', 'bin', 'python')
19
+ const bootstrapPath = bootstrap instanceof URL ? bootstrap.pathname : String(bootstrap)
20
+ if (!exists(python) || !exists(bootstrapPath)) throw new Error('Hermes venv Python or ThinkPool ACP bootstrap is missing')
21
+ // `thinkpool` is the dedicated profile wrapper on supported installs. Its
22
+ // config output is evidence, not the ACP launch path.
23
+ const configOutput = execFile(command, ['config', 'show'], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
24
+ const config = clean(String(configOutput).match(PROFILE)?.[1])
25
+ const profile = config ? path.dirname(config) : ''
26
+ if (!profile || !path.isAbsolute(profile) || path.basename(profile) !== 'thinkpool') throw new Error('Hermes did not report the isolated thinkpool profile')
27
+ return { python, bootstrap: bootstrapPath, profile, install }
28
+ } catch (error) {
29
+ return { error: clean(error?.stderr || error?.message || error) || 'could not resolve Hermes ACP runtime' }
30
+ }
31
+ }
32
+
33
+ export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], execFile = execFileSync, env = process.env, strictBootstrap = false, exists = fs.existsSync } = {}) {
6
34
  try {
7
35
  const versionOutput = execFile(command, [...prefixArgs, '--version'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
8
36
  const version = clean(versionOutput).match(/Hermes Agent v([^\s]+)/i)?.[1] || null
@@ -16,7 +44,9 @@ export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], exe
16
44
  && /ThinkPool blocks hidden Hermes delegation/i.test(guardOutput)
17
45
  && /pre_tool_call[\s\S]*delegate_task[\s\S]*(?:✓ allowed|allowed)/i.test(hooksOutput)
18
46
  if (!doctorHealthy || !delegationBlocked) return { available: false, version, reason: 'delegate_task guard is not healthy, unchanged, allowlisted, and structurally blocking in the dedicated Hermes profile' }
19
- return { available: true, version, acpProtocol: 1, delegationBlocked: true }
47
+ const runtime = strictBootstrap ? resolveHermesAcpRuntime({ command, execFile, exists }) : null
48
+ if (runtime?.error) return { available: false, version, reason: `Hermes ACP bootstrap unavailable: ${runtime.error}` }
49
+ return { available: true, version, acpProtocol: 1, delegationBlocked: true, ...(runtime || {}) }
20
50
  } catch (error) {
21
51
  return { available: false, version: null, reason: clean(error?.stderr || error?.message || error) }
22
52
  }