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
@@ -0,0 +1,175 @@
1
+ """Resume handshake.
2
+
3
+ Structural file conversion alone cannot resume a session that stopped mid-turn:
4
+ the target harness will happily replay a transcript, but it won't *know* that a
5
+ tool call was left open, that a user message was queued but never processed, or
6
+ that a stated goal is still in flight. The handshake turns that pending state
7
+ (plus the lossy-conversion warnings) into an explicit instruction block that is
8
+ injected as the first message of the resumed session, so the receiving agent
9
+ picks up deliberately instead of guessing.
10
+
11
+ This is the "handshake protocol, not just file conversion" the research flagged.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import replace
17
+ from typing import Any
18
+
19
+ from .ir import BlockType, ContentBlock, ConversionReport, Message, Role, Session
20
+
21
+ HANDSHAKE_TITLE = "# Session resume handshake"
22
+
23
+ # An unforgeable marker embedded in every handshake body. Detection matches on
24
+ # THIS, not the human-readable title: a real user message could legitimately
25
+ # start with the title (e.g. someone quoting a prior handshake), and stripping it
26
+ # would silently delete real content. The HTML comment is invisible in rendered
27
+ # Markdown and vanishingly unlikely to appear by coincidence in genuine input.
28
+ HANDSHAKE_MARKER = "<!-- session-bridge:handshake -->"
29
+
30
+
31
+ def is_handshake_message(message: Message) -> bool:
32
+ # Match on the embedded marker, not the title text or the role: role is
33
+ # unreliable (SYSTEM can round-trip back as USER once a writer folds it) and
34
+ # the title alone collides with legitimate user prose.
35
+ return any(
36
+ b.type is BlockType.TEXT and HANDSHAKE_MARKER in (b.text or "")
37
+ for b in message.content
38
+ )
39
+
40
+
41
+ def strip_prior_handshakes(session: Session) -> Session:
42
+ """Remove any handshake messages a previous conversion hop injected, so a
43
+ fresh handshake replaces them instead of stacking."""
44
+ kept = tuple(m for m in session.messages if not is_handshake_message(m))
45
+ return session.with_messages(kept)
46
+
47
+
48
+ # Synthetic result injected for a tool call left unresolved at the tail. Providers
49
+ # reject a tool call with no matching result (OpenAI Responses "No tool output
50
+ # found for function call" 400; Anthropic requires a tool_result), and harness
51
+ # resume-repair passes do NOT stub it. The industry-standard fix is to inject an
52
+ # error tool_result marking the call interrupted, so the transcript is valid AND
53
+ # the model learns the action did not complete.
54
+ INTERRUPTED_RESULT_TEXT = "[session interrupted before this tool call returned]"
55
+
56
+
57
+ def stub_open_tool_calls(session: Session) -> Session:
58
+ """Append a synthetic error tool_result for every genuinely-open tool call,
59
+ so the resumed session is a valid transcript (no orphaned tool call) and the
60
+ model sees the call as failed/interrupted rather than crashing the provider.
61
+
62
+ Appends one TOOL message carrying the stub results, only for call_ids in
63
+ ``pending.open_tool_calls`` (tail-outstanding, never resolved). No-op when
64
+ there are none. Pending state is cleared for those calls since they are now
65
+ resolved (as interrupted)."""
66
+ open_ids = session.pending.open_tool_calls
67
+ if not open_ids:
68
+ return session
69
+ stub = Message(
70
+ role=Role.TOOL,
71
+ content=tuple(
72
+ ContentBlock.tool_result(cid, INTERRUPTED_RESULT_TEXT, is_error=True)
73
+ for cid in open_ids
74
+ ),
75
+ )
76
+ new_pending = replace(session.pending, open_tool_calls=())
77
+ return replace(
78
+ session,
79
+ messages=session.messages + (stub,),
80
+ pending=new_pending,
81
+ )
82
+
83
+
84
+ def _open_call_details(session: Session) -> list[tuple[str, str, str]]:
85
+ """(call_id, tool_name, arguments-preview) for each genuinely-open call.
86
+
87
+ ``pending.open_tool_calls`` (see readers/_pending.py) already scopes which
88
+ call_ids are genuinely open. Here, pick the LAST issuing block per open id so
89
+ a reissued/interrupted call shows its current args, not an earlier occurrence.
90
+ """
91
+ open_ids = set(session.pending.open_tool_calls)
92
+ last_block: dict[str, Any] = {}
93
+ for m in session.messages:
94
+ for b in m.content:
95
+ if b.type is BlockType.TOOL_CALL and b.call_id in open_ids:
96
+ last_block[b.call_id] = b
97
+ out = []
98
+ for call_id in session.pending.open_tool_calls:
99
+ b = last_block.get(call_id)
100
+ if b is None:
101
+ continue
102
+ args = b.tool_input or {}
103
+ preview = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:4])
104
+ out.append((call_id, b.tool_name or "?", preview))
105
+ return out
106
+
107
+
108
+ def build_handshake(session: Session, report: ConversionReport, target: str) -> str:
109
+ """Render a human+agent readable resume preamble in Markdown."""
110
+ src = session.meta.source_harness
111
+ lines: list[str] = []
112
+ lines.append(HANDSHAKE_MARKER)
113
+ lines.append(HANDSHAKE_TITLE)
114
+ lines.append("")
115
+ lines.append(f"This session was exported from **{src}** and resumed in **{target}** "
116
+ f"by session-bridge. Read this before continuing.")
117
+ lines.append("")
118
+
119
+ # Context recap.
120
+ lines.append("## Original context")
121
+ if session.meta.cwd:
122
+ lines.append(f"- Working directory: `{session.meta.cwd}`")
123
+ if session.meta.model:
124
+ lines.append(f"- Source model: `{session.meta.model}`")
125
+ real_turns = sum(1 for m in session.messages if not is_handshake_message(m))
126
+ lines.append(f"- Turns carried over: {real_turns}")
127
+ lines.append("")
128
+
129
+ pending = session.pending
130
+ if pending.is_empty():
131
+ lines.append("## Pending state")
132
+ lines.append("- None. The source stopped at a clean turn boundary; continue normally.")
133
+ lines.append("")
134
+ else:
135
+ lines.append("## Pending state — resolve these before proceeding")
136
+ open_calls = _open_call_details(session)
137
+ if open_calls:
138
+ lines.append("")
139
+ lines.append("### Open tool calls (issued, no result)")
140
+ for call_id, name, preview in open_calls:
141
+ lines.append(f"- `{name}`({preview}) — call_id `{call_id}`. "
142
+ f"Re-run this tool (or decide it is no longer needed) "
143
+ f"before continuing the turn.")
144
+ if pending.queued_user_messages:
145
+ lines.append("")
146
+ lines.append("### Queued user input (typed but never processed)")
147
+ for q in pending.queued_user_messages:
148
+ lines.append(f"- {q}")
149
+ if pending.active_goal:
150
+ lines.append("")
151
+ lines.append(f"### Active goal\n- {pending.active_goal}")
152
+ lines.append("")
153
+
154
+ # Lossy conversion notes.
155
+ if report.warnings:
156
+ lines.append("## Conversion notes (what did not transfer losslessly)")
157
+ for w in report.warnings:
158
+ lines.append(f"- {w}")
159
+ lines.append("")
160
+
161
+ lines.append("## Instruction")
162
+ if pending.is_empty():
163
+ lines.append("Resume the conversation from the last turn above.")
164
+ else:
165
+ lines.append("First satisfy the pending state above, then resume from the "
166
+ "last turn. Do not silently skip open tool calls or queued input.")
167
+ return "\n".join(lines)
168
+
169
+
170
+ def handshake_message(session: Session, report: ConversionReport, target: str) -> Message:
171
+ """The handshake as an IR system message, ready to prepend before writing."""
172
+ return Message(
173
+ role=Role.SYSTEM,
174
+ content=(ContentBlock.text_block(build_handshake(session, report, target)),),
175
+ )
session_bridge/ir.py ADDED
@@ -0,0 +1,246 @@
1
+ """Intermediate representation (IR) for cross-harness session portability.
2
+
3
+ Every harness reader normalizes into this model; every writer renders from it.
4
+ The IR is the union of what Claude Code, Codex, and Hermes can express, so that
5
+ conversion is lossless where the target supports a feature and *explicitly* lossy
6
+ (recorded in ``ConversionReport``) where it does not.
7
+
8
+ Design rules:
9
+ - Immutable value objects (frozen dataclasses). Transforms return new objects.
10
+ - Content is a list of typed blocks, not a flat string, so reasoning and tool
11
+ calls survive round-trips instead of being flattened into text.
12
+ - ``raw`` fields preserve the source record so a writer can fall back to
13
+ harness-specific detail the IR does not model.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ from dataclasses import dataclass, field, replace
20
+ from enum import Enum
21
+ from typing import Any, Optional
22
+
23
+ # Marker a reader substitutes for a source content block the IR cannot represent
24
+ # (e.g. a Claude Code image block). Lives here so readers and writers share one
25
+ # definition; report_losses scans for it to report the loss.
26
+ UNSUPPORTED_BLOCK_MARKER = "[unsupported "
27
+
28
+ # Prefix a writer prepends to a failed tool result when the target format has no
29
+ # native error flag (Codex / Hermes). Readers recover is_error from it so a
30
+ # failure survives a multi-hop round trip instead of reading back as success.
31
+ # The token is deliberately unforgeable (like HANDSHAKE_MARKER) so that genuine
32
+ # tool output which merely starts with "[tool error]" is NOT mistaken for a
33
+ # bridge-inserted failure marker. The human-readable prefix follows the token so
34
+ # the result still reads naturally.
35
+ ERROR_MARKER_TOKEN = "<!-- session-bridge:tool-error -->"
36
+ ERROR_MARKER = ERROR_MARKER_TOKEN + "[tool error] "
37
+
38
+ # Model ids a harness stamps on locally-synthesized (non-completion) messages —
39
+ # e.g. Claude Code's "<synthetic>" on an auth-error notice. Not real, routable
40
+ # model ids; readers must not adopt them as the session model and the loss
41
+ # reporter must not count them as a real model.
42
+ PLACEHOLDER_MODELS = frozenset({"<synthetic>"})
43
+
44
+
45
+ def recover_tool_error(text: str) -> tuple[str, bool]:
46
+ """If ``text`` carries the error marker, strip it and report an error.
47
+
48
+ Lets a reader reconstruct ``is_error`` for a result whose failure was baked
49
+ into text by a prior hop's writer (the target had no native error flag).
50
+ Matches only the unforgeable token, so genuine output beginning with
51
+ '[tool error]' is not misclassified."""
52
+ if text.startswith(ERROR_MARKER):
53
+ return text[len(ERROR_MARKER):], True
54
+ return text, False
55
+
56
+
57
+ class Role(str, Enum):
58
+ USER = "user"
59
+ ASSISTANT = "assistant"
60
+ SYSTEM = "system"
61
+ TOOL = "tool"
62
+
63
+
64
+ class BlockType(str, Enum):
65
+ TEXT = "text"
66
+ REASONING = "reasoning"
67
+ TOOL_CALL = "tool_call"
68
+ TOOL_RESULT = "tool_result"
69
+ RAW = "raw"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ContentBlock:
74
+ """One typed unit inside a message.
75
+
76
+ - TEXT: ``text`` holds the prose.
77
+ - REASONING: ``text`` holds the thinking content.
78
+ - TOOL_CALL: ``tool_name`` + ``tool_input`` + ``call_id``.
79
+ - TOOL_RESULT: ``call_id`` links back to the call; ``text`` holds the
80
+ result payload; ``is_error`` marks a failed call.
81
+ - RAW: a source content block the IR has no typed representation for (e.g. a
82
+ Claude Code image/document block). ``raw_block`` holds the original block
83
+ verbatim and ``raw_kind`` its source type, so a same-harness writer can
84
+ re-emit it losslessly; a cross-harness writer degrades it to a reported
85
+ placeholder. ``text`` holds that human-readable placeholder.
86
+ """
87
+
88
+ type: BlockType
89
+ text: Optional[str] = None
90
+ tool_name: Optional[str] = None
91
+ tool_input: Optional[dict[str, Any]] = None
92
+ call_id: Optional[str] = None
93
+ is_error: bool = False
94
+ raw_block: Optional[dict[str, Any]] = None
95
+ raw_kind: Optional[str] = None
96
+ # On a TOOL_RESULT: the original non-text parts (e.g. an image) of a
97
+ # tool_result whose content was a block list. Carried WITH the result rather
98
+ # than as sibling RAW blocks, so a same-harness writer re-emits them inside
99
+ # the result's own content list and no writer mistakes them for a new turn.
100
+ result_parts: tuple[dict[str, Any], ...] = ()
101
+
102
+ @staticmethod
103
+ def text_block(text: str) -> "ContentBlock":
104
+ return ContentBlock(type=BlockType.TEXT, text=text)
105
+
106
+ @staticmethod
107
+ def raw(raw_block: dict[str, Any], raw_kind: str) -> "ContentBlock":
108
+ return ContentBlock(
109
+ type=BlockType.RAW,
110
+ text=f"{UNSUPPORTED_BLOCK_MARKER}{raw_kind} block]",
111
+ raw_block=copy.deepcopy(raw_block),
112
+ raw_kind=raw_kind,
113
+ )
114
+
115
+ @staticmethod
116
+ def reasoning(text: str) -> "ContentBlock":
117
+ return ContentBlock(type=BlockType.REASONING, text=text)
118
+
119
+ @staticmethod
120
+ def tool_call(call_id: str, tool_name: str, tool_input: dict[str, Any]) -> "ContentBlock":
121
+ return ContentBlock(
122
+ type=BlockType.TOOL_CALL,
123
+ call_id=call_id,
124
+ tool_name=tool_name,
125
+ tool_input=copy.deepcopy(tool_input),
126
+ )
127
+
128
+ @staticmethod
129
+ def tool_result(
130
+ call_id: str,
131
+ text: str,
132
+ is_error: bool = False,
133
+ result_parts: tuple[dict[str, Any], ...] = (),
134
+ ) -> "ContentBlock":
135
+ return ContentBlock(
136
+ type=BlockType.TOOL_RESULT,
137
+ call_id=call_id,
138
+ text=text,
139
+ is_error=is_error,
140
+ result_parts=tuple(copy.deepcopy(p) for p in result_parts),
141
+ )
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class Message:
146
+ """A single turn.
147
+
148
+ ``uid`` is a stable identifier within the session. ``parent_uid`` records
149
+ explicit thread linkage (Claude Code) so a non-linear thread survives; when a
150
+ harness is purely append-ordered, ``parent_uid`` is left ``None`` and order is
151
+ the list order.
152
+ """
153
+
154
+ role: Role
155
+ content: tuple[ContentBlock, ...]
156
+ uid: Optional[str] = None
157
+ parent_uid: Optional[str] = None
158
+ timestamp: Optional[str] = None
159
+ raw: Optional[dict[str, Any]] = None
160
+
161
+ def text(self) -> str:
162
+ """Concatenated TEXT blocks (convenience for display, not round-trip)."""
163
+ return "\n".join(b.text or "" for b in self.content if b.type == BlockType.TEXT)
164
+
165
+ def display_text(self) -> str:
166
+ """TEXT blocks plus RAW placeholders — the text a writer that cannot hold
167
+ RAW should emit, so a RAW block degrades to its placeholder instead of
168
+ vanishing when a message is flattened to a single string."""
169
+ return "\n".join(
170
+ b.text or ""
171
+ for b in self.content
172
+ if b.type in (BlockType.TEXT, BlockType.RAW)
173
+ )
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class ToolSchema:
178
+ """A tool the source session had available.
179
+
180
+ Kept so a target harness can be told which tools the resumed session expects,
181
+ and so portability gaps (target lacks this tool) can be detected.
182
+ """
183
+
184
+ name: str
185
+ description: Optional[str] = None
186
+ parameters: Optional[dict[str, Any]] = None
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class PendingState:
191
+ """Half-finished state at the point the source session stopped.
192
+
193
+ - ``open_tool_calls``: call_ids issued with no matching tool_result.
194
+ - ``queued_user_messages``: user inputs enqueued but never processed
195
+ (Claude Code queue-operation enqueue with no dequeue+turn).
196
+ - ``active_goal``: a stated goal/todo still in flight, if the harness records one.
197
+ """
198
+
199
+ open_tool_calls: tuple[str, ...] = ()
200
+ queued_user_messages: tuple[str, ...] = ()
201
+ active_goal: Optional[str] = None
202
+
203
+ def is_empty(self) -> bool:
204
+ return not (self.open_tool_calls or self.queued_user_messages or self.active_goal)
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class SessionMeta:
209
+ source_harness: str
210
+ session_id: Optional[str] = None
211
+ cwd: Optional[str] = None
212
+ model: Optional[str] = None
213
+ model_provider: Optional[str] = None
214
+ system_instructions: Optional[str] = None
215
+ permission_mode: Optional[str] = None
216
+ version: Optional[str] = None
217
+ extra: dict[str, Any] = field(default_factory=dict)
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class Session:
222
+ """A fully normalized session, harness-agnostic."""
223
+
224
+ meta: SessionMeta
225
+ messages: tuple[Message, ...]
226
+ tools: tuple[ToolSchema, ...] = ()
227
+ pending: PendingState = field(default_factory=PendingState)
228
+
229
+ def with_messages(self, messages: tuple[Message, ...]) -> "Session":
230
+ return replace(self, messages=messages)
231
+
232
+
233
+ @dataclass
234
+ class ConversionReport:
235
+ """Records what a reader/writer could not represent losslessly.
236
+
237
+ Not frozen: accumulated during a conversion, then reported to the user.
238
+ """
239
+
240
+ warnings: list[str] = field(default_factory=list)
241
+
242
+ def warn(self, message: str) -> None:
243
+ self.warnings.append(message)
244
+
245
+ def ok(self) -> bool:
246
+ return not self.warnings
@@ -0,0 +1,98 @@
1
+ """Place a converted session where a harness's resume flow will find it.
2
+
3
+ Currently implemented for Claude Code, whose ``--resume <uuid>`` resolves a
4
+ session directly from ``~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`` when
5
+ ``claude`` is launched from the matching cwd. Placement therefore means:
6
+
7
+ 1. encode the target cwd the way Claude Code does (path separators -> ``-``),
8
+ 2. rewrite each record's ``sessionId`` and ``cwd`` to the chosen values,
9
+ 3. write ``<uuid>.jsonl`` into that directory.
10
+
11
+ Hermes is intentionally not supported here: it indexes sessions in a SQLite
12
+ store, so a file drop is not enough (see README / issue #1).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ from ._ids import validate_session_id
23
+
24
+
25
+ class UnsafeCwdError(ValueError):
26
+ """The target cwd cannot be encoded into a safe project-dir name."""
27
+
28
+
29
+ # encode_cwd collapses the whole cwd into ONE directory-name component (every
30
+ # ``/`` -> ``-``), so its length is bounded by the filesystem's per-component
31
+ # limit (255 on macOS/Linux). Guard well under that so mkdir raises a clean
32
+ # UnsafeCwdError instead of a raw OSError (ENAMETOOLONG) — matching how
33
+ # validate_session_id guards the id half of the same path.
34
+ _MAX_ENCODED_CWD_LEN = 200
35
+
36
+
37
+ def encode_cwd(cwd: str) -> str:
38
+ """Reproduce Claude Code's project-dir encoding: resolve the real path
39
+ (macOS symlinks such as /tmp -> /private/tmp matter) and replace every
40
+ path separator with ``-``."""
41
+ real = os.path.realpath(os.path.expanduser(cwd))
42
+ return real.replace("/", "-")
43
+
44
+
45
+ def claude_project_dir(cwd: str, claude_home: Optional[Path] = None) -> Path:
46
+ home = claude_home or Path(os.path.expanduser("~/.claude"))
47
+ return home / "projects" / encode_cwd(cwd)
48
+
49
+
50
+ class SessionExistsError(FileExistsError):
51
+ """A transcript already exists at the target session-id path."""
52
+
53
+
54
+ def place_claude_code(
55
+ records: list[dict[str, Any]],
56
+ cwd: str,
57
+ session_id: str,
58
+ *,
59
+ claude_home: Optional[Path] = None,
60
+ overwrite: bool = False,
61
+ ) -> Path:
62
+ """Write ``records`` as a resumable Claude Code session for ``cwd``.
63
+
64
+ Returns the transcript path. Rewrites ``sessionId``/``cwd`` on message
65
+ records so the transcript is internally consistent with where it lives.
66
+
67
+ Fails closed if a transcript already exists at the target path (a reused
68
+ session id) unless ``overwrite`` is set: silently clobbering a placed
69
+ transcript would destroy a possibly-precious recovered session, mirroring
70
+ the duplicate-id guard ``register_hermes_session`` already enforces.
71
+ """
72
+ validate_session_id(session_id) # reject path-traversal ids before touching the fs
73
+ encoded = encode_cwd(cwd)
74
+ if len(encoded) > _MAX_ENCODED_CWD_LEN:
75
+ raise UnsafeCwdError(
76
+ f"target cwd encodes to a {len(encoded)}-char directory name "
77
+ f"(max {_MAX_ENCODED_CWD_LEN}); use a shorter path"
78
+ )
79
+ directory = claude_project_dir(cwd, claude_home)
80
+ directory.mkdir(parents=True, exist_ok=True)
81
+ target = directory / f"{session_id}.jsonl"
82
+ if target.exists() and not overwrite:
83
+ raise SessionExistsError(
84
+ f"a session transcript already exists at {target}; "
85
+ f"choose a different --session-id or pass --force to overwrite"
86
+ )
87
+
88
+ real_cwd = os.path.realpath(os.path.expanduser(cwd))
89
+ lines: list[str] = []
90
+ for rec in records:
91
+ rec = dict(rec) # immutable-friendly copy
92
+ if rec.get("type") in ("user", "assistant"):
93
+ rec["sessionId"] = session_id
94
+ rec["cwd"] = real_cwd
95
+ lines.append(json.dumps(rec, ensure_ascii=False))
96
+
97
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
98
+ return target
File without changes
@@ -0,0 +1,42 @@
1
+ """Shared content-part normalization for readers.
2
+
3
+ Codex and Hermes both carry message ``content`` as either a plain string or a
4
+ list of typed parts (OpenAI multi-modal shape). Text parts become TEXT blocks;
5
+ any other part (image, etc.) becomes a RAW passthrough block rather than being
6
+ silently dropped — matching how the Claude Code reader already handles unknown
7
+ blocks, so all three readers preserve+report unrepresentable content uniformly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from ..ir import ContentBlock
15
+
16
+ # Part ``type`` values that carry plain text across Codex and Hermes.
17
+ _TEXT_PART_TYPES = {"input_text", "output_text", "text"}
18
+
19
+
20
+ def content_blocks(content: Any) -> tuple[ContentBlock, ...]:
21
+ """Normalize a message ``content`` (str or list of parts) to IR blocks.
22
+
23
+ A string yields one TEXT block (empty string yields no block). A list yields
24
+ a TEXT block per text part and a RAW block per non-text part, in order.
25
+ """
26
+ if isinstance(content, str):
27
+ return (ContentBlock.text_block(content),) if content else ()
28
+ if not isinstance(content, list):
29
+ return ()
30
+
31
+ blocks: list[ContentBlock] = []
32
+ for part in content:
33
+ if not isinstance(part, dict):
34
+ continue
35
+ ptype = part.get("type")
36
+ if ptype in _TEXT_PART_TYPES:
37
+ text = part.get("text", "")
38
+ if text:
39
+ blocks.append(ContentBlock.text_block(text))
40
+ elif ptype:
41
+ blocks.append(ContentBlock.raw(part, ptype))
42
+ return tuple(blocks)
@@ -0,0 +1,50 @@
1
+ """Shared, defensive JSONL loading for all readers.
2
+
3
+ Session logs are appended live by a running harness, so a real file can end in a
4
+ half-written final line (the process died mid-flush — exactly the situation this
5
+ tool exists to recover from) or contain a hand-edited/tool-mangled line. Loading
6
+ must therefore be tolerant: skip lines that are not a JSON object rather than
7
+ letting one bad line abort the whole read and lose every valid record before it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ def load_records(path: str | Path) -> list[dict[str, Any]]:
18
+ """Return the JSON-object records in a JSONL file.
19
+
20
+ Tolerates the one benign corruption a live-appended log actually produces: a
21
+ truncated/partial **final** line (the process died mid-flush). That last line
22
+ is skipped so every complete record before it survives.
23
+
24
+ A parse failure on an **interior** line is NOT benign — it means a real
25
+ record was corrupted and silently skipping it would drop a turn and orphan
26
+ the messages that referenced it. Interior parse failures re-raise rather than
27
+ vanish. Non-object JSON (list/str/number/null) on any line is not a session
28
+ record and is skipped quietly.
29
+ """
30
+ raw_lines = [ln.strip() for ln in open(path, encoding="utf-8")]
31
+ # Index of the last non-empty line: a parse failure there is a tail truncation.
32
+ last_content_idx = max((i for i, ln in enumerate(raw_lines) if ln), default=-1)
33
+
34
+ records: list[dict[str, Any]] = []
35
+ for i, line in enumerate(raw_lines):
36
+ if not line:
37
+ continue
38
+ try:
39
+ obj = json.loads(line)
40
+ except json.JSONDecodeError:
41
+ if i == last_content_idx:
42
+ # Truncated final line (mid-write crash): safe to drop.
43
+ continue
44
+ # Corrupted interior line: a real record is being lost. Fail loudly
45
+ # rather than silently returning a partial, orphan-linked session.
46
+ raise
47
+ if isinstance(obj, dict):
48
+ records.append(obj)
49
+ # Non-dict JSON is not a session record; skip.
50
+ return records
@@ -0,0 +1,63 @@
1
+ """Shared pending-state computation for all readers.
2
+
3
+ A tool call is "open" only if the source genuinely stopped mid-turn with that call
4
+ outstanding. Two real cases must both be handled, and they pull in opposite
5
+ directions on "does later activity mean abandoned?":
6
+
7
+ - OVERLAP (r22): call A issued, then call B issued, then B resolves and the session
8
+ ends with A never answered. A is genuinely open even though a *different* call
9
+ resolved after it — the session stopped with A outstanding.
10
+ - ABANDONED RETRY (r21): call A errored, was reissued, then the session moved on
11
+ and other calls resolved after it. A's retry was dropped; reporting it would tell
12
+ a resuming agent to blindly re-run stale work.
13
+ - INTERRUPTED RETRY (r22 case 7): call A resolved, reissued+resolved, reissued a
14
+ third time and the session stopped there. That final retry is genuinely open.
15
+
16
+ The rule that satisfies all three keys on *this* call_id's own history, not a
17
+ global index: a call is open iff its current (most-recent) issue is unresolved AND
18
+ either it was never resolved in any cycle (overlap/interrupted-turn) OR its
19
+ unresolved issue is at the tail with no later result for any call (interrupted
20
+ retry). A resolved-then-reissued call whose retry is followed by other resolved
21
+ activity is abandoned, not open.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from ..ir import BlockType, Message
27
+
28
+
29
+ def open_tool_calls(messages: tuple[Message, ...]) -> tuple[str, ...]:
30
+ """Return call_ids genuinely open at the point the session stopped.
31
+
32
+ See the module docstring for the three real cases this rule reconciles
33
+ (overlap, abandoned retry, interrupted retry). Insertion order preserved.
34
+ """
35
+ last_issue_idx: dict[str, int] = {} # call_id -> index of most recent issue
36
+ current_cycle_resolved: dict[str, bool] = {}
37
+ ever_resolved: set[str] = set()
38
+ last_result_idx = -1
39
+
40
+ for i, m in enumerate(messages):
41
+ for b in m.content:
42
+ if b.type is BlockType.TOOL_CALL and b.call_id:
43
+ last_issue_idx[b.call_id] = i
44
+ current_cycle_resolved[b.call_id] = False
45
+ elif b.type is BlockType.TOOL_RESULT and b.call_id:
46
+ last_result_idx = i
47
+ ever_resolved.add(b.call_id)
48
+ # Resolve the current cycle only if the result comes after the
49
+ # call's most recent issue (a result before a reissue doesn't
50
+ # resolve the reissue).
51
+ if b.call_id in last_issue_idx and i >= last_issue_idx[b.call_id]:
52
+ current_cycle_resolved[b.call_id] = True
53
+
54
+ return tuple(
55
+ cid
56
+ for cid, idx in last_issue_idx.items()
57
+ if not current_cycle_resolved[cid]
58
+ # Open iff the current cycle is unresolved AND either the call was never
59
+ # resolved at all (overlap / interrupted turn) or its unresolved issue is
60
+ # at the tail (interrupted retry). A resolved-then-reissued call whose
61
+ # retry is superseded by later resolved activity is abandoned, not open.
62
+ and (cid not in ever_resolved or idx > last_result_idx)
63
+ )