From ba5e54d7e082270f0c4abb0e99275b4ca00dd820 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 22 Sep 2026 17:00:38 +0200 Subject: [PATCH] feat(agent): add Hermes as a first-class CLI --- src/main/ipc/agentSessionStamps.test.ts | 40 ++++ src/main/ipc/agentSessionStamps.ts | 69 +++++- src/main/ipc/shell.ts | 2 +- src/main/runtime/types.ts | 4 + src/renderer/assets/agentLogos/hermes.svg | 4 + .../PanelRelationContextToggle.test.tsx | 1 + src/renderer/lib/agent/agentLogos.ts | 2 + .../lib/agent/agentStatus.integration.test.ts | 13 ++ .../sessionSerialize.roundTrip.test.ts | 3 +- src/renderer/panels/TerminalPanel.tsx | 4 +- src/renderer/settings/AgentHooksSettings.tsx | 9 +- src/renderer/settings/AgentSettings.tsx | 2 +- src/renderer/stores/appStore/panelSlice.ts | 3 +- .../capabilities/agentChanges.hermes.itest.ts | 98 +++++++++ .../capabilities/agentChanges.liveHarness.ts | 2 +- .../agentChanges.regression.test.ts | 5 +- src/runtime/capabilities/agentChanges.ts | 6 +- src/runtime/capabilities/agentHooks.ts | 60 ++++- .../capabilities/agentPresence.test.ts | 38 +++- src/runtime/capabilities/agentPresence.ts | 49 ++++- .../capabilities/agentTitles/hermes.ts | 5 + src/runtime/capabilities/agentTitles/index.ts | 2 + .../capabilities/hermesIntegration.test.ts | 22 ++ src/runtime/capabilities/hermesIntegration.ts | 205 ++++++++++++++++++ src/runtime/capabilities/index.ts | 12 +- .../capabilities/process.agentStatus.test.ts | 1 + src/runtime/capabilities/process.ts | 14 +- src/shared/agentHooks.test.ts | 19 ++ src/shared/agentHooks.ts | 68 +++++- src/shared/agentRegistry.test.ts | 7 +- src/shared/agents.test.ts | 5 + src/shared/agents.ts | 40 +++- src/shared/codingAgentRuns.test.ts | 1 + src/shared/skills.ts | 1 + src/shared/types.ts | 2 + src/skills/main/seedCateCliSkill.ts | 2 +- src/skills/main/targets.test.ts | 1 + 37 files changed, 760 insertions(+), 61 deletions(-) create mode 100644 src/renderer/assets/agentLogos/hermes.svg create mode 100644 src/runtime/capabilities/agentChanges.hermes.itest.ts create mode 100644 src/runtime/capabilities/agentTitles/hermes.ts create mode 100644 src/runtime/capabilities/hermesIntegration.test.ts create mode 100644 src/runtime/capabilities/hermesIntegration.ts diff --git a/src/main/ipc/agentSessionStamps.test.ts b/src/main/ipc/agentSessionStamps.test.ts index 56266e5e8..a2ad6260b 100644 --- a/src/main/ipc/agentSessionStamps.test.ts +++ b/src/main/ipc/agentSessionStamps.test.ts @@ -106,6 +106,46 @@ describe('kiro resumability gating', () => { }) }) +describe('hermes profile-aware resumability', () => { + it('waits for the first turn and persists the exact profile', () => { + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-start', 'id-1', '/w'), profile: 'work' }) + expect(stamps(tid)).toEqual([]) + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'turn-start', 'id-1', '/w'), profile: 'work' }) + expect(stamps(tid)).toEqual([{ agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }]) + }) + + it('does not let an older finalize clear a newer profile session', () => { + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'turn-start', 'new', '/w'), profile: 'work', sourcePid: 22 }) + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-end', 'old', '/w'), profile: 'default', sourcePid: 11 }) + expect(stamps(tid)).toEqual([{ agentId: 'hermes', sessionId: 'new', cwd: '/w', profile: 'work' }]) + }) + + it('clears the old stamp when a replacement session starts, then rejects the old finalize', () => { + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'turn-start', 'old', '/w'), profile: 'default', sourcePid: 11 }) + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-start', 'new', '/w'), profile: 'work', sourcePid: 22 }) + ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-end', 'old', '/w'), profile: 'default', sourcePid: 11 }) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'old', cwd: '/w', profile: 'default' }, + null, + ]) + }) + + it('does not let an old presence falling edge clear a newer process stamp', () => { + ingestAgentSessionStamp(runtime, { + ...ev(tid, 'hermes', 'turn-start', 'old', '/w'), + profile: 'default', sourcePid: 11, sourceStartedAt: '100', + }) + ingestAgentSessionStamp(runtime, { + ...ev(tid, 'hermes', 'turn-start', 'new', '/w'), + profile: 'work', sourcePid: 22, sourceStartedAt: '200', + }) + clearAgentSessionStamp(tid, 11, '100') + expect(stamps(tid).at(-1)).toEqual({ + agentId: 'hermes', sessionId: 'new', cwd: '/w', profile: 'work', + }) + }) +}) + describe('agents whose first sessionId-bearing event is already persisted', () => { it.each(['codex', 'cursor', 'opencode'] as const)('%s stamps on session-start', (agentId) => { ingestAgentSessionStamp(runtime, ev(tid, agentId, 'session-start', 'id-1', '/w')) diff --git a/src/main/ipc/agentSessionStamps.ts b/src/main/ipc/agentSessionStamps.ts index e4dcf04ef..dca034c9f 100644 --- a/src/main/ipc/agentSessionStamps.ts +++ b/src/main/ipc/agentSessionStamps.ts @@ -24,7 +24,7 @@ // ============================================================================= import { SHELL_AGENT_SESSION_UPDATE } from '../../shared/ipc-channels' -import type { AgentHookEvent } from '../../shared/agentHooks' +import { normalizeAgentSourceStartedAt, type AgentHookEvent } from '../../shared/agentHooks' import type { AgentId } from '../../shared/agents' import type { Runtime } from '../runtime/types' import type { TerminalAgentSession } from '../../shared/types' @@ -47,6 +47,8 @@ const RESUMABLE_FROM_SESSION_START: Record = { // already open and on disk when the id arrives — a session killed mid-turn, // before its Stop, still resumes (pinned live). grok: true, + // Hermes creates the record before its first persisted user turn. + hermes: false, // Kiro documents that sessions are saved on each conversation turn; its // agentSpawn event precedes that first turn, so stamp from prompt submit. kiro: false, @@ -57,6 +59,11 @@ interface StampState { /** Dedup key of the last SHELL_AGENT_SESSION_UPDATE sent, so an unchanged * stamp doesn't re-emit (and re-touch renderer panel state). */ key?: string | null + latest?: Pick & { + sourcePid?: number + sourceStartedAt?: string + } + hermesHighWater?: bigint /** Monotonic ingest counter — an async cwd lookup captures it and drops its * result if a newer event (or a clear) landed while it was in flight. */ seq: number @@ -78,7 +85,9 @@ function emit(terminalId: string, session: TerminalAgentSession | null): void { const ownerWindowId = getTerminalOwner(terminalId) if (ownerWindowId == null) return const st = stateFor(terminalId) - const key = session ? `${session.agentId}\0${session.sessionId}\0${session.cwd}` : null + const key = session + ? `${session.agentId}\0${session.sessionId}\0${session.cwd}\0${session.profile ?? ''}` + : null if (st.key === key) return st.key = key sendToWindow(ownerWindowId, SHELL_AGENT_SESSION_UPDATE, terminalId, session) @@ -102,16 +111,51 @@ export function ingestAgentSessionStamp(runtime: Runtime, event: AgentHookEvent) const { terminalId } = event if (event.kind === 'session-title' || event.kind === 'input-submit' || event.kind === 'input-interrupt') return const st = stateFor(terminalId) - st.seq++ + if (event.agentId === 'hermes') { + const startedAt = normalizeAgentSourceStartedAt(event.sourceStartedAt) + if (!startedAt && st.hermesHighWater !== undefined) return + if (startedAt) { + const incoming = BigInt(startedAt) + if (st.hermesHighWater !== undefined && incoming < st.hermesHighWater) return + st.hermesHighWater = incoming + } + } if (event.kind === 'session-end') { + if (!event.sessionId) return + if (st.latest && ( + st.latest.agentId !== event.agentId || + st.latest.sessionId !== event.sessionId || + (event.profile !== undefined && st.latest.profile !== event.profile) || + (event.sourcePid !== undefined && st.latest.sourcePid !== undefined && st.latest.sourcePid !== event.sourcePid) || + (event.sourceStartedAt !== undefined && st.latest.sourceStartedAt !== undefined && st.latest.sourceStartedAt !== event.sourceStartedAt) + )) return + st.seq++ + st.latest = undefined emit(terminalId, null) return } if (event.sessionId == null) return - if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) return + st.seq++ const { agentId, sessionId } = event + const profile = event.profile ? { profile: event.profile } : {} + const identity = { + agentId, + sessionId, + ...profile, + ...(event.sourcePid ? { sourcePid: event.sourcePid } : {}), + ...(event.sourceStartedAt ? { sourceStartedAt: event.sourceStartedAt } : {}), + } + if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) { + const previous = st.latest + st.latest = identity + if (agentId === 'hermes' && previous?.agentId === 'hermes' && ( + previous.sessionId !== sessionId || previous.profile !== event.profile + )) emit(terminalId, null) + return + } + st.latest = identity if (event.cwd) { - emit(terminalId, { agentId, sessionId, cwd: event.cwd }) + emit(terminalId, { agentId, sessionId, cwd: event.cwd, ...profile }) return } const seq = st.seq @@ -119,16 +163,27 @@ export function ingestAgentSessionStamp(runtime: Runtime, event: AgentHookEvent) .getCwd(terminalId) .then((cwd) => { if (states.get(terminalId)?.seq !== seq) return // superseded while in flight - emit(terminalId, { agentId, sessionId, cwd: cwd ?? '' }) + emit(terminalId, { agentId, sessionId, cwd: cwd ?? '', ...profile }) }) .catch(() => { /* runtime gone — no stamp beats a cwd-less guess */ }) } /** Falling edge: the agent exited while the terminal lives on — nothing to * resume. Clears the stamp; the next agent run re-stamps via fresh events. */ -export function clearAgentSessionStamp(terminalId: string): void { +export function clearAgentSessionStamp( + terminalId: string, + endedAgentPid?: number, + endedAgentStartedAt?: string, +): void { const st = stateFor(terminalId) + if (endedAgentPid !== undefined && st.latest?.sourcePid !== undefined && st.latest.sourcePid !== endedAgentPid) return + if ( + endedAgentStartedAt !== undefined && + st.latest?.sourceStartedAt !== undefined && + st.latest.sourceStartedAt !== endedAgentStartedAt + ) return st.seq++ + st.latest = undefined emit(terminalId, null) } diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 3a6abde27..ea6d0da15 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -186,7 +186,7 @@ async function runActivityScan(): Promise { // kills the poll loop itself, leaving the last stamp persisted — // exactly "what was running at save time". if (!agentPresent && prev.previousAgentPresent) { - clearAgentSessionStamp(terminalId) + clearAgentSessionStamp(terminalId, scanned?.endedAgentPid, scanned?.endedAgentStartedAt) } } }), diff --git a/src/main/runtime/types.ts b/src/main/runtime/types.ts index 4c85a0afd..18404fb3b 100644 --- a/src/main/runtime/types.ts +++ b/src/main/runtime/types.ts @@ -104,6 +104,10 @@ export interface PtyActivity { activity: TerminalActivity agentName: string | null agentPresent: boolean + /** Process identity for a confirmed falling edge. Used to avoid clearing a + * resume stamp belonging to a newer overlapping agent process. */ + endedAgentPid?: number + endedAgentStartedAt?: string } export interface ProcessHost { diff --git a/src/renderer/assets/agentLogos/hermes.svg b/src/renderer/assets/agentLogos/hermes.svg new file mode 100644 index 000000000..5073d6996 --- /dev/null +++ b/src/renderer/assets/agentLogos/hermes.svg @@ -0,0 +1,4 @@ + + + ☤ + diff --git a/src/renderer/canvas/PanelRelationContextToggle.test.tsx b/src/renderer/canvas/PanelRelationContextToggle.test.tsx index 82f1a1b6a..403a6dfec 100644 --- a/src/renderer/canvas/PanelRelationContextToggle.test.tsx +++ b/src/renderer/canvas/PanelRelationContextToggle.test.tsx @@ -41,6 +41,7 @@ function openCli(agentId: AgentId): void { codex: 'codex', cursor: 'cursor-agent', grok: 'grok', + hermes: 'hermes', kiro: 'kiro-cli', opencode: 'opencode', } diff --git a/src/renderer/lib/agent/agentLogos.ts b/src/renderer/lib/agent/agentLogos.ts index f0d95307d..386f3fc7c 100644 --- a/src/renderer/lib/agent/agentLogos.ts +++ b/src/renderer/lib/agent/agentLogos.ts @@ -10,6 +10,7 @@ import claudeLogo from '../../assets/agentLogos/claude.svg?url' import codexLogo from '../../assets/agentLogos/codex.svg?url' import cursorLogo from '../../assets/agentLogos/cursor.svg?url' import grokLogo from '../../assets/agentLogos/grok.svg?url' +import hermesLogo from '../../assets/agentLogos/hermes.svg?url' import kiroLogo from '../../assets/agentLogos/kiro.svg?url' import opencodeLogo from '../../assets/agentLogos/opencode.svg?url' @@ -18,6 +19,7 @@ const LOGO_BY_ID: Partial> = { codex: codexLogo, cursor: cursorLogo, grok: grokLogo, + hermes: hermesLogo, kiro: kiroLogo, opencode: opencodeLogo, } diff --git a/src/renderer/lib/agent/agentStatus.integration.test.ts b/src/renderer/lib/agent/agentStatus.integration.test.ts index 07b31a9a1..97de2b957 100644 --- a/src/renderer/lib/agent/agentStatus.integration.test.ts +++ b/src/renderer/lib/agent/agentStatus.integration.test.ts @@ -56,6 +56,12 @@ const fixtures: AgentLifecycleFixture[] = [ turnStart: { hookEventName: 'user_prompt_submit', sessionId: SESSION }, turnEnd: { hookEventName: 'stop', sessionId: SESSION }, }, + { + agentId: 'hermes', + sessionStart: { hook_event_name: 'on_session_start', session_id: SESSION, profile: 'default', platform: 'cli' }, + turnStart: { hook_event_name: 'pre_llm_call', session_id: SESSION, profile: 'default', platform: 'cli' }, + turnEnd: { hook_event_name: 'on_session_end', session_id: SESSION, profile: 'default', platform: 'cli' }, + }, { agentId: 'opencode', sessionStart: { type: 'session.created', sessionID: SESSION }, @@ -98,6 +104,13 @@ const permissionFixtures: PermissionFixture[] = [ sessionId: SESSION, }, }, + { + agentId: 'hermes', + turnStart: { hook_event_name: 'pre_llm_call', session_id: SESSION, profile: 'default', platform: 'cli' }, + permissionWait: { + hook_event_name: 'pre_approval_request', session_id: SESSION, profile: 'default', platform: 'cli', + }, + }, { agentId: 'opencode', turnStart: { type: 'session.status', sessionID: SESSION, status: { type: 'busy' } }, diff --git a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts index e2d5c55e1..c163003dd 100644 --- a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts +++ b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts @@ -191,9 +191,10 @@ describe('workspace.json + session.json round-trip', () => { it('round-trips a terminal agent-session stamp through session.json only', () => { const { snapshot } = buildSnapshot() const agentSession = { - agentId: 'claude-code', + agentId: 'hermes', sessionId: '11111111-1111-4111-8111-111111111111', cwd: WORKTREE_PATH, + profile: 'work', } snapshot.panels!['term-1'] = { ...snapshot.panels!['term-1'], agentSession } diff --git a/src/renderer/panels/TerminalPanel.tsx b/src/renderer/panels/TerminalPanel.tsx index 7cee45172..29475c053 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -406,7 +406,9 @@ export default function TerminalPanel({ const agentSession = useAppStore.getState().workspaces .find((w) => w.id === workspaceId)?.panels[panelId]?.agentSession const resumeCommand = agentSession - ? resumeCommandForAgent(agentSession.agentId, agentSession.sessionId) ?? undefined + ? resumeCommandForAgent(agentSession.agentId, agentSession.sessionId, { + profile: agentSession.profile, + }) ?? undefined : undefined // Keep the dead xterm visible behind the runtime lock while disconnected. diff --git a/src/renderer/settings/AgentHooksSettings.tsx b/src/renderer/settings/AgentHooksSettings.tsx index c84c44d55..96ce5e5e9 100644 --- a/src/renderer/settings/AgentHooksSettings.tsx +++ b/src/renderer/settings/AgentHooksSettings.tsx @@ -1,8 +1,9 @@ // ============================================================================= // Agent hooks settings — per-workspace, per-agent control over Cate's hook -// injection (the push-based agent status/session events). Every agent injects -// through workspace files, so every agent gets the same tri-state: Auto (inject -// only when the agent's own config folder is already in the repo), On, or Off. +// injection (the push-based agent status/session events). Most agents inject +// through workspace files; profile-scoped integrations are installed by the +// runtime host. Both use the same tri-state: Auto (enable when the agent's own +// config folder is already in the repo), On, or Off. // // Overrides live in settings.agentHookInjection keyed by workspace id and are // applied by the terminal layer on the NEXT terminal spawn (injection is a @@ -74,7 +75,7 @@ export function AgentHooksSettings() { } return ( - + {agents === null && } {error &&

Could not check agent hooks. Reopen settings to try again.

} {!!agents?.length &&
diff --git a/src/renderer/settings/AgentSettings.tsx b/src/renderer/settings/AgentSettings.tsx index 073e7cb55..b0dcc781e 100644 --- a/src/renderer/settings/AgentSettings.tsx +++ b/src/renderer/settings/AgentSettings.tsx @@ -132,7 +132,7 @@ export function AgentSettings() { }, [authSession?.phase, authSession?.providerId, refreshProviderStatuses]) return ( - +
(
diff --git a/src/renderer/stores/appStore/panelSlice.ts b/src/renderer/stores/appStore/panelSlice.ts index 560813144..e999b5457 100644 --- a/src/renderer/stores/appStore/panelSlice.ts +++ b/src/renderer/stores/appStore/panelSlice.ts @@ -369,7 +369,8 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { session && prev && prev.agentId === session.agentId && prev.sessionId === session.sessionId && - prev.cwd === session.cwd + prev.cwd === session.cwd && + prev.profile === session.profile ) return panel return { ...panel, agentSession: session ?? undefined } }) diff --git a/src/runtime/capabilities/agentChanges.hermes.itest.ts b/src/runtime/capabilities/agentChanges.hermes.itest.ts new file mode 100644 index 000000000..f1e66a3a5 --- /dev/null +++ b/src/runtime/capabilities/agentChanges.hermes.itest.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { describe, expect, test } from 'vitest' +import { createLiveChangeFixture, runLiveCli, runLiveTui } from './agentChanges.liveHarness' + +const LIVE_HERMES = process.env.CATE_LIVE_HERMES === '1' + +// Opt-in paid-provider contract test. The caller owns authentication and the +// disposable Hermes profile; this test exercises Cate's production endpoint, +// managed plugin, context response, lifecycle normalization, and edit capture. +describe.skipIf(!LIVE_HERMES)('real Hermes integration', () => { + test('managed plugin delivers context, lifecycle, and a native file edit', { timeout: 180_000 }, async () => { + const profile = process.env.CATE_LIVE_HERMES_PROFILE + if (!profile) throw new Error('CATE_LIVE_HERMES_PROFILE is required') + if (!process.env.OPENROUTER_API_KEY) throw new Error('OPENROUTER_API_KEY is required') + + const fixture = await createLiveChangeFixture('hermes') + const contextValue = `cate-live-${randomUUID()}` + fixture.hooks.setPromptContext(fixture.terminalId, `CATE_LIVE_CONTEXT=${contextValue}`) + try { + const prompt = 'Read target.txt, then use the native patch tool in replace mode with old_string="before" and new_string="after". Do not use write_file or a shell command. Change no other file. Integration-provided context contains CATE_LIVE_CONTEXT; after editing, reply only with its value.' + const result = await runLiveCli('hermes', [ + '--profile', profile, 'chat', '--oneshot', '--quiet', '--provider', 'openrouter', + '--model', process.env.CATE_LIVE_HERMES_MODEL ?? 'deepseek/deepseek-v4.1-flash-20260910', + '--reasoning', 'none', '--toolsets', 'file', '--yolo', '--query', prompt, + ], { cwd: fixture.cwd, env: fixture.env, timeout: 150_000 }) + + expect(result.stdout).toContain(contextValue) + expect(await readFile(`${fixture.cwd}/target.txt`, 'utf8')).toBe('after\n') + await expect.poll(async () => (await fixture.records()).length, { timeout: 5000 }).toBe(1) + const [record] = await fixture.records() + expect(record).toMatchObject({ + agentId: 'hermes', source: 'terminal', sourceId: fixture.terminalId, + panelId: fixture.panelId, cwd: fixture.cwd, + }) + expect(record.sessionId).toBeTruthy() + expect(record.turnId).toBeTruthy() + expect(record.files).toHaveLength(1) + expect(record.files[0]).toMatchObject({ path: 'target.txt', coverage: 'fragment' }) + expect(record.files[0].hunks.flatMap((hunk) => hunk.lines) + .some((line) => line.kind === 'delete' && line.text === 'before')).toBe(true) + expect(record.files[0].hunks.flatMap((hunk) => hunk.lines) + .some((line) => line.kind === 'add' && line.text === 'after')).toBe(true) + const payloads = fixture.posts.flatMap((post) => { + const body = post.body as { payload?: Record } + return body.payload ? [body.payload] : [] + }) + const names = payloads.map((payload) => payload.hook_event_name) + expect(names).toContain('on_session_start') + expect(names).toContain('pre_llm_call') + expect(names).toContain('post_tool_call') + expect(names).toContain('on_session_end') + expect(names).toContain('on_session_finalize') + expect(payloads.every((payload) => payload.profile === profile)).toBe(true) + expect(payloads.some((payload) => 'conversation_history' in payload)).toBe(false) + expect(payloads.some((payload) => 'user_message' in payload)).toBe(false) + } finally { + await fixture.close() + } + }) + + test('Cate launch form seeds a turn and remains interactive on a PTY', { timeout: 180_000 }, async () => { + const profile = process.env.CATE_LIVE_HERMES_PROFILE + if (!profile) throw new Error('CATE_LIVE_HERMES_PROFILE is required') + if (!process.env.OPENROUTER_API_KEY) throw new Error('OPENROUTER_API_KEY is required') + + const fixture = await createLiveChangeFixture('hermes') + let turnEndedAt = 0 + try { + await runLiveTui('hermes', [ + '--profile', profile, 'chat', '--cli', '--provider', 'openrouter', + '--model', process.env.CATE_LIVE_HERMES_MODEL ?? 'deepseek/deepseek-v4.1-flash-20260910', + '--reasoning', 'none', '--toolsets', 'file', '--query', 'Reply only OK. Do not call tools.', + ], { + cwd: fixture.cwd, + env: fixture.env, + timeout: 150_000, + complete: () => { + const ended = fixture.posts.some((post) => { + const body = post.body as { payload?: { hook_event_name?: string } } + return body.payload?.hook_event_name === 'on_session_end' + }) + if (ended && !turnEndedAt) turnEndedAt = Date.now() + return turnEndedAt > 0 && Date.now() - turnEndedAt >= 750 + }, + }) + const names = fixture.posts.map((post) => { + const body = post.body as { payload?: { hook_event_name?: string } } + return body.payload?.hook_event_name + }) + expect(names).toContain('on_session_start') + expect(names).toContain('pre_llm_call') + expect(names).toContain('on_session_end') + } finally { + await fixture.close() + } + }) +}) diff --git a/src/runtime/capabilities/agentChanges.liveHarness.ts b/src/runtime/capabilities/agentChanges.liveHarness.ts index 6c71338ee..296b256a8 100644 --- a/src/runtime/capabilities/agentChanges.liveHarness.ts +++ b/src/runtime/capabilities/agentChanges.liveHarness.ts @@ -144,7 +144,7 @@ export async function createLiveChangeFixture(agentId: AgentId) { '-c', `core.hooksPath=${path.join(directory, 'empty-git-hooks')}`, 'commit', '-qm', 'Fixture'], { cwd, env: baseEnv }) hooks.registerChangeSource(terminalId, { cwd, panelId, kind: 'terminal' }) await hooks.prepareWorkspace(cwd, { [agentId]: 'on' }) - const env = await hooks.envForPty(terminalId, baseEnv) + const env = await hooks.envForPty(terminalId, baseEnv, { [agentId]: 'on' }, cwd, undefined, agentId) await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)) env.CATE_HOOK_ENDPOINT = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` return { diff --git a/src/runtime/capabilities/agentChanges.regression.test.ts b/src/runtime/capabilities/agentChanges.regression.test.ts index cbada1533..8bb33abe2 100644 --- a/src/runtime/capabilities/agentChanges.regression.test.ts +++ b/src/runtime/capabilities/agentChanges.regression.test.ts @@ -15,6 +15,7 @@ const adapters = [ { id: 'codex', tool: 'apply_patch', wrap: (input: unknown, output: unknown, session: string, call: string) => ({ hook_event_name: 'PostToolUse', session_id: session, tool_use_id: call, tool_name: 'apply_patch', tool_input: input, tool_response: output }) }, { id: 'cursor', tool: 'edit', wrap: (input: unknown, output: unknown, session: string, call: string) => ({ hook_event_name: 'postToolUse', conversation_id: session, tool_use_id: call, tool_name: 'edit', tool_input: input, tool_output: output }) }, { id: 'grok', tool: 'replace_file_content', wrap: (input: unknown, output: unknown, session: string, call: string) => ({ hookEventName: 'post_tool_use', sessionId: session, toolUseId: call, toolName: 'replace_file_content', toolInput: input, toolResponse: output }) }, + { id: 'hermes', tool: 'patch', wrap: (input: unknown, output: unknown, session: string, call: string) => ({ hook_event_name: 'post_tool_call', session_id: session, tool_call_id: call, tool_name: 'patch', args: input, result: output, status: 'completed', profile: 'default', platform: 'cli' }) }, { id: 'kiro', tool: 'fs_write', wrap: (input: unknown, output: unknown, session: string, call: string) => ({ hook_event_name: 'PostToolUse', session_id: session, tool_use_id: call, tool_name: 'fs_write', tool_input: input, tool_response: output }) }, { id: 'opencode', tool: 'edit', wrap: (input: unknown, output: unknown, session: string, call: string) => ({ type: 'message.part.updated', sessionID: session, part: { type: 'tool', tool: 'edit', callID: call, state: { status: 'completed', input, metadata: output } } }) }, ] as const @@ -37,7 +38,9 @@ describe.each(adapters)('$id capture contract', (adapter) => { it('tracks successive turn boundaries without merging reused tool-call IDs', async () => { const lifecycle = (start: boolean) => adapter.id === 'opencode' ? { type: 'session.status', sessionID: 'session', status: { type: start ? 'busy' : 'idle' } } - : adapter.id === 'grok' + : adapter.id === 'hermes' + ? { hook_event_name: start ? 'pre_llm_call' : 'on_session_end', session_id: 'session', profile: 'default', platform: 'cli' } + : adapter.id === 'grok' ? { hookEventName: start ? 'user_prompt_submit' : 'stop', sessionId: 'session' } : adapter.id === 'cursor' ? { hook_event_name: start ? 'beforeSubmitPrompt' : 'stop', conversation_id: 'session' } diff --git a/src/runtime/capabilities/agentChanges.ts b/src/runtime/capabilities/agentChanges.ts index 2ddc30c8d..9f9c2bd13 100644 --- a/src/runtime/capabilities/agentChanges.ts +++ b/src/runtime/capabilities/agentChanges.ts @@ -160,18 +160,18 @@ export function createAgentChangesStore(directory = path.join( const cursorEdit = agentId === 'cursor' && name === 'afterFileEdit' const part = object(raw.part) const partState = object(part.state) - const completed = cursorEdit || /^(PostToolUse|postToolUse|post_tool_use)$/.test(name) + const completed = cursorEdit || /^(PostToolUse|postToolUse|post_tool_use|post_tool_call)$/.test(name) || (name === 'message.part.updated' && part.type === 'tool' && partState.status === 'completed') if (!completed) return const output = raw.tool_response ?? raw.toolResponse ?? raw.tool_output ?? raw.result ?? partState.metadata - if (object(output).is_error === true || object(output).success === false || raw.success === false + if (object(output).is_error === true || object(output).success === false || raw.success === false || raw.error_type || ['error', 'failed', 'declined'].includes(String(object(output).status ?? raw.status))) return const toolName = cursorEdit ? 'Edit' : string(raw.tool_name ?? raw.toolName ?? part.tool) ?? '' // Cursor emits both afterFileEdit (the before/after fragments) and a // generic Write completion (only new contents), without a shared ID. // The dedicated hook is authoritative; storing both duplicates edits. if (agentId === 'cursor' && !cursorEdit && toolName === 'Write') return - const input = cursorEdit ? raw : raw.tool_input ?? raw.toolInput ?? raw.input ?? partState.input + const input = cursorEdit ? raw : raw.tool_input ?? raw.toolInput ?? raw.args ?? raw.input ?? partState.input const reportedCwd = event?.cwd ?? string(raw.cwd ?? raw.directory) if (reportedCwd && (!path.isAbsolute(reportedCwd) || reportedCwd.includes('\0'))) return const executionCwd = reportedCwd ? canonical(reportedCwd) : source.cwd diff --git a/src/runtime/capabilities/agentHooks.ts b/src/runtime/capabilities/agentHooks.ts index 23eec6668..5c5f0f3d5 100644 --- a/src/runtime/capabilities/agentHooks.ts +++ b/src/runtime/capabilities/agentHooks.ts @@ -49,6 +49,7 @@ import { AGENTS, type AgentId } from '../../shared/agents' import { createAgentChangesStore, type AgentChangeSource } from './agentChanges' import { AGENT_TITLE_RESOLVERS, createAgentTitleTracker } from './agentTitles' import type { AgentTitleResolvers } from './agentTitles/types' +import { ensureHermesIntegration, inspectHermesIntegration } from './hermesIntegration' import { AGENT_HOOK_SPECS, CATE_HOOK_MARKER, @@ -57,6 +58,7 @@ import { CATE_TERMINAL_ID_ENV, agentHookFolder, normalizeAgentHookPayload, + normalizeAgentSourceStartedAt, resolveAgentHookMode, type AgentHookAgentState, type AgentHookConfig, @@ -82,7 +84,14 @@ export interface AgentHooksCapability { * simply never reads it. Lazily boots the ingestion endpoint + hooks dir on * first use; returns `env` unchanged when hook setup fails (a plain shell is * always spawnable). */ - envForPty(ptyId: string, env: Record): Promise> + envForPty( + ptyId: string, + env: Record, + config?: AgentHookConfig, + cwd?: string, + baseCwd?: string, + launchedAgentId?: AgentId, + ): Promise> /** Write (or, for 'off', remove) workspace-scoped hook files for the PTY's * cwd and keep the ones we wrote out of git status via .git/info/exclude. * `config` carries per-agent tri-state overrides: 'auto' (default) injects @@ -92,7 +101,7 @@ export interface AgentHooksCapability { * Best-effort and idempotent; never touches the user's home dir (~/.codex, * ~/.claude etc. are the CLIs' USER-GLOBAL config dirs — injection stays * repo-local). */ - prepareWorkspace(cwd: string, config?: AgentHookConfig, baseCwd?: string): Promise + prepareWorkspace(cwd: string, config?: AgentHookConfig, baseCwd?: string, launchedAgentId?: AgentId): Promise /** Inspect a workspace's per-agent hook-file injection state (for the * Settings UI): which agents write repo files, whether each one's config * folder is already in the repo (the 'auto' signal), and whether Cate has @@ -130,7 +139,7 @@ export interface AgentHooksDeps { * when the poster didn't send one). AWAITED before the HTTP response goes * out: the presence tracker's ancestry walk needs the bridge's process * chain alive, and the bridge holds it exactly until it hears back. */ - onPost?: (post: { terminalId: string; agentId: AgentId; pid?: number }) => void | Promise + onPost?: (post: { terminalId: string; agentId: AgentId; pid?: number; sourceStartedAt?: string }) => void | Promise /** Tests may replace the filesystem-backed CLI resolvers. */ titleResolvers?: AgentTitleResolvers /** Runtime-host home containing each CLI's session store. */ @@ -248,7 +257,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context }, }) } - return hook === 'stdout' ? context : '' + return hook === 'stdout' || hook === 'hermes' ? context : '' } let titleTracker: ReturnType | null = null @@ -408,6 +417,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook agentId?: unknown terminalId?: unknown pid?: unknown + processStartedAt?: unknown payload?: unknown threadId?: string turnId?: string @@ -459,10 +469,17 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook terminalId: body.terminalId, agentId: body.agentId as AgentId, pid: typeof body.pid === 'number' ? body.pid : undefined, + ...(normalizeAgentSourceStartedAt(body.processStartedAt) + ? { sourceStartedAt: normalizeAgentSourceStartedAt(body.processStartedAt) } + : {}), }) } catch { /* presence tracking must never fail the hook */ } } const event = normalizeAgentHookPayload(body.agentId, body.terminalId, body.payload as Record) + if (event?.agentId === 'hermes' && typeof body.pid === 'number' && Number.isInteger(body.pid) && body.pid > 0) { + event.sourcePid = body.pid + event.sourceStartedAt = normalizeAgentSourceStartedAt(body.processStartedAt) + } if (body.agentId in AGENT_HOOK_SPECS) { try { await changes.ingestHook(body.terminalId, body.agentId as AgentId, body.payload as Record, event) } catch (error) { console.warn('[agent changes] Could not save reported edit', error) } @@ -582,7 +599,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook listChanges: (cwd) => changes.list(cwd), readChanges: (cwd, knownRevision) => changes.readChanges(cwd, knownRevision), bindChanges: (cwd, threadId, panelId) => changes.bind(cwd, threadId, panelId), - async envForPty(ptyId, env) { + async envForPty(ptyId, env, config, cwd, baseCwd, launchedAgentId) { if (disposed) return env let state: HookState try { @@ -594,10 +611,23 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook out[CATE_HOOK_ENDPOINT_ENV] = state.url out[CATE_HOOK_TOKEN_ENV] = hookTokenForTerminal(state.secret, ptyId) out[CATE_TERMINAL_ID_ENV] = ptyId + const hermesMode = resolveAgentHookMode(config, 'hermes') + const hermesFolder = agentHookFolder('hermes') + const hermesConfigured = hermesMode === 'on' || ( + hermesMode === 'auto' && ( + launchedAgentId === 'hermes' || !!( + cwd && hermesFolder && ( + await dirExists(path.join(cwd, hermesFolder)) || + (baseCwd ? await dirExists(path.join(baseCwd, hermesFolder)) : false) + ) + ) + ) + ) + out.CATE_HERMES_HOOKS = hermesConfigured ? '1' : '0' return out }, - async prepareWorkspace(cwd, config, baseCwd) { + async prepareWorkspace(cwd, config, baseCwd, launchedAgentId) { if (disposed) return // Never plant (or strip) agent files in the user's home dir or against a // non-absolute cwd — see isRepoLocalCwd. @@ -614,8 +644,23 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook const excludeRels: string[] = [] for (const agent of AGENTS) { const spec = AGENT_HOOK_SPECS[agent.id] - if (!spec.projectFiles) continue const mode = resolveAgentHookMode(config, agent.id) + if (spec.externalPlugin) { + if (mode !== 'off') { + const folder = agentHookFolder(agent.id) + const configured = mode === 'on' || launchedAgentId === agent.id || !!( + folder && ( + await dirExists(path.join(cwd, folder)) || + (autoBaseCwd && await dirExists(path.join(autoBaseCwd, folder))) + ) + ) + if (configured && agent.id === 'hermes') { + try { await ensureHermesIntegration() } catch { /* hooks must not block a terminal */ } + } + } + continue + } + if (!spec.projectFiles) continue // 'off': reclaim anything we previously injected, then move on. if (mode === 'off') { for (const pf of spec.projectFiles) { @@ -690,6 +735,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook } } catch { /* absent — not injected via this path */ } } + if (agent.id === 'hermes') injected = await inspectHermesIntegration() } states.push({ agentId: agent.id, displayName: agent.displayName, folderPresent, injected }) } diff --git a/src/runtime/capabilities/agentPresence.test.ts b/src/runtime/capabilities/agentPresence.test.ts index e6dce0721..ded345e6e 100644 --- a/src/runtime/capabilities/agentPresence.test.ts +++ b/src/runtime/capabilities/agentPresence.test.ts @@ -61,7 +61,7 @@ describe('notePost → presenceFor', () => { }) test('falling edge: registered pid gone from the snapshot → absent and deregistered', async () => { - const { tracker } = makeTracker(TMUX_TREE) + const { tracker } = makeTracker(TMUX_TREE, { alive: () => false }) await tracker.notePost(T, 'claude-code', 41) const without = tree([[10, 1, 'zsh'], [20, 10, 'tmux'], [30, 1, 'tmux'], [31, 30, 'zsh']]) @@ -126,6 +126,42 @@ describe('notePost → presenceFor', () => { expect(tracker.presenceFor(T, both)).toEqual({ agentName: 'Codex', agentPresent: true }) }) + test('Hermes uses its authenticated in-process pid and rejects an older generation', async () => { + const processes = tree([[90, 10, 'python3'], [91, 10, 'python3']]) + const { tracker } = makeTracker(processes) + await tracker.notePost(T, 'hermes', 90, '100') + expect(tracker.presenceFor(T, processes)).toEqual({ agentName: 'Hermes', agentPresent: true }) + await tracker.notePost(T, 'hermes', 91, '200') + await tracker.notePost(T, 'hermes', 90, '100') + const onlyNew = tree([[91, 10, 'python3']]) + expect(tracker.presenceFor(T, onlyNew)).toEqual({ agentName: 'Hermes', agentPresent: true }) + }) + + test('a slower old Hermes lookup cannot overwrite a newer generation', async () => { + const oldTree = tree([[90, 10, 'python3']]) + const newTree = tree([[91, 10, 'python3']]) + let resolveOld!: (value: ProcTree) => void + const oldSnapshot = new Promise((resolve) => { resolveOld = resolve }) + const snapshot = vi.fn() + .mockReturnValueOnce(oldSnapshot) + .mockResolvedValueOnce(newTree) + const tracker = createAgentPresenceTracker({ snapshot, isAlive: () => true }) + + const oldPost = tracker.notePost(T, 'hermes', 90, '100') + await tracker.notePost(T, 'hermes', 91, '200') + resolveOld(oldTree) + await oldPost + + expect(tracker.presenceFor(T, newTree)).toEqual({ agentName: 'Hermes', agentPresent: true }) + }) + + test('a process missing from a stale snapshot remains present when it is alive', async () => { + const processes = tree([[90, 10, 'python3']]) + const { tracker } = makeTracker(processes, { alive: () => true }) + await tracker.notePost(T, 'hermes', 90, '100') + expect(tracker.presenceFor(T, tree([]))).toEqual({ agentName: 'Hermes', agentPresent: true }) + }) + test('a cyclic parent chain terminates', async () => { // Corrupt/racy snapshots can produce cycles; the walk must not spin. const cyclic = tree([[41, 42, 'sh'], [42, 41, 'sh']]) diff --git a/src/runtime/capabilities/agentPresence.ts b/src/runtime/capabilities/agentPresence.ts index 4933d1665..a5a19993a 100644 --- a/src/runtime/capabilities/agentPresence.ts +++ b/src/runtime/capabilities/agentPresence.ts @@ -31,11 +31,17 @@ import type { AgentId } from '../../shared/agents' import { AGENTS } from '../../shared/agents' +import { normalizeAgentSourceStartedAt } from '../../shared/agentHooks' import type { ProcTree } from './procfs' export interface AgentPresence { agentName: string | null agentPresent: boolean + /** Identity of the registration that just fell. The main process passes + * this through when clearing a resume stamp so a delayed scan cannot + * erase a newer Hermes process's session. */ + endedAgentPid?: number + endedAgentStartedAt?: string } export interface AgentPresenceTracker { @@ -43,7 +49,7 @@ export interface AgentPresenceTracker { * re-resolves after an agent relaunch) the registered agent pid for the * terminal. Await it before answering the post — the bridge's ancestry * chain is only guaranteed alive while the post is in flight. */ - notePost(terminalId: string, agentId: AgentId, pid: number | undefined): Promise + notePost(terminalId: string, agentId: AgentId, pid: number | undefined, sourceStartedAt?: string): Promise /** Liveness verdict against a process-table snapshot (the scan tick's own). * A registered pid that vanished — or changed comm (pid reuse) — is * deregistered and reads absent: the falling edge. */ @@ -67,6 +73,7 @@ interface Registration { /** comm at registration time — presenceFor requires it unchanged, so a * recycled pid can't impersonate the agent. */ comm: string + sourceStartedAt?: string } function defaultIsAlive(pid: number): boolean { @@ -92,22 +99,42 @@ function parentMap(tree: ProcTree): Map { export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresenceTracker { const isAlive = deps.isAlive ?? defaultIsAlive const registrations = new Map() + const hermesHighWater = new Map() return { - async notePost(terminalId, agentId, pid) { + async notePost(terminalId, agentId, pid, sourceStartedAt) { // Reject anything that isn't a plain positive pid: posts are made by // processes inside the terminal, so the value is untrusted input (and // pid 0 / negatives address process GROUPS in kill()). if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return const def = AGENTS.find((a) => a.id === agentId) if (!def) return + const canonicalStartedAt = normalizeAgentSourceStartedAt(sourceStartedAt) + if (def.hookProcess === 'self' && canonicalStartedAt) { + const incoming = BigInt(canonicalStartedAt) + const current = hermesHighWater.get(terminalId) + if (current !== undefined && incoming < current) return + hermesHighWater.set(terminalId, incoming) + } // Fast path: this terminal's agent is already registered and alive — // the common per-tool-call event needs no snapshot. const existing = registrations.get(terminalId) - if (existing && existing.agentId === agentId && isAlive(existing.pid)) return + if (existing && existing.agentId === agentId && isAlive(existing.pid) + && (def.hookProcess !== 'self' || existing.pid === pid)) return const tree = await deps.snapshot() + if (def.hookProcess === 'self') { + // A slower lookup from an older Hermes process must not overwrite the + // registration installed by a newer hook post while this awaited. + if (canonicalStartedAt) { + const current = hermesHighWater.get(terminalId) + if (current !== undefined && BigInt(canonicalStartedAt) < current) return + } + const comm = tree.nameByPid.get(pid) + if (comm) registrations.set(terminalId, { agentId, pid, comm, sourceStartedAt: canonicalStartedAt }) + return + } const parent = parentMap(tree) const visited = new Set() // Inclusive walk: an in-process plugin posts the agent's own pid; the @@ -132,14 +159,28 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen const def = AGENTS.find((a) => a.id === reg.agentId) return { agentName: def?.displayName ?? null, agentPresent: true } } + // The process may have posted after this scan's snapshot was taken. + // A direct liveness check avoids treating that stale snapshot as a + // falling edge. A present pid with a different comm still falls below, + // preserving the pid-reuse guard. + if (!tree.nameByPid.has(reg.pid) && isAlive(reg.pid)) { + const def = AGENTS.find((a) => a.id === reg.agentId) + return { agentName: def?.displayName ?? null, agentPresent: true } + } // Pid gone (or recycled under a different comm) — the falling edge. // The next agent run re-registers itself through fresh hook posts. registrations.delete(terminalId) - return { agentName: null, agentPresent: false } + return { + agentName: null, + agentPresent: false, + endedAgentPid: reg.pid, + ...(reg.sourceStartedAt ? { endedAgentStartedAt: reg.sourceStartedAt } : {}), + } }, drop(terminalId) { registrations.delete(terminalId) + hermesHighWater.delete(terminalId) }, } } diff --git a/src/runtime/capabilities/agentTitles/hermes.ts b/src/runtime/capabilities/agentTitles/hermes.ts new file mode 100644 index 000000000..45c25a182 --- /dev/null +++ b/src/runtime/capabilities/agentTitles/hermes.ts @@ -0,0 +1,5 @@ +import type { AgentTitleResolver } from './types' + +// Hermes stores generated titles asynchronously and exposes no title-change +// hook. Keep the panel's stable "Hermes" label until a supported API exists. +export const resolveHermesTitle: AgentTitleResolver = async () => null diff --git a/src/runtime/capabilities/agentTitles/index.ts b/src/runtime/capabilities/agentTitles/index.ts index 365855d1d..aab653b3d 100644 --- a/src/runtime/capabilities/agentTitles/index.ts +++ b/src/runtime/capabilities/agentTitles/index.ts @@ -3,6 +3,7 @@ import { resolveClaudeTitle } from './claude' import { resolveCodexTitle } from './codex' import { resolveCursorTitle } from './cursor' import { resolveGrokTitle } from './grok' +import { resolveHermesTitle } from './hermes' import { resolveKiroTitle } from './kiro' import { resolveOpenCodeTitle } from './opencode' @@ -12,6 +13,7 @@ export const AGENT_TITLE_RESOLVERS: AgentTitleResolvers = { codex: resolveCodexTitle, cursor: resolveCursorTitle, grok: resolveGrokTitle, + hermes: resolveHermesTitle, kiro: resolveKiroTitle, opencode: resolveOpenCodeTitle, } diff --git a/src/runtime/capabilities/hermesIntegration.test.ts b/src/runtime/capabilities/hermesIntegration.test.ts new file mode 100644 index 000000000..73a5d1b1d --- /dev/null +++ b/src/runtime/capabilities/hermesIntegration.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { HERMES_PLUGIN_MANIFEST, HERMES_PLUGIN_SOURCE } from './hermesIntegration' + +describe('managed Hermes plugin asset', () => { + it('declares every registered hook and returns Cate context from pre_llm_call', () => { + const hooks = [ + 'on_session_start', 'on_session_reset', 'pre_llm_call', 'on_session_end', + 'on_session_finalize', 'pre_approval_request', 'post_approval_response', 'post_tool_call', + ] + for (const hook of hooks) { + expect(HERMES_PLUGIN_MANIFEST).toContain(` - ${hook}`) + expect(HERMES_PLUGIN_SOURCE).toContain(`"${hook}"`) + } + expect(HERMES_PLUGIN_SOURCE).toContain('return {"context": output}') + expect(HERMES_PLUGIN_SOURCE).toContain('endpoint + "/hook"') + expect(HERMES_PLUGIN_SOURCE).toContain('ProxyHandler({})') + expect(HERMES_PLUGIN_SOURCE).toContain('time.monotonic_ns()') + expect(HERMES_PLUGIN_SOURCE).toContain('CATE_HERMES_HOOKS') + expect(HERMES_PLUGIN_SOURCE).toContain('if key in allowed') + expect(HERMES_PLUGIN_SOURCE).not.toContain('payload = dict(kwargs)') + }) +}) diff --git a/src/runtime/capabilities/hermesIntegration.ts b/src/runtime/capabilities/hermesIntegration.ts new file mode 100644 index 000000000..4d4af1e66 --- /dev/null +++ b/src/runtime/capabilities/hermesIntegration.ts @@ -0,0 +1,205 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const PLUGIN_ID = 'cate-agent-state' +const OWNER_FILE = '.cate-managed.json' +const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}' +const SAFE_PROFILE = /^[a-z0-9][a-z0-9_-]{0,63}$/ + +export const HERMES_PLUGIN_MANIFEST = `name: ${PLUGIN_ID} +version: 2.0.0 +description: "Connect Hermes terminal lifecycle, approvals, edits, and context to Cate." +author: "Cate" +provides_hooks: + - on_session_start + - on_session_reset + - pre_llm_call + - on_session_end + - on_session_finalize + - pre_approval_request + - post_approval_response + - post_tool_call +` + +// Kept inline so the same runtime bundle installs it on local, SSH, and WSL +// hosts; no source checkout or app-side filesystem path is involved. +export const HERMES_PLUGIN_SOURCE = String.raw`"""Cate bridge for Hermes terminal sessions.""" +import json +import os +import re +import time +from urllib.parse import urlparse +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener + +_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_PROCESS_STARTED_AT = str(time.monotonic_ns()) +class _RejectRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +_OPENER = build_opener(ProxyHandler({}), _RejectRedirects()) +_COMMON_KEYS = { + "session_id", "task_id", "turn_id", "api_request_id", "platform", + "completed", "failed", "interrupted", "turn_exit_reason", "reason", + "old_session_id", "new_session_id", +} +_TOOL_KEYS = { + "tool_name", "args", "result", "tool_call_id", "duration_ms", "status", + "error_type", "error_message", +} + +def _connection(): + if os.environ.get("CATE_HERMES_HOOKS") != "1": + return None + endpoint = os.environ.get("CATE_HOOK_ENDPOINT", "") + token = os.environ.get("CATE_HOOK_TOKEN", "") + terminal_id = os.environ.get("CATE_TERMINAL_ID", "") + try: + parsed = urlparse(endpoint) + except ValueError: + return None + if (not token or not terminal_id or parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "::1"}): + return None + return endpoint, token, terminal_id + +def _report(name, profile, **kwargs): + connection = _connection() + if connection is None or not _PROFILE_RE.fullmatch(profile): + return None + platform = str(kwargs.get("platform") or "") + if platform and platform not in {"cli", "tui"}: + return None + endpoint, token, terminal_id = connection + # pre_llm_call also carries the user's message and entire conversation + # history. Cate needs neither; keep the bridge's data boundary limited to + # lifecycle identity and post-tool change evidence. + allowed = _COMMON_KEYS | (_TOOL_KEYS if name == "post_tool_call" else set()) + payload = {key: value for key, value in kwargs.items() if key in allowed} + payload.update({ + "hook_event_name": name, + "profile": profile, + "platform": platform, + "cwd": os.getcwd(), + }) + body = json.dumps({ + "agentId": "hermes", + "terminalId": terminal_id, + "pid": os.getpid(), + "processStartedAt": _PROCESS_STARTED_AT, + "payload": payload, + }, default=str, separators=(",", ":")).encode("utf-8") + try: + request = Request(endpoint + "/hook", data=body, headers={ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, method="POST") + with _OPENER.open(request, timeout=0.75) as response: + output = response.read().decode("utf-8") + if name == "pre_llm_call" and output: + return {"context": output} + except Exception: + pass + return None + +def _callback(name, profile): + def report(**kwargs): + return _report(name, profile, **kwargs) + return report + +def register(ctx): + profile = str(getattr(ctx, "profile_name", "default")) + for name in ( + "on_session_start", "on_session_reset", "pre_llm_call", + "on_session_end", "on_session_finalize", "pre_approval_request", + "post_approval_response", "post_tool_call", + ): + ctx.register_hook(name, _callback(name, profile)) +` + +function profileArgs(profile?: string): string[] { + if (profile === undefined) return [] + if (!SAFE_PROFILE.test(profile) || profile === 'custom') throw new Error(`Invalid Hermes profile: ${profile}`) + return ['--profile', profile] +} + +async function runHermes(profile: string | undefined, args: string[]): Promise { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'HERMES_HOME')) + const { stdout } = await execFileAsync('hermes', [...profileArgs(profile), ...args], { + env, + windowsHide: true, + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }) + return stdout +} + +async function targetFor(profile?: string): Promise { + const config = (await runHermes(profile, ['config', 'path'])).trim() + if (!path.isAbsolute(config)) throw new Error('Hermes returned an invalid profile config path') + return path.join(path.dirname(config), 'plugins', PLUGIN_ID) +} + +async function ownership(target: string): Promise<'missing' | 'managed' | 'foreign'> { + try { + const info = await lstat(target) + if (!info.isDirectory() || info.isSymbolicLink()) return 'foreign' + return (await readFile(path.join(target, OWNER_FILE), 'utf8').catch(() => '')) === OWNER_SENTINEL + ? 'managed' + : 'foreign' + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing' + throw error + } +} + +export async function inspectHermesIntegration(profile?: string): Promise { + try { + const rows = JSON.parse(await runHermes(profile, ['plugins', 'list', '--enabled', '--json'])) + return Array.isArray(rows) && rows.some((row) => row?.name === PLUGIN_ID && row?.status === 'enabled') + } catch { + return false + } +} + +export async function ensureHermesIntegration(profile?: string): Promise { + const target = await targetFor(profile) + const owner = await ownership(target) + if (owner === 'foreign') throw new Error(`${target} exists and is not managed by Cate`) + const wasEnabled = owner === 'managed' && await inspectHermesIntegration(profile) + const pluginsDir = path.dirname(target) + await mkdir(pluginsDir, { recursive: true }) + const staging = await mkdtemp(path.join(pluginsDir, `.${PLUGIN_ID}-`)) + const backup = path.join(pluginsDir, `.${PLUGIN_ID}-backup-${randomUUID()}`) + let backedUp = false + try { + await writeFile(path.join(staging, 'plugin.yaml'), HERMES_PLUGIN_MANIFEST) + await writeFile(path.join(staging, '__init__.py'), HERMES_PLUGIN_SOURCE) + await writeFile(path.join(staging, OWNER_FILE), OWNER_SENTINEL) + if (owner === 'managed') { + await rename(target, backup) + backedUp = true + } + await rename(staging, target) + await runHermes(profile, ['plugins', 'enable', PLUGIN_ID, '--no-allow-tool-override']) + if (!(await inspectHermesIntegration(profile))) throw new Error('Hermes did not enable the Cate plugin') + if (backedUp) await rm(backup, { recursive: true, force: true }) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) + if (backedUp) await rename(backup, target).catch(() => {}) + // Roll back Hermes's enabled-plugin registry as well as the files. A + // failed first install must not leave a dangling enabled entry; replacing + // an existing working install restores its prior enabled state. + if (backedUp && wasEnabled) { + await runHermes(profile, ['plugins', 'enable', PLUGIN_ID, '--no-allow-tool-override']).catch(() => {}) + } else if (!backedUp) { + await runHermes(profile, ['plugins', 'disable', PLUGIN_ID]).catch(() => {}) + } + throw error + } +} diff --git a/src/runtime/capabilities/index.ts b/src/runtime/capabilities/index.ts index 0d26e2fef..7d8338898 100644 --- a/src/runtime/capabilities/index.ts +++ b/src/runtime/capabilities/index.ts @@ -186,7 +186,8 @@ export function buildDaemonRuntime(config: DaemonRuntimeConfig): DaemonRuntime { // liveness — the one presence authority (no child scan). const agentPresence = createAgentPresenceTracker({ snapshot: snapshotProcessTree }) const agentHooks = createAgentHooksCapability({ - onPost: ({ terminalId, agentId, pid }) => agentPresence.notePost(terminalId, agentId, pid), + onPost: ({ terminalId, agentId, pid, sourceStartedAt }) => + agentPresence.notePost(terminalId, agentId, pid, sourceStartedAt), }) const innerProc = createProcessCapability({ @@ -202,8 +203,9 @@ export function buildDaemonRuntime(config: DaemonRuntimeConfig): DaemonRuntime { hooks: { noteInput: (ptyId, data) => agentHooks.noteInput(ptyId, data), forgetTerminal: (ptyId) => agentHooks.forgetTerminal(ptyId), - envForPty: (ptyId, env) => agentHooks.envForPty(ptyId, env), - prepareWorkspace: (cwd, config, baseCwd) => agentHooks.prepareWorkspace(cwd, config, baseCwd), + envForPty: (ptyId, env, config, cwd, baseCwd, launchedAgentId) => + agentHooks.envForPty(ptyId, env, config, cwd, baseCwd, launchedAgentId), + prepareWorkspace: (cwd, config, baseCwd, launchedAgentId) => agentHooks.prepareWorkspace(cwd, config, baseCwd, launchedAgentId), }, agentPresence, }) @@ -303,6 +305,8 @@ export function buildDaemonRuntime(config: DaemonRuntimeConfig): DaemonRuntime { server, tunnel, agentHooks, - killAll: () => { server.killAll(); tunnel.closeAll(); agentHooks.dispose() }, + // Stop hook ingestion before terminating PTYs: graceful agent finalizers + // must not erase the resume stamp Cate is preserving for cold restore. + killAll: () => { agentHooks.dispose(); server.killAll(); tunnel.closeAll() }, } } diff --git a/src/runtime/capabilities/process.agentStatus.test.ts b/src/runtime/capabilities/process.agentStatus.test.ts index 32fa43196..359f2a67a 100644 --- a/src/runtime/capabilities/process.agentStatus.test.ts +++ b/src/runtime/capabilities/process.agentStatus.test.ts @@ -22,6 +22,7 @@ const fixtures = [ { agentId: 'codex', start: { hook_event_name: 'UserPromptSubmit' }, wait: { hook_event_name: 'PermissionRequest' } }, { agentId: 'claude-code', start: { hook_event_name: 'UserPromptSubmit' }, wait: { hook_event_name: 'PermissionRequest' } }, { agentId: 'grok', start: { hookEventName: 'user_prompt_submit' }, wait: { hookEventName: 'notification', notificationType: 'permission_prompt' } }, + { agentId: 'hermes', start: { hook_event_name: 'pre_llm_call', platform: 'cli' }, wait: { hook_event_name: 'pre_approval_request', platform: 'cli' } }, { agentId: 'opencode', start: { type: 'session.status', status: { type: 'busy' } }, wait: { type: 'permission.asked' } }, // These CLIs have no permission-wait hook. Input must preserve their running state. { agentId: 'cursor', start: { hook_event_name: 'beforeSubmitPrompt' }, wait: null }, diff --git a/src/runtime/capabilities/process.ts b/src/runtime/capabilities/process.ts index c7c3c5e9e..71cef8dad 100644 --- a/src/runtime/capabilities/process.ts +++ b/src/runtime/capabilities/process.ts @@ -17,6 +17,7 @@ import type { ProcessHost, PtyCreateOptions, PtyHandle, PtyActivity } from '../. import type { TerminalActivity } from '../../shared/types' import type { AgentPresenceTracker } from './agentPresence' import type { AgentHookConfig } from '../../shared/agentHooks' +import { agentForLaunchCommand, type AgentId } from '../../shared/agents' import { catePathEnv } from '../cateCli' import { type ProcTree, @@ -165,8 +166,8 @@ export interface ProcessDeps { * tests without hook support spawn plain shells. */ hooks?: { - envForPty(ptyId: string, env: Record): Promise> - prepareWorkspace(cwd: string, config?: AgentHookConfig, baseCwd?: string): Promise + envForPty(ptyId: string, env: Record, config?: AgentHookConfig, cwd?: string, baseCwd?: string, launchedAgentId?: AgentId): Promise> + prepareWorkspace(cwd: string, config?: AgentHookConfig, baseCwd?: string, launchedAgentId?: AgentId): Promise noteInput?(ptyId: string, data: string): void forgetTerminal?(ptyId: string): void } @@ -271,8 +272,13 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { // must never fail to open over hooks. if (deps.hooks && opts.agentHooks) { try { - env = await deps.hooks.envForPty(id, env) - await deps.hooks.prepareWorkspace(cwd, opts.agentHookConfig, opts.workspaceBaseCwd) + const launchedAgent = opts.command ? agentForLaunchCommand(opts.command.executable) : null + env = await deps.hooks.envForPty(id, env, opts.agentHookConfig, cwd, opts.workspaceBaseCwd, launchedAgent?.id) + if (launchedAgent) { + await deps.hooks.prepareWorkspace(cwd, opts.agentHookConfig, opts.workspaceBaseCwd, launchedAgent.id) + } else { + await deps.hooks.prepareWorkspace(cwd, opts.agentHookConfig, opts.workspaceBaseCwd) + } } catch { /* hook injection unavailable */ } } const pty = ptySpawn(executable, args, { diff --git a/src/shared/agentHooks.test.ts b/src/shared/agentHooks.test.ts index 0b039c328..f1af54d83 100644 --- a/src/shared/agentHooks.test.ts +++ b/src/shared/agentHooks.test.ts @@ -548,6 +548,24 @@ describe('kiro spec', () => { }) }) +describe('hermes spec', () => { + const base = { session_id: 'session-1', turn_id: 'turn-1', profile: 'work', cwd: '/repo', platform: 'cli' } + + test('normalizes lifecycle, approvals, and interactive-platform events', () => { + expect(norm('hermes', { hook_event_name: 'on_session_start', ...base })).toMatchObject({ + kind: 'session-start', sessionId: 'session-1', turnId: 'turn-1', profile: 'work', cwd: '/repo', + }) + expect(norm('hermes', { hook_event_name: 'pre_llm_call', ...base })?.kind).toBe('turn-start') + expect(norm('hermes', { hook_event_name: 'on_session_end', ...base })?.kind).toBe('turn-end') + expect(norm('hermes', { hook_event_name: 'pre_approval_request', ...base })?.kind).toBe('permission-wait') + expect(norm('hermes', { hook_event_name: 'post_approval_response', ...base })?.kind).toBe('turn-resume') + expect(norm('hermes', { hook_event_name: 'post_tool_call', ...base })?.kind).toBe('turn-resume') + expect(norm('hermes', { hook_event_name: 'on_session_finalize', ...base })?.kind).toBe('session-end') + expect(norm('hermes', { hook_event_name: 'pre_llm_call', ...base, platform: 'gateway' })).toBeNull() + expect(AGENT_HOOK_SPECS.hermes.externalPlugin).toEqual({ id: 'cate-agent-state' }) + }) +}) + describe('normalizeAgentHookPayload', () => { test('unknown agents and untracked payloads drop; raw payload rides along', () => { expect(normalizeAgentHookPayload('not-an-agent', 't', { hook_event_name: 'Stop' })).toBeNull() @@ -580,6 +598,7 @@ describe('reportsTurnEndOnInterrupt', () => { // Expected self-heal via stop{cancelled}; streaming path not yet // observed live (test account quota) — see grokSpec. grok: true, + hermes: true, // Verified live: Ctrl-C returns to Kiro's prompt without a Stop hook; // renderer terminal input supplies the scoped recovery edge. kiro: false, diff --git a/src/shared/agentHooks.ts b/src/shared/agentHooks.ts index 4eaaa7820..94cd7462b 100644 --- a/src/shared/agentHooks.ts +++ b/src/shared/agentHooks.ts @@ -17,11 +17,7 @@ // ============================================================================= import { createHash } from 'crypto' -import type { AgentId } from './agents' -import { - resolveAgentHookMode, - type AgentHookConfig, -} from './agentHookModes' +import { AGENTS, type AgentId } from './agents' export { resolveAgentHookMode, type AgentHookConfig, @@ -78,6 +74,12 @@ export interface AgentHookEvent { /** The transcript / rollout / session file backing the session, when the * payload carries one. */ transcriptPath?: string + /** Named CLI profile that owns the session, when the agent exposes one. */ + profile?: string + /** Process that emitted an in-process hook; never persisted. */ + sourcePid?: number + /** Monotonic process-start clock supplied by an in-process hook. */ + sourceStartedAt?: string /** Present only for session-title events. */ title?: string /** The raw payload as posted by the bridge, for consumers that need @@ -86,7 +88,12 @@ export interface AgentHookEvent { } export type NormalizedHookFields = Pick & - Partial> + Partial> + +export function normalizeAgentSourceStartedAt(value: unknown): string | undefined { + if (typeof value !== 'string' || !/^[0-9]{1,32}$/.test(value)) return undefined + try { return BigInt(value) > 0n ? BigInt(value).toString() : undefined } catch { return undefined } +} // --------------------------------------------------------------------------- // Injection declarations @@ -188,6 +195,8 @@ export interface AgentHookSpec { */ strip?(existing: string): AgentHookStrip }> + /** Profile-scoped plugin managed on the runtime host instead of in the repo. */ + externalPlugin?: { id: string } /** Normalize one raw payload posted by this agent's bridge. Null = drop * (an event Cate doesn't track, e.g. claude's idle_prompt notification). */ normalize(payload: Record): NormalizedHookFields | null @@ -319,13 +328,12 @@ function stripSharedHooksFile(existing: string, events: readonly string[]): Agen } /** The repo-local config folder whose presence gates 'auto' injection for one - * agent (`.claude`, `.codex`, `.cursor`, `.opencode`), or null for an - * agent that writes no project files. Derived from the agent's first project - * file so it stays in lockstep with the spec. */ + * agent (`.claude`, `.codex`, `.cursor`, `.opencode`). Project-file hooks + * derive it from their first file; external plugins use their skills dir. */ export function agentHookFolder(agentId: AgentId): string | null { const rel = AGENT_HOOK_SPECS[agentId]?.projectFiles?.[0]?.relPath - if (!rel) return null - return rel.split('/')[0] + if (rel) return rel.split('/')[0] + return AGENTS.find((agent) => agent.id === agentId)?.skills?.baseSegments[0] ?? null } // --------------------------------------------------------------------------- @@ -828,6 +836,43 @@ const kiroSpec: AgentHookSpec = { }, } +// --------------------------------------------------------------------------- +// Hermes — a profile-scoped Python plugin posts lifecycle and tool events to +// Cate's authenticated per-PTY endpoint. The managed plugin is a no-op outside +// Cate terminals and returns connected-panel context from pre_llm_call. +// --------------------------------------------------------------------------- + +const HERMES_INTERACTIVE_PLATFORMS = new Set(['cli', 'tui']) + +const hermesSpec: AgentHookSpec = { + reportsTurnEndOnInterrupt: true, + externalPlugin: { id: 'cate-agent-state' }, + normalize: (p) => { + const platform = str(p.platform) + if (platform && !HERMES_INTERACTIVE_PLATFORMS.has(platform)) return null + const base = { + sessionId: str(p.session_id), + turnId: str(p.turn_id), + cwd: str(p.cwd) ?? undefined, + profile: str(p.profile) ?? undefined, + } + switch (p.hook_event_name) { + case 'on_session_start': + case 'on_session_reset': return { kind: 'session-start', ...base } + case 'pre_llm_call': return { kind: 'turn-start', ...base } + case 'on_session_end': return { kind: 'turn-end', ...base } + case 'pre_approval_request': return { kind: 'permission-wait', ...base } + case 'post_approval_response': return { kind: 'turn-resume', ...base } + // Confirms execution resumed even if an older/custom approval flow did + // not emit post_approval_response; it also keeps status active across + // ordinary tool calls while edit ingestion consumes the raw payload. + case 'post_tool_call': return { kind: 'turn-resume', ...base } + case 'on_session_finalize': return { kind: 'session-end', ...base } + default: return null + } + }, +} + // --------------------------------------------------------------------------- // Registry + normalization entry point // --------------------------------------------------------------------------- @@ -837,6 +882,7 @@ export const AGENT_HOOK_SPECS: Record = { codex: codexSpec, cursor: cursorSpec, grok: grokSpec, + hermes: hermesSpec, kiro: kiroSpec, opencode: opencodeSpec, } diff --git a/src/shared/agentRegistry.test.ts b/src/shared/agentRegistry.test.ts index 6913fbf98..d564ae59c 100644 --- a/src/shared/agentRegistry.test.ts +++ b/src/shared/agentRegistry.test.ts @@ -36,6 +36,7 @@ describe('agent registry coverage', () => { codex: 'additional-context', cursor: null, grok: null, + hermes: 'hermes', kiro: 'stdout', opencode: 'opencode', }) @@ -79,7 +80,7 @@ describe('agent registry coverage', () => { // restatement of the type. test('persisted SkillTargetId values never drift', () => { const expected: SkillTargetId[] = [ - 'claude-code', 'opencode', 'codex', 'cursor', 'grok', 'kiro', + 'claude-code', 'opencode', 'codex', 'cursor', 'grok', 'hermes', 'kiro', ] expect([...SKILL_TARGETS].map((t) => t.id).sort()).toEqual([...expected].sort()) }) @@ -90,7 +91,7 @@ describe('agent registry coverage', () => { for (const a of AGENTS) { const spec = AGENT_HOOK_SPECS[a.id] expect(spec, `${a.id} hook spec`).toBeTruthy() - expect(spec.projectFiles?.length, `${a.id} has no project-file injection channel`).toBeTruthy() + expect(Boolean(spec.projectFiles?.length || spec.externalPlugin), `${a.id} has no injection channel`).toBe(true) } }) @@ -101,7 +102,7 @@ describe('agent registry coverage', () => { `${a.id} does not detect its own command name`).toBe(true) // resumeArgs is nullable by design (a CLI may not resume by id) — assert // it is a real decision, and that the argv it builds is non-empty. - if (a.resumeArgs) expect(a.resumeArgs('abc').length).toBeGreaterThan(0) + if (a.resumeArgs && a.id !== 'hermes') expect(a.resumeArgs('abc')?.length).toBeGreaterThan(0) } }) }) diff --git a/src/shared/agents.test.ts b/src/shared/agents.test.ts index a9cfb52d6..493208ff7 100644 --- a/src/shared/agents.test.ts +++ b/src/shared/agents.test.ts @@ -7,6 +7,7 @@ describe('agentForLaunchCommand', () => { expect(agentForLaunchCommand('/usr/local/bin/codex --some-flag')?.id).toBe('codex') expect(agentForLaunchCommand('"C:\\tools\\cursor-agent"')?.id).toBe('cursor') expect(agentForLaunchCommand('/usr/local/bin/kiro-cli chat')?.id).toBe('kiro') + expect(agentForLaunchCommand('hermes chat')?.id).toBe('hermes') }) it('does not guess through compound shell syntax', () => { @@ -23,6 +24,7 @@ describe('matchAgentDef', () => { expect(matchAgentDef('cursor-agent')?.id).toBe('cursor') expect(matchAgentDef('cursor')?.id).toBe('cursor') expect(matchAgentDef('kiro-cli')?.id).toBe('kiro') + expect(matchAgentDef('hermes')?.id).toBe('hermes') expect(matchAgentDef('node')).toBeNull() }) }) @@ -38,6 +40,9 @@ describe('resumeCommandForAgent', () => { expect(resumeCommandForAgent('grok', uuid)).toBe(`grok --resume ${uuid}`) expect(resumeCommandForAgent('opencode', 'ses_abc123')).toBe('opencode --session ses_abc123') expect(resumeCommandForAgent('kiro', uuid)).toBe(`kiro-cli chat --v3 --resume-id ${uuid}`) + expect(resumeCommandForAgent('hermes', uuid, { profile: 'work' })).toBe(`hermes --profile work chat --resume ${uuid}`) + expect(resumeCommandForAgent('hermes', uuid)).toBeNull() + expect(resumeCommandForAgent('hermes', uuid, { profile: 'custom' })).toBeNull() }) it('returns null for unknown agent ids', () => { diff --git a/src/shared/agents.ts b/src/shared/agents.ts index 5ef466775..569084825 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -37,6 +37,7 @@ export type AgentId = | 'codex' | 'cursor' | 'grok' + | 'hermes' | 'kiro' | 'opencode' @@ -89,13 +90,16 @@ export interface AgentDef { /** True when a shell child process with this (already-lowercased) name means * this agent is the one running in that terminal. */ matchProcess: (procName: string) => boolean + /** In-process hooks can prove identity from the authenticated posting pid + * even when the interpreter hides the CLI name in `ps comm`. */ + hookProcess?: 'self' /** Native post-submit extension point Cate can use to add graph context * without reading or rewriting the terminal's PTY input. */ - promptContextHook: 'additional-context' | 'stdout' | 'opencode' | null + promptContextHook: 'additional-context' | 'stdout' | 'opencode' | 'hermes' | null /** Argv (after `command`) that re-attaches to `sessionId` on a terminal * restore, or null when this CLI cannot resume by id. Every contract here is * pinned live by agentHookContracts.itest.ts. */ - resumeArgs: ((sessionId: string) => string[]) | null + resumeArgs: ((sessionId: string, context?: { profile?: string }) => string[] | null) | null /** Project-skills integration, or null when Cate installs no skills for this * agent. Verified against each CLI's own docs — see the per-agent notes. */ skills: AgentSkillTarget | null @@ -199,6 +203,23 @@ export const AGENTS: readonly AgentDef[] = [ resumeArgs: (sid) => ['--session', sid], skills: folderSkills('opencode', ['.opencode', 'skills']), }, + { + id: 'hermes', + displayName: 'Hermes', + command: 'hermes', + // Hermes >=0.21 keeps `chat -q` interactive when attached to Cate's PTY. + codingAgentArgs: (prompt) => ['chat', '-q', prompt], + codingAgentFollowUp: true, + matchProcess: (n) => n === 'hermes' || n === 'hermes.exe', + hookProcess: 'self', + promptContextHook: 'hermes', + // Profiles have independent session stores, so an exact resume must carry + // the profile that emitted the session lifecycle event. + resumeArgs: (sid, context) => context?.profile + ? ['--profile', context.profile, 'chat', '--resume', sid] + : null, + skills: folderSkills('hermes', ['.hermes', 'skills']), + }, // Kiro CLI with its v3 engine. Standalone workspace hooks require that engine, so select // it explicitly for fresh and resumed sessions. { @@ -270,14 +291,23 @@ export function agentIdForT3Provider(provider: string): AgentId | null { // terminal process can forge, and a dash-led "id" (`--dangerously-skip- // permissions`) would otherwise be joined into the resume command as a flag. // Real ids are uuids / opencode `ses_*` — never dash-led. -const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/ +const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ +const SAFE_PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/ /** The full shell command that resumes `sessionId` for `agentId`, or null when * the agent is unknown / can't resume by id (or the id isn't a bare token). */ -export function resumeCommandForAgent(agentId: string, sessionId: string): string | null { +export function resumeCommandForAgent( + agentId: string, + sessionId: string, + context?: { profile?: string }, +): string | null { const def = AGENTS.find((a) => a.id === agentId) if (!def?.resumeArgs || !SAFE_SESSION_ID.test(sessionId)) return null - return [def.command, ...def.resumeArgs(sessionId)].join(' ') + if (context?.profile !== undefined && ( + !SAFE_PROFILE_NAME.test(context.profile) || (agentId === 'hermes' && context.profile === 'custom') + )) return null + const args = def.resumeArgs(sessionId, context) + return args ? [def.command, ...args].join(' ') : null } /** Shared identities, filtered by the execution integration required by the caller. */ diff --git a/src/shared/codingAgentRuns.test.ts b/src/shared/codingAgentRuns.test.ts index 60524a857..37c0e3d5d 100644 --- a/src/shared/codingAgentRuns.test.ts +++ b/src/shared/codingAgentRuns.test.ts @@ -20,6 +20,7 @@ describe('codingAgentCommand', () => { { id: 'cursor', command: { executable: 'cursor-agent', args: [prefixed] }, followUp: true }, { id: 'grok', command: { executable: 'grok', args: [prefixed] }, followUp: true }, { id: 'opencode', command: { executable: 'opencode', args: ['--prompt', prefixed] }, followUp: true }, + { id: 'hermes', command: { executable: 'hermes', args: ['chat', '-q', prefixed] }, followUp: true }, { id: 'kiro', command: { executable: 'kiro-cli', args: ['chat', '--v3', prefixed] }, followUp: true }, ]) }) diff --git a/src/shared/skills.ts b/src/shared/skills.ts index 7110b7967..051d8bdc8 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -34,6 +34,7 @@ export type SkillTargetId = | 'codex' | 'cursor' | 'grok' + | 'hermes' | 'kiro' /** Where a skill lives in a source repo: the directory that contains its diff --git a/src/shared/types.ts b/src/shared/types.ts index 24163ec25..aa241017d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -72,6 +72,8 @@ export interface TerminalAgentSession { /** The cwd the session belongs to (from the hook payload, or the terminal's * cwd when the payload carries none). */ cwd: string + /** Named CLI profile that owns this session, when the agent exposes one. */ + profile?: string } // ----------------------------------------------------------------------------- diff --git a/src/skills/main/seedCateCliSkill.ts b/src/skills/main/seedCateCliSkill.ts index 1e651094f..39d7aff71 100644 --- a/src/skills/main/seedCateCliSkill.ts +++ b/src/skills/main/seedCateCliSkill.ts @@ -12,7 +12,7 @@ import { bundledSkillSource } from './bundledSkillSource' // runtime connects only after create/attach. // - gated by the cliSkillInstallEnabled setting (Settings → CLI); // - every target (one per agent CLI that declares `skills` in -// src/shared/agents.ts — claude-code, codex, cursor, grok, kiro, opencode, +// src/shared/agents.ts — claude-code, codex, cursor, grok, hermes, kiro, opencode — // is seeded only when its tool dir (`.claude`, `.codex`, etc.) // already exists in the workspace, so repos don't grow dot-dirs for agents // nobody uses there. A tool dir created later is picked up on a subsequent diff --git a/src/skills/main/targets.test.ts b/src/skills/main/targets.test.ts index 550f7f3c3..9b156d837 100644 --- a/src/skills/main/targets.test.ts +++ b/src/skills/main/targets.test.ts @@ -16,6 +16,7 @@ describe('skillsRootDir', () => { expect(skillsRootDir('claude-code', 'local', cwd)).toBe(path.join(cwd, '.claude', 'skills')) expect(skillsRootDir('opencode', 'local', cwd)).toBe(path.join(cwd, '.opencode', 'skills')) expect(skillsRootDir('codex', 'local', cwd)).toBe(path.join(cwd, '.codex', 'skills')) + expect(skillsRootDir('hermes', 'local', cwd)).toBe(path.join(cwd, '.hermes', 'skills')) }) it('uses POSIX joins for a remote runtime', () => {