From 7c3636947edadb62886c99ef4d32f52f39a38a09 Mon Sep 17 00:00:00 2001 From: hwjoo Date: Fri, 9 Oct 2026 19:16:34 +0900 Subject: [PATCH] feat(chat): read Hermes Agent conversations from its own session store A Hermes pane had no conversation reader, so Chat fell back to the terminal-text heuristic. server/hermes.ts reads Hermes's state.db read-only: the session herdr's herdr:hermes integration reports, else the one a foreground process of the pane holds in runtime/active_sessions.json (an ended one is no answer: the classic CLI keeps its lease after /new), shown as Hermes's own display projection and paged by display group. Work blocks count Hermes's terminal, read_file and write_file calls as commands, reads and edits. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + README.ja.md | 4 +- README.ko.md | 4 +- README.md | 4 +- README.zh-CN.md | 4 +- docs/guide.md | 10 + server/AGENTS.md | 2 +- server/api.contract.test.ts | 42 +++ server/conversation.ts | 56 +++- server/hermes.test.ts | 339 +++++++++++++++++++++++++ server/hermes.ts | 494 ++++++++++++++++++++++++++++++++++++ server/index.ts | 9 +- shared/protocol.ts | 4 +- site/demo/transport.ts | 2 +- src/lib/toolVerbs.test.ts | 2 + src/lib/toolVerbs.ts | 4 +- src/lib/workBlocks.test.ts | 4 + src/lib/workBlocks.ts | 2 +- 18 files changed, 965 insertions(+), 29 deletions(-) create mode 100644 server/hermes.test.ts create mode 100644 server/hermes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 528dc63b3f..b9a33af954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Added +- Chat reads Hermes Agent sessions from Hermes's own database (`~/.hermes/state.db`, profiles + and `HERMES_HOME` included): prompts, answers, reasoning and tool calls with their output, + instead of the terminal text. The session is the one herdr's Hermes integration reports, or + the one the pane's Hermes process holds; install the integration + (`herdr integration install hermes`) for the chat to follow a `/new` in the classic CLI. + Hermes's `terminal`, `read_file` and `write_file` calls count as commands, reads and edits. + ## [0.4.3] - 2026-10-09 ### Fixed diff --git a/README.ja.md b/README.ja.md index aabbf1c2c5..569341be3b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -76,7 +76,7 @@ https://github.com/user-attachments/assets/d854dbb6-64bd-4eba-81c7-fbd3f525726b

どのクリップも、パソコンとスマートフォンを同時に収録した実際の動作です。等速・カットなし。クリックすると動画全体を再生できます。

-- **チャットとターミナルを、ひとつのペインで** — Claude Code、Codex、omp、omo、gjc、pi の会話履歴と、herdr が識別した OpenCode 2 と Devin CLI のセッションを表示し、ワンクリックでライブターミナルに切り替えられます。エージェントの作業中に次のメッセージを書いておくと次のターンで送られ、Send now ですぐに送ることもできます。[対応エージェント →](docs/guide.md#supported-agents) +- **チャットとターミナルを、ひとつのペインで** — Claude Code、Codex、omp、omo、gjc、pi の会話履歴と、herdr や実行中のプロセスが識別した OpenCode 2、Devin CLI、Hermes Agent のセッションを表示し、ワンクリックでライブターミナルに切り替えられます。エージェントの作業中に次のメッセージを書いておくと次のターンで送られ、Send now ですぐに送ることもできます。[対応エージェント →](docs/guide.md#supported-agents) - **タップで承認** — 承認リクエスト、質問、計画メニューがカードになり、問いかけがまだ有効か確認してから回答を送信します。 - **対応が必要なときに通知** — すべてのペインの状態をリアルタイムに表示し、アプリを開いているときは通知が上から降りてきます。入力が必要なときや完了したときには、アプリを閉じていてもプッシュ通知が届きます。 - **スマートフォンにインストール** — キーボードの上にキーバー(Esc、Tab、Ctrl、Alt、Shift、Enter、矢印キー)が並ぶ PWA。キーの並べ替えや独自のキー組み合わせの追加は Settings(設定)でできます。Tailscale のアドレスは QR コードで表示されます。[スマートフォンの設定 →](docs/guide.md#on-your-phone) @@ -128,7 +128,7 @@ herdr が起動した状態で **[localhost:7317](http://localhost:7317)** を **チャット表示はどのエージェントに対応していますか?** -Claude Code、Codex、omp、omo、gjc、pi は、それぞれのセッションファイルから読み取ります。OpenCode 2 は herdr が報告したセッションを OpenCode 自身のデータベースから読み取り、ホーム画面または 1.x のストアではライブターミナルを表示します。Devin CLI は `devin --resume ` のように herdr や実行中のプロセスがセッションを示すときにチャットになり、単に `devin` で起動した場合はライブターミナルを表示します。それ以外のプログラムはライブターミナルと状態が表示されます。[対応エージェント →](docs/guide.md#supported-agents) +Claude Code、Codex、omp、omo、gjc、pi は、それぞれのセッションファイルから読み取ります。OpenCode 2 は herdr が報告したセッションを OpenCode 自身のデータベースから読み取り、ホーム画面または 1.x のストアではライブターミナルを表示します。Devin CLI は `devin --resume ` のように herdr や実行中のプロセスがセッションを示すときにチャットになり、単に `devin` で起動した場合はライブターミナルを表示します。Hermes Agent は、herdr の Hermes 連携が報告したセッション、またはペインの Hermes プロセスが開いているセッションを Hermes 自身のデータベースから読み取ります。それ以外のプログラムはライブターミナルと状態が表示されます。[対応エージェント →](docs/guide.md#supported-agents) **herdr の TUI の代わりになるものですか?** diff --git a/README.ko.md b/README.ko.md index 691477dc6e..2a6eec63af 100644 --- a/README.ko.md +++ b/README.ko.md @@ -76,7 +76,7 @@ https://github.com/user-attachments/assets/d854dbb6-64bd-4eba-81c7-fbd3f525726b

모든 클립은 데스크톱과 폰을 동시에 녹화한 실제 동작이며, 실제 속도로 컷 없이 담았습니다. 누르면 전체 영상을 볼 수 있습니다.

-- **채팅과 터미널을 pane 하나에** — Claude Code, Codex, omp, omo, gjc, pi의 대화 기록과 herdr가 식별한 OpenCode 2와 Devin CLI 세션을 보여 주고, 클릭 한 번이면 라이브 터미널로 넘어갑니다. 에이전트가 일하는 동안 다음 메시지를 써 두면 다음 턴에 들어가고, Send now로 바로 보낼 수도 있습니다. [지원 에이전트 →](docs/guide.md#supported-agents) +- **채팅과 터미널을 pane 하나에** — Claude Code, Codex, omp, omo, gjc, pi의 대화 기록과 herdr나 실행 중인 프로세스가 식별한 OpenCode 2, Devin CLI, Hermes Agent 세션을 보여 주고, 클릭 한 번이면 라이브 터미널로 넘어갑니다. 에이전트가 일하는 동안 다음 메시지를 써 두면 다음 턴에 들어가고, Send now로 바로 보낼 수도 있습니다. [지원 에이전트 →](docs/guide.md#supported-agents) - **탭 한 번으로 승인** — 승인 요청, 질문, 계획 메뉴가 카드로 뜹니다. 답을 보내기 전에 그 질문이 아직 유효한지 확인합니다. - **내가 필요할 때 알림** — 모든 pane의 상태를 실시간으로 보여 주고, 앱을 보고 있을 때는 알림이 위에서 내려오며, 에이전트가 입력을 기다리거나 일을 끝내면 앱이 닫혀 있어도 푸시 알림을 보냅니다. - **폰에 설치해서 쓰기** — 키보드 위에 키 바(Esc, Tab, Ctrl, Alt, Shift, Enter, 방향키)가 붙은 PWA입니다. 키 순서를 바꾸거나 나만의 키 조합을 Settings(설정)에서 추가할 수 있습니다. Tailscale 주소는 QR 코드로 받아 갑니다. [폰 설정 →](docs/guide.md#on-your-phone) @@ -128,7 +128,7 @@ herdr가 실행 중인 상태에서 **[localhost:7317](http://localhost:7317)** **채팅 화면은 어떤 에이전트를 지원하나요?** -Claude Code, Codex, omp, omo, gjc, pi는 각자의 세션 파일에서 읽습니다. OpenCode 2는 herdr가 보고한 세션을 OpenCode 데이터베이스에서 읽으며, 홈 화면이나 1.x 저장소에서는 라이브 터미널을 표시합니다. Devin CLI는 `devin --resume `처럼 herdr나 실행 중인 프로세스가 세션을 알려 줄 때 채팅으로 보이고, 그냥 `devin`으로 띄우면 라이브 터미널로 보입니다. 그 밖의 프로그램은 라이브 터미널과 상태로 보입니다. [지원 에이전트 →](docs/guide.md#supported-agents) +Claude Code, Codex, omp, omo, gjc, pi는 각자의 세션 파일에서 읽습니다. OpenCode 2는 herdr가 보고한 세션을 OpenCode 데이터베이스에서 읽으며, 홈 화면이나 1.x 저장소에서는 라이브 터미널을 표시합니다. Devin CLI는 `devin --resume `처럼 herdr나 실행 중인 프로세스가 세션을 알려 줄 때 채팅으로 보이고, 그냥 `devin`으로 띄우면 라이브 터미널로 보입니다. Hermes Agent는 herdr의 Hermes 통합이 알려 준 세션이나 pane의 Hermes 프로세스가 열고 있는 세션을 Hermes 자체 데이터베이스에서 읽습니다. 그 밖의 프로그램은 라이브 터미널과 상태로 보입니다. [지원 에이전트 →](docs/guide.md#supported-agents) **herdr의 TUI를 대체하나요?** diff --git a/README.md b/README.md index 144baab658..8607d62483 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ A browser and phone client for [herdr](https://github.com/herdrdev/herdr). Read

Each clip is a live recording of the desktop and a phone at once, in real time with no cuts. Click one for the full video.

-- **Chat and terminal, one pane** — native Claude Code, Codex, omp, omo, gjc and pi transcripts, plus OpenCode 2 and Devin CLI sessions herdr identifies, with the live terminal a click away. Write the next message while the agent works: it goes in with the next turn, or at once with Send now. [Supported agents →](docs/guide.md#supported-agents) +- **Chat and terminal, one pane** — native Claude Code, Codex, omp, omo, gjc and pi transcripts, plus OpenCode 2, Devin CLI and Hermes Agent sessions herdr or the running process identifies, with the live terminal a click away. Write the next message while the agent works: it goes in with the next turn, or at once with Send now. [Supported agents →](docs/guide.md#supported-agents) - **Approve with a tap** — approvals, questions and plan menus become cards, checked to be current before your answer is sent. - **Know when you're needed** — live status for every pane, an alert that drops in while the app is open, and push alerts when an agent needs input or finishes, even with the app closed. - **Install it on your phone** — a PWA with a key bar above the keyboard (Esc, Tab, Ctrl, Alt, Shift, Enter, arrows) that you can rearrange and extend with your own combinations in Settings, and a QR code to your Tailscale address. [Phone setup →](docs/guide.md#on-your-phone) @@ -123,7 +123,7 @@ Yes. Run the agent in a [herdr](https://github.com/herdrdev/herdr) pane on your **Which agents does the chat view support?** -Claude Code, Codex, omp, omo, gjc and pi are read from their own session files. For OpenCode 2, the chat reads the session herdr reports from OpenCode's database; the home screen and OpenCode 1.x use the live terminal instead. Devin CLI is a chat when herdr or the running process names its session, as `devin --resume ` does; a plain `devin` shows the live terminal. Any other program in a herdr pane gets the live terminal and its status. [Supported agents →](docs/guide.md#supported-agents) +Claude Code, Codex, omp, omo, gjc and pi are read from their own session files. For OpenCode 2, the chat reads the session herdr reports from OpenCode's database; the home screen and OpenCode 1.x use the live terminal instead. Devin CLI is a chat when herdr or the running process names its session, as `devin --resume ` does; a plain `devin` shows the live terminal. Hermes Agent is read from its own database, for the session herdr's Hermes integration reports or the pane's Hermes process holds. Any other program in a herdr pane gets the live terminal and its status. [Supported agents →](docs/guide.md#supported-agents) **Does it replace herdr's own TUI?** diff --git a/README.zh-CN.md b/README.zh-CN.md index ccc46d002f..07cfa10028 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -76,7 +76,7 @@ https://github.com/user-attachments/assets/d854dbb6-64bd-4eba-81c7-fbd3f525726b

每段片段都是同时录制电脑和手机的真实操作,实时、无剪辑。点击可播放完整视频。

-- **聊天与终端,共用一个窗格** — 阅读 Claude Code、Codex、omp、omo、gjc 和 pi 的原生会话记录,以及 herdr 识别出的 OpenCode 2 和 Devin CLI 会话,一键切换到实时终端。智能体工作时可以先写好下一条消息,它会在下一轮发出,也可以用 Send now 立即发送。[支持的智能体 →](docs/guide.md#supported-agents) +- **聊天与终端,共用一个窗格** — 阅读 Claude Code、Codex、omp、omo、gjc 和 pi 的原生会话记录,以及 herdr 或运行中的进程识别出的 OpenCode 2、Devin CLI 和 Hermes Agent 会话,一键切换到实时终端。智能体工作时可以先写好下一条消息,它会在下一轮发出,也可以用 Send now 立即发送。[支持的智能体 →](docs/guide.md#supported-agents) - **轻点即可批准** — 审批请求、问题和计划菜单会显示为卡片,发送回答前会先确认提示仍然有效。 - **需要你时及时提醒** — 实时显示每个窗格的状态;应用打开时提醒会从顶部滑下;智能体需要输入或完成任务时发送推送通知,即使应用已关闭也能收到。 - **安装到手机** — PWA 在键盘上方提供按键栏(Esc、Tab、Ctrl、Alt、Shift、Enter 和方向键),可在 Settings(设置)中调整顺序或添加自定义组合键;Tailscale 地址以二维码显示。[手机设置 →](docs/guide.md#on-your-phone) @@ -128,7 +128,7 @@ herdr plugin install devswha/herdr-web-ui **聊天视图支持哪些智能体?** -Claude Code、Codex、omp、omo、gjc 和 pi 直接从各自的会话文件读取。OpenCode 2 会从 OpenCode 自己的数据库读取 herdr 报告的会话;处于主屏幕或使用 1.x 数据库时则显示实时终端。Devin CLI 在 herdr 或运行中的进程指明其会话时(例如 `devin --resume `)显示为聊天,直接运行 `devin` 则显示实时终端。其他程序显示实时终端和状态。[支持的智能体 →](docs/guide.md#supported-agents) +Claude Code、Codex、omp、omo、gjc 和 pi 直接从各自的会话文件读取。OpenCode 2 会从 OpenCode 自己的数据库读取 herdr 报告的会话;处于主屏幕或使用 1.x 数据库时则显示实时终端。Devin CLI 在 herdr 或运行中的进程指明其会话时(例如 `devin --resume `)显示为聊天,直接运行 `devin` 则显示实时终端。Hermes Agent 会从 Hermes 自己的数据库读取 herdr 的 Hermes 集成报告的会话,或窗格中 Hermes 进程持有的会话。其他程序显示实时终端和状态。[支持的智能体 →](docs/guide.md#supported-agents) **它会取代 herdr 自带的 TUI 吗?** diff --git a/docs/guide.md b/docs/guide.md index b1ff395b87..513919b67c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -140,6 +140,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **pi** | Native session file, resolved through herdr; after `/tree`, the branch in play | ✓ its dialogs: a question, a confirmation, an answer typed in | | **OpenCode** | Native session in OpenCode 2's own database, resolved through herdr: `~/.local/share/opencode/opencode.db` (`%USERPROFILE%\.local\share\opencode\opencode.db` on Windows), or where the server's `XDG_DATA_HOME` or `OPENCODE_DB` puts it | — use Terminal | | **Devin CLI** | Native SQLite session, showing the active branch when herdr or the live process explicitly identifies the session; otherwise terminal text | — use Terminal | +| **Hermes Agent** | Native session in Hermes's own database, `~/.hermes/state.db` (a profile's under `~/.hermes/profiles/`, or where the server's `HERMES_HOME` puts it), for the session herdr's Hermes integration reports or the pane's Hermes process holds | — use Terminal | | **Anything else** | The terminal's text | — use Terminal | Devin sessions are never selected by directory or recency alone. A plain `devin` shows the @@ -151,6 +152,15 @@ unreadable history uses the terminal-text fallback, including histories beyond t Terminal for the rest. The database is opened read-only, though SQLite may create its WAL coordination files beside it. +Hermes sessions are not selected by directory or recency either. With herdr's Hermes integration +(`herdr integration install hermes`), the chat follows the session the pane shows, through `/new` +too. Without it, the chat reads the session the pane's Hermes process holds in Hermes's own +registry (`runtime/active_sessions.json`), in the classic CLI and in `hermes --tui`; but the +classic CLI keeps holding the session a `/new` ended, so after a `/new` that pane shows the +terminal text until the integration is installed. A session before its first message is an empty +chat. Long tool output is shortened; the tool row opens it whole. The database is opened +read-only for each read. + When the last visible line is a familiar password, SSH passphrase or PIN request, both views show a **Password or PIN** field. It hides what you type and sends it directly to the terminal with Enter. The value is cleared on send, cancel, disconnect, pane diff --git a/server/AGENTS.md b/server/AGENTS.md index 7522ff55c1..c02f14f8bf 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -61,7 +61,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the | Workspace / agent HTTP endpoints | index.ts + herdr/client.ts | /api/agents and /api/workspace/create/rename/move/close; creation validates cwd and may start an agent in the root pane | | Slash-command discovery | commands.ts + GET /api/pane/commands in index.ts | per-agent built-ins; Claude user/project `.claude/commands/**/*.md` | | File mention search | files.ts + GET /api/pane/files in index.ts | git inventory or bounded walk, ranked filtering, 5s cwd cache | -| Chat-lens transcripts | conversation.ts + codex.ts + devin.ts + opencode.ts + GET /api/pane/conversation in index.ts | Codex native rollouts: session metadata/open descriptors first, unique substantial pane-text match for shared app-server TUIs; canonical store paths only, no subagent sessions, no cwd-recency guess. `createServer({ codexHome, devinDbPath, opencodeDb })` isolates test stores. claude/omp use `agent.get`; an omp pane herdr names no omp path for falls back to its omp process's open transcript or terminal breadcrumb (omp.ts, gjc-runtime.ts `heldTranscript`), in the store `--profile`/OMP_PROFILE picks. omo uses its process tree + own store. Devin requires explicit live session identity, never a directory-only guess, and unreadable histories use the terminal-text fallback. OpenCode (opencode.ts): herdr's `herdr:opencode` session id into OpenCode 2's SQLite store (`session_v2`/`session_message`, opened read-only per request), paged by `seq` instead of byte offset. Cache compares inode/size/mtime; `end_ts` records actual assistant activity. | +| Chat-lens transcripts | conversation.ts + codex.ts + devin.ts + opencode.ts + hermes.ts + GET /api/pane/conversation in index.ts | Codex native rollouts: session metadata/open descriptors first, unique substantial pane-text match for shared app-server TUIs; canonical store paths only, no subagent sessions, no cwd-recency guess. `createServer({ codexHome, devinDbPath, opencodeDb, hermesHome })` isolates test stores. claude/omp use `agent.get`; an omp pane herdr names no omp path for falls back to its omp process's open transcript or terminal breadcrumb (omp.ts, gjc-runtime.ts `heldTranscript`), in the store `--profile`/OMP_PROFILE picks. omo uses its process tree + own store. Devin requires explicit live session identity, never a directory-only guess, and unreadable histories use the terminal-text fallback. OpenCode (opencode.ts): herdr's `herdr:opencode` session id into OpenCode 2's SQLite store (`session_v2`/`session_message`, opened read-only per request), paged by `seq` instead of byte offset. Hermes (hermes.ts): herdr's `herdr:hermes` session id, else the session a pane's foreground process holds in `runtime/active_sessions.json` (an ended one is no answer: the classic CLI keeps its lease after `/new`), read from `/state.db` as Hermes's display projection shows it (`active = 1 OR compacted = 1`, no model-only rows, one row per `display_order` group), paged by group. Cache compares inode/size/mtime; `end_ts` records actual assistant activity. | | Interactive pane prompts | prompt.ts + /api/pane/prompt routes in index.ts | visible-screen parser and responder-specific key/text answers; a prompt's id is the hash of what it says plus its asking (`asked`: a new one after a read without it, a committing key sent from here, or the agent back at work), so an answer to an earlier asking is refused. An answer holds its asking to its last key (no key once it ended), and after ↑/↓ re-reads the menu and the cursor once, before its first key that is not a move (`SETTLE_MS`); the keys after that one go out unseen, as they always did. Reads by other clients while an answer is under way (`answersUnderWay`) neither end nor start an asking. A new responder needs a route test in server/prompt.test.ts for a moved, non-default choice on a redrawn screen: `promptTailIsActive` must accept the cursor on the last row. Claude Code's `/model` list (`claude-model`) is answered with the letter `s` (this session only) and NEVER with its Enter, which saves the pick as the default for every new session; it offers only the rows on screen, its moves after the first each wait for the list to show the one before, a list the reader cannot take gets no fallback card either (`claudeModelListWaits`: that card offers Enter), and herdr never reports the pane blocked while it waits. Claude Code's `/effort` slider (`claude-effort`) follows the same rules: ←/→ to the level under ▲, then `s`, NEVER Enter (it saves the level as the default), and a slider the reader cannot take holds the screen in `modelListWaits` too. Codex's `/model` lists (`codex-model`) follow the same rules, with one more: the footer belongs to the row under the cursor (`enter select` only opens the next list, `enter default · s session` picks), so an answer ends in a `pick` step whose key is read off the screen after the moves (`rowKey`): NEVER choose Enter for a row whose footer offers `s`, and never fix a list's key when the card is read. Enter is sent in the list of models alone ("Select Model and Effort"); in a list of levels the row that opens a list ("More reasoning…") is not offered and no row is answered with Enter, because the rows beside it save a default on that key (maintainer's decision, #503). A list is known by its title, because `enter select · esc back` is the footer of every list Codex draws | | Model / reasoning metadata | conversation-metadata.ts + conversation.ts cache | Latest recorded settings only: Codex turn_context, omp/omo model_change + thinking_level_change, Claude message.model. HTTP `metadata.model` / `metadata.reasoning_effort` use null for unknown values; never infer effort from thinking text. | | Background task state | conversation.ts `taskNotice` / `placeStrayNotices` | Claude's `` (a user turn, a `queue-operation` enqueue, or a `queued_command` attachment) sets `task: { status, summary }` on the tool part its `` names and never shows as a turn. The newest page parses in stretches, so notices a stretch cannot place are matched to earlier stretches by tool_use id (kept in a WeakMap, off the wire). Older pages are not revisited. | diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index f4803ddc4e..703e4aaac4 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -70,6 +70,48 @@ describe("Devin conversation API", () => { }); }); +describe("Hermes conversation API", () => { + it("reads the session herdr's integration reports, and keeps the terminal fallback without one", async () => { + const hermesHome = mkdtempSync(join(tmpdir(), "herdr-web-ui-hermes-contract-")); + const created = await workspaceCreate({ cwd: hermesHome, label: "herdr-web-ui-test-hermes-session" }); + const session = "20261009_090000_c0ffee"; + let bridge: ReturnType | undefined; + try { + const paneId = created.root_pane.pane_id; + const db = new Database(join(hermesHome, "state.db")); + try { + db.exec("CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT, started_at REAL NOT NULL, ended_at REAL)"); + db.exec("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)"); + db.query("INSERT INTO sessions VALUES (?, 'cli', 'example/model-1', 1791536000, NULL)").run(session); + const add = db.query("INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?, ?)"); + add.run(session, "user", "Synthetic Hermes prompt", null, null, 1791536001); + add.run(session, "assistant", "", null, JSON.stringify([{ id: "call_1", type: "function", function: { name: "terminal", arguments: JSON.stringify({ command: "echo synthetic" }) } }]), 1791536002); + add.run(session, "tool", JSON.stringify({ output: "x".repeat(20_000), exit_code: 0, error: null }), "call_1", null, 1791536003); + add.run(session, "assistant", "Synthetic Hermes answer", null, null, 1791536004); + } finally { db.close(); } + await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "hermes", state: "idle" }); + bridge = createServer({ port: 0, stateDir: hermesHome, hermesHome }); + const conversation = `http://localhost:${bridge.port}/api/pane/conversation?pane_id=${encodeURIComponent(paneId)}`; + // no report and no Hermes process holding a session: never a guess from the directory + expect(await (await fetch(conversation)).json()).toEqual({ source: "scrollback", turns: [] }); + + await herdrRpc("pane.report_agent_session", { pane_id: paneId, source: "herdr:hermes", agent: "hermes", seq: Date.now(), agent_session_id: session, session_start_source: "startup" }); + const response = await fetch(conversation); + expect(response.status).toBe(200); + const body = await response.json() as { source: string; history_id: string; metadata: { model: string | null }; turns: { role: string; parts: { kind: string; text?: string; output_ref?: string }[] }[] }; + expect([body.source, body.history_id, body.metadata.model]).toEqual(["hermes-transcript", `hermes-${session}`, "example/model-1"]); + expect(body.turns.map((turn) => [turn.role, turn.parts.map((part) => part.kind)])).toEqual([["user", ["text"]], ["assistant", ["tool", "text"]]]); + const ref = body.turns[1]!.parts[0]!.output_ref!; + const whole = await fetch(`http://localhost:${bridge.port}/api/pane/conversation/tool-output?pane_id=${encodeURIComponent(paneId)}&ref=${encodeURIComponent(ref)}`); + expect([whole.status, (await whole.text()).length]).toEqual([200, 20_000]); + } finally { + bridge?.stop(); + await workspaceClose(created.workspace.workspace_id); + rmSync(hermesHome, { recursive: true, force: true }); + } + }); +}); + describe("usage API", () => { it("returns OpenCode Go windows without exposing its credential", async () => { const usageState = mkdtempSync(join(tmpdir(), "herdr-opencode-contract-")); diff --git a/server/conversation.ts b/server/conversation.ts index fd19e48b2d..5adf3a336f 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -49,6 +49,7 @@ import { forgetOpencodeRead, forgetOpencodeState, OPENCODE_IMAGE_REF, opencodeCo import { piTranscriptInStore, piTranscriptPath, unwrittenSession } from "./pi.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchSegments } from "./pi-tree.ts"; import { defaultDevinDbPath, DevinHistoryUnavailable, devinConversation, forgetDevinState } from "./devin.ts"; +import { forgetHermesRead, forgetHermesState, hermesConversation, hermesHomeRoots, hermesHomes, hermesReadKey, hermesSessionForPane, hermesToolOutput } from "./hermes.ts"; import { trimOutput } from "./tool-output.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; @@ -415,7 +416,7 @@ export class HistoryChanged extends Error { /** What paneConversation resolved: which store the turns came from, and where they start. */ export type RecognizedConversation = { - source: "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript"; + source: "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript"; turns: ConversationTurn[]; metadata: ConversationMetadata; /** the first turn's position, for the page before it; null at the conversation's beginning */ @@ -431,8 +432,8 @@ export type RecognizedConversation = { version: string; }; -/** The stores read as one append-only file: OpenCode's and Devin's databases are paged by opencode.ts and devin.ts. */ -type StreamSource = Exclude; +/** The stores read as one append-only file: OpenCode's, Devin's and Hermes's databases are paged by opencode.ts, devin.ts and hermes.ts. */ +type StreamSource = Exclude; /** A restart may parse the same files differently: its answers never match an earlier ETag. */ const PROCESS_VERSION = randomUUID(); @@ -883,6 +884,7 @@ export function forgetTranscriptState(): void { forgetAllCodexState(); forgetAllPiIndexes(); forgetDevinState(); + forgetHermesState(); } /** @@ -916,6 +918,7 @@ export function forgetPaneTranscriptState(paneId: string): void { forgetPiIndex(path); forgetClaudeSessionFile(path); forgetOpencodeRead(path); + forgetHermesRead(path); } } @@ -1039,10 +1042,11 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { return inStore; } -/** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ +/** Where a pane's conversation is: a transcript file, or a session in OpenCode's or Hermes's database. */ type ResolvedTranscript = | { source: StreamSource; path: string; codexHome?: string } - | { source: "opencode-transcript"; path: string; session: string }; + | { source: "opencode-transcript"; path: string; session: string } + | { source: "hermes-transcript"; path: string; session: string }; /** * The store a pane's transcript lives in. herdr's agent label follows the @@ -1052,7 +1056,7 @@ type ResolvedTranscript = * label: omo's own store is read only when omo is really running * in that pane, never on a matching cwd alone. */ -async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: string, panes?: HerdrPane[], opencodeDb?: string): Promise { +async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: string, panes?: HerdrPane[], opencodeDb?: string, hermesHome?: string): Promise { const paneId = pane.pane_id; let agent = pane.agent ?? pane.agent_session?.agent ?? ""; // herdr names no agent for this pane: a session report an earlier agent left behind says @@ -1101,6 +1105,17 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin if (path === null) throw new ConversationUnavailable("no_session_path"); return { source: "opencode-transcript", path, session }; } + if (agent === "hermes") { + // the session is the one herdr's integration reported, or the one a process in the pane leases + const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid?: unknown }[] } }>( + "pane.process_info", { pane_id: paneId }, + ).catch(() => null); + const pids = (info?.process_info?.foreground_processes ?? []).map((process) => process.pid).filter((pid): pid is number => typeof pid === "number"); + const bound = hermesSessionForPane(pane, pids, hermesHomes(hermesHome === undefined ? hermesHomeRoots() : [hermesHome])); + if (bound.kind === "unwritten") throw new ConversationNotStarted(bound.session, "hermes-transcript"); + if (bound.kind === "unavailable") throw new ConversationUnavailable(bound.reason); + return { source: "hermes-transcript", path: bound.path, session: bound.session }; + } throw new ConversationUnavailable("no_recognized_transcript"); } catch (error) { if (!(error instanceof ConversationUnavailable) || !(await paneRunsOmo(paneId))) throw error; @@ -1169,7 +1184,7 @@ export function devinSessionForPane(pane: HerdrPane, panes: HerdrPane[], argv: s * shows the pages before it. A cursor from another file throws HistoryChanged. * `opencodeDb` is OpenCode's store, unset where OpenCode itself would find it. */ -export async function paneConversation(paneId: string, codexHome?: string, page: ConversationPage = {}, devinDbPath?: string, opencodeDb?: string): Promise { +export async function paneConversation(paneId: string, codexHome?: string, page: ConversationPage = {}, devinDbPath?: string, opencodeDb?: string, hermesHome?: string): Promise { const snapshot = await sessionSnapshot(); const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); if (pane === undefined) throw new ConversationUnavailable("pane_not_found"); @@ -1203,7 +1218,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: let resolved: ResolvedTranscript; try { - resolved = await resolveTranscript(pane, pane.cwd, codexHome, snapshot.panes, opencodeDb); + resolved = await resolveTranscript(pane, pane.cwd, codexHome, snapshot.panes, opencodeDb, hermesHome); } catch (error) { if (!(error instanceof ConversationNotStarted)) throw error; // a file read before and gone since is no conversation not begun: the terminal stands in for it @@ -1227,6 +1242,19 @@ export async function paneConversation(paneId: string, codexHome?: string, page: const key = `opencode\0${resolved.path}\0${resolved.session}\0${page.before ?? ""}\0${page.since ?? ""}\0${page.from ?? ""}`; return { source: resolved.source, ...conversation, version: answerVersion(key, signature) }; } + if (resolved.source === "hermes-transcript") { + rememberPaneRead(paneId, hermesReadKey(resolved.path, resolved.session)); + const answer = hermesConversation(resolved.path, resolved.session, page); + if (answer.kind === "history_changed") throw new HistoryChanged(); + if (answer.kind === "unavailable") throw new ConversationUnavailable(answer.reason); + // read once, a session that turns up unwritten again was removed: the terminal stands in for it + const written = `${resolved.source}\0${resolved.session}`; + writtenSessions.add(written); + rememberPaneSession(paneId, written); + const { kind: _kind, signature, ...conversation } = answer; + const key = `${hermesReadKey(resolved.path, resolved.session)}\0${page.before ?? ""}\0${page.since ?? ""}\0${page.from ?? ""}`; + return { source: resolved.source, ...conversation, version: answerVersion(key, signature) }; + } const identity = resolved.source === "claude-transcript" ? nodePath.basename(resolved.path, ".jsonl") : resolved.source === "pi-transcript" || resolved.source === "omp-transcript" ? realpathSync(resolved.path) : null; rememberPaneRead(paneId, resolved.path); @@ -1324,13 +1352,13 @@ export function transcriptPage(source: StreamSource, path: string, page: Convers * here rather than sent with every poll of the conversation. Null when there is no such * image. Codex uses a hash of the native attachment and searches only the bound history. */ -export async function conversationImage(paneId: string, ref: string, codexHome?: string, opencodeDb?: string): Promise<{ mediaType: string; bytes: Uint8Array } | null> { +export async function conversationImage(paneId: string, ref: string, codexHome?: string, opencodeDb?: string, hermesHome?: string): Promise<{ mediaType: string; bytes: Uint8Array } | null> { if (!IMAGE_REF.test(ref) && !CODEX_IMAGE_REF.test(ref) && !PI_IMAGE_REF.test(ref) && !OPENCODE_IMAGE_REF.test(ref)) return null; const snapshot = await sessionSnapshot(); const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); if (pane === undefined || typeof pane.cwd !== "string" || pane.cwd.length === 0) return null; let resolved: ResolvedTranscript; - try { resolved = await resolveTranscript(pane, pane.cwd, codexHome, snapshot.panes, opencodeDb); } + try { resolved = await resolveTranscript(pane, pane.cwd, codexHome, snapshot.panes, opencodeDb, hermesHome); } catch (error) { if (error instanceof ConversationUnavailable) return null; throw error; } // OpenCode's store is read per request and keeps nothing for the pane to release if (resolved.source === "opencode-transcript") return opencodeImage(resolved.path, resolved.session, ref); @@ -1370,18 +1398,22 @@ const TOOL_REF = /^[A-Za-z0-9_:.-]{1,128}$/; const TOOL_OUTPUT_MAX = 2_000_000; /** The whole output of a tool call whose page output was cut, by its id; null when there is none. */ -export async function toolOutput(paneId: string, ref: string, codexHome?: string, opencodeDb?: string): Promise { +export async function toolOutput(paneId: string, ref: string, codexHome?: string, opencodeDb?: string, hermesHome?: string): Promise { if (!TOOL_REF.test(ref)) return null; const snapshot = await sessionSnapshot(); const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); if (pane === undefined || typeof pane.cwd !== "string" || pane.cwd.length === 0) return null; let resolved: ResolvedTranscript; - try { resolved = await resolveTranscript(pane, pane.cwd, codexHome, snapshot.panes, opencodeDb); } + try { resolved = await resolveTranscript(pane, pane.cwd, codexHome, snapshot.panes, opencodeDb, hermesHome); } catch (error) { if (error instanceof ConversationUnavailable) return null; throw error; } if (resolved.source === "opencode-transcript") { const output = opencodeToolOutput(resolved.path, resolved.session, ref); return output !== null && output.length > TOOL_OUTPUT_MAX ? `${output.slice(0, TOOL_OUTPUT_MAX)}\n… trimmed` : output; } + if (resolved.source === "hermes-transcript") { + const output = hermesToolOutput(resolved.path, resolved.session, ref); + return output !== null && output.length > TOOL_OUTPUT_MAX ? `${output.slice(0, TOOL_OUTPUT_MAX)}\n… trimmed` : output; + } rememberPaneRead(paneId, resolved.path); return transcriptToolOutput(resolved.source, resolved.path, ref, resolved.codexHome ?? codexHome); } diff --git a/server/hermes.test.ts b/server/hermes.test.ts new file mode 100644 index 0000000000..6da6fae959 --- /dev/null +++ b/server/hermes.test.ts @@ -0,0 +1,339 @@ +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + forgetHermesState, hermesContent, hermesConversation, hermesHomeRoots, hermesHomes, hermesReportedSession, hermesSessionForPane, + hermesToolOutput, hermesToolResult, hermesTurns, type HermesAnswer, type HermesRow, +} from "./hermes.ts"; +import type { ConversationPart, ConversationTurn, HerdrPane } from "../shared/protocol.ts"; + +const root = mkdtempSync(join(tmpdir(), "herdr-hermes-store-")); +const opened: Database[] = []; +// Windows refuses to delete a file a handle still holds +afterAll(() => { for (const db of opened) db.close(); rmSync(root, { recursive: true, force: true }); }); +beforeEach(() => forgetHermesState()); + +// The columns Hermes 2026.9.24 (schema 31) reads a conversation from, as it creates them (the +// many it keeps for accounting, search and gateways left out). +const SESSIONS = `CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT, model_config TEXT, parent_session_id TEXT, + started_at REAL NOT NULL, ended_at REAL, end_reason TEXT, cwd TEXT, title TEXT, rewind_count INTEGER NOT NULL DEFAULT 0 +)`; +const MESSAGES = `CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id), role TEXT NOT NULL, content TEXT, + tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL NOT NULL, finish_reason TEXT, reasoning TEXT, reasoning_content TEXT, + _compressed_summary INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0, + display_kind TEXT, display_metadata TEXT, display_order INTEGER +)`; +// what a store from before display grouping, rewinds and compaction kept +const LEGACY_MESSAGES = `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, tool_name TEXT, timestamp REAL NOT NULL +)`; +const T0 = Date.parse("2026-10-09T09:00:00Z") / 1000; + +let homes = 0; +type Message = Partial<{ role: string; content: string | null; tool_call_id: string; tool_calls: unknown; tool_name: string; reasoning: string; summary: number; active: number; compacted: number; display_kind: string; display_metadata: unknown; display_order: number; timestamp: number }>; + +/** A Hermes home of its own with one session, and a writer that appends rows as Hermes does. */ +function home(options: { session?: string; legacy?: boolean; profile?: string; base?: string; journal?: "wal" | "delete" } = {}) { + const dir = options.base !== undefined && options.profile !== undefined ? join(options.base, "profiles", options.profile) : join(root, `home-${++homes}`); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "state.db"); + const db = new Database(path, { create: true }); + opened.push(db); + db.exec(`PRAGMA journal_mode = ${options.journal ?? "wal"}`); + db.exec(SESSIONS); + db.exec(options.legacy ? LEGACY_MESSAGES : MESSAGES); + const session = options.session ?? "20261009_090000_a1b2c3"; + const start = (id: string, extra: { ended?: boolean; rewinds?: number } = {}) => db.query("INSERT INTO sessions (id, source, model, model_config, started_at, ended_at, end_reason, cwd, rewind_count) VALUES (?, 'cli', 'example/model-1', ?, ?, ?, ?, '/work/demo', ?)") + .run(id, JSON.stringify({ reasoning_config: { effort: "high" } }), T0, extra.ended ? T0 + 60 : null, extra.ended ? "new_session" : null, extra.rewinds ?? 0); + start(session); + let clock = T0; + const add = (message: Message, to = session): number => { + clock += 1; + const columns: Record = { + session_id: to, role: message.role ?? "user", content: message.content ?? null, tool_call_id: message.tool_call_id ?? null, + tool_calls: message.tool_calls === undefined ? null : JSON.stringify(message.tool_calls), tool_name: message.tool_name ?? null, timestamp: message.timestamp ?? clock, + }; + if (!options.legacy) { + Object.assign(columns, { + reasoning: message.reasoning ?? null, _compressed_summary: message.summary ?? 0, active: message.active ?? 1, compacted: message.compacted ?? 0, + display_kind: message.display_kind ?? null, display_metadata: message.display_metadata === undefined ? null : JSON.stringify(message.display_metadata), + }); + } + const names = Object.keys(columns); + db.query(`INSERT INTO messages (${names.join(", ")}) VALUES (${names.map(() => "?").join(", ")})`).run(...(Object.values(columns) as (string | number | null)[])); + const id = db.query<{ id: number }, []>("SELECT last_insert_rowid() AS id").get()!.id; + if (!options.legacy) db.query("UPDATE messages SET display_order = ? WHERE id = ?").run(message.display_order ?? id, id); + return id; + }; + const prompt = (text: string) => add({ role: "user", content: text }); + const call = (id: string, name: string, args: Record, text = "") => add({ role: "assistant", content: text, tool_calls: [{ id, call_id: id, type: "function", function: { name, arguments: JSON.stringify(args) } }] }); + const result = (id: string, name: string, content: string) => add({ role: "tool", content, tool_call_id: id, tool_name: name }); + const answer = (text: string) => add({ role: "assistant", content: text }); + return { dir, path, db, session, start, add, prompt, call, result, answer }; +} + +const page = (answer: HermesAnswer) => { + if (answer.kind !== "page") throw new Error(`expected a page, got ${JSON.stringify(answer)}`); + return answer; +}; +const texts = (turns: ConversationTurn[]) => turns.map((turn) => `${turn.role}: ${turn.parts.map((part: ConversationPart) => part.kind === "text" ? part.text : part.kind === "tool" ? `[${part.name} ${part.summary} -> ${part.output}${part.error ? " !" : ""}]` : `<${part.kind}>`).join(" ")}`); +const row = (fields: Partial & Pick): HermesRow => ({ + content: null, timestamp: T0 + fields.id, tool_call_id: null, tool_calls: null, reasoning: null, reasoning_content: null, display_kind: null, summary: 0, ...fields, +}); + +describe("hermesTurns", () => { + it("makes a prompt, the calls that answer it and their results one assistant turn", () => { + const turns = hermesTurns([ + row({ id: 1, role: "user", content: "Count the files" }), + row({ id: 2, role: "assistant", content: "I'll look.", reasoning: "List them first.", tool_calls: JSON.stringify([{ id: "call_a", call_id: "call_a", type: "function", function: { name: "terminal", arguments: JSON.stringify({ command: "ls | wc -l" }) } }]) }), + row({ id: 3, role: "tool", tool_call_id: "call_a", content: JSON.stringify({ output: "12", exit_code: 0, error: null }) }), + row({ id: 4, role: "assistant", content: "There are **12** files." }), + ]); + expect(texts(turns)).toEqual(["user: Count the files", "assistant: I'll look. [terminal ls | wc -l -> 12] There are **12** files."]); + expect(turns[1]!.ts).toBe(new Date((T0 + 2) * 1000).toISOString()); + expect(turns[1]!.end_ts).toBe(new Date((T0 + 4) * 1000).toISOString()); + }); + + it("marks a command that exited non-zero, and keeps the error a tool reported", () => { + const turns = hermesTurns([ + row({ id: 1, role: "user", content: "Build it" }), + row({ id: 2, role: "assistant", tool_calls: JSON.stringify([ + { id: "call_a", function: { name: "terminal", arguments: JSON.stringify({ command: "make" }) } }, + { id: "call_b", function: { name: "web_search", arguments: JSON.stringify({ query: "make error 2" }) } }, + ]) }), + row({ id: 3, role: "tool", tool_call_id: "call_a", content: JSON.stringify({ output: "make: *** Error 2", exit_code: 2, error: null }) }), + row({ id: 4, role: "tool", tool_call_id: "call_b", content: JSON.stringify({ success: false, error: "search is offline" }) }), + ]); + expect(texts(turns)).toEqual(["user: Build it", "assistant: [terminal make -> make: *** Error 2 !] [web_search make error 2 -> {\"success\":false,\"error\":\"search is offline\"} !]"]); + }); + + it("shows a runtime row as a notice, a steer as the user's words, a compaction as its summary, and nothing hidden", () => { + const turns = hermesTurns([ + row({ id: 1, role: "user", content: "[Model switched to example/model-2]", display_kind: "model_switch" }), + row({ id: 2, role: "user", content: "also check the tests", display_kind: "steer" }), + row({ id: 3, role: "assistant", content: "", display_kind: "hidden" }), + row({ id: 4, role: "user", content: "The user asked for a file count; it was 12.", summary: 1 }), + row({ id: 5, role: "system", content: "You are Hermes." }), + ]); + expect(turns.map((turn) => [turn.role, turn.parts[0]])).toEqual([ + ["user", { kind: "notice", text: "[Model switched to example/model-2]", source: "model_switch" }], + ["user", { kind: "text", text: "also check the tests" }], + ["user", { kind: "compact", text: "The user asked for a file count; it was 12." }], + ]); + }); + + it("reads list content behind Hermes's JSON marker, and a result for no call it knows is left out", () => { + const turns = hermesTurns([ + row({ id: 1, role: "user", content: `\u0000json:${JSON.stringify([{ type: "text", text: "What is in this picture?" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }])}` }), + row({ id: 2, role: "tool", tool_call_id: "call_missing", content: "orphan" }), + row({ id: 3, role: "assistant", content: "A cat." }), + ]); + expect(texts(turns)).toEqual(["user: What is in this picture?", "assistant: A cat."]); + expect(hermesContent(`\u0000json:${JSON.stringify("\u0000json:literal")}`)).toBe("\u0000json:literal"); + }); + + it("cuts a long output and names the row that holds the rest", () => { + const long = "x".repeat(50_000); + const turns = hermesTurns([ + row({ id: 1, role: "user", content: "Dump it" }), + row({ id: 2, role: "assistant", tool_calls: JSON.stringify([{ id: "call_a", function: { name: "read_file", arguments: JSON.stringify({ path: "big.log" }) } }]) }), + row({ id: 3, role: "tool", tool_call_id: "call_a", content: JSON.stringify({ content: long }) }), + ]); + const tool = turns[1]!.parts[0] as Extract; + expect([tool.summary, tool.output_ref, tool.output_size, tool.output.endsWith("… trimmed")]).toEqual(["big.log", "hermes:3", 50_000, true]); + }); +}); + +describe("hermesToolResult", () => { + it("unwraps a terminal result and leaves any other text as written", () => { + expect(hermesToolResult(JSON.stringify({ output: "", exit_code: 1, error: "command not found" }))).toEqual({ text: "command not found", error: true }); + expect(hermesToolResult("plain text")).toEqual({ text: "plain text", error: false }); + expect(hermesToolResult(JSON.stringify([1, 2]))).toEqual({ text: "[1,2]", error: false }); + }); +}); + +describe("hermesConversation", () => { + it("reads a session, its model and its reasoning effort", () => { + const store = home(); + store.prompt("Count the files"); + store.call("call_a", "terminal", { command: "ls | wc -l" }, "I'll look."); + store.result("call_a", "terminal", JSON.stringify({ output: "12", exit_code: 0, error: null })); + store.answer("There are 12 files."); + const answer = page(hermesConversation(store.path, store.session)); + expect(texts(answer.turns)).toEqual(["user: Count the files", "assistant: I'll look. [terminal ls | wc -l -> 12] There are 12 files."]); + expect(answer.metadata).toEqual({ model: "example/model-1", reasoning_effort: "high" }); + expect([answer.cursor, answer.history_id]).toEqual([null, `hermes-${store.session}`]); + }); + + it("shows each message once across compaction generations, and never a rewound or model-only one", () => { + const store = home(); + const first = store.add({ role: "user", content: "First question", active: 0, compacted: 1 }); + store.add({ role: "assistant", content: "First answer", active: 0, compacted: 1 }); + const kept = store.add({ role: "user", content: "Second question", active: 0, compacted: 1 }); + store.add({ role: "assistant", content: "Undone answer", active: 0, compacted: 0 }); + // the compaction's summary and the protected tail copied into the live generation + store.add({ role: "user", content: "Summary of the first question.", summary: 1 }); + store.add({ role: "user", content: "Second question", display_order: kept }); + store.add({ role: "user", content: "internal nudge", display_metadata: { model_only: true } }); + store.answer("Second answer"); + const answer = page(hermesConversation(store.path, store.session)); + expect(texts(answer.turns)).toEqual(["user: First question", "assistant: First answer", "user: Second question", "user: ", "assistant: Second answer"]); + expect(first).toBe(1); + }); + + it("reads a store from before display grouping and compaction", () => { + const store = home({ legacy: true }); + store.prompt("Hello"); + store.answer("Hi there."); + expect(texts(page(hermesConversation(store.path, store.session)).turns)).toEqual(["user: Hello", "assistant: Hi there."]); + }); + + it("pages back by prompts and refuses a cursor an undo removed", () => { + const store = home(); + const prompts: number[] = []; + for (let n = 1; n <= 60; n++) { + prompts.push(store.prompt(`question ${n}`)); + store.answer(`answer ${n}`); + } + const newest = page(hermesConversation(store.path, store.session)); + expect(newest.turns.length).toBe(100); + expect(texts(newest.turns)[0]).toBe("user: question 11"); + expect(newest.cursor).toBe(`hermes-${store.session}:${prompts[10]}`); + const older = page(hermesConversation(store.path, store.session, { before: newest.cursor! })); + expect(texts(older.turns)).toEqual(Array.from({ length: 10 }, (_, index) => [`user: question ${index + 1}`, `assistant: answer ${index + 1}`]).flat()); + expect(older.cursor).toBeNull(); + // a chat holding the newest page keeps its start while it is still in the newest page, and + // gets the newest page once that moved past it + store.answer("answer 60, continued"); + const held = page(hermesConversation(store.path, store.session, { from: newest.cursor! })); + expect([texts(held.turns)[0], held.cursor]).toEqual(["user: question 11", newest.cursor]); + store.prompt("question 61"); + const moved = page(hermesConversation(store.path, store.session, { from: newest.cursor! })); + expect([texts(moved.turns)[0], texts(moved.turns).at(-1)]).toEqual(["user: question 12", "user: question 61"]); + store.db.query("UPDATE messages SET active = 0 WHERE id = ?").run(prompts[10]!); + expect(hermesConversation(store.path, store.session, { before: newest.cursor! })).toEqual({ kind: "history_changed" }); + expect(hermesConversation(store.path, store.session, { before: "hermes-other:1" })).toEqual({ kind: "history_changed" }); + }); + + it("names a rewound session's history apart, so a chat reloads it", () => { + const store = home(); + store.prompt("Hello"); + const before = page(hermesConversation(store.path, store.session)).history_id; + store.db.query("UPDATE sessions SET rewind_count = 1 WHERE id = ?").run(store.session); + expect([before, page(hermesConversation(store.path, store.session)).history_id]).toEqual([`hermes-${store.session}`, `hermes-${store.session}-r1`]); + }); + + it("answers a cached page until the session changes", () => { + const store = home(); + store.prompt("Hello"); + const first = page(hermesConversation(store.path, store.session)); + expect(hermesConversation(store.path, store.session)).toBe(first); + store.answer("Hi."); + const second = page(hermesConversation(store.path, store.session)); + expect([second === first, second.signature === first.signature, texts(second.turns)]).toEqual([false, false, ["user: Hello", "assistant: Hi."]]); + }); + + it("is unavailable for a session the store does not hold, a missing store or one that is not Hermes's", () => { + const store = home(); + expect(hermesConversation(store.path, "20261009_000000_ffffff")).toEqual({ kind: "unavailable", reason: "session_not_found" }); + expect(hermesConversation(join(root, "missing.db"), store.session)).toEqual({ kind: "unavailable", reason: "transcript_missing" }); + const other = join(root, "other.db"); + const db = new Database(other, { create: true }); + db.exec("CREATE TABLE notes (id INTEGER)"); + db.close(); + expect(hermesConversation(other, store.session)).toEqual({ kind: "unavailable", reason: "transcript_missing" }); + writeFileSync(join(root, "text.db"), "not a database"); + expect(hermesConversation(join(root, "text.db"), store.session)).toEqual({ kind: "unavailable", reason: "transcript_missing" }); + expect(hermesConversation(store.path, "../escape")).toEqual({ kind: "unavailable", reason: "no_session_id" }); + }); + + it("gives a cut output whole by its row", () => { + const store = home(); + store.prompt("Dump it"); + store.call("call_a", "terminal", { command: "cat big.log" }); + const id = store.result("call_a", "terminal", JSON.stringify({ output: "y".repeat(40_000), exit_code: 0, error: null })); + expect(hermesToolOutput(store.path, store.session, `hermes:${id}`)?.length).toBe(40_000); + expect(hermesToolOutput(store.path, store.session, "hermes:1")).toBeNull(); + expect(hermesToolOutput(store.path, store.session, "other:1")).toBeNull(); + }); +}); + +describe("hermesSessionForPane", () => { + const pane = (agent: string | null, reported: string | null): Pick => ({ + agent, agent_session: reported === null ? null : { agent: "hermes", kind: "id", source: "herdr:hermes", value: reported }, + }); + const lease = (dir: string, entries: { pid: number; session: string }[]) => { + mkdirSync(join(dir, "runtime"), { recursive: true }); + writeFileSync(join(dir, "runtime", "active_sessions.json"), JSON.stringify({ entries: entries.map(({ pid, session }) => ({ lease_id: `lease-${pid}`, session_id: session, surface: "cli", pid, process_start_time: T0, started_at: T0, updated_at: T0 })) })); + }; + + it("follows herdr's report into the profile that holds the session", () => { + const base = join(root, `base-${++homes}`); + const main = home({ base, profile: "work", session: "20261009_100000_aaaaaa" }); + mkdirSync(base, { recursive: true }); + const homesFound = hermesHomes([base]); + expect(homesFound).toEqual([base, main.dir]); + expect(hermesSessionForPane(pane("hermes", main.session), [], homesFound)).toEqual({ kind: "session", path: main.path, session: main.session }); + // reported before its first prompt: nothing is written yet where the pane's Hermes runs + lease(main.dir, [{ pid: 4242, session: "20261009_100500_bbbbbb" }]); + expect(hermesSessionForPane(pane("hermes", "20261009_100500_bbbbbb"), [4242], homesFound)).toEqual({ kind: "unwritten", session: "20261009_100500_bbbbbb" }); + // a session no known home holds, from a Hermes no lease places: a home this server does not + // know of may hold its whole history, so the terminal stands in rather than an empty chat + expect(hermesSessionForPane(pane("hermes", "20261009_100500_bbbbbb"), [], homesFound)).toEqual({ kind: "unavailable", reason: "no_session_path" }); + }); + + it("keeps a session whose store Hermes holds for a moment, and the page read before", () => { + const store = home({ journal: "delete" }); + store.prompt("Hello"); + const before = page(hermesConversation(store.path, store.session)); + const writer = new Database(store.path); + opened.push(writer); + writer.exec("BEGIN EXCLUSIVE"); + try { + expect(hermesSessionForPane(pane("hermes", store.session), [], [store.dir])).toEqual({ kind: "session", path: store.path, session: store.session }); + expect(hermesConversation(store.path, store.session)).toBe(before); + } finally { writer.exec("ROLLBACK"); } + }); + + it("finds the session a process in the pane leases, and nothing for a lease the pane does not hold", () => { + const store = home(); + lease(store.dir, [{ pid: 4242, session: store.session }, { pid: 5151, session: "20261009_110000_cccccc" }]); + expect(hermesSessionForPane(pane("hermes", null), [100, 4242], [store.dir])).toEqual({ kind: "session", path: store.path, session: store.session }); + expect(hermesSessionForPane(pane("hermes", null), [5151], [store.dir])).toEqual({ kind: "unwritten", session: "20261009_110000_cccccc" }); + expect(hermesSessionForPane(pane("hermes", null), [100], [store.dir])).toEqual({ kind: "unavailable", reason: "no_session_id" }); + // two sessions held by the pane's processes: no guess between them + expect(hermesSessionForPane(pane("hermes", null), [4242, 5151], [store.dir])).toEqual({ kind: "unavailable", reason: "no_session_id" }); + }); + + it("takes no leased session a /new ended, and no report a pane herdr does not call hermes left behind", () => { + const store = home(); + store.start("20261009_120000_dddddd", { ended: true }); + lease(store.dir, [{ pid: 4242, session: "20261009_120000_dddddd" }]); + expect(hermesSessionForPane(pane("hermes", null), [4242], [store.dir])).toEqual({ kind: "unavailable", reason: "no_session_id" }); + expect(hermesSessionForPane(pane(null, store.session), [], [store.dir])).toEqual({ kind: "unavailable", reason: "no_session_id" }); + // the report wins over a lease the CLI left on the session it ended + expect(hermesSessionForPane(pane("hermes", store.session), [4242], [store.dir])).toEqual({ kind: "session", path: store.path, session: store.session }); + }); + + it("reads only herdr's own Hermes report", () => { + expect(hermesReportedSession({ agent_session: { agent: "hermes", kind: "id", source: "herdr:hermes", value: "20261009_090000_a1b2c3" } })).toBe("20261009_090000_a1b2c3"); + expect(hermesReportedSession({ agent_session: { agent: "hermes", kind: "path", source: "herdr:hermes", value: "/tmp/x" } })).toBeNull(); + expect(hermesReportedSession({ agent_session: { agent: "claude", kind: "id", source: "herdr:claude", value: "abc" } })).toBeNull(); + expect(hermesReportedSession({ agent_session: { agent: "hermes", kind: "id", source: "herdr:hermes", value: "../x" } })).toBeNull(); + }); +}); + +describe("hermesHomeRoots", () => { + it("finds the home where Hermes does: HERMES_HOME, else the platform's default", () => { + expect(hermesHomeRoots({}, "/home/demo", "linux")).toEqual(["/home/demo/.hermes"]); + expect(hermesHomeRoots({ HERMES_HOME: "~/agents/hermes" }, "/home/demo", "darwin")).toEqual(["/home/demo/agents/hermes", "/home/demo/.hermes"]); + expect(hermesHomeRoots({ LOCALAPPDATA: "C:\\Users\\demo\\AppData\\Local" }, "C:\\Users\\demo", "win32")[0]).toContain("hermes"); + expect(hermesHomeRoots({ HERMES_DATA_DIR_SUFFIX: "-dev" }, "/home/demo", "linux")).toEqual(["/home/demo/.hermes-dev"]); + }); +}); diff --git a/server/hermes.ts b/server/hermes.ts new file mode 100644 index 0000000000..c2ab47fa26 --- /dev/null +++ b/server/hermes.ts @@ -0,0 +1,494 @@ +/** + * Hermes Agent's own session store, read-only (Hermes 2026.9.24, `state.db` schema 31). + * + * Hermes keeps every conversation in one SQLite database, `/state.db`. The home is + * `HERMES_HOME`, else `~/.hermes` (`%LOCALAPPDATA%\hermes` on Windows), and a profile + * (`hermes -p `) is `/profiles/` (hermes_constants.py). A session is a `sessions` + * row and its conversation the `messages` rows of its id in `id` order, in OpenAI's shape: `user`, + * `assistant` with `tool_calls`, and `tool` answering one call by `tool_call_id`. + * + * A pane is bound to a session only by what proves it, never by its directory or recency: + * - herdr's Hermes integration reports the session the pane's Hermes shows (`herdr:hermes`, kind + * `id`), again after a `/new`; + * - without it, Hermes's own lease registry (`runtime/active_sessions.json`) names the session each + * live process holds, and one of the pane's foreground processes holds it (the classic CLI is + * that process, the TUI's gateway is a child in the same group). The classic CLI keeps its lease + * on the session a `/new` ended, so a leased session that has ended is no answer. + * + * The chat shows what Hermes's own display projection shows (`hermes_state_messages.py`): rows that + * are live or that a compaction summarized away (`active = 1 OR compacted = 1`), never one marked + * model-only, and one row per `display_order` group, the live copy first, then the newest (a + * compaction copies its protected tail into each generation). A page is a range of groups, so a + * cursor names a group: one an undo or rewind removed is refused, and `rewind_count` is part of + * the history id. + */ + +import { Database } from "bun:sqlite"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +import type { ConversationMetadata, ConversationPart, ConversationTurn, HerdrPane } from "../shared/protocol.ts"; +import { pageStart, type RowMeta } from "./opencode.ts"; +import { trimOutput } from "./tool-output.ts"; +import { toolSummary } from "./transcript-records.ts"; + +/** Hermes may hold the write lock for a moment; a read waits this long, never more. */ +const BUSY_TIMEOUT_MS = 250; +/** The newest page is read on every poll while the agent works: at most this much of the session. */ +const WINDOW_BYTES = 16 * 1024 * 1024; +/** One message's text on a page; a longer one is cut with a visible mark. */ +const MAX_TEXT = 256 * 1024; +const LEASES_MAX_BYTES = 1024 * 1024; +const MAX_PROFILES = 64; +/** `_encode_content` writes list content (multimodal parts) as JSON behind this marker. */ +const CONTENT_JSON_PREFIX = "\u0000json:"; +/** Hermes's ids are `_