diff --git a/README.md b/README.md index 2196ae6..475a97e 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ says it has no live timeline. | Codex CLI | `~/.codex/sessions///
/rollout-*.jsonl` (`CODEX_HOME`) | `src/codex-transcript.ts` | | Gemini CLI | `~/.gemini/tmp//chats/session-*.jsonl` (`GEMINI_CLI_HOME`) | `src/gemini-transcript.ts` | | OpenCode 2.x | no file — the `session_message` table of `~/.local/share/opencode/opencode.db` (`OPENCODE_DB`, `XDG_DATA_HOME`), asked again when the store changes, at most once a second per session. The pane's session is the newest one in its directory written to since the pane started | `src/opencode-transcript.ts` | -| Cursor, Hermes, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid | — none yet | — | +| Hermes | no file — the `messages` table of `~/.hermes/state.db`, asked again when the store changes, at most once a second per session. The pane's session is the newest one in its directory with a message since the pane started | `src/hermes-transcript.ts` | +| Cursor, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid | — none yet | — | Adding an agent means a projector file of its own, not another branch in an existing one: the formats share no vocabulary. Each projector is pinned to the diff --git a/src/hermes-transcript.test.ts b/src/hermes-transcript.test.ts new file mode 100644 index 0000000..92104b3 --- /dev/null +++ b/src/hermes-transcript.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + HERMES_MIN_QUERY_MS, + hermesLinesOf, + hermesRowsSql, + hermesSourcePath, + projectHermesEntries, + readHermesTranscript, + type HermesRow, +} from './hermes-transcript.js'; +import { sqliteJson } from './remote-agents.js'; +import { MAX_EVENT_TEXT_CHARS, type TurnEvent } from './transcript-tail.js'; + +/** + * Rows shaped like what `hermesRowsSql` returns, built by hand from the + * hermes 0.20.0 store's structure (see the module header) — no row of a real + * conversation is copied here. + */ +const T0 = 1_793_437_342; // seconds + +const user = (id: number, text: string): HermesRow => ({ id, role: 'user', ts: T0 + id, text }); +const reply = (id: number, text: string, tools?: HermesRow['tools']): HermesRow => ({ id, role: 'assistant', ts: T0 + id, text, tools }); +const result = (id: number, callId: string, lines = 3): HermesRow => ({ id, role: 'tool', ts: T0 + id, callId, lines }); + +const events = (rows: HermesRow[]): TurnEvent[] => projectHermesEntries(hermesLinesOf(rows)); + +describe('hermes transcript projects', () => { + it('projects a prompt, a tool round trip and the reply in order', () => { + const out = events([ + user(1, 'read the file'), + reply(2, '', [{ id: 'call_1', name: 'read_file', target: '/tmp/a.txt' }]), + result(3, 'call_1', 12), + reply(4, 'done'), + ]); + expect(out.map((e) => e.kind)).toEqual(['prompt', 'tool', 'tool_result', 'text']); + expect(out[1]).toMatchObject({ kind: 'tool', id: 'call_1', name: 'read_file', target: '/tmp/a.txt' }); + expect(out[2]).toMatchObject({ kind: 'tool_result', id: 'call_1', ok: true, lines: 12 }); + expect(out[3]).toMatchObject({ kind: 'text', text: 'done' }); + }); + + it('stamps seconds as an ISO time, not 1970', () => { + const [prompt] = events([user(1, 'hi')]); + expect(prompt?.at).toBe(new Date(T0 * 1000 + 1000).toISOString()); + }); + + it('draws no empty reply for an assistant row that only calls tools', () => { + const out = events([user(1, 'go'), reply(2, ' ', [{ id: 'c', name: 'ls' }])]); + expect(out.map((e) => e.kind)).toEqual(['prompt', 'tool']); + }); + + it('cuts prose at the event cap', () => { + const [prompt] = events([user(1, 'x'.repeat(MAX_EVENT_TEXT_CHARS * 2))]); + expect((prompt as { text: string }).text.length).toBeLessThanOrEqual(MAX_EVENT_TEXT_CHARS); + }); + + it('skips a malformed tool list without throwing and names it', () => { + const unknown: string[] = []; + const lines = hermesLinesOf([user(1, 'go'), reply(2, 'x', 'not json'), reply(3, 'y', [{ name: 'no id' } as never])]); + const out = projectHermesEntries(lines, { onUnknown: (w) => unknown.push(w) }); + expect(out.map((e) => e.kind)).toEqual(['prompt', 'text', 'text']); + expect(unknown).toHaveLength(2); + }); + + it('keeps only the turn in flight on a backfill', () => { + const rows = [user(1, 'old'), reply(2, 'old reply'), user(3, 'new'), reply(4, 'new reply')]; + const out = projectHermesEntries(hermesLinesOf(rows), { sinceLastPrompt: true }); + expect(out.map((e) => e.kind)).toEqual(['prompt', 'text']); + expect((out[0] as { text: string }).text).toBe('new'); + }); +}); + +describe('hermes transcript never carries a command', () => { + it('drops command and cmd arguments in SQL, keeps a path', () => { + const db = join(mkdtempSync(join(tmpdir(), 'hwip-hermes-')), 'state.db'); + const calls = JSON.stringify([ + { id: 'c1', type: 'function', function: { name: 'terminal', arguments: JSON.stringify({ command: 'curl -H "Authorization: Bearer SECRET"' }) } }, + { id: 'c2', type: 'function', function: { name: 'sh', arguments: JSON.stringify({ cmd: 'echo SECRET' }) } }, + { id: 'c3', type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/ok.txt', command: 'SECRET' }) } }, + { id: 'c4', type: 'function', function: { name: 'broken', arguments: 'not json SECRET' } }, + ]).replace(/'/g, "''"); + const sql = ` + CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, + tool_call_id TEXT, tool_calls TEXT, timestamp REAL NOT NULL, active INTEGER DEFAULT 1); + INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s_1', 'user', 'go', ${T0}); + INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES ('s_1', 'assistant', '', '${calls}', ${T0 + 1}); + INSERT INTO messages (session_id, role, content, tool_call_id, timestamp) VALUES ('s_1', 'tool', 'a +b +c', 'c3', ${T0 + 2}); + INSERT INTO messages (session_id, role, content, timestamp, active) VALUES ('s_1', 'user', 'rewound', ${T0 + 3}, 0); + INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s_2', 'user', 'other session', ${T0 + 4});`; + const made = spawnSync('sqlite3', [db, sql], { encoding: 'utf-8' }); + expect(made.status).toBe(0); + + const rows = sqliteJson(db, hermesRowsSql('s_1', null)); + expect(rows).not.toBeNull(); + const out = projectHermesEntries(hermesLinesOf(rows as HermesRow[])); + expect(JSON.stringify(out)).not.toContain('SECRET'); + expect(out.filter((e) => e.kind === 'tool').map((e) => (e as { target?: string }).target)).toEqual([undefined, undefined, '/tmp/ok.txt', undefined]); + expect(out.find((e) => e.kind === 'tool_result')).toMatchObject({ id: 'c3', lines: 3 }); + expect(out.some((e) => e.kind === 'prompt' && (e as { text: string }).text === 'rewound')).toBe(false); + expect(out.some((e) => e.kind === 'prompt' && (e as { text: string }).text === 'other session')).toBe(false); + }); +}); + +describe('hermes transcript reader', () => { + const db = '/tmp/hermes-state.db'; + const path = hermesSourcePath(db, 's_1'); + + it('reads the backfill, then only rows after the last id', () => { + let clock = 10_000; + const query = vi.fn((_db: string, sql: string) => (sql.includes('ORDER BY m.id DESC') ? [user(1, 'a'), reply(2, 'b')] : [reply(3, 'c')])); + const deps = { query, stamp: () => 'v1', now: () => clock }; + const first = readHermesTranscript(path, null, deps); + expect(first?.lines).toHaveLength(2); + clock += HERMES_MIN_QUERY_MS; + const second = readHermesTranscript(path, first!.state, { ...deps, stamp: () => 'v2' }); + expect(second?.lines).toHaveLength(1); + expect(query.mock.calls[1]![1]).toContain('m.id >= 3'); + }); + + it('returns null when the store did not change, or inside the minimum interval', () => { + let clock = 10_000; + const query = vi.fn(() => [user(1, 'a')]); + const deps = { query, stamp: () => 'v1', now: () => clock }; + const first = readHermesTranscript(path, null, deps)!; + clock += HERMES_MIN_QUERY_MS; + expect(readHermesTranscript(path, first.state, deps)).toBeNull(); // same stamp + expect(readHermesTranscript(path, first.state, { ...deps, stamp: () => 'v2', now: () => clock - 1 })).toBeNull(); // too soon + expect(query).toHaveBeenCalledTimes(1); + }); + + it('starts over when the pane moves to another session', () => { + const clock = 10_000; + const query = vi.fn(() => [user(7, 'a')]); + const first = readHermesTranscript(path, null, { query, stamp: () => 'v1', now: () => clock })!; + readHermesTranscript(hermesSourcePath(db, 's_2'), first.state, { query, stamp: () => 'v2', now: () => clock + 5000 }); + expect(query.mock.calls[1]![1]).toContain('ORDER BY m.id DESC'); + }); + + it('returns null when the query fails', () => { + expect(readHermesTranscript(path, null, { query: () => null, stamp: () => 'v1', now: () => 10_000 })).toBeNull(); + }); +}); + +describe('hermes transcript session id', () => { + it('refuses an id that could carry SQL, before any query is built', () => { + for (const bad of ["x'; DROP TABLE messages; --", '', 'a b', '../x', 'x'.repeat(97)]) { + expect(() => hermesRowsSql(bad, null)).toThrow(); + } + expect(() => hermesRowsSql('20260814_112441_5d0c6f', null)).not.toThrow(); + }); + + it('reads nothing from a path whose id or db is not safe', () => { + const query = vi.fn(() => []); + const deps = { query, stamp: () => 'v', now: () => 10_000 }; + expect(readHermesTranscript("/tmp/state.db#x'; --", null, deps)).toBeNull(); + expect(readHermesTranscript('relative.db#s_1', null, deps)).toBeNull(); + expect(readHermesTranscript('-n#s_1', null, deps)).toBeNull(); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hermes-transcript.ts b/src/hermes-transcript.ts new file mode 100644 index 0000000..41eb543 --- /dev/null +++ b/src/hermes-transcript.ts @@ -0,0 +1,307 @@ +/** + * Hermes' session store, read into the events the timeline draws. + * + * Hermes keeps no transcript file: every conversation is rows of `messages` in + * `~/.hermes/state.db` (SQLite). Like opencode's, the row behind this agent + * carries a reader of its own (`readHermesTranscript`, a `TranscriptReader`) + * and a projector for what it hands back (`projectHermesEntries`). + * + * Shape measured 2026-11-01 against hermes 0.20.0 (schema_version 25), with + * `.schema` and the writer, `hermes_state.py`: + * + * - `messages(id INTEGER AUTOINCREMENT, session_id, role, content, + * tool_call_id, tool_calls, tool_name, timestamp REAL, active, compacted, …)`. + * `timestamp` is **seconds** since the epoch, as a REAL. + * - Rows are inserted once and never rewritten (the only UPDATEs touch + * `display_*` and flip `active`/`compacted` when a conversation is rewound or + * compressed), so `id` is a safe cursor: a row read is final. + * - `role` is `user` · `assistant` · `tool`. An assistant row that calls tools + * has `tool_calls`, a JSON array of `{ id, type: "function", function: { + * name, arguments } }` where `arguments` is itself a JSON **string**; each + * call is answered by a `tool` row whose `tool_call_id` is that `id`. + * + * Only rows with `active = 1` are drawn — a rewound turn is not part of the + * conversation any more. + */ + +import { statSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; + +import { + clamp, + MAX_EVENT_FIELD_CHARS, + MAX_EVENT_TEXT_CHARS, + oneLine, + sliceSinceLastPrompt, + TRANSCRIPT_BACKFILL_BYTES, + type ProjectorOptions, + type TailRead, + type TailState, + type TranscriptProjector, + type TurnEvent, +} from './transcript-tail.js'; + +/** + * A Hermes session id (`__`, measured). The query has + * nowhere to bind a parameter (`sqliteJson` runs the `sqlite3` binary), so an + * id goes into the SQL text only after it passes this. + */ +export const HERMES_SESSION_ID = /^[A-Za-z0-9_-]{1,96}$/; + +/** The transcript "path" a Hermes pane resolves to: the store and the session in one string. */ +export const hermesSourcePath = (db: string, sessionId: string): string => `${db}#${sessionId}`; + +const parseSourcePath = (path: string): { db: string; sessionId: string } | null => { + const at = path.lastIndexOf('#'); + if (at <= 0) return null; + const sessionId = path.slice(at + 1); + const db = path.slice(0, at); + // The path also comes back from the recorder's state file and goes to `sqlite3` as an argument: + // a relative one could start with `-` and read as an option. + return HERMES_SESSION_ID.test(sessionId) && isAbsolute(db) ? { db, sessionId } : null; +}; + +/** One tool call of an assistant row, reduced in SQL to the fields drawn. */ +export interface HermesTool { + id?: string | null; + name?: string | null; + target?: string | null; +} + +/** One `messages` row as the query below returns it. */ +export interface HermesRow { + id: number; + role: string; + /** Seconds since the epoch. */ + ts?: number | null; + text?: string | null; + /** Assistant rows: the calls, as a JSON array (string) or already parsed. */ + tools?: HermesTool[] | string | null; + /** `tool` rows: the id of the call answered. */ + callId?: string | null; + /** `tool` rows: line count of the output. */ + lines?: number | null; +} + +const isRow = (value: unknown): value is HermesRow => { + if (!value || typeof value !== 'object') return false; + const row = value as Record; + return typeof row.id === 'number' && Number.isSafeInteger(row.id) && typeof row.role === 'string'; +}; + +/** Rows read on the first look at a session. */ +export const HERMES_BACKFILL_ROWS = 200; +/** Rows read per look after that. */ +export const HERMES_ROWS_PER_READ = 500; +/** The fewest milliseconds between two queries for one session. */ +export const HERMES_MIN_QUERY_MS = 1_000; + +/** + * Which tool argument reads as "what this call is about", first match wins. + * `command` and `cmd` are absent on purpose: a command line is the argument + * most likely to carry a secret. + */ +const TARGET_KEYS = ['path', 'file_path', 'pattern', 'query', 'url', 'image_url', 'name'] as const; + +const SQL_TEXT_CHARS = MAX_EVENT_TEXT_CHARS + 1; + +/** + * The rows of one session from message id `from` on, or its last + * `HERMES_BACKFILL_ROWS` when `from` is null. Read-only; exported for the test + * that pins the id check. + */ +export const hermesRowsSql = (sessionId: string, from: number | null): string => { + if (!HERMES_SESSION_ID.test(sessionId)) throw new Error('not a hermes session id'); + if (from !== null && !Number.isSafeInteger(from)) throw new Error('not a row position'); + const args = `json_extract(c.value, '$.function.arguments')`; + const arg = (key: string) => `CASE WHEN json_valid(${args}) THEN json_extract(${args}, '$.${key}') END`; + const target = `substr(coalesce(${TARGET_KEYS.map(arg).join(', ')}), 1, ${MAX_EVENT_FIELD_CHARS + 1})`; + const tools = `CASE WHEN m.role = 'assistant' AND m.tool_calls IS NOT NULL AND json_valid(m.tool_calls) + THEN (SELECT json_group_array(json_object('id', coalesce(json_extract(c.value, '$.id'), json_extract(c.value, '$.call_id')), + 'name', json_extract(c.value, '$.function.name'), 'target', ${target})) + FROM json_each(m.tool_calls) c) END`; + const select = `SELECT m.id AS id, m.role AS role, m.timestamp AS ts, + CASE WHEN m.role IN ('user', 'assistant') THEN substr(m.content, 1, ${SQL_TEXT_CHARS}) END AS text, + ${tools} AS tools, + CASE WHEN m.role = 'tool' THEN m.tool_call_id END AS callId, + CASE WHEN m.role = 'tool' THEN length(m.content) - length(replace(m.content, char(10), '')) + 1 END AS lines + FROM messages m + WHERE m.session_id = '${sessionId}' AND m.role IN ('user', 'assistant', 'tool') AND coalesce(m.active, 1) = 1`; + return from === null + ? `SELECT * FROM (${select} ORDER BY m.id DESC LIMIT ${HERMES_BACKFILL_ROWS}) ORDER BY id` + : `${select} AND m.id >= ${from} ORDER BY m.id LIMIT ${HERMES_ROWS_PER_READ}`; +}; + +const isoOfSeconds = (seconds: unknown): { at?: string } => { + if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return {}; + const date = new Date(seconds * 1000); + return Number.isNaN(date.getTime()) ? {} : { at: date.toISOString() }; +}; + +const toolsOf = (row: HermesRow): HermesTool[] | null => { + if (row.tools === undefined || row.tools === null) return []; + let parsed: unknown = row.tools; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + return null; + } + } + return Array.isArray(parsed) ? (parsed as HermesTool[]) : null; +}; + +const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0); + +const unknownLine = (what: string): string => JSON.stringify({ unknown: what }); + +/** Rows → one JSON event per line (what the projector reads back), in row order. */ +export const hermesLinesOf = (rows: readonly HermesRow[]): string[] => { + const lines: string[] = []; + const warned = new Set(); + const once = (key: string, what: string): void => { + if (warned.has(key)) return; + warned.add(key); + lines.push(unknownLine(what)); + }; + for (const row of rows) { + const at = isoOfSeconds(row.ts); + if (row.role === 'user') { + const text = (typeof row.text === 'string' ? row.text : '').trim(); + if (text) lines.push(JSON.stringify({ kind: 'prompt', text: clamp(text, MAX_EVENT_TEXT_CHARS), ...at })); + continue; + } + if (row.role === 'assistant') { + const text = (typeof row.text === 'string' ? row.text : '').trim(); + if (text) lines.push(JSON.stringify({ kind: 'text', text: clamp(text, MAX_EVENT_TEXT_CHARS), ...at })); + const tools = toolsOf(row); + if (tools === null) once(`${row.id}:x`, 'hermes tool_calls that are not a list'); + for (const tool of tools ?? []) { + if (!tool || typeof tool !== 'object' || typeof tool.id !== 'string' || typeof tool.name !== 'string') { + once(`${row.id}:x`, 'hermes tool call without an id or name'); + continue; + } + const line = oneLine(typeof tool.target === 'string' ? tool.target : undefined); + const target = line ? clamp(line) : undefined; + lines.push(JSON.stringify({ kind: 'tool', id: clamp(tool.id), name: clamp(tool.name), ...(target ? { target } : {}), ...at })); + } + continue; + } + if (row.role === 'tool') { + if (typeof row.callId !== 'string' || !row.callId) { + once(`${row.id}:x`, 'hermes tool result without a call id'); + continue; + } + const count = num(row.lines); + lines.push(JSON.stringify({ kind: 'tool_result', id: clamp(row.callId), ok: true, ...(count ? { lines: count } : {}), ...at })); + } + } + return lines; +}; + +/** What the reader keeps between reads, on `TailState.cursor`. */ +interface HermesCursor { + readonly session: string; + /** The next message id to ask for. */ + readonly from: number; + /** The store's files as of the last query: unchanged means nothing to ask. */ + readonly stamp: string; + readonly queriedAt: number; +} + +const cursorOf = (prev: TailState | null, session: string): HermesCursor | null => { + const c: unknown = prev?.cursor; + if (!c || typeof c !== 'object') return null; + const { session: s, from, stamp, queriedAt } = c as Record; + if (s !== session || typeof from !== 'number' || !Number.isSafeInteger(from)) return null; + if (typeof stamp !== 'string' || typeof queriedAt !== 'number') return null; + return { session, from, stamp, queriedAt }; +}; + +/** When a query last failed, per source path — so an unreadable store is not asked on every tick. */ +const failedAt = new Map(); + +export interface HermesReadDeps { + /** Rows of a SQLite file, null when the read failed (`sqliteJson`). */ + query: (db: string, sql: string) => unknown[] | null; + /** The store's files, size and mtime — any write shows up in one of them. */ + stamp?: (db: string) => string | null; + now?: () => number; +} + +const fileStamp = (db: string): string | null => { + const parts: string[] = []; + for (const path of [db, `${db}-wal`]) { + try { + const s = statSync(path); + parts.push(`${s.size}:${s.mtimeMs}`); + } catch { + parts.push('-'); + } + } + return parts[0] === '-' ? null : parts.join('|'); +}; + +/** + * What was added to one Hermes session since `prev`, or null when nothing was — + * the store's files have not moved, the last query was under + * `HERMES_MIN_QUERY_MS` ago, or the store cannot be read. The WAL moves on every + * write by every session on the machine, so the time floor is what keeps a busy + * neighbour from turning a 500 ms poll into a `sqlite3` spawn per tick. + */ +export const readHermesTranscript = (path: string, prev: TailState | null, deps: HermesReadDeps): TailRead | null => { + const source = parseSourcePath(path); + if (!source) return null; + const now = deps.now ?? Date.now; + const stamp = (deps.stamp ?? fileStamp)(source.db); + if (stamp === null) return null; + + const cursor = cursorOf(prev, source.sessionId); + if (cursor && cursor.stamp === stamp) return null; + const last = Math.max(cursor?.queriedAt ?? -Infinity, failedAt.get(path) ?? -Infinity); + if (now() - last < HERMES_MIN_QUERY_MS) return null; + + const raw = deps.query(source.db, hermesRowsSql(source.sessionId, cursor ? cursor.from : null)); + if (raw === null) { + failedAt.set(path, now()); + return null; + } + failedAt.delete(path); + const rows = raw.filter(isRow); + const newest = rows.reduce((max, r) => Math.max(max, r.id), cursor ? cursor.from - 1 : 0); + const state: TailState = { + offset: newest, + // `agent read` says there is more above when the read was a window. + size: !cursor && rows.length >= HERMES_BACKFILL_ROWS ? TRANSCRIPT_BACKFILL_BYTES + 1 : 0, + mtimeMs: 0, + ino: 0, + carry: '', + resyncing: false, + salvage: [], + cursor: { session: source.sessionId, from: newest + 1, stamp, queriedAt: now() } satisfies HermesCursor, + }; + return { lines: hermesLinesOf(rows), state, bytesRead: 0, droppedLines: 0 }; +}; + +const EVENT_KINDS = new Set(['prompt', 'text', 'tool', 'tool_result']); + +/** The reader's lines back into events; names what the reader marked unknown. */ +export const projectHermesEntries: TranscriptProjector = (lines, opts: ProjectorOptions = {}): TurnEvent[] => { + const events: TurnEvent[] = []; + for (const raw of lines) { + let entry: Record; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') continue; + entry = parsed as Record; + } catch { + continue; + } + if (typeof entry.unknown === 'string') { + opts.onUnknown?.(entry.unknown); + continue; + } + if (typeof entry.kind === 'string' && EVENT_KINDS.has(entry.kind)) events.push(entry as TurnEvent); + } + return sliceSinceLastPrompt(events, opts); +}; diff --git a/src/remote-agents.test.ts b/src/remote-agents.test.ts index d68b3d7..9c97576 100644 --- a/src/remote-agents.test.ts +++ b/src/remote-agents.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readTranscriptDelta } from './transcript-tail.js'; -import { detectClaudeProviderName, detectClaudeSessionHandle, detectClaudeSessionIdByPid, findAgentBySubcommand, matchAgentByPaneCommand, REMOTE_AGENTS } from './remote-agents.js'; +import { hermesTranscriptPath, detectClaudeProviderName, detectClaudeSessionHandle, detectClaudeSessionIdByPid, findAgentBySubcommand, matchAgentByPaneCommand, REMOTE_AGENTS } from './remote-agents.js'; import type { PidSessionRecord } from './remote-agents.js'; // Top-level CLI commands the registry's subcommands must never collide @@ -40,16 +40,16 @@ describe('remote-agents.ts: table invariants', () => { } }); - // Cursor and Hermes stay unmeasured (Cursor: no TUI transcript seen; - // Hermes: SQLite, no reply rows seen), so a projector for either would be a - // guess. opencode carries the pair since its 2.x store was read (C137). - it('gemini and opencode carry the transcript pair; cursor and hermes still carry none', () => { - for (const kind of ['gemini', 'opencode']) { + // Cursor stays unmeasured (no TUI transcript seen), so a projector for it + // would be a guess. opencode carries the pair since its 2.x store was read + // (C137), hermes since its `messages` table was (hermes-transcript.ts). + it('gemini, opencode and hermes carry the transcript pair; cursor still carries none', () => { + for (const kind of ['gemini', 'opencode', 'hermes']) { const row = REMOTE_AGENTS.find((a) => a.kind === kind); expect(typeof row?.resolveTranscript, kind).toBe('function'); expect(typeof row?.projectTranscript, kind).toBe('function'); } - for (const kind of ['cursor', 'hermes']) { + for (const kind of ['cursor']) { const row = REMOTE_AGENTS.find((a) => a.kind === kind); expect(row?.resolveTranscript, kind).toBeUndefined(); expect(row?.projectTranscript, kind).toBeUndefined(); @@ -57,12 +57,12 @@ describe('remote-agents.ts: table invariants', () => { }); // A reader with no path to read is dead code, and the file tailer is the - // reader for every row whose transcript is a file — only opencode's is not. - it('only a row that resolves a transcript carries its own reader, and only opencode does', () => { + // reader for every row whose transcript is a file — only the SQLite ones are not. + it('only a row that resolves a transcript carries its own reader, and only opencode and hermes do', () => { for (const a of REMOTE_AGENTS) { if (a.readTranscript) expect(a.resolveTranscript, a.kind).toBeDefined(); } - expect(REMOTE_AGENTS.filter((a) => a.readTranscript).map((a) => a.kind)).toEqual(['opencode']); + expect(REMOTE_AGENTS.filter((a) => a.readTranscript).map((a) => a.kind)).toEqual(['hermes', 'opencode']); }); it('no subcommand collides with a built-in CLI command', () => { @@ -1091,3 +1091,24 @@ describe('remote-agents.ts: bounded reader spawns', () => { } }); }); + +describe('remote-agents.ts: hermes transcript', () => { + const NOW = 1_800_000_000_000; + const deps = (rows: unknown[]) => ({ rows, startTimes: new Map([[50, NOW - 60_000]]), descendants: new Set([50]) }); + + it('resolves the pane session to a # path, and nothing without one', () => { + const live = { id: '20261101_100000_abcdef', cwd: '/work', active: (NOW - 1_000) / 1_000 }; + const path = hermesTranscriptPath('/work', 99, deps([live])); + expect(path).toMatch(/\.hermes\/state\.db#20261101_100000_abcdef$/); + expect(hermesTranscriptPath('/elsewhere', 99, deps([live]))).toBeNull(); + expect(hermesTranscriptPath(null, 99, deps([live]))).toBeNull(); + expect(hermesTranscriptPath('/work', undefined, deps([live]))).toBeNull(); + }); + + it('wires the projector and its own reader on the hermes row', () => { + const row = REMOTE_AGENTS.find((a) => a.kind === 'hermes'); + expect(typeof row?.resolveTranscript).toBe('function'); + expect(typeof row?.projectTranscript).toBe('function'); + expect(typeof row?.readTranscript).toBe('function'); + }); +}); diff --git a/src/remote-agents.ts b/src/remote-agents.ts index e2439e7..a4fb692 100644 --- a/src/remote-agents.ts +++ b/src/remote-agents.ts @@ -22,6 +22,7 @@ import { projectPiEntries } from './pi-transcript.js'; import { createTailWatch, latestClaudeAiTitle, piNamesStep, type ProviderName } from './session-title.js'; import { projectCodexEntries } from './codex-transcript.js'; import { projectGeminiEntries } from './gemini-transcript.js'; +import { hermesSourcePath, projectHermesEntries, readHermesTranscript } from './hermes-transcript.js'; import { opencodeDbPath, opencodeSourcePath, OPENCODE_SESSION_ID, projectOpencodeEntries, readOpencodeTranscript } from './opencode-transcript.js'; export interface RemoteAgent { @@ -991,6 +992,20 @@ export const detectHermesResumeId = ( return best?.id ?? null; }; +/** `sqliteJson` with its own warning: a failed transcript read costs the timeline, not the session list. */ +const queryHermesTranscript = (db: string, sql: string): unknown[] | null => { + const rows = sqliteJson(db, sql); + if (rows === null) warnReaderOnce('hermes transcript', `sqlite3 ${basename(db)}`, 'hermes panes show no live timeline'); + return rows; +}; + +/** The store and the pane's session in one string, as opencode's. Null until the session has a message. */ +export const hermesTranscriptPath = (paneCwd: string | null, panePid?: number, deps: StoreResolveDeps = {}): string | null => { + if (!paneCwd) return null; + const id = detectHermesResumeId(paneCwd, panePid, deps); + return id ? hermesSourcePath(HERMES_STATE_DB, id) : null; +}; + // ── Codex ──────────────────────────────────────────────────────── const CODEX_DIR = join(homedir(), '.codex'); @@ -1531,6 +1546,10 @@ const REMOTE_AGENT_TABLE = [ // `_` string, and nothing on the wire consumes it yet. resolveSessionName: detectHermesSessionName, resolveResumeId: detectHermesResumeId, + // No file: the conversation is rows of `messages` in the state store. + resolveTranscript: (paneCwd, panePid) => hermesTranscriptPath(paneCwd, panePid), + projectTranscript: projectHermesEntries, + readTranscript: (path, prev) => readHermesTranscript(path, prev, { query: queryHermesTranscript }), // Measured: `--resume ` carries the conversation on, and an id the // store lost starts a new session rather than exit — so, like opencode, // not checked against the store.