Skip to content

Commit 7feedd9

Browse files
Brian Akakaclaude
andcommitted
v0.3.0: add Hermes Agent support
Hermes (Nous Research) stores all sessions in one SQLite DB at ~/.hermes/state.db. rejoin/hermes.py opens it read-only and reads the sessions + messages tables directly, mapping into our SessionRecord + Turn model. No dependency on any external library. - list_hermes_sessions reads sessions.id, model, started_at, message_count, tool_call_count, title, plus subqueries for first/ last user prompts and the latest message timestamp (since Hermes' single state.db doesn't have per-session mtimes). - iter_hermes_turns walks messages by session_id, emits user/ assistant text + tool_use (parsed from messages.tool_calls JSON blob) + tool_result (role='tool') turns. - Hermes ships its own session titles; we write those into our titles table with content_hash 'hermes-native' so the OpenRouter titler skips them — free tokens for users. - Resume uses Hermes's native `hermes --resume <id>` flag — fully interactive, restores the whole conversation history. - Magenta #7A3F74 tag color in web + TUI (nods to Nous's palette). - Four pytest cases using a synthetic SQLite fixture that matches the documented schema; brings test count to 33. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1931911 commit 7feedd9

File tree

13 files changed

+284
-4
lines changed

13 files changed

+284
-4
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to **rejoin** are documented here.
55
Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
66
version numbers follow [Semantic Versioning](https://semver.org/).
77

8+
## [0.3.0] — 2026-04-12
9+
10+
### Added
11+
- **Hermes Agent** support via direct read of `~/.hermes/state.db` (SQLite).
12+
Shapes Hermes's sessions/messages tables into our SessionRecord + Turn
13+
model without writing to the DB.
14+
- **Uses Hermes's native title** when present. Inserted into our `titles`
15+
table with content_hash `hermes-native` so the OpenRouter titler
16+
doesn't waste tokens regenerating.
17+
- Resume: `hermes --resume <id>` (Hermes's native flag; fully interactive).
18+
- Magenta `#7A3F74` tag color in both front-ends.
19+
820
## [0.2.0] — 2026-04-12
921

1022
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![CI](https://github.com/akakabrian/rejoin/actions/workflows/ci.yml/badge.svg)](https://github.com/akakabrian/rejoin/actions/workflows/ci.yml)
77

88
> **One dashboard for every coding agent you use.**
9-
> rejoin indexes Claude Code, Codex, OpenCode, Pi, and OpenClaw sessions into a single searchable view — web or terminal — and lets you tmux-resume any of them with one keystroke.
9+
> rejoin indexes Claude Code, Codex, OpenCode, Pi, OpenClaw, and Hermes sessions into a single searchable view — web or terminal — and lets you tmux-resume any of them with one keystroke.
1010
1111
## Why
1212

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "rejoin"
7-
version = "0.2.0"
8-
description = "Local dashboard for browsing and rejoining AI coding-agent sessions (Claude Code, Codex, OpenCode, Pi, OpenClaw)."
7+
version = "0.3.0"
8+
description = "Local dashboard for browsing and rejoining AI coding-agent sessions (Claude Code, Codex, OpenCode, Pi, OpenClaw, Hermes)."
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = { text = "MIT" }

rejoin/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import Literal
99

10-
Tool = Literal["claude", "codex", "opencode", "pi", "openclaw"]
10+
Tool = Literal["claude", "codex", "opencode", "pi", "openclaw", "hermes"]
1111

1212
TEXT_PART_TYPES = frozenset({"text", "input_text", "output_text"})
1313

rejoin/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CLAUDE_PROJECTS_ROOT = HOME / ".claude" / "projects"
1212
CODEX_SESSIONS_ROOT = HOME / ".codex" / "sessions"
1313
OPENCLAW_AGENTS_ROOT = HOME / ".openclaw" / "agents"
14+
HERMES_DB_PATH = HOME / ".hermes" / "state.db"
1415

1516
DATA_DIR = HOME / ".local" / "share" / "rejoin"
1617
DB_PATH = DATA_DIR / "index.db"

rejoin/hermes.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Hermes Agent provider. Reads ~/.hermes/state.db directly.
2+
3+
Hermes (Nous Research) stores all sessions in one SQLite DB:
4+
- sessions(id, model, started_at, message_count, tool_call_count, title, ...)
5+
- messages(id, session_id, role, content, tool_calls, timestamp, ...)
6+
7+
Schema docs: https://hermes-agent.nousresearch.com/docs/developer-guide/session-storage
8+
"""
9+
from __future__ import annotations
10+
11+
import json
12+
import sqlite3
13+
from collections.abc import Iterator
14+
from datetime import UTC, datetime
15+
from pathlib import Path
16+
17+
from .common import utcnow_iso
18+
from .config import HERMES_DB_PATH
19+
from .transcript import Turn
20+
21+
22+
def _connect(path: Path) -> sqlite3.Connection:
23+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
24+
conn.row_factory = sqlite3.Row
25+
return conn
26+
27+
28+
def _epoch_to_iso(ts: float | None) -> str | None:
29+
if ts is None:
30+
return None
31+
try:
32+
return datetime.fromtimestamp(float(ts), UTC).isoformat()
33+
except (ValueError, TypeError, OSError):
34+
return None
35+
36+
37+
def list_hermes_sessions(db_path: Path = HERMES_DB_PATH) -> list[dict]:
38+
"""Return session records; shape compatible with SessionRecord fields."""
39+
if not db_path.exists():
40+
return []
41+
out: list[dict] = []
42+
with _connect(db_path) as conn:
43+
rows = conn.execute(
44+
"""
45+
SELECT s.id, s.model, s.started_at, s.ended_at,
46+
s.message_count, s.tool_call_count, s.title,
47+
(SELECT content FROM messages
48+
WHERE session_id = s.id AND role = 'user'
49+
ORDER BY timestamp ASC LIMIT 1) AS first_prompt,
50+
(SELECT content FROM messages
51+
WHERE session_id = s.id AND role = 'user'
52+
ORDER BY timestamp DESC LIMIT 1) AS last_prompt,
53+
(SELECT MAX(timestamp) FROM messages
54+
WHERE session_id = s.id) AS last_msg_ts
55+
FROM sessions s
56+
ORDER BY s.started_at DESC
57+
"""
58+
).fetchall()
59+
stat = db_path.stat()
60+
for r in rows:
61+
started = _epoch_to_iso(r["started_at"])
62+
last_act = _epoch_to_iso(r["last_msg_ts"] or r["started_at"])
63+
out.append({
64+
"id": r["id"],
65+
"tool": "hermes",
66+
"path": f"hermes://{r['id']}",
67+
"cwd": None,
68+
"started_at": started,
69+
"last_activity": last_act,
70+
"mtime": stat.st_mtime, # shared DB mtime — not per-session
71+
"size": 0,
72+
"message_count": r["message_count"] or 0,
73+
"tool_call_count": r["tool_call_count"] or 0,
74+
"model": r["model"],
75+
"first_prompt": r["first_prompt"],
76+
"last_prompt": r["last_prompt"],
77+
"codex_summary": None,
78+
"native_title": r["title"],
79+
"indexed_at": utcnow_iso(),
80+
})
81+
return out
82+
83+
84+
def iter_hermes_turns(session_id: str,
85+
db_path: Path = HERMES_DB_PATH) -> Iterator[Turn]:
86+
if not db_path.exists():
87+
return
88+
with _connect(db_path) as conn:
89+
rows = conn.execute(
90+
"""
91+
SELECT role, content, tool_calls, tool_name, timestamp
92+
FROM messages
93+
WHERE session_id = :id
94+
ORDER BY timestamp ASC
95+
""",
96+
{"id": session_id},
97+
).fetchall()
98+
for r in rows:
99+
role = r["role"]
100+
ts = _epoch_to_iso(r["timestamp"])
101+
if role in ("user", "assistant") and r["content"]:
102+
yield Turn(role, r["content"], {"ts": ts})
103+
if r["tool_calls"]:
104+
try:
105+
tcs = json.loads(r["tool_calls"])
106+
except (ValueError, TypeError):
107+
tcs = []
108+
if isinstance(tcs, list):
109+
for tc in tcs:
110+
if not isinstance(tc, dict):
111+
continue
112+
fn = (tc.get("function") or {})
113+
args = fn.get("arguments") or tc.get("arguments") or {}
114+
if isinstance(args, str):
115+
try:
116+
args = json.loads(args)
117+
except ValueError:
118+
pass
119+
name = fn.get("name") or tc.get("name") or r["tool_name"]
120+
body = json.dumps(args, indent=2) if not isinstance(args, str) else args
121+
yield Turn("tool_use", body[:4000], {"name": name, "ts": ts})
122+
if role == "tool" and r["content"]:
123+
yield Turn("tool_result", str(r["content"])[:4000],
124+
{"name": r["tool_name"], "ts": ts})

rejoin/indexer.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,34 @@ def reindex(force: bool = False) -> dict:
260260
except Exception:
261261
stats["errors"] += 1
262262

263+
# Hermes uses a single SQLite DB (~/.hermes/state.db). Read it
264+
# directly and upsert each session. Native Hermes titles land in
265+
# the `titles` table so our OpenRouter titler doesn't regenerate.
266+
stats.setdefault("hermes_new", 0)
267+
stats.setdefault("hermes_updated", 0)
268+
try:
269+
from .hermes import list_hermes_sessions
270+
for raw in list_hermes_sessions():
271+
native_title = raw.pop("native_title", None)
272+
rec = SessionRecord(**{k: v for k, v in raw.items()
273+
if k != "indexed_at"})
274+
prior = existing.get(rec.path)
275+
upsert(conn, rec)
276+
stats["hermes_updated" if prior else "hermes_new"] += 1
277+
changed += 1
278+
if native_title:
279+
conn.execute(
280+
"""INSERT INTO titles
281+
(session_id, title, content_hash, generated_at, tokens_in, tokens_out)
282+
VALUES (:id, :t, 'hermes-native', :now, 0, 0)
283+
ON CONFLICT(session_id) DO UPDATE SET
284+
title = excluded.title,
285+
content_hash = excluded.content_hash""",
286+
{"id": rec.id, "t": native_title, "now": utcnow_iso()},
287+
)
288+
except Exception:
289+
stats["errors"] += 1
290+
263291
if changed:
264292
refresh_fts(conn)
265293
return stats

rejoin/resume.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ def resume_command(tool: str, session_id: str, cwd: str | None) -> str:
2828
inner = f"codex resume {shlex.quote(session_id)}"
2929
elif tool == "pi":
3030
inner = f"pi {shlex.quote(session_id)}"
31+
elif tool == "hermes":
32+
inner = f"hermes --resume {shlex.quote(session_id)}"
3133
elif tool == "openclaw":
3234
# OpenClaw doesn't expose a `resume` subcommand; closest interactive
3335
# flow is `openclaw agent --session-id <id>` with a follow-up message.

rejoin/static/style.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ main {
233233
.tag-opencode { background: rgba(121, 82, 197, 0.14); color: #6940B0; }
234234
.tag-pi { background: rgba(52, 110, 177, 0.14); color: #2E5D8E; }
235235
.tag-openclaw { background: rgba(220, 80, 50, 0.14); color: #B8432A; }
236+
.tag-hermes { background: rgba(139, 71, 137, 0.16); color: #7A3F74; }
236237
.count { margin: 10px 4px; font-family: var(--font-mono); font-size: 11px; color: var(--cloudy-dim); }
237238

238239
/* ---------- detail header ---------- */

rejoin/transcript.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,11 @@ def load_turns(tool: Tool, path: Path) -> list[Turn]:
133133
except Exception:
134134
return []
135135
return list(iter_external_turns(tool, session_id))
136+
if tool == "hermes":
137+
session_id = str(path).rsplit("/", 1)[-1]
138+
try:
139+
from .hermes import iter_hermes_turns
140+
except Exception:
141+
return []
142+
return list(iter_hermes_turns(session_id))
136143
raise ValueError(f"unknown tool: {tool}")

0 commit comments

Comments
 (0)