@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 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,7 @@
1
+ #!/usr/bin/env python3
2
+ """Dev shim so `python3 hc.py ...` works without installing. The real entry
3
+ point is `hc` (see pyproject.toml -> hconv.cli:main)."""
4
+ from hconv.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -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"]
@@ -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,2 @@
1
+ """Importing this package registers every adapter."""
2
+ from . import claude, codex, opencode # noqa: F401
@@ -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()
@@ -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
@@ -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
+ }