thinkpool-pair 0.7.233 → 0.7.234

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' },
@@ -437,12 +440,9 @@ const STRUCTURED = process.env.TP_STRUCTURED === '1' || argv.includes('--structu
437
440
  // Claude Code runs as a STRUCTURED Agent-SDK session by default now (the picked
438
441
  // design — structured reader, risk-tiered permissions). Other CLIs keep the PTY
439
442
  // 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
- }
443
+ const structuredRuntime = structuredRuntimeForCommand
444
444
  const wantStructured = (cmd) => !!structuredRuntime(cmd) && process.env.TP_PTY !== '1'
445
- const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
445
+ const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd) && (!a.availability || a.availability()))
446
446
  const dashIdx = argv.indexOf('--')
447
447
  // Own flags are only read from BEFORE `--`; after it, every token belongs
448
448
  // to the shared command (`-- claude --continue` must not trip these).
@@ -1063,7 +1063,7 @@ const announce = () => {
1063
1063
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1064
1064
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1065
1065
  // 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' } : {}),
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, 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
1067
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1068
1068
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1069
1069
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1657,14 +1657,14 @@ function worktreeSnapshot(cwd) {
1657
1657
  // rolling log replays to joiners and survives bridge restarts (session-store).
1658
1658
  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
1659
  if (sessions.has(id)) return
1660
- runtime = runtime === 'codex' ? 'codex' : 'claude'
1660
+ runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
1661
1661
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
1662
1662
  // codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
1663
1663
  // advance / ship instead of hitting the no-network wall; claude → default.
1664
1664
  // Only fires when no mode was passed — a person's pick, a resume (persisted
1665
1665
  // entry.mode), a flow lane, and spawn_terminal (inherits the parent's mode +
1666
1666
  // its own bypass-escalation gate) all pass an explicit mode and skip this.
1667
- mode = STRUCTURED_MODES.has(mode) ? mode : defaultModeForRuntime(runtime)
1667
+ mode = STRUCTURED_MODES.has(mode) && structuredRuntimeSupportsMode(runtime, mode) ? mode : defaultStructuredMode(runtime)
1668
1668
  assertRuntimeModelCompatible({ runtime, provider, model })
1669
1669
  // The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
1670
1670
  // label (entry.model) and the SDK's `model` option below. These used to be computed
@@ -1679,6 +1679,8 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1679
1679
  ? (codexRequestedModel
1680
1680
  ? resolveCodexModel(codexRequestedModel, codexModels)
1681
1681
  : flowLaneModelFor({ sliceType: 'feature', runtime: 'codex', catalog: codexModels }))
1682
+ : runtime === 'hermes'
1683
+ ? (model || undefined)
1682
1684
  : effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
1683
1685
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1684
1686
  // session store so the Ensemble flag survives a bridge restart (else a respin
@@ -1700,7 +1702,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1700
1702
  // No configured model → the provider's NAME, never a false Claude id (an inherited
1701
1703
  // claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
1702
1704
  // label fallback here). Built-in Anthropic keeps the passed model.
1703
- model: runtime === 'codex'
1705
+ model: runtime === 'codex' || runtime === 'hermes'
1704
1706
  ? (laneModel || null)
1705
1707
  : (!provider || provider === 'anthropic')
1706
1708
  ? (laneModel || null)
@@ -2410,7 +2412,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2410
2412
  flowRole: entry.flowRole,
2411
2413
  sideParent: entry.sideParent,
2412
2414
  })
2413
- entry.session = (runtime === 'codex' ? startCodexSession : startClaudeSession)({
2415
+ entry.session = startStructuredSession(runtime, {
2414
2416
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2415
2417
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2416
2418
  // Claude id here defeats the whole per-provider env. undefined → option omitted.
@@ -2556,7 +2558,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2556
2558
  // App Server steering is opt-in and can fall back to exec at runtime. Keep
2557
2559
  // the roster capability authoritative so the browser never immediate-sends
2558
2560
  // into an exec-only turn and loses its shared queue.
2559
- { const canSteerNow = entry.runtime === 'codex' && (entry.session?.canSteer ?? false)
2561
+ { const canSteerNow = structuredRuntimeMetadata(entry.runtime)?.canSteer === true && (entry.session?.canSteer ?? false)
2560
2562
  if (canSteerNow !== entry._canSteerAnn) { entry._canSteerAnn = canSteerNow; announce() } }
2561
2563
  // Capture this session's current MODEL (resolved full id, with version — from
2562
2564
  // system/result `model` or usage `ctx.model`) and the SDK's supported-model
@@ -2573,13 +2575,14 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2573
2575
  // SDK id overwrite it. Only trust the SDK's model on the built-in Anthropic path.
2574
2576
  // Keyed on the LANE's provider (entry.provider), not the bridge default — lanes can
2575
2577
  // 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
2578
+ { const onBuiltin = entry.runtime !== 'hermes' && (!entry.provider || entry.provider === 'anthropic')
2579
+ const m = (entry.runtime === 'hermes' || onBuiltin) ? (evt.model || evt.ctx?.model) : null
2578
2580
  if (m && m !== entry.model) { entry.model = m; announce() }
2579
2581
  // A custom Anthropic-compatible endpoint may expose its own catalog through the
2580
2582
  // same SDK method. Never publish that as the ROOM's Claude catalog: its models
2581
2583
  // 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() } }
2584
+ if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
2585
+ else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2583
2586
  // Cache the last usage/ctx meter on the entry (persisted + replayed — see
2584
2587
  // openStructured). Chrome events bypass pushLog/persist in emitTail below, so
2585
2588
  // usage needs an explicit persist() here to survive a bridge restart.
@@ -2795,7 +2798,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2795
2798
  process.stderr.write(req.risk === 'plan'
2796
2799
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
2797
2800
  : 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`
2801
+ ? `\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
2802
  : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
2800
2803
  const autoResolutionMs = Number(req.autoResolutionMs)
2801
2804
  if (req.risk === 'ask' && Number.isFinite(autoResolutionMs) && autoResolutionMs > 0) {
@@ -2830,7 +2833,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2830
2833
  // room would otherwise show a fresh empty terminal + strand the transcript).
2831
2834
  if (!entry.log.length) entry.flush()
2832
2835
  announce()
2833
- if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
2836
+ if (!entry.log.length) process.stderr.write(`\n ◆ structured ${structuredRuntimeMetadata(runtime)?.label || 'agent'} session (${id.slice(0, 8)}) — driven from the room.\n`)
2834
2837
  return entry
2835
2838
  }
2836
2839
 
@@ -3140,7 +3143,7 @@ channel
3140
3143
  runtime,
3141
3144
  mode: parent?.mode || payload.mode,
3142
3145
  model: parent?.model || payload.model || undefined,
3143
- provider: runtime === 'codex' ? undefined : (parent?.provider || payload.provider || undefined),
3146
+ provider: runtime === 'claude' ? (parent?.provider || payload.provider || undefined) : undefined,
3144
3147
  cwd: parent ? (parent.cwd || process.cwd()) : undefined,
3145
3148
  sideParent: sideParent || undefined,
3146
3149
  sideTask: sideParent ? String(payload.sideTask).trim() : undefined,
@@ -3318,7 +3321,7 @@ channel
3318
3321
  // ── structured-session control (Phase 2) ──
3319
3322
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
3320
3323
  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 })
3324
+ 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
3325
  })
3323
3326
  .on('broadcast', { event: 'side-handback-request' }, ({ payload }) => {
3324
3327
  const side = payload?.term && sessions.get(payload.term)
@@ -3387,7 +3390,7 @@ channel
3387
3390
  if (mm) {
3388
3391
  if (mm[1]) {
3389
3392
  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')
3393
+ ctlLine(`finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching models`)
3391
3394
  return
3392
3395
  }
3393
3396
  // Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
@@ -3410,7 +3413,7 @@ channel
3410
3413
  }
3411
3414
  if (/^\/clear\s*$/.test(text)) {
3412
3415
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
3413
- if (s.runtime === 'codex') s.session.clearContext?.()
3416
+ if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
3414
3417
  else s.session.sendTurn(text)
3415
3418
  // The seq reset is load-bearing on BOTH ends: the client's clear handler sets
3416
3419
  // seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
@@ -3494,7 +3497,7 @@ channel
3494
3497
  // image paths from the bridge-owned UPDIR + deterministic attachment fields,
3495
3498
  // then cover the separate file-put/code-turn download race with a bounded
3496
3499
  // readiness wait. A late image remains available through its quoted text path.
3497
- const nativeImages = s.runtime === 'codex'
3500
+ const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
3498
3501
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
3499
3502
  : []
3500
3503
  s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
@@ -3535,9 +3538,9 @@ channel
3535
3538
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
3536
3539
  const s = payload?.term && sessions.get(payload.term)
3537
3540
  if (s && STRUCTURED_MODES.has(payload.mode)) {
3538
- if (s.runtime === 'codex') {
3541
+ if (s.runtime === 'codex' || s.runtime === 'hermes') {
3539
3542
  if (!s.session.setMode(payload.mode)) {
3540
- const evt = { kind: 'control', text: 'finish or stop the current Codex turn before switching permissions' }
3543
+ const evt = { kind: 'control', text: `finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching permissions` }
3541
3544
  pushLog(s, evt)
3542
3545
  bcast('code-event', { term: payload.term, evt })
3543
3546
  announce()
@@ -3735,13 +3738,12 @@ channel
3735
3738
  // leaves that file at zero bytes; both App Server and exec reject
3736
3739
  // it. Treat only that provably-empty case as non-resumable so the
3737
3740
  // existing fresh-thread recap recovery handles it.
3738
- const resumable = canResume(rec)
3739
- && (rec.runtime !== 'codex' || codexThreadCanResume(rec.sessionId))
3741
+ const resumable = shouldAttemptNativeResume(rec, { canResume, codexThreadCanResume })
3740
3742
  const recoveryRecap = wasInterrupted ? buildRecapFromLog(rec.log || [], RECAP_CAP) : ''
3741
3743
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3742
3744
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3743
3745
  // 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,
3746
+ 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
3747
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3746
3748
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3747
3749
  // (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,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', execFile = execFileSync, env = process.env } = {}) {
6
+ try {
7
+ const versionOutput = execFile(command, ['--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, ['acp', '--check'], { encoding: 'utf8', env, timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
10
+ const hooksOutput = execFile(command, ['hooks', 'list'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
11
+ const guardOutput = execFile(command, ['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
+ }
@@ -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
@@ -27,6 +27,7 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
27
27
  export const KNOWN_AGENTS = [
28
28
  { label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
29
29
  { label: 'Codex CLI', cmd: 'codex' },
30
+ { label: 'Hermes Agent', cmd: 'thinkpool' },
30
31
  { label: 'Gemini CLI', cmd: 'gemini' },
31
32
  { label: 'Aider', cmd: 'aider' },
32
33
  { label: 'Cursor CLI', cmd: 'cursor-agent' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.233",
3
+ "version": "0.7.234",
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,13 @@
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-delegation-guard.mjs",
28
+ "runtime-registry.mjs",
29
+ "runtime-session.mjs",
23
30
  "turn-stall.mjs",
24
31
  "update-gate.mjs",
25
32
  "agent-notify.mjs",
@@ -70,6 +77,7 @@
70
77
  "node": ">=18"
71
78
  },
72
79
  "dependencies": {
80
+ "@agentclientprotocol/sdk": "1.2.1",
73
81
  "@anthropic-ai/claude-agent-sdk": "^0.3.198",
74
82
  "@modelcontextprotocol/sdk": "^1.29.0",
75
83
  "@supabase/supabase-js": "^2.45.0",
@@ -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
+ }