diff --git a/README.md b/README.md index 0e9796a..12faebd 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` | @@ -59,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. @@ -74,10 +76,11 @@ 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`) | ✅ | ➖ reads native `CLI` + `Other`; `hop resume agy` seeds into `Other` | `Stop` hook | read ✔ · resume ✔ · 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. +"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 @@ -100,9 +103,9 @@ 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`. 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`. 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 +129,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. +- **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. @@ -141,7 +144,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. @@ -149,7 +152,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 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/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/__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/antigravity.py b/src/samethread/agents/antigravity.py index b4a978a..7a45828 100644 --- a/src/samethread/agents/antigravity.py +++ b/src/samethread/agents/antigravity.py @@ -1,4 +1,4 @@ -"""Antigravity CLI (agy): read-only. Conversations are runtime-owned protobuf; hop reads the transcript log.""" +"""Antigravity CLI (agy): read native CLI/Other history; seed new Other conversations.""" import json import os import re @@ -10,10 +10,30 @@ 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) +def db(): + return os.path.join(ROOT, 'conversation_summaries.db') + + +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): base = os.path.join(ROOT, 'brain', cid, '.system_generated', 'logs') for name in ('transcript_full.jsonl', 'transcript.jsonl'): @@ -23,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): @@ -37,10 +62,9 @@ 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): + 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 from conversation_summaries').fetchall() @@ -86,10 +110,11 @@ def read(self, s, cfg): return els 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/src/samethread/agents/hermes.py b/src/samethread/agents/hermes.py new file mode 100644 index 0000000..288cc11 --- /dev/null +++ b/src/samethread/agents/hermes.py @@ -0,0 +1,194 @@ +"""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, HopError, 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 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) + 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): + """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 + 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: + 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']), sig=f'{upd}:{n}:{mx}', updated=upd) + 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): + 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() + 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 + 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, first, last, len(msgs)) + except sqlite3.IntegrityError: + # Hermes requires unique titles: a colliding mirror gets a short suffix, never a blank title. + 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,))] + return {'id': sid, 'seen': seen, **stat(c, sid)} + finally: + c.close() + + def _session_row(self, c, cp, sid, cwd, title, first, last, n): + if cp: + 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. + 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(), 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'])) + 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..0bea66d 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 @@ -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): + log(f'! could not seed {thread_title(th)} into agy; continuing') + + +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 + ':')] @@ -214,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') @@ -227,15 +278,19 @@ 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': - 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 ea7543b..ba0071a 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 @@ -40,10 +42,21 @@ 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') - self.assertEqual(len(NAMES), 9) + self.assertEqual(resolve('hermes'), 'hermes') + self.assertEqual(len(NAMES), 10) class Render(unittest.TestCase): @@ -143,6 +156,80 @@ 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_sync_agy_dry_run(self): + from samethread.cli import cmd_sync_agy + 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 + 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 = [ @@ -162,5 +249,170 @@ def test_reads_transcript_steps(self): ('tool', '▸ run_command: npm test'), ('tool', ' ⎿ 1 failing')]) +HERMES_HOME_DIR = 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(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(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')]) + 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_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')") + 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("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(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): + """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()