thinkpool-pair 0.7.246 → 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/bridge.mjs +59 -20
- package/flow-models.mjs +26 -2
- package/flow-task-graph.mjs +3 -3
- package/hermes-acp-bootstrap.py +196 -0
- package/hermes-policy.mjs +85 -0
- package/hermes-probe.mjs +32 -2
- package/hermes-session.mjs +62 -13
- package/package.json +4 -1
- package/review-check.mjs +155 -0
- package/runtime-registry.mjs +1 -1
package/bridge.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
|
54
54
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
55
55
|
import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
56
56
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
57
|
+
import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
|
|
57
58
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
58
59
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
59
60
|
import { commandOnPath } from './agent-detect.mjs'
|
|
@@ -82,6 +83,7 @@ function stopFlowPreviews (flowId, laneId = null) {
|
|
|
82
83
|
}
|
|
83
84
|
import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
|
|
84
85
|
import { reviewGateDecision } from './flow-review-gate.mjs'
|
|
86
|
+
import { readReviewFile, runReviewCheck } from './review-check.mjs'
|
|
85
87
|
import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
|
|
86
88
|
import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
|
|
87
89
|
import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
|
|
@@ -1727,6 +1729,12 @@ function restoredTurnOpen(log) {
|
|
|
1727
1729
|
// minutes-scale. Plain `git worktree list` output (path · sha · [branch]) is the
|
|
1728
1730
|
// readable shape an agent acts on. Fail-quiet [] — a snapshot must never break a turn.
|
|
1729
1731
|
const _wtCache = new Map() // cwd → { at, list }
|
|
1732
|
+
function immutableReviewSnapshot(cwd, taskKey = 'parent') {
|
|
1733
|
+
const source = path.resolve(cwd || process.cwd())
|
|
1734
|
+
const sha = execFileSync('git', ['-C', source, 'rev-parse', '--verify', 'HEAD^{commit}'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
1735
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error('parent review source has no immutable HEAD')
|
|
1736
|
+
return { taskKey, cwd: source, sha }
|
|
1737
|
+
}
|
|
1730
1738
|
function worktreeSnapshot(cwd) {
|
|
1731
1739
|
const key = cwd || process.cwd()
|
|
1732
1740
|
const hit = _wtCache.get(key)
|
|
@@ -1744,7 +1752,7 @@ function worktreeSnapshot(cwd) {
|
|
|
1744
1752
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1745
1753
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1746
1754
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1747
|
-
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1755
|
+
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1748
1756
|
if (sessions.has(id)) return
|
|
1749
1757
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
1750
1758
|
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
@@ -1797,9 +1805,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
1797
1805
|
? (laneModel || null)
|
|
1798
1806
|
: (laneModel || providerNameMap()[provider] || provider),
|
|
1799
1807
|
provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
1808
|
+
sliceType: sliceType === 'review' ? 'review' : null,
|
|
1800
1809
|
flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
|
|
1801
1810
|
flowReviewTarget: flowReviewTarget || null,
|
|
1802
1811
|
flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
|
|
1812
|
+
flowReviewSnapshots: Array.isArray(flowReviewSnapshots) ? flowReviewSnapshots.filter((item) => item?.taskKey && item?.sha && item?.cwd) : [],
|
|
1803
1813
|
flowReviewRound: Number.isInteger(flowReviewRound) && flowReviewRound >= 0 ? flowReviewRound : 0,
|
|
1804
1814
|
dispatchBaseSha: dispatchBaseSha || null,
|
|
1805
1815
|
revertTarget: revertTarget || null,
|
|
@@ -2046,7 +2056,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2046
2056
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
2047
2057
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
2048
2058
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
2049
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
|
|
2059
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
|
|
2050
2060
|
const persist = () => saveSession(room, id, sessionData())
|
|
2051
2061
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
2052
2062
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -2074,7 +2084,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2074
2084
|
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.` }
|
|
2075
2085
|
}
|
|
2076
2086
|
if (runtime === 'hermes' && args?.mode && !['default', 'acceptEdits'].includes(args.mode)) {
|
|
2077
|
-
return { error: `Hermes ACP
|
|
2087
|
+
return { error: `Hermes ACP only exposes default/acceptEdits as user modes; Flow roles use a bridge-owned process-local tool policy.` }
|
|
2078
2088
|
}
|
|
2079
2089
|
if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
|
|
2080
2090
|
return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
|
|
@@ -2342,7 +2352,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2342
2352
|
// not receive this tool, so the hierarchy is capability-enforced.
|
|
2343
2353
|
...(canSpawnWorkers ? [tool(
|
|
2344
2354
|
'spawn_terminal',
|
|
2345
|
-
'Open a visible WORKER SUB-TERMINAL (Claude, Codex, or—only from a Hermes parent—Hermes) in the ThinkPool Ensemble. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal.
|
|
2355
|
+
'Open a visible WORKER SUB-TERMINAL (Claude, Codex, or—only from a Hermes parent—Hermes) in the ThinkPool Ensemble. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal. For ordinary workers use an appropriate non-Sol tier: gpt-5.6-luna or gpt-5.4-mini for scaffold/search, gpt-5.6-terra for feature/fix, and a balanced tier for adversarial review; never select Sol for routine workers. A Hermes sliceType=review lane is structurally reviewer-scoped before ACP startup: it can inspect files and use the bridge review-check tool, but has no terminal, write, patch, delegate, browser, skill, memory, or session-history schema. Pass sliceType=scaffold for mechanical work, feature/fix for builders, and review for adversarial verification. Give the worker a bounded initial task, collect its result with read_terminal, then ALWAYS close_terminal it.',
|
|
2346
2356
|
{
|
|
2347
2357
|
name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
|
|
2348
2358
|
task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
|
|
@@ -2438,10 +2448,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2438
2448
|
// otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
|
|
2439
2449
|
const childSpawnDepth = (entry.spawnDepth || 0) + 1
|
|
2440
2450
|
const childHop = (entry.hop || 0) + 1
|
|
2451
|
+
let manualReviewSnapshots = []
|
|
2452
|
+
if (args?.sliceType === 'review') {
|
|
2453
|
+
// A manual reviewer never accepts a model-supplied path/ref. It
|
|
2454
|
+
// reads this exact parent HEAD through read_review_file and runs
|
|
2455
|
+
// checks only through its archived `parent` target.
|
|
2456
|
+
try { manualReviewSnapshots = [immutableReviewSnapshot(entry.cwd || process.cwd())] }
|
|
2457
|
+
catch (error) { return okText(`Review lane was not opened: could not pin the spawning parent’s HEAD (${error?.message || error}).`) }
|
|
2458
|
+
}
|
|
2441
2459
|
// Stamp ownership/depth BEFORE the runtime starts so its first system
|
|
2442
2460
|
// preamble is truthful. Mutating ne.spawnedBy after openStructured was
|
|
2443
2461
|
// too late: Codex/Claude had already booted with the top-level wording.
|
|
2444
|
-
openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
|
|
2462
|
+
openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
|
|
2445
2463
|
const ne = sessions.get(newId)
|
|
2446
2464
|
if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
|
|
2447
2465
|
ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
|
|
@@ -2449,7 +2467,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2449
2467
|
if (args?.task) {
|
|
2450
2468
|
// The user-visible relay reinforces (but never defines) the structural
|
|
2451
2469
|
// role already injected by the system preamble above.
|
|
2452
|
-
const
|
|
2470
|
+
const reviewTarget = manualReviewSnapshots.length
|
|
2471
|
+
? `\n\n[Immutable review target: parent at spawn time — target key "parent", pinned HEAD ${manualReviewSnapshots[0].sha}. Use read_review_file with target="parent" for source and run_review_check with target="parent" for fixed checks. Do not infer a target path/ref or mutate anything.]`
|
|
2472
|
+
: ''
|
|
2473
|
+
const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
|
|
2453
2474
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2454
2475
|
stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
|
|
2455
2476
|
try { ne.session.sendTurn(msg) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
|
|
@@ -2564,6 +2585,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2564
2585
|
return { content: [{ type: 'text', text: result.message }] }
|
|
2565
2586
|
},
|
|
2566
2587
|
)] : []),
|
|
2588
|
+
...(entry.flowRole === 'reviewer' || entry.sliceType === 'review' ? [tool(
|
|
2589
|
+
'read_review_file',
|
|
2590
|
+
'Read one regular file from the declared immutable review target. This always reads the target’s pinned Git object, never a live worktree; target and path are validated and the response is byte-bounded.',
|
|
2591
|
+
{ target: z.string(), path: z.string() },
|
|
2592
|
+
async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await readReviewFile({ target: args?.target, filePath: args?.path, snapshots: entry.flowReviewSnapshots })) }] }),
|
|
2593
|
+
), tool(
|
|
2594
|
+
'run_review_check',
|
|
2595
|
+
'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id; it never accepts a shell command, cwd, environment, executable, or arguments. The check runs in a scratch archive, never in a builder worktree.',
|
|
2596
|
+
{ target: z.string(), checkId: z.string() },
|
|
2597
|
+
async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await runReviewCheck({ target: args?.target, checkId: args?.checkId, snapshots: entry.flowReviewSnapshots })) }] }),
|
|
2598
|
+
)] : []),
|
|
2567
2599
|
],
|
|
2568
2600
|
})
|
|
2569
2601
|
const peekServer = withMcpSessionFactory(createPeekServer)
|
|
@@ -2663,8 +2695,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2663
2695
|
// role-scoped tools through local /tools before a user turn. Claude/Codex
|
|
2664
2696
|
// already fail startup when their required MCP handshake fails.
|
|
2665
2697
|
requiredMcpTools: runtime === 'hermes'
|
|
2666
|
-
?
|
|
2698
|
+
? hermesRequiredMcpTools(hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }), { canSpawnWorkers })
|
|
2667
2699
|
: undefined,
|
|
2700
|
+
hermesRole: runtime === 'hermes' ? hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }) : undefined,
|
|
2668
2701
|
// Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
|
|
2669
2702
|
// disabled/looping/over-cap post never bothers a person. Closes over `entry`.
|
|
2670
2703
|
crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
|
|
@@ -2717,9 +2750,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2717
2750
|
openStructured({
|
|
2718
2751
|
id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
|
|
2719
2752
|
provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
|
|
2720
|
-
spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId,
|
|
2753
|
+
spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId,
|
|
2721
2754
|
flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
|
|
2722
|
-
flowReviewTargets: entry.flowReviewTargets, flowReviewRound: entry.flowReviewRound,
|
|
2755
|
+
flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
|
|
2723
2756
|
dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
|
|
2724
2757
|
managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
|
|
2725
2758
|
reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
|
|
@@ -3155,7 +3188,7 @@ function respawnStructured(id, provider) {
|
|
|
3155
3188
|
// openStructured seed from the TARGET provider's configured model, which is the
|
|
3156
3189
|
// only model this lane was ever asked for. A same-env model change never reaches
|
|
3157
3190
|
// here — that path is an in-place setModel (see provider-switch).
|
|
3158
|
-
const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
|
|
3191
|
+
const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
|
|
3159
3192
|
// Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
|
|
3160
3193
|
// starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
|
|
3161
3194
|
// (before teardown) and hand it to the fresh session as its first turn so the agent
|
|
@@ -3174,7 +3207,7 @@ function respawnStructured(id, provider) {
|
|
|
3174
3207
|
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
3175
3208
|
// restores the lane on its CURRENT provider, not the original — and its next
|
|
3176
3209
|
// announce carries the new provider badge (additive {id,name} projection).
|
|
3177
|
-
openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
|
|
3210
|
+
openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
|
|
3178
3211
|
return true
|
|
3179
3212
|
}
|
|
3180
3213
|
|
|
@@ -3988,7 +4021,7 @@ channel
|
|
|
3988
4021
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3989
4022
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3990
4023
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3991
|
-
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
|
|
4024
|
+
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
|
|
3992
4025
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3993
4026
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3994
4027
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
|
@@ -4103,7 +4136,7 @@ flowChannel
|
|
|
4103
4136
|
if (payload.originTerm && !origin) return
|
|
4104
4137
|
const flowRuntime = origin ? normalizeFlowRuntime(origin.runtime, null) : 'claude'
|
|
4105
4138
|
if (!flowRuntime) return
|
|
4106
|
-
const flowCatalog = flowRuntime === 'codex' ? (origin?.models || readCodexModels()) : []
|
|
4139
|
+
const flowCatalog = flowRuntime === 'codex' ? (origin?.models || readCodexModels()) : flowRuntime === 'hermes' ? (origin?.models || []) : []
|
|
4107
4140
|
const conductorModel = flowConductorModelFor({ runtime: flowRuntime, originModel: origin?.model, catalog: flowCatalog })
|
|
4108
4141
|
const cid = randomUUID()
|
|
4109
4142
|
termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
|
|
@@ -4115,7 +4148,7 @@ flowChannel
|
|
|
4115
4148
|
// Model tiers (2026-07-03-flow-lane-model-tiers): the conductor keeps whatever brain it was
|
|
4116
4149
|
// given by default (TP_FLOW_CONDUCTOR_MODEL unset → undefined → today's behavior); set the env
|
|
4117
4150
|
// to pin a cheaper/smarter conductor. Lanes get tiered below via laneModelFor.
|
|
4118
|
-
openStructured({ id: cid, runtime: flowRuntime, model: conductorModel, mode: flowRuntime === 'codex' ? 'plan' : 'default', rolePrompt: flowRuntime === '
|
|
4151
|
+
openStructured({ id: cid, runtime: flowRuntime, model: conductorModel, mode: flowRuntime === 'codex' ? 'plan' : 'default', rolePrompt: flowRuntime === 'claude' ? FLOW_CONDUCTOR_PROMPT : FLOW_CODEX_CONDUCTOR_PROMPT, flowSessionId: payload.flowId, flowRole: 'conductor', spawnedBy: `flow:${payload.flowId}` })
|
|
4119
4152
|
const ce = sessions.get(cid)
|
|
4120
4153
|
if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
|
|
4121
4154
|
process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
|
|
@@ -4136,7 +4169,7 @@ flowChannel
|
|
|
4136
4169
|
}
|
|
4137
4170
|
const flowRuntime = normalizeFlowRuntime(conductor.runtime, null)
|
|
4138
4171
|
if (!flowRuntime) return
|
|
4139
|
-
const flowCatalog = flowRuntime === 'codex' ? (conductor.models || readCodexModels()) : []
|
|
4172
|
+
const flowCatalog = flowRuntime === 'codex' ? (conductor.models || readCodexModels()) : flowRuntime === 'hermes' ? (conductor.models || []) : []
|
|
4140
4173
|
const assignments = []
|
|
4141
4174
|
// Step 6 — cap concurrent Flow lanes (invariant: ≤ FLOW_LIMITS.maxConcurrentLanes).
|
|
4142
4175
|
// Overflow tasks stay pending; the room re-dispatches them in the next wave.
|
|
@@ -4166,7 +4199,7 @@ flowChannel
|
|
|
4166
4199
|
// every other slice gets the builder prompt.
|
|
4167
4200
|
const isReview = t.slice_type === 'review'
|
|
4168
4201
|
if (!validReviewTargetShape(t, flowRuntime)) {
|
|
4169
|
-
process.stderr.write(`\n ${A.yel}◆ flow dispatch held — Codex review ${t.task_key} must target exactly one dependency.${A.rst}\n`)
|
|
4202
|
+
process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${flowRuntime === 'hermes' ? 'Hermes' : 'Codex'} review ${t.task_key} must target exactly one dependency.${A.rst}\n`)
|
|
4170
4203
|
continue
|
|
4171
4204
|
}
|
|
4172
4205
|
const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
|
|
@@ -4194,6 +4227,12 @@ flowChannel
|
|
|
4194
4227
|
const reviewSliceRoots = isReview
|
|
4195
4228
|
? (t.deps || []).map((dep) => worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir)
|
|
4196
4229
|
: []
|
|
4230
|
+
const flowReviewSnapshots = isReview ? (t.deps || []).map((dep) => {
|
|
4231
|
+
const cwd = worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir
|
|
4232
|
+
let sha = null
|
|
4233
|
+
try { sha = execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* target is not safely reviewable */ }
|
|
4234
|
+
return { taskKey: dep, cwd, sha }
|
|
4235
|
+
}).filter((item) => item.sha) : []
|
|
4197
4236
|
// Lanes build autonomously in their own worktree — bypassPermissions so they
|
|
4198
4237
|
// don't stall on a card for every write/bash (matches the user's expectation that
|
|
4199
4238
|
// a Flow summoned from a bypass terminal runs hands-off).
|
|
@@ -4204,14 +4243,14 @@ flowChannel
|
|
|
4204
4243
|
// a lane later, on demand, via activateLaneSkill — never the base prompt here.
|
|
4205
4244
|
// S4 — resume: on a re-dispatch, replay the killed lane's HEALED transcript (Heal-3'd,
|
|
4206
4245
|
// no dangling tool_use → no 400) instead of a cold start; undefined for a fresh lane.
|
|
4207
|
-
const laneBase = flowRuntime === 'codex'
|
|
4246
|
+
const laneBase = flowRuntime === 'codex' || flowRuntime === 'hermes'
|
|
4208
4247
|
? (isReview ? FLOW_CODEX_REVIEWER_PROMPT : FLOW_CODEX_LANE_PROMPT)
|
|
4209
4248
|
: (isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT)
|
|
4210
4249
|
const laneRolePrompt = buildLanePrompt({ base: laneBase })
|
|
4211
4250
|
// Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
|
|
4212
4251
|
// (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
|
|
4213
4252
|
// restore today's exact behavior). undefined → no model key passed (openStructured default).
|
|
4214
|
-
openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: flowRuntime === 'codex' && isReview ? 'review' : 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
|
|
4253
|
+
openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: flowRuntime === 'codex' && isReview ? 'review' : 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
|
|
4215
4254
|
const le = sessions.get(laneId)
|
|
4216
4255
|
if (le) {
|
|
4217
4256
|
// S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
|
|
@@ -4236,7 +4275,7 @@ flowChannel
|
|
|
4236
4275
|
: '') +
|
|
4237
4276
|
`ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
|
|
4238
4277
|
`\nProject (context): ${payload.flowPrompt || ''}\n\n` +
|
|
4239
|
-
(flowRuntime === 'codex'
|
|
4278
|
+
(flowRuntime === 'codex' || flowRuntime === 'hermes'
|
|
4240
4279
|
? `Review it without mutating the builder worktree. For write-producing install/build/test commands, copy its source into scratch under your own current worktree first. Submit the structured verdict with the ThinkPool submit_flow_review MCP tool; never write FLOW_REVIEW.json or call mark_flow_done.`
|
|
4241
4280
|
: `Review it — run it, hunt for failure. When done, use the Write tool to write FLOW_REVIEW.json with {"pass":<boolean>,"reasons":["<specific finding>"],"taskKey":"<the slice you reviewed>"}. That Write IS your verdict.`)
|
|
4242
4281
|
: `[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
|
|
@@ -4251,7 +4290,7 @@ flowChannel
|
|
|
4251
4290
|
assembleCrossWaveContext(payload.flowId, { baseDir: process.cwd(), deps: t.deps }).text) +
|
|
4252
4291
|
`\nProject (context): ${payload.flowPrompt || ''}\n\n` +
|
|
4253
4292
|
`Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.` +
|
|
4254
|
-
(flowRuntime === 'codex' ? ` Then call the ThinkPool mark_flow_done MCP tool; do not write FLOW_DONE.` : '')
|
|
4293
|
+
(flowRuntime === 'codex' || flowRuntime === 'hermes' ? ` Then call the ThinkPool mark_flow_done MCP tool; do not write FLOW_DONE.` : '')
|
|
4255
4294
|
try { le.session.sendTurn(spec) } catch { /* session still starting */ }
|
|
4256
4295
|
}
|
|
4257
4296
|
}
|
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,
|
package/flow-task-graph.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/hermes-session.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
6
6
|
import { AcpClient } from './acp-client.mjs'
|
|
7
7
|
import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
|
|
8
8
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
9
|
+
import { hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
9
10
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
10
11
|
import { classifyRisk } from './claude-session.mjs'
|
|
11
12
|
|
|
@@ -44,7 +45,7 @@ export function startHermesSession({
|
|
|
44
45
|
cwd, model, resume, env = process.env, mode = 'default', onEvent, requestPermission,
|
|
45
46
|
roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
|
|
46
47
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
47
|
-
mcpHttpFactory = startCodexMcpHttp, lazy = false,
|
|
48
|
+
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
48
49
|
} = {}) {
|
|
49
50
|
let activeCwd = cwd
|
|
50
51
|
const requestedModel = model || null
|
|
@@ -68,6 +69,7 @@ export function startHermesSession({
|
|
|
68
69
|
let resuming = false
|
|
69
70
|
let inventoryProbe = null
|
|
70
71
|
let modelSwitchPending = false
|
|
72
|
+
const policyRole = hermesRole || hermesRoleFor({})
|
|
71
73
|
|
|
72
74
|
const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
73
75
|
|
|
@@ -101,7 +103,14 @@ export function startHermesSession({
|
|
|
101
103
|
async function probeMcpTools() {
|
|
102
104
|
const required = [...new Set((Array.isArray(requiredMcpTools) ? requiredMcpTools : [])
|
|
103
105
|
.map((name) => String(name || '').trim()).filter(Boolean))]
|
|
104
|
-
|
|
106
|
+
// Direct runtime tests and unscoped upstream callers have no bridge role
|
|
107
|
+
// contract to prove. Every bridge-created Hermes lane supplies its required
|
|
108
|
+
// MCP list; only those lanes enter the exact-inventory transaction.
|
|
109
|
+
if (!required.length) return { inventory: '', missing: [], forbidden: [] }
|
|
110
|
+
const exact = hermesExactInventory(policyRole, { mcpTools: required })
|
|
111
|
+
const allowedBuiltins = exact.builtinTools
|
|
112
|
+
const requiredBuiltins = exact.requiredBuiltinTools
|
|
113
|
+
const expectedMcp = exact.mcpTools
|
|
105
114
|
const chunks = []
|
|
106
115
|
inventoryProbe = chunks
|
|
107
116
|
try {
|
|
@@ -112,7 +121,32 @@ export function startHermesSession({
|
|
|
112
121
|
}, 30_000)
|
|
113
122
|
} finally { inventoryProbe = null }
|
|
114
123
|
const inventory = chunks.join('')
|
|
115
|
-
|
|
124
|
+
const has = (name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(inventory)
|
|
125
|
+
const mcpNames = [...inventory.matchAll(/\bmcp(?:__|_)[a-z0-9_]+/gi)].map((item) => item[0])
|
|
126
|
+
const expectedMcpNames = new Set(expectedMcp.flatMap((name) => [`mcp__thinkpool__${name}`, `mcp_thinkpool_${name}`]))
|
|
127
|
+
const unexpectedMcp = [...new Set(mcpNames.filter((name) => !expectedMcpNames.has(name)))]
|
|
128
|
+
// This is a zero-inference ACP inventory command. It proves the complete
|
|
129
|
+
// role schema (required built-ins plus exactly this lane's ThinkPool MCP
|
|
130
|
+
// set). Optional provider/browser capabilities may be absent, but any
|
|
131
|
+
// presented builtin outside the role allowlist is still a hard failure.
|
|
132
|
+
return {
|
|
133
|
+
inventory,
|
|
134
|
+
missing: [
|
|
135
|
+
...requiredBuiltins.filter((name) => !has(name)),
|
|
136
|
+
...expectedMcp.filter((name) => !has(`mcp__thinkpool__${name}`) && !has(`mcp_thinkpool_${name}`)),
|
|
137
|
+
],
|
|
138
|
+
forbidden: [
|
|
139
|
+
...exact.allBuiltinTools.filter((name) => !allowedBuiltins.includes(name) && has(name)),
|
|
140
|
+
...unexpectedMcp,
|
|
141
|
+
],
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function assertMcpReadiness() {
|
|
146
|
+
const probe = await probeMcpTools()
|
|
147
|
+
if (!probe.missing.length && !probe.forbidden.length) return probe
|
|
148
|
+
const details = [probe.missing.length ? `missing ${probe.missing.join(', ')}` : '', probe.forbidden.length ? `forbidden ${probe.forbidden.join(', ')}` : ''].filter(Boolean).join('; ')
|
|
149
|
+
throw new Error(`Hermes ThinkPool MCP readiness failed; ${details}`)
|
|
116
150
|
}
|
|
117
151
|
|
|
118
152
|
async function boot() {
|
|
@@ -126,18 +160,25 @@ export function startHermesSession({
|
|
|
126
160
|
}
|
|
127
161
|
const thinkpool = mcpServers?.thinkpool
|
|
128
162
|
if (thinkpool && !mcpHttp) mcpHttp = await mcpHttpFactory({ sdkServer: thinkpool })
|
|
129
|
-
|
|
163
|
+
let childEnv = hermesChildEnv(env)
|
|
164
|
+
let launchCommand = command
|
|
165
|
+
let launchArgs = args
|
|
130
166
|
// The bridge advertises Hermes only after this probe, but lazy lanes may
|
|
131
167
|
// boot much later. Re-check immediately before every real ACP process so
|
|
132
168
|
// a removed/changed delegation hook cannot ride a stale startup verdict.
|
|
133
169
|
if (command === HERMES_COMMAND && clientFactory === createAcpClient) {
|
|
134
|
-
const profile = probeHermesRuntime({ command, env: childEnv })
|
|
170
|
+
const profile = probeHermesRuntime({ command, env: childEnv, strictBootstrap: true })
|
|
135
171
|
if (!profile.available) throw new Error(`Hermes profile safety check failed: ${profile.reason || 'runtime unavailable'}`)
|
|
172
|
+
// The executable is the installed venv Python, never the mutable
|
|
173
|
+
// profile wrapper. HERMES_HOME is the probe-verified isolated profile.
|
|
174
|
+
launchCommand = profile.python
|
|
175
|
+
launchArgs = [profile.bootstrap]
|
|
176
|
+
childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(policyRole, { mcpTools: requiredMcpTools }) }
|
|
136
177
|
}
|
|
137
178
|
let retired = false
|
|
138
179
|
retireClient = () => { retired = true }
|
|
139
180
|
client = clientFactory({
|
|
140
|
-
command, args, cwd: activeCwd, env: childEnv,
|
|
181
|
+
command: launchCommand, args: launchArgs, cwd: activeCwd, env: childEnv,
|
|
141
182
|
onNotification,
|
|
142
183
|
onRequest,
|
|
143
184
|
onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
|
|
@@ -175,7 +216,8 @@ export function startHermesSession({
|
|
|
175
216
|
} else state = await client.request('session/new', params, 30_000)
|
|
176
217
|
if (!sessionId) sessionId = state?.sessionId || null
|
|
177
218
|
if (!sessionId) throw new Error('Hermes ACP did not return a session id')
|
|
178
|
-
let
|
|
219
|
+
let toolProbe = await probeMcpTools()
|
|
220
|
+
let missingMcpTools = toolProbe.missing
|
|
179
221
|
// Hermes treats ACP-provided MCP registration as non-fatal. A transient
|
|
180
222
|
// first connection can therefore leave an otherwise healthy session with
|
|
181
223
|
// only built-in tools. Re-running the released resume lifecycle retries
|
|
@@ -186,10 +228,12 @@ export function startHermesSession({
|
|
|
186
228
|
resuming = true
|
|
187
229
|
state = await client.request('session/resume', { ...params, sessionId }, 30_000)
|
|
188
230
|
} finally { resuming = false }
|
|
189
|
-
|
|
231
|
+
toolProbe = await probeMcpTools()
|
|
232
|
+
missingMcpTools = toolProbe.missing
|
|
190
233
|
}
|
|
191
|
-
if (missingMcpTools.length) {
|
|
192
|
-
|
|
234
|
+
if (missingMcpTools.length || toolProbe.forbidden.length) {
|
|
235
|
+
const details = [missingMcpTools.length ? `missing ${missingMcpTools.join(', ')}` : '', toolProbe.forbidden.length ? `forbidden ${toolProbe.forbidden.join(', ')}` : ''].filter(Boolean).join('; ')
|
|
236
|
+
throw new Error(`Hermes ThinkPool MCP readiness failed; ${details}`)
|
|
193
237
|
}
|
|
194
238
|
const serverModel = state?.models?.currentModelId || null
|
|
195
239
|
activeModel = requestedModel || serverModel || activeModel
|
|
@@ -197,8 +241,9 @@ export function startHermesSession({
|
|
|
197
241
|
// ACP catalog. Acknowledge that model BEFORE publishing the initial system/
|
|
198
242
|
// models events; otherwise the worker runs the requested model while the UI
|
|
199
243
|
// and persisted resume record lie that it still uses the profile default.
|
|
200
|
-
if (requestedModel
|
|
244
|
+
if (requestedModel) {
|
|
201
245
|
await client.request('session/set_model', { sessionId, modelId: activeModel })
|
|
246
|
+
await assertMcpReadiness()
|
|
202
247
|
}
|
|
203
248
|
const publishedModels = state?.models
|
|
204
249
|
? { ...state.models, currentModelId: activeModel }
|
|
@@ -209,7 +254,7 @@ export function startHermesSession({
|
|
|
209
254
|
if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
|
|
210
255
|
await client.request('session/set_mode', { sessionId, modeId: acpMode })
|
|
211
256
|
}
|
|
212
|
-
emit({ kind: 'capabilities', runtime: 'hermes', protocol: 'acp', protocolVersion: initialized?.protocolVersion, capabilities: initialized?.agentCapabilities || {}, models: modelList(state?.models), flow:
|
|
257
|
+
emit({ kind: 'capabilities', runtime: 'hermes', protocol: 'acp', protocolVersion: initialized?.protocolVersion, capabilities: initialized?.agentCapabilities || {}, models: modelList(state?.models), flow: command === HERMES_COMMAND && clientFactory === createAcpClient })
|
|
213
258
|
})().catch((error) => {
|
|
214
259
|
crashed = true
|
|
215
260
|
client?.end()
|
|
@@ -328,7 +373,11 @@ export function startHermesSession({
|
|
|
328
373
|
if (!nextModel || turnActive || modelSwitchPending) return false
|
|
329
374
|
const requested = String(nextModel)
|
|
330
375
|
modelSwitchPending = true
|
|
331
|
-
void boot().then(() => client.request('session/set_model', { sessionId, modelId: requested })).then(() => {
|
|
376
|
+
void boot().then(() => client.request('session/set_model', { sessionId, modelId: requested })).then(async () => {
|
|
377
|
+
// 0.18.2 reconstructs state.agent during set_model. The bootstrap
|
|
378
|
+
// re-registers its MCP; this independent zero-inference /tools check is
|
|
379
|
+
// the transaction's acknowledgement boundary.
|
|
380
|
+
await assertMcpReadiness()
|
|
332
381
|
if (ended) return
|
|
333
382
|
activeModel = requested
|
|
334
383
|
if (mapper) mapper.model = activeModel
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.247",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"codex-event-mapper.mjs",
|
|
25
25
|
"acp-client.mjs",
|
|
26
26
|
"hermes-session.mjs",
|
|
27
|
+
"hermes-policy.mjs",
|
|
28
|
+
"hermes-acp-bootstrap.py",
|
|
27
29
|
"hermes-event-mapper.mjs",
|
|
28
30
|
"hermes-probe.mjs",
|
|
29
31
|
"hermes-setup.mjs",
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"viewport.mjs",
|
|
57
59
|
"design-edit.mjs",
|
|
58
60
|
"flow-review.mjs",
|
|
61
|
+
"review-check.mjs",
|
|
59
62
|
"flow-review-gate.mjs",
|
|
60
63
|
"flow-review-reflect.mjs",
|
|
61
64
|
"flow-assembly.mjs",
|
package/review-check.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Fixed, bridge-owned review checks. Models receive only { target, checkId }.
|
|
2
|
+
// This is host execution parity with Claude/Codex Flow, not a malicious-code sandbox.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import fsp from 'node:fs/promises'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { execFile } from 'node:child_process'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
|
|
13
|
+
const CHECKS = Object.freeze({
|
|
14
|
+
'node-test': ['node', ['--test']], 'npm-test': ['npm', ['test']],
|
|
15
|
+
'npm-build': ['npm', ['run', 'build']], pytest: ['pytest', []],
|
|
16
|
+
'cargo-test': ['cargo', ['test']], 'go-test': ['go', ['test', './...']],
|
|
17
|
+
})
|
|
18
|
+
const OUTPUT_CAP = 512 * 1024
|
|
19
|
+
|
|
20
|
+
export function reviewCheckCommand(checkId) {
|
|
21
|
+
if (!Object.hasOwn(CHECKS, checkId)) throw new Error('unapproved review check')
|
|
22
|
+
const [command, args] = CHECKS[checkId]
|
|
23
|
+
return { command, args: [...args] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function reviewCheckEnv(env = process.env, root = null) {
|
|
27
|
+
root ||= await fsp.mkdtemp(path.join(os.tmpdir(), 'tp-review-env-'))
|
|
28
|
+
const keptPath = env.PATH || process.env.PATH || ''
|
|
29
|
+
const home = path.join(root, 'home')
|
|
30
|
+
const npmCache = path.join(root, 'npm-cache')
|
|
31
|
+
const tmp = path.join(root, 'tmp')
|
|
32
|
+
await Promise.all([fsp.mkdir(home, { recursive: true }), fsp.mkdir(npmCache, { recursive: true }), fsp.mkdir(tmp, { recursive: true })])
|
|
33
|
+
return { PATH: keptPath, HOME: home, TMPDIR: path.join(root, 'tmp'), npm_config_cache: npmCache, npm_config_userconfig: path.join(home, '.npmrc'), GIT_CONFIG_NOSYSTEM: '1' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateReviewTarget(target, snapshots) {
|
|
37
|
+
if (typeof target !== 'string' || !/^[a-z0-9-]{1,64}$/.test(target)) throw new Error('invalid reviewed target')
|
|
38
|
+
const snapshot = (Array.isArray(snapshots) ? snapshots : []).find((item) => item?.taskKey === target)
|
|
39
|
+
if (!snapshot || !/^[0-9a-f]{40}$/i.test(snapshot.sha) || !path.isAbsolute(snapshot.cwd || '')) throw new Error('reviewed target is not pinned')
|
|
40
|
+
return snapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateReviewPath(filePath) {
|
|
44
|
+
if (typeof filePath !== 'string' || !filePath || filePath.length > 512 || path.posix.isAbsolute(filePath)) throw new Error('invalid review path')
|
|
45
|
+
const normalized = path.posix.normalize(filePath)
|
|
46
|
+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || normalized.split('/').some((part) => !part || part === '.')) throw new Error('invalid review path')
|
|
47
|
+
return normalized
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read the pinned Git blob, never the reviewer worktree. git ls-tree refuses
|
|
51
|
+
// directories and symlinks before git show is allowed to materialize bytes.
|
|
52
|
+
export async function readReviewFile({ target, filePath, snapshots, maxBytes = 512 * 1024 } = {}) {
|
|
53
|
+
const snapshot = validateReviewTarget(target, snapshots)
|
|
54
|
+
const normalized = validateReviewPath(filePath)
|
|
55
|
+
const cap = Number.isInteger(maxBytes) && maxBytes > 0 && maxBytes <= 512 * 1024 ? maxBytes : 512 * 1024
|
|
56
|
+
const listed = await execFileAsync('git', ['-C', snapshot.cwd, 'ls-tree', '-z', snapshot.sha, '--', normalized], { encoding: 'buffer', maxBuffer: 1024 * 1024 })
|
|
57
|
+
const record = Buffer.from(listed.stdout).toString('utf8').split('\0').filter(Boolean)[0] || ''
|
|
58
|
+
const match = /^(100[0-7]{3}) blob ([0-9a-f]{40})\t(.+)$/.exec(record)
|
|
59
|
+
if (!match || match[3] !== normalized) throw new Error('review file is not a pinned regular file')
|
|
60
|
+
const shown = await execFileAsync('git', ['-C', snapshot.cwd, 'show', `${snapshot.sha}:${normalized}`], { encoding: 'buffer', maxBuffer: cap + 1 })
|
|
61
|
+
const bytes = Buffer.from(shown.stdout)
|
|
62
|
+
if (bytes.length > cap) throw new Error('review file exceeds byte limit')
|
|
63
|
+
return { target, sha: snapshot.sha, path: normalized, content: bytes.toString('utf8'), bytes: bytes.length }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function boundedPush(parts, value) {
|
|
67
|
+
const text = String(value || '')
|
|
68
|
+
parts.push(text)
|
|
69
|
+
let total = parts.reduce((n, part) => n + part.length, 0)
|
|
70
|
+
while (total > OUTPUT_CAP && parts.length) total -= parts.shift().length
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function killTree(child, signal = 'SIGTERM', { platform = process.platform, spawnImpl = spawn, taskkillTimeoutMs = 1_000 } = {}) {
|
|
74
|
+
if (!child?.pid) return true
|
|
75
|
+
if (platform === 'win32' && signal === 'SIGKILL') {
|
|
76
|
+
// Windows has no process-group equivalent. This is trusted-host parity,
|
|
77
|
+
// not unescapable process-tree isolation. taskkill itself can hang on a
|
|
78
|
+
// hostile/broken host, so race it: cleanup remains best-effort and the
|
|
79
|
+
// review request always settles.
|
|
80
|
+
return await new Promise((resolve) => {
|
|
81
|
+
let done = false
|
|
82
|
+
let timer = null
|
|
83
|
+
const finish = (ok) => {
|
|
84
|
+
if (done) return
|
|
85
|
+
done = true
|
|
86
|
+
if (timer) clearTimeout(timer)
|
|
87
|
+
resolve(ok)
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const taskkill = spawnImpl('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
91
|
+
taskkill.once('close', (code) => finish(code === 0))
|
|
92
|
+
taskkill.once('error', () => finish(false))
|
|
93
|
+
timer = setTimeout(() => { try { taskkill.kill?.() } catch {} finish(false) }, taskkillTimeoutMs)
|
|
94
|
+
} catch { finish(false) }
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
try { platform === 'win32' ? child.kill(signal) : process.kill(-child.pid, signal); return true } catch { try { return child.kill(signal) } catch { return false } }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function runProcess(command, args, { cwd, env, timeoutMs = 120_000, killGraceMs = 250 } = {}) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const stdout = [], stderr = []
|
|
103
|
+
let timedOut = false
|
|
104
|
+
let settled = false
|
|
105
|
+
let child
|
|
106
|
+
let timer = null
|
|
107
|
+
let forceTimer = null
|
|
108
|
+
let settleTimer = null
|
|
109
|
+
const settle = (code) => {
|
|
110
|
+
if (settled) return
|
|
111
|
+
settled = true
|
|
112
|
+
if (timer) clearTimeout(timer)
|
|
113
|
+
if (forceTimer) clearTimeout(forceTimer)
|
|
114
|
+
if (settleTimer) clearTimeout(settleTimer)
|
|
115
|
+
resolve({ exitCode: Number.isInteger(code) ? code : null, stdout: stdout.join('').slice(-OUTPUT_CAP), stderr: stderr.join('').slice(-OUTPUT_CAP), timedOut })
|
|
116
|
+
}
|
|
117
|
+
try { child = spawn(command, args, { cwd, env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }) }
|
|
118
|
+
catch (error) { stderr.push(String(error?.message || error)); settle(null); return }
|
|
119
|
+
child.stdout.on('data', (value) => boundedPush(stdout, value))
|
|
120
|
+
child.stderr.on('data', (value) => boundedPush(stderr, value))
|
|
121
|
+
timer = setTimeout(() => {
|
|
122
|
+
timedOut = true; void killTree(child)
|
|
123
|
+
forceTimer = setTimeout(async () => {
|
|
124
|
+
if (!await killTree(child, 'SIGKILL')) boundedPush(stderr, 'review check cleanup could not confirm process-tree termination')
|
|
125
|
+
// SIGKILL is unconditional on POSIX, but a platform/process failure must
|
|
126
|
+
// not leave the MCP call pending forever. A second best-effort kill then
|
|
127
|
+
// settles the caller; the scratch finally block can run.
|
|
128
|
+
settleTimer = setTimeout(() => {
|
|
129
|
+
void killTree(child, 'SIGKILL')
|
|
130
|
+
boundedPush(stderr, 'review check process did not report close after forced termination')
|
|
131
|
+
settle(null)
|
|
132
|
+
}, 1_000)
|
|
133
|
+
}, killGraceMs)
|
|
134
|
+
}, timeoutMs)
|
|
135
|
+
child.once('error', (error) => boundedPush(stderr, error?.message || error))
|
|
136
|
+
child.once('close', (code) => settle(code))
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function runReviewCheck({ target, checkId, snapshots, env = process.env, tmpdir = os.tmpdir(), timeoutMs } = {}) {
|
|
141
|
+
const snapshot = validateReviewTarget(target, snapshots)
|
|
142
|
+
const archiveDir = await fsp.mkdtemp(path.join(tmpdir, 'tp-review-'))
|
|
143
|
+
const archive = path.join(archiveDir, 'source.tar')
|
|
144
|
+
const scratch = path.join(archiveDir, 'tree')
|
|
145
|
+
try {
|
|
146
|
+
await fsp.mkdir(scratch)
|
|
147
|
+
const archiveResult = await runProcess('git', ['-C', snapshot.cwd, 'archive', '--format=tar', `--output=${archive}`, snapshot.sha], { env: await reviewCheckEnv(env, archiveDir), timeoutMs: 15_000 })
|
|
148
|
+
if (archiveResult.exitCode !== 0 || archiveResult.timedOut) return { pass: false, target, sha: snapshot.sha, command: ['git', 'archive'], exitCode: archiveResult.exitCode, ...archiveResult }
|
|
149
|
+
const extract = await runProcess('tar', ['-xf', archive, '-C', scratch], { env: await reviewCheckEnv(env, archiveDir), timeoutMs: 15_000 })
|
|
150
|
+
if (extract.exitCode !== 0 || extract.timedOut) return { pass: false, target, sha: snapshot.sha, command: ['tar', '-xf'], exitCode: extract.exitCode, ...extract }
|
|
151
|
+
const spec = reviewCheckCommand(checkId)
|
|
152
|
+
const result = await runProcess(spec.command, spec.args, { cwd: scratch, env: await reviewCheckEnv(env, archiveDir), timeoutMs })
|
|
153
|
+
return { pass: result.exitCode === 0 && !result.timedOut, target, sha: snapshot.sha, command: [spec.command, ...spec.args], ...result }
|
|
154
|
+
} finally { await fsp.rm(archiveDir, { recursive: true, force: true }) }
|
|
155
|
+
}
|
package/runtime-registry.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const RUNTIMES = Object.freeze({
|
|
|
13
13
|
}),
|
|
14
14
|
hermes: Object.freeze({
|
|
15
15
|
id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
|
|
16
|
-
structured: true, flow:
|
|
16
|
+
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl: false, defaultMode: 'default',
|
|
17
17
|
modes: Object.freeze(['default', 'acceptEdits']),
|
|
18
18
|
beta: true,
|
|
19
19
|
}),
|