From 817da1326401c5ec7dc9e36d964c6d433fd45ee7 Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Wed, 23 Sep 2026 16:45:30 +0700 Subject: [PATCH 1/7] 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/7] 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): From 27bd241d3ce6ec9d0dd6f8c5cd831ed687d6cb2c Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Wed, 23 Sep 2026 18:02:32 +0700 Subject: [PATCH 3/7] Make the agy adapter writable: mirror foreign chats into its 'Other' tab agy owns a protobuf execution trajectory (conversations/.db) that hop does not write, but a conversation's readable history is a JSONL transcript under brain// and its picker index is the conversation_summaries table. Writing those two, with app_data_dir != 'antigravity-cli', puts a mirror under agy's resume-picker 'Other' tab (verified: rows with app_data_dir='antigravity-cli' are the 'CLI' tab; every other app_data_dir is 'Other'). - write(): emit the transcript JSONL (the exact inverse of read()) + a summary row, so a mirror is readable in agy and continues as text. # ponytail: no protobuf trajectory, so no agy-native tool state; upgrade via agy's importConversationCmd if bit-identical resume matters. - delete()/retitle(): remove the transcript + row / rename the label, so hop forget is reversible. - Mirror detection: rows are tagged samethread and list() sets is_hop, so sync never re-imports its own copy (no loop). - title == preview == the picker label, matching real agy rows. - write() raises HopError (not a raw OperationalError) if agy has never run. Tests: write/read round-trip, 'Other'-tab shape (app_data_dir != cli, empty project/parent, depth 0), is_hop no-loop, delete removes both, missing store -> HopError. 19 tests green on py3.9 and py3.12 with the exact CI commands. Docs updated where they claimed agy was unwritable. --- README.md | 8 +- src/samethread/agents/antigravity.py | 108 ++++++++++++++++++++++++--- tests/test_core.py | 66 ++++++++++++++++ 3 files changed, 169 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0e9796a..78329b9 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ That's it. Keep working the way you do, and use each agent's own resume. `hop ag | **Pi** | ✅ | ✅ entry tree + `session_info` name | extension, every `agent_end` | resume ✔ · hook ✔ | | **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 ✔ | +| **Antigravity CLI** (`agy`) | ✅ | ✅ JSONL transcript + `"Other"`-tab summary row (no protobuf trajectory) | `Stop` hook | read ✔ · write ✔ · hook ✔ | | **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. @@ -102,7 +102,7 @@ flowchart LR |---|---| | `hop sync` | Mirror new and changed chats. The hooks run this for you. | | `hop list [-a]` | Chats for this folder (or all folders), with the exact resume command for every copy. | -| `hop resume [n]` | Open chat *n* in any agent: `cc`, `oc`, `codex`, `gemini`, `qwen`, `pi`, `kimi`, `agy`, `mmx`. For Antigravity and MiniMax it seeds a new conversation, and its turns flow back to the others. | +| `hop resume [n]` | Open chat *n* in any agent: `cc`, `oc`, `codex`, `gemini`, `qwen`, `pi`, `kimi`, `agy`, `mmx`. For Antigravity and MiniMax it seeds a new conversation; `hop sync` also writes mirrors into Antigravity's `"Other"` tab. Turns flow back to the others. | | `hop agents` | Which agents are installed, and how hop reaches each. | | `hop forget ` | Delete a chat's mirrors and stop mirroring it. | | `hop install` / `hop uninstall` | Add or remove the hooks. `hop install` backs up every config file it touches first. | @@ -126,7 +126,7 @@ Logs go to `~/.hop/hop.log`. ## Honest limits -- **Two agents can't be written to.** Antigravity's conversations are runtime-owned protobuf, and MiniMax Code's store is a migration-managed database. For both, `hop resume` opens a new conversation that reads the transcript first. +- **MiniMax can't be written to.** MiniMax Code's store is a migration-managed database, so `hop resume mmx` opens a new session that reads the transcript first. Antigravity *is* written, but as a JSONL transcript + a summary row (readable, lands in the `"Other"` tab) — not agy's protobuf execution trajectory, so a mirror has no agy-native tool state. - **Mirrors are copies, not a live shared session.** If you continue the same chat in two agents at once, you get a fork, not a merge. - **Long chats get trimmed.** They start from the latest compaction summary if there is one, then keep the newest messages that fit in `max_chars`. - **Some agents need a first run.** Gemini CLI mirrors go only into folders Gemini has already opened. Kimi mirrors copy the agent profile from your newest real Kimi session. OpenCode needs the chat's folder to still exist. @@ -149,7 +149,7 @@ casr covers more agents. Reach for it when you need a one-off conversion to some - [ ] Cursor CLI and Crush adapters - [ ] `pipx install samethread` from PyPI -- [ ] Native Antigravity and MiniMax writes, if they ship import APIs +- [ ] Native MiniMax writes, if it ships an import API (Antigravity's protobuf trajectory, so mirrors get real tool state) - [ ] Optional merge of forks Adding an agent is one file in [`src/samethread/agents/`](src/samethread/agents/): a lister, a reader and, if the agent allows it, a writer and a hook. PRs welcome. diff --git a/src/samethread/agents/antigravity.py b/src/samethread/agents/antigravity.py index b4a978a..4165df8 100644 --- a/src/samethread/agents/antigravity.py +++ b/src/samethread/agents/antigravity.py @@ -1,17 +1,39 @@ -"""Antigravity CLI (agy): read-only. Conversations are runtime-owned protobuf; hop reads the transcript log.""" +"""Antigravity CLI (agy): foreign chats land in the resume picker's "Other" tab. + +agy owns a protobuf execution trajectory (conversations/.db) that hop does not write; the +readable history is a JSONL transcript under brain//, and the picker index is the +conversation_summaries table. Writing those two (with app_data_dir != 'antigravity-cli') puts a +mirror under the "Other" tab. # ponytail: no protobuf trajectory, so a mirror is readable and +continues as text but has no agy-native tool state; upgrade via agy's importConversationCmd if +bit-identical resume ever matters. +""" +import datetime import json import os import re +import shutil import sqlite3 import urllib.parse +import uuid from .. import hookkit -from ..core import HOME, el, file_sig, fmt_call, fmt_result, iso_ms, read_jsonl, remap +from ..core import (HOME, HopError, el, file_sig, fmt_call, fmt_result, iso_ms, ms_iso, + read_jsonl, remap, write_lines) from .base import Agent ROOT = os.path.join(HOME, '.gemini', 'antigravity-cli') HOOKS = os.path.join(HOME, '.gemini', 'config', 'hooks.json') HDR_RE = re.compile(r'^(Created At|Completed At):.*\n?', re.M) +MIRROR = 'samethread' # app_data_dir / source marker: a hop-written mirror, lands in "Other" + + +def db(): + return os.path.join(ROOT, 'conversation_summaries.db') + + +def stamp(ms): + """agy's summary timestamps: '2026-03-13 17:12:11.670405+00:00'.""" + return datetime.datetime.fromtimestamp((ms or 0) / 1000, datetime.timezone.utc).isoformat(' ') def transcript(cid): @@ -31,23 +53,23 @@ def uri_to_path(u): class Antigravity(Agent): - key, name, aliases, binary, writable = 'agy', 'Agy', ('antigravity',), 'agy', False + key, name, aliases, binary, writable = 'agy', 'Agy', ('antigravity',), 'agy', True def detect(self): return os.path.isdir(ROOT) or super().detect() def list(self, cfg): - db = os.path.join(ROOT, 'conversation_summaries.db') - if not os.path.exists(db): + if not os.path.exists(db()): return {} - c = sqlite3.connect(f'file:{db}?mode=ro', uri=True, timeout=15) + c = sqlite3.connect(f'file:{db()}?mode=ro', uri=True, timeout=15) try: rows = c.execute('select conversation_id, title, last_modified_time, workspace_uris, ' - 'parent_conversation_id, nesting_depth from conversation_summaries').fetchall() + 'parent_conversation_id, nesting_depth, app_data_dir from ' + 'conversation_summaries').fetchall() finally: c.close() out = {} - for cid, title, modified, ws, parent, depth in rows: + for cid, title, modified, ws, parent, depth, app_dir in rows: path = transcript(cid) if parent or depth or not path: continue @@ -57,7 +79,8 @@ def list(self, cfg): uris = [] out['agy:' + cid] = self.session(cid, path=path, title=title, sig=file_sig(path), cwd=remap(cfg, uri_to_path(uris[0]) if uris else HOME), - updated=iso_ms(modified) or int(os.path.getmtime(path) * 1000)) + updated=iso_ms(modified) or int(os.path.getmtime(path) * 1000), + is_hop=app_dir == MIRROR) return out def read(self, s, cfg): @@ -85,6 +108,73 @@ def read(self, s, cfg): els.append(el(idx, 'tool', fmt_result(HDR_RE.sub('', content).strip() or d.get('error'), cfg), ts)) return els + def write(self, cp, cwd, msgs, title, ctx): + if not os.path.isdir(ROOT): + raise HopError('run agy once so its store exists') + raw = (cp.get('id') if cp else '') or '' + cid = raw.split(':', 1)[-1] or str(uuid.uuid4()) + path = os.path.join(ROOT, 'brain', cid, '.system_generated', 'logs', 'transcript_full.jsonl') + lines = [] + for i, m in enumerate(msgs): + if m['role'] == 'user': + rec = {'step_index': i, 'source': 'USER_EXPLICIT', 'type': 'USER_INPUT', + 'status': 'DONE', 'created_at': ms_iso(m['ts']), + 'content': '\n' + (m['text'] or '') + '\n'} + else: + rec = {'step_index': i, 'source': 'MODEL', 'type': 'PLANNER_RESPONSE', + 'status': 'DONE', 'created_at': ms_iso(m['ts']), 'content': m['text'] or ''} + lines.append(rec) + write_lines(path, lines, mtime_ms=msgs[-1]['ts'] if msgs else None) + self._summary(cid, cwd, msgs, title) + return self.session(cid, path=path, title=title, sig=file_sig(path), cwd=cwd, + updated=msgs[-1]['ts'] if msgs else 0, is_hop=True) + + def _summary(self, cid, cwd, msgs, title): + users = [i for i, m in enumerate(msgs) if m['role'] == 'user'] + last_user = users[-1] if users else -1 + # agy shows title == preview as the picker label (verified against real rows). + label = (title or (msgs[users[0]]['text'] if users else ''))[:200] + c = sqlite3.connect(db(), timeout=15) + try: + with c: + c.execute('insert or replace into conversation_summaries (conversation_id, title, ' + 'preview, step_count, last_modified_time, workspace_uris, source, project_id, ' + 'agent_name, parent_conversation_id, nesting_depth, last_user_input_time, ' + 'last_user_input_step_index, app_data_dir, group_id) ' + 'values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', + (cid, label, label, len(msgs), + stamp(msgs[-1]['ts'] if msgs else 0), + json.dumps(['file://' + urllib.parse.quote(cwd or '', safe='/')]), + MIRROR, '', MIRROR, '', 0, + stamp(msgs[last_user]['ts'] if last_user >= 0 else 0), + last_user, MIRROR, MIRROR)) + finally: + c.close() + + def retitle(self, cp, title): + if not os.path.exists(db()): + return False + c = sqlite3.connect(db(), timeout=15) + try: + with c: + c.execute('update conversation_summaries set title=? where conversation_id=?', + (title or '', (cp.get('id') or '').split(':', 1)[-1])) + finally: + c.close() + return True + + def delete(self, cp): + if not os.path.isdir(ROOT): + return + cid = (cp.get('id') or '').split(':', 1)[-1] + shutil.rmtree(os.path.join(ROOT, 'brain', cid), ignore_errors=True) + c = sqlite3.connect(db(), timeout=15) + try: + with c: + c.execute('delete from conversation_summaries where conversation_id=?', (cid,)) + finally: + c.close() + def resume_cmd(self, sid): return ['agy', '--conversation', sid] diff --git a/tests/test_core.py b/tests/test_core.py index ea7543b..01c3a94 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,7 @@ import json import os +import shutil +import sqlite3 import tempfile import unittest @@ -162,5 +164,69 @@ def test_reads_transcript_steps(self): ('tool', '▸ run_command: npm test'), ('tool', ' ⎿ 1 failing')]) +AGY_DDL = """CREATE TABLE IF NOT EXISTS `conversation_summaries` (`conversation_id` text,`title` text NOT NULL DEFAULT "",`preview` text NOT NULL DEFAULT "",`step_count` integer NOT NULL DEFAULT 0,`last_modified_time` datetime NOT NULL,`workspace_uris` text NOT NULL,`status` text NOT NULL DEFAULT "",`source` text NOT NULL DEFAULT "",`project_id` text NOT NULL DEFAULT "",`agent_name` text NOT NULL DEFAULT "",`parent_conversation_id` text NOT NULL DEFAULT "",`nesting_depth` integer NOT NULL DEFAULT 0,`battle_id` text NOT NULL DEFAULT "",`winning_conversation_id` text NOT NULL DEFAULT "",`not_fully_idle` numeric NOT NULL DEFAULT false,`killed` numeric NOT NULL DEFAULT false,`last_user_input_time` datetime NOT NULL,`last_user_input_step_index` integer NOT NULL DEFAULT -1,`app_data_dir` text NOT NULL DEFAULT "", raw_summary BLOB, group_id TEXT NOT NULL DEFAULT '',PRIMARY KEY (`conversation_id`))""" + + +class AgyWrite(unittest.TestCase): + """A mirrored chat must land in agy's resume picker "Other" tab (not "CLI").""" + + def setUp(self): + import samethread.agents.antigravity as agy_mod + self.mod, self._root = agy_mod, agy_mod.ROOT + self.root = agy_mod.ROOT = tempfile.mkdtemp(prefix='agy-sandbox-') + c = sqlite3.connect(os.path.join(self.root, 'conversation_summaries.db')) + c.executescript(AGY_DDL) + c.commit() + c.close() + + def tearDown(self): + self.mod.ROOT = self._root + shutil.rmtree(self.root, ignore_errors=True) + + def conn(self): + return sqlite3.connect(os.path.join(self.root, 'conversation_summaries.db')) + + @staticmethod + def msgs(): + return [{'role': 'user', 'text': 'hello world', 'ts': 1790000000000}, + {'role': 'assistant', 'text': 'hi there', 'ts': 1790000001000}] + + def test_write_roundtrips_and_lands_in_other_tab(self): + agent = AGENTS['agy'] + res = agent.write(None, '/tmp/work', self.msgs(), 'My Chat', {'cfg': CFG}) + cid = res['id'] + els = agent.read({'path': res['path']}, CFG) # write -> read round-trip + self.assertEqual([(e['role'], e['text']) for e in els], + [('user', 'hello world'), ('assistant', 'hi there')]) + c = self.conn() + row = c.execute('select title, preview, step_count, app_data_dir, project_id, ' + 'parent_conversation_id, nesting_depth, source from conversation_summaries ' + 'where conversation_id=?', (cid,)).fetchone() + c.close() + title, preview, steps, app_dir, proj, parent, depth, src = row + self.assertEqual((title, preview, steps), ('My Chat', 'My Chat', 2)) # title==preview==label + self.assertNotEqual(app_dir, 'antigravity-cli') # not the CLI tab -> "Other" + self.assertEqual((proj, parent, depth), ('', '', 0)) + self.assertEqual(src, 'samethread') + self.assertTrue(agent.list(CFG)['agy:' + cid]['is_hop']) # no re-import loop + + def test_missing_store_raises_hop_error(self): + self.mod.ROOT = os.path.join(self.root, 'never-ran') + with self.assertRaises(core.HopError): + AGENTS['agy'].write(None, '/tmp', self.msgs(), 'X', {'cfg': CFG}) + + def test_delete_removes_transcript_and_row(self): + agent = AGENTS['agy'] + res = agent.write(None, '/tmp', self.msgs(), 'Del', {'cfg': CFG}) + cid = res['id'] + self.assertTrue(os.path.exists(res['path'])) + agent.delete({'id': cid}) + self.assertFalse(os.path.exists(res['path'])) + c = self.conn() + n = c.execute('select count(*) from conversation_summaries where conversation_id=?', (cid,)).fetchone()[0] + c.close() + self.assertEqual(n, 0) + + if __name__ == '__main__': unittest.main() From 8230341681724fe3cdedbbbaf22c9ecf46b99cde Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Thu, 24 Sep 2026 12:38:06 +0700 Subject: [PATCH 4/7] fix agy Other resume routing --- README.md | 8 +- src/samethread/agents/antigravity.py | 125 +++++++-------------------- tests/test_core.py | 85 ++++-------------- 3 files changed, 53 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 78329b9..19a35c1 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ That's it. Keep working the way you do, and use each agent's own resume. `hop ag | **Pi** | ✅ | ✅ entry tree + `session_info` name | extension, every `agent_end` | resume ✔ · hook ✔ | | **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`) | ✅ | ✅ JSONL transcript + `"Other"`-tab summary row (no protobuf trajectory) | `Stop` hook | read ✔ · write ✔ · hook ✔ | +| **Antigravity CLI** (`agy`) | ✅ | ➖ reads native `CLI` + `Other`; `hop resume agy` seeds into `Other` | `Stop` hook | read ✔ · resume ✔ · hook ✔ | | **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. @@ -102,7 +102,7 @@ flowchart LR |---|---| | `hop sync` | Mirror new and changed chats. The hooks run this for you. | | `hop list [-a]` | Chats for this folder (or all folders), with the exact resume command for every copy. | -| `hop resume [n]` | Open chat *n* in any agent: `cc`, `oc`, `codex`, `gemini`, `qwen`, `pi`, `kimi`, `agy`, `mmx`. For Antigravity and MiniMax it seeds a new conversation; `hop sync` also writes mirrors into Antigravity's `"Other"` tab. Turns flow back to the others. | +| `hop resume [n]` | Open chat *n* in any agent: `cc`, `oc`, `codex`, `gemini`, `qwen`, `pi`, `kimi`, `agy`, `mmx`. Antigravity reads native `CLI` + `Other`; `hop resume agy` seeds a new `Other` conversation. MiniMax also seeds a new session. | | `hop agents` | Which agents are installed, and how hop reaches each. | | `hop forget ` | Delete a chat's mirrors and stop mirroring it. | | `hop install` / `hop uninstall` | Add or remove the hooks. `hop install` backs up every config file it touches first. | @@ -126,7 +126,7 @@ Logs go to `~/.hop/hop.log`. ## Honest limits -- **MiniMax can't be written to.** MiniMax Code's store is a migration-managed database, so `hop resume mmx` opens a new session that reads the transcript first. Antigravity *is* written, but as a JSONL transcript + a summary row (readable, lands in the `"Other"` tab) — not agy's protobuf execution trajectory, so a mirror has no agy-native tool state. +- **Two agents can't be written to.** Antigravity and MiniMax are read from their native stores; `hop resume agy` and `hop resume mmx` seed new conversations. Antigravity seeds into the native `Other` store with `--app_data_dir=antigravity`. - **Mirrors are copies, not a live shared session.** If you continue the same chat in two agents at once, you get a fork, not a merge. - **Long chats get trimmed.** They start from the latest compaction summary if there is one, then keep the newest messages that fit in `max_chars`. - **Some agents need a first run.** Gemini CLI mirrors go only into folders Gemini has already opened. Kimi mirrors copy the agent profile from your newest real Kimi session. OpenCode needs the chat's folder to still exist. @@ -149,7 +149,7 @@ casr covers more agents. Reach for it when you need a one-off conversion to some - [ ] Cursor CLI and Crush adapters - [ ] `pipx install samethread` from PyPI -- [ ] Native MiniMax writes, if it ships an import API (Antigravity's protobuf trajectory, so mirrors get real tool state) +- [ ] Native MiniMax and Antigravity mirror writers, if their apps expose an import API - [ ] Optional merge of forks Adding an agent is one file in [`src/samethread/agents/`](src/samethread/agents/): a lister, a reader and, if the agent allows it, a writer and a hook. PRs welcome. diff --git a/src/samethread/agents/antigravity.py b/src/samethread/agents/antigravity.py index 4165df8..7a45828 100644 --- a/src/samethread/agents/antigravity.py +++ b/src/samethread/agents/antigravity.py @@ -1,39 +1,37 @@ -"""Antigravity CLI (agy): foreign chats land in the resume picker's "Other" tab. - -agy owns a protobuf execution trajectory (conversations/.db) that hop does not write; the -readable history is a JSONL transcript under brain//, and the picker index is the -conversation_summaries table. Writing those two (with app_data_dir != 'antigravity-cli') puts a -mirror under the "Other" tab. # ponytail: no protobuf trajectory, so a mirror is readable and -continues as text but has no agy-native tool state; upgrade via agy's importConversationCmd if -bit-identical resume ever matters. -""" -import datetime +"""Antigravity CLI (agy): read native CLI/Other history; seed new Other conversations.""" import json import os import re -import shutil import sqlite3 import urllib.parse -import uuid from .. import hookkit -from ..core import (HOME, HopError, el, file_sig, fmt_call, fmt_result, iso_ms, ms_iso, - read_jsonl, remap, write_lines) +from ..core import HOME, el, file_sig, fmt_call, fmt_result, iso_ms, read_jsonl, remap from .base import Agent ROOT = os.path.join(HOME, '.gemini', 'antigravity-cli') +OTHER_ROOT = os.path.join(HOME, '.gemini', 'antigravity') HOOKS = os.path.join(HOME, '.gemini', 'config', 'hooks.json') HDR_RE = re.compile(r'^(Created At|Completed At):.*\n?', re.M) -MIRROR = 'samethread' # app_data_dir / source marker: a hop-written mirror, lands in "Other" def db(): return os.path.join(ROOT, 'conversation_summaries.db') -def stamp(ms): - """agy's summary timestamps: '2026-03-13 17:12:11.670405+00:00'.""" - return datetime.datetime.fromtimestamp((ms or 0) / 1000, datetime.timezone.utc).isoformat(' ') +def connect(): + path = db() + if not os.path.isfile(path): + return None + c = None + try: + c = sqlite3.connect(f'file:{path}?mode=ro', uri=True, timeout=15) + c.execute('pragma schema_version') + return c + except sqlite3.OperationalError: + if c: + c.close() + return sqlite3.connect(path, timeout=15) def transcript(cid): @@ -45,6 +43,11 @@ def transcript(cid): return None +def other_trajectory(cid): + root = os.path.join(OTHER_ROOT, 'conversations') + return any(os.path.isfile(os.path.join(root, cid + suffix)) for suffix in ('.pb', '.db')) + + def uri_to_path(u): p = urllib.parse.unquote(urllib.parse.urlparse(u).path) if re.match(r'^/[A-Za-z]:', p): @@ -53,23 +56,22 @@ def uri_to_path(u): class Antigravity(Agent): - key, name, aliases, binary, writable = 'agy', 'Agy', ('antigravity',), 'agy', True + key, name, aliases, binary, writable = 'agy', 'Agy', ('antigravity',), 'agy', False def detect(self): return os.path.isdir(ROOT) or super().detect() def list(self, cfg): - if not os.path.exists(db()): + c = connect() + if not c: return {} - c = sqlite3.connect(f'file:{db()}?mode=ro', uri=True, timeout=15) try: rows = c.execute('select conversation_id, title, last_modified_time, workspace_uris, ' - 'parent_conversation_id, nesting_depth, app_data_dir from ' - 'conversation_summaries').fetchall() + 'parent_conversation_id, nesting_depth from conversation_summaries').fetchall() finally: c.close() out = {} - for cid, title, modified, ws, parent, depth, app_dir in rows: + for cid, title, modified, ws, parent, depth in rows: path = transcript(cid) if parent or depth or not path: continue @@ -79,8 +81,7 @@ def list(self, cfg): uris = [] out['agy:' + cid] = self.session(cid, path=path, title=title, sig=file_sig(path), cwd=remap(cfg, uri_to_path(uris[0]) if uris else HOME), - updated=iso_ms(modified) or int(os.path.getmtime(path) * 1000), - is_hop=app_dir == MIRROR) + updated=iso_ms(modified) or int(os.path.getmtime(path) * 1000)) return out def read(self, s, cfg): @@ -108,78 +109,12 @@ def read(self, s, cfg): els.append(el(idx, 'tool', fmt_result(HDR_RE.sub('', content).strip() or d.get('error'), cfg), ts)) return els - def write(self, cp, cwd, msgs, title, ctx): - if not os.path.isdir(ROOT): - raise HopError('run agy once so its store exists') - raw = (cp.get('id') if cp else '') or '' - cid = raw.split(':', 1)[-1] or str(uuid.uuid4()) - path = os.path.join(ROOT, 'brain', cid, '.system_generated', 'logs', 'transcript_full.jsonl') - lines = [] - for i, m in enumerate(msgs): - if m['role'] == 'user': - rec = {'step_index': i, 'source': 'USER_EXPLICIT', 'type': 'USER_INPUT', - 'status': 'DONE', 'created_at': ms_iso(m['ts']), - 'content': '\n' + (m['text'] or '') + '\n'} - else: - rec = {'step_index': i, 'source': 'MODEL', 'type': 'PLANNER_RESPONSE', - 'status': 'DONE', 'created_at': ms_iso(m['ts']), 'content': m['text'] or ''} - lines.append(rec) - write_lines(path, lines, mtime_ms=msgs[-1]['ts'] if msgs else None) - self._summary(cid, cwd, msgs, title) - return self.session(cid, path=path, title=title, sig=file_sig(path), cwd=cwd, - updated=msgs[-1]['ts'] if msgs else 0, is_hop=True) - - def _summary(self, cid, cwd, msgs, title): - users = [i for i, m in enumerate(msgs) if m['role'] == 'user'] - last_user = users[-1] if users else -1 - # agy shows title == preview as the picker label (verified against real rows). - label = (title or (msgs[users[0]]['text'] if users else ''))[:200] - c = sqlite3.connect(db(), timeout=15) - try: - with c: - c.execute('insert or replace into conversation_summaries (conversation_id, title, ' - 'preview, step_count, last_modified_time, workspace_uris, source, project_id, ' - 'agent_name, parent_conversation_id, nesting_depth, last_user_input_time, ' - 'last_user_input_step_index, app_data_dir, group_id) ' - 'values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', - (cid, label, label, len(msgs), - stamp(msgs[-1]['ts'] if msgs else 0), - json.dumps(['file://' + urllib.parse.quote(cwd or '', safe='/')]), - MIRROR, '', MIRROR, '', 0, - stamp(msgs[last_user]['ts'] if last_user >= 0 else 0), - last_user, MIRROR, MIRROR)) - finally: - c.close() - - def retitle(self, cp, title): - if not os.path.exists(db()): - return False - c = sqlite3.connect(db(), timeout=15) - try: - with c: - c.execute('update conversation_summaries set title=? where conversation_id=?', - (title or '', (cp.get('id') or '').split(':', 1)[-1])) - finally: - c.close() - return True - - def delete(self, cp): - if not os.path.isdir(ROOT): - return - cid = (cp.get('id') or '').split(':', 1)[-1] - shutil.rmtree(os.path.join(ROOT, 'brain', cid), ignore_errors=True) - c = sqlite3.connect(db(), timeout=15) - try: - with c: - c.execute('delete from conversation_summaries where conversation_id=?', (cid,)) - finally: - c.close() - def resume_cmd(self, sid): - return ['agy', '--conversation', sid] + root = '--app_data_dir=antigravity' if other_trajectory(sid) else None + return ['agy', *([root] if root else []), '--conversation', sid] def seed_cmd(self, prompt): - return ['agy', '-i', prompt] + return ['agy', '--app_data_dir=antigravity', '-i', prompt] def install(self, dry): hooks = hookkit.load(HOOKS) diff --git a/tests/test_core.py b/tests/test_core.py index 01c3a94..1a65a24 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,7 +1,5 @@ import json import os -import shutil -import sqlite3 import tempfile import unittest @@ -145,6 +143,25 @@ def test_claude_detects_a_continued_mirror(self): class Antigravity(unittest.TestCase): + def test_resume_other_uses_ide_store(self): + import samethread.agents.antigravity as agy_mod + old = agy_mod.OTHER_ROOT + root = tempfile.mkdtemp(prefix='agy-other-') + try: + agy_mod.OTHER_ROOT = root + os.makedirs(os.path.join(root, 'conversations')) + open(os.path.join(root, 'conversations', 'other-id.pb'), 'wb').close() + self.assertEqual(AGENTS['agy'].resume_cmd('other-id'), + ['agy', '--app_data_dir=antigravity', '--conversation', 'other-id']) + finally: + agy_mod.OTHER_ROOT = old + import shutil + shutil.rmtree(root, ignore_errors=True) + + def test_seed_targets_other_store(self): + self.assertEqual(AGENTS['agy'].seed_cmd('continue'), + ['agy', '--app_data_dir=antigravity', '-i', 'continue']) + def test_reads_transcript_steps(self): path = os.path.join(TMP, 'transcript_full.jsonl') steps = [ @@ -164,69 +181,5 @@ def test_reads_transcript_steps(self): ('tool', '▸ run_command: npm test'), ('tool', ' ⎿ 1 failing')]) -AGY_DDL = """CREATE TABLE IF NOT EXISTS `conversation_summaries` (`conversation_id` text,`title` text NOT NULL DEFAULT "",`preview` text NOT NULL DEFAULT "",`step_count` integer NOT NULL DEFAULT 0,`last_modified_time` datetime NOT NULL,`workspace_uris` text NOT NULL,`status` text NOT NULL DEFAULT "",`source` text NOT NULL DEFAULT "",`project_id` text NOT NULL DEFAULT "",`agent_name` text NOT NULL DEFAULT "",`parent_conversation_id` text NOT NULL DEFAULT "",`nesting_depth` integer NOT NULL DEFAULT 0,`battle_id` text NOT NULL DEFAULT "",`winning_conversation_id` text NOT NULL DEFAULT "",`not_fully_idle` numeric NOT NULL DEFAULT false,`killed` numeric NOT NULL DEFAULT false,`last_user_input_time` datetime NOT NULL,`last_user_input_step_index` integer NOT NULL DEFAULT -1,`app_data_dir` text NOT NULL DEFAULT "", raw_summary BLOB, group_id TEXT NOT NULL DEFAULT '',PRIMARY KEY (`conversation_id`))""" - - -class AgyWrite(unittest.TestCase): - """A mirrored chat must land in agy's resume picker "Other" tab (not "CLI").""" - - def setUp(self): - import samethread.agents.antigravity as agy_mod - self.mod, self._root = agy_mod, agy_mod.ROOT - self.root = agy_mod.ROOT = tempfile.mkdtemp(prefix='agy-sandbox-') - c = sqlite3.connect(os.path.join(self.root, 'conversation_summaries.db')) - c.executescript(AGY_DDL) - c.commit() - c.close() - - def tearDown(self): - self.mod.ROOT = self._root - shutil.rmtree(self.root, ignore_errors=True) - - def conn(self): - return sqlite3.connect(os.path.join(self.root, 'conversation_summaries.db')) - - @staticmethod - def msgs(): - return [{'role': 'user', 'text': 'hello world', 'ts': 1790000000000}, - {'role': 'assistant', 'text': 'hi there', 'ts': 1790000001000}] - - def test_write_roundtrips_and_lands_in_other_tab(self): - agent = AGENTS['agy'] - res = agent.write(None, '/tmp/work', self.msgs(), 'My Chat', {'cfg': CFG}) - cid = res['id'] - els = agent.read({'path': res['path']}, CFG) # write -> read round-trip - self.assertEqual([(e['role'], e['text']) for e in els], - [('user', 'hello world'), ('assistant', 'hi there')]) - c = self.conn() - row = c.execute('select title, preview, step_count, app_data_dir, project_id, ' - 'parent_conversation_id, nesting_depth, source from conversation_summaries ' - 'where conversation_id=?', (cid,)).fetchone() - c.close() - title, preview, steps, app_dir, proj, parent, depth, src = row - self.assertEqual((title, preview, steps), ('My Chat', 'My Chat', 2)) # title==preview==label - self.assertNotEqual(app_dir, 'antigravity-cli') # not the CLI tab -> "Other" - self.assertEqual((proj, parent, depth), ('', '', 0)) - self.assertEqual(src, 'samethread') - self.assertTrue(agent.list(CFG)['agy:' + cid]['is_hop']) # no re-import loop - - def test_missing_store_raises_hop_error(self): - self.mod.ROOT = os.path.join(self.root, 'never-ran') - with self.assertRaises(core.HopError): - AGENTS['agy'].write(None, '/tmp', self.msgs(), 'X', {'cfg': CFG}) - - def test_delete_removes_transcript_and_row(self): - agent = AGENTS['agy'] - res = agent.write(None, '/tmp', self.msgs(), 'Del', {'cfg': CFG}) - cid = res['id'] - self.assertTrue(os.path.exists(res['path'])) - agent.delete({'id': cid}) - self.assertFalse(os.path.exists(res['path'])) - c = self.conn() - n = c.execute('select count(*) from conversation_summaries where conversation_id=?', (cid,)).fetchone()[0] - c.close() - self.assertEqual(n, 0) - - if __name__ == '__main__': unittest.main() From 1bf8dce4bb20313bb99324c0dd60d063f641302d Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Fri, 25 Sep 2026 12:19:18 +0700 Subject: [PATCH 5/7] fix: ask which chat to resume for agy --- src/samethread/cli.py | 73 +++++++++++++++++++++++++++++++++++++------ tests/test_core.py | 43 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/samethread/cli.py b/src/samethread/cli.py index 537a7e3..680a4e5 100644 --- a/src/samethread/cli.py +++ b/src/samethread/cli.py @@ -71,9 +71,8 @@ def cmd_list(args): print(f' {NAMES.get(tool, tool):<9} {state:<8}{behind} {shown(AGENTS[tool].resume_cmd(sid))}') -def seed(agent, th): - """Continue a chat in an agent hop can't write into: a new conversation reads the transcript first.""" - cfg = load_config() +def handoff(agent, th, cfg): + """Write a source transcript and return the prompt used to seed an agent.""" path = os.path.join(HANDOFF_DIR, th['tid'] + '.md') body = [f'# {thread_title(th)}\n'] for m in render(th, cfg): @@ -84,10 +83,62 @@ def seed(agent, th): pending = read_json(os.path.join(HANDOFF_DIR, 'pending.json'), {}) pending[th['tid']] = now_ms() - 60000 write_json(os.path.join(HANDOFF_DIR, 'pending.json'), pending) - prompt = (f'[hop-handoff:{th["tid"]}] We are continuing the conversation "{th["base_title"]}" that started in ' - f'{tag(th)}. Read the whole transcript at {path} with your file tool (all of it, page through if long). ' - f'Then reply with one short line saying you are ready, and wait for me.') - launch(agent.seed_cmd(prompt), th['cwd']) + return (f'[hop-handoff:{th["tid"]}] We are continuing the conversation "{th["base_title"]}" that started in ' + f'{tag(th)}. Read the whole transcript at {path} with your file tool (all of it, page through if long). ' + 'Then reply with one short line saying you are ready, and wait for me.') + + +def run(cmd, cwd): + exe = shutil.which(cmd[0]) or cmd[0] + return subprocess.call([exe] + cmd[1:], cwd=cwd if cwd and os.path.isdir(cwd) else None) + + +def seed(agent, th, cfg=None): + """Continue a chat in an agent hop can't write into: a new conversation reads the transcript first.""" + return run(agent.seed_cmd(handoff(agent, th, cfg or load_config())), th['cwd']) + + +def cmd_sync_agy(args): + """Seed every known thread into agy's native Other store.""" + cfg = load_config() + threads = all_threads() + if args.dry_run: + print(f'{len(threads)} threads would be seeded into agy Other') + return + for i, th in enumerate(threads, 1): + if any(k.startswith('agy:') for k in th.get('copies', {})): + continue + print(f'[{i}/{len(threads)}] {thread_title(th)}') + if seed(AGENTS['agy'], th, cfg): + return + + +def choose(threads, query): + if query is not None: + return pick(threads, query) + if not threads: + return None + if not sys.stdin.isatty(): + sys.exit('Choose a chat explicitly when hop is not interactive: hop agy ') + + print('Choose a chat to continue:') + for i, th in enumerate(threads, 1): + print(f'{i:>3}. {thread_title(th)} · {ago(th["updated"])} · {th["cwd"]}') + while True: + try: + query = input('Number or title (q to cancel): ').strip() + except (EOFError, KeyboardInterrupt): + print() + return None + if query.lower() in {'q', 'quit', 'exit'}: + return None + if not query: + print('Enter a number from the list or part of its title.') + continue + th = pick(threads, query) + if th: + return th + print('No matching chat. Try a number from the list or part of its title.') def cmd_resume(args): @@ -96,7 +147,7 @@ def cmd_resume(args): except KeyError: sys.exit(f'Unknown agent "{args.agent}". Supported: {", ".join(AGENTS)}') agent = AGENTS[key] - th = pick(scoped(args), args.query) + th = choose(scoped(args), args.query) if not th: sys.exit('No matching chat. See: hop list') copies = [k for k in th['copies'] if k.startswith(key + ':')] @@ -231,11 +282,15 @@ def main(): if name == 'agy': p.set_defaults(agent='agy') if name != 'list': - p.add_argument('query', nargs='?', help='number from `hop list`, or part of the title') + p.add_argument('query', nargs='?', help='number from `hop list`, or part of the title; omit to choose interactively') p.add_argument('-a', '--all', action='store_true', help='all folders, not just the current one') p.add_argument('-n', '--limit', type=int, default=30) p.set_defaults(fn=fn) + p = sub.add_parser('sync-agy', help='seed every known thread into agy Other') + p.add_argument('--dry-run', action='store_true') + p.set_defaults(fn=cmd_sync_agy) + p = sub.add_parser('hook', help='called by agent hooks; starts a background sync') p.add_argument('--json', action='store_true', help='print {} for hook protocols that need JSON (agy)') p.add_argument('payload', nargs='*', help=argparse.SUPPRESS) # Codex `notify` appends its event JSON diff --git a/tests/test_core.py b/tests/test_core.py index 80498be..1b43c48 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -165,6 +165,49 @@ def test_seed_targets_other_store(self): self.assertEqual(AGENTS['agy'].seed_cmd('continue'), ['agy', '--app_data_dir=antigravity', '-i', 'continue']) + def test_sync_agy_dry_run(self): + from samethread.cli import cmd_sync_agy + args = type('Args', (), {'dry_run': True})() + cmd_sync_agy(args) + + def test_resume_without_query_asks_for_a_chat(self): + import io + from unittest.mock import patch + from samethread import cli + + first = thread([e('1', 'user', 'first')]) + first['updated'] = 200 + second = thread([e('2', 'user', 'second')]) + second['updated'] = 100 + second['base_title'] = 'Second chat' + + class TTYInput(io.StringIO): + def isatty(self): + return True + + args = type('Args', (), {'agent': 'agy', 'query': None, 'all': False})() + with patch.object(cli, 'scoped', return_value=[first, second]), \ + patch.object(cli, 'seed') as seed, \ + patch('sys.stdin', TTYInput('2\n')), \ + self.assertRaises(SystemExit) as exit: + cli.cmd_resume(args) + + self.assertIn('Second chat', str(exit.exception)) + seed.assert_called_once() + self.assertEqual(seed.call_args.args[1]['base_title'], 'Second chat') + + def test_resume_without_query_does_not_guess_when_not_interactive(self): + import io + from unittest.mock import patch + from samethread import cli + + args = type('Args', (), {'agent': 'agy', 'query': None, 'all': False})() + with patch.object(cli, 'scoped', return_value=[thread([e('1', 'user', 'only')])]), \ + patch.object(cli.sys, 'stdin', io.StringIO()), \ + self.assertRaises(SystemExit) as exit: + cli.cmd_resume(args) + self.assertIn('Choose a chat explicitly', str(exit.exception)) + def test_reads_transcript_steps(self): path = os.path.join(TMP, 'transcript_full.jsonl') steps = [ From fb586e725480fbb7f2374781ae504d046ee16337 Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Fri, 25 Sep 2026 14:04:41 +0700 Subject: [PATCH 6/7] fix: harden mirror retry and agy batch sync --- src/samethread/agents/hermes.py | 11 +++++++--- src/samethread/cli.py | 4 ++-- tests/test_core.py | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/samethread/agents/hermes.py b/src/samethread/agents/hermes.py index e41bdf4..288cc11 100644 --- a/src/samethread/agents/hermes.py +++ b/src/samethread/agents/hermes.py @@ -122,7 +122,10 @@ def write(self, cp, cwd, msgs, title, ctx): self._session_row(c, cp, sid, cwd, title, first, last, len(msgs)) except sqlite3.IntegrityError: # 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)) + try: + self._session_row(c, cp, sid, cwd, f'{title} · {sid[-6:]}', first, last, len(msgs)) + except sqlite3.IntegrityError as e: + raise HopError('Hermes mirror session disappeared or could not be retitled') from e 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,))] @@ -132,8 +135,10 @@ def write(self, cp, cwd, msgs, title, ctx): def _session_row(self, c, cp, sid, cwd, title, first, last, n): if cp: - c.execute('update sessions set title=?, started_at=?, last_activity_at=?, message_count=? where id=?', - (title, first, last, n, sid)) + updated = c.execute('update sessions set title=?, started_at=?, last_activity_at=?, message_count=? where id=?', + (title, first, last, n, sid)) + if updated.rowcount != 1: + raise HopError('Hermes mirror session disappeared') 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. diff --git a/src/samethread/cli.py b/src/samethread/cli.py index 680a4e5..9e7b024 100644 --- a/src/samethread/cli.py +++ b/src/samethread/cli.py @@ -16,7 +16,7 @@ from . import __version__ from .agents import AGENTS, NAMES, resolve, targets -from .core import (HANDOFF_DIR, INDEX_PATH, LOCK_PATH, THREAD_DIR, ago, load_config, now_ms, read_json, same_path, +from .core import (HANDOFF_DIR, INDEX_PATH, LOCK_PATH, THREAD_DIR, ago, load_config, log, now_ms, read_json, same_path, settings, write_json) from .sync import detach, locked_sync, render, tag, thread_title, wait_lock @@ -110,7 +110,7 @@ def cmd_sync_agy(args): continue print(f'[{i}/{len(threads)}] {thread_title(th)}') if seed(AGENTS['agy'], th, cfg): - return + log(f'! could not seed {thread_title(th)} into agy; continuing') def choose(threads, query): diff --git a/tests/test_core.py b/tests/test_core.py index 1b43c48..ff7982e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -170,6 +170,18 @@ def test_sync_agy_dry_run(self): args = type('Args', (), {'dry_run': True})() cmd_sync_agy(args) + def test_sync_agy_continues_after_a_failed_seed(self): + from unittest.mock import patch + from samethread import cli + + first = thread([e('1', 'user', 'first')]) + second = thread([e('2', 'user', 'second')]) + args = type('Args', (), {'dry_run': False})() + with patch.object(cli, 'all_threads', return_value=[first, second]), \ + patch.object(cli, 'seed', side_effect=[1, 0]) as seed: + cli.cmd_sync_agy(args) + self.assertEqual(seed.call_count, 2) + def test_resume_without_query_asks_for_a_chat(self): import io from unittest.mock import patch @@ -288,6 +300,33 @@ def test_mirror_roundtrips_and_collides_on_title(self): # ('hop-import') is not a provider model, which makes the mirror unresumable. self.assertEqual(prof, ('default', None, 'cli')) + def test_update_rewrites_the_same_mirror(self): + agent = AGENTS['hermes'] + first = thread([e('1', 'user', 'old')]) + res = agent.write(None, os.getcwd(), sync.render(first, CFG), 'Mirror', {'cfg': CFG}) + cp = agent.list(CFG)['hermes:' + res['id']] + second = thread([e('2', 'user', 'new')]) + updated = agent.write(cp, os.getcwd(), sync.render(second, CFG), 'Mirror', {'cfg': CFG}) + self.assertEqual(updated['id'], res['id']) + text = [e['text'] for e in sync.load(agent.list(CFG)['hermes:' + res['id']], CFG)] + self.assertEqual(len(text), 1) + self.assertIn('new', text[0]) + self.assertNotIn('old', text[0]) + + def test_vanished_mirror_raises_hop_error_without_retryable_raw_error(self): + agent = AGENTS['hermes'] + res = agent.write(None, os.getcwd(), sync.render(thread([e('1', 'user', 'old')]), CFG), + 'Mirror', {'cfg': CFG}) + c = self.conn() + c.execute('delete from messages where session_id=?', (res['id'],)) + c.execute('delete from sessions where id=?', (res['id'],)) + c.commit() + c.close() + cp = {'id': res['id']} + with self.assertRaises(core.HopError): + agent.write(cp, os.getcwd(), sync.render(thread([e('2', 'user', 'new')]), CFG), + 'Mirror', {'cfg': CFG}) + 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')") From a35f27cfb5452da0d7aa9e0ec0ccea9e6519f705 Mon Sep 17 00:00:00 2001 From: KennyVWS Date: Fri, 25 Sep 2026 14:14:23 +0700 Subject: [PATCH 7/7] feat: add hop refresh alias for manual sync --- README.md | 3 ++- src/samethread/cli.py | 2 +- tests/test_core.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e6d7d0..12faebd 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ None of them can see the others. So you re-explain the task, paste logs again, a pipx install git+https://github.com/GodrezJr2/samethread # or: pip install git+https://github.com/GodrezJr2/samethread hop install # hooks for every agent it finds hop sync # mirror existing chats once +hop refresh # same command, clearer when catching up manually ``` That's it. Keep working the way you do, and use each agent's own resume. `hop agents` shows what was found; `hop list` shows every copy of a chat with the command to open it. @@ -102,7 +103,7 @@ flowchart LR | Command | What it does | |---|---| -| `hop sync` | Mirror new and changed chats. The hooks run this for you. | +| `hop sync` / `hop refresh` | Mirror new and changed chats. The hooks run this for you. | | `hop list [-a]` | Chats for this folder (or all folders), with the exact resume command for every copy. | | `hop resume [n]` | Open chat *n* in any agent: `cc`, `oc`, `codex`, `gemini`, `qwen`, `pi`, `kimi`, `agy`, `mmx`. Antigravity reads native `CLI` + `Other`; `hop resume agy` seeds a new `Other` conversation. MiniMax also seeds a new session. | | `hop agents` | Which agents are installed, and how hop reaches each. | diff --git a/src/samethread/cli.py b/src/samethread/cli.py index 9e7b024..0bea66d 100644 --- a/src/samethread/cli.py +++ b/src/samethread/cli.py @@ -265,7 +265,7 @@ def main(): p = sub.add_parser('agents', help='supported agents, which are installed, and how hop reaches them') p.set_defaults(fn=cmd_agents) - p = sub.add_parser('sync', help='mirror new and changed chats into every agent') + p = sub.add_parser('sync', aliases=['refresh'], help='mirror new and changed chats into every agent') p.add_argument('--days', type=int, help='only adopt chats active in the last N days (default: config)') p.add_argument('--dry-run', action='store_true') p.add_argument('--detach', action='store_true', help='run in the background and return immediately') diff --git a/tests/test_core.py b/tests/test_core.py index ff7982e..ba0071a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -42,6 +42,16 @@ def test_fmt_call_prefers_the_meaningful_argument(self): self.assertEqual(core.fmt_call('Bash', {'command': 'ls -la', 'timeout': 5}, CFG), '▸ Bash: ls -la') self.assertEqual(core.fmt_call('shell', {'command': ['git', 'status']}, CFG), '▸ shell: git status') + def test_refresh_alias_runs_sync(self): + from unittest.mock import patch + from samethread import cli + + with patch.object(cli.sys, 'argv', ['hop', 'refresh']), \ + patch.object(cli.os, 'makedirs'), \ + patch.object(cli, 'cmd_sync') as sync: + cli.main() + sync.assert_called_once() + def test_agent_names_resolve(self): self.assertEqual(resolve('claude'), 'cc') self.assertEqual(resolve('MiniMax'), 'mmx')