@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.
@@ -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"]