@theharshitsingh/hc 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/bin/hc.js +31 -0
- package/hc.py +7 -0
- package/hconv/__init__.py +13 -0
- package/hconv/adapter.py +82 -0
- package/hconv/adapters/__init__.py +2 -0
- package/hconv/adapters/claude.py +164 -0
- package/hconv/adapters/codex.py +204 -0
- package/hconv/adapters/opencode.py +258 -0
- package/hconv/cli.py +83 -0
- package/hconv/common.py +90 -0
- package/hconv/enrich.py +81 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harshit Singh Bhandari
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# harness-convert (`hc`)
|
|
2
|
+
|
|
3
|
+
Relocate a coding-agent session across harnesses and resume it natively.
|
|
4
|
+
|
|
5
|
+
**The escape hatch:** you're 80% through a fix, your harness hits a rate limit /
|
|
6
|
+
outage mid-task, and you can't even ask it for a handoff. `hc` reads the session
|
|
7
|
+
transcript off disk (the dead harness doesn't need to be running or your quota
|
|
8
|
+
intact), rewrites it into the target harness's format, and you keep going there.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
hc --from claude --to codex # move the latest Claude session here -> Codex
|
|
12
|
+
hc --from codex --to claude <session-id> # a specific session
|
|
13
|
+
hc --from claude --to codex --dest-cwd DIR # land it in a different folder
|
|
14
|
+
hc list --from codex # what's the latest convertible session here
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
By default it's a dry run; pass `--write` to create the file, then it prints the
|
|
18
|
+
exact `codex resume` / `claude --resume` command.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
A session is **(a)** a model-context stream, **(b)** a UI-render stream, and
|
|
23
|
+
**(c)** identity metadata. Conversion maps all three.
|
|
24
|
+
|
|
25
|
+
- **Common interface** (`hconv/common.py`): every harness maps to four records:
|
|
26
|
+
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`. This universal floor
|
|
27
|
+
guarantees any pair converts and resumes. Private reasoning is dropped (each
|
|
28
|
+
harness encrypts/owns its own; unrecoverable).
|
|
29
|
+
- **N² enrichment** (`hconv/enrich.py`): surplus the floor can't hold (session
|
|
30
|
+
titles, ...) rides a sparse `(source, dest)` map, layered on top. A pair with no
|
|
31
|
+
entry is simply common-only. The map never re-encodes the common records.
|
|
32
|
+
- **Adapters** (`hconv/adapters/`): one per harness, `locate / read / dest_path /
|
|
33
|
+
write`. Codex's writer emits BOTH streams (`response_item` for the model,
|
|
34
|
+
`event_msg` for scrollback incl. `exec_command_end` / `patch_apply_end` tool
|
|
35
|
+
cards); Claude's single row set serves both. OpenCode is SQLite, not JSONL: it
|
|
36
|
+
reads the `session`/`message`/`part` tables read-only, and writes the canonical
|
|
37
|
+
`{info, messages}` file that `opencode import` validates and ingests (safer than
|
|
38
|
+
poking a live WAL DB), so `opencode -s <id>` resumes it.
|
|
39
|
+
- **Ragged-tail close** (`synthesize_missing_results`): the source usually died
|
|
40
|
+
mid-tool-call, so every orphan `ToolCall` gets a synthetic result, else the
|
|
41
|
+
resumed API call rejects the history.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pipx install harness-convert # PyPI
|
|
47
|
+
npm i -g @theharshitsingh/hc # npm (needs python3 on PATH)
|
|
48
|
+
brew install harshitsinghbhandari/tap/harness-convert # Homebrew
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Stdlib only, no dependencies. From a checkout, `pipx install .` or plain
|
|
52
|
+
`python3 hc.py ...` also work.
|
|
53
|
+
|
|
54
|
+
## Supported
|
|
55
|
+
|
|
56
|
+
Codex (`~/.codex`), Claude Code (`~/.claude`), and OpenCode
|
|
57
|
+
(`~/.local/share/opencode`): any direction between them. Converting *into*
|
|
58
|
+
OpenCode writes an import file; resume with `opencode import <file> && opencode -s
|
|
59
|
+
<id>` (the command `hc` prints). Within a harness, sessions are also freely
|
|
60
|
+
relocatable across working directories (pure metadata rewrite, lossless).
|
|
61
|
+
|
|
62
|
+
## Test
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python3 tests/test_hconv.py
|
|
66
|
+
```
|
package/bin/hc.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require("child_process");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(__dirname, "..");
|
|
8
|
+
const existing = process.env.PYTHONPATH;
|
|
9
|
+
const env = {
|
|
10
|
+
...process.env,
|
|
11
|
+
PYTHONPATH: existing ? `${root}${path.delimiter}${existing}` : root,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function run(python) {
|
|
15
|
+
return spawnSync(python, ["-m", "hconv.cli", ...process.argv.slice(2)], {
|
|
16
|
+
stdio: "inherit",
|
|
17
|
+
env,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let result = run("python3");
|
|
22
|
+
if (result.error && result.error.code === "ENOENT") {
|
|
23
|
+
result = run("python");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (result.error) {
|
|
27
|
+
console.error(`hc: failed to launch Python: ${result.error.message}`);
|
|
28
|
+
process.exit(127);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
process.exit(result.status ?? 1);
|
package/hc.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""harness-convert: relocate a coding-agent session across harnesses.
|
|
2
|
+
|
|
3
|
+
Common interface (common.py) every harness maps to + an N^2 enrichment map
|
|
4
|
+
(enrich.py) for the surplus. Adapters (adapters/) read/write each harness.
|
|
5
|
+
"""
|
|
6
|
+
from .common import (AssistantMessage, Session, ToolCall, ToolResult,
|
|
7
|
+
UserMessage, synthesize_missing_results)
|
|
8
|
+
from .adapter import Adapter, convert, get, known, register
|
|
9
|
+
from . import adapters # noqa: F401 (registers claude + codex)
|
|
10
|
+
|
|
11
|
+
__all__ = ["Session", "UserMessage", "AssistantMessage", "ToolCall",
|
|
12
|
+
"ToolResult", "synthesize_missing_results", "Adapter",
|
|
13
|
+
"convert", "get", "known", "register"]
|
package/hconv/adapter.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Adapter contract + registry. One adapter per harness.
|
|
2
|
+
|
|
3
|
+
The whole conversion pipeline is:
|
|
4
|
+
|
|
5
|
+
src.locate(cwd, id?) -> path
|
|
6
|
+
src.read(path) -> Session (transcript -> common interface)
|
|
7
|
+
normalize(session) -> Session (close ragged tails, shared)
|
|
8
|
+
enrich(src, dst, s) -> Session (N^2 surplus, optional)
|
|
9
|
+
dst.write(session) -> path (common interface -> transcript)
|
|
10
|
+
|
|
11
|
+
The common path (locate/read/write over the four records) guarantees any pair
|
|
12
|
+
works. Enrichment only adds surplus and is allowed to be missing.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .common import Session, synthesize_missing_results
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Adapter(ABC):
|
|
23
|
+
"""A harness's reader/writer/locator. Subclasses set `name` and register()."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def locate(self, cwd: str, session_id: str | None = None) -> Path:
|
|
29
|
+
"""Resolve a transcript file. With session_id: that session. Without:
|
|
30
|
+
the most recent session for `cwd` in this harness's store (the fast-path
|
|
31
|
+
default that makes `hc --from X --to Y` need no id)."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def read(self, path: Path) -> Session:
|
|
35
|
+
"""Parse a transcript file into the common Session. Drops private
|
|
36
|
+
reasoning; stashes harness surplus into Session.extra for enrichment."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def dest_path(self, session: Session, dest_cwd: str) -> Path:
|
|
40
|
+
"""Where a converted Session WOULD be written under dest_cwd. Pure; no IO.
|
|
41
|
+
Used for dry-run and so write() and the dry-run path never disagree."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def write(self, session: Session, dest_cwd: str) -> Path:
|
|
45
|
+
"""Materialize a Session as this harness's transcript at dest_path(),
|
|
46
|
+
rewriting identity (id/cwd) so the result is self-consistent and natively
|
|
47
|
+
resumable. Returns the written path."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_REGISTRY: dict[str, Adapter] = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register(adapter: Adapter) -> Adapter:
|
|
54
|
+
_REGISTRY[adapter.name] = adapter
|
|
55
|
+
return adapter
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get(name: str) -> Adapter:
|
|
59
|
+
try:
|
|
60
|
+
return _REGISTRY[name]
|
|
61
|
+
except KeyError:
|
|
62
|
+
known = ", ".join(sorted(_REGISTRY)) or "(none registered)"
|
|
63
|
+
raise SystemExit(f"unknown harness '{name}'. known: {known}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def known() -> list[str]:
|
|
67
|
+
return sorted(_REGISTRY)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def convert(src_name: str, dst_name: str, cwd: str, dest_cwd: str,
|
|
71
|
+
session_id: str | None = None, write: bool = False):
|
|
72
|
+
"""Run the full pipeline. Returns (session, dest_path). Writes only if asked."""
|
|
73
|
+
from .enrich import enrich
|
|
74
|
+
|
|
75
|
+
src, dst = get(src_name), get(dst_name)
|
|
76
|
+
path = src.locate(cwd, session_id)
|
|
77
|
+
session = src.read(path)
|
|
78
|
+
session.records = synthesize_missing_results(session.records)
|
|
79
|
+
enrich(src_name, dst_name, session)
|
|
80
|
+
if not write:
|
|
81
|
+
return session, dst.dest_path(session, dest_cwd)
|
|
82
|
+
return session, dst.write(session, dest_cwd)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Transcript: ~/.claude/projects/<enc(cwd)>/<sessionId>.jsonl, a parentUuid tree.
|
|
4
|
+
One row set both renders and feeds the model (no dual stream). Resume keys off
|
|
5
|
+
(launch cwd -> project dir) + (filename stem == sessionId) + the tree.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..adapter import Adapter, register
|
|
17
|
+
from ..common import (AssistantMessage, Session, ToolCall, ToolResult,
|
|
18
|
+
UserMessage)
|
|
19
|
+
|
|
20
|
+
PROJECTS = Path(os.path.expanduser("~/.claude/projects"))
|
|
21
|
+
VERSION = "2.1.153"
|
|
22
|
+
|
|
23
|
+
# Codex tool vocabulary -> Claude's, so converted calls render as native cards.
|
|
24
|
+
INBOUND_NAMES = {"exec_command": "Bash", "shell": "Bash",
|
|
25
|
+
"apply_patch": "Edit", "read_file": "Read", "view_image": "Read"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def enc(cwd: str) -> str:
|
|
29
|
+
"""Claude's project-dir encoding: every non-alphanumeric char -> '-'."""
|
|
30
|
+
return re.sub(r"[^A-Za-z0-9]", "-", cwd)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _toolu(call_id: str) -> str:
|
|
34
|
+
return "toolu_" + hashlib.sha1(call_id.encode()).hexdigest()[:24]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClaudeAdapter(Adapter):
|
|
38
|
+
name = "claude"
|
|
39
|
+
|
|
40
|
+
def locate(self, cwd: str, session_id: str | None = None) -> Path:
|
|
41
|
+
d = PROJECTS / enc(cwd)
|
|
42
|
+
if session_id:
|
|
43
|
+
p = d / f"{session_id}.jsonl"
|
|
44
|
+
if not p.exists():
|
|
45
|
+
raise SystemExit(f"no Claude session {session_id} under {d}")
|
|
46
|
+
return p
|
|
47
|
+
files = sorted(d.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
48
|
+
if not files:
|
|
49
|
+
raise SystemExit(f"no Claude sessions found for cwd {cwd} (looked in {d})")
|
|
50
|
+
return files[0]
|
|
51
|
+
|
|
52
|
+
def read(self, path: Path) -> Session:
|
|
53
|
+
sid = path.stem
|
|
54
|
+
cwd = git = started = title = ""
|
|
55
|
+
records = []
|
|
56
|
+
for line in path.read_text().splitlines():
|
|
57
|
+
if not line.strip():
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
d = json.loads(line)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
if d.get("aiTitle"):
|
|
64
|
+
title = d["aiTitle"]
|
|
65
|
+
if d.get("type") not in ("user", "assistant") or not d.get("uuid"):
|
|
66
|
+
continue
|
|
67
|
+
if d.get("isSidechain"):
|
|
68
|
+
continue
|
|
69
|
+
cwd = d.get("cwd", cwd) or cwd
|
|
70
|
+
git = d.get("gitBranch", git) or git
|
|
71
|
+
started = started or d.get("timestamp", "")
|
|
72
|
+
ts = d.get("timestamp", "")
|
|
73
|
+
msg = d.get("message", {})
|
|
74
|
+
role = msg.get("role")
|
|
75
|
+
content = msg.get("content")
|
|
76
|
+
if isinstance(content, str):
|
|
77
|
+
records.append((UserMessage if role == "user" else AssistantMessage)(content, ts))
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(content, list):
|
|
80
|
+
continue
|
|
81
|
+
text = []
|
|
82
|
+
for b in content:
|
|
83
|
+
if not isinstance(b, dict):
|
|
84
|
+
continue
|
|
85
|
+
bt = b.get("type")
|
|
86
|
+
if bt == "text":
|
|
87
|
+
text.append(b.get("text", ""))
|
|
88
|
+
elif bt == "tool_use":
|
|
89
|
+
if text:
|
|
90
|
+
records.append(AssistantMessage("\n".join(text), ts)); text = []
|
|
91
|
+
records.append(ToolCall(b["id"], b.get("name", "tool"),
|
|
92
|
+
b.get("input", {}) or {}, ts))
|
|
93
|
+
elif bt == "tool_result":
|
|
94
|
+
c = b.get("content", "")
|
|
95
|
+
if isinstance(c, list):
|
|
96
|
+
c = "".join(x.get("text", "") if isinstance(x, dict) else str(x) for x in c)
|
|
97
|
+
records.append(ToolResult(b["tool_use_id"],
|
|
98
|
+
c if isinstance(c, str) else json.dumps(c),
|
|
99
|
+
ts, bool(b.get("is_error"))))
|
|
100
|
+
# thinking / redacted_thinking -> dropped
|
|
101
|
+
if text:
|
|
102
|
+
records.append((UserMessage if role == "user" else AssistantMessage)("\n".join(text), ts))
|
|
103
|
+
s = Session("claude", sid, cwd, records, git, started)
|
|
104
|
+
if title:
|
|
105
|
+
s.extra["title"] = title
|
|
106
|
+
return s
|
|
107
|
+
|
|
108
|
+
def dest_path(self, session: Session, dest_cwd: str) -> Path:
|
|
109
|
+
return PROJECTS / enc(dest_cwd) / f"{session.session_id}.jsonl"
|
|
110
|
+
|
|
111
|
+
def write(self, session: Session, dest_cwd: str) -> Path:
|
|
112
|
+
# records -> Claude rows, merging consecutive same-side blocks into one
|
|
113
|
+
# message (Anthropic tool-ordering), chaining parentUuid into a tree.
|
|
114
|
+
merged, side, blocks = [], None, []
|
|
115
|
+
|
|
116
|
+
def block_of(r):
|
|
117
|
+
if isinstance(r, UserMessage):
|
|
118
|
+
return "user", {"type": "text", "text": r.text}
|
|
119
|
+
if isinstance(r, AssistantMessage):
|
|
120
|
+
return "assistant", {"type": "text", "text": r.text}
|
|
121
|
+
if isinstance(r, ToolCall):
|
|
122
|
+
return "assistant", {"type": "tool_use", "id": _toolu(r.call_id),
|
|
123
|
+
"name": INBOUND_NAMES.get(r.name, r.name), "input": r.input}
|
|
124
|
+
return "user", {"type": "tool_result", "tool_use_id": _toolu(r.call_id),
|
|
125
|
+
"content": r.output, **({"is_error": True} if r.is_error else {})}
|
|
126
|
+
|
|
127
|
+
ts = session.started_at
|
|
128
|
+
for r in session.records:
|
|
129
|
+
s, b = block_of(r)
|
|
130
|
+
if s != side and blocks:
|
|
131
|
+
merged.append((side, blocks, ts)); blocks = []
|
|
132
|
+
side, ts = s, getattr(r, "ts", "") or session.started_at
|
|
133
|
+
blocks.append(b)
|
|
134
|
+
if blocks:
|
|
135
|
+
merged.append((side, blocks, ts))
|
|
136
|
+
|
|
137
|
+
rows, prev = [], None
|
|
138
|
+
for s, blks, ts in merged:
|
|
139
|
+
u = str(uuid.uuid4())
|
|
140
|
+
content = (blks[0]["text"] if s == "user" and len(blks) == 1
|
|
141
|
+
and blks[0]["type"] == "text" else blks)
|
|
142
|
+
msg = {"role": s, "content": content}
|
|
143
|
+
if s == "assistant":
|
|
144
|
+
msg["model"] = "claude-opus-4-7"
|
|
145
|
+
rows.append({"parentUuid": prev, "isSidechain": False, "userType": "external",
|
|
146
|
+
"cwd": dest_cwd, "sessionId": session.session_id, "version": VERSION,
|
|
147
|
+
"gitBranch": session.git_branch, "type": s, "message": msg,
|
|
148
|
+
"uuid": u, "timestamp": ts})
|
|
149
|
+
prev = u
|
|
150
|
+
|
|
151
|
+
# N^2 surplus: a session title rides as an ai-title row.
|
|
152
|
+
ai_title = session.extra.get("out", {}).get("ai_title")
|
|
153
|
+
if ai_title:
|
|
154
|
+
rows.append({"type": "ai-title", "aiTitle": ai_title,
|
|
155
|
+
"sessionId": session.session_id, "uuid": str(uuid.uuid4()),
|
|
156
|
+
"timestamp": session.started_at})
|
|
157
|
+
|
|
158
|
+
dest = self.dest_path(session, dest_cwd)
|
|
159
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
dest.write_text("".join(json.dumps(r) + "\n" for r in rows))
|
|
161
|
+
return dest
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
register(ClaudeAdapter())
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Codex adapter.
|
|
2
|
+
|
|
3
|
+
Transcript: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl, a flat log with
|
|
4
|
+
TWO parallel streams: response_item (model context, replayed on resume) and
|
|
5
|
+
event_msg (UI scrollback, replayed to paint history). BOTH are required, else
|
|
6
|
+
resume works but the conversation is invisible. Sessions are stored by date, so
|
|
7
|
+
locating "latest for cwd" means scanning rollouts and filtering session_meta.cwd.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..adapter import Adapter, register
|
|
17
|
+
from ..common import (AssistantMessage, Session, ToolCall, ToolResult,
|
|
18
|
+
UserMessage)
|
|
19
|
+
|
|
20
|
+
SESSIONS = Path(os.path.expanduser("~/.codex/sessions"))
|
|
21
|
+
INDEX = Path(os.path.expanduser("~/.codex/session_index.jsonl"))
|
|
22
|
+
CLI_VERSION = "0.142.2"
|
|
23
|
+
|
|
24
|
+
# Claude tool vocabulary -> Codex's. (Cosmetic: history is context, not re-run.)
|
|
25
|
+
INBOUND_NAMES = {"Bash": "shell", "Edit": "apply_patch", "Write": "apply_patch",
|
|
26
|
+
"Read": "read_file"}
|
|
27
|
+
# Which destination tool names render as a shell card vs a patch card.
|
|
28
|
+
SHELL_NAMES = {"shell", "exec_command", "Bash", "local_shell"}
|
|
29
|
+
PATCH_NAMES = {"apply_patch", "Edit", "Write"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _call(call_id: str) -> str:
|
|
33
|
+
return "call_" + hashlib.sha1(call_id.encode()).hexdigest()[:22]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CodexAdapter(Adapter):
|
|
37
|
+
name = "codex"
|
|
38
|
+
|
|
39
|
+
def _meta(self, path: Path) -> dict:
|
|
40
|
+
with path.open() as fh:
|
|
41
|
+
first = fh.readline()
|
|
42
|
+
try:
|
|
43
|
+
d = json.loads(first)
|
|
44
|
+
return d["payload"] if d.get("type") == "session_meta" else {}
|
|
45
|
+
except (json.JSONDecodeError, KeyError):
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
def locate(self, cwd: str, session_id: str | None = None) -> Path:
|
|
49
|
+
if session_id:
|
|
50
|
+
hits = list(SESSIONS.glob(f"**/rollout-*{session_id}*.jsonl"))
|
|
51
|
+
if not hits:
|
|
52
|
+
raise SystemExit(f"no Codex session {session_id} under {SESSIONS}")
|
|
53
|
+
return hits[0]
|
|
54
|
+
best = None # newest rollout whose session_meta.cwd matches
|
|
55
|
+
for p in SESSIONS.glob("**/rollout-*.jsonl"):
|
|
56
|
+
m = self._meta(p)
|
|
57
|
+
if m.get("cwd") == cwd:
|
|
58
|
+
ts = m.get("timestamp", "")
|
|
59
|
+
if best is None or ts > best[0]:
|
|
60
|
+
best = (ts, p)
|
|
61
|
+
if best is None:
|
|
62
|
+
raise SystemExit(f"no Codex sessions found for cwd {cwd}")
|
|
63
|
+
return best[1]
|
|
64
|
+
|
|
65
|
+
def _title(self, sid: str) -> str:
|
|
66
|
+
if not INDEX.exists():
|
|
67
|
+
return ""
|
|
68
|
+
for line in INDEX.read_text().splitlines():
|
|
69
|
+
try:
|
|
70
|
+
d = json.loads(line)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
if d.get("id") == sid:
|
|
74
|
+
return d.get("thread_name", "")
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
def read(self, path: Path) -> Session:
|
|
78
|
+
meta = self._meta(path)
|
|
79
|
+
sid = meta.get("id", path.stem)
|
|
80
|
+
cwd = meta.get("cwd", "")
|
|
81
|
+
git = (meta.get("git") or {}).get("branch", "")
|
|
82
|
+
started = meta.get("timestamp", "")
|
|
83
|
+
records = []
|
|
84
|
+
for line in path.read_text().splitlines():
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
d = json.loads(line)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
continue
|
|
91
|
+
if d.get("type") != "response_item":
|
|
92
|
+
continue
|
|
93
|
+
p = d["payload"]
|
|
94
|
+
pt = p.get("type")
|
|
95
|
+
ts = d.get("timestamp", "")
|
|
96
|
+
if pt == "reasoning":
|
|
97
|
+
continue
|
|
98
|
+
if pt == "message":
|
|
99
|
+
role = p.get("role")
|
|
100
|
+
if role == "developer":
|
|
101
|
+
continue
|
|
102
|
+
text = "".join(c.get("text", "") for c in p.get("content", [])
|
|
103
|
+
if c.get("type") in ("input_text", "output_text", "text"))
|
|
104
|
+
if not text.strip():
|
|
105
|
+
continue
|
|
106
|
+
records.append((AssistantMessage if role == "assistant" else UserMessage)(text, ts))
|
|
107
|
+
elif pt in ("function_call", "custom_tool_call"):
|
|
108
|
+
raw = p.get("arguments", p.get("input", ""))
|
|
109
|
+
try:
|
|
110
|
+
inp = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
inp = {"raw": raw}
|
|
113
|
+
records.append(ToolCall(p["call_id"], p.get("name", "tool"),
|
|
114
|
+
inp if isinstance(inp, dict) else {"input": inp}, ts))
|
|
115
|
+
elif pt in ("function_call_output", "custom_tool_call_output"):
|
|
116
|
+
o = p.get("output", "")
|
|
117
|
+
records.append(ToolResult(p["call_id"],
|
|
118
|
+
o if isinstance(o, str) else json.dumps(o), ts))
|
|
119
|
+
s = Session("codex", sid, cwd, records, git, started)
|
|
120
|
+
title = self._title(sid)
|
|
121
|
+
if title:
|
|
122
|
+
s.extra["title"] = title
|
|
123
|
+
return s
|
|
124
|
+
|
|
125
|
+
def dest_path(self, session: Session, dest_cwd: str) -> Path:
|
|
126
|
+
ts = session.started_at or "1970-01-01T00:00:00Z"
|
|
127
|
+
date, _, rest = ts.partition("T")
|
|
128
|
+
hms = rest.split(".")[0].replace(":", "-") or "00-00-00"
|
|
129
|
+
y, m, d = date.split("-")
|
|
130
|
+
return SESSIONS / y / m / d / f"rollout-{date}T{hms}-{session.session_id}.jsonl"
|
|
131
|
+
|
|
132
|
+
def write(self, session: Session, dest_cwd: str) -> Path:
|
|
133
|
+
out = []
|
|
134
|
+
calls = {r.call_id: r for r in session.records if isinstance(r, ToolCall)}
|
|
135
|
+
|
|
136
|
+
def ri(payload, ts):
|
|
137
|
+
out.append({"timestamp": ts, "type": "response_item", "payload": payload})
|
|
138
|
+
|
|
139
|
+
def ev(payload, ts):
|
|
140
|
+
out.append({"timestamp": ts, "type": "event_msg", "payload": payload})
|
|
141
|
+
|
|
142
|
+
def tool_card(call: ToolCall, result: ToolResult, ts):
|
|
143
|
+
"""The event_msg the TUI renders a tool card from (only _end exists)."""
|
|
144
|
+
name = INBOUND_NAMES.get(call.name, call.name)
|
|
145
|
+
cid = _call(call.call_id)
|
|
146
|
+
if name in PATCH_NAMES:
|
|
147
|
+
ev({"type": "patch_apply_end", "call_id": cid, "stdout": result.output,
|
|
148
|
+
"stderr": "", "success": not result.is_error}, ts)
|
|
149
|
+
elif name in SHELL_NAMES:
|
|
150
|
+
cmd = call.input.get("command") or call.input.get("cmd") or ""
|
|
151
|
+
ev({"type": "exec_command_end", "call_id": cid,
|
|
152
|
+
"command": ["/bin/zsh", "-lc", cmd] if isinstance(cmd, str) else cmd,
|
|
153
|
+
"cwd": dest_cwd, "stdout": result.output, "stderr": "",
|
|
154
|
+
"aggregated_output": result.output,
|
|
155
|
+
"exit_code": 1 if result.is_error else 0,
|
|
156
|
+
"status": "failed" if result.is_error else "completed"}, ts)
|
|
157
|
+
# other tools (Read, MCP, ...) -> function_call_output carries them; no card
|
|
158
|
+
|
|
159
|
+
for r in session.records:
|
|
160
|
+
ts = getattr(r, "ts", "") or session.started_at
|
|
161
|
+
if isinstance(r, UserMessage):
|
|
162
|
+
ri({"type": "message", "role": "user",
|
|
163
|
+
"content": [{"type": "input_text", "text": r.text}]}, ts)
|
|
164
|
+
ev({"type": "user_message", "message": r.text}, ts)
|
|
165
|
+
elif isinstance(r, AssistantMessage):
|
|
166
|
+
ri({"type": "message", "role": "assistant",
|
|
167
|
+
"content": [{"type": "output_text", "text": r.text}]}, ts)
|
|
168
|
+
ev({"type": "agent_message", "message": r.text}, ts)
|
|
169
|
+
elif isinstance(r, ToolCall):
|
|
170
|
+
ri({"type": "function_call", "name": INBOUND_NAMES.get(r.name, r.name),
|
|
171
|
+
"arguments": json.dumps(r.input), "call_id": _call(r.call_id)}, ts)
|
|
172
|
+
elif isinstance(r, ToolResult):
|
|
173
|
+
ri({"type": "function_call_output", "call_id": _call(r.call_id),
|
|
174
|
+
"output": r.output}, ts)
|
|
175
|
+
if r.call_id in calls:
|
|
176
|
+
tool_card(calls[r.call_id], r, ts)
|
|
177
|
+
|
|
178
|
+
meta = {"timestamp": session.started_at, "type": "session_meta",
|
|
179
|
+
"payload": {"id": session.session_id, "timestamp": session.started_at,
|
|
180
|
+
"cwd": dest_cwd, "originator": "codex-cli",
|
|
181
|
+
"cli_version": CLI_VERSION, "instructions": None,
|
|
182
|
+
**({"git": {"branch": session.git_branch}} if session.git_branch else {})}}
|
|
183
|
+
dest = self.dest_path(session, dest_cwd)
|
|
184
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
dest.write_text("".join(json.dumps(l) + "\n" for l in [meta] + out))
|
|
186
|
+
|
|
187
|
+
# N^2 surplus: a session title goes in the index the picker reads.
|
|
188
|
+
name = session.extra.get("out", {}).get("thread_name")
|
|
189
|
+
if name:
|
|
190
|
+
self._index_put(session.session_id, name, session.started_at)
|
|
191
|
+
return dest
|
|
192
|
+
|
|
193
|
+
def _index_put(self, sid: str, thread_name: str, updated_at: str) -> None:
|
|
194
|
+
rows = []
|
|
195
|
+
if INDEX.exists():
|
|
196
|
+
rows = [l for l in INDEX.read_text().splitlines()
|
|
197
|
+
if l.strip() and json.loads(l).get("id") != sid]
|
|
198
|
+
rows.append(json.dumps({"id": sid, "thread_name": thread_name,
|
|
199
|
+
"updated_at": updated_at}))
|
|
200
|
+
INDEX.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
INDEX.write_text("\n".join(rows) + "\n")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
register(CodexAdapter())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""OpenCode adapter.
|
|
2
|
+
|
|
3
|
+
Storage is SQLite, not JSONL: $XDG_DATA_HOME/opencode/opencode.db, with three
|
|
4
|
+
tables (session, message, part) whose `data` columns hold JSON. A `tool` part
|
|
5
|
+
bundles the call AND its result together (state.input / state.output /
|
|
6
|
+
state.status), where Codex/Claude keep them as two separate records; read()
|
|
7
|
+
splits it back into our ToolCall + ToolResult, write() fuses them.
|
|
8
|
+
|
|
9
|
+
Read is direct + read-only (the dead source harness never has to run). Write does
|
|
10
|
+
NOT poke the live DB (FK to project, WAL, drizzle schema drift = corruption risk);
|
|
11
|
+
it emits the canonical `{info, messages}` interchange file that `opencode import`
|
|
12
|
+
validates and ingests, preserving the id so `opencode -s <id>` resumes it.
|
|
13
|
+
|
|
14
|
+
Times on disk are epoch-milliseconds ints; the common records carry ISO strings
|
|
15
|
+
(what Codex/Claude use), so we convert on the boundary in both directions.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sqlite3
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ..adapter import Adapter, register
|
|
28
|
+
from ..common import (AssistantMessage, Session, ToolCall, ToolResult,
|
|
29
|
+
UserMessage)
|
|
30
|
+
|
|
31
|
+
DATA = (Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share").expanduser()
|
|
32
|
+
/ "opencode")
|
|
33
|
+
DB = DATA / "opencode.db"
|
|
34
|
+
IMPORTS = DATA / "imports" # where write() drops import files
|
|
35
|
+
STATE = (Path(os.environ.get("XDG_STATE_HOME") or "~/.local/state").expanduser()
|
|
36
|
+
/ "opencode")
|
|
37
|
+
VERSION = "1.14.46"
|
|
38
|
+
|
|
39
|
+
# Claude/Codex tool vocab -> OpenCode's (cosmetic: history is context, not re-run).
|
|
40
|
+
INBOUND_NAMES = {"Bash": "bash", "shell": "bash", "exec_command": "bash",
|
|
41
|
+
"local_shell": "bash", "Edit": "edit", "apply_patch": "edit",
|
|
42
|
+
"Write": "write", "Read": "read", "read_file": "read",
|
|
43
|
+
"view_image": "read"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _iso(ms) -> str:
|
|
47
|
+
try:
|
|
48
|
+
return (datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc)
|
|
49
|
+
.isoformat().replace("+00:00", "Z"))
|
|
50
|
+
except (TypeError, ValueError, OSError):
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ms(iso: str) -> int:
|
|
55
|
+
if not iso:
|
|
56
|
+
return 0
|
|
57
|
+
try:
|
|
58
|
+
return int(datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
59
|
+
.timestamp() * 1000)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _id(prefix: str, *seed) -> str:
|
|
65
|
+
"""Deterministic opencode-style id, so re-importing upserts instead of piling
|
|
66
|
+
up duplicates (import keys off the id)."""
|
|
67
|
+
return prefix + hashlib.sha1(":".join(map(str, seed)).encode()).hexdigest()[:24]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _default_model() -> dict:
|
|
71
|
+
"""The model the resumed scrollback attributes to past assistant turns. Read
|
|
72
|
+
the user's own recent choice so it renders native; fall back to a generic id.
|
|
73
|
+
ponytail: cosmetic metadata on already-happened turns, never re-run."""
|
|
74
|
+
try:
|
|
75
|
+
recent = json.loads((STATE / "model.json").read_text()).get("recent") or []
|
|
76
|
+
if recent:
|
|
77
|
+
return {"providerID": recent[0]["providerID"],
|
|
78
|
+
"modelID": recent[0]["modelID"]}
|
|
79
|
+
except (OSError, ValueError, KeyError):
|
|
80
|
+
pass
|
|
81
|
+
return {"providerID": "anthropic", "modelID": "claude-sonnet-4-6"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OpenCodeAdapter(Adapter):
|
|
85
|
+
name = "opencode"
|
|
86
|
+
|
|
87
|
+
# locate() returns "<db>#<session_id>": one db holds every session, so a bare
|
|
88
|
+
# path can't address one. read() splits the id back off.
|
|
89
|
+
def _open(self) -> sqlite3.Connection:
|
|
90
|
+
return sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
|
91
|
+
|
|
92
|
+
def locate(self, cwd: str, session_id: str | None = None) -> Path:
|
|
93
|
+
if not DB.exists():
|
|
94
|
+
raise SystemExit(f"no OpenCode database at {DB}")
|
|
95
|
+
con = self._open()
|
|
96
|
+
try:
|
|
97
|
+
if session_id:
|
|
98
|
+
row = con.execute(
|
|
99
|
+
"SELECT id FROM session WHERE id = ? OR id LIKE ? "
|
|
100
|
+
"ORDER BY time_created DESC LIMIT 1",
|
|
101
|
+
(session_id, f"%{session_id}%")).fetchone()
|
|
102
|
+
if not row:
|
|
103
|
+
raise SystemExit(f"no OpenCode session {session_id} in {DB}")
|
|
104
|
+
return Path(f"{DB}#{row[0]}")
|
|
105
|
+
row = con.execute(
|
|
106
|
+
"SELECT id FROM session WHERE directory = ? "
|
|
107
|
+
"ORDER BY time_created DESC LIMIT 1", (cwd,)).fetchone()
|
|
108
|
+
if not row:
|
|
109
|
+
raise SystemExit(f"no OpenCode sessions found for cwd {cwd}")
|
|
110
|
+
return Path(f"{DB}#{row[0]}")
|
|
111
|
+
finally:
|
|
112
|
+
con.close()
|
|
113
|
+
|
|
114
|
+
def read(self, path: Path) -> Session:
|
|
115
|
+
_, _, sid = str(path).rpartition("#")
|
|
116
|
+
con = self._open()
|
|
117
|
+
try:
|
|
118
|
+
srow = con.execute(
|
|
119
|
+
"SELECT directory, title, time_created FROM session WHERE id = ?",
|
|
120
|
+
(sid,)).fetchone()
|
|
121
|
+
if not srow:
|
|
122
|
+
raise SystemExit(f"no OpenCode session {sid} in {DB}")
|
|
123
|
+
cwd, title, started_ms = srow
|
|
124
|
+
records = []
|
|
125
|
+
msgs = con.execute(
|
|
126
|
+
"SELECT id, data FROM message WHERE session_id = ? "
|
|
127
|
+
"ORDER BY time_created, id", (sid,)).fetchall()
|
|
128
|
+
for mid, mdata in msgs:
|
|
129
|
+
role = json.loads(mdata).get("role")
|
|
130
|
+
Msg = AssistantMessage if role == "assistant" else UserMessage
|
|
131
|
+
parts = con.execute(
|
|
132
|
+
"SELECT data FROM part WHERE message_id = ? "
|
|
133
|
+
"ORDER BY time_created, id", (mid,)).fetchall()
|
|
134
|
+
for (pdata,) in parts:
|
|
135
|
+
p = json.loads(pdata)
|
|
136
|
+
pt = p.get("type")
|
|
137
|
+
ts = _iso(p.get("time", {}).get("start") if pt == "tool"
|
|
138
|
+
else started_ms)
|
|
139
|
+
if pt == "text":
|
|
140
|
+
t = p.get("text", "")
|
|
141
|
+
if t.strip():
|
|
142
|
+
records.append(Msg(t, _iso(started_ms)))
|
|
143
|
+
elif pt == "tool":
|
|
144
|
+
st = p.get("state", {}) or {}
|
|
145
|
+
cid = p.get("callID", p.get("id", ""))
|
|
146
|
+
records.append(ToolCall(cid, p.get("tool", "tool"),
|
|
147
|
+
st.get("input") or {}, ts))
|
|
148
|
+
status = st.get("status")
|
|
149
|
+
if status in ("completed", "error"):
|
|
150
|
+
records.append(ToolResult(
|
|
151
|
+
cid, st.get("output", ""), ts,
|
|
152
|
+
is_error=(status == "error")))
|
|
153
|
+
# running/pending -> no result; synthesize closes it
|
|
154
|
+
# reasoning / step-* / file / patch / compaction -> dropped
|
|
155
|
+
s = Session("opencode", sid, cwd or "", records,
|
|
156
|
+
started_at=_iso(started_ms))
|
|
157
|
+
if title and not title.startswith("New session"):
|
|
158
|
+
s.extra["title"] = title
|
|
159
|
+
return s
|
|
160
|
+
finally:
|
|
161
|
+
con.close()
|
|
162
|
+
|
|
163
|
+
def dest_path(self, session: Session, dest_cwd: str) -> Path:
|
|
164
|
+
return IMPORTS / f"{session.session_id}.json"
|
|
165
|
+
|
|
166
|
+
def write(self, session: Session, dest_cwd: str) -> Path:
|
|
167
|
+
sid = session.session_id
|
|
168
|
+
model = _default_model()
|
|
169
|
+
results = {r.call_id: r for r in session.records
|
|
170
|
+
if isinstance(r, ToolResult)}
|
|
171
|
+
consumed: set[str] = set()
|
|
172
|
+
|
|
173
|
+
def text_part(mid, i, text):
|
|
174
|
+
return {"type": "text", "text": text, "id": _id("prt_", sid, mid, i),
|
|
175
|
+
"sessionID": sid, "messageID": mid}
|
|
176
|
+
|
|
177
|
+
def tool_part(mid, i, call: ToolCall):
|
|
178
|
+
res = results.get(call.call_id)
|
|
179
|
+
consumed.add(call.call_id)
|
|
180
|
+
name = INBOUND_NAMES.get(call.name, call.name.lower())
|
|
181
|
+
ms = _ms(call.ts) or _ms(session.started_at)
|
|
182
|
+
time = {"start": ms, "end": _ms(res.ts) if res else ms}
|
|
183
|
+
# error tool parts carry state.error (a string); completed carry output.
|
|
184
|
+
if res and res.is_error:
|
|
185
|
+
state = {"status": "error", "input": call.input,
|
|
186
|
+
"error": res.output, "time": time}
|
|
187
|
+
else:
|
|
188
|
+
state = {"status": "completed", "input": call.input,
|
|
189
|
+
"output": res.output if res else "",
|
|
190
|
+
"metadata": {}, "title": name, "time": time}
|
|
191
|
+
return {"type": "tool", "tool": name,
|
|
192
|
+
"callID": _id("call_", call.call_id), "state": state,
|
|
193
|
+
"id": _id("prt_", sid, mid, i), "sessionID": sid, "messageID": mid}
|
|
194
|
+
|
|
195
|
+
# Group records into role-runs; a message is one role's contiguous parts.
|
|
196
|
+
runs: list[tuple[str, list]] = []
|
|
197
|
+
side = None
|
|
198
|
+
for r in session.records:
|
|
199
|
+
s = "user" if isinstance(r, UserMessage) else "assistant"
|
|
200
|
+
if isinstance(r, ToolResult) and r.call_id in consumed:
|
|
201
|
+
continue # already fused into its ToolCall's tool part
|
|
202
|
+
if s != side:
|
|
203
|
+
runs.append((s, [])); side = s
|
|
204
|
+
runs[-1][1].append(r)
|
|
205
|
+
|
|
206
|
+
messages, prev_mid = [], None
|
|
207
|
+
for run_i, (s, recs) in enumerate(runs):
|
|
208
|
+
mid = _id("msg_", sid, run_i)
|
|
209
|
+
parts = []
|
|
210
|
+
for i, r in enumerate(recs):
|
|
211
|
+
if isinstance(r, (UserMessage, AssistantMessage)):
|
|
212
|
+
parts.append(text_part(mid, i, r.text))
|
|
213
|
+
elif isinstance(r, ToolCall):
|
|
214
|
+
parts.append(tool_part(mid, i, r))
|
|
215
|
+
elif isinstance(r, ToolResult): # orphan result (no matching call)
|
|
216
|
+
parts.append(text_part(mid, i, r.output))
|
|
217
|
+
if not parts:
|
|
218
|
+
continue
|
|
219
|
+
created = _ms(getattr(recs[0], "ts", "")) or _ms(session.started_at)
|
|
220
|
+
if s == "user":
|
|
221
|
+
info = {"role": "user", "time": {"created": created},
|
|
222
|
+
"agent": "build", "model": model,
|
|
223
|
+
"summary": {"diffs": []}, "id": mid, "sessionID": sid}
|
|
224
|
+
else:
|
|
225
|
+
info = {"parentID": prev_mid or mid, "role": "assistant",
|
|
226
|
+
"mode": "build", "agent": "build",
|
|
227
|
+
"path": {"cwd": dest_cwd, "root": dest_cwd}, "cost": 0,
|
|
228
|
+
"tokens": {"total": 0, "input": 0, "output": 0,
|
|
229
|
+
"reasoning": 0, "cache": {"write": 0, "read": 0}},
|
|
230
|
+
"modelID": model["modelID"], "providerID": model["providerID"],
|
|
231
|
+
"time": {"created": created, "completed": created},
|
|
232
|
+
"finish": "stop", "id": mid, "sessionID": sid}
|
|
233
|
+
messages.append({"info": info, "parts": parts})
|
|
234
|
+
prev_mid = mid
|
|
235
|
+
|
|
236
|
+
title = (session.extra.get("out", {}).get("opencode_title")
|
|
237
|
+
or "Relocated session")
|
|
238
|
+
created = _ms(session.started_at)
|
|
239
|
+
last = _ms(getattr(session.records[-1], "ts", "")) if session.records else created
|
|
240
|
+
doc = {"info": {"id": sid, "slug": _slug(title), "directory": dest_cwd,
|
|
241
|
+
"projectID": hashlib.sha1(dest_cwd.encode()).hexdigest()[:40],
|
|
242
|
+
"title": title, "version": VERSION,
|
|
243
|
+
"summary": {"additions": 0, "deletions": 0, "files": 0},
|
|
244
|
+
"time": {"created": created, "updated": max(last, created)}},
|
|
245
|
+
"messages": messages}
|
|
246
|
+
|
|
247
|
+
dest = self.dest_path(session, dest_cwd)
|
|
248
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
dest.write_text(json.dumps(doc))
|
|
250
|
+
return dest
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _slug(title: str) -> str:
|
|
254
|
+
s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
255
|
+
return s[:40] or "relocated"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
register(OpenCodeAdapter())
|
package/hconv/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hc: relocate a coding-agent session across harnesses.
|
|
3
|
+
|
|
4
|
+
Escape hatch: your harness hit a wall (rate limit, outage) mid-task. Move the
|
|
5
|
+
session to a live harness and keep going. Reads transcripts off disk; the source
|
|
6
|
+
harness does NOT need to be running or your quota intact.
|
|
7
|
+
|
|
8
|
+
hc --from claude --to codex # move latest Claude session here -> Codex
|
|
9
|
+
hc --from codex --to claude <id> # a specific session
|
|
10
|
+
hc --from claude --to codex --cwd DIR # source/dest folder (default: pwd)
|
|
11
|
+
hc list --from codex # what's convertible for this cwd
|
|
12
|
+
|
|
13
|
+
By default prints what it WOULD do; pass --write to actually create the file.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from hconv import convert, get, known
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_convert(a):
|
|
23
|
+
session, dest = convert(a.from_harness, a.to, a.cwd, a.dest_cwd or a.cwd,
|
|
24
|
+
session_id=a.session_id, write=a.write)
|
|
25
|
+
n_tool = sum(1 for r in session.records if type(r).__name__ == "ToolCall")
|
|
26
|
+
print(f"from : {a.from_harness} ({session.session_id})")
|
|
27
|
+
print(f"to : {a.to}")
|
|
28
|
+
print(f"cwd : {a.dest_cwd or a.cwd}")
|
|
29
|
+
print(f"records: {len(session.records)} ({n_tool} tool calls)")
|
|
30
|
+
print(f"dest : {dest}")
|
|
31
|
+
if a.write:
|
|
32
|
+
sid, cwd = session.session_id, a.dest_cwd or a.cwd
|
|
33
|
+
resume = {"codex": f"codex resume {sid}",
|
|
34
|
+
"claude": f"claude --resume {sid}",
|
|
35
|
+
"opencode": f"opencode import {dest} && opencode -s {sid}"}[a.to]
|
|
36
|
+
print(f"\nWROTE. resume with:\n cd {cwd} && {resume}")
|
|
37
|
+
else:
|
|
38
|
+
print("\n(dry run; pass --write to create it)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_list(a):
|
|
42
|
+
adapter = get(a.from_harness)
|
|
43
|
+
try:
|
|
44
|
+
p = adapter.locate(a.cwd)
|
|
45
|
+
print(f"latest {a.from_harness} session for {a.cwd}:\n {p}")
|
|
46
|
+
except SystemExit as e:
|
|
47
|
+
print(e)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main():
|
|
51
|
+
ap = argparse.ArgumentParser(prog="hc", description=__doc__,
|
|
52
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
53
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
54
|
+
|
|
55
|
+
def add_common(p):
|
|
56
|
+
p.add_argument("--from", dest="from_harness", required=True,
|
|
57
|
+
choices=known(), help="source harness")
|
|
58
|
+
p.add_argument("--cwd", default=os.getcwd(), help="source folder (default: pwd)")
|
|
59
|
+
|
|
60
|
+
c = sub.add_parser("convert", help="move a session to another harness")
|
|
61
|
+
add_common(c)
|
|
62
|
+
c.add_argument("--to", required=True, choices=known(), help="destination harness")
|
|
63
|
+
c.add_argument("session_id", nargs="?", help="session id (default: latest for cwd)")
|
|
64
|
+
c.add_argument("--dest-cwd", help="destination folder (default: same as --cwd)")
|
|
65
|
+
c.add_argument("--write", action="store_true", help="actually write the file")
|
|
66
|
+
c.set_defaults(func=cmd_convert)
|
|
67
|
+
|
|
68
|
+
l = sub.add_parser("list", help="show the latest convertible session for a cwd")
|
|
69
|
+
add_common(l)
|
|
70
|
+
l.set_defaults(func=cmd_list)
|
|
71
|
+
|
|
72
|
+
# bare `hc --from X --to Y` == `hc convert ...`
|
|
73
|
+
if len(sys.argv) > 1 and sys.argv[1] not in ("convert", "list", "-h", "--help"):
|
|
74
|
+
sys.argv.insert(1, "convert")
|
|
75
|
+
|
|
76
|
+
args = ap.parse_args()
|
|
77
|
+
if not getattr(args, "func", None):
|
|
78
|
+
ap.print_help(); sys.exit(1)
|
|
79
|
+
args.func(args)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
package/hconv/common.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The common interface every harness must satisfy.
|
|
2
|
+
|
|
3
|
+
A session is structural metadata + an ordered list of records. The four record
|
|
4
|
+
kinds below are the universal floor: EVERY adapter reads its transcript into
|
|
5
|
+
these and writes these back out, no exceptions. This is what guarantees that any
|
|
6
|
+
harness pair converts at all.
|
|
7
|
+
|
|
8
|
+
Anything richer than the four records (session titles, permission modes, model
|
|
9
|
+
settings, ...) is deliberately NOT here. That surplus rides the N^2 enrichment
|
|
10
|
+
map (see enrich.py) and is parked in Session.extra. The common interface never
|
|
11
|
+
encodes the surplus; the surplus never re-encodes the common conversation.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class UserMessage:
|
|
20
|
+
"""Something the human typed."""
|
|
21
|
+
text: str
|
|
22
|
+
ts: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AssistantMessage:
|
|
27
|
+
"""Agent's visible text reply (not its private reasoning, which is dropped)."""
|
|
28
|
+
text: str
|
|
29
|
+
ts: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ToolCall:
|
|
34
|
+
"""An agent tool invocation. `name`/`input` are the source harness's; adapters
|
|
35
|
+
translate to the destination's tool vocabulary on write."""
|
|
36
|
+
call_id: str
|
|
37
|
+
name: str
|
|
38
|
+
input: dict
|
|
39
|
+
ts: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolResult:
|
|
44
|
+
"""The output that came back for a ToolCall, paired by call_id."""
|
|
45
|
+
call_id: str
|
|
46
|
+
output: str
|
|
47
|
+
ts: str = ""
|
|
48
|
+
is_error: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# The closed set of common records. A harness that needs more uses enrich.py.
|
|
52
|
+
Record = UserMessage | AssistantMessage | ToolCall | ToolResult
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Session:
|
|
57
|
+
"""Harness-neutral session: identity + the common record stream + a parking
|
|
58
|
+
lot for enrichment payloads.
|
|
59
|
+
|
|
60
|
+
Identity fields (id/cwd/branch/started_at) are structural: every adapter needs
|
|
61
|
+
them to materialize a transcript. They are not "features"; they're addressing.
|
|
62
|
+
"""
|
|
63
|
+
harness: str # source harness name, e.g. "claude"
|
|
64
|
+
session_id: str
|
|
65
|
+
cwd: str
|
|
66
|
+
records: list[Record] = field(default_factory=list)
|
|
67
|
+
git_branch: str = ""
|
|
68
|
+
started_at: str = "" # ISO timestamp of first record
|
|
69
|
+
extra: dict = field(default_factory=dict) # surplus, populated by enrich.py
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def synthesize_missing_results(records: list[Record]) -> list[Record]:
|
|
73
|
+
"""Close every open ToolCall.
|
|
74
|
+
|
|
75
|
+
Escape-hatch reality: the source harness usually died MID-TURN (rate limit hit
|
|
76
|
+
while a tool was running), so the last ToolCall often has no ToolResult. Every
|
|
77
|
+
destination needs the pairing closed or the resumed API call rejects the
|
|
78
|
+
history. Inject a synthetic error result immediately after each orphan.
|
|
79
|
+
|
|
80
|
+
This is the common case for this tool, not an edge case.
|
|
81
|
+
"""
|
|
82
|
+
have = {r.call_id for r in records if isinstance(r, ToolResult)}
|
|
83
|
+
out: list[Record] = []
|
|
84
|
+
for r in records:
|
|
85
|
+
out.append(r)
|
|
86
|
+
if isinstance(r, ToolCall) and r.call_id not in have:
|
|
87
|
+
out.append(ToolResult(r.call_id,
|
|
88
|
+
"[no output; source session ended here]",
|
|
89
|
+
ts=r.ts, is_error=True))
|
|
90
|
+
return out
|
package/hconv/enrich.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""N^2 enrichment map. Keyed by (source_harness, dest_harness).
|
|
2
|
+
|
|
3
|
+
Carries ONLY the surplus fields the common interface drops, and only for pairs
|
|
4
|
+
where both harnesses can represent the feature. An enricher reads what the source
|
|
5
|
+
adapter stashed in Session.extra and translates it into a form the destination
|
|
6
|
+
adapter's write() will pick up (also via Session.extra).
|
|
7
|
+
|
|
8
|
+
By design this is optional and sparse: a missing (src, dst) entry just means
|
|
9
|
+
"common-only conversion", which always works. The map never re-encodes the four
|
|
10
|
+
common records, only the extras.
|
|
11
|
+
|
|
12
|
+
Example (not yet wired): carry the session title both ways.
|
|
13
|
+
|
|
14
|
+
@register("claude", "codex")
|
|
15
|
+
def _(s: Session) -> None:
|
|
16
|
+
if "title" in s.extra:
|
|
17
|
+
s.extra["codex_thread_name"] = s.extra["title"]
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from .common import Session
|
|
24
|
+
|
|
25
|
+
# Mutates Session.extra in place so the destination adapter's write() can consume.
|
|
26
|
+
Enricher = Callable[[Session], None]
|
|
27
|
+
|
|
28
|
+
_MAP: dict[tuple[str, str], Enricher] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register(src: str, dst: str):
|
|
32
|
+
def deco(fn: Enricher) -> Enricher:
|
|
33
|
+
_MAP[(src, dst)] = fn
|
|
34
|
+
return fn
|
|
35
|
+
return deco
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def enrich(src: str, dst: str, session: Session) -> None:
|
|
39
|
+
fn = _MAP.get((src, dst))
|
|
40
|
+
if fn:
|
|
41
|
+
fn(session)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- the surplus map -------------------------------------------------------
|
|
45
|
+
# Both harnesses support a human-facing session title. The common interface
|
|
46
|
+
# drops it; these carry it for the two pairs that can represent it. Writers
|
|
47
|
+
# consume only Session.extra["out"], so a pair with no enricher stays common-only.
|
|
48
|
+
|
|
49
|
+
@register("claude", "codex")
|
|
50
|
+
def _claude_to_codex(s: Session) -> None:
|
|
51
|
+
if s.extra.get("title"):
|
|
52
|
+
s.extra.setdefault("out", {})["thread_name"] = s.extra["title"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@register("codex", "claude")
|
|
56
|
+
def _codex_to_claude(s: Session) -> None:
|
|
57
|
+
if s.extra.get("title"):
|
|
58
|
+
s.extra.setdefault("out", {})["ai_title"] = s.extra["title"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# OpenCode carries the title as a first-class session column. Into opencode the
|
|
62
|
+
# writer reads out["opencode_title"]; out of opencode we reuse the keys the
|
|
63
|
+
# claude/codex writers already consume (ai_title / thread_name).
|
|
64
|
+
|
|
65
|
+
@register("claude", "opencode")
|
|
66
|
+
@register("codex", "opencode")
|
|
67
|
+
def _to_opencode(s: Session) -> None:
|
|
68
|
+
if s.extra.get("title"):
|
|
69
|
+
s.extra.setdefault("out", {})["opencode_title"] = s.extra["title"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@register("opencode", "claude")
|
|
73
|
+
def _opencode_to_claude(s: Session) -> None:
|
|
74
|
+
if s.extra.get("title"):
|
|
75
|
+
s.extra.setdefault("out", {})["ai_title"] = s.extra["title"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@register("opencode", "codex")
|
|
79
|
+
def _opencode_to_codex(s: Session) -> None:
|
|
80
|
+
if s.extra.get("title"):
|
|
81
|
+
s.extra.setdefault("out", {})["thread_name"] = s.extra["title"]
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theharshitsingh/hc",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Relocate a coding-agent session across harnesses (Codex <-> Claude Code) and resume it natively.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://hc.theharshitsingh.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/harshitsinghbhandari/harness-convert.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/harshitsinghbhandari/harness-convert/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"hc": "bin/hc.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"hconv/",
|
|
20
|
+
"!hconv/**/__pycache__/**",
|
|
21
|
+
"!hconv/**/*.pyc",
|
|
22
|
+
"hc.py",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"codex",
|
|
28
|
+
"claude",
|
|
29
|
+
"claude-code",
|
|
30
|
+
"opencode",
|
|
31
|
+
"agents",
|
|
32
|
+
"cli",
|
|
33
|
+
"transcript",
|
|
34
|
+
"session",
|
|
35
|
+
"harness"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|