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 +140 -0
- package/agent-detect.mjs +1 -1
- package/bridge.mjs +31 -29
- package/hermes-delegation-guard.mjs +15 -0
- package/hermes-event-mapper.mjs +131 -0
- package/hermes-probe.mjs +20 -0
- package/hermes-session.mjs +246 -0
- package/interrupted-resume.mjs +13 -1
- package/launcher.mjs +1 -0
- package/package.json +9 -1
- package/runtime-registry.mjs +30 -0
- package/runtime-session.mjs +15 -0
package/acp-client.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Runtime-neutral supervised ACP stdio client. Protocol framing, JSON-RPC,
|
|
2
|
+
// cancellation, typed client handlers, and schemas come from the official ACP
|
|
3
|
+
// SDK. ThinkPool owns only subprocess supervision and its stricter stdout rule.
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { Readable, Transform, Writable } from 'node:stream'
|
|
6
|
+
import * as acp from '@agentclientprotocol/sdk'
|
|
7
|
+
|
|
8
|
+
const closedError = (value, fallback = 'ACP process closed') => value instanceof Error
|
|
9
|
+
? value
|
|
10
|
+
: new Error(String(value || fallback))
|
|
11
|
+
|
|
12
|
+
function strictProtocolGate(onError) {
|
|
13
|
+
let buffered = ''
|
|
14
|
+
const validate = (line) => {
|
|
15
|
+
if (!line.trim()) return
|
|
16
|
+
let message
|
|
17
|
+
try { message = JSON.parse(line) } catch { throw new Error('ACP stdout contained non-JSON protocol data') }
|
|
18
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
19
|
+
throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
|
|
20
|
+
}
|
|
21
|
+
const hasId = Object.prototype.hasOwnProperty.call(message, 'id')
|
|
22
|
+
const hasMethod = typeof message.method === 'string' && message.method.length > 0
|
|
23
|
+
const hasResult = Object.prototype.hasOwnProperty.call(message, 'result')
|
|
24
|
+
const hasError = Object.prototype.hasOwnProperty.call(message, 'error')
|
|
25
|
+
const isCall = hasMethod && !(hasResult || hasError)
|
|
26
|
+
const isResponse = hasId && !hasMethod && hasResult !== hasError
|
|
27
|
+
if (message.jsonrpc !== '2.0' || (!isCall && !isResponse)) {
|
|
28
|
+
throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const gate = new Transform({
|
|
32
|
+
transform(chunk, _encoding, callback) {
|
|
33
|
+
try {
|
|
34
|
+
buffered += String(chunk)
|
|
35
|
+
const lines = buffered.split('\n')
|
|
36
|
+
buffered = lines.pop() || ''
|
|
37
|
+
for (const line of lines) validate(line)
|
|
38
|
+
callback(null, chunk)
|
|
39
|
+
} catch (error) { callback(error) }
|
|
40
|
+
},
|
|
41
|
+
flush(callback) {
|
|
42
|
+
try { validate(buffered); callback(null, buffered || undefined) }
|
|
43
|
+
catch (error) { callback(error) }
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
gate.on('error', onError)
|
|
47
|
+
return gate
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AcpClient {
|
|
51
|
+
constructor({ command, args = ['acp'], cwd, env = process.env, spawnImpl = spawn, onNotification, onRequest, onStderr, onClose, timeoutMs = 10_000 } = {}) {
|
|
52
|
+
this.command = command
|
|
53
|
+
this.args = args
|
|
54
|
+
this.cwd = cwd
|
|
55
|
+
this.env = env
|
|
56
|
+
this.spawnImpl = spawnImpl
|
|
57
|
+
this.onNotification = onNotification
|
|
58
|
+
this.onRequest = onRequest
|
|
59
|
+
this.onStderr = onStderr
|
|
60
|
+
this.onClose = onClose
|
|
61
|
+
this.timeoutMs = timeoutMs
|
|
62
|
+
this.child = null
|
|
63
|
+
this.connection = null
|
|
64
|
+
this.closedError = null
|
|
65
|
+
this.stderrTail = ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get alive() { return !!this.child && !!this.connection && !this.closedError }
|
|
69
|
+
|
|
70
|
+
start() {
|
|
71
|
+
if (this.child) return
|
|
72
|
+
const child = this.spawnImpl(this.command, this.args, { cwd: this.cwd, env: this.env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
73
|
+
this.child = child
|
|
74
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
75
|
+
const text = String(chunk)
|
|
76
|
+
this.stderrTail = (this.stderrTail + text).slice(-2000)
|
|
77
|
+
try { this.onStderr?.(text) } catch { /* consumer isolation */ }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const failProtocol = (error) => {
|
|
81
|
+
const failure = closedError(error, 'ACP protocol stream failed')
|
|
82
|
+
this.connection?.close(failure)
|
|
83
|
+
this._close(failure)
|
|
84
|
+
try { child.kill('SIGTERM') } catch { /* noop */ }
|
|
85
|
+
}
|
|
86
|
+
const protocolOutput = strictProtocolGate(failProtocol)
|
|
87
|
+
child.stdout.pipe(protocolOutput)
|
|
88
|
+
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(protocolOutput))
|
|
89
|
+
const app = acp.client({ name: 'thinkpool-pair' })
|
|
90
|
+
.onRequest(acp.methods.client.session.requestPermission, ({ params, requestId }) => {
|
|
91
|
+
if (!this.onRequest) throw Object.assign(new Error('Unsupported ACP client method: session/request_permission'), { code: -32601 })
|
|
92
|
+
return this.onRequest(acp.methods.client.session.requestPermission, params, requestId)
|
|
93
|
+
})
|
|
94
|
+
.onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
95
|
+
try { this.onNotification?.(acp.methods.client.session.update, params) } catch { /* consumer isolation */ }
|
|
96
|
+
})
|
|
97
|
+
this.connection = app.connect(stream)
|
|
98
|
+
void this.connection.closed.then(() => {
|
|
99
|
+
if (!this.closedError) this._close(closedError(this.connection?.signal?.reason))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
child.stdin?.on?.('error', (error) => this._close(error))
|
|
103
|
+
child.on('error', (error) => this._close(error))
|
|
104
|
+
child.on('close', (code, signal) => this._close(new Error(`ACP process exited ${code ?? signal ?? 'unknown'}${this.stderrTail ? `: ${this.stderrTail.trim().slice(-300)}` : ''}`)))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
request(method, params = {}, timeoutMs = this.timeoutMs) {
|
|
108
|
+
if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
|
|
109
|
+
const controller = new AbortController()
|
|
110
|
+
const request = this.connection.agent.request(method, params, { cancellationSignal: controller.signal })
|
|
111
|
+
if (!(timeoutMs > 0)) return request
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
controller.abort(new Error(`${method} timed out after ${timeoutMs}ms`))
|
|
115
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`))
|
|
116
|
+
}, timeoutMs)
|
|
117
|
+
timer.unref?.()
|
|
118
|
+
request.then(resolve, reject).finally(() => clearTimeout(timer))
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
notify(method, params) {
|
|
123
|
+
if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
|
|
124
|
+
return this.connection.agent.notify(method, params)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_close(error) {
|
|
128
|
+
if (this.closedError) return
|
|
129
|
+
this.closedError = closedError(error)
|
|
130
|
+
this.child = null
|
|
131
|
+
this.connection?.close(this.closedError)
|
|
132
|
+
try { this.onClose?.(this.closedError) } catch { /* consumer isolation */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
end() {
|
|
136
|
+
const child = this.child
|
|
137
|
+
this._close(new Error('ACP client ended'))
|
|
138
|
+
if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } }
|
|
139
|
+
}
|
|
140
|
+
}
|
package/agent-detect.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
|
|
7
7
|
export const KNOWN_AGENT_COMMANDS = [
|
|
8
|
-
'claude', 'codex', 'gemini', 'aider', 'cursor-agent',
|
|
8
|
+
'claude', 'codex', 'thinkpool', 'gemini', 'aider', 'cursor-agent',
|
|
9
9
|
'opencode', 'copilot', 'goose', 'crush', 'qwen',
|
|
10
10
|
]
|
|
11
11
|
|
package/bridge.mjs
CHANGED
|
@@ -49,8 +49,10 @@ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, l
|
|
|
49
49
|
import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
52
|
+
import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
|
|
53
|
+
import { startStructuredSession } from './runtime-session.mjs'
|
|
54
|
+
import { defaultStructuredMode, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
55
|
+
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
54
56
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
55
57
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
56
58
|
import { commandOnPath } from './agent-detect.mjs'
|
|
@@ -103,7 +105,7 @@ const flowRedispatch = new Map()
|
|
|
103
105
|
// broadcasts; without persistent state the cap can never bite.
|
|
104
106
|
const flowBudgets = new Map()
|
|
105
107
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
|
|
106
|
-
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
108
|
+
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
107
109
|
import { turnInFlight } from './update-gate.mjs'
|
|
108
110
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
109
111
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
@@ -167,6 +169,7 @@ catch {
|
|
|
167
169
|
const KNOWN_AGENTS = [
|
|
168
170
|
{ label: 'Claude Code', cmd: 'claude', resume: { args: ['--continue'], probe: claudeHasSession } },
|
|
169
171
|
{ label: 'Codex CLI', cmd: 'codex' },
|
|
172
|
+
{ label: 'Hermes Agent', cmd: 'thinkpool', availability: () => probeHermesRuntime().available },
|
|
170
173
|
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
171
174
|
{ label: 'Aider', cmd: 'aider' },
|
|
172
175
|
{ label: 'Cursor CLI', cmd: 'cursor-agent' },
|
|
@@ -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 =
|
|
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
|
|
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 :
|
|
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
|
|
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 ===
|
|
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 (
|
|
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
|
|
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
|
|
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 === '
|
|
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 === '
|
|
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(
|
|
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:
|
|
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 =
|
|
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 === '
|
|
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
|
+
}
|
package/hermes-probe.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
4
|
+
|
|
5
|
+
export function probeHermesRuntime({ command = 'thinkpool', 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
|
+
}
|
package/interrupted-resume.mjs
CHANGED
|
@@ -52,12 +52,24 @@ export function isMissingResumeError(message) {
|
|
|
52
52
|
return /No conversation found|no rollout found for thread id|thread\/resume[^\n]*failed[^\n]*no rollout/i.test(String(message || ''))
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function shouldAttemptNativeResume(record, { canResume = () => false, codexThreadCanResume = () => true } = {}) {
|
|
56
|
+
if (!record?.sessionId) return false
|
|
57
|
+
// Hermes has an explicit, non-replaying ACP resume request that fails loudly
|
|
58
|
+
// when identity is gone. Always ask the runtime instead of silently aging the
|
|
59
|
+
// identity out under Claude's historical local freshness heuristic.
|
|
60
|
+
if (record.runtime === 'hermes') return true
|
|
61
|
+
return canResume(record) && (record.runtime !== 'codex' || codexThreadCanResume(record.sessionId))
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
// Bound the stale-native-thread self-heal at the ownership seam. The old entry is
|
|
56
65
|
// marked before teardown/reopen so duplicate error delivery can never fork another
|
|
57
66
|
// process. `reopen` returns the new structured entry; Codex consumes the recap
|
|
58
67
|
// immediately because a fresh exec has no idle init event.
|
|
59
68
|
export function recoverMissingResumeOnce(entry, { message, recap = '', reopen } = {}) {
|
|
60
|
-
|
|
69
|
+
// Hermes ACP session identity is part of the lane contract. If native resume
|
|
70
|
+
// fails, surfacing that failure is safer than silently replacing the session
|
|
71
|
+
// with a recap-backed fresh identity.
|
|
72
|
+
if (!entry || entry.runtime === 'hermes' || entry.recovered || !isMissingResumeError(message)) return { recovered: false, delivery: 'ignored' }
|
|
61
73
|
entry.recovered = true
|
|
62
74
|
try { entry.session?.end?.() } catch { /* already dead */ }
|
|
63
75
|
let recovered = null
|
package/launcher.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|