@longshot-harness/longshot-linux-x64 0.1.0-beta.1 → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +201 -201
- package/longshot +0 -0
- package/package.json +5 -2
- package/sidecars/claude/events.py +0 -457
- package/sidecars/claude/history.py +0 -133
- package/sidecars/claude/main.py +0 -367
- package/sidecars/claude/mcpserver.py +0 -96
- package/sidecars/claude/permissions.py +0 -89
- package/sidecars/claude/protocol.py +0 -217
- package/sidecars/claude/pyproject.toml +0 -21
- package/sidecars/claude/session.py +0 -855
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
"""The sidecar wire protocol: envelope, NDJSON framing and correlation.
|
|
2
|
-
|
|
3
|
-
This is the Python half of the contract frozen in
|
|
4
|
-
internal/backend/claude/protocol.go and documented in full in
|
|
5
|
-
docs/claude-sidecar.md. It defines the shape of a frame, and — at the
|
|
6
|
-
bottom of this file — the one request/response correlation registry every
|
|
7
|
-
request this side initiates goes through. The hello gate and the op
|
|
8
|
-
dispatcher live in main.py.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import asyncio
|
|
14
|
-
import json
|
|
15
|
-
from collections.abc import Callable
|
|
16
|
-
|
|
17
|
-
# 2 adds one event, message.completed: the doc's bump rule is that adding a
|
|
18
|
-
# message bumps, because an unknown event kind is refused rather than ignored
|
|
19
|
-
# (docs/claude-sidecar.md, "Versioning and the handshake").
|
|
20
|
-
PROTOCOL_VERSION: int = 2
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class FrameError(Exception):
|
|
24
|
-
"""Raised by read_frame for a line that is not a well-formed frame.
|
|
25
|
-
|
|
26
|
-
Never raised for EOF — that is read_frame returning None, the one
|
|
27
|
-
unconditional exit signal main.py's loop acts on.
|
|
28
|
-
"""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class Code:
|
|
32
|
-
"""Refusal codes, verbatim from protocol.go's Code registry."""
|
|
33
|
-
|
|
34
|
-
HELLO_REQUIRED = "helloRequired"
|
|
35
|
-
UNSUPPORTED_VERSION = "unsupportedVersion"
|
|
36
|
-
MALFORMED_REQUEST = "malformedRequest"
|
|
37
|
-
UNKNOWN_OPERATION = "unknownOperation"
|
|
38
|
-
UNKNOWN_SESSION = "unknownSession"
|
|
39
|
-
NO_ACTIVE_TURN = "noActiveTurn"
|
|
40
|
-
UNSUPPORTED_MODE = "unsupportedMode"
|
|
41
|
-
FORBIDDEN_VALUE = "forbiddenValue"
|
|
42
|
-
INTERNAL = "internal"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class Op:
|
|
46
|
-
"""Operations Longshot may send, verbatim from protocol.go's HostOps.
|
|
47
|
-
|
|
48
|
-
tool.call and permission.request are the sidecar's own two
|
|
49
|
-
sidecar-to-Longshot requests (protocol.go's SidecarOps); they are not
|
|
50
|
-
listed here because main.py's dispatcher only classifies frames arriving
|
|
51
|
-
*from* Longshot.
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
HELLO = "hello"
|
|
55
|
-
SESSION_START = "session.start"
|
|
56
|
-
SESSION_RESUME = "session.resume"
|
|
57
|
-
SESSION_READ = "session.read"
|
|
58
|
-
SESSION_HISTORY = "session.history"
|
|
59
|
-
INPUT_SUBMIT = "input.submit"
|
|
60
|
-
TURN_INTERRUPT = "turn.interrupt"
|
|
61
|
-
MODEL_SET = "model.set"
|
|
62
|
-
PERMISSION_SET = "permission.set"
|
|
63
|
-
SESSION_CLOSE = "session.close"
|
|
64
|
-
|
|
65
|
-
KNOWN = frozenset(
|
|
66
|
-
(
|
|
67
|
-
HELLO,
|
|
68
|
-
SESSION_START,
|
|
69
|
-
SESSION_RESUME,
|
|
70
|
-
SESSION_READ,
|
|
71
|
-
SESSION_HISTORY,
|
|
72
|
-
INPUT_SUBMIT,
|
|
73
|
-
TURN_INTERRUPT,
|
|
74
|
-
MODEL_SET,
|
|
75
|
-
PERMISSION_SET,
|
|
76
|
-
SESSION_CLOSE,
|
|
77
|
-
)
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def read_frame(stream) -> dict | None:
|
|
82
|
-
"""Read one NDJSON frame from stream, or None on EOF.
|
|
83
|
-
|
|
84
|
-
A line that is not valid JSON, or that decodes to something other than a
|
|
85
|
-
JSON object, raises FrameError rather than being skipped: skipping an
|
|
86
|
-
unrecognised line would let the second half of a split frame pass as
|
|
87
|
-
valid (docs/claude-sidecar.md, "Framing").
|
|
88
|
-
"""
|
|
89
|
-
line = stream.readline()
|
|
90
|
-
if line == "":
|
|
91
|
-
return None
|
|
92
|
-
try:
|
|
93
|
-
frame = json.loads(line)
|
|
94
|
-
except ValueError as exc:
|
|
95
|
-
raise FrameError(f"the line did not parse as JSON: {exc}") from exc
|
|
96
|
-
if not isinstance(frame, dict):
|
|
97
|
-
raise FrameError("the line did not decode to a JSON object")
|
|
98
|
-
return frame
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def write_frame(stream, frame: dict) -> None:
|
|
102
|
-
"""Write one frame as a single compact, newline-terminated JSON line.
|
|
103
|
-
|
|
104
|
-
separators=(",", ":") and ensure_ascii=False match the Go encoder in
|
|
105
|
-
codec.go: payloads carry prompts and diffs whose non-ASCII characters
|
|
106
|
-
must round-trip verbatim, and every frame is exactly one line.
|
|
107
|
-
"""
|
|
108
|
-
stream.write(json.dumps(frame, separators=(",", ":"), ensure_ascii=False))
|
|
109
|
-
stream.write("\n")
|
|
110
|
-
stream.flush()
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def response(frame_id: int, result: dict) -> dict:
|
|
114
|
-
"""Build a success response. result is never omitted: an empty result is
|
|
115
|
-
{}, matching NewResponse's rule on the Go side."""
|
|
116
|
-
return {
|
|
117
|
-
"kind": "response",
|
|
118
|
-
"id": frame_id,
|
|
119
|
-
"result": result if result is not None else {},
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def refusal(frame_id: int, code: str, message: str) -> dict:
|
|
124
|
-
"""Build a refusal response. message is a whole sentence."""
|
|
125
|
-
return {
|
|
126
|
-
"kind": "response",
|
|
127
|
-
"id": frame_id,
|
|
128
|
-
"error": {"code": code, "message": message},
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def event(name: str, data: dict) -> dict:
|
|
133
|
-
"""Build an event frame. Has no id: docs/claude-sidecar.md, "Envelope"."""
|
|
134
|
-
return {"kind": "event", "event": name, "data": data if data is not None else {}}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# ---- Sidecar to Longshot: request/response correlation.
|
|
138
|
-
#
|
|
139
|
-
# Both requests this side initiates - permission.request (T21) and tool.call
|
|
140
|
-
# (T20) - share one id counter, one pending registry and one resolve(), and
|
|
141
|
-
# that sharing is the whole reason the primitive sits here instead of in
|
|
142
|
-
# either caller: T20-S02 refused a second correlation mechanism outright
|
|
143
|
-
# ("второго механизма корреляции здесь не появляется"), and two counters or
|
|
144
|
-
# two registries would be exactly that. main.py's kind:"response" route
|
|
145
|
-
# calls resolve() once, for both.
|
|
146
|
-
#
|
|
147
|
-
# The counter is this side's own: each side of the channel numbers its own
|
|
148
|
-
# requests, unique as (initiator, id) (docs/claude-sidecar.md, "Correlation
|
|
149
|
-
# and identifiers"), so nothing here ever reads or reuses a Longshot request
|
|
150
|
-
# id.
|
|
151
|
-
|
|
152
|
-
Emit = Callable[[dict], None]
|
|
153
|
-
|
|
154
|
-
_pending: dict[int, asyncio.Future[dict]] = {}
|
|
155
|
-
_next_id = 0
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _mint_id() -> int:
|
|
159
|
-
global _next_id
|
|
160
|
-
_next_id += 1
|
|
161
|
-
return _next_id
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def resolve(frame: dict) -> bool:
|
|
165
|
-
"""Routes one incoming kind:response frame to whichever sidecar-initiated
|
|
166
|
-
request is waiting on its id. Returns False when nobody is waiting: the
|
|
167
|
-
id belongs to something else, or the waiter already gave up (the pending
|
|
168
|
-
entry is removed the moment request() returns, win or lose)."""
|
|
169
|
-
future = _pending.get(frame.get("id"))
|
|
170
|
-
if future is None or future.done():
|
|
171
|
-
return False
|
|
172
|
-
error = frame.get("error")
|
|
173
|
-
if error is not None:
|
|
174
|
-
future.set_exception(RuntimeError(error.get("message", "")))
|
|
175
|
-
else:
|
|
176
|
-
future.set_result(frame.get("result") or {})
|
|
177
|
-
return True
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
async def request(emit: Emit, op: str, params: dict) -> dict:
|
|
181
|
-
"""Sends one sidecar-initiated request and awaits its correlated result.
|
|
182
|
-
|
|
183
|
-
There is no deadline, and none is added here: the protocol freezes
|
|
184
|
-
"there is no protocol-level timeout on either" of the two sidecar
|
|
185
|
-
requests (docs/claude-sidecar.md, "Sidecar to Longshot: requests"), and
|
|
186
|
-
each is already bounded by the peer - permission.request waits for an
|
|
187
|
-
operator, which is work rather than a hang, and tool.call is answered by
|
|
188
|
-
tools.Dispatcher, which bounds every call at
|
|
189
|
-
tools.DefaultDispatchTimeout and never fails to answer
|
|
190
|
-
(internal/tools/dispatcher.go). The bound this side does keep is channel
|
|
191
|
-
life: fail_pending below resolves everything still outstanding when the
|
|
192
|
-
session goes away, so no waiter outlives the channel it is waiting on.
|
|
193
|
-
"""
|
|
194
|
-
frame_id = _mint_id()
|
|
195
|
-
future: asyncio.Future[dict] = asyncio.get_event_loop().create_future()
|
|
196
|
-
_pending[frame_id] = future
|
|
197
|
-
try:
|
|
198
|
-
emit({"kind": "request", "id": frame_id, "op": op, "params": params})
|
|
199
|
-
return await future
|
|
200
|
-
finally:
|
|
201
|
-
_pending.pop(frame_id, None)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def fail_pending(reason: str) -> None:
|
|
205
|
-
"""Fails every request still outstanding, with reason as the message.
|
|
206
|
-
|
|
207
|
-
Called when the channel this side is waiting on goes away
|
|
208
|
-
(session.disconnect, which session.close and main.py's EOF path both
|
|
209
|
-
reach unconditionally). A waiter that outlived its channel would wait
|
|
210
|
-
forever, and for tool.call that means a turn that never ends; raising
|
|
211
|
-
instead reaches the model as a readable tool result, because the SDK
|
|
212
|
-
turns a handler exception into an isError result rather than a protocol
|
|
213
|
-
error (claude_agent_sdk's create_sdk_mcp_server, run_tool).
|
|
214
|
-
"""
|
|
215
|
-
for future in list(_pending.values()):
|
|
216
|
-
if not future.done():
|
|
217
|
-
future.set_exception(RuntimeError(reason))
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Lint and format configuration for the Claude sidecar. The sidecar is the
|
|
2
|
-
# only Python in this repository, so the configuration lives next to it
|
|
3
|
-
# rather than at the repository root, which is Go.
|
|
4
|
-
#
|
|
5
|
-
# ruff check sidecars/claude --fix
|
|
6
|
-
# black sidecars/claude
|
|
7
|
-
|
|
8
|
-
[tool.black]
|
|
9
|
-
line-length = 88
|
|
10
|
-
target-version = ["py310"]
|
|
11
|
-
|
|
12
|
-
[tool.ruff]
|
|
13
|
-
line-length = 88
|
|
14
|
-
# The floor the sidecar README states: Python 3.10+.
|
|
15
|
-
target-version = "py310"
|
|
16
|
-
|
|
17
|
-
[tool.ruff.lint]
|
|
18
|
-
select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RET"]
|
|
19
|
-
# black owns line length; E501 would only duplicate it and disagree at the
|
|
20
|
-
# margins black deliberately leaves long (URLs, long string literals).
|
|
21
|
-
ignore = ["E501"]
|