Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<p align="center">
<a href="#quick-start"><img src="https://img.shields.io/badge/setup-3_commands-5EEAD4?style=for-the-badge" alt="Setup: 3 commands"></a>
<a href="#supported-agents"><img src="https://img.shields.io/badge/agents-9-8AA4FF?style=for-the-badge" alt="9 agents"></a>
<a href="#supported-agents"><img src="https://img.shields.io/badge/agents-10-8AA4FF?style=for-the-badge" alt="10 agents"></a>
<a href="#how-it-works"><img src="https://img.shields.io/badge/resume-native,_not_a_summary-E8845C?style=for-the-badge" alt="Native resume"></a>
</p>

Expand All @@ -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` |
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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 <agent> [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 <agent> [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 <n\|title>` | 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. |
Expand All @@ -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.
Expand All @@ -141,15 +144,15 @@ 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.

## Roadmap

- [ ] 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.
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/samethread/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}


Expand Down
37 changes: 31 additions & 6 deletions src/samethread/agents/antigravity.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'):
Expand All @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
194 changes: 194 additions & 0 deletions src/samethread/agents/hermes.py
Original file line number Diff line number Diff line change
@@ -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():
"""<root>/state.db -> 'default'; <root>/profiles/<name>/state.db -> '<name>'."""
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)]
Loading