Skip to content
Closed
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
8 changes: 5 additions & 3 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 @@ -75,9 +76,10 @@ 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.
"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 Down Expand Up @@ -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.

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
189 changes: 189 additions & 0 deletions src/samethread/agents/hermes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""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.
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,))]
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:
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(), 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)]
4 changes: 2 additions & 2 deletions src/samethread/agents/kimi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/samethread/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Loading