From 817da1326401c5ec7dc9e36d964c6d433fd45ee7 Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Wed, 23 Sep 2026 16:45:30 +0700 Subject: [PATCH 1/2] Add a Hermes Agent adapter (10th agent) Mirrors chats into Hermes' SQLite store ($HERMES_HOME/state.db) so they show up in Hermes' own /resume, and reads Hermes chats into every other agent. Writes sessions + messages rows directly (the schema is stable and already maintained by AFTER-INSERT triggers), marked via origin_json so hop never re-adopts its own mirror. model is left NULL on purpose: --resume reuses the stored model, and a placeholder like 'hop-import' makes the mirror unresumable. Also fixes two pre-existing bugs found while wiring this up: - kimi: updated was the raw ISO string from state.json, so sync's sort-by-updated crashed with str/int comparison (TypeError). - kimi: list() now converts it with core.iso_ms. Tests: 3 new (Hermes mirror round-trip + title collision, reading a real session, WAL store without -wal/-shm sidecars; Kimi ISO updatedAt). --- README.md | 6 +- src/samethread/agents/__init__.py | 4 +- src/samethread/agents/hermes.py | 176 ++++++++++++++++++++++++++++++ src/samethread/agents/kimi.py | 4 +- src/samethread/cli.py | 2 +- tests/test_core.py | 109 +++++++++++++++++- 6 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 src/samethread/agents/hermes.py diff --git a/README.md b/README.md index 0e9796a..1d54f9d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Setup: 3 commands - 9 agents + 10 agents Native resume

@@ -31,6 +31,7 @@ I use Claude Code for the heavy reasoning and cheaper agents for the easy stuff. | Claude Code | JSONL files under `~/.claude/projects` | | OpenCode | a SQLite database, `opencode.db` | | Codex CLI | rollout JSONL files under `~/.codex/sessions` | +| Hermes Agent | a SQLite `state.db` under `$HERMES_HOME` (default `~/.hermes`) | | Gemini CLI | JSONL files under `~/.gemini/tmp`, keyed by a project registry | | Qwen Code | JSONL files under `~/.qwen/projects` | | Pi | a JSONL entry tree under `~/.pi/agent/sessions` | @@ -75,6 +76,7 @@ That's it. Keep working the way you do, and use each agent's own resume. `hop ag | **Kimi Code** 2.x | ✅ | ✅ event log + custom title | `[[hooks]]` `Stop` in `config.toml` | resume ✔ · hook ✔ | | **Gemini CLI** | ✅ | ✅ in folders Gemini already knows | `AfterAgent` + `SessionEnd` hooks | listed with title ✔ | | **Antigravity CLI** (`agy`) | ✅ | ➖ no import API: `hop resume agy` seeds a new conversation | `Stop` hook | read ✔ · hook ✔ | +| **Hermes Agent** | ✅ | ✅ SQLite `sessions`/`messages` + `hermes --resume` | none yet: picked up by any other agent's sync | resume ✔ | | **MiniMax Code** | ✅ | ➖ `hop resume mmx` seeds a new session | none yet: picked up by any other agent's sync | read ✔ | "resume ✔" means a mirror written by SameThread was reopened with the agent's own resume command, and the model answered a question only the imported history could answer. Tested on Windows 11 with Claude Code 2.1.280, OpenCode 1.18.32, Codex 0.156.1, Qwen Code 0.24.4, Pi 0.87.1, Kimi Code 2.0.2, Gemini CLI 0.62 nightly, agy 1.2.9 and MiniMax Code 0.5.2. Storage paths are the same on macOS and Linux, and CI runs the tests on all three systems. @@ -141,7 +143,7 @@ Logs go to `~/.hop/hop.log`. | Writes into current OpenCode (1.x) and Kimi Code | ✅ | ❌ | ➖ | | Automatic sync through hooks | ✅ | ❌ one-shot | ❌ one-shot | | Round-trips with source tags | ✅ | ❌ | ❌ | -| Number of agents | 9 | 15+ | 6 | +| Number of agents | 10 | 15+ | 6 | casr covers more agents. Reach for it when you need a one-off conversion to something like Cursor or Aider. SameThread is for people who switch between agents all day and want it to be invisible. diff --git a/src/samethread/agents/__init__.py b/src/samethread/agents/__init__.py index 54e1ab3..57bdd9f 100644 --- a/src/samethread/agents/__init__.py +++ b/src/samethread/agents/__init__.py @@ -3,13 +3,15 @@ from .claude import Claude from .codex import Codex from .gemini import Gemini +from .hermes import Hermes from .kimi import Kimi from .minimax import MiniMax from .opencode import OpenCode from .pi import Pi from .qwen import Qwen -AGENTS = {a.key: a for a in (Claude(), OpenCode(), Antigravity(), Codex(), Gemini(), Qwen(), Pi(), Kimi(), MiniMax())} +AGENTS = {a.key: a for a in (Claude(), OpenCode(), Hermes(), Antigravity(), Codex(), Gemini(), Qwen(), Pi(), Kimi(), + MiniMax())} NAMES = {k: a.name for k, a in AGENTS.items()} diff --git a/src/samethread/agents/hermes.py b/src/samethread/agents/hermes.py new file mode 100644 index 0000000..682d2e7 --- /dev/null +++ b/src/samethread/agents/hermes.py @@ -0,0 +1,176 @@ +"""Hermes Agent: $HERMES_HOME/state.db — SQLite `sessions`/`messages` tables.""" +import datetime as dt +import json +import os +import sqlite3 +import uuid + +from ..core import HOME, HOP_ORIGIN, el, fmt_call, fmt_result +from .base import Agent + +ROOT = os.environ.get('HERMES_HOME') or os.path.join(HOME, '.hermes') +DB = os.path.join(ROOT, 'state.db') +ORIGIN = json.dumps({'imported_from': {'tool': HOP_ORIGIN}}) +FIELDS = ('id, source, model, system_prompt, parent_session_id, title, cwd, profile_name, ' + 'started_at, last_activity_at, message_count, origin_json') +KEYS = tuple(f.strip() for f in FIELDS.split(',')) + + +def connect(rw=False): + if rw: + return sqlite3.connect(f'file:{DB}?mode=rw', uri=True, timeout=15) + if not os.path.exists(DB): + return None + try: + c = sqlite3.connect(f'file:{DB}?mode=ro', uri=True, timeout=15) + c.execute('pragma schema_version') # sqlite3.connect is lazy: force the open so a failure is catchable + return c + except sqlite3.OperationalError: + # A WAL-mode db whose -wal/-shm sidecars are gone (Hermes exited cleanly) cannot be opened + # mode=ro; a read-write handle reads the same committed data. + return sqlite3.connect(f'file:{DB}?mode=rw', uri=True, timeout=15) + + +def profile_name(): + """/state.db -> 'default'; /profiles//state.db -> ''.""" + parent = os.path.dirname(DB) + return os.path.basename(parent) if os.path.basename(os.path.dirname(parent)) == 'profiles' else 'default' + + +def new_session_id(): + return dt.datetime.now().strftime('%Y%m%d_%H%M%S_') + uuid.uuid4().hex[:6] + + +def stat(c, sid): + n, mx = c.execute('select count(*), coalesce(max(id), 0) from messages where session_id=?', (sid,)).fetchone() + row = c.execute('select last_activity_at, started_at from sessions where id=?', (sid,)).fetchone() + upd = int(max(row[0] or 0, row[1] or 0) * 1000) if row else 0 + return {'sig': f'{upd}:{n}:{mx}', 'updated': upd} + + +class Hermes(Agent): + key, name, aliases, binary, writable = 'hermes', 'Hermes', ('hermes-agent',), 'hermes', True + + def detect(self): + return os.path.exists(DB) or super().detect() + + def list(self, cfg): + c = connect() + if not c: + return {} + try: + out = {} + for row in c.execute(f'select {FIELDS}, model_config from sessions where source=? ' + 'and parent_session_id is null and archived=0 and hidden=0', ('cli',)): + d = dict(zip(KEYS, row)) + if '_delegate_from' in (row[-1] or ''): + continue + out['hermes:' + d['id']] = self.session( + d['id'], cwd=os.path.normpath(d['cwd'] or HOME), title=d['title'], + is_hop=self._is_hop(d['origin_json']), **stat(c, d['id'])) + return out + finally: + c.close() + + def read(self, s, cfg): + c = connect() + try: + s['cwd'] = os.path.normpath(s['cwd']) if s.get('cwd') else None + row = c.execute('select cwd, title, origin_json from sessions where id=?', (s['id'],)).fetchone() + if row: + s['cwd'] = os.path.normpath(row[0] or HOME) + s['title'] = row[1] + s['is_hop'] = self._is_hop(row[2]) + rows = c.execute("select id, role, content, tool_calls, tool_name, timestamp from messages " + "where session_id=? and active=1 and role in ('user','assistant','tool') " + "order by timestamp, id", (s['id'],)).fetchall() + finally: + c.close() + els = [] + for mid, role, content, calls, tname, ts in rows: + ts = int((ts or 0) * 1000) + if role == 'tool': + els.append(el(mid, 'tool', fmt_result(content, cfg), ts)) + continue + if content: + els.append(el(mid, role, content, ts)) + for i, tc in enumerate(self._calls(calls)): + fn = tc.get('function') or {} + els.append(el(f'{mid}#t{i}', 'tool', fmt_call(fn.get('name') or tname, fn.get('arguments'), cfg), ts)) + return els + + def write(self, cp, cwd, msgs, title, ctx): + sid = cp['id'] if cp else new_session_id() + os.makedirs(ROOT, exist_ok=True) + c = connect(rw=True) + try: + c.execute('PRAGMA foreign_keys=ON') + last = (msgs[-1]['ts'] / 1000) if msgs else 0 + if cp: + c.execute('delete from messages where session_id=?', (sid,)) + with c: # one transaction; on a title collision retry without it (title is unique) + try: + self._session_row(c, cp, sid, cwd, title, last, len(msgs)) + except sqlite3.IntegrityError: + self._session_row(c, cp, sid, cwd, None, last, len(msgs)) + c.executemany('insert into messages (session_id, role, content, timestamp) values (?,?,?,?)', + [(sid, m['role'], m['text'], m['ts'] / 1000) for m in msgs]) + seen = [str(i) for (i,) in c.execute('select id from messages where session_id=? order by id', (sid,))] + return {'id': sid, 'seen': seen, **stat(c, sid)} + finally: + c.close() + + def _session_row(self, c, cp, sid, cwd, title, last, n): + if cp: + c.execute('update sessions set title=?, last_activity_at=?, message_count=? where id=?', (title, last, n, sid)) + else: + # model is left NULL on purpose: `hermes --resume` reuses the session's stored model, and a + # placeholder like 'hop-import' is not a real provider model — it makes the mirror unresumable. + c.execute('insert into sessions (id, source, title, cwd, profile_name, started_at, ' + 'last_activity_at, message_count, origin_json) values (?,?,?,?,?,?,?,?,?)', + (sid, 'cli', title, cwd, profile_name(), last, last, n, ORIGIN)) + + def retitle(self, cp, title): + c = connect(rw=True) + try: + with c: + c.execute('update sessions set title=? where id=?', (title, cp['id'])) + except sqlite3.IntegrityError: + return False + finally: + c.close() + return True + + def delete(self, cp): + c = connect(rw=True) + if not c: + return + try: + with c: + c.execute('delete from messages where session_id=?', (cp['id'],)) # FK: children first + c.execute('delete from sessions where id=?', (cp['id'],)) + finally: + c.close() + + def resume_cmd(self, sid): + return ['hermes', '--resume', sid] + + def install(self, dry): + # Hermes' shell hooks need an allowlist plus a config.yaml edit a third-party installer should not make, + # so there is nothing safe to add here. Mirrors land on any other agent's sync, or `hop sync`. + return 'no hook support yet; synced when another agent (or `hop sync`) runs' + + @staticmethod + def _is_hop(origin_json): + try: + return json.loads(origin_json or '')['imported_from']['tool'] == HOP_ORIGIN + except (ValueError, KeyError, TypeError): + return False + + @staticmethod + def _calls(raw): + try: + calls = json.loads(raw or '[]') + except ValueError: + return [] + return [tc for tc in calls if isinstance(tc, dict)] diff --git a/src/samethread/agents/kimi.py b/src/samethread/agents/kimi.py index 19cb237..1f35e7f 100644 --- a/src/samethread/agents/kimi.py +++ b/src/samethread/agents/kimi.py @@ -7,7 +7,7 @@ import uuid from .. import hookkit -from ..core import HOME, HopError, dumps, el, file_sig, fmt_call, fmt_result, lp, read_json, read_jsonl, write_lines +from ..core import HOME, HopError, dumps, el, file_sig, fmt_call, fmt_result, iso_ms, lp, read_json, read_jsonl, write_lines from .base import Agent ROOT = os.path.join(HOME, '.kimi-code') @@ -54,7 +54,7 @@ def list(self, cfg): out['kimi:' + sid] = self.session(sid, path=wire, dir=d['sessionDir'], title=state.get('title'), cwd=os.path.normpath(state.get('cwd') or d.get('workDir') or HOME), is_hop=bool((state.get('custom') or {}).get('samethread')), - sig=file_sig(wire), updated=state.get('updatedAt') or 0) + sig=file_sig(wire), updated=iso_ms(state.get('updatedAt'))) return out def read(self, s, cfg): diff --git a/src/samethread/cli.py b/src/samethread/cli.py index db6bd8d..537a7e3 100644 --- a/src/samethread/cli.py +++ b/src/samethread/cli.py @@ -227,7 +227,7 @@ def main(): ('forget', cmd_forget, "delete a chat's mirrors and stop mirroring it")): p = sub.add_parser(name, help=hlp) if name == 'resume': - p.add_argument('agent', help='cc, oc, agy, codex, gemini, qwen, pi, kimi, mmx (or the full name)') + p.add_argument('agent', help='cc, oc, hermes, agy, codex, gemini, qwen, pi, kimi, mmx (or the full name)') if name == 'agy': p.set_defaults(agent='agy') if name != 'list': diff --git a/tests/test_core.py b/tests/test_core.py index ea7543b..65830ac 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,10 +1,12 @@ import json import os +import sqlite3 import tempfile import unittest TMP = tempfile.mkdtemp(prefix='hop-test-') -os.environ.update(HOP_HOME=os.path.join(TMP, 'hop'), HOME=TMP, USERPROFILE=TMP, CODEX_HOME=os.path.join(TMP, '.codex')) +os.environ.update(HOP_HOME=os.path.join(TMP, 'hop'), HOME=TMP, USERPROFILE=TMP, CODEX_HOME=os.path.join(TMP, '.codex'), + HERMES_HOME=os.path.join(TMP, '.hermes')) from samethread import core, sync # noqa: E402 from samethread.agents import AGENTS, NAMES, resolve # noqa: E402 @@ -43,7 +45,8 @@ def test_fmt_call_prefers_the_meaningful_argument(self): def test_agent_names_resolve(self): self.assertEqual(resolve('claude'), 'cc') self.assertEqual(resolve('MiniMax'), 'mmx') - self.assertEqual(len(NAMES), 9) + self.assertEqual(resolve('hermes'), 'hermes') + self.assertEqual(len(NAMES), 10) class Render(unittest.TestCase): @@ -162,5 +165,107 @@ def test_reads_transcript_steps(self): ('tool', '▸ run_command: npm test'), ('tool', ' ⎿ 1 failing')]) +HOP = os.path.join(TMP, '.hermes') + +DDL = """ +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT, model_config TEXT, + system_prompt TEXT, parent_session_id TEXT, + started_at REAL NOT NULL, ended_at REAL, end_reason TEXT, + message_count INTEGER DEFAULT 0, title TEXT, title_source TEXT, + cwd TEXT, last_activity_at REAL, origin_json TEXT, + profile_name TEXT, archived INTEGER NOT NULL DEFAULT 0, hidden INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id)); +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, active INTEGER NOT NULL DEFAULT 1); +CREATE UNIQUE INDEX idx_sessions_title_unique ON sessions(title) WHERE title IS NOT NULL; +""" + + +class HermesStore(unittest.TestCase): + """The store must be shaped exactly like the real $HERMES_HOME/state.db (title index included).""" + + @classmethod + def setUpClass(cls): + os.makedirs(HOP, exist_ok=True) + c = sqlite3.connect(os.path.join(HOP, 'state.db')) + c.executescript(DDL) + c.commit() + c.close() + + def conn(self): + return sqlite3.connect(os.path.join(HOP, 'state.db')) + + def test_mirror_roundtrips_and_collides_on_title(self): + agent, th = AGENTS['hermes'], thread([e('1', 'user', 'remember MANGO'), e('2', 'assistant', 'stored')]) + res = agent.write(None, os.getcwd(), sync.render(th, CFG), 'Hermes (Agy)', {'cfg': CFG}) + s = agent.list(CFG)['hermes:' + res['id']] + els = sync.load(s, CFG) + self.assertEqual({x['id'] for x in els}, set(res['seen'])) + self.assertEqual(els[-1]['text'], 'stored') + self.assertIn('remember MANGO', els[0]['text']) + self.assertTrue(s['is_hop']) + self.assertEqual(s['sig'], res['sig']) + again = agent.write(None, os.getcwd(), sync.render(th, CFG), 'Hermes (Agy)', {'cfg': CFG}) # same title + self.assertNotEqual(again['id'], res['id']) + c = self.conn() + count = c.execute('select count(*) from messages where session_id=?', (res['id'],)).fetchone()[0] + prof = c.execute('select profile_name, model, source from sessions where id=?', (res['id'],)).fetchone() + c.close() + self.assertEqual(count, len(res['seen'])) + # model must stay NULL: hermes --resume reuses the stored model, and a placeholder + # ('hop-import') is not a provider model, which makes the mirror unresumable. + self.assertEqual(prof, ('default', None, 'cli')) + + def test_reads_a_real_user_session(self): + c = self.conn() + c.execute("insert into sessions (id, source, started_at, title, cwd) values ('real1','cli',1.0,'My chat','/tmp')") + c.executemany('insert into messages (session_id, role, content, timestamp, tool_calls, tool_name) ' + 'values (?,?,?,?,?,?)', + [('real1', 'user', 'fix it', 2.0, None, None), + ('real1', 'assistant', 'on it', 3.0, + '[{"id":"t1","type":"function","function":{"name":"Bash","arguments":"{\\"command\\":\\"ls\\"}"}}]', None), + ('real1', 'tool', 'a.py', 4.0, None, 'Bash'), + ('real1', 'session_meta', 'hidden', 5.0, None, None)]) + c.commit() + c.close() + s = AGENTS['hermes'].list(CFG)['hermes:real1'] + self.assertFalse(s['is_hop']) + els = sync.load(s, CFG) + self.assertEqual([(x['role'], x['text']) for x in els], + [('user', 'fix it'), ('assistant', 'on it'), ('tool', '▸ Bash: ls'), ('tool', ' ⎿ a.py')]) + + def test_reads_a_wal_store_without_sidecars(self): + """Hermes exits cleanly, leaving a WAL-mode db with no -wal/-shm; list() must still open it.""" + c = self.conn() + c.execute('PRAGMA journal_mode=WAL') + c.close() # sidecars are removed on close + self.assertFalse(os.path.exists(os.path.join(HOP, 'state.db-wal'))) + self.assertIn('hermes:real1', AGENTS['hermes'].list(CFG)) + + +class KimiIndex(unittest.TestCase): + """A real Kimi state.json stores updatedAt as an ISO string; list() must hand sync an int (ms).""" + + def test_updated_at_is_converted_to_epoch_ms(self): + sdir = os.path.join(TMP, '.kimi-code', 'sessions', 'wd_x_abc', 'session_iso1') + os.makedirs(os.path.join(sdir, 'agents', 'main'), exist_ok=True) + with open(os.path.join(sdir, 'agents', 'main', 'wire.jsonl'), 'w') as f: + f.write(core.dumps({'type': 'context.append_message', 'time': 1, 'message': { + 'role': 'user', 'content': [{'type': 'text', 'text': 'hi'}], + 'origin': {'kind': 'user'}, 'id': 'm1'}}) + '\n') + with open(os.path.join(sdir, 'state.json'), 'w') as f: + f.write(core.dumps({'id': 'session_iso1', 'archived': False, 'cwd': '/tmp', + 'updatedAt': '2026-07-18T13:10:14.672Z', 'title': 'T'})) + with open(os.path.join(TMP, '.kimi-code', 'session_index.jsonl'), 'a') as f: + f.write(core.dumps({'sessionId': 'session_iso1', 'sessionDir': sdir, 'workDir': '/tmp'}) + '\n') + s = AGENTS['kimi'].list(CFG)['kimi:session_iso1'] + self.assertIsInstance(s['updated'], int) + self.assertEqual(s['updated'], core.iso_ms('2026-07-18T13:10:14.672Z')) + sorted([s], key=lambda x: x['updated']) # sync.adopt_new's sort key must not raise + + if __name__ == '__main__': unittest.main() From 519f823a111c489a3d079617b44bdc203d8a512c Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Wed, 23 Sep 2026 17:09:49 +0700 Subject: [PATCH 2/2] hermes: fix the three failures from review (missing store, title collision, delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. write() on a store that does not exist yet (Hermes installed but never run) raised a raw OperationalError, which is not a HopError, so sync queued the chat for retry on every turn forever. It now raises HopError('run Hermes once so its state.db exists') before touching anything, like Gemini/Kimi. 2. A title collision retried with no title at all, leaving mirrors untitled (Hermes requires unique titles). It now retries with ' · '. 3. delete() (hop forget) and retitle() crashed when ~/.hermes was gone. The existence check moved into connect() so every rw caller treats a missing store as a no-op. Also from the same review: - started_at is the first message's time, not the last. - The delete in write() moved inside the transaction block. - list() uses one grouped query for message counts instead of two per session. - Tests: per-test store reset (no test depends on another having run), the WAL test seeds its own session, HOP renamed to HERMES_HOME_DIR. - Docs: pyproject description/keywords and the README 'Tested on' line mention Hermes. Hero image left for the maintainer's make_hero.py. - Added 3 tests: missing store -> HopError + delete no-op, title collision keeps a suffixed title, WAL store without sidecars. --- README.md | 2 +- pyproject.toml | 4 +-- src/samethread/agents/hermes.py | 39 ++++++++++++++++++--------- tests/test_core.py | 48 ++++++++++++++++++++++++++++----- 4 files changed, 71 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1d54f9d..979dc9a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ That's it. Keep working the way you do, and use each agent's own resume. `hop ag | **Hermes Agent** | ✅ | ✅ SQLite `sessions`/`messages` + `hermes --resume` | none yet: picked up by any other agent's sync | resume ✔ | | **MiniMax Code** | ✅ | ➖ `hop resume mmx` seeds a new session | none yet: picked up by any other agent's sync | read ✔ | -"resume ✔" means a mirror written by SameThread was reopened with the agent's own resume command, and the model answered a question only the imported history could answer. Tested on Windows 11 with Claude Code 2.1.280, OpenCode 1.18.32, Codex 0.156.1, Qwen Code 0.24.4, Pi 0.87.1, Kimi Code 2.0.2, Gemini CLI 0.62 nightly, agy 1.2.9 and MiniMax Code 0.5.2. Storage paths are the same on macOS and Linux, and CI runs the tests on all three systems. +"resume ✔" means a mirror written by SameThread was reopened with the agent's own resume command, and the model answered a question only the imported history could answer. Tested on Windows 11 with Claude Code 2.1.280, OpenCode 1.18.32, Codex 0.156.1, Qwen Code 0.24.4, Pi 0.87.1, Kimi Code 2.0.2, Gemini CLI 0.62 nightly, agy 1.2.9, MiniMax Code 0.5.2 and Hermes Agent (on macOS; a mirror was resumed in Hermes and it answered from the imported history). Storage paths are the same on macOS and Linux, and CI runs the tests on all three systems. ## How it works diff --git a/pyproject.toml b/pyproject.toml index b988dcb..6560156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta" [project] name = "samethread" dynamic = ["version"] -description = "Hop between coding agents, stay on the same thread: one chat history across Claude Code, OpenCode, Codex, Gemini CLI, Qwen Code, Pi, Kimi Code, Antigravity and MiniMax Code." +description = "Hop between coding agents, stay on the same thread: one chat history across Claude Code, OpenCode, Codex, Gemini CLI, Qwen Code, Pi, Kimi Code, Antigravity, MiniMax Code and Hermes Agent." readme = "README.md" requires-python = ">=3.9" license = { text = "MIT" } authors = [{ name = "GodrezJr2" }] -keywords = ["claude-code", "opencode", "codex", "gemini-cli", "qwen-code", "pi", "kimi", "antigravity", "minimax", "ai-agents", "coding-agents", "session", "resume", "cli"] +keywords = ["claude-code", "opencode", "codex", "gemini-cli", "qwen-code", "pi", "kimi", "antigravity", "minimax", "hermes", "ai-agents", "coding-agents", "session", "resume", "cli"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", diff --git a/src/samethread/agents/hermes.py b/src/samethread/agents/hermes.py index 682d2e7..e41bdf4 100644 --- a/src/samethread/agents/hermes.py +++ b/src/samethread/agents/hermes.py @@ -5,7 +5,7 @@ import sqlite3 import uuid -from ..core import HOME, HOP_ORIGIN, el, fmt_call, fmt_result +from ..core import HOME, HOP_ORIGIN, HopError, el, fmt_call, fmt_result from .base import Agent ROOT = os.environ.get('HERMES_HOME') or os.path.join(HOME, '.hermes') @@ -17,10 +17,10 @@ def connect(rw=False): + if not os.path.exists(DB): + return None # no store yet (Hermes never run) or removed; callers treat None as "nothing to do" if rw: return sqlite3.connect(f'file:{DB}?mode=rw', uri=True, timeout=15) - if not os.path.exists(DB): - return None try: c = sqlite3.connect(f'file:{DB}?mode=ro', uri=True, timeout=15) c.execute('pragma schema_version') # sqlite3.connect is lazy: force the open so a failure is catchable @@ -42,6 +42,7 @@ def new_session_id(): def stat(c, sid): + """Post-write signature of one mirror: what sync stores to detect later changes.""" n, mx = c.execute('select count(*), coalesce(max(id), 0) from messages where session_id=?', (sid,)).fetchone() row = c.execute('select last_activity_at, started_at from sessions where id=?', (sid,)).fetchone() upd = int(max(row[0] or 0, row[1] or 0) * 1000) if row else 0 @@ -59,15 +60,19 @@ def list(self, cfg): if not c: return {} try: + stats = {sid: (n, mx) for sid, n, mx in + c.execute('select session_id, count(*), coalesce(max(id), 0) from messages group by session_id')} out = {} for row in c.execute(f'select {FIELDS}, model_config from sessions where source=? ' 'and parent_session_id is null and archived=0 and hidden=0', ('cli',)): d = dict(zip(KEYS, row)) if '_delegate_from' in (row[-1] or ''): continue + n, mx = stats.get(d['id'], (0, 0)) + upd = int(max(d['last_activity_at'] or 0, d['started_at'] or 0) * 1000) out['hermes:' + d['id']] = self.session( d['id'], cwd=os.path.normpath(d['cwd'] or HOME), title=d['title'], - is_hop=self._is_hop(d['origin_json']), **stat(c, d['id'])) + is_hop=self._is_hop(d['origin_json']), sig=f'{upd}:{n}:{mx}', updated=upd) return out finally: c.close() @@ -100,19 +105,24 @@ def read(self, s, cfg): return els def write(self, cp, cwd, msgs, title, ctx): + if not os.path.exists(DB): + # A missing store is permanent until the user runs Hermes once; a HopError stops sync from + # queueing this chat for retry on every turn (same as the Gemini and Kimi adapters). + raise HopError('run Hermes once so its state.db exists') sid = cp['id'] if cp else new_session_id() - os.makedirs(ROOT, exist_ok=True) c = connect(rw=True) try: c.execute('PRAGMA foreign_keys=ON') + first = (msgs[0]['ts'] / 1000) if msgs else 0 last = (msgs[-1]['ts'] / 1000) if msgs else 0 - if cp: - c.execute('delete from messages where session_id=?', (sid,)) - with c: # one transaction; on a title collision retry without it (title is unique) + with c: # one transaction: the delete and every insert land together, or not at all + if cp: + c.execute('delete from messages where session_id=?', (sid,)) try: - self._session_row(c, cp, sid, cwd, title, last, len(msgs)) + self._session_row(c, cp, sid, cwd, title, first, last, len(msgs)) except sqlite3.IntegrityError: - self._session_row(c, cp, sid, cwd, None, last, len(msgs)) + # Hermes requires unique titles: a colliding mirror gets a short suffix, never a blank title. + self._session_row(c, cp, sid, cwd, f'{title} · {sid[-6:]}', first, last, len(msgs)) c.executemany('insert into messages (session_id, role, content, timestamp) values (?,?,?,?)', [(sid, m['role'], m['text'], m['ts'] / 1000) for m in msgs]) seen = [str(i) for (i,) in c.execute('select id from messages where session_id=? order by id', (sid,))] @@ -120,18 +130,21 @@ def write(self, cp, cwd, msgs, title, ctx): finally: c.close() - def _session_row(self, c, cp, sid, cwd, title, last, n): + def _session_row(self, c, cp, sid, cwd, title, first, last, n): if cp: - c.execute('update sessions set title=?, last_activity_at=?, message_count=? where id=?', (title, last, n, sid)) + c.execute('update sessions set title=?, started_at=?, last_activity_at=?, message_count=? where id=?', + (title, first, last, n, sid)) else: # model is left NULL on purpose: `hermes --resume` reuses the session's stored model, and a # placeholder like 'hop-import' is not a real provider model — it makes the mirror unresumable. c.execute('insert into sessions (id, source, title, cwd, profile_name, started_at, ' 'last_activity_at, message_count, origin_json) values (?,?,?,?,?,?,?,?,?)', - (sid, 'cli', title, cwd, profile_name(), last, last, n, ORIGIN)) + (sid, 'cli', title, cwd, profile_name(), first, last, n, ORIGIN)) def retitle(self, cp, title): c = connect(rw=True) + if not c: + return False try: with c: c.execute('update sessions set title=? where id=?', (title, cp['id'])) diff --git a/tests/test_core.py b/tests/test_core.py index 65830ac..83daa46 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -165,7 +165,7 @@ def test_reads_transcript_steps(self): ('tool', '▸ run_command: npm test'), ('tool', ' ⎿ 1 failing')]) -HOP = os.path.join(TMP, '.hermes') +HERMES_HOME_DIR = os.path.join(TMP, '.hermes') DDL = """ CREATE TABLE sessions ( @@ -189,14 +189,21 @@ class HermesStore(unittest.TestCase): @classmethod def setUpClass(cls): - os.makedirs(HOP, exist_ok=True) - c = sqlite3.connect(os.path.join(HOP, 'state.db')) + os.makedirs(HERMES_HOME_DIR, exist_ok=True) + c = sqlite3.connect(os.path.join(HERMES_HOME_DIR, 'state.db')) c.executescript(DDL) c.commit() c.close() def conn(self): - return sqlite3.connect(os.path.join(HOP, 'state.db')) + return sqlite3.connect(os.path.join(HERMES_HOME_DIR, 'state.db')) + + def setUp(self): + # Every test starts from an empty store: no test may depend on another having run first. + c = self.conn() + c.executescript('delete from messages; delete from sessions;') + c.commit() + c.close() def test_mirror_roundtrips_and_collides_on_title(self): agent, th = AGENTS['hermes'], thread([e('1', 'user', 'remember MANGO'), e('2', 'assistant', 'stored')]) @@ -240,10 +247,39 @@ def test_reads_a_real_user_session(self): def test_reads_a_wal_store_without_sidecars(self): """Hermes exits cleanly, leaving a WAL-mode db with no -wal/-shm; list() must still open it.""" c = self.conn() + c.execute("insert into sessions (id, source, started_at, title, cwd) values ('wal1','cli',1.0,'W','/tmp')") + c.execute("insert into messages (session_id, role, content, timestamp) values ('wal1','user','hi',2.0)") + c.commit() c.execute('PRAGMA journal_mode=WAL') c.close() # sidecars are removed on close - self.assertFalse(os.path.exists(os.path.join(HOP, 'state.db-wal'))) - self.assertIn('hermes:real1', AGENTS['hermes'].list(CFG)) + self.assertFalse(os.path.exists(os.path.join(HERMES_HOME_DIR, 'state.db-wal'))) + self.assertIn('hermes:wal1', AGENTS['hermes'].list(CFG)) + + def test_missing_store_raises_hop_error(self): + """Hermes installed but never run: a HopError, not a raw OperationalError, so sync stops retrying.""" + db = os.path.join(HERMES_HOME_DIR, 'state.db') + os.rename(db, db + '.away') # keep the store for later tests + try: + with self.assertRaises(core.HopError): + AGENTS['hermes'].write(None, os.getcwd(), sync.render(thread([e('1', 'user', 'x')]), CFG), + 'T', {'cfg': CFG}) + AGENTS['hermes'].delete({'id': 'whatever'}) # must be a no-op, not a crash + self.assertEqual(AGENTS['hermes'].list(CFG), {}) + finally: + os.rename(db + '.away', db) + + def test_title_collision_gets_a_suffix_not_a_blank(self): + """Hermes requires unique titles; a second mirror must keep a title (suffixed), never go untitled.""" + agent, th = AGENTS['hermes'], thread([e('1', 'user', 'a'), e('2', 'assistant', 'b')]) + msgs, title = sync.render(th, CFG), 'Greeting (OpenCode)' + res = agent.write(None, os.getcwd(), msgs, title, {'cfg': CFG}) + res2 = agent.write(None, os.getcwd(), msgs, title, {'cfg': CFG}) + c = self.conn() + t1 = c.execute('select title from sessions where id=?', (res['id'],)).fetchone()[0] + t2 = c.execute('select title from sessions where id=?', (res2['id'],)).fetchone()[0] + c.close() + self.assertEqual(t1, title) + self.assertTrue(t2 and t2.startswith(title) and t2 != title, t2) class KimiIndex(unittest.TestCase):