agent-session-bridge 0.2.0__py3-none-any.whl
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.
- agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
- agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
- agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
- agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
- agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
- session_bridge/__init__.py +0 -0
- session_bridge/_ids.py +43 -0
- session_bridge/cli.py +473 -0
- session_bridge/convert.py +130 -0
- session_bridge/handshake.py +175 -0
- session_bridge/ir.py +246 -0
- session_bridge/place.py +98 -0
- session_bridge/readers/__init__.py +0 -0
- session_bridge/readers/_content.py +42 -0
- session_bridge/readers/_jsonl.py +50 -0
- session_bridge/readers/_pending.py +63 -0
- session_bridge/readers/claude_code.py +226 -0
- session_bridge/readers/codex.py +203 -0
- session_bridge/readers/hermes.py +167 -0
- session_bridge/skill_install.py +134 -0
- session_bridge/skills/session-handoff/SKILL.md +119 -0
- session_bridge/tui/__init__.py +0 -0
- session_bridge/tui/actions.py +123 -0
- session_bridge/tui/app.py +65 -0
- session_bridge/tui/discovery.py +208 -0
- session_bridge/tui/options.py +86 -0
- session_bridge/tui/register.py +475 -0
- session_bridge/tui/screens.py +710 -0
- session_bridge/tui/summary.py +41 -0
- session_bridge/writers/__init__.py +0 -0
- session_bridge/writers/_common.py +288 -0
- session_bridge/writers/claude_code.py +152 -0
- session_bridge/writers/codex.py +154 -0
- session_bridge/writers/codex_db.py +302 -0
- session_bridge/writers/hermes.py +142 -0
- session_bridge/writers/hermes_db.py +294 -0
session_bridge/cli.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""session-bridge CLI.
|
|
2
|
+
|
|
3
|
+
session-bridge inspect --from claude-code SESSION.jsonl
|
|
4
|
+
session-bridge convert --from codex --to hermes SESSION.jsonl -o out.jsonl
|
|
5
|
+
session-bridge convert --from claude-code --to codex SESSION.jsonl --no-handshake
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .convert import (
|
|
16
|
+
HARNESSES,
|
|
17
|
+
convert,
|
|
18
|
+
default_output_name,
|
|
19
|
+
dump_jsonl,
|
|
20
|
+
now_codex_timestamp,
|
|
21
|
+
read_session,
|
|
22
|
+
)
|
|
23
|
+
from .ir import BlockType
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _count_blocks(session, block_type):
|
|
27
|
+
return sum(1 for m in session.messages for b in m.content if b.type is block_type)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_inspect(args: argparse.Namespace) -> int:
|
|
31
|
+
session = read_session(args.source, args.path)
|
|
32
|
+
m = session.meta
|
|
33
|
+
print(f"source harness : {m.source_harness}")
|
|
34
|
+
print(f"session id : {m.session_id}")
|
|
35
|
+
print(f"model : {m.model} (provider: {m.model_provider})")
|
|
36
|
+
print(f"cwd : {m.cwd}")
|
|
37
|
+
print(f"permission : {m.permission_mode}")
|
|
38
|
+
print(f"messages : {len(session.messages)}")
|
|
39
|
+
print(f" text blocks : {_count_blocks(session, BlockType.TEXT)}")
|
|
40
|
+
print(f" reasoning : {_count_blocks(session, BlockType.REASONING)}")
|
|
41
|
+
print(f" tool calls : {_count_blocks(session, BlockType.TOOL_CALL)}")
|
|
42
|
+
print(f" tool results : {_count_blocks(session, BlockType.TOOL_RESULT)}")
|
|
43
|
+
print(f"tool schemas : {len(session.tools)}")
|
|
44
|
+
p = session.pending
|
|
45
|
+
print("pending state :")
|
|
46
|
+
print(f" open tool calls : {list(p.open_tool_calls)}")
|
|
47
|
+
print(f" queued user input : {len(p.queued_user_messages)}")
|
|
48
|
+
print(f" active goal : {p.active_goal or '-'}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_convert(args: argparse.Namespace) -> int:
|
|
53
|
+
# Validate incompatible flags before writing anything, so an invalid combo
|
|
54
|
+
# doesn't leave a stray -o output file on disk alongside the error.
|
|
55
|
+
if args.place_claude_cwd and args.target != "claude-code":
|
|
56
|
+
print("--place-claude-cwd only applies when --to claude-code", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
|
|
59
|
+
# Stamp Codex output with the real current time so the session isn't dated to
|
|
60
|
+
# the writer's placeholder epoch (which can hide it from Codex's recency sort).
|
|
61
|
+
codex_ts = now_codex_timestamp() if args.target == "codex" else None
|
|
62
|
+
|
|
63
|
+
result = convert(
|
|
64
|
+
args.source,
|
|
65
|
+
args.target,
|
|
66
|
+
args.path,
|
|
67
|
+
inject_handshake=not args.no_handshake,
|
|
68
|
+
codex_timestamp=codex_ts,
|
|
69
|
+
stub_open_calls=args.stub_open_calls,
|
|
70
|
+
)
|
|
71
|
+
out = args.output or default_output_name(args.path, args.target)
|
|
72
|
+
dump_jsonl(result.records, out)
|
|
73
|
+
print(f"wrote {len(result.records)} records -> {out}")
|
|
74
|
+
|
|
75
|
+
if result.report.warnings:
|
|
76
|
+
print(f"\n{len(result.report.warnings)} conversion note(s):", file=sys.stderr)
|
|
77
|
+
for w in result.report.warnings:
|
|
78
|
+
print(f" - {w}", file=sys.stderr)
|
|
79
|
+
else:
|
|
80
|
+
print("\nlossless conversion (no warnings).", file=sys.stderr)
|
|
81
|
+
|
|
82
|
+
if args.handshake_out:
|
|
83
|
+
Path(args.handshake_out).write_text(result.handshake, encoding="utf-8")
|
|
84
|
+
print(f"wrote resume handshake -> {args.handshake_out}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
if args.place_claude_cwd:
|
|
87
|
+
import shlex
|
|
88
|
+
import uuid
|
|
89
|
+
|
|
90
|
+
from ._ids import UnsafeSessionIdError
|
|
91
|
+
from .place import SessionExistsError, UnsafeCwdError, place_claude_code
|
|
92
|
+
|
|
93
|
+
session_id = args.session_id or str(uuid.uuid4())
|
|
94
|
+
try:
|
|
95
|
+
placed = place_claude_code(
|
|
96
|
+
result.records,
|
|
97
|
+
args.place_claude_cwd,
|
|
98
|
+
session_id,
|
|
99
|
+
overwrite=args.force,
|
|
100
|
+
)
|
|
101
|
+
except UnsafeSessionIdError as exc:
|
|
102
|
+
print(f"invalid --session-id: {exc}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
except UnsafeCwdError as exc:
|
|
105
|
+
print(f"invalid --place-claude-cwd: {exc}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
except SessionExistsError as exc:
|
|
108
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
print(f"placed resumable session -> {placed}", file=sys.stderr)
|
|
111
|
+
print(
|
|
112
|
+
f"resume with: (cd {shlex.quote(args.place_claude_cwd)} "
|
|
113
|
+
f"&& claude --resume {shlex.quote(session_id)})",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_register(args: argparse.Namespace) -> int:
|
|
120
|
+
"""Register a converted session into Hermes's SQLite store so it resumes.
|
|
121
|
+
|
|
122
|
+
Takes a backup of the DB first (unless --no-backup). Claude Code needs no
|
|
123
|
+
registration — use `convert --place-claude-cwd` for that.
|
|
124
|
+
"""
|
|
125
|
+
import time
|
|
126
|
+
import uuid
|
|
127
|
+
|
|
128
|
+
from ._ids import UnsafeSessionIdError, validate_session_id
|
|
129
|
+
from .handshake import stub_open_tool_calls
|
|
130
|
+
from .writers._common import HERMES_DB_CAPS, report_losses
|
|
131
|
+
from .writers.hermes_db import (
|
|
132
|
+
HermesRegistrationError,
|
|
133
|
+
backup_hermes_db,
|
|
134
|
+
register_hermes_session,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
session = read_session(args.source, args.path)
|
|
138
|
+
|
|
139
|
+
# Surface conversion losses on the register path too (previously silent):
|
|
140
|
+
# e.g. orphaned tool calls that break resume, dropped tool schemas, etc.
|
|
141
|
+
# Report from the PRE-stub session so an interrupted call is still disclosed.
|
|
142
|
+
# Use the DB writer's real capabilities (HERMES_DB_CAPS) rather than the
|
|
143
|
+
# "hermes" file-writer caps: the state.db has no tool-catalog column, so a
|
|
144
|
+
# hermes-sourced session's tool schemas ARE dropped here and must be warned.
|
|
145
|
+
reg_report = report_losses(session, "hermes", caps_override=HERMES_DB_CAPS)
|
|
146
|
+
if reg_report.warnings:
|
|
147
|
+
print(f"\n{len(reg_report.warnings)} conversion note(s):", file=sys.stderr)
|
|
148
|
+
for w in reg_report.warnings:
|
|
149
|
+
print(f" - {w}", file=sys.stderr)
|
|
150
|
+
|
|
151
|
+
# register is the command that makes `hermes --resume` work, so it needs the
|
|
152
|
+
# same open-call remediation convert has: a genuinely-open tool call is
|
|
153
|
+
# written into state.db in a shape the provider rejects on resume. With
|
|
154
|
+
# --stub-open-calls, append a synthetic interrupted result so the registered
|
|
155
|
+
# session is actually resumable (the warning above still discloses it).
|
|
156
|
+
if args.stub_open_calls:
|
|
157
|
+
session = stub_open_tool_calls(session)
|
|
158
|
+
|
|
159
|
+
db_path = args.db or os.path.expanduser("~/.hermes/state.db")
|
|
160
|
+
if not os.path.exists(db_path):
|
|
161
|
+
print(f"Hermes state.db not found: {db_path}", file=sys.stderr)
|
|
162
|
+
return 2
|
|
163
|
+
|
|
164
|
+
session_id = args.session_id or f"sb_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
|
165
|
+
try:
|
|
166
|
+
validate_session_id(session_id) # fail fast before backup/writes
|
|
167
|
+
except UnsafeSessionIdError as exc:
|
|
168
|
+
print(f"invalid --session-id: {exc}", file=sys.stderr)
|
|
169
|
+
return 2
|
|
170
|
+
|
|
171
|
+
if not args.no_backup:
|
|
172
|
+
backup = f"{db_path}.session-bridge-backup-{time.time_ns()}-{uuid.uuid4().hex}"
|
|
173
|
+
# WAL-safe: a plain file copy would miss committed rows still in the
|
|
174
|
+
# sibling -wal file (Hermes runs WAL with the gateway holding the DB
|
|
175
|
+
# open). The SQLite backup API reads through the live state.
|
|
176
|
+
backup_hermes_db(db_path, backup)
|
|
177
|
+
print(f"backed up state.db -> {backup}", file=sys.stderr)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
register_hermes_session(
|
|
181
|
+
session,
|
|
182
|
+
db_path,
|
|
183
|
+
session_id,
|
|
184
|
+
title=args.title,
|
|
185
|
+
started_at=time.time(),
|
|
186
|
+
model=args.model,
|
|
187
|
+
)
|
|
188
|
+
except HermesRegistrationError as exc:
|
|
189
|
+
print(f"registration failed: {exc}", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
print(f"registered session {session_id} into {db_path}")
|
|
193
|
+
if not args.model:
|
|
194
|
+
print(
|
|
195
|
+
"note: stored the source model id; if `hermes --resume` loses context, "
|
|
196
|
+
"re-register with --model set to a Hermes-configured model.",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
print(f"resume with: hermes --resume {session_id}", file=sys.stderr)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_register_codex(args: argparse.Namespace) -> int:
|
|
204
|
+
"""Register a converted session into Codex's index and rollout store."""
|
|
205
|
+
import time
|
|
206
|
+
import uuid
|
|
207
|
+
|
|
208
|
+
from ._ids import UnsafeSessionIdError, validate_session_id
|
|
209
|
+
from .handshake import handshake_message, stub_open_tool_calls
|
|
210
|
+
from .writers._common import report_losses
|
|
211
|
+
from .writers.codex_db import (
|
|
212
|
+
CodexRegistrationError,
|
|
213
|
+
infer_codex_model,
|
|
214
|
+
register_codex_session,
|
|
215
|
+
validate_codex_store,
|
|
216
|
+
)
|
|
217
|
+
from .writers.hermes_db import backup_sqlite_db
|
|
218
|
+
|
|
219
|
+
session = read_session(args.source, args.path)
|
|
220
|
+
report = report_losses(session, "codex")
|
|
221
|
+
if report.warnings:
|
|
222
|
+
print(f"\n{len(report.warnings)} conversion note(s):", file=sys.stderr)
|
|
223
|
+
for warning in report.warnings:
|
|
224
|
+
print(f" - {warning}", file=sys.stderr)
|
|
225
|
+
|
|
226
|
+
if args.stub_open_calls:
|
|
227
|
+
session = stub_open_tool_calls(session)
|
|
228
|
+
session = session.with_messages((handshake_message(session, report, "codex"),) + session.messages)
|
|
229
|
+
|
|
230
|
+
session_id = args.session_id or str(uuid.uuid4())
|
|
231
|
+
try:
|
|
232
|
+
validate_session_id(session_id)
|
|
233
|
+
validate_codex_store(args.codex_home)
|
|
234
|
+
except (UnsafeSessionIdError, CodexRegistrationError) as exc:
|
|
235
|
+
print(f"invalid Codex registration: {exc}", file=sys.stderr)
|
|
236
|
+
return 2
|
|
237
|
+
|
|
238
|
+
db_path = Path(args.codex_home).expanduser() / "state_5.sqlite"
|
|
239
|
+
model = args.model or infer_codex_model(args.codex_home, args.model_provider)
|
|
240
|
+
if not model:
|
|
241
|
+
print(
|
|
242
|
+
"Codex registration needs a target model; pass --model because no prior "
|
|
243
|
+
f"{args.model_provider!r} model was found in {db_path}",
|
|
244
|
+
file=sys.stderr,
|
|
245
|
+
)
|
|
246
|
+
return 2
|
|
247
|
+
if not args.no_backup:
|
|
248
|
+
backup = f"{db_path}.session-bridge-backup-{time.time_ns()}-{uuid.uuid4().hex}"
|
|
249
|
+
backup_sqlite_db(str(db_path), backup)
|
|
250
|
+
print(f"backed up Codex state_5.sqlite -> {backup}", file=sys.stderr)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
rollout = register_codex_session(
|
|
254
|
+
session,
|
|
255
|
+
args.codex_home,
|
|
256
|
+
session_id,
|
|
257
|
+
cwd=args.cwd,
|
|
258
|
+
title=args.title or f"resumed from {args.source}",
|
|
259
|
+
model=model,
|
|
260
|
+
model_provider=args.model_provider,
|
|
261
|
+
)
|
|
262
|
+
except CodexRegistrationError as exc:
|
|
263
|
+
print(f"Codex registration failed: {exc}", file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
|
|
266
|
+
print(f"registered session {session_id} into {db_path}")
|
|
267
|
+
print(f"using Codex model {args.model_provider}/{model}")
|
|
268
|
+
print(f"wrote Codex rollout -> {rollout}")
|
|
269
|
+
print(f"resume with: (cd {args.cwd} && codex resume {session_id})", file=sys.stderr)
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cmd_tui(args: argparse.Namespace) -> int:
|
|
274
|
+
"""Launch the interactive TUI (requires the optional 'textual' dependency)."""
|
|
275
|
+
try:
|
|
276
|
+
from .tui.app import run_tui
|
|
277
|
+
except ImportError:
|
|
278
|
+
# ImportError, not just ModuleNotFoundError: also covers a broken or
|
|
279
|
+
# half-installed textual, which should get the same actionable hint.
|
|
280
|
+
print(
|
|
281
|
+
"the TUI needs the optional 'textual' dependency:\n"
|
|
282
|
+
" pip install 'agent-session-bridge[tui]'",
|
|
283
|
+
file=sys.stderr,
|
|
284
|
+
)
|
|
285
|
+
return 2
|
|
286
|
+
return run_tui()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def cmd_install_skill(args: argparse.Namespace) -> int:
|
|
290
|
+
"""Distribute the packaged session-handoff skill to installed harnesses."""
|
|
291
|
+
from pathlib import Path
|
|
292
|
+
|
|
293
|
+
from .skill_install import HARNESS_HOMES, install_skill
|
|
294
|
+
|
|
295
|
+
homes = {name: Path(path) for name, path in HARNESS_HOMES.items()}
|
|
296
|
+
results = install_skill(homes, copy=args.copy, force=args.force)
|
|
297
|
+
failed = False
|
|
298
|
+
for r in results:
|
|
299
|
+
if r.action == "error":
|
|
300
|
+
failed = True
|
|
301
|
+
print(f"{r.harness}: ERROR — {r.detail}", file=sys.stderr)
|
|
302
|
+
else:
|
|
303
|
+
print(f"{r.harness}: {r.action} — {r.detail}")
|
|
304
|
+
if all(r.action == "skipped" for r in results):
|
|
305
|
+
print(
|
|
306
|
+
"no harness homes found (~/.claude, ~/.codex, ~/.hermes) — nothing installed",
|
|
307
|
+
file=sys.stderr,
|
|
308
|
+
)
|
|
309
|
+
return 1
|
|
310
|
+
return 1 if failed else 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _package_version() -> str:
|
|
314
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
return version("agent-session-bridge")
|
|
318
|
+
except PackageNotFoundError: # running from a source tree without install
|
|
319
|
+
return "unknown"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
323
|
+
parser = argparse.ArgumentParser(prog="session-bridge", description=__doc__)
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"--version", action="version", version=f"%(prog)s {_package_version()}"
|
|
326
|
+
)
|
|
327
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
328
|
+
|
|
329
|
+
insp = sub.add_parser("inspect", help="parse a session and print its structure")
|
|
330
|
+
insp.add_argument("--from", dest="source", required=True, choices=HARNESSES)
|
|
331
|
+
insp.add_argument("path")
|
|
332
|
+
insp.set_defaults(func=cmd_inspect)
|
|
333
|
+
|
|
334
|
+
conv = sub.add_parser("convert", help="convert a session between harnesses")
|
|
335
|
+
conv.add_argument("--from", dest="source", required=True, choices=HARNESSES)
|
|
336
|
+
conv.add_argument("--to", dest="target", required=True, choices=HARNESSES)
|
|
337
|
+
conv.add_argument("path")
|
|
338
|
+
conv.add_argument("-o", "--output", help="output JSONL path")
|
|
339
|
+
conv.add_argument("--handshake-out", help="also write the resume handshake to this path")
|
|
340
|
+
conv.add_argument("--no-handshake", action="store_true",
|
|
341
|
+
help="do not prepend the resume handshake message")
|
|
342
|
+
conv.add_argument("--stub-open-calls", action="store_true",
|
|
343
|
+
help="append a synthetic interrupted tool_result for each "
|
|
344
|
+
"still-open tool call, so the output is a valid transcript "
|
|
345
|
+
"a provider will accept on resume (a call with no result "
|
|
346
|
+
"is a 400 on OpenAI Responses / rejected by Anthropic)")
|
|
347
|
+
conv.add_argument("--place-claude-cwd", metavar="CWD",
|
|
348
|
+
help="also place the converted session under Claude Code's "
|
|
349
|
+
"project dir for this cwd, so `claude --resume` finds it "
|
|
350
|
+
"(only valid with --to claude-code)")
|
|
351
|
+
conv.add_argument("--session-id",
|
|
352
|
+
help="session id to use when placing (default: a fresh uuid)")
|
|
353
|
+
conv.add_argument("--force", action="store_true",
|
|
354
|
+
help="overwrite an existing placed transcript at the same "
|
|
355
|
+
"--session-id (default: fail rather than clobber it)")
|
|
356
|
+
conv.set_defaults(func=cmd_convert)
|
|
357
|
+
|
|
358
|
+
reg = sub.add_parser(
|
|
359
|
+
"register",
|
|
360
|
+
help="register a session into Hermes's SQLite store so `hermes --resume` finds it",
|
|
361
|
+
)
|
|
362
|
+
reg.add_argument("--from", dest="source", required=True, choices=HARNESSES)
|
|
363
|
+
reg.add_argument("path")
|
|
364
|
+
reg.add_argument("--db", help="path to Hermes state.db (default: ~/.hermes/state.db)")
|
|
365
|
+
reg.add_argument("--model",
|
|
366
|
+
help="model id to store; set to a Hermes-configured model so resume "
|
|
367
|
+
"keeps context (a cross-harness source id may not route)")
|
|
368
|
+
reg.add_argument("--title", help="session title (must be unique in the store)")
|
|
369
|
+
reg.add_argument("--session-id", help="session id to use (default: a generated sb_ id)")
|
|
370
|
+
reg.add_argument("--no-backup", action="store_true",
|
|
371
|
+
help="skip backing up state.db first (not recommended)")
|
|
372
|
+
reg.add_argument("--stub-open-calls", action="store_true",
|
|
373
|
+
help="append a synthetic interrupted tool_result for each "
|
|
374
|
+
"still-open tool call before registering, so the stored "
|
|
375
|
+
"session is resumable (a call with no result breaks "
|
|
376
|
+
"`hermes --resume`)")
|
|
377
|
+
reg.set_defaults(func=cmd_register)
|
|
378
|
+
|
|
379
|
+
codex_reg = sub.add_parser(
|
|
380
|
+
"register-codex",
|
|
381
|
+
help="register a session into Codex's local store so `codex resume` finds it",
|
|
382
|
+
)
|
|
383
|
+
codex_reg.add_argument("--from", dest="source", required=True, choices=HARNESSES)
|
|
384
|
+
codex_reg.add_argument("path")
|
|
385
|
+
codex_reg.add_argument(
|
|
386
|
+
"--codex-home", default=os.path.expanduser("~/.codex"),
|
|
387
|
+
help="Codex home containing state_5.sqlite (default: ~/.codex)",
|
|
388
|
+
)
|
|
389
|
+
codex_reg.add_argument(
|
|
390
|
+
"--cwd", required=True,
|
|
391
|
+
help="existing project directory Codex uses to discover the resumed session",
|
|
392
|
+
)
|
|
393
|
+
codex_reg.add_argument("--title", help="title shown in Codex's resume picker")
|
|
394
|
+
codex_reg.add_argument(
|
|
395
|
+
"--model",
|
|
396
|
+
help="target Codex model; defaults to the most recently used model for --model-provider",
|
|
397
|
+
)
|
|
398
|
+
codex_reg.add_argument(
|
|
399
|
+
"--model-provider",
|
|
400
|
+
default="openai",
|
|
401
|
+
help="target Codex model provider (default: openai; never inferred from the source)",
|
|
402
|
+
)
|
|
403
|
+
codex_reg.add_argument("--session-id", help="UUID to use (default: a generated UUID)")
|
|
404
|
+
codex_reg.add_argument(
|
|
405
|
+
"--no-backup", action="store_true",
|
|
406
|
+
help="skip backing up state_5.sqlite first (not recommended)",
|
|
407
|
+
)
|
|
408
|
+
codex_reg.add_argument(
|
|
409
|
+
"--stub-open-calls", action="store_true",
|
|
410
|
+
help="append a synthetic interrupted tool_result for each still-open tool call "
|
|
411
|
+
"before registering, so the resumed transcript is provider-valid",
|
|
412
|
+
)
|
|
413
|
+
codex_reg.set_defaults(func=cmd_register_codex)
|
|
414
|
+
|
|
415
|
+
tui = sub.add_parser(
|
|
416
|
+
"tui",
|
|
417
|
+
help="interactive session picker/converter "
|
|
418
|
+
"(requires: pip install 'agent-session-bridge[tui]')",
|
|
419
|
+
)
|
|
420
|
+
tui.set_defaults(func=cmd_tui)
|
|
421
|
+
|
|
422
|
+
skill = sub.add_parser(
|
|
423
|
+
"install-skill",
|
|
424
|
+
help="link the session-handoff agent skill into every installed "
|
|
425
|
+
"harness's skills dir (~/.claude, ~/.codex, ~/.hermes)",
|
|
426
|
+
)
|
|
427
|
+
skill.add_argument(
|
|
428
|
+
"--copy", action="store_true",
|
|
429
|
+
help="copy instead of symlink (survives uninstalling session-bridge, "
|
|
430
|
+
"but goes stale on upgrades)",
|
|
431
|
+
)
|
|
432
|
+
skill.add_argument(
|
|
433
|
+
"--force", action="store_true",
|
|
434
|
+
help="replace an existing differing skill at the target path",
|
|
435
|
+
)
|
|
436
|
+
skill.set_defaults(func=cmd_install_skill)
|
|
437
|
+
return parser
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def main(argv: list[str] | None = None) -> int:
|
|
441
|
+
import json
|
|
442
|
+
import sqlite3
|
|
443
|
+
|
|
444
|
+
parser = build_parser()
|
|
445
|
+
args = parser.parse_args(argv)
|
|
446
|
+
try:
|
|
447
|
+
return args.func(args)
|
|
448
|
+
except (FileNotFoundError, IsADirectoryError, PermissionError) as exc:
|
|
449
|
+
# Clean error for common filesystem failures instead of a raw traceback,
|
|
450
|
+
# matching the handling for other error classes in the commands.
|
|
451
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
452
|
+
return 2
|
|
453
|
+
except UnicodeDecodeError as exc:
|
|
454
|
+
print(f"error: file is not valid UTF-8: {exc}", file=sys.stderr)
|
|
455
|
+
return 2
|
|
456
|
+
except json.JSONDecodeError as exc:
|
|
457
|
+
# A hand-edited or tool-mangled interior line in a session file: the
|
|
458
|
+
# readers re-raise it by design; surface it cleanly, not as a traceback.
|
|
459
|
+
print(f"error: not a valid session file (JSON parse error): {exc}", file=sys.stderr)
|
|
460
|
+
return 2
|
|
461
|
+
except sqlite3.Error as exc:
|
|
462
|
+
# e.g. --db pointing at a file that is not a valid SQLite database.
|
|
463
|
+
print(f"error: SQLite failure: {exc}", file=sys.stderr)
|
|
464
|
+
return 2
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
# Catch-all for filesystem failures not covered above (e.g. ENAMETOOLONG,
|
|
467
|
+
# ENOSPC). Keep this last so the specific handlers above win.
|
|
468
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
469
|
+
return 2
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
if __name__ == "__main__":
|
|
473
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Top-level conversion API: read a source session, optionally prepend a resume
|
|
2
|
+
handshake, and render it to a target harness."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
from .handshake import (
|
|
12
|
+
build_handshake,
|
|
13
|
+
handshake_message,
|
|
14
|
+
strip_prior_handshakes,
|
|
15
|
+
stub_open_tool_calls,
|
|
16
|
+
)
|
|
17
|
+
from .ir import ConversionReport, Session
|
|
18
|
+
from .readers.claude_code import read_claude_code
|
|
19
|
+
from .readers.codex import read_codex
|
|
20
|
+
from .readers.hermes import read_hermes
|
|
21
|
+
from .writers.claude_code import write_claude_code
|
|
22
|
+
from .writers.codex import write_codex
|
|
23
|
+
from .writers.hermes import write_hermes
|
|
24
|
+
|
|
25
|
+
READERS: dict[str, Callable[[str | Path], Session]] = {
|
|
26
|
+
"claude-code": read_claude_code,
|
|
27
|
+
"codex": read_codex,
|
|
28
|
+
"hermes": read_hermes,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
WRITERS: dict[str, Callable[[Session], tuple[list[dict[str, Any]], ConversionReport]]] = {
|
|
32
|
+
"claude-code": write_claude_code,
|
|
33
|
+
"codex": write_codex,
|
|
34
|
+
"hermes": write_hermes,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
HARNESSES = tuple(READERS.keys())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ConversionResult:
|
|
42
|
+
session: Session
|
|
43
|
+
records: list[dict[str, Any]]
|
|
44
|
+
report: ConversionReport
|
|
45
|
+
handshake: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_output_name(path: str | Path, target: str) -> str:
|
|
49
|
+
"""Default output filename for a conversion, shared by the CLI and the TUI."""
|
|
50
|
+
return Path(path).with_suffix("").name + f".{target}.jsonl"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def now_codex_timestamp() -> str:
|
|
54
|
+
"""Current UTC time in the ISO shape Codex stamps on session_meta records."""
|
|
55
|
+
import time
|
|
56
|
+
|
|
57
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_session(source: str, path: str | Path) -> Session:
|
|
61
|
+
if source not in READERS:
|
|
62
|
+
raise ValueError(f"unknown source harness '{source}'; choose from {HARNESSES}")
|
|
63
|
+
return READERS[source](path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def convert(
|
|
67
|
+
source: str,
|
|
68
|
+
target: str,
|
|
69
|
+
path: str | Path,
|
|
70
|
+
*,
|
|
71
|
+
inject_handshake: bool = True,
|
|
72
|
+
codex_timestamp: Optional[str] = None,
|
|
73
|
+
stub_open_calls: bool = False,
|
|
74
|
+
) -> ConversionResult:
|
|
75
|
+
"""Convert a session file from ``source`` to ``target``.
|
|
76
|
+
|
|
77
|
+
When ``inject_handshake`` is set (default), a resume-handshake system message is
|
|
78
|
+
prepended so the target agent resolves pending state before continuing.
|
|
79
|
+
|
|
80
|
+
When ``stub_open_calls`` is set, a synthetic interrupted tool_result is appended
|
|
81
|
+
for each genuinely-open tool call, making the output a valid transcript that a
|
|
82
|
+
provider will accept on resume (a tool call with no result is a documented 400
|
|
83
|
+
on OpenAI Responses and rejected by Anthropic). Off by default because it adds
|
|
84
|
+
a message the source did not contain; the handshake reports the calls either way.
|
|
85
|
+
|
|
86
|
+
``codex_timestamp`` (ISO string) stamps a Codex-target session_meta so the
|
|
87
|
+
session isn't dated to the writer's placeholder epoch; the CLI passes the real
|
|
88
|
+
current time (scripts can't call the clock). Ignored for other targets.
|
|
89
|
+
"""
|
|
90
|
+
if target not in WRITERS:
|
|
91
|
+
raise ValueError(f"unknown target harness '{target}'; choose from {HARNESSES}")
|
|
92
|
+
|
|
93
|
+
def _write(sess: Session):
|
|
94
|
+
if target == "codex" and codex_timestamp is not None:
|
|
95
|
+
return WRITERS[target](sess, timestamp=codex_timestamp)
|
|
96
|
+
return WRITERS[target](sess)
|
|
97
|
+
|
|
98
|
+
session = read_session(source, path)
|
|
99
|
+
# Strip any handshake a previous conversion hop injected, so multi-hop
|
|
100
|
+
# conversions replace rather than accumulate handshakes (and the turn count
|
|
101
|
+
# reflects real turns, not stale injected instructions).
|
|
102
|
+
session = strip_prior_handshakes(session)
|
|
103
|
+
|
|
104
|
+
# Build the report first (from the untouched session) so the handshake text can
|
|
105
|
+
# cite the real losses (incl. still-open tool calls) before any stubbing.
|
|
106
|
+
_, report = _write(session)
|
|
107
|
+
handshake_text = build_handshake(session, report, target)
|
|
108
|
+
|
|
109
|
+
# Optionally append synthetic interrupted results so the output is a valid,
|
|
110
|
+
# provider-acceptable transcript. Done AFTER building the report/handshake so
|
|
111
|
+
# both still reflect the real pre-stub open-call state.
|
|
112
|
+
if stub_open_calls:
|
|
113
|
+
session = stub_open_tool_calls(session)
|
|
114
|
+
|
|
115
|
+
to_write = session
|
|
116
|
+
if inject_handshake:
|
|
117
|
+
hs = handshake_message(session, report, target)
|
|
118
|
+
to_write = session.with_messages((hs,) + session.messages)
|
|
119
|
+
|
|
120
|
+
records, _ = _write(to_write)
|
|
121
|
+
return ConversionResult(
|
|
122
|
+
session=session, records=records, report=report, handshake=handshake_text
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def dump_jsonl(records: list[dict[str, Any]], path: str | Path) -> None:
|
|
127
|
+
path = Path(path)
|
|
128
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
129
|
+
for rec in records:
|
|
130
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|