From ce104545c69f02a589658f4594b510a733ab1769 Mon Sep 17 00:00:00 2001 From: Mathias Munk Hojer Date: Mon, 14 Sep 2026 13:59:30 +0200 Subject: [PATCH 1/6] [verified] feat: restore exact Hermes terminal sessions Add a profile-scoped Hermes lifecycle plugin, exact session/profile persistence, process-generation race protection, hardened installation management, and Windows cold-restart coverage. --- .gitignore | 2 + README.md | 2 +- e2e/agent-changes.spec.ts | 33 +- e2e/fixtures/fake-hermes-session.cjs | 89 +++++ e2e/hermes-session-resume.spec.ts | 242 +++++++++++ integrations/hermes/README.md | 46 +++ .../cate-agent-state/.cate-managed.json | 1 + .../hermes/cate-agent-state/__init__.py | 105 +++++ .../hermes/cate-agent-state/plugin.yaml | 10 + package.json | 2 + scripts/hermes-integration.mjs | 377 ++++++++++++++++++ scripts/hermes-integration.test.mjs | 319 +++++++++++++++ scripts/test_hermes_integration_asset.py | 215 ++++++++++ src/main/ipc/agentSessionStamps.test.ts | 75 +++- src/main/ipc/agentSessionStamps.ts | 63 ++- src/main/ipc/shell.ts | 2 +- src/main/runtime/types.ts | 2 + .../lib/agent/agentStatus.integration.test.ts | 6 + .../sessionSerialize.roundTrip.test.ts | 5 +- src/renderer/panels/TerminalPanel.tsx | 6 +- src/runtime/capabilities/agentHooks.test.ts | 36 ++ src/runtime/capabilities/agentHooks.ts | 30 +- .../capabilities/agentPresence.test.ts | 23 ++ src/runtime/capabilities/agentPresence.ts | 35 +- .../capabilities/agentTitles/hermes.ts | 4 + src/runtime/capabilities/agentTitles/index.ts | 2 + src/runtime/capabilities/index.ts | 2 +- .../capabilities/process.agentStatus.test.ts | 1 + src/runtime/index.ts | 6 +- src/shared/agentHooks.test.ts | 38 ++ src/shared/agentHooks.ts | 48 ++- src/shared/agentRegistry.test.ts | 24 +- src/shared/agents.test.ts | 17 + src/shared/agents.ts | 50 ++- src/shared/codingAgentRuns.test.ts | 8 +- src/shared/types.ts | 2 + 36 files changed, 1870 insertions(+), 58 deletions(-) create mode 100644 e2e/fixtures/fake-hermes-session.cjs create mode 100644 e2e/hermes-session-resume.spec.ts create mode 100644 integrations/hermes/README.md create mode 100644 integrations/hermes/cate-agent-state/.cate-managed.json create mode 100644 integrations/hermes/cate-agent-state/__init__.py create mode 100644 integrations/hermes/cate-agent-state/plugin.yaml create mode 100644 scripts/hermes-integration.mjs create mode 100644 scripts/hermes-integration.test.mjs create mode 100644 scripts/test_hermes_integration_asset.py create mode 100644 src/runtime/capabilities/agentTitles/hermes.ts diff --git a/.gitignore b/.gitignore index 3fce1ccd6..11fe80c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist/ dist-runtime/ .DS_Store *.tsbuildinfo +__pycache__/ +*.py[cod] .worktrees/ # Build artifacts (keep signing entitlements tracked) diff --git a/README.md b/README.md index 902443a2b..c00e1f53b 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ brew install --cask cate - **Integrated agent chats.** T3 Code runs Codex, Claude Code, Cursor, Grok, OpenCode and Antigravity in a full chat panel with streaming output, tool calls and approvals. - **Agent-aware terminals.** Supported agent CLIs report turn start, turn end, and permission prompts, so each panel shows running / waiting / finished and pings you when it needs an answer. -- **Agent sessions survive restarts.** Reopen the project and terminals come back with their scrollback, each agent reattached with its own resume command. +- **Agent sessions survive restarts.** Reopen the project and terminals come back with their scrollback, each agent reattached with its own resume command. Hermes users can enable exact profile-aware restore with the [Hermes integration](integrations/hermes/README.md). - **Worktrees for parallel branches.** Type what you're working on and Cate creates the worktree and branch, off a local branch, a remote branch, or an open PR. - **Panels on a canvas or in a dock.** Terminals, Monaco editors, browsers, PDF/image/DOCX viewers, nested canvases. Float, dock into tabs and splits, or detach into their own window. Layout persists per project. - **Git and search.** Multi-repo source control, git badges in the file tree, side-by-side diffs, ripgrep search, and `Cmd+K` for commands, panels, and files. diff --git a/e2e/agent-changes.spec.ts b/e2e/agent-changes.spec.ts index 32f645342..bb2117573 100644 --- a/e2e/agent-changes.spec.ts +++ b/e2e/agent-changes.spec.ts @@ -12,8 +12,15 @@ let directory: string let workspaceId: string test.beforeEach(async () => { directory = realpathSync(mkdtempSync(path.join(tmpdir(), 'cate-agent-capture-e2e-'))) - ;({ electronApp: app, mainWindow: page } = await launchApp({ userDataDir: path.join(directory, 'userdata') })) + // Avoid launchApp's disposable seed project: re-rooting that workspace leaves + // two on-disk snapshots with the same id, and the empty one can win restore. + ;({ electronApp: app, mainWindow: page } = await launchApp({ + userDataDir: path.join(directory, 'userdata'), + empty: true, + })) await openTrustedWorkspace(page, directory) + await page.evaluate(() => window.__cateE2E!.createPanel('canvas')) + await page.waitForSelector('[data-canvas-panel-id]') workspaceId = await page.evaluate(() => window.__cateE2E!.selectedWorkspaceId()) await resetViewport(page) }) @@ -67,13 +74,27 @@ for (const agentId of ['claude-code', 'codex', 'cursor', 'grok', 'kiro', 'openco // The workspace, review panel, source association and recorded history // must all survive a full app/runtime restart, not only a fresh launch. await closeApp(app) - ;({ electronApp: app, mainWindow: page } = await launchApp({ userDataDir: path.join(directory, 'userdata') })) + ;({ electronApp: app, mainWindow: page } = await launchApp({ + userDataDir: path.join(directory, 'userdata'), + empty: true, + })) + await page.waitForFunction( + () => performance.getEntriesByName('session-restored').length > 0, + undefined, + { timeout: 30_000 }, + ) await expect(page.getByText('This review panel hit an error')).toHaveCount(0) + await page.waitForFunction( + (id) => !!window.__cateE2E!.terminalPtyId(id), + node, + { timeout: 30_000 }, + ) await resetViewport(page) - const restoredSource = await titleBarCentre(page, node) - expect(restoredSource).not.toBeNull() - await page.mouse.click(restoredSource!.x, restoredSource!.y + 60) - await page.locator(`[data-node-id="${node}"]`).getByRole('button', { name: 'Open agent changes' }).click() + const restoredNode = page.locator(`[data-node-id="${node}"]`) + await expect(restoredNode).toBeVisible() + const unfocusedOverlay = restoredNode.locator('[data-unfocused-overlay="true"]') + if (await unfocusedOverlay.isVisible()) await unfocusedOverlay.click() + await restoredNode.getByRole('button', { name: 'Open agent changes' }).click() await page.getByRole('button', { name: /Use Agent changes/ }).click() await expect(page.locator('[data-review-file="own.ts"]')).toBeVisible() await expect(page.locator('[data-review-file="other.ts"]')).toHaveCount(0) diff --git a/e2e/fixtures/fake-hermes-session.cjs b/e2e/fixtures/fake-hermes-session.cjs new file mode 100644 index 000000000..fda8f02fe --- /dev/null +++ b/e2e/fixtures/fake-hermes-session.cjs @@ -0,0 +1,89 @@ +'use strict' +/* global process, require, URL, Buffer, setInterval */ +/* eslint-disable @typescript-eslint/no-require-imports */ + +const fs = require('node:fs') +const http = require('node:http') + +const args = process.argv.slice(2) +const flag = (name) => { + const index = args.indexOf(name) + return index >= 0 ? args[index + 1] : undefined +} + +const endpoint = process.env.CATE_HOOK_ENDPOINT +const token = process.env.CATE_HOOK_TOKEN +const terminalId = process.env.CATE_TERMINAL_ID +const logPath = process.env.FAKE_HERMES_LOG +const profile = flag('--profile') || 'default' +const resumedSessionId = flag('--resume') +const sessionId = resumedSessionId || `fake_${String(terminalId).replace(/[^A-Za-z0-9_-]/g, '_')}` + +function post(hookEventName) { + if (!endpoint || !token || !terminalId) return Promise.resolve() + const body = JSON.stringify({ + agentId: 'hermes', + terminalId, + pid: process.pid, + payload: { + hook_event_name: hookEventName, + session_id: sessionId, + cwd: process.cwd(), + profile, + platform: 'cli', + }, + }) + return new Promise((resolve) => { + const url = new URL(endpoint) + const request = http.request({ + hostname: url.hostname, + port: url.port, + path: url.pathname || '/', + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + timeout: 1_000, + }, (response) => { + response.resume() + response.once('end', resolve) + }) + request.once('timeout', () => { request.destroy(); resolve() }) + request.once('error', resolve) + request.end(body) + }) +} + +let finishing = false +async function finish() { + if (finishing) return + finishing = true + await post('on_session_finalize') + process.exit(0) +} + +void (async () => { + if (logPath) { + fs.appendFileSync(logPath, `${JSON.stringify({ + phase: resumedSessionId ? 'resume' : 'start', + pid: process.pid, + terminalId, + sessionId, + profile, + args, + })}\n`) + } + await post('on_session_start') + await post('pre_llm_call') + process.stdout.write(`FAKE_HERMES_${resumedSessionId ? 'RESUMED' : 'READY'} ${sessionId}\r\n`) + process.on('SIGTERM', () => { void finish() }) + process.on('SIGINT', () => { void finish() }) + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk) => { + if (chunk.includes('__CATE_E2E_EXIT__')) void finish() + }) + process.stdin.resume() + setInterval(() => {}, 60_000).unref() +})() diff --git a/e2e/hermes-session-resume.spec.ts b/e2e/hermes-session-resume.spec.ts new file mode 100644 index 000000000..00c0beb6b --- /dev/null +++ b/e2e/hermes-session-resume.spec.ts @@ -0,0 +1,242 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { closeApp, launchApp, resetViewport, seedTerminal } from './fixtures/electron-app' +import { openTrustedWorkspace } from './fixtures/workspace' + +interface FakeLaunch { + phase: 'start' | 'resume' + pid: number + terminalId: string + sessionId: string + profile: string + args: string[] +} + +function fakeLaunches(logPath: string): FakeLaunch[] { + try { + return readFileSync(logPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as FakeLaunch) + } catch { + return [] + } +} + +function stopFakeLaunches(entries: FakeLaunch[]): void { + for (const entry of entries) { + try { + process.kill(entry.pid, 'SIGTERM') + } catch { + // The PTY may already have terminated the fixture. + } + } +} + +function killUserDataProcesses(userDataDir: string): void { + if (process.platform !== 'win32') return + const escaped = userDataDir.toLowerCase().replaceAll("'", "''") + const script = [ + `$needle = '${escaped}';`, + '$all = @(Get-CimInstance Win32_Process);', + '$targets = @($all | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and $_.CommandLine.ToLower().Contains($needle) });', + '$ids = @($targets.ProcessId) + @($targets.ParentProcessId);', + "$all | Where-Object { $ids -contains $_.ProcessId -and $_.ExecutablePath -and $_.ExecutablePath.ToLower().EndsWith('\\electron.exe') }", + '| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }', + ].join(' ') + try { + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: 'ignore', + windowsHide: true, + }) + } catch { + // Only stale Chromium helpers should remain after the Electron main process exits. + } +} + +async function closeTestApp(app: ElectronApplication, userDataDir: string): Promise { + const pid = app.process().pid + await closeApp(app) + if (process.platform === 'win32' && pid) { + try { + execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }) + } catch { + // A clean Electron shutdown removes the process before taskkill runs. + } + killUserDataProcesses(userDataDir) + } +} + +async function panelSessions(page: Page, ids: string[]) { + return page.evaluate((panelIdsToRead) => { + const panels = window.__cateE2E!.panels() + return panelIdsToRead.map((id) => panels.find((panel) => panel.id === id)?.agentSession ?? null) + }, ids) +} + +test('two Hermes terminals cold-restore their own exact profile-scoped sessions', async () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cate-hermes-resume-e2e-'))) + const workspace = path.join(root, 'workspace') + const userDataDir = path.join(root, 'userdata') + const binDir = path.join(root, 'bin') + const logPath = path.join(root, 'fake-hermes.jsonl') + mkdirSync(workspace, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + + const fixture = path.resolve(__dirname, 'fixtures', 'fake-hermes-session.cjs') + const launcher = path.join(binDir, 'hermes') + writeFileSync(launcher, `#!/bin/sh\nexec "${process.execPath}" "${fixture}" "$@"\n`) + chmodSync(launcher, 0o755) + writeFileSync( + path.join(binDir, 'hermes.cmd'), + `@echo off\r\n"${process.execPath}" "${fixture}" %*\r\n`, + ) + + const system32 = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + const env = { + // Prefer Windows' bsdtar over Git-Bash tar: the latter parses `C:\\...` + // archive paths as remote hosts during the E2E runtime bootstrap. + PATH: `${binDir}${path.delimiter}${system32}${path.delimiter}${process.env.PATH ?? ''}`, + FAKE_HERMES_LOG: logPath, + } + let app: ElectronApplication | undefined + let page: Page + let passed = false + + try { + ;({ electronApp: app, mainWindow: page } = await launchApp({ userDataDir, env, empty: true })) + await openTrustedWorkspace(page, workspace) + await page.evaluate(() => window.__cateE2E!.createPanel('canvas')) + await page.waitForSelector('[data-canvas-panel-id]') + await resetViewport(page) + + const nodes: string[] = [] + for (const point of [{ x: 100, y: 100 }, { x: 800, y: 100 }]) { + const node = await seedTerminal(page, point) + await page.waitForFunction( + (id) => !!window.__cateE2E!.terminalPtyId(id), + node, + ) + nodes.push(node) + } + const panels = await page.evaluate((ids) => { + const liveNodes = window.__cateE2E!.nodes() + return ids.map((id) => liveNodes.find((node) => node.id === id)!.panelId) + }, nodes) + const terminalIds = await page.evaluate( + (ids) => ids.map((id) => window.__cateE2E!.terminalPtyId(id)!), + nodes, + ) + const profiles = ['work', 'default'] as const + + for (const [index, node] of nodes.entries()) { + await page.evaluate( + ({ id, profile }) => window.__cateE2E!.writeTerminal(id, `hermes --profile ${profile}\r`), + { id: node, profile: profiles[index] }, + ) + } + for (const node of nodes) { + await expect.poll( + () => page.evaluate((id) => window.__cateE2E!.terminalText(id), node), + ).toContain('FAKE_HERMES_READY') + } + + await expect.poll(() => panelSessions(page, panels)).toEqual([ + expect.objectContaining({ agentId: 'hermes', profile: 'work' }), + expect.objectContaining({ agentId: 'hermes', profile: 'default' }), + ]) + const before = await panelSessions(page, panels) + const sessionIds = before.map((session) => session!.sessionId) + expect(new Set(sessionIds).size).toBe(2) + + // Give the normal autosave debounce time to persist the stamped panels. + await page.waitForTimeout(1_000) + await closeTestApp(app, userDataDir) + app = undefined + stopFakeLaunches(fakeLaunches(logPath).filter((entry) => entry.phase === 'start')) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const savedSession = readFileSync(path.join(workspace, '.cate', 'session.json'), 'utf8') + for (const sessionId of sessionIds) expect(savedSession).toContain(sessionId) + for (const profile of profiles) expect(savedSession).toContain(`"profile": "${profile}"`) + writeFileSync(path.join(root, 'first-session.json'), savedSession) + writeFileSync( + path.join(root, 'first-workspace.json'), + readFileSync(path.join(workspace, '.cate', 'workspace.json'), 'utf8'), + ) + + ;({ electronApp: app, mainWindow: page } = await launchApp({ userDataDir, env, empty: true })) + await page.waitForFunction( + () => performance.getEntriesByName('session-restored').length > 0, + undefined, + { timeout: 30_000 }, + ) + for (const node of nodes) { + await page.waitForSelector(`[data-node-id="${node}"]`, { timeout: 30_000 }) + } + await resetViewport(page) + for (const node of nodes) { + await page.waitForFunction( + (id) => !!window.__cateE2E!.terminalPtyId(id), + node, + { timeout: 30_000 }, + ) + } + const restoredTerminalIds = await page.evaluate( + (ids) => ids.map((id) => window.__cateE2E!.terminalPtyId(id)!), + nodes, + ) + expect(restoredTerminalIds).toEqual(terminalIds) + await expect.poll( + () => fakeLaunches(logPath).filter((entry) => entry.phase === 'resume'), + { timeout: 30_000 }, + ).toHaveLength(2) + + const resumed = fakeLaunches(logPath).filter((entry) => entry.phase === 'resume') + for (const [index, terminalId] of restoredTerminalIds.entries()) { + const entry = resumed.find((candidate) => candidate.terminalId === terminalId) + expect(entry).toMatchObject({ + terminalId, + sessionId: sessionIds[index], + profile: profiles[index], + }) + expect(entry?.args).toEqual([ + '--profile', + profiles[index], + 'chat', + '--resume', + sessionIds[index], + ]) + } + stopFakeLaunches(resumed) + await new Promise((resolve) => setTimeout(resolve, 500)) + passed = true + } finally { + if (app) await closeTestApp(app, userDataDir) + stopFakeLaunches(fakeLaunches(logPath)) + if (passed) { + try { + rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }) + } catch (error) { + console.warn(`Could not remove Hermes E2E fixture ${root}: ${String(error)}`) + } + } else { + console.log(`Preserved failing Hermes E2E fixture at ${root}`) + } + } +}) diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md new file mode 100644 index 000000000..4203817ac --- /dev/null +++ b/integrations/hermes/README.md @@ -0,0 +1,46 @@ +# Hermes Agent integration + +Cate can restore each terminal to the exact Hermes conversation it previously hosted. The integration uses a small Hermes plugin installed into one Hermes profile. The plugin is inert outside Cate terminals. + +## Install + +From the Cate checkout: + +```bash +bun run hermes:integration -- install --profile work +``` + +Restart any running Hermes CLI after installation so it loads the plugin. + +Check status: + +```bash +bun run hermes:integration -- status --profile work +``` + +Remove it: + +```bash +bun run hermes:integration -- uninstall --profile work +``` + +`--profile` is required. Use `--profile default` explicitly for Hermes's default profile; arbitrary custom `HERMES_HOME` layouts are not eligible for automatic restore. The installer removes any ambient `HERMES_HOME` override from its Hermes subprocesses so the selected profile always resolves under Hermes's canonical profile root. + +## How it works + +1. Cate places a loopback endpoint, per-terminal bearer token, and terminal ID in every PTY environment. +2. The `cate-agent-state` plugin reports root CLI lifecycle hooks to that endpoint. +3. Cate saves the Hermes session ID and canonical profile with the terminal panel. +4. A restored panel starts a fresh shell and runs `hermes --profile chat --resume `. + +Exact session IDs are used instead of bare `--continue`, so two terminals in the same directory cannot accidentally converge on the newest conversation. + +## Safety and limits + +- The endpoint must be loopback HTTP and is authenticated with Cate's per-terminal token. +- Processes launched inside a Cate terminal inherit that token and share the terminal's trust boundary; the token isolates terminals, not processes within one terminal. +- The plugin ignores subagent, cron, and gateway sessions. +- Hook delivery is synchronous but best-effort, with a 750 ms timeout; failures never abort the Hermes session. +- Cate accepts no command string from the plugin; session and profile values must be shell-safe tokens. +- Install separately for every named Hermes profile used inside Cate. +- Custom `HERMES_HOME` layouts report Hermes profile `custom` and are intentionally not stamped for automatic restore. diff --git a/integrations/hermes/cate-agent-state/.cate-managed.json b/integrations/hermes/cate-agent-state/.cate-managed.json new file mode 100644 index 000000000..78d287ed0 --- /dev/null +++ b/integrations/hermes/cate-agent-state/.cate-managed.json @@ -0,0 +1 @@ +{"schema":1,"owner":"Cate","plugin":"cate-agent-state"} diff --git a/integrations/hermes/cate-agent-state/__init__.py b/integrations/hermes/cate-agent-state/__init__.py new file mode 100644 index 000000000..cff280d1a --- /dev/null +++ b/integrations/hermes/cate-agent-state/__init__.py @@ -0,0 +1,105 @@ +"""Hermes lifecycle bridge for Cate terminal session restoration.""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Callable +from urllib.parse import urlparse +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener + +PLUGIN_ID = "cate-agent-state" +PLUGIN_VERSION = "1.0.0" +_INTERACTIVE_PLATFORMS = frozenset({"cli", "tui"}) +_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_HOOKS = ( + "on_session_start", + "on_session_reset", + "pre_llm_call", + "on_session_end", + "on_session_finalize", +) + + +class _RejectRedirects(HTTPRedirectHandler): + """Never forward Cate's terminal bearer token to a redirected endpoint.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +# Ignore ambient HTTP(S)_PROXY settings: this transport is loopback-only. +_OPENER = build_opener(ProxyHandler({}), _RejectRedirects()) + + +def _cate_connection() -> tuple[str, str, str] | None: + endpoint = os.environ.get("CATE_HOOK_ENDPOINT", "") + token = os.environ.get("CATE_HOOK_TOKEN", "") + terminal_id = os.environ.get("CATE_TERMINAL_ID", "") + if not endpoint or not token or not terminal_id: + return None + try: + parsed = urlparse(endpoint) + except ValueError: + return None + if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "::1"}: + return None + return endpoint, token, terminal_id + + +def _report(hook_event_name: str, profile: str, **kwargs: Any) -> None: + connection = _cate_connection() + if connection is None: + return + if profile == "custom" or not _PROFILE_RE.fullmatch(profile): + return + platform = str(kwargs.get("platform") or "") + if platform and platform not in _INTERACTIVE_PLATFORMS: + return + endpoint, token, terminal_id = connection + try: + cwd = os.getcwd() + except OSError: + cwd = "" + body = { + "agentId": "hermes", + "terminalId": terminal_id, + "pid": os.getpid(), + "payload": { + "hook_event_name": hook_event_name, + "session_id": kwargs.get("session_id"), + "cwd": cwd, + "profile": profile, + "platform": platform, + "reason": kwargs.get("reason"), + }, + } + try: + request = Request( + endpoint, + data=json.dumps(body, separators=(",", ":")).encode("utf-8"), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + with _OPENER.open(request, timeout=0.75) as response: + response.read(1) + except Exception: + # Hooks must never delay or break the user's Hermes session. + return + + +def _callback(name: str, profile: str) -> Callable[..., None]: + def report(**kwargs: Any) -> None: + _report(name, profile, **kwargs) + + return report + + +def register(ctx: Any) -> None: + profile = str(getattr(ctx, "profile_name", "custom")) + for hook_name in _HOOKS: + ctx.register_hook(hook_name, _callback(hook_name, profile)) diff --git a/integrations/hermes/cate-agent-state/plugin.yaml b/integrations/hermes/cate-agent-state/plugin.yaml new file mode 100644 index 000000000..941fe2ca3 --- /dev/null +++ b/integrations/hermes/cate-agent-state/plugin.yaml @@ -0,0 +1,10 @@ +name: cate-agent-state +version: 1.0.0 +description: "Report root Hermes CLI session lifecycle to Cate for exact terminal restore." +author: "TheXRMonk" +provides_hooks: + - on_session_start + - on_session_reset + - pre_llm_call + - on_session_end + - on_session_finalize diff --git a/package.json b/package.json index d8550fdf3..b9d5cb843 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "test:runtime:linux-glibc": "node scripts/test-linux-node-pty-baseline.mjs", "test:agent-contracts": "CATE_LIVE_AGENT_CLIS=1 vitest run --config vitest.live.config.ts agentHookContracts", "test:agent-prompt-context": "CATE_LIVE_AGENT_CLIS=1 vitest run --config vitest.live.config.ts agentHookContracts -t 'Cate native.*context reaches the model'", + "test:hermes-integration": "vitest run scripts/hermes-integration.test.mjs && python scripts/test_hermes_integration_asset.py", + "hermes:integration": "node scripts/hermes-integration.mjs", "test:e2e": "playwright test", "test:e2e:perf": "node scripts/run-e2e-perf.mjs", "test:e2e:agent": "playwright test e2e/t3-agent.spec.ts", diff --git a/scripts/hermes-integration.mjs b/scripts/hermes-integration.mjs new file mode 100644 index 000000000..7a1e71dd4 --- /dev/null +++ b/scripts/hermes-integration.mjs @@ -0,0 +1,377 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { constants as fsConstants } from 'node:fs' +import { + copyFile, + lstat, + mkdir, + mkdtemp, + open, + realpath, + rename, + rm, +} from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +export const PLUGIN_ID = 'cate-agent-state' +export const OWNER_FILE = '.cate-managed.json' +export const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}\n' +const SAFE_PROFILE = /^[a-z0-9][a-z0-9_-]{0,63}$/ +const HERMES_COMMAND_TIMEOUT_MS = 30_000 +const execFileAsync = promisify(execFile) +const defaultSourceDir = fileURLToPath( + new URL('../integrations/hermes/cate-agent-state', import.meta.url), +) + +function profileArgs(profile) { + if (profile === undefined) throw new Error('Hermes profile is required') + if (profile === 'custom' || !SAFE_PROFILE.test(profile)) throw new Error(`invalid Hermes profile: ${profile}`) + return ['--profile', profile] +} + +export function hermesSubprocessEnv(env = process.env) { + return Object.fromEntries( + Object.entries(env).filter(([key]) => key.toUpperCase() !== 'HERMES_HOME'), + ) +} + +async function defaultRunHermes(args) { + const hermesBin = process.env.HERMES_BIN || 'hermes' + const { stdout, stderr } = await execFileAsync(hermesBin, args, { + env: hermesSubprocessEnv(), + windowsHide: true, + maxBuffer: 4 * 1024 * 1024, + timeout: HERMES_COMMAND_TIMEOUT_MS, + killSignal: 'SIGKILL', + }) + return { stdout, stderr } +} + +async function hermesHome(profile, runHermes) { + const { stdout } = await runHermes([...profileArgs(profile), 'config', 'path']) + const configPath = String(stdout).trim() + if (!configPath || !path.isAbsolute(configPath)) { + throw new Error(`Hermes returned an invalid config path: ${configPath || '(empty)'}`) + } + return path.dirname(configPath) +} + +function samePath(left, right) { + const normalize = (value) => process.platform === 'win32' + ? path.resolve(value).toLowerCase() + : path.resolve(value) + return normalize(left) === normalize(right) +} + +function sameFileIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino +} + +async function hasExactOwnershipSentinel(markerPath) { + const expectedSize = Buffer.byteLength(OWNER_SENTINEL) + let before + try { + before = await lstat(markerPath) + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } + if (!before.isFile() || before.isSymbolicLink() || before.size !== expectedSize) return false + + // O_NOFOLLOW closes the lstat/open race on POSIX. On Windows, comparing the + // lstat and opened-file identities rejects a link swapped in between calls. + const noFollow = process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0) + let handle + try { + handle = await open(markerPath, fsConstants.O_RDONLY | noFollow) + const opened = await handle.stat() + if (!opened.isFile() || opened.size !== expectedSize || !sameFileIdentity(before, opened)) return false + const value = await handle.readFile('utf8') + const after = await lstat(markerPath) + return ( + value === OWNER_SENTINEL && + after.isFile() && + !after.isSymbolicLink() && + after.size === expectedSize && + sameFileIdentity(before, after) + ) + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ELOOP') return false + throw error + } finally { + await handle?.close() + } +} + +async function ensureSafePluginsDir(home, pluginsDir) { + if (!samePath(pluginsDir, path.join(home, 'plugins'))) { + throw new Error(`unsafe Hermes plugins path: ${pluginsDir}`) + } + await mkdir(pluginsDir, { recursive: true }) + const info = await lstat(pluginsDir) + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error(`unsafe Hermes plugins directory: ${pluginsDir}`) + } + const [homeReal, pluginsReal] = await Promise.all([realpath(home), realpath(pluginsDir)]) + if (!samePath(path.dirname(pluginsReal), homeReal)) { + throw new Error(`Hermes plugins directory escapes its profile home: ${pluginsDir}`) + } +} + +async function ownership(home, targetDir) { + const pluginsDir = path.join(home, 'plugins') + if (!samePath(targetDir, path.join(pluginsDir, PLUGIN_ID))) { + throw new Error(`unsafe Hermes plugin target: ${targetDir}`) + } + + let pluginsInfo + try { + pluginsInfo = await lstat(pluginsDir) + } catch (error) { + if (error?.code === 'ENOENT') return 'missing' + throw error + } + if (!pluginsInfo.isDirectory() || pluginsInfo.isSymbolicLink()) return 'foreign' + const [homeReal, pluginsReal] = await Promise.all([realpath(home), realpath(pluginsDir)]) + if (!samePath(path.dirname(pluginsReal), homeReal)) return 'foreign' + + let info + try { + info = await lstat(targetDir) + } catch (error) { + if (error?.code === 'ENOENT') return 'missing' + throw error + } + if (!info.isDirectory() || info.isSymbolicLink()) return 'foreign' + + const targetReal = await realpath(targetDir) + if (!samePath(path.dirname(targetReal), pluginsReal)) return 'foreign' + + return await hasExactOwnershipSentinel(path.join(targetDir, OWNER_FILE)) ? 'managed' : 'foreign' +} + +async function enabled(profile, runHermes) { + const { stdout } = await runHermes([ + ...profileArgs(profile), + 'plugins', + 'list', + '--enabled', + '--json', + ]) + let rows + try { + rows = JSON.parse(String(stdout)) + } catch { + throw new Error('Hermes returned invalid JSON from plugins list') + } + return Array.isArray(rows) && rows.some( + (row) => row?.name === PLUGIN_ID && row?.status === 'enabled', + ) +} + +async function disableAndVerify(profile, runHermes) { + await runHermes([...profileArgs(profile), 'plugins', 'disable', PLUGIN_ID]) + if (await enabled(profile, runHermes)) { + throw new Error(`Hermes still reports ${PLUGIN_ID} as enabled`) + } +} + +export async function statusHermesIntegration({ + profile, + runHermes = defaultRunHermes, +} = {}) { + const home = await hermesHome(profile, runHermes) + const targetDir = path.join(home, 'plugins', PLUGIN_ID) + const owner = await ownership(home, targetDir) + if (owner === 'missing') return { state: 'missing', profile, home, targetDir } + if (owner === 'foreign') return { state: 'foreign', profile, home, targetDir } + return { + state: await enabled(profile, runHermes) ? 'enabled' : 'disabled', + profile, + home, + targetDir, + } +} + +export async function installHermesIntegration({ + profile, + sourceDir = defaultSourceDir, + runHermes = defaultRunHermes, + renamePath = rename, +} = {}) { + profileArgs(profile) + const home = await hermesHome(profile, runHermes) + const pluginsDir = path.join(home, 'plugins') + const targetDir = path.join(pluginsDir, PLUGIN_ID) + await ensureSafePluginsDir(home, pluginsDir) + const owner = await ownership(home, targetDir) + if (owner === 'foreign') { + throw new Error(`${targetDir} exists and is not managed by Cate`) + } + const wasEnabled = owner === 'managed' && await enabled(profile, runHermes) + + if (!(await hasExactOwnershipSentinel(path.join(sourceDir, OWNER_FILE)))) { + throw new Error(`invalid Cate ownership sentinel in ${sourceDir}`) + } + + const stagingDir = await mkdtemp(path.join(pluginsDir, `.${PLUGIN_ID}-`)) + const backupDir = path.join(pluginsDir, `.${PLUGIN_ID}-backup-${randomUUID()}`) + let backedUp = false + let replacementInstalled = false + try { + await copyFile(path.join(sourceDir, 'plugin.yaml'), path.join(stagingDir, 'plugin.yaml')) + await copyFile(path.join(sourceDir, '__init__.py'), path.join(stagingDir, '__init__.py')) + await copyFile(path.join(sourceDir, OWNER_FILE), path.join(stagingDir, OWNER_FILE)) + if (owner === 'managed') { + await renamePath(targetDir, backupDir) + backedUp = true + } + await renamePath(stagingDir, targetDir) + replacementInstalled = true + + await runHermes([ + ...profileArgs(profile), + 'plugins', + 'enable', + PLUGIN_ID, + '--no-allow-tool-override', + ]) + if (!(await enabled(profile, runHermes))) { + throw new Error(`Hermes did not report ${PLUGIN_ID} as enabled`) + } + } catch (error) { + const rollbackErrors = [] + if (replacementInstalled && !wasEnabled) { + try { + await disableAndVerify(profile, runHermes) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + try { + await rm(stagingDir, { recursive: true, force: true }) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + if (replacementInstalled) { + try { + await rm(targetDir, { recursive: true, force: true }) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + let restoredBackup = false + if (backedUp) { + try { + await renamePath(backupDir, targetDir) + restoredBackup = true + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (restoredBackup && wasEnabled) { + try { + await runHermes([ + ...profileArgs(profile), + 'plugins', + 'enable', + PLUGIN_ID, + '--no-allow-tool-override', + ]) + if (!(await enabled(profile, runHermes))) { + throw new Error(`Hermes did not restore ${PLUGIN_ID} to enabled`) + } + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + `failed to install ${PLUGIN_ID} and fully restore its previous state`, + ) + } + throw error + } + if (backedUp) await rm(backupDir, { recursive: true, force: true }) + return { state: 'enabled', profile, home, targetDir } +} + +export async function uninstallHermesIntegration({ + profile, + sourceDir = defaultSourceDir, + runHermes = defaultRunHermes, +} = {}) { + profileArgs(profile) + const home = await hermesHome(profile, runHermes) + const pluginsDir = path.join(home, 'plugins') + const targetDir = path.join(pluginsDir, PLUGIN_ID) + const owner = await ownership(home, targetDir) + if (owner === 'foreign') { + throw new Error(`${targetDir} exists and is not managed by Cate`) + } + if (owner === 'managed') { + await disableAndVerify(profile, runHermes) + await rm(targetDir, { recursive: true, force: true }) + return { state: 'missing', profile, home, targetDir } + } + + // Hermes resolves `plugins disable` through installed manifests. If files + // were deleted manually while the allow-list entry remained, materialize a + // temporary managed copy so the CLI can remove that stale configuration. + await ensureSafePluginsDir(home, pluginsDir) + if (!(await hasExactOwnershipSentinel(path.join(sourceDir, OWNER_FILE)))) { + throw new Error(`invalid Cate ownership sentinel in ${sourceDir}`) + } + const stagingDir = await mkdtemp(path.join(pluginsDir, `.${PLUGIN_ID}-uninstall-`)) + let temporaryInstalled = false + try { + await copyFile(path.join(sourceDir, 'plugin.yaml'), path.join(stagingDir, 'plugin.yaml')) + await copyFile(path.join(sourceDir, '__init__.py'), path.join(stagingDir, '__init__.py')) + await copyFile(path.join(sourceDir, OWNER_FILE), path.join(stagingDir, OWNER_FILE)) + await rename(stagingDir, targetDir) + temporaryInstalled = true + await disableAndVerify(profile, runHermes) + } finally { + await rm(stagingDir, { recursive: true, force: true }) + if (temporaryInstalled) await rm(targetDir, { recursive: true, force: true }) + } + return { state: 'missing', profile, home, targetDir } +} + +function parseCli(argv) { + const [action, ...rest] = argv + if (!['install', 'status', 'uninstall'].includes(action)) { + throw new Error('usage: hermes-integration.mjs install|status|uninstall [--profile ]') + } + let profile + for (let index = 0; index < rest.length; index += 1) { + if (rest[index] !== '--profile' || !rest[index + 1] || index + 2 !== rest.length) { + throw new Error('usage: hermes-integration.mjs install|status|uninstall [--profile ]') + } + profile = rest[++index] + } + profileArgs(profile) + return { action, profile } +} + +async function main() { + const { action, profile } = parseCli(process.argv.slice(2)) + const options = { profile } + const result = action === 'install' + ? await installHermesIntegration(options) + : action === 'uninstall' + ? await uninstallHermesIntegration(options) + : await statusHermesIntegration(options) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write(`cate Hermes integration: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/hermes-integration.test.mjs b/scripts/hermes-integration.test.mjs new file mode 100644 index 000000000..2e603ff1f --- /dev/null +++ b/scripts/hermes-integration.test.mjs @@ -0,0 +1,319 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + hermesSubprocessEnv, + installHermesIntegration, + statusHermesIntegration, + uninstallHermesIntegration, +} from './hermes-integration.mjs' + +const roots = [] +const OWNER_FILE = '.cate-managed.json' +const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}\n' + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'cate-hermes-integration-')) + roots.push(root) + const sourceDir = path.join(root, 'source') + const home = path.join(root, 'hermes', 'profiles', 'work') + await mkdir(sourceDir, { recursive: true }) + await mkdir(home, { recursive: true }) + await writeFile(path.join(sourceDir, 'plugin.yaml'), 'name: cate-agent-state\n') + await writeFile( + path.join(sourceDir, '__init__.py'), + 'PLUGIN_ID = "cate-agent-state"\nPLUGIN_VERSION = "1.0.0"\n', + ) + await writeFile(path.join(sourceDir, OWNER_FILE), OWNER_SENTINEL) + return { root, sourceDir, home } +} + +function fakeHermes(home, initiallyEnabled = true, { mutate = true } = {}) { + const calls = [] + let isEnabled = initiallyEnabled + const runHermes = async (args) => { + calls.push(args) + if (args.at(-2) === 'config' && args.at(-1) === 'path') { + return { stdout: path.join(home, 'config.yaml'), stderr: '' } + } + if (args.includes('enable') && mutate) isEnabled = true + if (args.includes('disable') && mutate) isEnabled = false + if (args.includes('list')) { + return { + stdout: JSON.stringify(isEnabled ? [{ name: 'cate-agent-state', status: 'enabled' }] : []), + stderr: '', + } + } + return { stdout: '', stderr: '' } + } + return { calls, runHermes } +} + +describe('Hermes integration manager', () => { + it('installs, enables, and verifies the managed plugin in the requested profile', async () => { + const { sourceDir, home } = await fixture() + const fake = fakeHermes(home) + + const result = await installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fake.runHermes, + }) + + expect(result).toMatchObject({ state: 'enabled', profile: 'work' }) + expect(await readFile(path.join(home, 'plugins', 'cate-agent-state', 'plugin.yaml'), 'utf8')) + .toContain('name: cate-agent-state') + expect(await readFile(path.join(home, 'plugins', 'cate-agent-state', OWNER_FILE), 'utf8')) + .toBe(OWNER_SENTINEL) + expect(fake.calls).toEqual([ + ['--profile', 'work', 'config', 'path'], + ['--profile', 'work', 'plugins', 'enable', 'cate-agent-state', '--no-allow-tool-override'], + ['--profile', 'work', 'plugins', 'list', '--enabled', '--json'], + ]) + }) + + it('refuses to overwrite a foreign plugin directory', async () => { + const { sourceDir, home } = await fixture() + const target = path.join(home, 'plugins', 'cate-agent-state') + await mkdir(target, { recursive: true }) + // A source-code substring is not an ownership proof. + await writeFile(path.join(target, '__init__.py'), 'PLUGIN_ID = "cate-agent-state"\n') + const fake = fakeHermes(home) + + await expect(installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fake.runHermes, + })).rejects.toThrow('not managed by Cate') + + expect(fake.calls).toEqual([['--profile', 'work', 'config', 'path']]) + }) + + it.skipIf(process.platform === 'win32')('refuses a linked ownership marker', async () => { + const { root, sourceDir, home } = await fixture() + const target = path.join(home, 'plugins', 'cate-agent-state') + const externalMarker = path.join(root, 'foreign-marker') + await mkdir(target, { recursive: true }) + await writeFile(externalMarker, OWNER_SENTINEL) + await symlink(externalMarker, path.join(target, OWNER_FILE), 'file') + const fake = fakeHermes(home) + + await expect(installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fake.runHermes, + })).rejects.toThrow('not managed by Cate') + }) + + it('refuses an oversized ownership marker', async () => { + const { sourceDir, home } = await fixture() + const target = path.join(home, 'plugins', 'cate-agent-state') + await mkdir(target, { recursive: true }) + await writeFile(path.join(target, OWNER_FILE), `${OWNER_SENTINEL}unexpected`) + const fake = fakeHermes(home) + + await expect(installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fake.runHermes, + })).rejects.toThrow('not managed by Cate') + }) + + it('refuses to follow a plugin-directory junction outside the profile', async () => { + const { root, sourceDir, home } = await fixture() + const foreign = path.join(root, 'foreign-plugin') + const target = path.join(home, 'plugins', 'cate-agent-state') + await mkdir(foreign, { recursive: true }) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(path.join(foreign, OWNER_FILE), OWNER_SENTINEL) + await symlink(foreign, target, 'junction') + const fake = fakeHermes(home) + + await expect(installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fake.runHermes, + })).rejects.toThrow('not managed by Cate') + + expect(await readFile(path.join(foreign, OWNER_FILE), 'utf8')).toBe(OWNER_SENTINEL) + }) + + it('refuses to uninstall through a symlinked plugins directory', async () => { + const { root, home } = await fixture() + const externalPlugins = path.join(root, 'external-plugins') + const externalTarget = path.join(externalPlugins, 'cate-agent-state') + await mkdir(externalTarget, { recursive: true }) + await writeFile(path.join(externalTarget, OWNER_FILE), OWNER_SENTINEL) + await symlink(externalPlugins, path.join(home, 'plugins'), 'junction') + const fake = fakeHermes(home) + + await expect(uninstallHermesIntegration({ + profile: 'work', + runHermes: fake.runHermes, + })).rejects.toThrow('not managed by Cate') + + expect(await readFile(path.join(externalTarget, OWNER_FILE), 'utf8')).toBe(OWNER_SENTINEL) + expect(fake.calls).toEqual([['--profile', 'work', 'config', 'path']]) + }) + + it('rejects missing and non-canonical profile identifiers before invoking Hermes', async () => { + const runHermes = async () => { + throw new Error('must not run') + } + + await expect(statusHermesIntegration({ runHermes })) + .rejects.toThrow('Hermes profile is required') + for (const profile of ['Work', '../work', 'work.profile', 'custom', 'a'.repeat(65)]) { + await expect(statusHermesIntegration({ profile, runHermes })) + .rejects.toThrow(`invalid Hermes profile: ${profile}`) + } + }) + + it('removes ambient custom-home overrides from Hermes subprocesses', () => { + const parentEnv = { + PATH: 'C:/tools', + HERMES_BIN: 'C:/tools/hermes.exe', + HERMES_HOME: 'C:/custom-home', + Hermes_Home: 'C:/case-insensitive-custom-home', + } + + expect(hermesSubprocessEnv(parentEnv)).toEqual({ + PATH: 'C:/tools', + HERMES_BIN: 'C:/tools/hermes.exe', + }) + expect(parentEnv.HERMES_HOME).toBe('C:/custom-home') + expect(parentEnv.Hermes_Home).toBe('C:/case-insensitive-custom-home') + }) + + it('restores the previous managed plugin when replacement fails', async () => { + const { sourceDir, home } = await fixture() + const fake = fakeHermes(home) + await installHermesIntegration({ profile: 'work', sourceDir, runHermes: fake.runHermes }) + const target = path.join(home, 'plugins', 'cate-agent-state') + await writeFile(path.join(sourceDir, '__init__.py'), 'PLUGIN_VERSION = "2.0.0"\n') + + let failed = false + const failingRename = async (from, to) => { + if (!failed && to === target && path.basename(from).startsWith('.cate-agent-state-')) { + failed = true + throw new Error('simulated replacement failure') + } + return rename(from, to) + } + + await expect(installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fake.runHermes, + renamePath: failingRename, + })).rejects.toThrow('simulated replacement failure') + + expect(await readFile(path.join(target, '__init__.py'), 'utf8')) + .toContain('PLUGIN_VERSION = "1.0.0"') + expect((await readdir(path.dirname(target))).filter((name) => name.includes('backup'))) + .toEqual([]) + }) + + it('restores the previous managed plugin when Hermes rejects the replacement', async () => { + const { sourceDir, home } = await fixture() + await installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fakeHermes(home).runHermes, + }) + const target = path.join(home, 'plugins', 'cate-agent-state') + await writeFile(path.join(sourceDir, '__init__.py'), 'PLUGIN_VERSION = "2.0.0"\n') + + await expect(installHermesIntegration({ + profile: 'work', + sourceDir, + runHermes: fakeHermes(home, false, { mutate: false }).runHermes, + })).rejects.toThrow('did not report cate-agent-state as enabled') + + expect(await readFile(path.join(target, '__init__.py'), 'utf8')) + .toContain('PLUGIN_VERSION = "1.0.0"') + expect((await readdir(path.dirname(target))).filter((name) => name.includes('backup'))) + .toEqual([]) + }) + + it('restores a disabled activation state when a fresh install fails after enablement', async () => { + const { sourceDir, home } = await fixture() + let isEnabled = false + const calls = [] + const runHermes = async (args) => { + calls.push(args) + if (args.at(-2) === 'config' && args.at(-1) === 'path') { + return { stdout: path.join(home, 'config.yaml'), stderr: '' } + } + if (args.includes('enable')) { + isEnabled = true + return { stdout: '', stderr: '' } + } + if (args.includes('disable')) { + isEnabled = false + return { stdout: '', stderr: '' } + } + if (args.includes('list')) return { stdout: '[]', stderr: '' } + return { stdout: '', stderr: '' } + } + + await expect(installHermesIntegration({ profile: 'work', sourceDir, runHermes })) + .rejects.toThrow('did not report cate-agent-state as enabled') + + expect(isEnabled).toBe(false) + expect(calls).toContainEqual([ + '--profile', 'work', 'plugins', 'disable', 'cate-agent-state', + ]) + }) + + it('reconciles stale enabled configuration when managed plugin files are missing', async () => { + const { sourceDir, home } = await fixture() + let isEnabled = true + const calls = [] + const runHermes = async (args) => { + calls.push(args) + if (args.at(-2) === 'config' && args.at(-1) === 'path') { + return { stdout: path.join(home, 'config.yaml'), stderr: '' } + } + if (args.includes('disable')) { + isEnabled = false + return { stdout: '', stderr: '' } + } + if (args.includes('list')) { + return { + stdout: JSON.stringify(isEnabled ? [{ name: 'cate-agent-state', status: 'enabled' }] : []), + stderr: '', + } + } + return { stdout: '', stderr: '' } + } + + await expect(uninstallHermesIntegration({ profile: 'work', sourceDir, runHermes })) + .resolves.toMatchObject({ state: 'missing', profile: 'work' }) + expect(isEnabled).toBe(false) + expect(calls).toContainEqual([ + '--profile', 'work', 'plugins', 'disable', 'cate-agent-state', + ]) + }) + + it('reports enabled status and removes only a Cate-managed plugin', async () => { + const { sourceDir, home } = await fixture() + const fake = fakeHermes(home) + await installHermesIntegration({ profile: 'work', sourceDir, runHermes: fake.runHermes }) + + await expect(statusHermesIntegration({ profile: 'work', runHermes: fake.runHermes })) + .resolves.toMatchObject({ state: 'enabled', profile: 'work' }) + await expect(uninstallHermesIntegration({ profile: 'work', runHermes: fake.runHermes })) + .resolves.toMatchObject({ state: 'missing', profile: 'work' }) + await expect(readFile(path.join(home, 'plugins', 'cate-agent-state', '__init__.py'), 'utf8')) + .rejects.toThrow() + expect(fake.calls).toContainEqual([ + '--profile', 'work', 'plugins', 'disable', 'cate-agent-state', + ]) + }) +}) diff --git a/scripts/test_hermes_integration_asset.py b/scripts/test_hermes_integration_asset.py new file mode 100644 index 000000000..ddd52b421 --- /dev/null +++ b/scripts/test_hermes_integration_asset.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import unittest +from unittest.mock import MagicMock, patch +from urllib.request import ProxyHandler + + +PLUGIN_PATH = ( + Path(__file__).resolve().parents[1] + / "integrations" + / "hermes" + / "cate-agent-state" + / "__init__.py" +) +MANIFEST_PATH = PLUGIN_PATH.with_name("plugin.yaml") + + +def load_plugin(): + spec = importlib.util.spec_from_file_location("cate_agent_state_test", PLUGIN_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {PLUGIN_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class FakeContext: + def __init__(self, profile_name: str = "work") -> None: + self.hooks: dict[str, object] = {} + self.profile_name = profile_name + + def register_hook(self, name: str, callback) -> None: + self.hooks[name] = callback + + +class CateAgentStatePluginTests(unittest.TestCase): + def test_manifest_declares_provided_hooks(self) -> None: + manifest = MANIFEST_PATH.read_text(encoding="utf-8") + self.assertIn("provides_hooks:", manifest) + self.assertNotIn("\nhooks:", manifest) + + def test_accepts_only_literal_loopback_hook_endpoints(self) -> None: + plugin = load_plugin() + base_env = { + "CATE_HOOK_TOKEN": "secret-token", + "CATE_TERMINAL_ID": "term-1", + } + + with patch.dict( + os.environ, + {**base_env, "CATE_HOOK_ENDPOINT": "http://localhost:43210"}, + clear=False, + ): + self.assertIsNone(plugin._cate_connection()) + + with patch.dict( + os.environ, + {**base_env, "CATE_HOOK_ENDPOINT": "http://[::1]:43210"}, + clear=False, + ): + self.assertIsNotNone(plugin._cate_connection()) + + def test_transport_disables_proxies_and_redirects(self) -> None: + plugin = load_plugin() + proxy_handlers = [ + handler for handler in plugin._OPENER.handlers + if isinstance(handler, ProxyHandler) + ] + self.assertEqual(proxy_handlers, []) + redirect_handlers = [ + handler for handler in plugin._OPENER.handlers + if isinstance(handler, plugin._RejectRedirects) + ] + self.assertEqual(len(redirect_handlers), 1) + self.assertIsNone( + redirect_handlers[0].redirect_request( + None, None, 302, "Found", {}, "http://example.com/leak" + ) + ) + + def test_registers_only_root_session_lifecycle_hooks(self) -> None: + plugin = load_plugin() + ctx = FakeContext() + + plugin.register(ctx) + + self.assertEqual( + set(ctx.hooks), + { + "on_session_start", + "on_session_reset", + "pre_llm_call", + "on_session_end", + "on_session_finalize", + }, + ) + + def test_posts_authenticated_profile_scoped_payload(self) -> None: + plugin = load_plugin() + ctx = FakeContext() + plugin.register(ctx) + response = MagicMock() + response.__enter__.return_value = response + response.__exit__.return_value = False + + with patch.dict( + os.environ, + { + "CATE_HOOK_ENDPOINT": "http://127.0.0.1:43210", + "CATE_HOOK_TOKEN": "secret-token", + "CATE_TERMINAL_ID": "term-1", + }, + clear=False, + ), patch.object(plugin._OPENER, "open", return_value=response) as send: + ctx.hooks["pre_llm_call"]( + session_id="20260911_131500_abcd1234", + platform="cli", + model="test-model", + ) + + request = send.call_args.args[0] + self.assertEqual(request.full_url, "http://127.0.0.1:43210") + self.assertEqual(request.get_header("Authorization"), "Bearer secret-token") + body = json.loads(request.data) + self.assertEqual(body["agentId"], "hermes") + self.assertEqual(body["terminalId"], "term-1") + self.assertEqual(body["pid"], os.getpid()) + self.assertEqual( + body["payload"], + { + "hook_event_name": "pre_llm_call", + "session_id": "20260911_131500_abcd1234", + "cwd": os.getcwd(), + "profile": "work", + "platform": "cli", + "reason": None, + }, + ) + + def test_is_silent_without_cate_environment(self) -> None: + plugin = load_plugin() + ctx = FakeContext() + plugin.register(ctx) + env = { + key: value + for key, value in os.environ.items() + if key not in {"CATE_HOOK_ENDPOINT", "CATE_HOOK_TOKEN", "CATE_TERMINAL_ID"} + } + + with patch.dict(os.environ, env, clear=True), patch.object(plugin._OPENER, "open") as send: + ctx.hooks["on_session_start"](session_id="session-1", platform="cli") + + send.assert_not_called() + + def test_drops_noninteractive_surfaces(self) -> None: + plugin = load_plugin() + ctx = FakeContext() + plugin.register(ctx) + + with patch.dict( + os.environ, + { + "CATE_HOOK_ENDPOINT": "http://127.0.0.1:43210", + "CATE_HOOK_TOKEN": "secret-token", + "CATE_TERMINAL_ID": "term-1", + }, + clear=False, + ), patch.object(plugin._OPENER, "open") as send: + ctx.hooks["pre_llm_call"](session_id="child-1", platform="subagent") + + send.assert_not_called() + + def test_drops_custom_profiles_that_cannot_be_resumed_safely(self) -> None: + plugin = load_plugin() + ctx = FakeContext(profile_name="custom") + plugin.register(ctx) + + with patch.dict( + os.environ, + { + "CATE_HOOK_ENDPOINT": "http://127.0.0.1:43210", + "CATE_HOOK_TOKEN": "secret-token", + "CATE_TERMINAL_ID": "term-1", + }, + clear=False, + ), patch.object(plugin._OPENER, "open") as send: + ctx.hooks["pre_llm_call"](session_id="session-1", platform="cli") + + send.assert_not_called() + + def test_rejects_non_loopback_endpoint_and_swallows_transport_failures(self) -> None: + plugin = load_plugin() + ctx = FakeContext() + plugin.register(ctx) + base_env = { + "CATE_HOOK_TOKEN": "secret-token", + "CATE_TERMINAL_ID": "term-1", + } + + with patch.dict(os.environ, {**base_env, "CATE_HOOK_ENDPOINT": "https://example.com/hook"}, clear=False), \ + patch.object(plugin._OPENER, "open") as send: + ctx.hooks["on_session_start"](session_id="session-1", platform="cli") + send.assert_not_called() + + with patch.dict(os.environ, {**base_env, "CATE_HOOK_ENDPOINT": "http://127.0.0.1:43210"}, clear=False), \ + patch.object(plugin._OPENER, "open", side_effect=OSError("offline")): + ctx.hooks["on_session_start"](session_id="session-1", platform="cli") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/ipc/agentSessionStamps.test.ts b/src/main/ipc/agentSessionStamps.test.ts index 56266e5e8..d8790fa98 100644 --- a/src/main/ipc/agentSessionStamps.test.ts +++ b/src/main/ipc/agentSessionStamps.test.ts @@ -52,8 +52,10 @@ function ev( kind: AgentHookEventKind, sessionId: string | null, cwd?: string, + profile?: string, + sourcePid?: number, ): AgentHookEvent { - return { terminalId, agentId, kind, sessionId, cwd, raw: {} } + return { terminalId, agentId, kind, sessionId, cwd, profile, sourcePid, raw: {} } } const stamps = (terminalId: string) => harness.sent.filter((s) => s.terminalId === terminalId).map((s) => s.session) @@ -106,6 +108,77 @@ describe('kiro resumability gating', () => { }) }) +describe('Hermes resumability gating', () => { + it('waits for the first turn and preserves the named profile', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-start', 'id-1', '/w', 'work')) + expect(stamps(tid)).toEqual([]) + + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work')) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + ]) + }) + + it('clears an intentionally finalized session', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', 'id-1', '/w', 'work')) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + null, + ]) + }) + + it('does not let a delayed finalize erase a newer session', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-2', '/w', 'work')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', 'id-1', '/w', 'work')) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + { agentId: 'hermes', sessionId: 'id-2', cwd: '/w', profile: 'work' }, + ]) + }) + + it('does not let a stale finalize cancel a newer pending cwd lookup', async () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-2', undefined, 'work')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', 'id-1', '/w', 'work')) + + await resolveCwds() + + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + { agentId: 'hermes', sessionId: 'id-2', cwd: '/runtime-cwd', profile: 'work' }, + ]) + }) + + it('ignores an uncorrelated session-end with no session id', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101)) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', null, '/w', 'work', 101)) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + ]) + }) + + it('ignores late events from an older process generation with the same identity', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101)) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 202)) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-end', 'id-1', '/w', 'work', 101)) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', 'id-1', '/w', 'work', 101)) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + ]) + }) + + it('ignores a falling edge from an older process generation', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101)) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 202)) + clearAgentSessionStamp(tid, 101) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', 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..95824ebf2 100644 --- a/src/main/ipc/agentSessionStamps.ts +++ b/src/main/ipc/agentSessionStamps.ts @@ -1,6 +1,6 @@ // ============================================================================= // Agent-session stamping — decides WHEN a terminal's agent session becomes the -// persisted resume stamp ({agentId, sessionId, cwd} on the terminal's +// persisted resume stamp ({agentId, sessionId, cwd, profile?} on the terminal's // PanelState, saved into .cate/session.json and typed back as a resume command // on restore). Emits SHELL_AGENT_SESSION_UPDATE, so the renderer/persistence/ // restore chain is unchanged from the old probe-based producer. @@ -47,6 +47,9 @@ 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, + // on_session_start precedes the first persisted turn; stamp once pre_llm_call + // proves the session is active and resumable. + 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 +60,15 @@ 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 + /** Last stamp emitted for this terminal, used to reject stale end events. */ + session?: TerminalAgentSession | null + /** Latest session identity accepted synchronously, including an event whose + * cwd lookup is still pending. This prevents an older finalize from clearing + * the prior stamp and cancelling the newer lookup. */ + latest?: Pick & { + /** Process generation for in-process hook providers such as Hermes. */ + sourcePid?: number + } /** 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,9 +90,12 @@ 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 + st.session = session sendToWindow(ownerWindowId, SHELL_AGENT_SESSION_UPDATE, terminalId, session) } @@ -92,8 +107,8 @@ function emit(terminalId: string, session: TerminalAgentSession | null): void { * sessionId-bearing event stamps, except a session-start for an agent whose * sessions aren't resumable yet at that point. * - * cwd: the event's own cwd when the payload carries one (claude/codex/ - * opencode); when an event doesn't, the terminal's current cwd is fetched + * cwd: the event's own cwd when the payload carries one; when an event doesn't, + * the terminal's current cwd is fetched * from its runtime. Restore only types ` ` into the * respawned shell (worktree respawn drops the stamp wholesale rather than * comparing cwds), so the stamp's cwd is informational. @@ -102,16 +117,42 @@ 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' && + st.latest?.agentId === 'hermes' && + st.latest.sourcePid !== undefined && + event.sourcePid !== st.latest.sourcePid && + event.kind !== 'session-start' && + event.kind !== 'turn-start' + ) return if (event.kind === 'session-end') { + // An end without an identity cannot safely clear a resumable session. + if (!event.sessionId) return + const current = st.latest ?? st.session ?? undefined + if (current && ( + current.agentId !== event.agentId || + current.sessionId !== event.sessionId || + (event.profile !== undefined && current.profile !== event.profile) || + (st.latest?.sourcePid !== undefined && st.latest.sourcePid !== event.sourcePid) + )) return + st.seq++ + st.latest = undefined emit(terminalId, null) return } if (event.sessionId == null) return + st.seq++ + st.latest = { + agentId: event.agentId, + sessionId: event.sessionId, + ...(event.profile ? { profile: event.profile } : {}), + ...(event.sourcePid !== undefined ? { sourcePid: event.sourcePid } : {}), + } if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) return const { agentId, sessionId } = event + const profile = event.profile ? { profile: event.profile } : {} 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 +160,22 @@ 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): void { const st = stateFor(terminalId) + if ( + endedAgentPid !== undefined && + st.latest?.sourcePid !== undefined && + st.latest.sourcePid !== endedAgentPid + ) 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..095c59afe 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) } } }), diff --git a/src/main/runtime/types.ts b/src/main/runtime/types.ts index 4c85a0afd..46e702a98 100644 --- a/src/main/runtime/types.ts +++ b/src/main/runtime/types.ts @@ -104,6 +104,8 @@ export interface PtyActivity { activity: TerminalActivity agentName: string | null agentPresent: boolean + /** Pid whose disappearance produced an agent falling edge, when known. */ + endedAgentPid?: number } export interface ProcessHost { diff --git a/src/renderer/lib/agent/agentStatus.integration.test.ts b/src/renderer/lib/agent/agentStatus.integration.test.ts index 07b31a9a1..42733edaa 100644 --- a/src/renderer/lib/agent/agentStatus.integration.test.ts +++ b/src/renderer/lib/agent/agentStatus.integration.test.ts @@ -62,6 +62,12 @@ const fixtures: AgentLifecycleFixture[] = [ turnStart: { type: 'session.status', sessionID: SESSION, status: { type: 'busy' } }, turnEnd: { type: 'session.status', sessionID: SESSION, status: { type: 'idle' } }, }, + { + agentId: 'hermes', + sessionStart: { hook_event_name: 'on_session_start', session_id: SESSION, platform: 'cli' }, + turnStart: { hook_event_name: 'pre_llm_call', session_id: SESSION, platform: 'cli' }, + turnEnd: { hook_event_name: 'on_session_end', session_id: SESSION, platform: 'cli' }, + }, { agentId: 'kiro', sessionStart: { hook_event_name: 'agentSpawn', session_id: SESSION }, diff --git a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts index e2d5c55e1..63c2e4b54 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', - sessionId: '11111111-1111-4111-8111-111111111111', + agentId: 'hermes', + sessionId: '20260911_131500_abcd1234', 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..a2c17b6ca 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -406,7 +406,11 @@ 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/runtime/capabilities/agentHooks.test.ts b/src/runtime/capabilities/agentHooks.test.ts index a715c6abb..4185c587e 100644 --- a/src/runtime/capabilities/agentHooks.test.ts +++ b/src/runtime/capabilities/agentHooks.test.ts @@ -240,6 +240,41 @@ describe('agentHooks capability', () => { expect(events.length).toBe(1) }) + test('ingestion preserves Hermes profile identity from an authenticated plugin post', async () => { + const posts: Array<{ terminalId: string; agentId: string; pid?: number }> = [] + const cap = makeCap({ onPost: (value) => { posts.push(value) } }) + const events = collect(cap) + const { url, tokenFor } = await cap.endpoint() + const payload = { + hook_event_name: 'pre_llm_call', + session_id: '20260911_131500_abcd1234', + cwd: 'C:/work/project', + profile: 'work', + platform: 'cli', + } + + expect((await post(url, tokenFor('rpty-hermes'), { + agentId: 'hermes', terminalId: 'rpty-hermes', pid: 4321, payload, + })).status).toBe(204) + await waitFor(() => events.length === 1) + + expect(events[0]).toMatchObject({ + terminalId: 'rpty-hermes', + agentId: 'hermes', + kind: 'turn-start', + sessionId: payload.session_id, + cwd: payload.cwd, + profile: 'work', + sourcePid: 4321, + }) + expect(posts).toEqual([{ + terminalId: 'rpty-hermes', + agentId: 'hermes', + pid: 4321, + kind: 'turn-start', + }]) + }) + test('resolves CLI metadata into a normal session-title event without delaying the hook', async () => { const resolver = vi.fn(async () => 'Fix terminal titles') const cap = makeCap({ @@ -686,6 +721,7 @@ describe('agentHooks capability', () => { // opencode injects a repo file like every other agent. expect(byId.opencode).toMatchObject({ folderPresent: false, injected: false }) expect(byId.kiro).toMatchObject({ folderPresent: false, injected: false }) + expect(byId.hermes).toBeUndefined() // Every agent carries a display name for the UI. expect(states.every((s) => s.displayName.length > 0)).toBe(true) }) diff --git a/src/runtime/capabilities/agentHooks.ts b/src/runtime/capabilities/agentHooks.ts index 23eec6668..c62ae7146 100644 --- a/src/runtime/capabilities/agentHooks.ts +++ b/src/runtime/capabilities/agentHooks.ts @@ -61,6 +61,7 @@ import { type AgentHookAgentState, type AgentHookConfig, type AgentHookEvent, + type AgentHookEventKind, type HookInjectionContext, } from '../../shared/agentHooks' @@ -76,10 +77,9 @@ export interface AgentHooksCapability { * native prompt-submit hooks. Null clears it. */ setPromptContext(terminalId: string, context: string | null): void /** The full spawn env for a PTY: hook endpoint + this terminal's derived - * token + CATE_TERMINAL_ID=ptyId. Agent-agnostic (the per-agent tri-state - * is enforced by prepareWorkspace, the only injection channel) and repo-free, - * so it is planted unconditionally: a hook file that was never written - * simply never reads it. Lazily boots the ingestion endpoint + hooks dir on + * token + CATE_TERMINAL_ID=ptyId. Agent-agnostic and repo-free, so it is + * planted unconditionally: project hook files and explicitly installed + * user plugins read the same endpoint contract. 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> @@ -127,10 +127,16 @@ export interface AgentHooksDeps { /** Called on every AUTHENTICATED post for a known agent — including ones * whose payload normalizes to null — with the poster's lineage claim * (`pid`: the bridge's parent / the in-process agent itself; undefined - * when the poster didn't send one). AWAITED before the HTTP response goes + * when the poster didn't send one; `kind`: normalized lifecycle generation + * signal for in-process integrations). 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 + kind?: AgentHookEventKind + }) => void | Promise /** Tests may replace the filesystem-backed CLI resolvers. */ titleResolvers?: AgentTitleResolvers /** Runtime-host home containing each CLI's session store. */ @@ -449,6 +455,15 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook res.end() return } + 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 + } // Presence lineage: every authenticated post from a known agent // counts, even one whose payload normalizes to null — the post // itself proves the agent is alive. Awaited BEFORE responding so @@ -459,10 +474,10 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook terminalId: body.terminalId, agentId: body.agentId as AgentId, pid: typeof body.pid === 'number' ? body.pid : undefined, + ...(body.agentId === 'hermes' && event ? { kind: event.kind } : {}), }) } catch { /* presence tracking must never fail the hook */ } } - const event = normalizeAgentHookPayload(body.agentId, body.terminalId, body.payload as Record) 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) } @@ -674,6 +689,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook const states: AgentHookAgentState[] = [] for (const agent of AGENTS) { const spec = AGENT_HOOK_SPECS[agent.id] + if (!spec.projectFiles) continue let folderPresent = false let injected = false // Only touch the filesystem for a real repo cwd (never ~ or a relative diff --git a/src/runtime/capabilities/agentPresence.test.ts b/src/runtime/capabilities/agentPresence.test.ts index e6dce0721..e725ba6d9 100644 --- a/src/runtime/capabilities/agentPresence.test.ts +++ b/src/runtime/capabilities/agentPresence.test.ts @@ -118,6 +118,29 @@ describe('notePost → presenceFor', () => { expect(t2.presenceFor(T, relaunched)).toEqual({ agentName: 'Claude Code', agentPresent: true }) }) + test('a post from a new process generation replaces a still-live registration', async () => { + const overlapping = tree([ + [10, 1, 'zsh'], + [40, 10, 'hermes'], + [70, 10, 'hermes'], + ]) + const { tracker, snapshot } = makeTracker(overlapping) + await tracker.notePost(T, 'hermes', 40, 'turn-start') + await tracker.notePost(T, 'hermes', 70, 'turn-start') + await tracker.notePost(T, 'hermes', 40, 'turn-end') + expect(snapshot).toHaveBeenCalledTimes(2) + + const onlyNew = tree([[10, 1, 'zsh'], [70, 10, 'hermes']]) + expect(tracker.presenceFor(T, onlyNew)).toEqual({ agentName: 'Hermes', agentPresent: true }) + + const ended = tree([[10, 1, 'zsh']]) + expect(tracker.presenceFor(T, ended)).toEqual({ + agentName: null, + agentPresent: false, + endedAgentPid: 70, + }) + }) + test('a different agent in the same terminal replaces the registration', async () => { const both = tree([[10, 1, 'zsh'], [40, 10, 'claude'], [41, 40, 'sh'], [80, 10, 'codex'], [81, 80, 'sh']]) const { tracker } = makeTracker(both) diff --git a/src/runtime/capabilities/agentPresence.ts b/src/runtime/capabilities/agentPresence.ts index 4933d1665..14726316d 100644 --- a/src/runtime/capabilities/agentPresence.ts +++ b/src/runtime/capabilities/agentPresence.ts @@ -31,11 +31,15 @@ import type { AgentId } from '../../shared/agents' import { AGENTS } from '../../shared/agents' +import type { AgentHookEventKind } from '../../shared/agentHooks' import type { ProcTree } from './procfs' export interface AgentPresence { agentName: string | null agentPresent: boolean + /** Agent pid whose disappearance produced this result. Present only for a + * real falling edge, so downstream state can reject an older generation. */ + endedAgentPid?: number } export interface AgentPresenceTracker { @@ -43,7 +47,12 @@ 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, + kind?: AgentHookEventKind, + ): 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. */ @@ -64,6 +73,9 @@ export interface AgentPresenceDeps { interface Registration { agentId: AgentId pid: number + /** Pid carried by the hook post. For in-process hooks this is the agent pid; + * for bridge-based hooks it distinguishes repeated posts for the fast path. */ + sourcePid: number /** comm at registration time — presenceFor requires it unchanged, so a * recycled pid can't impersonate the agent. */ comm: string @@ -94,7 +106,7 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen const registrations = new Map() return { - async notePost(terminalId, agentId, pid) { + async notePost(terminalId, agentId, pid, kind) { // 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()). @@ -102,10 +114,19 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen const def = AGENTS.find((a) => a.id === agentId) if (!def) return - // Fast path: this terminal's agent is already registered and alive — - // the common per-tool-call event needs no snapshot. + // Bridge-based integrations spawn a fresh helper pid for many events, so + // their live agent registration remains the fast-path authority. Hermes + // posts in-process: a changed source pid is therefore a new generation + // even while the previous process is still shutting down. const existing = registrations.get(terminalId) - if (existing && existing.agentId === agentId && isAlive(existing.pid)) return + const sameGeneration = agentId !== 'hermes' || existing?.sourcePid === pid + const canReplaceHermesGeneration = kind === 'session-start' || kind === 'turn-start' + if ( + existing && + existing.agentId === agentId && + isAlive(existing.pid) && + (sameGeneration || (agentId === 'hermes' && !canReplaceHermesGeneration)) + ) return const tree = await deps.snapshot() const parent = parentMap(tree) @@ -116,7 +137,7 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen visited.add(p) const comm = tree.nameByPid.get(p) if (comm && def.matchProcess(comm.toLowerCase())) { - registrations.set(terminalId, { agentId, pid: p, comm }) + registrations.set(terminalId, { agentId, pid: p, sourcePid: pid, comm }) return } } @@ -135,7 +156,7 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen // 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 } }, drop(terminalId) { diff --git a/src/runtime/capabilities/agentTitles/hermes.ts b/src/runtime/capabilities/agentTitles/hermes.ts new file mode 100644 index 000000000..48b5961d8 --- /dev/null +++ b/src/runtime/capabilities/agentTitles/hermes.ts @@ -0,0 +1,4 @@ +import type { AgentTitleResolver } from './types' + +/** Hermes does not currently expose a stable public title lookup contract. */ +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/index.ts b/src/runtime/capabilities/index.ts index 0d26e2fef..ef76b8bc7 100644 --- a/src/runtime/capabilities/index.ts +++ b/src/runtime/capabilities/index.ts @@ -186,7 +186,7 @@ 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, kind }) => agentPresence.notePost(terminalId, agentId, pid, kind), }) const innerProc = createProcessCapability({ diff --git a/src/runtime/capabilities/process.agentStatus.test.ts b/src/runtime/capabilities/process.agentStatus.test.ts index 32fa43196..0db7f52be 100644 --- a/src/runtime/capabilities/process.agentStatus.test.ts +++ b/src/runtime/capabilities/process.agentStatus.test.ts @@ -25,6 +25,7 @@ const fixtures = [ { 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 }, + { agentId: 'hermes', start: { hook_event_name: 'pre_llm_call', platform: 'cli' }, wait: null }, { agentId: 'kiro', start: { hook_event_name: 'UserPromptSubmit' }, wait: null }, ] as const diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 3fd367a1a..cb8fb5ead 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -61,7 +61,7 @@ async function main(): Promise { // Reap harness children left behind by a previous daemon crash. reapOrphanServers(args.id) - const { runtime, process: proc, killAll } = buildDaemonRuntime({ + const { runtime, process: proc, agentHooks, killAll } = buildDaemonRuntime({ id: args.id, exclusions: args.exclusions, idleSuspend: args.idleSuspend, @@ -69,6 +69,10 @@ async function main(): Promise { const server = new RpcServer(runtime, (line) => process.stdout.write(line)) const shutdown = (): void => { + // Stop accepting lifecycle posts before killing PTYs. Otherwise an agent's + // graceful finalize hook can erase the resume stamp Cate is preserving for + // the next launch. + agentHooks.dispose() proc.killAllGroups() killAll() server.dispose() diff --git a/src/shared/agentHooks.test.ts b/src/shared/agentHooks.test.ts index 0b039c328..15f473b48 100644 --- a/src/shared/agentHooks.test.ts +++ b/src/shared/agentHooks.test.ts @@ -580,9 +580,47 @@ describe('reportsTurnEndOnInterrupt', () => { // Expected self-heal via stop{cancelled}; streaming path not yet // observed live (test account quota) — see grokSpec. grok: true, + // Hermes emits on_session_end for interrupted root CLI turns. + 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, }) }) }) + +describe('hermes spec', () => { + const spec = AGENT_HOOK_SPECS.hermes + const base = { + session_id: '20260911_131500_abcd1234', + cwd: 'C:/work/project', + profile: 'work', + platform: 'cli', + } + + test('uses the profile-scoped external plugin delivery channel', () => { + expect(spec.externalPlugin).toEqual({ id: 'cate-agent-state' }) + expect(spec.projectFiles).toBeUndefined() + }) + + test('normalizes root CLI session and turn lifecycle with profile identity', () => { + expect(norm('hermes', { hook_event_name: 'on_session_start', ...base })).toMatchObject({ + agentId: 'hermes', + terminalId: 'term-1', + kind: 'session-start', + sessionId: base.session_id, + cwd: base.cwd, + profile: 'work', + }) + expect(norm('hermes', { hook_event_name: 'on_session_reset', ...base })?.kind).toBe('session-start') + 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: 'on_session_finalize', ...base })?.kind).toBe('session-end') + }) + + test('drops non-interactive child and background surfaces', () => { + for (const platform of ['subagent', 'cron', 'gateway']) { + expect(norm('hermes', { hook_event_name: 'pre_llm_call', ...base, platform })).toBeNull() + } + }) +}) diff --git a/src/shared/agentHooks.ts b/src/shared/agentHooks.ts index 4eaaa7820..7ea6c4154 100644 --- a/src/shared/agentHooks.ts +++ b/src/shared/agentHooks.ts @@ -1,10 +1,10 @@ // ============================================================================= // Agent hook abstraction — the per-CLI declarations that turn the agent // CLIs' hook/extension/plugin surfaces into ONE normalized push event stream. -// Each agent entry declares (a) WHICH workspace-scoped files Cate writes to -// inject its hook bridge and (b) how that CLI's raw hook payload normalizes -// into an AgentHookEvent. Adding a CLI is one entry here plus its AgentDef in -// agents.ts. +// Each agent entry declares (a) its delivery channel (workspace-scoped files +// or an explicitly installed user plugin) and (b) how that CLI's raw hook +// payload normalizes into an AgentHookEvent. Adding a CLI is one entry here +// plus its AgentDef in agents.ts. // // The injection/payload contracts are pinned LIVE against the installed CLIs // by src/runtime/capabilities/agentHookContracts.itest.ts — when a CLI update @@ -78,6 +78,11 @@ 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. Used only to reject lifecycle + * events from an older process generation; never persisted across restarts. */ + sourcePid?: number /** Present only for session-title events. */ title?: string /** The raw payload as posted by the bridge, for consumers that need @@ -86,7 +91,7 @@ export interface AgentHookEvent { } export type NormalizedHookFields = Pick & - Partial> + Partial> // --------------------------------------------------------------------------- // Injection declarations @@ -188,6 +193,8 @@ export interface AgentHookSpec { */ strip?(existing: string): AgentHookStrip }> + /** User-global plugin that delivers hooks without writing project files. */ + 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 @@ -828,6 +835,36 @@ const kiroSpec: AgentHookSpec = { }, } +// --------------------------------------------------------------------------- +// hermes — a profile-scoped user plugin posts lifecycle events directly to +// Cate's authenticated per-PTY endpoint. It is installed explicitly by the +// integration script and is a silent no-op outside Cate terminals. +// --------------------------------------------------------------------------- + +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), + 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 'on_session_finalize': return { kind: 'session-end', ...base } + default: return null + } + }, +} + // --------------------------------------------------------------------------- // Registry + normalization entry point // --------------------------------------------------------------------------- @@ -837,6 +874,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..1da756ae1 100644 --- a/src/shared/agentRegistry.test.ts +++ b/src/shared/agentRegistry.test.ts @@ -22,12 +22,14 @@ import { AGENT_HOOK_SPECS } from './agentHooks' import { SKILL_TARGETS, type SkillTargetId } from './skills' /** Agents deliberately without a skills integration. Add an id here ONLY with a - * reason — the point of the failure is to force the decision, not to be muted. */ -const NO_SKILLS: ReadonlySet = new Set([]) + * reason — the point of the failure is to force the decision, not to be muted. + * Hermes skills are profile-scoped; this feature adds only terminal restore. */ +const NO_SKILLS: ReadonlySet = new Set(['hermes']) /** Agents deliberately without a bundled logo (they fall back to the panel's - * default icon). */ -const NO_LOGO: ReadonlySet = new Set([]) + * default icon). Hermes uses the fallback until a redistributable brand asset + * is selected. */ +const NO_LOGO: ReadonlySet = new Set(['hermes']) describe('agent registry coverage', () => { test('native prompt context is gated to CLIs with a supported submit hook', () => { @@ -84,13 +86,14 @@ describe('agent registry coverage', () => { expect([...SKILL_TARGETS].map((t) => t.id).sort()).toEqual([...expected].sort()) }) - test('every agent has a hook spec', () => { + test('every agent has exactly one hook delivery channel', () => { // Total Record, so this is belt-and-braces — but it also catches a spec - // that is present and empty (no injection channel at all). + // that is present and empty or ambiguously declares two delivery channels. 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() + const channels = Number(Boolean(spec.projectFiles?.length)) + Number(Boolean(spec.externalPlugin)) + expect(channels, `${a.id} must declare one hook delivery channel`).toBe(1) } }) @@ -100,8 +103,11 @@ describe('agent registry coverage', () => { expect(a.matchProcess(a.command.toLowerCase()) || a.id === 'claude-code', `${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) + // it is a real decision, and that valid context yields non-empty argv. + if (a.resumeArgs) { + const args = a.resumeArgs('abc', a.id === 'hermes' ? { profile: 'default' } : undefined) + expect(args?.length).toBeGreaterThan(0) + } } }) }) diff --git a/src/shared/agents.test.ts b/src/shared/agents.test.ts index a9cfb52d6..5aecad7f2 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('"C:\\tools\\hermes" --profile work')?.id).toBe('hermes') }) it('does not guess through compound shell syntax', () => { @@ -23,6 +24,8 @@ 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('Hermes.exe')?.id).toBe('hermes') expect(matchAgentDef('node')).toBeNull() }) }) @@ -38,6 +41,13 @@ 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)).toBeNull() + expect(resumeCommandForAgent('hermes', uuid, { profile: 'work' })).toBe( + `hermes --profile work chat --resume ${uuid}`, + ) + expect(resumeCommandForAgent('hermes', uuid, { profile: 'default' })).toBe( + `hermes --profile default chat --resume ${uuid}`, + ) }) it('returns null for unknown agent ids', () => { @@ -49,6 +59,7 @@ describe('resumeCommandForAgent', () => { expect(resumeCommandForAgent('claude-code', 'abc def')).toBeNull() expect(resumeCommandForAgent('claude-code', '$(evil)')).toBeNull() expect(resumeCommandForAgent('claude-code', '')).toBeNull() + expect(resumeCommandForAgent('claude-code', 'a'.repeat(129))).toBeNull() }) it('rejects dash-led session ids (flag injection into the resume argv)', () => { @@ -56,4 +67,10 @@ describe('resumeCommandForAgent', () => { expect(resumeCommandForAgent('codex', '-x')).toBeNull() expect(resumeCommandForAgent('opencode', '_leading-underscore')).toBeNull() }) + + it('rejects unsafe Hermes profile names', () => { + for (const profile of ['work; calc', '../work', '-p', 'work profile', '', 'Work', 'custom', 'a'.repeat(65)]) { + expect(resumeCommandForAgent('hermes', 'session-1', { profile })).toBeNull() + } + }) }) diff --git a/src/shared/agents.ts b/src/shared/agents.ts index 5ef466775..b1f930c68 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -37,6 +37,7 @@ export type AgentId = | 'codex' | 'cursor' | 'grok' + | 'hermes' | 'kiro' | 'opencode' @@ -73,6 +74,11 @@ export interface AgentSkillTarget { export type T3ProviderId = 'codex' | 'claude' | 'cursor' | 'grok' | 'opencode' export type T3DriverId = 'codex' | 'claudeAgent' | 'cursor' | 'grok' | 'opencode' +export interface AgentResumeContext { + /** Named CLI profile that owns the persisted session. */ + profile?: string +} + export interface AgentDef { id: AgentId /** Optional T3 integration of this same provider; readiness is integration-specific. */ @@ -83,7 +89,8 @@ export interface AgentDef { * as the detected process name. */ command: string /** Build the shell-free argv for a new Cate-owned mission worker. */ - codingAgentArgs: (prompt: string) => string[] + /** Exact argv for a Cate-owned mission worker, or null when unsupported. */ + codingAgentArgs: ((prompt: string) => string[]) | null /** Whether the launched surface accepts another prompt on the same PTY. */ codingAgentFollowUp: boolean /** True when a shell child process with this (already-lowercased) name means @@ -95,7 +102,7 @@ export interface AgentDef { /** 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?: AgentResumeContext) => 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 +206,21 @@ export const AGENTS: readonly AgentDef[] = [ resumeArgs: (sid) => ['--session', sid], skills: folderSkills('opencode', ['.opencode', 'skills']), }, + { + id: 'hermes', + displayName: 'Hermes', + command: 'hermes', + // Hermes `-q` is explicitly one-shot; terminal restore is supported, but + // Cate-owned interactive mission workers are not yet. + codingAgentArgs: null, + codingAgentFollowUp: false, + matchProcess: (n) => n === 'hermes' || n === 'hermes.exe', + resumeArgs: (sid, context) => context?.profile + ? ['--profile', context.profile, 'chat', '--resume', sid] + : null, + // Hermes skills are profile-scoped; Cate only installs project skills. + skills: null, + }, // Kiro CLI with its v3 engine. Standalone workspace hooks require that engine, so select // it explicitly for fresh and resumed sessions. { @@ -270,16 +292,32 @@ 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?: AgentResumeContext, +): 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. */ -export const TERMINAL_AGENTS = AGENTS +export type TerminalAgentDef = AgentDef & { + codingAgentArgs: (prompt: string) => string[] +} + +export const TERMINAL_AGENTS: readonly TerminalAgentDef[] = AGENTS.filter( + (agent): agent is TerminalAgentDef => agent.codingAgentArgs !== null, +) export const T3_AGENTS = AGENTS.filter((agent): agent is AgentDef & { t3: NonNullable } => !!agent.t3) diff --git a/src/shared/codingAgentRuns.test.ts b/src/shared/codingAgentRuns.test.ts index 60524a857..07abea9d7 100644 --- a/src/shared/codingAgentRuns.test.ts +++ b/src/shared/codingAgentRuns.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { AGENTS } from './agents' +import { TERMINAL_AGENTS } from './agents' import { codingAgentCommand, codingAgentSupportsFollowUp, @@ -10,7 +10,7 @@ describe('codingAgentCommand', () => { it('resolves only canonical agent ids to exact argv without a shell', () => { const task = 'Fix it; touch /tmp/pwned' const prefixed = `Complete this coding task:\n\n${task}` - expect(AGENTS.map((agent) => ({ + expect(TERMINAL_AGENTS.map((agent) => ({ id: agent.id, command: codingAgentCommand({ agentId: agent.id, prompt: task }), followUp: codingAgentSupportsFollowUp(agent.id), @@ -45,6 +45,10 @@ describe('codingAgentCommand', () => { it('rejects unknown ids and blank tasks', () => { expect(parseCodingAgentId('/tmp/fake-agent')).toBeNull() expect(parseCodingAgentId('codex')).toBe('codex') + expect(parseCodingAgentId('hermes')).toBeNull() + expect(() => codingAgentCommand({ agentId: 'hermes', prompt: 'task' })).toThrow( + 'Unsupported coding agent: hermes', + ) expect(() => codingAgentCommand({ agentId: 'codex', prompt: ' ' })).toThrow( 'A coding-agent prompt is required', ) diff --git a/src/shared/types.ts b/src/shared/types.ts index 24163ec25..d8084a27a 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 agent profile that owns this session, when the CLI exposes one. */ + profile?: string } // ----------------------------------------------------------------------------- From 84ba10aa7cd91ccef7a88365064a385df7bed555 Mon Sep 17 00:00:00 2001 From: Mathias Munk Hojer Date: Mon, 14 Sep 2026 14:29:07 +0200 Subject: [PATCH 2/6] fix: persist profile-only agent session updates --- .../appStore/panelSlice.agentSession.test.ts | 63 +++++++++++++++++++ src/renderer/stores/appStore/panelSlice.ts | 3 +- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/renderer/stores/appStore/panelSlice.agentSession.test.ts diff --git a/src/renderer/stores/appStore/panelSlice.agentSession.test.ts b/src/renderer/stores/appStore/panelSlice.agentSession.test.ts new file mode 100644 index 000000000..d181c2ffe --- /dev/null +++ b/src/renderer/stores/appStore/panelSlice.agentSession.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceState } from '../../../shared/types' + +vi.mock('../../lib/logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})) +vi.mock('../../lib/terminal/terminalRegistry', () => ({ + terminalRegistry: { + setPendingRestore: vi.fn(), + dispose: vi.fn(), + disposeWorkspace: vi.fn(), + getEntry: vi.fn(), + has: vi.fn(() => false), + }, +})) + +import { useAppStore } from '../appStore' + +const workspaceId = 'workspace' +const panelId = 'terminal' + +function workspace(): WorkspaceState { + return { + id: workspaceId, + name: 'Workspace', + color: '', + rootPath: '/tmp/workspace', + panels: { + [panelId]: { + id: panelId, + type: 'terminal', + title: 'Terminal', + isDirty: false, + agentSession: { + agentId: 'hermes', + sessionId: 'session-1', + cwd: '/tmp/workspace', + profile: 'work', + }, + }, + }, + } as WorkspaceState +} + +describe('setPanelAgentSession', () => { + beforeEach(() => { + useAppStore.setState({ workspaces: [workspace()], selectedWorkspaceId: workspaceId }) + }) + + it('applies a profile-only identity change', () => { + useAppStore.getState().setPanelAgentSession(workspaceId, panelId, { + agentId: 'hermes', + sessionId: 'session-1', + cwd: '/tmp/workspace', + profile: 'default', + }) + + expect(useAppStore.getState().workspaces[0]?.panels[panelId]?.agentSession?.profile) + .toBe('default') + }) +}) 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 } }) From 57a3f41b77fa07ecb59361a81518595ae6fb4ac6 Mon Sep 17 00:00:00 2001 From: Mathias Munk Hojer Date: Mon, 14 Sep 2026 14:36:08 +0200 Subject: [PATCH 3/6] test: verify Hermes resume output per pane --- e2e/hermes-session-resume.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/e2e/hermes-session-resume.spec.ts b/e2e/hermes-session-resume.spec.ts index 00c0beb6b..75ef3a2b8 100644 --- a/e2e/hermes-session-resume.spec.ts +++ b/e2e/hermes-session-resume.spec.ts @@ -202,6 +202,12 @@ test('two Hermes terminals cold-restore their own exact profile-scoped sessions' nodes, ) expect(restoredTerminalIds).toEqual(terminalIds) + for (const [index, node] of nodes.entries()) { + await expect.poll( + () => page.evaluate((id) => window.__cateE2E!.terminalText(id), node), + { timeout: 30_000 }, + ).toContain(`FAKE_HERMES_RESUMED ${sessionIds[index]}`) + } await expect.poll( () => fakeLaunches(logPath).filter((entry) => entry.phase === 'resume'), { timeout: 30_000 }, From 187ee1e0dac1a8d71766de14c864f410dd4515ff Mon Sep 17 00:00:00 2001 From: Mathias Munk Hojer Date: Tue, 15 Sep 2026 14:59:56 +0200 Subject: [PATCH 4/6] fix: align Hermes session restore with Cate 2 --- e2e/agent-changes.spec.ts | 5 +- e2e/fixtures/fake-hermes-session.cjs | 2 + e2e/hermes-session-resume.spec.ts | 5 - integrations/hermes/README.md | 1 + .../cate-agent-state/.cate-managed.json | 2 +- .../hermes/cate-agent-state/__init__.py | 5 +- .../hermes/cate-agent-state/plugin.yaml | 2 +- scripts/hermes-integration.mjs | 14 ++- scripts/hermes-integration.test.mjs | 15 ++- scripts/test_hermes_integration_asset.py | 1 + src/main/ipc/agentSessionStamps.test.ts | 65 +++++++++- src/main/ipc/agentSessionStamps.ts | 72 +++++++++-- src/main/ipc/shell.ts | 6 +- src/main/runtime/types.ts | 2 + .../PanelRelationContextToggle.test.tsx | 3 +- src/runtime/capabilities/agentHooks.test.ts | 12 +- src/runtime/capabilities/agentHooks.ts | 12 +- .../capabilities/agentPresence.test.ts | 119 +++++++++++++++++- src/runtime/capabilities/agentPresence.ts | 96 ++++++++++++-- src/runtime/capabilities/index.ts | 3 +- src/shared/agentHooks.test.ts | 22 ++++ src/shared/agentHooks.ts | 43 ++++++- src/shared/agentRegistry.test.ts | 1 + src/shared/agents.ts | 1 + 24 files changed, 458 insertions(+), 51 deletions(-) diff --git a/e2e/agent-changes.spec.ts b/e2e/agent-changes.spec.ts index bb2117573..6e1c73b81 100644 --- a/e2e/agent-changes.spec.ts +++ b/e2e/agent-changes.spec.ts @@ -24,7 +24,10 @@ test.beforeEach(async () => { workspaceId = await page.evaluate(() => window.__cateE2E!.selectedWorkspaceId()) await resetViewport(page) }) -test.afterEach(async () => { if (app) await closeApp(app); rmSync(directory, { recursive: true, force: true }) }) +test.afterEach(async () => { + if (app) await closeApp(app) + rmSync(directory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }) +}) for (const agentId of ['claude-code', 'codex', 'cursor', 'grok', 'kiro', 'opencode']) { test(`${agentId}: real terminal hooks reach the filtered diff panel without other sessions or Git changes`, async () => { diff --git a/e2e/fixtures/fake-hermes-session.cjs b/e2e/fixtures/fake-hermes-session.cjs index fda8f02fe..2983d7bc0 100644 --- a/e2e/fixtures/fake-hermes-session.cjs +++ b/e2e/fixtures/fake-hermes-session.cjs @@ -14,6 +14,7 @@ const flag = (name) => { const endpoint = process.env.CATE_HOOK_ENDPOINT const token = process.env.CATE_HOOK_TOKEN const terminalId = process.env.CATE_TERMINAL_ID +const processStartedAt = process.hrtime.bigint().toString() const logPath = process.env.FAKE_HERMES_LOG const profile = flag('--profile') || 'default' const resumedSessionId = flag('--resume') @@ -25,6 +26,7 @@ function post(hookEventName) { agentId: 'hermes', terminalId, pid: process.pid, + processStartedAt, payload: { hook_event_name: hookEventName, session_id: sessionId, diff --git a/e2e/hermes-session-resume.spec.ts b/e2e/hermes-session-resume.spec.ts index 75ef3a2b8..f066cd62c 100644 --- a/e2e/hermes-session-resume.spec.ts +++ b/e2e/hermes-session-resume.spec.ts @@ -138,10 +138,6 @@ test('two Hermes terminals cold-restore their own exact profile-scoped sessions' const liveNodes = window.__cateE2E!.nodes() return ids.map((id) => liveNodes.find((node) => node.id === id)!.panelId) }, nodes) - const terminalIds = await page.evaluate( - (ids) => ids.map((id) => window.__cateE2E!.terminalPtyId(id)!), - nodes, - ) const profiles = ['work', 'default'] as const for (const [index, node] of nodes.entries()) { @@ -201,7 +197,6 @@ test('two Hermes terminals cold-restore their own exact profile-scoped sessions' (ids) => ids.map((id) => window.__cateE2E!.terminalPtyId(id)!), nodes, ) - expect(restoredTerminalIds).toEqual(terminalIds) for (const [index, node] of nodes.entries()) { await expect.poll( () => page.evaluate((id) => window.__cateE2E!.terminalText(id), node), diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index 4203817ac..cd921a505 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -41,6 +41,7 @@ Exact session IDs are used instead of bare `--continue`, so two terminals in the - Processes launched inside a Cate terminal inherit that token and share the terminal's trust boundary; the token isolates terminals, not processes within one terminal. - The plugin ignores subagent, cron, and gateway sessions. - Hook delivery is synchronous but best-effort, with a 750 ms timeout; failures never abort the Hermes session. +- Each Hermes process supplies an ephemeral monotonic start marker so delayed events from a superseded process cannot reclaim or clear the current session stamp; the marker is never persisted. - Cate accepts no command string from the plugin; session and profile values must be shell-safe tokens. - Install separately for every named Hermes profile used inside Cate. - Custom `HERMES_HOME` layouts report Hermes profile `custom` and are intentionally not stamped for automatic restore. diff --git a/integrations/hermes/cate-agent-state/.cate-managed.json b/integrations/hermes/cate-agent-state/.cate-managed.json index 78d287ed0..5a7727ff0 100644 --- a/integrations/hermes/cate-agent-state/.cate-managed.json +++ b/integrations/hermes/cate-agent-state/.cate-managed.json @@ -1 +1 @@ -{"schema":1,"owner":"Cate","plugin":"cate-agent-state"} +{"schema":1,"owner":"Cate","plugin":"cate-agent-state"} \ No newline at end of file diff --git a/integrations/hermes/cate-agent-state/__init__.py b/integrations/hermes/cate-agent-state/__init__.py index cff280d1a..baff6911c 100644 --- a/integrations/hermes/cate-agent-state/__init__.py +++ b/integrations/hermes/cate-agent-state/__init__.py @@ -5,12 +5,14 @@ import json import os import re +import time from typing import Any, Callable from urllib.parse import urlparse from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener PLUGIN_ID = "cate-agent-state" -PLUGIN_VERSION = "1.0.0" +PLUGIN_VERSION = "1.1.0" +_PROCESS_STARTED_AT = str(time.monotonic_ns()) _INTERACTIVE_PLATFORMS = frozenset({"cli", "tui"}) _PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _HOOKS = ( @@ -66,6 +68,7 @@ def _report(hook_event_name: str, profile: str, **kwargs: Any) -> None: "agentId": "hermes", "terminalId": terminal_id, "pid": os.getpid(), + "processStartedAt": _PROCESS_STARTED_AT, "payload": { "hook_event_name": hook_event_name, "session_id": kwargs.get("session_id"), diff --git a/integrations/hermes/cate-agent-state/plugin.yaml b/integrations/hermes/cate-agent-state/plugin.yaml index 941fe2ca3..9d622c834 100644 --- a/integrations/hermes/cate-agent-state/plugin.yaml +++ b/integrations/hermes/cate-agent-state/plugin.yaml @@ -1,5 +1,5 @@ name: cate-agent-state -version: 1.0.0 +version: 1.1.0 description: "Report root Hermes CLI session lifecycle to Cate for exact terminal restore." author: "TheXRMonk" provides_hooks: diff --git a/scripts/hermes-integration.mjs b/scripts/hermes-integration.mjs index 7a1e71dd4..6c10f6ee8 100644 --- a/scripts/hermes-integration.mjs +++ b/scripts/hermes-integration.mjs @@ -17,7 +17,8 @@ import { promisify } from 'node:util' export const PLUGIN_ID = 'cate-agent-state' export const OWNER_FILE = '.cate-managed.json' -export const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}\n' +export const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}' +const LEGACY_OWNER_SENTINEL = `${OWNER_SENTINEL}\n` const SAFE_PROFILE = /^[a-z0-9][a-z0-9_-]{0,63}$/ const HERMES_COMMAND_TIMEOUT_MS = 30_000 const execFileAsync = promisify(execFile) @@ -70,7 +71,8 @@ function sameFileIdentity(left, right) { } async function hasExactOwnershipSentinel(markerPath) { - const expectedSize = Buffer.byteLength(OWNER_SENTINEL) + const acceptedSentinels = [OWNER_SENTINEL, LEGACY_OWNER_SENTINEL] + const acceptedSizes = new Set(acceptedSentinels.map((value) => Buffer.byteLength(value))) let before try { before = await lstat(markerPath) @@ -78,7 +80,7 @@ async function hasExactOwnershipSentinel(markerPath) { if (error?.code === 'ENOENT') return false throw error } - if (!before.isFile() || before.isSymbolicLink() || before.size !== expectedSize) return false + if (!before.isFile() || before.isSymbolicLink() || !acceptedSizes.has(before.size)) return false // O_NOFOLLOW closes the lstat/open race on POSIX. On Windows, comparing the // lstat and opened-file identities rejects a link swapped in between calls. @@ -87,14 +89,14 @@ async function hasExactOwnershipSentinel(markerPath) { try { handle = await open(markerPath, fsConstants.O_RDONLY | noFollow) const opened = await handle.stat() - if (!opened.isFile() || opened.size !== expectedSize || !sameFileIdentity(before, opened)) return false + if (!opened.isFile() || !acceptedSizes.has(opened.size) || !sameFileIdentity(before, opened)) return false const value = await handle.readFile('utf8') const after = await lstat(markerPath) return ( - value === OWNER_SENTINEL && + acceptedSentinels.includes(value) && after.isFile() && !after.isSymbolicLink() && - after.size === expectedSize && + acceptedSizes.has(after.size) && sameFileIdentity(before, after) ) } catch (error) { diff --git a/scripts/hermes-integration.test.mjs b/scripts/hermes-integration.test.mjs index 2e603ff1f..eebd28f7f 100644 --- a/scripts/hermes-integration.test.mjs +++ b/scripts/hermes-integration.test.mjs @@ -11,7 +11,8 @@ import { const roots = [] const OWNER_FILE = '.cate-managed.json' -const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}\n' +const OWNER_SENTINEL = '{"schema":1,"owner":"Cate","plugin":"cate-agent-state"}' +const LEGACY_OWNER_SENTINEL = `${OWNER_SENTINEL}\n` afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) @@ -77,6 +78,18 @@ describe('Hermes integration manager', () => { ]) }) + it('accepts the legacy LF-terminated marker and rewrites it canonically', async () => { + const { sourceDir, home } = await fixture() + const fake = fakeHermes(home) + await installHermesIntegration({ profile: 'work', sourceDir, runHermes: fake.runHermes }) + const target = path.join(home, 'plugins', 'cate-agent-state') + await writeFile(path.join(target, OWNER_FILE), LEGACY_OWNER_SENTINEL) + + await installHermesIntegration({ profile: 'work', sourceDir, runHermes: fake.runHermes }) + + expect(await readFile(path.join(target, OWNER_FILE), 'utf8')).toBe(OWNER_SENTINEL) + }) + it('refuses to overwrite a foreign plugin directory', async () => { const { sourceDir, home } = await fixture() const target = path.join(home, 'plugins', 'cate-agent-state') diff --git a/scripts/test_hermes_integration_asset.py b/scripts/test_hermes_integration_asset.py index ddd52b421..8c6a82e66 100644 --- a/scripts/test_hermes_integration_asset.py +++ b/scripts/test_hermes_integration_asset.py @@ -129,6 +129,7 @@ def test_posts_authenticated_profile_scoped_payload(self) -> None: self.assertEqual(body["agentId"], "hermes") self.assertEqual(body["terminalId"], "term-1") self.assertEqual(body["pid"], os.getpid()) + self.assertRegex(body["processStartedAt"], r"^[1-9][0-9]*$") self.assertEqual( body["payload"], { diff --git a/src/main/ipc/agentSessionStamps.test.ts b/src/main/ipc/agentSessionStamps.test.ts index d8790fa98..41a211a0f 100644 --- a/src/main/ipc/agentSessionStamps.test.ts +++ b/src/main/ipc/agentSessionStamps.test.ts @@ -54,8 +54,9 @@ function ev( cwd?: string, profile?: string, sourcePid?: number, + sourceStartedAt?: string, ): AgentHookEvent { - return { terminalId, agentId, kind, sessionId, cwd, profile, sourcePid, raw: {} } + return { terminalId, agentId, kind, sessionId, cwd, profile, sourcePid, sourceStartedAt, raw: {} } } const stamps = (terminalId: string) => harness.sent.filter((s) => s.terminalId === terminalId).map((s) => s.session) @@ -169,6 +170,50 @@ describe('Hermes resumability gating', () => { ]) }) + it('does not let a delayed start event reclaim a newer process generation', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101, '100')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 202, '200')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101, '100')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', 'id-1', '/w', 'work', 101, '100')) + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + ]) + }) + + it('keeps the newer generation retired after its stamp is cleared', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101, '100')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-2', '/w', 'work', 202, '200')) + clearAgentSessionStamp(tid, 202, '200') + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101, '100')) + + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, + { agentId: 'hermes', sessionId: 'id-2', cwd: '/w', profile: 'work' }, + null, + ]) + }) + + it('clears an old stamp when a newer Hermes generation has not reached its first turn', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-old', '/w', 'work', 101, '100')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-start', 'id-new', '/w', 'work', 101, '200')) + + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-old', cwd: '/w', profile: 'work' }, + null, + ]) + }) + + it('rejects a clockless event after a clocked generation even when the pid is reused', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-new', '/w', 'work', 202, '200')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-old', '/w', 'work', 202)) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'session-end', 'id-new', '/w', 'work', 202, '200')) + + expect(stamps(tid)).toEqual([ + { agentId: 'hermes', sessionId: 'id-new', cwd: '/w', profile: 'work' }, + null, + ]) + }) + it('ignores a falling edge from an older process generation', () => { ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 101)) ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-1', '/w', 'work', 202)) @@ -177,6 +222,24 @@ describe('Hermes resumability gating', () => { { agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }, ]) }) + + it('ignores a same-pid falling edge from an older process-start generation', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-old', '/w', 'work', 101, '100')) + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'id-new', '/w', 'work', 101, '200')) + clearAgentSessionStamp(tid, 101, '100') + + expect(stamps(tid).at(-1)).toEqual({ + agentId: 'hermes', sessionId: 'id-new', cwd: '/w', profile: 'work', + }) + }) + + it('does not apply a prior Hermes high-water mark to another agent falling edge', () => { + ingestAgentSessionStamp(runtime, ev(tid, 'hermes', 'turn-start', 'hermes-id', '/w', 'work', 101, '100')) + ingestAgentSessionStamp(runtime, ev(tid, 'codex', 'session-start', 'codex-id', '/w')) + clearAgentSessionStamp(tid, 303) + + expect(stamps(tid).at(-1)).toBeNull() + }) }) describe('agents whose first sessionId-bearing event is already persisted', () => { diff --git a/src/main/ipc/agentSessionStamps.ts b/src/main/ipc/agentSessionStamps.ts index 95824ebf2..768bd6126 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 { compareAgentProcessGeneration, type AgentHookEvent } from '../../shared/agentHooks' import type { AgentId } from '../../shared/agents' import type { Runtime } from '../runtime/types' import type { TerminalAgentSession } from '../../shared/types' @@ -68,7 +68,12 @@ interface StampState { latest?: Pick & { /** Process generation for in-process hook providers such as Hermes. */ sourcePid?: number + sourceStartedAt?: string } + /** Highest Hermes process generation seen for this terminal. This survives + * session/falling-edge clears so delayed events cannot resurrect an older + * session after the newer process has exited. */ + hermesHighWater?: Pick /** 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 @@ -117,14 +122,30 @@ 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) - if ( - event.agentId === 'hermes' && - st.latest?.agentId === 'hermes' && - st.latest.sourcePid !== undefined && - event.sourcePid !== st.latest.sourcePid && - event.kind !== 'session-start' && - event.kind !== 'turn-start' - ) return + if (event.agentId === 'hermes') { + const generationOrder = st.hermesHighWater + ? compareAgentProcessGeneration(event, st.hermesHighWater) + : null + if (generationOrder === -1) return + const canReplaceLegacyGeneration = event.kind === 'session-start' || event.kind === 'turn-start' + if ( + !st.hermesHighWater || + generationOrder === 1 || + (generationOrder === null && canReplaceLegacyGeneration) + ) { + st.hermesHighWater = { + sourcePid: event.sourcePid, + sourceStartedAt: event.sourceStartedAt, + } + } + if ( + generationOrder === null && + st.latest?.agentId === 'hermes' && + st.latest.sourcePid !== undefined && + event.sourcePid !== st.latest.sourcePid && + !canReplaceLegacyGeneration + ) return + } if (event.kind === 'session-end') { // An end without an identity cannot safely clear a resumable session. if (!event.sessionId) return @@ -142,13 +163,29 @@ export function ingestAgentSessionStamp(runtime: Runtime, event: AgentHookEvent) } if (event.sessionId == null) return st.seq++ + const previousSession = st.session st.latest = { agentId: event.agentId, sessionId: event.sessionId, ...(event.profile ? { profile: event.profile } : {}), ...(event.sourcePid !== undefined ? { sourcePid: event.sourcePid } : {}), + ...(event.sourceStartedAt !== undefined ? { sourceStartedAt: event.sourceStartedAt } : {}), + } + if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) { + // A newly announced, not-yet-resumable Hermes session supersedes the old + // conversation before its first turn. Clear that old persisted identity + // now; a replacement process may register before the scan sees the old + // process fall, so relying on the falling edge can preserve a stale resume. + if ( + event.agentId === 'hermes' && + previousSession?.agentId === 'hermes' && + ( + previousSession.sessionId !== event.sessionId || + previousSession.profile !== event.profile + ) + ) emit(terminalId, null) + return } - if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) return const { agentId, sessionId } = event const profile = event.profile ? { profile: event.profile } : {} if (event.cwd) { @@ -167,8 +204,21 @@ export function ingestAgentSessionStamp(runtime: Runtime, event: AgentHookEvent) /** 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, endedAgentPid?: number): void { +export function clearAgentSessionStamp( + terminalId: string, + endedAgentPid?: number, + endedAgentStartedAt?: string, +): void { const st = stateFor(terminalId) + if ( + endedAgentPid !== undefined && + st.latest?.agentId === 'hermes' && + st.hermesHighWater && + compareAgentProcessGeneration( + { sourcePid: endedAgentPid, sourceStartedAt: endedAgentStartedAt }, + st.hermesHighWater, + ) === -1 + ) return if ( endedAgentPid !== undefined && st.latest?.sourcePid !== undefined && diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 095c59afe..d0572b366 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -186,7 +186,11 @@ 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, scanned?.endedAgentPid) + clearAgentSessionStamp( + terminalId, + scanned?.endedAgentPid, + scanned?.endedAgentStartedAt, + ) } } }), diff --git a/src/main/runtime/types.ts b/src/main/runtime/types.ts index 46e702a98..f89551693 100644 --- a/src/main/runtime/types.ts +++ b/src/main/runtime/types.ts @@ -106,6 +106,8 @@ export interface PtyActivity { agentPresent: boolean /** Pid whose disappearance produced an agent falling edge, when known. */ endedAgentPid?: number + /** Monotonic process-start marker paired with endedAgentPid, when known. */ + endedAgentStartedAt?: string } export interface ProcessHost { diff --git a/src/renderer/canvas/PanelRelationContextToggle.test.tsx b/src/renderer/canvas/PanelRelationContextToggle.test.tsx index 82f1a1b6a..750a6e2eb 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', } @@ -109,7 +110,7 @@ describe('PanelRelationContextToggle terminal hook registration', () => { }) } - for (const agentId of ['cursor', 'grok'] as const) { + for (const agentId of ['cursor', 'grok', 'hermes'] as const) { it(`keeps ${agentId} gated because it has no native context hook`, async () => { act(() => root.render()) await act(async () => openCli(agentId)) diff --git a/src/runtime/capabilities/agentHooks.test.ts b/src/runtime/capabilities/agentHooks.test.ts index 4185c587e..6a4e5d0a7 100644 --- a/src/runtime/capabilities/agentHooks.test.ts +++ b/src/runtime/capabilities/agentHooks.test.ts @@ -241,7 +241,13 @@ describe('agentHooks capability', () => { }) test('ingestion preserves Hermes profile identity from an authenticated plugin post', async () => { - const posts: Array<{ terminalId: string; agentId: string; pid?: number }> = [] + const posts: Array<{ + terminalId: string + agentId: string + pid?: number + kind?: string + sourceStartedAt?: string + }> = [] const cap = makeCap({ onPost: (value) => { posts.push(value) } }) const events = collect(cap) const { url, tokenFor } = await cap.endpoint() @@ -254,7 +260,7 @@ describe('agentHooks capability', () => { } expect((await post(url, tokenFor('rpty-hermes'), { - agentId: 'hermes', terminalId: 'rpty-hermes', pid: 4321, payload, + agentId: 'hermes', terminalId: 'rpty-hermes', pid: 4321, processStartedAt: '123456789', payload, })).status).toBe(204) await waitFor(() => events.length === 1) @@ -266,12 +272,14 @@ describe('agentHooks capability', () => { cwd: payload.cwd, profile: 'work', sourcePid: 4321, + sourceStartedAt: '123456789', }) expect(posts).toEqual([{ terminalId: 'rpty-hermes', agentId: 'hermes', pid: 4321, kind: 'turn-start', + sourceStartedAt: '123456789', }]) }) diff --git a/src/runtime/capabilities/agentHooks.ts b/src/runtime/capabilities/agentHooks.ts index c62ae7146..6a895e6cc 100644 --- a/src/runtime/capabilities/agentHooks.ts +++ b/src/runtime/capabilities/agentHooks.ts @@ -57,6 +57,7 @@ import { CATE_TERMINAL_ID_ENV, agentHookFolder, normalizeAgentHookPayload, + normalizeAgentSourceStartedAt, resolveAgentHookMode, type AgentHookAgentState, type AgentHookConfig, @@ -136,6 +137,7 @@ export interface AgentHooksDeps { agentId: AgentId pid?: number kind?: AgentHookEventKind + sourceStartedAt?: string }) => void | Promise /** Tests may replace the filesystem-backed CLI resolvers. */ titleResolvers?: AgentTitleResolvers @@ -414,6 +416,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook agentId?: unknown terminalId?: unknown pid?: unknown + processStartedAt?: unknown payload?: unknown threadId?: string turnId?: string @@ -456,6 +459,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook return } const event = normalizeAgentHookPayload(body.agentId, body.terminalId, body.payload as Record) + const sourceStartedAt = normalizeAgentSourceStartedAt(body.processStartedAt) if ( event?.agentId === 'hermes' && typeof body.pid === 'number' && @@ -463,6 +467,7 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook body.pid > 0 ) { event.sourcePid = body.pid + if (sourceStartedAt !== undefined) event.sourceStartedAt = sourceStartedAt } // Presence lineage: every authenticated post from a known agent // counts, even one whose payload normalizes to null — the post @@ -474,7 +479,12 @@ export function createAgentHooksCapability(deps: AgentHooksDeps = {}): AgentHook terminalId: body.terminalId, agentId: body.agentId as AgentId, pid: typeof body.pid === 'number' ? body.pid : undefined, - ...(body.agentId === 'hermes' && event ? { kind: event.kind } : {}), + ...(body.agentId === 'hermes' && event + ? { + kind: event.kind, + ...(sourceStartedAt !== undefined ? { sourceStartedAt } : {}), + } + : {}), }) } catch { /* presence tracking must never fail the hook */ } } diff --git a/src/runtime/capabilities/agentPresence.test.ts b/src/runtime/capabilities/agentPresence.test.ts index e725ba6d9..9fd383fbd 100644 --- a/src/runtime/capabilities/agentPresence.test.ts +++ b/src/runtime/capabilities/agentPresence.test.ts @@ -125,9 +125,10 @@ describe('notePost → presenceFor', () => { [70, 10, 'hermes'], ]) const { tracker, snapshot } = makeTracker(overlapping) - await tracker.notePost(T, 'hermes', 40, 'turn-start') - await tracker.notePost(T, 'hermes', 70, 'turn-start') - await tracker.notePost(T, 'hermes', 40, 'turn-end') + await tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + await tracker.notePost(T, 'hermes', 70, 'turn-start', '200') + await tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + await tracker.notePost(T, 'hermes', 40, 'turn-end', '100') expect(snapshot).toHaveBeenCalledTimes(2) const onlyNew = tree([[10, 1, 'zsh'], [70, 10, 'hermes']]) @@ -138,6 +139,118 @@ describe('notePost → presenceFor', () => { agentName: null, agentPresent: false, endedAgentPid: 70, + endedAgentStartedAt: '200', + }) + }) + + test('reverse snapshot completion cannot let an older generation overwrite a newer one', async () => { + const overlapping = tree([ + [10, 1, 'zsh'], + [40, 10, 'hermes'], + [70, 10, 'hermes'], + ]) + const resolvers: Array<(value: ProcTree) => void> = [] + const tracker = createAgentPresenceTracker({ + snapshot: () => new Promise((resolve) => resolvers.push(resolve)), + isAlive: () => true, + }) + + const older = tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + const newer = tracker.notePost(T, 'hermes', 70, 'turn-start', '200') + expect(resolvers).toHaveLength(2) + resolvers[1](overlapping) + await newer + resolvers[0](overlapping) + await older + + const onlyNew = tree([[10, 1, 'zsh'], [70, 10, 'hermes']]) + expect(tracker.presenceFor(T, onlyNew)).toEqual({ agentName: 'Hermes', agentPresent: true }) + }) + + test('a removed newer registration still blocks an older pending generation', async () => { + const overlapping = tree([ + [10, 1, 'zsh'], + [40, 10, 'hermes'], + [70, 10, 'hermes'], + ]) + const resolvers: Array<(value: ProcTree) => void> = [] + const tracker = createAgentPresenceTracker({ + snapshot: () => new Promise((resolve) => resolvers.push(resolve)), + isAlive: () => true, + }) + + const older = tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + const newer = tracker.notePost(T, 'hermes', 70, 'turn-start', '200') + resolvers[1](overlapping) + await newer + expect(tracker.presenceFor(T, tree([[10, 1, 'zsh']]))).toMatchObject({ + agentPresent: false, + endedAgentPid: 70, + }) + resolvers[0](overlapping) + await older + + expect(tracker.presenceFor(T, tree([[10, 1, 'zsh'], [40, 10, 'hermes']]))).toEqual({ + agentName: null, + agentPresent: false, + }) + }) + + test('a dead newer registration still blocks an older pending generation', async () => { + const overlapping = tree([ + [10, 1, 'zsh'], + [40, 10, 'hermes'], + [70, 10, 'hermes'], + ]) + const resolvers: Array<(value: ProcTree) => void> = [] + let newerAlive = true + const tracker = createAgentPresenceTracker({ + snapshot: () => new Promise((resolve) => resolvers.push(resolve)), + isAlive: (pid) => pid !== 70 || newerAlive, + }) + + const older = tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + const newer = tracker.notePost(T, 'hermes', 70, 'turn-start', '200') + resolvers[1](overlapping) + await newer + newerAlive = false + resolvers[0](overlapping) + await older + + expect(tracker.presenceFor(T, tree([[10, 1, 'zsh'], [40, 10, 'hermes']]))).toEqual({ + agentName: null, + agentPresent: false, + endedAgentPid: 70, + endedAgentStartedAt: '200', + }) + }) + + test('drop cancels an in-flight registration', async () => { + const processTree = tree([[10, 1, 'zsh'], [40, 10, 'hermes']]) + let resolveSnapshot!: (value: ProcTree) => void + const tracker = createAgentPresenceTracker({ + snapshot: () => new Promise((resolve) => { resolveSnapshot = resolve }), + isAlive: () => true, + }) + + const pending = tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + tracker.drop(T) + resolveSnapshot(processTree) + await pending + + expect(tracker.presenceFor(T, processTree)).toEqual({ agentName: null, agentPresent: false }) + }) + + test('falling-edge identity includes the process-start marker across pid reuse', async () => { + const oldTree = tree([[10, 1, 'zsh'], [40, 10, 'hermes']]) + const { tracker } = makeTracker(oldTree) + await tracker.notePost(T, 'hermes', 40, 'turn-start', '100') + + expect(tracker.presenceFor(T, tree([[10, 1, 'zsh']]))).toEqual({ + agentName: null, + agentPresent: false, + endedAgentPid: 40, + endedAgentStartedAt: '100', }) }) diff --git a/src/runtime/capabilities/agentPresence.ts b/src/runtime/capabilities/agentPresence.ts index 14726316d..8f480d251 100644 --- a/src/runtime/capabilities/agentPresence.ts +++ b/src/runtime/capabilities/agentPresence.ts @@ -31,7 +31,11 @@ import type { AgentId } from '../../shared/agents' import { AGENTS } from '../../shared/agents' -import type { AgentHookEventKind } from '../../shared/agentHooks' +import { + compareAgentProcessGeneration, + normalizeAgentSourceStartedAt, + type AgentHookEventKind, +} from '../../shared/agentHooks' import type { ProcTree } from './procfs' export interface AgentPresence { @@ -40,6 +44,7 @@ export interface AgentPresence { /** Agent pid whose disappearance produced this result. Present only for a * real falling edge, so downstream state can reject an older generation. */ endedAgentPid?: number + endedAgentStartedAt?: string } export interface AgentPresenceTracker { @@ -52,6 +57,7 @@ export interface AgentPresenceTracker { agentId: AgentId, pid: number | undefined, kind?: AgentHookEventKind, + 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 @@ -76,11 +82,19 @@ interface Registration { /** Pid carried by the hook post. For in-process hooks this is the agent pid; * for bridge-based hooks it distinguishes repeated posts for the fast path. */ sourcePid: number + sourceStartedAt?: string /** comm at registration time — presenceFor requires it unchanged, so a * recycled pid can't impersonate the agent. */ comm: string } +interface TerminalPresenceState { + /** Highest authenticated Hermes process generation accepted for this + * terminal. It intentionally outlives the live registration so an older + * in-flight post cannot reclaim presence after the newer process exits. */ + hermesHighWater?: Pick +} + function defaultIsAlive(pid: number): boolean { try { process.kill(pid, 0) @@ -104,29 +118,60 @@ function parentMap(tree: ProcTree): Map { export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresenceTracker { const isAlive = deps.isAlive ?? defaultIsAlive const registrations = new Map() + const terminalStates = new Map() + + const stateFor = (terminalId: string): TerminalPresenceState => { + let state = terminalStates.get(terminalId) + if (!state) { + state = {} + terminalStates.set(terminalId, state) + } + return state + } + + const shouldRetainExisting = ( + existing: Registration, + agentId: AgentId, + sourcePid: number, + kind: AgentHookEventKind | undefined, + sourceStartedAt: string | undefined, + ): boolean => { + if (existing.agentId !== agentId || !isAlive(existing.pid)) return false + if (agentId !== 'hermes') return true + const order = compareAgentProcessGeneration( + { sourcePid, sourceStartedAt }, + existing, + ) + if (order === -1 || order === 0) return true + const canReplaceLegacyGeneration = kind === 'session-start' || kind === 'turn-start' + return order === null && !canReplaceLegacyGeneration + } return { - async notePost(terminalId, agentId, pid, kind) { + async notePost(terminalId, agentId, pid, kind, 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 terminalState = stateFor(terminalId) // Bridge-based integrations spawn a fresh helper pid for many events, so // their live agent registration remains the fast-path authority. Hermes // posts in-process: a changed source pid is therefore a new generation // even while the previous process is still shutting down. const existing = registrations.get(terminalId) - const sameGeneration = agentId !== 'hermes' || existing?.sourcePid === pid - const canReplaceHermesGeneration = kind === 'session-start' || kind === 'turn-start' + const canonicalStartedAt = normalizeAgentSourceStartedAt(sourceStartedAt) if ( - existing && - existing.agentId === agentId && - isAlive(existing.pid) && - (sameGeneration || (agentId === 'hermes' && !canReplaceHermesGeneration)) + agentId === 'hermes' && + terminalState.hermesHighWater && + compareAgentProcessGeneration( + { sourcePid: pid, sourceStartedAt: canonicalStartedAt }, + terminalState.hermesHighWater, + ) === -1 ) return + if (existing && shouldRetainExisting(existing, agentId, pid, kind, canonicalStartedAt)) return const tree = await deps.snapshot() const parent = parentMap(tree) @@ -137,7 +182,30 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen visited.add(p) const comm = tree.nameByPid.get(p) if (comm && def.matchProcess(comm.toLowerCase())) { - registrations.set(terminalId, { agentId, pid: p, sourcePid: pid, comm }) + // drop() invalidates the captured state object. This prevents a + // pre-drop snapshot from registering into a reused terminal id. + if (terminalStates.get(terminalId) !== terminalState) return + if (agentId === 'hermes') { + const incoming = { sourcePid: pid, sourceStartedAt: canonicalStartedAt } + const highWater = terminalState.hermesHighWater + const order = highWater + ? compareAgentProcessGeneration(incoming, highWater) + : null + if (order === -1) return + if (!highWater || order === 1) terminalState.hermesHighWater = incoming + } + // A second post can complete its own snapshot while this await is in + // flight. Re-check before committing so reverse completion order can + // never let an older Hermes generation overwrite the newer one. + const current = registrations.get(terminalId) + if (current && shouldRetainExisting(current, agentId, pid, kind, canonicalStartedAt)) return + registrations.set(terminalId, { + agentId, + pid: p, + sourcePid: pid, + sourceStartedAt: canonicalStartedAt, + comm, + }) return } } @@ -156,11 +224,19 @@ export function createAgentPresenceTracker(deps: AgentPresenceDeps): AgentPresen // 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, endedAgentPid: reg.pid } + return { + agentName: null, + agentPresent: false, + endedAgentPid: reg.pid, + ...(reg.sourceStartedAt !== undefined + ? { endedAgentStartedAt: reg.sourceStartedAt } + : {}), + } }, drop(terminalId) { registrations.delete(terminalId) + terminalStates.delete(terminalId) }, } } diff --git a/src/runtime/capabilities/index.ts b/src/runtime/capabilities/index.ts index ef76b8bc7..bcab689b8 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, kind }) => agentPresence.notePost(terminalId, agentId, pid, kind), + onPost: ({ terminalId, agentId, pid, kind, sourceStartedAt }) => + agentPresence.notePost(terminalId, agentId, pid, kind, sourceStartedAt), }) const innerProc = createProcessCapability({ diff --git a/src/shared/agentHooks.test.ts b/src/shared/agentHooks.test.ts index 15f473b48..f3c4edaad 100644 --- a/src/shared/agentHooks.test.ts +++ b/src/shared/agentHooks.test.ts @@ -9,7 +9,9 @@ import { AGENT_HOOK_SPECS, CATE_HOOK_MARKER, codexTrustedHash, + compareAgentProcessGeneration, normalizeAgentHookPayload, + normalizeAgentSourceStartedAt, type HookInjectionContext, } from './agentHooks' @@ -20,6 +22,26 @@ const ctx: HookInjectionContext = { const norm = (agentId: string, payload: Record) => normalizeAgentHookPayload(agentId, 'term-1', payload) +describe('in-process hook generation ordering', () => { + test('canonicalizes valid clocks and rejects untrusted values', () => { + expect(normalizeAgentSourceStartedAt('000123')).toBe('123') + expect(normalizeAgentSourceStartedAt('0')).toBeUndefined() + expect(normalizeAgentSourceStartedAt('-1')).toBeUndefined() + expect(normalizeAgentSourceStartedAt('not-a-clock')).toBeUndefined() + expect(normalizeAgentSourceStartedAt(123)).toBeUndefined() + }) + + test('orders overlapping process generations and fails closed on a missing old clock', () => { + const oldGeneration = { sourcePid: 101, sourceStartedAt: '100' } + const newGeneration = { sourcePid: 202, sourceStartedAt: '200' } + expect(compareAgentProcessGeneration(oldGeneration, newGeneration)).toBe(-1) + expect(compareAgentProcessGeneration(newGeneration, oldGeneration)).toBe(1) + expect(compareAgentProcessGeneration(newGeneration, newGeneration)).toBe(0) + expect(compareAgentProcessGeneration({ sourcePid: 101 }, newGeneration)).toBe(-1) + expect(compareAgentProcessGeneration({ sourcePid: 202 }, newGeneration)).toBe(-1) + }) +}) + describe('codex trusted hash', () => { test('pinned vector — the exact builder verified live against codex', () => { // If this drifts, the live contract suite is the authority; both must move diff --git a/src/shared/agentHooks.ts b/src/shared/agentHooks.ts index 7ea6c4154..e444722f9 100644 --- a/src/shared/agentHooks.ts +++ b/src/shared/agentHooks.ts @@ -18,10 +18,6 @@ import { createHash } from 'crypto' import type { AgentId } from './agents' -import { - resolveAgentHookMode, - type AgentHookConfig, -} from './agentHookModes' export { resolveAgentHookMode, type AgentHookConfig, @@ -83,6 +79,9 @@ export interface AgentHookEvent { /** Process that emitted an in-process hook. Used only to reject lifecycle * events from an older process generation; never persisted across restarts. */ sourcePid?: number + /** Monotonic process-start clock supplied by an in-process hook provider. + * Unlike pid, this orders overlapping generations and is never persisted. */ + sourceStartedAt?: string /** Present only for session-title events. */ title?: string /** The raw payload as posted by the bridge, for consumers that need @@ -93,6 +92,42 @@ export interface AgentHookEvent { export type NormalizedHookFields = Pick & Partial> +/** Canonicalize an untrusted monotonic process-start timestamp. Decimal + * strings preserve nanosecond precision across the JSON/JavaScript boundary. */ +export function normalizeAgentSourceStartedAt(value: unknown): string | undefined { + if (typeof value !== 'string' || !/^[0-9]{1,32}$/.test(value)) return undefined + try { + const parsed = BigInt(value) + return parsed > 0n ? parsed.toString() : undefined + } catch { + return undefined + } +} + +/** Compare two in-process hook generations. `null` means the legacy fields do + * not establish an order, so callers may use lifecycle-kind fallback rules. */ +export function compareAgentProcessGeneration( + incoming: Pick, + current: Pick, +): -1 | 0 | 1 | null { + const incomingClock = normalizeAgentSourceStartedAt(incoming.sourceStartedAt) + const currentClock = normalizeAgentSourceStartedAt(current.sourceStartedAt) + if (incomingClock !== undefined && currentClock !== undefined) { + const left = BigInt(incomingClock) + const right = BigInt(currentClock) + if (left < right) return -1 + if (left > right) return 1 + return incoming.sourcePid === current.sourcePid ? 0 : -1 + } + if (incomingClock !== undefined) return 1 + if (currentClock !== undefined) { + // Once a clocked generation is authoritative, a legacy or malformed event + // cannot prove equality — even if the OS has reused the same pid. + return -1 + } + return incoming.sourcePid !== undefined && incoming.sourcePid === current.sourcePid ? 0 : null +} + // --------------------------------------------------------------------------- // Injection declarations // --------------------------------------------------------------------------- diff --git a/src/shared/agentRegistry.test.ts b/src/shared/agentRegistry.test.ts index 1da756ae1..405e9289a 100644 --- a/src/shared/agentRegistry.test.ts +++ b/src/shared/agentRegistry.test.ts @@ -38,6 +38,7 @@ describe('agent registry coverage', () => { codex: 'additional-context', cursor: null, grok: null, + hermes: null, kiro: 'stdout', opencode: 'opencode', }) diff --git a/src/shared/agents.ts b/src/shared/agents.ts index b1f930c68..e20d67f78 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -215,6 +215,7 @@ export const AGENTS: readonly AgentDef[] = [ codingAgentArgs: null, codingAgentFollowUp: false, matchProcess: (n) => n === 'hermes' || n === 'hermes.exe', + promptContextHook: null, resumeArgs: (sid, context) => context?.profile ? ['--profile', context.profile, 'chat', '--resume', sid] : null, From ed04cff7c78002f8f96a1bf17e8fa1caf28b6cbb Mon Sep 17 00:00:00 2001 From: Mathias Munk Hojer Date: Tue, 15 Sep 2026 16:15:22 +0200 Subject: [PATCH 5/6] fix: align runtime client version with Cate 2.0.1 --- src/runtime/version.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/runtime/version.test.ts diff --git a/src/runtime/version.test.ts b/src/runtime/version.test.ts new file mode 100644 index 000000000..1e7d06c46 --- /dev/null +++ b/src/runtime/version.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from 'vitest' +import packageJson from '../../package.json' +import { RUNTIME_VERSION } from './version' + +describe('runtime version', () => { + test('matches the application package version', () => { + expect(RUNTIME_VERSION).toBe(packageJson.version) + }) +}) From 666f6257013e125761ba240c291c8a93403ee808 Mon Sep 17 00:00:00 2001 From: Mathias Munk Hojer Date: Wed, 16 Sep 2026 09:22:24 +0200 Subject: [PATCH 6/6] fix: use Windows bsdtar for runtime install --- src/main/runtime/transports/localInstall.test.ts | 11 +++++++++-- src/main/runtime/transports/localTransport.ts | 14 +++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/transports/localInstall.test.ts b/src/main/runtime/transports/localInstall.test.ts index 384d3e6e3..e4bc2927a 100644 --- a/src/main/runtime/transports/localInstall.test.ts +++ b/src/main/runtime/transports/localInstall.test.ts @@ -20,12 +20,19 @@ import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, test } from 'vitest' -import { LocalSubprocessTransport } from './localTransport' +import { LocalSubprocessTransport, runtimeTarExecutable } from './localTransport' import type { RuntimeTarget } from '../runtimeArtifacts' import { RUNTIME_VERSION } from '../../../runtime/version' const execFileP = promisify(execFile) const TARGET: RuntimeTarget = 'linux-x64' // fixed, so the layout assertions are host-independent + +test('Windows runtime extraction uses System32 bsdtar instead of an inherited Git tar', () => { + expect(runtimeTarExecutable('win32', { SystemRoot: 'D:\\Windows' })).toBe( + path.join('D:\\Windows', 'System32', 'tar.exe'), + ) + expect(runtimeTarExecutable('linux', { PATH: '/usr/bin' })).toBe('tar') +}) const nodeName = process.platform === 'win32' ? 'node.exe' : 'node' let root: string @@ -45,7 +52,7 @@ async function makeTarball(name: string, body: string): Promise { await fs.writeFile(path.join(stage, 'runtime', 'bin', nodeName), body) await fs.writeFile(path.join(stage, 'runtime.cjs'), body) const tgz = path.join(root, `${name}.tgz`) - await execFileP('tar', ['-czf', tgz, '-C', stage, '.']) + await execFileP(runtimeTarExecutable(), ['-czf', tgz, '-C', stage, '.']) return tgz } diff --git a/src/main/runtime/transports/localTransport.ts b/src/main/runtime/transports/localTransport.ts index 6524f466d..a064dca1b 100644 --- a/src/main/runtime/transports/localTransport.ts +++ b/src/main/runtime/transports/localTransport.ts @@ -31,6 +31,18 @@ import { const execFileP = promisify(execFile) +/** Use Windows' bundled bsdtar directly. GUI Cate inherits the user's PATH, + * which can put Git's GNU tar first; GNU tar parses `C:\\...` archive paths as + * remote `host:path` specs and leaves runtime provisioning permanently empty. */ +export function runtimeTarExecutable( + platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + return platform === 'win32' + ? path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' +} + export interface LocalSubprocessOptions { root: string id: string @@ -187,7 +199,7 @@ export class LocalSubprocessTransport implements RuntimeTransport { await rm(staging, { recursive: true, force: true }) await mkdir(staging, { recursive: true }) try { - await execFileP('tar', ['-xzf', tarballPath, '-C', staging]) + await execFileP(runtimeTarExecutable(), ['-xzf', tarballPath, '-C', staging]) await writeFile(path.join(staging, '.ok'), await this.marker(version)) // Two renames, not one: POSIX has no atomic directory swap. The gap where // installDir is absent is a single syscall wide (vs. the minutes a full