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 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 { startClaudeSession } from './claude-session.mjs'
53
- import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume, defaultModeForRuntime } from './codex-session.mjs'
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 = (cmd) => {
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 === 'codex' ? 'codex' : 'claude'
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 : defaultModeForRuntime(runtime)
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 === 'codex' ? startCodexSession : startClaudeSession)({
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 === 'codex' && (entry.session?.canSteer ?? false)
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 (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
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 === 'codex' ? 'Codex' : 'Claude')} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
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 Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
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 === 'codex' ? undefined : (parent?.provider || payload.provider || undefined),
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 === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode, defer: true })
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(s.runtime === 'codex' ? 'finish or stop the current Codex turn before switching models' : 'model switch unavailable right now')
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: 'finish or stop the current Codex turn before switching permissions' }
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 = canResume(rec)
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 === 'codex' ? undefined : rec.provider || 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,
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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
- if (!entry || entry.recovered || !isMissingResumeError(message)) return { recovered: false, delivery: 'ignored' }
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 pick = await askChoice('\n change', [
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
- { label: '← Back to main menu', hint: '' },
214
- ], 5) // Enter = Back
215
- if (pick === 4) return
216
- if (pick === 0) await providerMenu()
217
- else if (pick === 1) { await actions.login(); resync(); return }
218
- else if (pick === 2) {
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.233",
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
+ }