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
|
@@ -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
|
session_bridge/place.py
ADDED
|
@@ -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
|
+
)
|