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.
Files changed (37) hide show
  1. agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
  2. agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
  3. agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
  4. agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
  7. session_bridge/__init__.py +0 -0
  8. session_bridge/_ids.py +43 -0
  9. session_bridge/cli.py +473 -0
  10. session_bridge/convert.py +130 -0
  11. session_bridge/handshake.py +175 -0
  12. session_bridge/ir.py +246 -0
  13. session_bridge/place.py +98 -0
  14. session_bridge/readers/__init__.py +0 -0
  15. session_bridge/readers/_content.py +42 -0
  16. session_bridge/readers/_jsonl.py +50 -0
  17. session_bridge/readers/_pending.py +63 -0
  18. session_bridge/readers/claude_code.py +226 -0
  19. session_bridge/readers/codex.py +203 -0
  20. session_bridge/readers/hermes.py +167 -0
  21. session_bridge/skill_install.py +134 -0
  22. session_bridge/skills/session-handoff/SKILL.md +119 -0
  23. session_bridge/tui/__init__.py +0 -0
  24. session_bridge/tui/actions.py +123 -0
  25. session_bridge/tui/app.py +65 -0
  26. session_bridge/tui/discovery.py +208 -0
  27. session_bridge/tui/options.py +86 -0
  28. session_bridge/tui/register.py +475 -0
  29. session_bridge/tui/screens.py +710 -0
  30. session_bridge/tui/summary.py +41 -0
  31. session_bridge/writers/__init__.py +0 -0
  32. session_bridge/writers/_common.py +288 -0
  33. session_bridge/writers/claude_code.py +152 -0
  34. session_bridge/writers/codex.py +154 -0
  35. session_bridge/writers/codex_db.py +302 -0
  36. session_bridge/writers/hermes.py +142 -0
  37. 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")