thinkpool-pair 0.7.232 → 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 +134 -55
- package/claude-session.mjs +11 -11
- package/codex-session.mjs +1 -1
- package/cross-terminal.mjs +6 -8
- 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/thinkpool-room-prompt.mjs +9 -6
package/claude-session.mjs
CHANGED
|
@@ -200,10 +200,10 @@ const TP_ROOM_REMINDER = [
|
|
|
200
200
|
THINKPOOL_DESIGN_DELIVERY_RULE,
|
|
201
201
|
'REMOTE USER: the people here may have no terminal and no access to this host — never ask them to run a local command, open a local file, or check something on the machine. Anything host-side, YOU run, and show the result.',
|
|
202
202
|
'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
|
|
203
|
-
'
|
|
203
|
+
'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
|
|
204
204
|
'PEER: before substantive work, check what the other lanes are doing (the ROOM NOW snapshot below, read_terminal for detail; list_sessions/read_session across rooms) — coordinate on shared files/branches instead of colliding.',
|
|
205
205
|
'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
|
|
206
|
-
'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans
|
|
206
|
+
'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
|
|
207
207
|
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
208
208
|
].join(' ')
|
|
209
209
|
|
|
@@ -516,25 +516,25 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
516
516
|
if (toolName === 'mcp__thinkpool__close_terminal') {
|
|
517
517
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes).' } }
|
|
518
518
|
}
|
|
519
|
-
// ThinkPool
|
|
520
|
-
//
|
|
519
|
+
// ThinkPool terminal creation — open_main_terminal creates an independent main
|
|
520
|
+
// conductor; spawn_terminal creates a worker the agent owns. Both inherit mode
|
|
521
521
|
// (`mode` here is THIS lane's current mode) unless an explicit `mode` overrides it.
|
|
522
522
|
// The only thing worth a human card is an ESCALATION: raising a child to
|
|
523
523
|
// bypassPermissions from a non-bypass lane (full unattended access the operator
|
|
524
524
|
// hasn't already opted into). If the spawner is already in bypass, children inherit
|
|
525
525
|
// bypass with NO prompt (you opted in once); a non-bypass child gates itself per
|
|
526
526
|
// action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
|
|
527
|
-
// enforces the
|
|
528
|
-
if (toolName === 'mcp__thinkpool__spawn_terminal') {
|
|
527
|
+
// enforces the one-worker-tier bound / burst window / machineMax / TP_SPAWN_OFF regardless.
|
|
528
|
+
if (toolName === 'mcp__thinkpool__spawn_terminal' || toolName === 'mcp__thinkpool__open_main_terminal') {
|
|
529
529
|
const childMode = toolInput?.mode || mode // inherit this lane's mode by default
|
|
530
530
|
const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
|
|
531
531
|
if (!escalating) {
|
|
532
|
-
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Auto-approved (
|
|
532
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Auto-approved (open a fresh ThinkPool terminal in ${childMode} mode — inherited, no escalation; bounded by room caps).` } }
|
|
533
533
|
}
|
|
534
534
|
let decision = 'deny'
|
|
535
535
|
try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: { ...toolInput, mode: childMode }, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
|
|
536
536
|
const allowed = decision === 'allow'
|
|
537
|
-
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass
|
|
537
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass terminal creation approved in the ThinkPool room — the new terminal runs autonomously.' : 'Bypass terminal creation denied in the room — do not retry as bypass; open it without mode:bypassPermissions or ask what to do.' } }
|
|
538
538
|
}
|
|
539
539
|
// ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane.
|
|
540
540
|
// First a bridge-side precheck (kill-switch / loop-breaker / per-turn cap) so a
|
|
@@ -702,15 +702,15 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
702
702
|
// no summon word. The agent INFERS build intent and runs plan→(optional)fan-out→build→self-verify,
|
|
703
703
|
// right-sized to the task, surfaced in the room's EXISTING surfaces (chat + lane list) — never a new panel,
|
|
704
704
|
// never plan-mode's approval card. Trivial asks stay single-lane with zero ceremony.
|
|
705
|
-
'DEFAULT BUILD WORKFLOW (this IS your standard behaviour — no keyword and no "flow" word is ever required to trigger it; you infer it from the request itself). RIGHT-SIZE to the task within the authoritative TERMINAL ROLE, always: a TRIVIAL ask — a one-line fix, a small tweak, a question — you just do it directly, with no plan and no extra lanes (never tax small work with ceremony). A NON-TRIVIAL build that genuinely decomposes, when your role permits delegation — you FIRST write a short plan as a normal chat message (the approach, the concrete slices, and what you will verify), THEN fan
|
|
705
|
+
'DEFAULT BUILD WORKFLOW (this IS your standard behaviour — no keyword and no "flow" word is ever required to trigger it; you infer it from the request itself). RIGHT-SIZE to the task within the authoritative TERMINAL ROLE, always: a TRIVIAL ask — a one-line fix, a small tweak, a question — you just do it directly, with no plan and no extra lanes (never tax small work with ceremony). A NON-TRIVIAL build that genuinely decomposes, when your role permits delegation — you FIRST write a short plan as a normal chat message (the approach, the concrete slices, and what you will verify), THEN fan worker slices into visible Ensemble lanes with spawn_terminal, build, and self-verify each slice before calling it done. When the person explicitly asks you to launch a SEPARATE Cascade/conductor terminal, call open_main_terminal instead; never represent that request with spawn_terminal. Worker, leaf, Side, and managed Flow lanes complete their assigned work directly and do not fan out.',
|
|
706
706
|
'CRUCIAL RECONCILIATION for that workflow: it is NOT plan mode. Never call ExitPlanMode and never make the room wait behind a "plan ready — approve to start" card — your plan lives in the CHAT as a message, and your lanes live in the room\'s EXISTING terminal/lane list. Reuse only those two surfaces; there is no new Flow panel or mode to switch into, and you must not ask for one. Keep the plan and the lanes VISIBLE — that shared visibility is the whole point (it is the pair differentiator, and it catches bugs a single silent lane would hide); never collapse a decomposable build into one hidden lane just to look tidy.',
|
|
707
707
|
...THINKPOOL_REMOTE_DELIVERY_RULES,
|
|
708
708
|
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
|
|
709
709
|
'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
|
|
710
|
-
'
|
|
710
|
+
'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers inherit your permission mode by default; raising one to bypass asks the room once. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals and are not owned/closed through Ensemble. Room caps and the kill-switch bind both creation paths.',
|
|
711
711
|
'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
|
|
712
712
|
'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
|
|
713
|
-
'SUBAGENT POLICY: in this room,
|
|
713
|
+
'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
|
|
714
714
|
'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
|
|
715
715
|
'PEER FIRST: other lanes may be working in the same repo as you, right now. Before starting substantive work — and before any code edit that could overlap another lane — check what the room is doing: the ROOM NOW snapshot appended to your latest turn, or read_terminal for detail; list_sessions/read_session when the question spans your other rooms. If a sibling is touching the same files or branch, coordinate (read its lane, or raise it in chat) instead of colliding.',
|
|
716
716
|
'WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.',
|
package/codex-session.mjs
CHANGED
|
@@ -210,7 +210,7 @@ export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
export const CODEX_ROOM_CASCADE_REMINDER = [
|
|
213
|
-
'THINKPOOL ROOM WORKFLOW: obey the authoritative TERMINAL ROLE above.
|
|
213
|
+
'THINKPOOL ROOM WORKFLOW: obey the authoritative TERMINAL ROLE above. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. Use spawn_terminal only for worker slices from a role permitted to delegate. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents. Each worker owns its own worktree. For a genuinely decomposable task in a conductor-capable role, state a short plan in chat, use sliceType scaffold for mechanical work, feature/fix for builders, and review for adversarial verification; review must use a balanced model tier. Read results, verify the integrated outcome, and close every worker you spawned.',
|
|
214
214
|
].join(' ')
|
|
215
215
|
|
|
216
216
|
export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false }) {
|
package/cross-terminal.mjs
CHANGED
|
@@ -301,8 +301,9 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
|
|
|
301
301
|
// OWN lanes. spawn_terminal gives it fresh lanes; this gate keeps that from
|
|
302
302
|
// becoming a fork bomb. Bounds, in order:
|
|
303
303
|
// • kill-switch (TP_SPAWN_OFF)
|
|
304
|
-
// • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward
|
|
305
|
-
//
|
|
304
|
+
// • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward.
|
|
305
|
+
// Cascade conductors now use the independent main-terminal lifecycle; every
|
|
306
|
+
// Ensemble-spawned lane is a worker (the loop breaker mirrors CROSSPOST).
|
|
306
307
|
// • machineMax — overall hard cap on LIVE lanes (sessions+terms): the runaway /
|
|
307
308
|
// don't-melt-the-laptop backstop, plan-INDEPENDENT.
|
|
308
309
|
// • dispatch ceiling — a SEPARATE, plan-tiered budget counting ONLY spawned lanes
|
|
@@ -323,12 +324,9 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
|
|
|
323
324
|
// this stays a PURE function (no Date.now inside; the clock is injected, like
|
|
324
325
|
// spawnCount was before).
|
|
325
326
|
export const SPAWN = {
|
|
326
|
-
maxHop:
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// machineMax + the plan ceiling + the burst window + the kill-switch.
|
|
330
|
-
// Was 1 until 2026-07-10 (docs/specs/2026-07-10-cascade-spawn-depth.md):
|
|
331
|
-
// conductors dispatched by a person's lane could not dispatch at all.
|
|
327
|
+
maxHop: 1, // main terminal/conductor = hop 0 and may dispatch workers;
|
|
328
|
+
// Ensemble worker = hop 1 and can never spawn onward. Conductors
|
|
329
|
+
// are created via open_main_terminal, not spawn_terminal.
|
|
332
330
|
perWindowCap: 6, // spawn_terminal calls allowed per rolling window (burst breaker);
|
|
333
331
|
// 6 matches plusMaxSpawned so one wave can open a full crew (was 4)
|
|
334
332
|
windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
|
|
@@ -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",
|