thinkpool-pair 0.7.233 → 0.7.235
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/acp-client.mjs +140 -0
- package/agent-detect.mjs +1 -1
- package/bridge.mjs +70 -29
- package/hermes-delegation-guard.mjs +15 -0
- package/hermes-event-mapper.mjs +131 -0
- package/hermes-isolation.mjs +59 -0
- package/hermes-probe.mjs +20 -0
- package/hermes-session.mjs +246 -0
- package/hermes-setup.mjs +145 -0
- package/interrupted-resume.mjs +13 -1
- package/launcher.mjs +29 -13
- package/package.json +12 -1
- package/runtime-registry.mjs +30 -0
- package/runtime-session.mjs +15 -0
package/acp-client.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Runtime-neutral supervised ACP stdio client. Protocol framing, JSON-RPC,
|
|
2
|
+
// cancellation, typed client handlers, and schemas come from the official ACP
|
|
3
|
+
// SDK. ThinkPool owns only subprocess supervision and its stricter stdout rule.
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { Readable, Transform, Writable } from 'node:stream'
|
|
6
|
+
import * as acp from '@agentclientprotocol/sdk'
|
|
7
|
+
|
|
8
|
+
const closedError = (value, fallback = 'ACP process closed') => value instanceof Error
|
|
9
|
+
? value
|
|
10
|
+
: new Error(String(value || fallback))
|
|
11
|
+
|
|
12
|
+
function strictProtocolGate(onError) {
|
|
13
|
+
let buffered = ''
|
|
14
|
+
const validate = (line) => {
|
|
15
|
+
if (!line.trim()) return
|
|
16
|
+
let message
|
|
17
|
+
try { message = JSON.parse(line) } catch { throw new Error('ACP stdout contained non-JSON protocol data') }
|
|
18
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
19
|
+
throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
|
|
20
|
+
}
|
|
21
|
+
const hasId = Object.prototype.hasOwnProperty.call(message, 'id')
|
|
22
|
+
const hasMethod = typeof message.method === 'string' && message.method.length > 0
|
|
23
|
+
const hasResult = Object.prototype.hasOwnProperty.call(message, 'result')
|
|
24
|
+
const hasError = Object.prototype.hasOwnProperty.call(message, 'error')
|
|
25
|
+
const isCall = hasMethod && !(hasResult || hasError)
|
|
26
|
+
const isResponse = hasId && !hasMethod && hasResult !== hasError
|
|
27
|
+
if (message.jsonrpc !== '2.0' || (!isCall && !isResponse)) {
|
|
28
|
+
throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const gate = new Transform({
|
|
32
|
+
transform(chunk, _encoding, callback) {
|
|
33
|
+
try {
|
|
34
|
+
buffered += String(chunk)
|
|
35
|
+
const lines = buffered.split('\n')
|
|
36
|
+
buffered = lines.pop() || ''
|
|
37
|
+
for (const line of lines) validate(line)
|
|
38
|
+
callback(null, chunk)
|
|
39
|
+
} catch (error) { callback(error) }
|
|
40
|
+
},
|
|
41
|
+
flush(callback) {
|
|
42
|
+
try { validate(buffered); callback(null, buffered || undefined) }
|
|
43
|
+
catch (error) { callback(error) }
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
gate.on('error', onError)
|
|
47
|
+
return gate
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AcpClient {
|
|
51
|
+
constructor({ command, args = ['acp'], cwd, env = process.env, spawnImpl = spawn, onNotification, onRequest, onStderr, onClose, timeoutMs = 10_000 } = {}) {
|
|
52
|
+
this.command = command
|
|
53
|
+
this.args = args
|
|
54
|
+
this.cwd = cwd
|
|
55
|
+
this.env = env
|
|
56
|
+
this.spawnImpl = spawnImpl
|
|
57
|
+
this.onNotification = onNotification
|
|
58
|
+
this.onRequest = onRequest
|
|
59
|
+
this.onStderr = onStderr
|
|
60
|
+
this.onClose = onClose
|
|
61
|
+
this.timeoutMs = timeoutMs
|
|
62
|
+
this.child = null
|
|
63
|
+
this.connection = null
|
|
64
|
+
this.closedError = null
|
|
65
|
+
this.stderrTail = ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get alive() { return !!this.child && !!this.connection && !this.closedError }
|
|
69
|
+
|
|
70
|
+
start() {
|
|
71
|
+
if (this.child) return
|
|
72
|
+
const child = this.spawnImpl(this.command, this.args, { cwd: this.cwd, env: this.env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
73
|
+
this.child = child
|
|
74
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
75
|
+
const text = String(chunk)
|
|
76
|
+
this.stderrTail = (this.stderrTail + text).slice(-2000)
|
|
77
|
+
try { this.onStderr?.(text) } catch { /* consumer isolation */ }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const failProtocol = (error) => {
|
|
81
|
+
const failure = closedError(error, 'ACP protocol stream failed')
|
|
82
|
+
this.connection?.close(failure)
|
|
83
|
+
this._close(failure)
|
|
84
|
+
try { child.kill('SIGTERM') } catch { /* noop */ }
|
|
85
|
+
}
|
|
86
|
+
const protocolOutput = strictProtocolGate(failProtocol)
|
|
87
|
+
child.stdout.pipe(protocolOutput)
|
|
88
|
+
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(protocolOutput))
|
|
89
|
+
const app = acp.client({ name: 'thinkpool-pair' })
|
|
90
|
+
.onRequest(acp.methods.client.session.requestPermission, ({ params, requestId }) => {
|
|
91
|
+
if (!this.onRequest) throw Object.assign(new Error('Unsupported ACP client method: session/request_permission'), { code: -32601 })
|
|
92
|
+
return this.onRequest(acp.methods.client.session.requestPermission, params, requestId)
|
|
93
|
+
})
|
|
94
|
+
.onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
95
|
+
try { this.onNotification?.(acp.methods.client.session.update, params) } catch { /* consumer isolation */ }
|
|
96
|
+
})
|
|
97
|
+
this.connection = app.connect(stream)
|
|
98
|
+
void this.connection.closed.then(() => {
|
|
99
|
+
if (!this.closedError) this._close(closedError(this.connection?.signal?.reason))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
child.stdin?.on?.('error', (error) => this._close(error))
|
|
103
|
+
child.on('error', (error) => this._close(error))
|
|
104
|
+
child.on('close', (code, signal) => this._close(new Error(`ACP process exited ${code ?? signal ?? 'unknown'}${this.stderrTail ? `: ${this.stderrTail.trim().slice(-300)}` : ''}`)))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
request(method, params = {}, timeoutMs = this.timeoutMs) {
|
|
108
|
+
if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
|
|
109
|
+
const controller = new AbortController()
|
|
110
|
+
const request = this.connection.agent.request(method, params, { cancellationSignal: controller.signal })
|
|
111
|
+
if (!(timeoutMs > 0)) return request
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
controller.abort(new Error(`${method} timed out after ${timeoutMs}ms`))
|
|
115
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`))
|
|
116
|
+
}, timeoutMs)
|
|
117
|
+
timer.unref?.()
|
|
118
|
+
request.then(resolve, reject).finally(() => clearTimeout(timer))
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
notify(method, params) {
|
|
123
|
+
if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
|
|
124
|
+
return this.connection.agent.notify(method, params)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_close(error) {
|
|
128
|
+
if (this.closedError) return
|
|
129
|
+
this.closedError = closedError(error)
|
|
130
|
+
this.child = null
|
|
131
|
+
this.connection?.close(this.closedError)
|
|
132
|
+
try { this.onClose?.(this.closedError) } catch { /* consumer isolation */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
end() {
|
|
136
|
+
const child = this.child
|
|
137
|
+
this._close(new Error('ACP client ended'))
|
|
138
|
+
if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } }
|
|
139
|
+
}
|
|
140
|
+
}
|
package/agent-detect.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
|
|
7
7
|
export const KNOWN_AGENT_COMMANDS = [
|
|
8
|
-
'claude', 'codex', 'gemini', 'aider', 'cursor-agent',
|
|
8
|
+
'claude', 'codex', 'thinkpool', 'gemini', 'aider', 'cursor-agent',
|
|
9
9
|
'opencode', 'copilot', 'goose', 'crush', 'qwen',
|
|
10
10
|
]
|
|
11
11
|
|
package/bridge.mjs
CHANGED
|
@@ -49,8 +49,10 @@ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, l
|
|
|
49
49
|
import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
52
|
+
import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
|
|
53
|
+
import { startStructuredSession } from './runtime-session.mjs'
|
|
54
|
+
import { defaultStructuredMode, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
55
|
+
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
54
56
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
55
57
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
56
58
|
import { commandOnPath } from './agent-detect.mjs'
|
|
@@ -103,7 +105,7 @@ const flowRedispatch = new Map()
|
|
|
103
105
|
// broadcasts; without persistent state the cap can never bite.
|
|
104
106
|
const flowBudgets = new Map()
|
|
105
107
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
|
|
106
|
-
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
108
|
+
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
107
109
|
import { turnInFlight } from './update-gate.mjs'
|
|
108
110
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
109
111
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
@@ -167,6 +169,7 @@ catch {
|
|
|
167
169
|
const KNOWN_AGENTS = [
|
|
168
170
|
{ label: 'Claude Code', cmd: 'claude', resume: { args: ['--continue'], probe: claudeHasSession } },
|
|
169
171
|
{ label: 'Codex CLI', cmd: 'codex' },
|
|
172
|
+
{ label: 'Hermes Agent', cmd: 'thinkpool', availability: () => probeHermesRuntime().available },
|
|
170
173
|
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
171
174
|
{ label: 'Aider', cmd: 'aider' },
|
|
172
175
|
{ label: 'Cursor CLI', cmd: 'cursor-agent' },
|
|
@@ -261,6 +264,39 @@ if (argv[0] === 'set-default-dir') {
|
|
|
261
264
|
process.exit(0)
|
|
262
265
|
}
|
|
263
266
|
if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
|
|
267
|
+
if (argv[0] === 'setup-hermes') {
|
|
268
|
+
const clone = argv.includes('--clone')
|
|
269
|
+
const clean = argv.includes('--clean')
|
|
270
|
+
if (clone && clean) { console.error(' choose one Hermes setup mode: --clone or --clean'); process.exit(1) }
|
|
271
|
+
let mode = clone ? 'clone' : clean ? 'clean' : undefined
|
|
272
|
+
if (!mode && process.stdin.isTTY) {
|
|
273
|
+
const answer = await new Promise((resolve) => {
|
|
274
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
275
|
+
rl.question('\n Clone your active Hermes provider/config into an isolated thinkpool profile? [Y/n] ', (value) => { rl.close(); resolve(String(value).trim()) })
|
|
276
|
+
})
|
|
277
|
+
mode = /^n/i.test(answer) ? 'clean' : 'clone'
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const { setupHermesRuntime } = await import('./hermes-setup.mjs')
|
|
281
|
+
const result = setupHermesRuntime({ mode })
|
|
282
|
+
process.stderr.write(`\n ◆ Hermes ${result.version || ''} is ready for ThinkPool ordinary lanes.\n profile: ${result.profile} (${result.created ? `${result.mode} created` : 'existing'})\n hidden delegation: blocked\n${result.created && result.mode === 'clean' ? ' provider: run `thinkpool setup` before the first model turn\n' : ''}\n`)
|
|
283
|
+
process.exit(0)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.error(`\n ◇ Hermes setup failed: ${error?.message || error}\n New profile: npx thinkpool-pair@latest setup-hermes --clone\n Clean profile: npx thinkpool-pair@latest setup-hermes --clean\n`)
|
|
286
|
+
process.exit(1)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (argv[0] === 'verify-hermes-isolation') {
|
|
290
|
+
try {
|
|
291
|
+
const { verifyHermesMcpIsolation } = await import('./hermes-isolation.mjs')
|
|
292
|
+
const evidence = await verifyHermesMcpIsolation({ rounds: 2 })
|
|
293
|
+
process.stderr.write(`\n ◆ Hermes MCP isolation HELD for ${evidence.length} consecutive two-lane rounds.\n Each ACP session advertised only its lane-bound marker endpoint.\n No model inference was used.\n\n`)
|
|
294
|
+
process.exit(0)
|
|
295
|
+
} catch (error) {
|
|
296
|
+
console.error(`\n ◇ Hermes MCP isolation VIOLATED or unproven: ${error?.message || error}\n`)
|
|
297
|
+
process.exit(1)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
264
300
|
|
|
265
301
|
// One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
|
|
266
302
|
// interactive menu instead of going straight to account mode. The menu only ASSEMBLES
|
|
@@ -307,6 +343,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
307
343
|
: ['anthropic']
|
|
308
344
|
await runProvider(args)
|
|
309
345
|
},
|
|
346
|
+
setupHermes: async ({ mode }) => {
|
|
347
|
+
const { setupHermesRuntime } = await import('./hermes-setup.mjs')
|
|
348
|
+
const result = setupHermesRuntime({ mode })
|
|
349
|
+
io.print(`\n ✓ Hermes ${result.version || ''} ready — isolated profile, ACP healthy, hidden delegation blocked.`)
|
|
350
|
+
if (result.created && result.mode === 'clean') io.print(' Run `thinkpool setup` once to choose a provider before the first model turn.')
|
|
351
|
+
},
|
|
310
352
|
}
|
|
311
353
|
await runLauncher({ actions, io, refresh: detectState })
|
|
312
354
|
process.exit(0)
|
|
@@ -437,12 +479,9 @@ const STRUCTURED = process.env.TP_STRUCTURED === '1' || argv.includes('--structu
|
|
|
437
479
|
// Claude Code runs as a STRUCTURED Agent-SDK session by default now (the picked
|
|
438
480
|
// design — structured reader, risk-tiered permissions). Other CLIs keep the PTY
|
|
439
481
|
// relay. TP_PTY=1 forces claude back to the raw PTY if ever needed.
|
|
440
|
-
const structuredRuntime =
|
|
441
|
-
const bin = path.basename(String(cmd || '')).toLowerCase()
|
|
442
|
-
return bin === 'codex' ? 'codex' : bin === 'claude' ? 'claude' : null
|
|
443
|
-
}
|
|
482
|
+
const structuredRuntime = structuredRuntimeForCommand
|
|
444
483
|
const wantStructured = (cmd) => !!structuredRuntime(cmd) && process.env.TP_PTY !== '1'
|
|
445
|
-
const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
|
|
484
|
+
const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd) && (!a.availability || a.availability()))
|
|
446
485
|
const dashIdx = argv.indexOf('--')
|
|
447
486
|
// Own flags are only read from BEFORE `--`; after it, every token belongs
|
|
448
487
|
// to the shared command (`-- claude --continue` must not trip these).
|
|
@@ -1063,7 +1102,7 @@ const announce = () => {
|
|
|
1063
1102
|
// laneStatusOf: authoritative busy/idle + last-action timestamp/age +
|
|
1064
1103
|
// STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
|
|
1065
1104
|
// every roster consumer reads one status instead of reconstructing it.
|
|
1066
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1105
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: structuredRuntimeMetadata(s.runtime || 'claude'), ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1067
1106
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1068
1107
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1069
1108
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1657,14 +1696,14 @@ function worktreeSnapshot(cwd) {
|
|
|
1657
1696
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1658
1697
|
function openStructured({ id, runtime = 'claude', model, 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 }) {
|
|
1659
1698
|
if (sessions.has(id)) return
|
|
1660
|
-
runtime = runtime
|
|
1699
|
+
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
1661
1700
|
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
1662
1701
|
// codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
|
|
1663
1702
|
// advance / ship instead of hitting the no-network wall; claude → default.
|
|
1664
1703
|
// Only fires when no mode was passed — a person's pick, a resume (persisted
|
|
1665
1704
|
// entry.mode), a flow lane, and spawn_terminal (inherits the parent's mode +
|
|
1666
1705
|
// its own bypass-escalation gate) all pass an explicit mode and skip this.
|
|
1667
|
-
mode = STRUCTURED_MODES.has(mode) ? mode :
|
|
1706
|
+
mode = STRUCTURED_MODES.has(mode) && structuredRuntimeSupportsMode(runtime, mode) ? mode : defaultStructuredMode(runtime)
|
|
1668
1707
|
assertRuntimeModelCompatible({ runtime, provider, model })
|
|
1669
1708
|
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1670
1709
|
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
@@ -1679,6 +1718,8 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1679
1718
|
? (codexRequestedModel
|
|
1680
1719
|
? resolveCodexModel(codexRequestedModel, codexModels)
|
|
1681
1720
|
: flowLaneModelFor({ sliceType: 'feature', runtime: 'codex', catalog: codexModels }))
|
|
1721
|
+
: runtime === 'hermes'
|
|
1722
|
+
? (model || undefined)
|
|
1682
1723
|
: effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
|
|
1683
1724
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1684
1725
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
@@ -1700,7 +1741,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1700
1741
|
// No configured model → the provider's NAME, never a false Claude id (an inherited
|
|
1701
1742
|
// claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
|
|
1702
1743
|
// label fallback here). Built-in Anthropic keeps the passed model.
|
|
1703
|
-
model: runtime === 'codex'
|
|
1744
|
+
model: runtime === 'codex' || runtime === 'hermes'
|
|
1704
1745
|
? (laneModel || null)
|
|
1705
1746
|
: (!provider || provider === 'anthropic')
|
|
1706
1747
|
? (laneModel || null)
|
|
@@ -2410,7 +2451,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2410
2451
|
flowRole: entry.flowRole,
|
|
2411
2452
|
sideParent: entry.sideParent,
|
|
2412
2453
|
})
|
|
2413
|
-
entry.session = (runtime
|
|
2454
|
+
entry.session = startStructuredSession(runtime, {
|
|
2414
2455
|
// laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
|
|
2415
2456
|
// ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
|
|
2416
2457
|
// Claude id here defeats the whole per-provider env. undefined → option omitted.
|
|
@@ -2556,7 +2597,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2556
2597
|
// App Server steering is opt-in and can fall back to exec at runtime. Keep
|
|
2557
2598
|
// the roster capability authoritative so the browser never immediate-sends
|
|
2558
2599
|
// into an exec-only turn and loses its shared queue.
|
|
2559
|
-
{ const canSteerNow = entry.runtime ===
|
|
2600
|
+
{ const canSteerNow = structuredRuntimeMetadata(entry.runtime)?.canSteer === true && (entry.session?.canSteer ?? false)
|
|
2560
2601
|
if (canSteerNow !== entry._canSteerAnn) { entry._canSteerAnn = canSteerNow; announce() } }
|
|
2561
2602
|
// Capture this session's current MODEL (resolved full id, with version — from
|
|
2562
2603
|
// system/result `model` or usage `ctx.model`) and the SDK's supported-model
|
|
@@ -2573,13 +2614,14 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2573
2614
|
// SDK id overwrite it. Only trust the SDK's model on the built-in Anthropic path.
|
|
2574
2615
|
// Keyed on the LANE's provider (entry.provider), not the bridge default — lanes can
|
|
2575
2616
|
// run on different registered providers. (bridge-truth slice.)
|
|
2576
|
-
{ const onBuiltin = !entry.provider || entry.provider === 'anthropic'
|
|
2577
|
-
const m = onBuiltin ? (evt.model || evt.ctx?.model) : null
|
|
2617
|
+
{ const onBuiltin = entry.runtime !== 'hermes' && (!entry.provider || entry.provider === 'anthropic')
|
|
2618
|
+
const m = (entry.runtime === 'hermes' || onBuiltin) ? (evt.model || evt.ctx?.model) : null
|
|
2578
2619
|
if (m && m !== entry.model) { entry.model = m; announce() }
|
|
2579
2620
|
// A custom Anthropic-compatible endpoint may expose its own catalog through the
|
|
2580
2621
|
// same SDK method. Never publish that as the ROOM's Claude catalog: its models
|
|
2581
2622
|
// already render in the provider group and otherwise leak under ANTHROPIC.
|
|
2582
|
-
if (
|
|
2623
|
+
if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
|
|
2624
|
+
else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
|
|
2583
2625
|
// Cache the last usage/ctx meter on the entry (persisted + replayed — see
|
|
2584
2626
|
// openStructured). Chrome events bypass pushLog/persist in emitTail below, so
|
|
2585
2627
|
// usage needs an explicit persist() here to survive a bridge restart.
|
|
@@ -2795,7 +2837,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2795
2837
|
process.stderr.write(req.risk === 'plan'
|
|
2796
2838
|
? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
|
|
2797
2839
|
: req.risk === 'ask'
|
|
2798
|
-
? `\n ${A.cyan}● ${req.asker || (runtime
|
|
2840
|
+
? `\n ${A.cyan}● ${req.asker || structuredRuntimeMetadata(runtime)?.label || 'Agent'} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
|
|
2799
2841
|
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
2800
2842
|
const autoResolutionMs = Number(req.autoResolutionMs)
|
|
2801
2843
|
if (req.risk === 'ask' && Number.isFinite(autoResolutionMs) && autoResolutionMs > 0) {
|
|
@@ -2830,7 +2872,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2830
2872
|
// room would otherwise show a fresh empty terminal + strand the transcript).
|
|
2831
2873
|
if (!entry.log.length) entry.flush()
|
|
2832
2874
|
announce()
|
|
2833
|
-
if (!entry.log.length) process.stderr.write(`\n ◆ structured
|
|
2875
|
+
if (!entry.log.length) process.stderr.write(`\n ◆ structured ${structuredRuntimeMetadata(runtime)?.label || 'agent'} session (${id.slice(0, 8)}) — driven from the room.\n`)
|
|
2834
2876
|
return entry
|
|
2835
2877
|
}
|
|
2836
2878
|
|
|
@@ -3140,7 +3182,7 @@ channel
|
|
|
3140
3182
|
runtime,
|
|
3141
3183
|
mode: parent?.mode || payload.mode,
|
|
3142
3184
|
model: parent?.model || payload.model || undefined,
|
|
3143
|
-
provider: runtime === '
|
|
3185
|
+
provider: runtime === 'claude' ? (parent?.provider || payload.provider || undefined) : undefined,
|
|
3144
3186
|
cwd: parent ? (parent.cwd || process.cwd()) : undefined,
|
|
3145
3187
|
sideParent: sideParent || undefined,
|
|
3146
3188
|
sideTask: sideParent ? String(payload.sideTask).trim() : undefined,
|
|
@@ -3318,7 +3360,7 @@ channel
|
|
|
3318
3360
|
// ── structured-session control (Phase 2) ──
|
|
3319
3361
|
.on('broadcast', { event: 'code-open' }, ({ payload }) => {
|
|
3320
3362
|
if (payload?.host && payload.host !== name) return
|
|
3321
|
-
openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === '
|
|
3363
|
+
openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'claude' ? payload?.provider : undefined, resume: payload?.resume, mode: payload?.mode, defer: true })
|
|
3322
3364
|
})
|
|
3323
3365
|
.on('broadcast', { event: 'side-handback-request' }, ({ payload }) => {
|
|
3324
3366
|
const side = payload?.term && sessions.get(payload.term)
|
|
@@ -3387,7 +3429,7 @@ channel
|
|
|
3387
3429
|
if (mm) {
|
|
3388
3430
|
if (mm[1]) {
|
|
3389
3431
|
if (s.session.setModel(mm[1]) === false) {
|
|
3390
|
-
ctlLine(
|
|
3432
|
+
ctlLine(`finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching models`)
|
|
3391
3433
|
return
|
|
3392
3434
|
}
|
|
3393
3435
|
// Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
|
|
@@ -3410,7 +3452,7 @@ channel
|
|
|
3410
3452
|
}
|
|
3411
3453
|
if (/^\/clear\s*$/.test(text)) {
|
|
3412
3454
|
s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
|
|
3413
|
-
if (s.runtime === 'codex') s.session.clearContext?.()
|
|
3455
|
+
if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
|
|
3414
3456
|
else s.session.sendTurn(text)
|
|
3415
3457
|
// The seq reset is load-bearing on BOTH ends: the client's clear handler sets
|
|
3416
3458
|
// seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
|
|
@@ -3494,7 +3536,7 @@ channel
|
|
|
3494
3536
|
// image paths from the bridge-owned UPDIR + deterministic attachment fields,
|
|
3495
3537
|
// then cover the separate file-put/code-turn download race with a bounded
|
|
3496
3538
|
// readiness wait. A late image remains available through its quoted text path.
|
|
3497
|
-
const nativeImages = s.runtime === 'codex'
|
|
3539
|
+
const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
|
|
3498
3540
|
? await waitForNativeImages(payload.files, { updir: UPDIR })
|
|
3499
3541
|
: []
|
|
3500
3542
|
s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
|
|
@@ -3535,9 +3577,9 @@ channel
|
|
|
3535
3577
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
3536
3578
|
const s = payload?.term && sessions.get(payload.term)
|
|
3537
3579
|
if (s && STRUCTURED_MODES.has(payload.mode)) {
|
|
3538
|
-
if (s.runtime === 'codex') {
|
|
3580
|
+
if (s.runtime === 'codex' || s.runtime === 'hermes') {
|
|
3539
3581
|
if (!s.session.setMode(payload.mode)) {
|
|
3540
|
-
const evt = { kind: 'control', text:
|
|
3582
|
+
const evt = { kind: 'control', text: `finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching permissions` }
|
|
3541
3583
|
pushLog(s, evt)
|
|
3542
3584
|
bcast('code-event', { term: payload.term, evt })
|
|
3543
3585
|
announce()
|
|
@@ -3735,13 +3777,12 @@ channel
|
|
|
3735
3777
|
// leaves that file at zero bytes; both App Server and exec reject
|
|
3736
3778
|
// it. Treat only that provably-empty case as non-resumable so the
|
|
3737
3779
|
// existing fresh-thread recap recovery handles it.
|
|
3738
|
-
const resumable =
|
|
3739
|
-
&& (rec.runtime !== 'codex' || codexThreadCanResume(rec.sessionId))
|
|
3780
|
+
const resumable = shouldAttemptNativeResume(rec, { canResume, codexThreadCanResume })
|
|
3740
3781
|
const recoveryRecap = wasInterrupted ? buildRecapFromLog(rec.log || [], RECAP_CAP) : ''
|
|
3741
3782
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3742
3783
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3743
3784
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3744
|
-
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === '
|
|
3785
|
+
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, 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,
|
|
3745
3786
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3746
3787
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3747
3788
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hermes shell hook: the ACP toolset currently includes delegate_task and ACP
|
|
3
|
+
// has no per-session tool exclusion. Block it before execution so all delegated
|
|
4
|
+
// work remains a visible ThinkPool lane.
|
|
5
|
+
let input = ''
|
|
6
|
+
process.stdin.setEncoding('utf8')
|
|
7
|
+
process.stdin.on('data', (chunk) => { input += chunk })
|
|
8
|
+
process.stdin.on('end', () => {
|
|
9
|
+
let payload = null
|
|
10
|
+
try { payload = JSON.parse(input) } catch { /* malformed hook input fails closed below */ }
|
|
11
|
+
const blocking = !payload || payload.tool_name === 'delegate_task'
|
|
12
|
+
process.stdout.write(JSON.stringify(blocking
|
|
13
|
+
? { action: 'block', message: 'ThinkPool blocks hidden Hermes delegation. Open visible ThinkPool lanes instead.' }
|
|
14
|
+
: {}) + '\n')
|
|
15
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const textOf = (content) => {
|
|
2
|
+
if (typeof content === 'string') return content
|
|
3
|
+
if (content?.type === 'text') return String(content.text || '')
|
|
4
|
+
if (content?.type === 'content') return textOf(content.content)
|
|
5
|
+
return ''
|
|
6
|
+
}
|
|
7
|
+
const outputText = (update) => {
|
|
8
|
+
if (typeof update?.rawOutput === 'string') return update.rawOutput
|
|
9
|
+
const text = (update?.content || []).map(textOf).filter(Boolean).join('\n')
|
|
10
|
+
if (text) return text
|
|
11
|
+
if (update?.rawOutput != null) {
|
|
12
|
+
try { return JSON.stringify(update.rawOutput) } catch { return String(update.rawOutput) }
|
|
13
|
+
}
|
|
14
|
+
return ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hermesToolFor(update = {}) {
|
|
18
|
+
const kind = String(update.kind || '').toLowerCase()
|
|
19
|
+
const title = String(update.title || '')
|
|
20
|
+
const raw = update.rawInput && typeof update.rawInput === 'object' ? update.rawInput : {}
|
|
21
|
+
const terminal = (update.content || []).find((part) => part?.type === 'terminal')
|
|
22
|
+
const shellText = (update.content || []).map(textOf).find((text) => text.trim().startsWith('$ '))
|
|
23
|
+
const location = update.locations?.[0]?.path
|
|
24
|
+
if (kind === 'execute' || terminal || shellText) return { name: 'Bash', input: { ...raw, command: raw.command || shellText?.trim().slice(2) || title } }
|
|
25
|
+
if (kind === 'edit') return { name: title.toLowerCase().includes('write') ? 'Write' : 'Edit', input: { ...raw, file_path: raw.path || location || '', path: raw.path || location || '' } }
|
|
26
|
+
if (kind === 'read') return { name: 'Read', input: { ...raw, file_path: raw.path || location || '', path: raw.path || location || '' } }
|
|
27
|
+
if (kind === 'search') return { name: 'Grep', input: raw }
|
|
28
|
+
if (kind === 'fetch') return { name: 'WebFetch', input: raw }
|
|
29
|
+
return { name: title || 'HermesTool', input: raw }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class HermesEventMapper {
|
|
33
|
+
constructor({ onEvent, sessionId = null, model = null } = {}) {
|
|
34
|
+
this.onEvent = onEvent
|
|
35
|
+
this.sessionId = sessionId
|
|
36
|
+
this.model = model
|
|
37
|
+
this.tools = new Map()
|
|
38
|
+
this.text = ''
|
|
39
|
+
this.thought = ''
|
|
40
|
+
this.textCid = null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_emit(event) { try { this.onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
44
|
+
|
|
45
|
+
startSession({ sessionId, models, modes, commands = [] } = {}) {
|
|
46
|
+
this.sessionId = sessionId || this.sessionId
|
|
47
|
+
this.model = models?.currentModelId || this.model
|
|
48
|
+
const list = (models?.availableModels || []).map((item) => ({ value: item.modelId, displayName: item.name || item.modelId, description: item.description || '' }))
|
|
49
|
+
this._emit({ kind: 'system', sessionId: this.sessionId, model: this.model, commands, models: list, modes })
|
|
50
|
+
if (list.length) this._emit({ kind: 'models', models: list, model: this.model })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
push(params = {}) {
|
|
54
|
+
if (params.sessionId && this.sessionId && params.sessionId !== this.sessionId) return
|
|
55
|
+
const update = params.update || params
|
|
56
|
+
switch (update.sessionUpdate) {
|
|
57
|
+
case 'agent_message_chunk': {
|
|
58
|
+
const chunk = textOf(update.content)
|
|
59
|
+
if (!chunk) return
|
|
60
|
+
this.text += chunk
|
|
61
|
+
this.textCid ||= `hermes-stream:${this.sessionId || 'session'}:${Date.now()}`
|
|
62
|
+
this._emit({ kind: 'assistant_stream', cid: this.textCid, text: this.text, streaming: true })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
case 'agent_thought_chunk':
|
|
66
|
+
this.thought += textOf(update.content)
|
|
67
|
+
return
|
|
68
|
+
case 'tool_call': {
|
|
69
|
+
const tool = hermesToolFor(update)
|
|
70
|
+
this.tools.set(update.toolCallId, { ...update, ...tool, startedAt: Date.now() })
|
|
71
|
+
this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: update.toolCallId, name: tool.name, input: tool.input }], parentToolUseId: null })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
case 'tool_call_update': {
|
|
75
|
+
const prior = this.tools.get(update.toolCallId) || {}
|
|
76
|
+
const merged = { ...prior, ...update }
|
|
77
|
+
this.tools.set(update.toolCallId, merged)
|
|
78
|
+
if (!['completed', 'failed'].includes(update.status)) return
|
|
79
|
+
this._emit({
|
|
80
|
+
kind: 'tool_result', toolUseId: update.toolCallId,
|
|
81
|
+
content: [{ type: 'text', text: outputText(merged) }],
|
|
82
|
+
isError: update.status === 'failed',
|
|
83
|
+
durationMs: prior.startedAt ? Date.now() - prior.startedAt : undefined,
|
|
84
|
+
parentToolUseId: null,
|
|
85
|
+
})
|
|
86
|
+
this.tools.delete(update.toolCallId)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
case 'usage_update': {
|
|
90
|
+
const used = Number(update.used) || 0
|
|
91
|
+
const max = Number(update.size) || 0
|
|
92
|
+
this._emit({ kind: 'usage', ctx: { used, max, pct: max ? Math.min(100, Math.round((used / max) * 100)) : 0, over: max > 0 && used > max, model: this.model } })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
case 'available_commands_update':
|
|
96
|
+
this._emit({ kind: 'system', sessionId: this.sessionId, model: this.model, commands: (update.availableCommands || []).map((command) => `/${command.name}`) })
|
|
97
|
+
return
|
|
98
|
+
case 'current_mode_update':
|
|
99
|
+
this._emit({ kind: 'mode', mode: update.currentModeId })
|
|
100
|
+
return
|
|
101
|
+
case 'plan': {
|
|
102
|
+
const todos = (update.entries || []).map((entry) => ({ content: entry.content || '', activeForm: entry.content || '', status: entry.status === 'in_progress' ? 'in_progress' : entry.status === 'completed' ? 'completed' : 'pending' }))
|
|
103
|
+
this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `hermes-plan:${this.sessionId}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
finishTurn({ stopReason = 'end_turn', usage = null } = {}) {
|
|
112
|
+
const blocks = []
|
|
113
|
+
if (this.thought) blocks.push({ type: 'thinking', text: this.thought })
|
|
114
|
+
if (this.text) blocks.push({ type: 'text', text: this.text })
|
|
115
|
+
if (blocks.length) this._emit({ kind: 'assistant', blocks, parentToolUseId: null, ...(this.textCid ? { replacesCid: this.textCid } : {}) })
|
|
116
|
+
this._emit({
|
|
117
|
+
kind: 'result', subtype: stopReason === 'cancelled' ? 'aborted' : stopReason === 'end_turn' ? 'success' : 'error_during_execution',
|
|
118
|
+
sessionId: this.sessionId, model: this.model, costUsd: null,
|
|
119
|
+
usage: usage ? {
|
|
120
|
+
input_tokens: Number(usage.inputTokens) || 0,
|
|
121
|
+
cache_read_input_tokens: Number(usage.cachedReadTokens) || 0,
|
|
122
|
+
cache_creation_input_tokens: 0,
|
|
123
|
+
output_tokens: (Number(usage.outputTokens) || 0) + (Number(usage.thoughtTokens) || 0),
|
|
124
|
+
} : null,
|
|
125
|
+
numTurns: 1, durationMs: undefined, denials: 0, resultText: this.text || null,
|
|
126
|
+
})
|
|
127
|
+
this.text = ''
|
|
128
|
+
this.thought = ''
|
|
129
|
+
this.textCid = null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
6
|
+
import { startHermesSession } from './hermes-session.mjs'
|
|
7
|
+
|
|
8
|
+
const waitFor = (predicate, timeoutMs = 25_000) => new Promise((resolve, reject) => {
|
|
9
|
+
const started = Date.now()
|
|
10
|
+
const timer = setInterval(() => {
|
|
11
|
+
if (predicate()) { clearInterval(timer); resolve() }
|
|
12
|
+
else if (Date.now() - started > timeoutMs) { clearInterval(timer); reject(new Error('Hermes MCP isolation probe timed out')) }
|
|
13
|
+
}, 50)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export function assertHermesIsolation(outputs, markers) {
|
|
17
|
+
if (!Array.isArray(outputs) || outputs.length !== 2 || !Array.isArray(markers) || markers.length !== 2) {
|
|
18
|
+
throw new TypeError('Hermes MCP isolation requires exactly two session outputs and markers')
|
|
19
|
+
}
|
|
20
|
+
for (let i = 0; i < 2; i++) {
|
|
21
|
+
if (!outputs[i].includes(`mcp__thinkpool__${markers[i]}`)) throw new Error(`Hermes lane ${i + 1} did not advertise its own MCP marker`)
|
|
22
|
+
if (outputs[i].includes(`mcp__thinkpool__${markers[1 - i]}`)) throw new Error(`Hermes lane ${i + 1} advertised the other lane's MCP marker`)
|
|
23
|
+
}
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function verifyHermesMcpIsolation({ rounds = 2, sessionFactory = startHermesSession } = {}) {
|
|
28
|
+
const evidence = []
|
|
29
|
+
for (let round = 0; round < rounds; round++) {
|
|
30
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tp-hermes-mcp-isolation-'))
|
|
31
|
+
const suffix = randomUUID().replaceAll('-', '').slice(0, 10)
|
|
32
|
+
const markers = [`lane_alpha_${suffix}`, `lane_beta_${suffix}`]
|
|
33
|
+
const events = [[], []]
|
|
34
|
+
const sessions = []
|
|
35
|
+
try {
|
|
36
|
+
for (let i = 0; i < 2; i++) {
|
|
37
|
+
const cwd = path.join(root, i === 0 ? 'alpha' : 'beta')
|
|
38
|
+
fs.mkdirSync(cwd)
|
|
39
|
+
const sdkServer = createSdkMcpServer({
|
|
40
|
+
name: 'thinkpool', version: '1.0.0',
|
|
41
|
+
tools: [tool(markers[i], 'Synthetic ThinkPool lane-isolation marker.', {}, async () => ({ content: [{ type: 'text', text: markers[i] }] }))],
|
|
42
|
+
})
|
|
43
|
+
sessions.push(sessionFactory({ cwd, lazy: true, mcpServers: { thinkpool: sdkServer }, onEvent: (event) => events[i].push(event) }))
|
|
44
|
+
}
|
|
45
|
+
sessions.forEach((session) => session.sendTurn('/tools'))
|
|
46
|
+
await waitFor(() => events.every((list) => list.some((event) => event.kind === 'result') || list.some((event) => event.kind === 'error')))
|
|
47
|
+
const errors = events.flatMap((list) => list.filter((event) => event.kind === 'error'))
|
|
48
|
+
if (errors.length) throw new Error(`Hermes MCP isolation probe failed: ${errors.map((event) => event.message).join('; ')}`)
|
|
49
|
+
const outputs = events.map((list) => list.filter((event) => event.kind === 'assistant_stream').map((event) => event.text).join(''))
|
|
50
|
+
assertHermesIsolation(outputs, markers)
|
|
51
|
+
evidence.push({ round: round + 1, markers, sessionIds: sessions.map((session) => session.sessionId) })
|
|
52
|
+
} finally {
|
|
53
|
+
sessions.forEach((session) => session.end())
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
55
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return evidence
|
|
59
|
+
}
|
package/hermes-probe.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
4
|
+
|
|
5
|
+
export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], execFile = execFileSync, env = process.env } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const versionOutput = execFile(command, [...prefixArgs, '--version'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
8
|
+
const version = clean(versionOutput).match(/Hermes Agent v([^\s]+)/i)?.[1] || null
|
|
9
|
+
execFile(command, [...prefixArgs, 'acp', '--check'], { encoding: 'utf8', env, timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
10
|
+
const hooksOutput = execFile(command, [...prefixArgs, 'hooks', 'list'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
11
|
+
const guardOutput = execFile(command, [...prefixArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
12
|
+
const delegationBlocked = /(?:action|decision)["']?\s*[:=]\s*["']block/i.test(guardOutput)
|
|
13
|
+
&& /ThinkPool blocks hidden Hermes delegation/i.test(guardOutput)
|
|
14
|
+
&& /pre_tool_call[\s\S]*delegate_task[\s\S]*(?:✓ allowed|allowed)/i.test(hooksOutput)
|
|
15
|
+
if (!delegationBlocked) return { available: false, version, reason: 'delegate_task is not structurally blocked by the dedicated Hermes profile' }
|
|
16
|
+
return { available: true, version, acpProtocol: 1, delegationBlocked: true }
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return { available: false, version: null, reason: clean(error?.stderr || error?.message || error) }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Hermes Agent structured runtime over the official ACP stdio transport.
|
|
2
|
+
// Implements the same narrow session contract as claude-session/codex-session.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { AcpClient } from './acp-client.mjs'
|
|
7
|
+
import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
|
|
8
|
+
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
9
|
+
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
10
|
+
import { classifyRisk } from './claude-session.mjs'
|
|
11
|
+
|
|
12
|
+
export const HERMES_COMMAND = 'thinkpool'
|
|
13
|
+
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
14
|
+
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
|
|
15
|
+
|
|
16
|
+
const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
|
|
17
|
+
export const hermesChildEnv = (env = {}) => Object.fromEntries(
|
|
18
|
+
Object.entries(env).filter(([key]) => !HERMES_SECRET_ENV_KEY.test(key)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const imageBlock = (imagePath) => {
|
|
22
|
+
const ext = path.extname(imagePath).toLowerCase()
|
|
23
|
+
const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg'
|
|
24
|
+
return { type: 'image', mimeType, data: fs.readFileSync(imagePath).toString('base64') }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const modelList = (state) => (state?.availableModels || []).map((item) => ({ value: item.modelId, displayName: item.name || item.modelId, description: item.description || '' }))
|
|
28
|
+
|
|
29
|
+
const mcpDescriptor = (url) => ({ type: 'http', name: 'thinkpool', url, headers: [] })
|
|
30
|
+
const createAcpClient = (options) => new AcpClient(options)
|
|
31
|
+
|
|
32
|
+
const permissionOutcome = (decision, options = []) => {
|
|
33
|
+
if (decision === 'deny' || !decision) return { outcome: 'cancelled' }
|
|
34
|
+
const preferred = decision === 'always'
|
|
35
|
+
? ['allow_always', 'allow_session', 'allow_once']
|
|
36
|
+
: ['allow_once', 'allow_session', 'allow_always']
|
|
37
|
+
const option = preferred.map((kind) => options.find((item) => item.kind === kind || item.optionId === kind)).find(Boolean)
|
|
38
|
+
|| options.find((item) => String(item.kind || '').startsWith('allow'))
|
|
39
|
+
return option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function startHermesSession({
|
|
43
|
+
cwd, model, resume, env = process.env, mode = 'default', onEvent, requestPermission,
|
|
44
|
+
roomContext, terminalRolePrompt, rolePrompt, mcpServers, prepareCwd = null,
|
|
45
|
+
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
46
|
+
mcpHttpFactory = startCodexMcpHttp, lazy = false,
|
|
47
|
+
} = {}) {
|
|
48
|
+
let activeCwd = cwd
|
|
49
|
+
const requestedModel = model || null
|
|
50
|
+
let activeModel = requestedModel
|
|
51
|
+
let activeMode = HERMES_SUPPORTED_MODES.has(mode) ? mode : 'default'
|
|
52
|
+
let sessionId = resume || null
|
|
53
|
+
let client = null
|
|
54
|
+
let mapper = null
|
|
55
|
+
let mcpHttp = null
|
|
56
|
+
let starting = null
|
|
57
|
+
let ended = false
|
|
58
|
+
let turnActive = false
|
|
59
|
+
let started = false
|
|
60
|
+
let crashed = false
|
|
61
|
+
let firstTurn = true
|
|
62
|
+
let stderrTail = ''
|
|
63
|
+
let promptChain = Promise.resolve()
|
|
64
|
+
|
|
65
|
+
const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
66
|
+
|
|
67
|
+
async function onRequest(method, params, requestId) {
|
|
68
|
+
if (method !== 'session/request_permission') throw Object.assign(new Error(`Unsupported ACP client method: ${method}`), { code: -32601 })
|
|
69
|
+
const tool = hermesToolFor(params.toolCall || mapper?.tools.get(params.toolCallId) || {})
|
|
70
|
+
const card = {
|
|
71
|
+
id: `hermes-perm:${requestId}`,
|
|
72
|
+
toolName: tool.name,
|
|
73
|
+
input: tool.input,
|
|
74
|
+
risk: classifyRisk(tool.name, tool.input),
|
|
75
|
+
}
|
|
76
|
+
let decision = 'deny'
|
|
77
|
+
try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
|
|
78
|
+
return { outcome: permissionOutcome(decision, params.options) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onNotification(method, params) {
|
|
82
|
+
if (method === 'session/update') mapper?.push(params)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function boot() {
|
|
86
|
+
if (ended) throw new Error('Hermes session is closed')
|
|
87
|
+
if (crashed) throw new Error('Hermes ACP process crashed; this lane must be restarted before sending another turn')
|
|
88
|
+
if (client?.alive && mapper) return
|
|
89
|
+
if (starting) return starting
|
|
90
|
+
starting = (async () => {
|
|
91
|
+
if (!started && prepareCwd) {
|
|
92
|
+
try { activeCwd = prepareCwd() || activeCwd } catch { /* retain original cwd */ }
|
|
93
|
+
}
|
|
94
|
+
const thinkpool = mcpServers?.thinkpool
|
|
95
|
+
if (thinkpool && !mcpHttp) mcpHttp = await mcpHttpFactory({ sdkServer: thinkpool })
|
|
96
|
+
const childEnv = hermesChildEnv(env)
|
|
97
|
+
// The bridge advertises Hermes only after this probe, but lazy lanes may
|
|
98
|
+
// boot much later. Re-check immediately before every real ACP process so
|
|
99
|
+
// a removed/changed delegation hook cannot ride a stale startup verdict.
|
|
100
|
+
if (command === HERMES_COMMAND && clientFactory === createAcpClient) {
|
|
101
|
+
const profile = probeHermesRuntime({ command, env: childEnv })
|
|
102
|
+
if (!profile.available) throw new Error(`Hermes profile safety check failed: ${profile.reason || 'runtime unavailable'}`)
|
|
103
|
+
}
|
|
104
|
+
client = clientFactory({
|
|
105
|
+
command, args, cwd: activeCwd, env: childEnv,
|
|
106
|
+
onNotification,
|
|
107
|
+
onRequest,
|
|
108
|
+
onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
|
|
109
|
+
onClose: (error) => {
|
|
110
|
+
if (ended || crashed) return
|
|
111
|
+
crashed = true
|
|
112
|
+
turnActive = false
|
|
113
|
+
emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
client.start()
|
|
117
|
+
const initialized = await client.request('initialize', {
|
|
118
|
+
protocolVersion: HERMES_ACP_PROTOCOL_VERSION,
|
|
119
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
120
|
+
clientInfo: { name: 'thinkpool-pair', title: 'ThinkPool Code', version: '1' },
|
|
121
|
+
})
|
|
122
|
+
if (initialized?.protocolVersion !== HERMES_ACP_PROTOCOL_VERSION) {
|
|
123
|
+
throw new Error(`Unsupported Hermes ACP protocol ${initialized?.protocolVersion ?? 'unknown'}; expected ${HERMES_ACP_PROTOCOL_VERSION}`)
|
|
124
|
+
}
|
|
125
|
+
const params = { cwd: path.resolve(activeCwd), mcpServers: mcpHttp ? [mcpDescriptor(mcpHttp.url)] : [] }
|
|
126
|
+
mapper = new HermesEventMapper({ onEvent: emit, sessionId, model: activeModel })
|
|
127
|
+
let state
|
|
128
|
+
if (sessionId) {
|
|
129
|
+
if (!initialized?.agentCapabilities?.sessionCapabilities?.resume) {
|
|
130
|
+
throw new Error(`Hermes session ${sessionId} cannot be resumed by this ACP release; no fresh session was substituted`)
|
|
131
|
+
}
|
|
132
|
+
try { state = await client.request('session/resume', { ...params, sessionId }, 30_000) }
|
|
133
|
+
catch (error) {
|
|
134
|
+
throw new Error(`Hermes session ${sessionId} is not available; no fresh session was substituted (${error?.message || error})`)
|
|
135
|
+
}
|
|
136
|
+
} else state = await client.request('session/new', params, 30_000)
|
|
137
|
+
if (!sessionId) sessionId = state?.sessionId || null
|
|
138
|
+
if (!sessionId) throw new Error('Hermes ACP did not return a session id')
|
|
139
|
+
const serverModel = state?.models?.currentModelId || null
|
|
140
|
+
activeModel = requestedModel || serverModel || activeModel
|
|
141
|
+
mapper.startSession({ sessionId, models: state?.models, modes: state?.modes, commands: [] })
|
|
142
|
+
started = true
|
|
143
|
+
if (requestedModel && serverModel && requestedModel !== serverModel) {
|
|
144
|
+
await client.request('session/set_model', { sessionId, modelId: activeModel })
|
|
145
|
+
}
|
|
146
|
+
const acpMode = activeMode === 'acceptEdits' ? 'accept_edits' : 'default'
|
|
147
|
+
if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
|
|
148
|
+
await client.request('session/set_mode', { sessionId, modeId: acpMode })
|
|
149
|
+
}
|
|
150
|
+
emit({ kind: 'capabilities', runtime: 'hermes', protocol: 'acp', protocolVersion: initialized?.protocolVersion, capabilities: initialized?.agentCapabilities || {}, models: modelList(state?.models), flow: false })
|
|
151
|
+
})().catch((error) => {
|
|
152
|
+
crashed = true
|
|
153
|
+
client?.end()
|
|
154
|
+
void mcpHttp?.close().catch(() => {})
|
|
155
|
+
emit({ kind: 'error', message: `Hermes ACP startup failed: ${error?.message || error}${stderrTail ? `: ${stderrTail.trim().slice(-240)}` : ''}`, recoverable: true })
|
|
156
|
+
throw error
|
|
157
|
+
}).finally(() => { starting = null })
|
|
158
|
+
return starting
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function promptBlocks(text, options = {}) {
|
|
162
|
+
const rawText = String(text)
|
|
163
|
+
// Hermes handles /steer, /queue, /compact and /reset only when the slash is
|
|
164
|
+
// the first character of a pure-text prompt.
|
|
165
|
+
if (/^\s*\//.test(rawText)) return [{ type: 'text', text: rawText }]
|
|
166
|
+
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
167
|
+
const preamble = firstTurn ? [terminalRolePrompt, rolePrompt, context].filter(Boolean).join('\n\n') : context
|
|
168
|
+
const blocks = [{ type: 'text', text: preamble ? `${preamble}\n\n${rawText}` : rawText }]
|
|
169
|
+
for (const imagePath of (Array.isArray(options.images) ? options.images : [])) {
|
|
170
|
+
try { blocks.push(imageBlock(imagePath)) } catch { /* quoted host path remains in text */ }
|
|
171
|
+
}
|
|
172
|
+
return blocks
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runPrompt(text, options = {}) {
|
|
176
|
+
await boot()
|
|
177
|
+
const steering = turnActive
|
|
178
|
+
const body = steering ? `/steer ${String(text)}` : String(text)
|
|
179
|
+
if (!steering) turnActive = true
|
|
180
|
+
const result = await client.request('session/prompt', {
|
|
181
|
+
sessionId,
|
|
182
|
+
prompt: promptBlocks(body, options),
|
|
183
|
+
messageId: randomUUID(),
|
|
184
|
+
}, 0)
|
|
185
|
+
if (!steering) {
|
|
186
|
+
turnActive = false
|
|
187
|
+
firstTurn = false
|
|
188
|
+
mapper.finishTurn({ stopReason: result?.stopReason, usage: result?.usage })
|
|
189
|
+
}
|
|
190
|
+
return result
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!lazy) queueMicrotask(() => { void boot().catch(() => {}) })
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
get sessionId() { return sessionId },
|
|
197
|
+
get turnActive() { return turnActive },
|
|
198
|
+
get canSteer() { return !!client?.alive },
|
|
199
|
+
get started() { return started },
|
|
200
|
+
get models() { return [] },
|
|
201
|
+
sendTurn(text, options = {}) {
|
|
202
|
+
if (ended || crashed) return false
|
|
203
|
+
// A busy prompt is a genuine ACP /steer call and may run concurrently.
|
|
204
|
+
if (turnActive) { void runPrompt(text, options).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true })); return true }
|
|
205
|
+
promptChain = promptChain.then(() => runPrompt(text, options)).catch((error) => {
|
|
206
|
+
turnActive = false
|
|
207
|
+
emit({ kind: 'error', message: `Hermes turn failed: ${error?.message || error}`, recoverable: true })
|
|
208
|
+
})
|
|
209
|
+
return true
|
|
210
|
+
},
|
|
211
|
+
abort() {
|
|
212
|
+
if (!sessionId || !client?.alive) return
|
|
213
|
+
void client.notify('session/cancel', { sessionId }).catch(() => { /* process already closed */ })
|
|
214
|
+
},
|
|
215
|
+
end() {
|
|
216
|
+
ended = true
|
|
217
|
+
turnActive = false
|
|
218
|
+
if (sessionId && client?.alive) void client.request('session/close', { sessionId }, 1500).catch(() => {}).finally(() => client?.end())
|
|
219
|
+
else client?.end()
|
|
220
|
+
void mcpHttp?.close().catch(() => {})
|
|
221
|
+
},
|
|
222
|
+
setModel(nextModel) {
|
|
223
|
+
if (!nextModel || turnActive) return false
|
|
224
|
+
void boot().then(() => client.request('session/set_model', { sessionId, modelId: String(nextModel) })).then(() => {
|
|
225
|
+
activeModel = String(nextModel)
|
|
226
|
+
if (mapper) mapper.model = activeModel
|
|
227
|
+
emit({ kind: 'note', text: `model → ${activeModel}` })
|
|
228
|
+
}).catch((error) => emit({ kind: 'error', message: `Hermes model switch failed: ${error?.message || error}`, recoverable: true }))
|
|
229
|
+
return true
|
|
230
|
+
},
|
|
231
|
+
setMode(nextMode) {
|
|
232
|
+
if (turnActive || !HERMES_SUPPORTED_MODES.has(nextMode)) return false
|
|
233
|
+
void boot().then(() => client.request('session/set_mode', { sessionId, modeId: nextMode === 'acceptEdits' ? 'accept_edits' : 'default' })).then(() => {
|
|
234
|
+
activeMode = nextMode
|
|
235
|
+
emit({ kind: 'mode', mode: activeMode })
|
|
236
|
+
}).catch((error) => emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true }))
|
|
237
|
+
return true
|
|
238
|
+
},
|
|
239
|
+
setEffort() { return false },
|
|
240
|
+
clearContext() {
|
|
241
|
+
if (turnActive) return false
|
|
242
|
+
return this.sendTurn('/reset')
|
|
243
|
+
},
|
|
244
|
+
warm() { void boot().catch(() => {}) },
|
|
245
|
+
}
|
|
246
|
+
}
|
package/hermes-setup.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execFileSync } from 'node:child_process'
|
|
5
|
+
import YAML from 'yaml'
|
|
6
|
+
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
7
|
+
|
|
8
|
+
export const HERMES_PROFILE = 'thinkpool'
|
|
9
|
+
export const HERMES_GUARD_FILENAME = 'hermes-delegation-guard.mjs'
|
|
10
|
+
|
|
11
|
+
const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
12
|
+
const commandText = (guardPath) => `node ${JSON.stringify(guardPath)}`
|
|
13
|
+
const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value)
|
|
14
|
+
const isThinkPoolGuard = (entry) => isObject(entry)
|
|
15
|
+
&& entry.matcher === '^delegate_task$'
|
|
16
|
+
&& new RegExp(`${HERMES_GUARD_FILENAME.replaceAll('.', '\\.')}$`).test(String(entry.command || '').replace(/["']/g, ''))
|
|
17
|
+
|
|
18
|
+
export function hermesGuardPath({ homeDir = os.homedir(), env = process.env, platform = process.platform } = {}) {
|
|
19
|
+
const dataRoot = platform === 'win32'
|
|
20
|
+
? (env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'))
|
|
21
|
+
: (env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'))
|
|
22
|
+
return path.join(dataRoot, 'thinkpool-pair', HERMES_GUARD_FILENAME)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function atomicWrite(filePath, content, { fsImpl = fs, mode = 0o600 } = {}) {
|
|
26
|
+
fsImpl.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
27
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
28
|
+
try {
|
|
29
|
+
fsImpl.writeFileSync(tempPath, content, { mode })
|
|
30
|
+
fsImpl.chmodSync?.(tempPath, mode)
|
|
31
|
+
fsImpl.renameSync(tempPath, filePath)
|
|
32
|
+
} finally {
|
|
33
|
+
try { fsImpl.rmSync(tempPath, { force: true }) } catch { /* already renamed */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function snapshotFile(filePath, fsImpl) {
|
|
38
|
+
if (!fsImpl.existsSync(filePath)) return null
|
|
39
|
+
return {
|
|
40
|
+
content: fsImpl.readFileSync(filePath),
|
|
41
|
+
mode: fsImpl.statSync(filePath).mode & 0o777,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function restoreFile(filePath, snapshot, fsImpl) {
|
|
46
|
+
if (snapshot) atomicWrite(filePath, snapshot.content, { fsImpl, mode: snapshot.mode })
|
|
47
|
+
else fsImpl.rmSync(filePath, { force: true })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function profileAliasReady(output, profile) {
|
|
51
|
+
const escaped = profile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
52
|
+
return new RegExp(`Alias:\\s+${escaped}\\s+.*hermes\\s+-p\\s+${escaped}`, 'i').test(String(output || ''))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setupHermesRuntime({
|
|
56
|
+
mode,
|
|
57
|
+
profile = HERMES_PROFILE,
|
|
58
|
+
command = 'hermes',
|
|
59
|
+
env = process.env,
|
|
60
|
+
homeDir = os.homedir(),
|
|
61
|
+
platform = process.platform,
|
|
62
|
+
execFile = execFileSync,
|
|
63
|
+
fsImpl = fs,
|
|
64
|
+
guardSource = fs.readFileSync(new URL('./hermes-delegation-guard.mjs', import.meta.url), 'utf8'),
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (profile !== HERMES_PROFILE) throw new Error(`ThinkPool only manages the isolated Hermes profile ${HERMES_PROFILE}`)
|
|
67
|
+
if (mode != null && mode !== 'clone' && mode !== 'clean') throw new Error('Hermes setup mode must be clone or clean')
|
|
68
|
+
|
|
69
|
+
const run = (args, options = {}) => execFile(command, args, {
|
|
70
|
+
encoding: 'utf8', env: options.env || env, timeout: options.timeout || 20_000,
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
})
|
|
73
|
+
const profileArgs = ['-p', profile]
|
|
74
|
+
let show = ''
|
|
75
|
+
let created = false
|
|
76
|
+
try { show = run(['profile', 'show', profile]) } catch { /* absent */ }
|
|
77
|
+
if (!/Profile:\s*thinkpool/i.test(show)) {
|
|
78
|
+
if (!mode) throw new Error('No isolated Hermes thinkpool profile exists; choose --clone or --clean')
|
|
79
|
+
const createArgs = ['profile', 'create', profile, '--description', 'Isolated ThinkPool Code runtime.']
|
|
80
|
+
if (mode === 'clone') createArgs.push('--clone')
|
|
81
|
+
else createArgs.push('--no-skills')
|
|
82
|
+
run(createArgs, { timeout: 60_000 })
|
|
83
|
+
created = true
|
|
84
|
+
show = run(['profile', 'show', profile])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!profileAliasReady(show, profile)) {
|
|
88
|
+
run(['profile', 'alias', profile, '--name', profile])
|
|
89
|
+
show = run(['profile', 'show', profile])
|
|
90
|
+
if (!profileAliasReady(show, profile)) throw new Error('Hermes did not create the required thinkpool profile alias')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rawConfigPath = clean(run([...profileArgs, 'config', 'path']))
|
|
94
|
+
if (!rawConfigPath) throw new Error('Hermes did not report the thinkpool profile config path')
|
|
95
|
+
const configPath = rawConfigPath.startsWith('~') ? path.join(homeDir, rawConfigPath.slice(1).replace(/^[/\\]/, '')) : path.resolve(rawConfigPath)
|
|
96
|
+
const profilePathMatch = String(show).match(/^Path:\s*(.+)$/mi)
|
|
97
|
+
if (!profilePathMatch) throw new Error('Hermes did not report the isolated thinkpool profile path')
|
|
98
|
+
const profilePath = profilePathMatch[1].trim().startsWith('~')
|
|
99
|
+
? path.join(homeDir, profilePathMatch[1].trim().slice(1).replace(/^[/\\]/, ''))
|
|
100
|
+
: path.resolve(profilePathMatch[1].trim())
|
|
101
|
+
const relativeConfig = path.relative(profilePath, configPath)
|
|
102
|
+
if (relativeConfig.startsWith('..') || path.isAbsolute(relativeConfig)) throw new Error('Hermes reported a config path outside the isolated thinkpool profile')
|
|
103
|
+
let config = {}
|
|
104
|
+
if (fsImpl.existsSync(configPath)) {
|
|
105
|
+
const parsed = YAML.parse(fsImpl.readFileSync(configPath, 'utf8'))
|
|
106
|
+
if (parsed != null && !isObject(parsed)) throw new Error('Hermes thinkpool config is not a YAML mapping; refusing to overwrite it')
|
|
107
|
+
config = parsed || {}
|
|
108
|
+
}
|
|
109
|
+
if (config.hooks != null && !isObject(config.hooks)) throw new Error('Hermes thinkpool hooks config is not a mapping; refusing to overwrite it')
|
|
110
|
+
const hooks = config.hooks ||= {}
|
|
111
|
+
if (hooks.pre_tool_call != null && !Array.isArray(hooks.pre_tool_call)) throw new Error('Hermes pre_tool_call hooks are not a list; refusing to overwrite them')
|
|
112
|
+
const prior = hooks.pre_tool_call || []
|
|
113
|
+
hooks.pre_tool_call = [
|
|
114
|
+
...prior.filter((entry) => !isThinkPoolGuard(entry)),
|
|
115
|
+
{ matcher: '^delegate_task$', command: commandText(hermesGuardPath({ homeDir, env, platform })), timeout: 5 },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const guardPath = hermesGuardPath({ homeDir, env, platform })
|
|
119
|
+
const guardSnapshot = snapshotFile(guardPath, fsImpl)
|
|
120
|
+
const configSnapshot = snapshotFile(configPath, fsImpl)
|
|
121
|
+
try {
|
|
122
|
+
atomicWrite(guardPath, guardSource, { fsImpl, mode: 0o700 })
|
|
123
|
+
atomicWrite(configPath, YAML.stringify(config, { lineWidth: 0 }), { fsImpl, mode: 0o600 })
|
|
124
|
+
|
|
125
|
+
const consentEnv = { ...env, HERMES_ACCEPT_HOOKS: '1' }
|
|
126
|
+
const testOutput = run([...profileArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { env: consentEnv })
|
|
127
|
+
if (!/(?:action|decision)["']?\s*[:=]\s*["']block/i.test(testOutput)
|
|
128
|
+
|| !/ThinkPool blocks hidden Hermes delegation/i.test(testOutput)) {
|
|
129
|
+
throw new Error('Hermes ran the ThinkPool delegation guard but did not report an explicit block')
|
|
130
|
+
}
|
|
131
|
+
run([...profileArgs, 'hooks', 'doctor'], { env: consentEnv })
|
|
132
|
+
run([...profileArgs, 'acp', '--check'], { env: consentEnv })
|
|
133
|
+
const probe = probeHermesRuntime({ command, prefixArgs: profileArgs, execFile, env: consentEnv })
|
|
134
|
+
if (!probe.available) throw new Error(`Hermes setup did not pass the final runtime probe: ${probe.reason || 'unavailable'}`)
|
|
135
|
+
return { ...probe, profile, created, mode: created ? mode : 'existing', guardPath, configPath }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
try {
|
|
138
|
+
restoreFile(configPath, configSnapshot, fsImpl)
|
|
139
|
+
restoreFile(guardPath, guardSnapshot, fsImpl)
|
|
140
|
+
} catch (rollbackError) {
|
|
141
|
+
error.message = `${error.message}; rollback also failed: ${clean(rollbackError.message)}`
|
|
142
|
+
}
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
package/interrupted-resume.mjs
CHANGED
|
@@ -52,12 +52,24 @@ export function isMissingResumeError(message) {
|
|
|
52
52
|
return /No conversation found|no rollout found for thread id|thread\/resume[^\n]*failed[^\n]*no rollout/i.test(String(message || ''))
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function shouldAttemptNativeResume(record, { canResume = () => false, codexThreadCanResume = () => true } = {}) {
|
|
56
|
+
if (!record?.sessionId) return false
|
|
57
|
+
// Hermes has an explicit, non-replaying ACP resume request that fails loudly
|
|
58
|
+
// when identity is gone. Always ask the runtime instead of silently aging the
|
|
59
|
+
// identity out under Claude's historical local freshness heuristic.
|
|
60
|
+
if (record.runtime === 'hermes') return true
|
|
61
|
+
return canResume(record) && (record.runtime !== 'codex' || codexThreadCanResume(record.sessionId))
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
// Bound the stale-native-thread self-heal at the ownership seam. The old entry is
|
|
56
65
|
// marked before teardown/reopen so duplicate error delivery can never fork another
|
|
57
66
|
// process. `reopen` returns the new structured entry; Codex consumes the recap
|
|
58
67
|
// immediately because a fresh exec has no idle init event.
|
|
59
68
|
export function recoverMissingResumeOnce(entry, { message, recap = '', reopen } = {}) {
|
|
60
|
-
|
|
69
|
+
// Hermes ACP session identity is part of the lane contract. If native resume
|
|
70
|
+
// fails, surfacing that failure is safer than silently replacing the session
|
|
71
|
+
// with a recap-backed fresh identity.
|
|
72
|
+
if (!entry || entry.runtime === 'hermes' || entry.recovered || !isMissingResumeError(message)) return { recovered: false, delivery: 'ignored' }
|
|
61
73
|
entry.recovered = true
|
|
62
74
|
try { entry.session?.end?.() } catch { /* already dead */ }
|
|
63
75
|
let recovered = null
|
package/launcher.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import fs from 'node:fs'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { execSync } from 'node:child_process'
|
|
17
17
|
import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
|
|
18
|
+
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
18
19
|
|
|
19
20
|
const HOME = os.homedir()
|
|
20
21
|
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
@@ -27,6 +28,7 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
|
|
|
27
28
|
export const KNOWN_AGENTS = [
|
|
28
29
|
{ label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
|
|
29
30
|
{ label: 'Codex CLI', cmd: 'codex' },
|
|
31
|
+
{ label: 'Hermes Agent', cmd: 'thinkpool' },
|
|
30
32
|
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
31
33
|
{ label: 'Aider', cmd: 'aider' },
|
|
32
34
|
{ label: 'Cursor CLI', cmd: 'cursor-agent' },
|
|
@@ -73,12 +75,16 @@ export function detectState() {
|
|
|
73
75
|
// provider.json is written with { provider, baseUrl, model } (see provider.mjs).
|
|
74
76
|
// Anything whose `provider` field is set and not "anthropic" is a custom endpoint.
|
|
75
77
|
const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
|
|
78
|
+
const hermesInstalled = onPath('hermes')
|
|
79
|
+
const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
|
|
76
80
|
return {
|
|
77
81
|
loggedIn: !!auth?.refresh_token,
|
|
78
82
|
email: auth?.email || auth?.user?.email || null,
|
|
79
83
|
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
80
84
|
providerModel: isCustom ? (provider.model || null) : null,
|
|
81
|
-
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
|
|
85
|
+
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd) && (a.cmd !== 'thinkpool' || hermesReady)),
|
|
86
|
+
hermesInstalled,
|
|
87
|
+
hermesReady,
|
|
82
88
|
accountSvc: serviceLoaded(null),
|
|
83
89
|
cwd: process.cwd(),
|
|
84
90
|
version: VERSION,
|
|
@@ -205,24 +211,34 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
205
211
|
|
|
206
212
|
const settingsMenu = async () => {
|
|
207
213
|
for (;;) {
|
|
208
|
-
const
|
|
209
|
-
{ label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
210
|
-
{ label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
211
|
-
{ label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
|
|
212
|
-
{ label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
214
|
+
const options = [
|
|
215
|
+
{ key: 'provider', label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
216
|
+
{ key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
217
|
+
{ key: 'restart', label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
|
|
218
|
+
{ key: 'remove', label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
219
|
+
]
|
|
220
|
+
if (state.hermesInstalled) options.push({ key: 'hermes', label: 'Hermes Agent', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
|
|
221
|
+
options.push({ key: 'back', label: '← Back to main menu', hint: '' })
|
|
222
|
+
const picked = options[await askChoice('\n change', options, options.length)]
|
|
223
|
+
if (picked.key === 'back') return
|
|
224
|
+
if (picked.key === 'provider') await providerMenu()
|
|
225
|
+
else if (picked.key === 'account') { await actions.login(); resync(); return }
|
|
226
|
+
else if (picked.key === 'restart') {
|
|
219
227
|
if (state.accountSvc) { await actions.restartUpdateService({ room: null }); resync() }
|
|
220
228
|
else io.print(' ' + C.yellow('no background service installed'))
|
|
221
229
|
}
|
|
222
|
-
else {
|
|
230
|
+
else if (picked.key === 'remove') {
|
|
223
231
|
if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
|
|
224
232
|
else io.print(' ' + C.yellow('no background service installed'))
|
|
225
233
|
}
|
|
234
|
+
else if (picked.key === 'hermes') {
|
|
235
|
+
const setup = await askChoice('\n Hermes profile', [
|
|
236
|
+
{ label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
|
|
237
|
+
{ label: 'Create clean profile', hint: 'no copied credentials or bundled skills; configure provider afterward' },
|
|
238
|
+
{ label: '← Back', hint: '' },
|
|
239
|
+
], 3)
|
|
240
|
+
if (setup !== 2) { await actions.setupHermes({ mode: setup === 0 ? 'clone' : 'clean' }); resync() }
|
|
241
|
+
}
|
|
226
242
|
}
|
|
227
243
|
}
|
|
228
244
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.235",
|
|
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": {
|
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
"codex-mcp-http.mjs",
|
|
21
21
|
"lane-worktree.mjs",
|
|
22
22
|
"codex-event-mapper.mjs",
|
|
23
|
+
"acp-client.mjs",
|
|
24
|
+
"hermes-session.mjs",
|
|
25
|
+
"hermes-event-mapper.mjs",
|
|
26
|
+
"hermes-probe.mjs",
|
|
27
|
+
"hermes-setup.mjs",
|
|
28
|
+
"hermes-isolation.mjs",
|
|
29
|
+
"hermes-delegation-guard.mjs",
|
|
30
|
+
"runtime-registry.mjs",
|
|
31
|
+
"runtime-session.mjs",
|
|
23
32
|
"turn-stall.mjs",
|
|
24
33
|
"update-gate.mjs",
|
|
25
34
|
"agent-notify.mjs",
|
|
@@ -70,9 +79,11 @@
|
|
|
70
79
|
"node": ">=18"
|
|
71
80
|
},
|
|
72
81
|
"dependencies": {
|
|
82
|
+
"@agentclientprotocol/sdk": "1.2.1",
|
|
73
83
|
"@anthropic-ai/claude-agent-sdk": "^0.3.198",
|
|
74
84
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
75
85
|
"@supabase/supabase-js": "^2.45.0",
|
|
86
|
+
"yaml": "2.9.0",
|
|
76
87
|
"zod": "^4.4.3"
|
|
77
88
|
},
|
|
78
89
|
"optionalDependencies": {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const RUNTIMES = Object.freeze({
|
|
4
|
+
claude: Object.freeze({
|
|
5
|
+
id: 'claude', command: 'claude', label: 'Claude Code', protocol: 'agent-sdk',
|
|
6
|
+
structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, defaultMode: 'default',
|
|
7
|
+
modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
|
|
8
|
+
}),
|
|
9
|
+
codex: Object.freeze({
|
|
10
|
+
id: 'codex', command: 'codex', label: 'Codex', protocol: 'codex-app-server',
|
|
11
|
+
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'bypassPermissions',
|
|
12
|
+
modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions', 'review']),
|
|
13
|
+
}),
|
|
14
|
+
hermes: Object.freeze({
|
|
15
|
+
id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
|
|
16
|
+
structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'default',
|
|
17
|
+
modes: Object.freeze(['default', 'acceptEdits']),
|
|
18
|
+
beta: true,
|
|
19
|
+
}),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const STRUCTURED_RUNTIME_IDS = Object.freeze(Object.keys(RUNTIMES))
|
|
23
|
+
export const structuredRuntimeMetadata = (runtime) => RUNTIMES[runtime] || null
|
|
24
|
+
export const structuredRuntimeForCommand = (command) => {
|
|
25
|
+
const bin = path.basename(String(command || '')).toLowerCase()
|
|
26
|
+
return Object.values(RUNTIMES).find((runtime) => runtime.command === bin)?.id || null
|
|
27
|
+
}
|
|
28
|
+
export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
|
|
29
|
+
export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
|
|
30
|
+
export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { startClaudeSession } from './claude-session.mjs'
|
|
2
|
+
import { startCodexSession } from './codex-session.mjs'
|
|
3
|
+
import { startHermesSession } from './hermes-session.mjs'
|
|
4
|
+
|
|
5
|
+
const FACTORIES = Object.freeze({
|
|
6
|
+
claude: startClaudeSession,
|
|
7
|
+
codex: startCodexSession,
|
|
8
|
+
hermes: startHermesSession,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export function startStructuredSession(runtime, options) {
|
|
12
|
+
const factory = FACTORIES[runtime]
|
|
13
|
+
if (!factory) throw new Error(`Unsupported structured runtime: ${runtime}`)
|
|
14
|
+
return factory(options)
|
|
15
|
+
}
|