@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,367 +0,0 @@
1
- """Entry point for the Claude sidecar process.
2
-
3
- Speaks the frozen wire protocol documented in docs/claude-sidecar.md over
4
- stdin/stdout; protocol.py owns the envelope and framing. This file owns the
5
- hello gate, the SDK-presence check and the op dispatcher: session.py
6
- (T16-S02) owns ClaudeAgentOptions and ClaudeSDKClient and, as of T18, the
7
- three operations that drive a turn on it; events.py (T17) owns the event
8
- stream; T20 adds tool execution.
9
- """
10
-
11
- import sys
12
-
13
- # Must run before any other code. On Windows, stdout's default encoding is
14
- # not UTF-8, and its text-mode newline translation turns "\n" into "\r\n"
15
- # *inside* a frame; today's json parsing tolerates the stray "\r" as
16
- # insignificant whitespace, but relying on that is out of scope here.
17
- # Redirecting sys.stdout to stderr afterwards means one forgotten call to the
18
- # print builtin lands in diagnostics instead of corrupting the frame stream;
19
- # the real stdout stays reachable as _frames for the one thing that may still
20
- # write to it.
21
- _frames = sys.stdout
22
- _frames.reconfigure(encoding="utf-8", newline="\n")
23
- sys.stdout = sys.stderr
24
-
25
- EXIT_OK = 0
26
- EXIT_INTERNAL_ERROR = 1
27
- EXIT_PROTOCOL_ERROR = 2
28
- EXIT_ENVIRONMENT_ERROR = (
29
- 3 # "environment", not "protocol": no interpreter, no SDK, no CLI
30
- )
31
-
32
- # Before any other import: session.py imports claude_agent_sdk at module
33
- # level too, and an uncaught ImportError there would print a Python
34
- # traceback instead of the one readable diagnostic line T16-S03 requires.
35
- # Checking here, first, means the frame stream is never opened at all when
36
- # the SDK is missing - there is nothing left to open before sys.exit below
37
- # runs. The reason reaches Longshot through the stderr drain that already
38
- # exists for T16-S01 (that drain is why this write is enough; nothing here
39
- # needs to reach stdout).
40
- #
41
- # The exception text is carried, not swallowed: a sidecar module shadowing
42
- # one of the SDK's own dependencies raises ImportError here too, and without
43
- # the real cause it reads as an absent package (`mcp.py` was exactly that).
44
- try:
45
- import claude_agent_sdk # noqa: F401
46
- except ImportError as exc:
47
- sys.stderr.write(
48
- "claude sidecar: claude-agent-sdk is not installed; install it with "
49
- "`pip install claude-agent-sdk` (see sidecars/claude/README.md); "
50
- f"import failed with: {exc!r}\n"
51
- )
52
- sys.exit(EXIT_ENVIRONMENT_ERROR)
53
-
54
- import asyncio # noqa: E402 (see the ordering comment above)
55
- import importlib.metadata # noqa: E402
56
- import os # noqa: E402
57
- import shutil # noqa: E402
58
- import subprocess # noqa: E402
59
-
60
- from protocol import ( # noqa: E402
61
- PROTOCOL_VERSION,
62
- Code,
63
- FrameError,
64
- Op,
65
- read_frame,
66
- refusal,
67
- resolve,
68
- response,
69
- write_frame,
70
- )
71
- from session import Refusal, Session, check_permission_mode # noqa: E402
72
-
73
- # One event loop for the process lifetime, not one per call: the live
74
- # ClaudeSDKClient a session.start answers with stays bound to whichever loop
75
- # ran connect(), and session.read/session.close/session.close-on-EOF must
76
- # reach the same client later.
77
- _loop = asyncio.new_event_loop()
78
-
79
-
80
- def _run(coro):
81
- return _loop.run_until_complete(coro)
82
-
83
-
84
- _session = Session()
85
-
86
- # Every operation dispatched to session.Session: the five T16-S02/T22
87
- # session ones plus T18's three, which are session operations in the same
88
- # sense - each needs the one live client and the one stream state that
89
- # object already holds. Each handler takes the frame's params and returns
90
- # the result dict to answer with; a session.Refusal it raises is turned
91
- # into a wire refusal by main().
92
- _SESSION_HANDLERS = {
93
- Op.SESSION_START: lambda params: _run(_session.start(params)),
94
- Op.SESSION_RESUME: lambda params: _run(_session.resume(params)),
95
- Op.SESSION_READ: lambda params: _session.read(params.get("session_id", "")),
96
- Op.SESSION_HISTORY: lambda params: _run(_session.history(params)),
97
- Op.SESSION_CLOSE: lambda params: _run(_session.close(params.get("session_id", ""))),
98
- Op.INPUT_SUBMIT: lambda params: _run(_session.submit(params)),
99
- Op.TURN_INTERRUPT: lambda params: _run(
100
- _session.interrupt(params.get("session_id", ""))
101
- ),
102
- Op.MODEL_SET: lambda params: _run(_session.set_model(params)),
103
- }
104
-
105
- # The two ops that leave the session open and ready to accept input, and so
106
- # emit session.ready once each. The event is written here, immediately after
107
- # the op's own result frame, and never from inside the handler: the caller
108
- # learns the session's id from that result, so an event naming a session
109
- # Longshot has not been told about yet would be a frame out of order. The
110
- # SDK's `system`/`init` message is not the trigger - it does not arrive until
111
- # a query is sent, which would leave an open-but-unprompted session emitting
112
- # nothing at all (events.session_ready's own docstring).
113
- _READY_AFTER = {Op.SESSION_START, Op.SESSION_RESUME}
114
-
115
- # Every op Longshot may send that this build does not implement yet, and the
116
- # task that will. There is no "accept and stay silent": each answers
117
- # `internal`, naming its owner, per docs/claude-sidecar.md's refusal table.
118
- # The table is empty as of T18: permission.set left it when T21 landed, and
119
- # input.submit, turn.interrupt and model.set when this one did, so every op
120
- # in Op.KNOWN now has a handler above. It stays here, empty, because the
121
- # refusal at the bottom of the dispatch loop is what an op added to
122
- # Op.KNOWN without a handler answers - the alternative is a request that
123
- # gets no frame back at all and a caller that waits forever.
124
- _UNIMPLEMENTED_OWNERS: dict[str, str] = {}
125
-
126
-
127
- def _diag(message: str) -> None:
128
- """Write one diagnostic line to the real stderr. Never the print builtin
129
- (see the ordering comment above) and never protocol data."""
130
- sys.stderr.write(message)
131
- sys.stderr.write("\n")
132
- sys.stderr.flush()
133
-
134
-
135
- def disconnect_client() -> None:
136
- """Disconnects the live ClaudeSDKClient, if one was ever connected. The
137
- finally block below calls this unconditionally, on EOF as much as on a
138
- clean session.close: this is the point where the sidecar releases its
139
- `claude` grandchild (T16, "Отсутствие процесса-сироты")."""
140
- _run(_session.disconnect())
141
-
142
-
143
- # The one string reported when a version cannot be established. It is never
144
- # a refusal: docs/claude-sidecar.md requires both members of the hello result
145
- # to be present, and a handshake that failed because a version string could
146
- # not be read would trade a working session for a diagnostic.
147
- _UNKNOWN_VERSION = "unknown"
148
-
149
-
150
- def _sdk_version() -> str:
151
- """The Agent SDK version this sidecar drives. Free: the package is
152
- already imported by the preamble above, so this reads its installed
153
- metadata and spawns nothing."""
154
- try:
155
- return importlib.metadata.version("claude-agent-sdk")
156
- except importlib.metadata.PackageNotFoundError:
157
- return _UNKNOWN_VERSION
158
-
159
-
160
- def _cli_version() -> str:
161
- """The Claude CLI version this sidecar drives, read once at hello.
162
-
163
- `claude --version` starts no session and spends no inference, and it is
164
- the only way to learn the number: the SDK reports it nowhere, and
165
- get_server_info() is both session-scoped and silent about it. shutil.which
166
- rather than a bare name in the argv, because on Windows the CLI is a
167
- `.cmd` shim that CreateProcess would not find on its own.
168
- """
169
- exe = shutil.which("claude")
170
- if not exe:
171
- return _UNKNOWN_VERSION
172
- try:
173
- out = subprocess.run( # noqa: S603
174
- [exe, "--version"], capture_output=True, text=True, timeout=10, check=False
175
- )
176
- except (OSError, subprocess.SubprocessError):
177
- return _UNKNOWN_VERSION
178
- if out.returncode != 0:
179
- return _UNKNOWN_VERSION
180
- return out.stdout.strip() or _UNKNOWN_VERSION
181
-
182
-
183
- def _hello_result() -> dict:
184
- return {
185
- "protocol": PROTOCOL_VERSION,
186
- "sidecar": {"name": "claude-sidecar", "version": "0.1.0", "pid": os.getpid()},
187
- "cli_version": _cli_version(),
188
- "sdk_version": _sdk_version(),
189
- }
190
-
191
-
192
- def _id_of(frame: dict) -> int:
193
- frame_id = frame.get("id")
194
- return frame_id if isinstance(frame_id, int) else 0
195
-
196
-
197
- def main() -> int:
198
- greeted = False
199
- try:
200
- while True:
201
- try:
202
- # Under the loop, not on the bare main thread: this read is
203
- # where the sidecar spends every idle moment, and the process
204
- # has exactly one event loop, which only turns inside _run.
205
- # Reading straight from sys.stdin here suspended
206
- # events.run_stream's task for the whole wait, so no
207
- # message.delta, tool.started, error or turn.completed could
208
- # leave the process between operations - the frames were
209
- # produced and then sat in a task nobody was pumping.
210
- # asyncio.to_thread parks the blocking readline on a worker
211
- # thread while run_until_complete turns the loop, so the
212
- # stream task drains on its own. Only the read moves: the
213
- # dispatch below stays synchronous, every handler keeps its
214
- # own _run, and the writes here keep running on a main
215
- # thread that is by construction not inside the loop.
216
- frame = _run(asyncio.to_thread(read_frame, sys.stdin))
217
- except FrameError as exc:
218
- _diag(f"malformed frame: {exc}")
219
- return EXIT_PROTOCOL_ERROR
220
-
221
- if frame is None:
222
- # EOF is the one unconditional exit point of this loop: a
223
- # Longshot stopped any way at all, including a hard kill,
224
- # closes this process's stdin, and that is the whole
225
- # mechanism behind "no orphan sidecar" (T16).
226
- return EXIT_OK
227
-
228
- kind = frame.get("kind")
229
- op = frame.get("op")
230
- frame_id = _id_of(frame)
231
-
232
- if not greeted:
233
- if kind != "request" or op != Op.HELLO:
234
- write_frame(
235
- _frames,
236
- refusal(
237
- frame_id,
238
- Code.HELLO_REQUIRED,
239
- f"the sidecar channel received {op or ''!r} before completing the "
240
- "hello handshake; a sidecar channel must complete hello first",
241
- ),
242
- )
243
- return EXIT_PROTOCOL_ERROR
244
-
245
- params = frame.get("params") or {}
246
- if params.get("protocol") != PROTOCOL_VERSION:
247
- write_frame(
248
- _frames,
249
- refusal(
250
- frame_id,
251
- Code.UNSUPPORTED_VERSION,
252
- f"sidecar protocol mismatch: this sidecar speaks protocol "
253
- f"{PROTOCOL_VERSION}, longshot speaks protocol "
254
- f"{params.get('protocol')!r}. Reinstall the sidecar that ships with "
255
- "this longshot build, or run the longshot build that matches it.",
256
- ),
257
- )
258
- return EXIT_PROTOCOL_ERROR
259
-
260
- write_frame(_frames, response(frame_id, _hello_result()))
261
- greeted = True
262
- continue
263
-
264
- if kind == "request" and op == Op.HELLO:
265
- # Rule 3: a second hello is malformedRequest and does NOT end
266
- # the channel.
267
- write_frame(
268
- _frames,
269
- refusal(
270
- frame_id,
271
- Code.MALFORMED_REQUEST,
272
- "the sidecar received hello twice; hello is the first frame of a "
273
- "channel and is sent once",
274
- ),
275
- )
276
- continue
277
-
278
- if kind == "response":
279
- # An answer to one of THIS sidecar's own requests -
280
- # permission.request (T21) or tool.call (T20). One resolve()
281
- # serves both, over protocol.py's one pending registry:
282
- # routing them separately would be the second correlation
283
- # mechanism T20-S02 refused. Each side keeps its own id
284
- # counter (docs/claude-sidecar.md, "Correlation and
285
- # identifiers"), so this is never confused with a response to
286
- # a request Longshot sent. An id nobody is waiting on is
287
- # dropped with a diagnostic, not refused: the waiter may
288
- # already have given up (session ending), and there is no
289
- # request id to answer on this path anyway.
290
- if not resolve(frame):
291
- _diag(
292
- f"claude sidecar: response id={frame_id} matched no pending sidecar-initiated request"
293
- )
294
- continue
295
-
296
- if kind != "request":
297
- write_frame(
298
- _frames,
299
- refusal(
300
- frame_id,
301
- Code.MALFORMED_REQUEST,
302
- f"the sidecar channel received a frame of kind {kind!r}, which "
303
- "Longshot may not send",
304
- ),
305
- )
306
- continue
307
-
308
- if op not in Op.KNOWN:
309
- write_frame(
310
- _frames,
311
- refusal(
312
- frame_id,
313
- Code.UNKNOWN_OPERATION,
314
- f"{op!r} is not an operation either side of the sidecar channel knows",
315
- ),
316
- )
317
- continue
318
-
319
- params = frame.get("params") or {}
320
-
321
- if op in _SESSION_HANDLERS:
322
- try:
323
- result = _SESSION_HANDLERS[op](params)
324
- except Refusal as exc:
325
- write_frame(_frames, refusal(frame_id, exc.code, exc.message))
326
- continue
327
- write_frame(_frames, response(frame_id, result))
328
- if op in _READY_AFTER:
329
- write_frame(_frames, _session.ready_event())
330
- continue
331
-
332
- if op == Op.PERMISSION_SET:
333
- # T15 requires the permission_mode refusal here too, ahead of
334
- # the actual mode change: the frame carries permission_mode,
335
- # and I08 must hold on this side of the channel even though
336
- # the other side (internal/backend/claude/settings.go)
337
- # checks it as well - a check on one side is a check a
338
- # refactor removes.
339
- try:
340
- check_permission_mode(params.get("permission_mode") or "")
341
- result = _run(
342
- _session.set_permission_mode(
343
- params.get("session_id", ""),
344
- params["permission_mode"],
345
- )
346
- )
347
- except Refusal as exc:
348
- write_frame(_frames, refusal(frame_id, exc.code, exc.message))
349
- continue
350
- write_frame(_frames, response(frame_id, result))
351
- continue
352
-
353
- owner = _UNIMPLEMENTED_OWNERS.get(op, "a later task")
354
- write_frame(
355
- _frames,
356
- refusal(
357
- frame_id,
358
- Code.INTERNAL,
359
- f"{op!r} is not implemented by this sidecar build yet; {owner} owns it",
360
- ),
361
- )
362
- finally:
363
- disconnect_client()
364
-
365
-
366
- if __name__ == "__main__":
367
- sys.exit(main())
@@ -1,96 +0,0 @@
1
- """Builds the in-process "longshot" MCP server ClaudeAgentOptions.mcp_servers
2
- takes for one session, from the tool definitions session.start carries on
3
- its additive `tools` field (T20-S01, docs/claude-sidecar.md's `session.start`
4
- params table).
5
-
6
- I12 is the whole shape of this module: the sidecar owns nothing here. Every
7
- declared tool gets the exact same forwarding handler (_forward) — there is
8
- no branch on a tool's name, no local execution of any tool, no cached
9
- result, and no timeout of this module's own invention: a `tool.call` request
10
- waits for its reply as long as it takes, by protocol v1 (T15's decision, not
11
- this slice's — see docs/claude-sidecar.md, "Sidecar to Longshot: requests").
12
- Description and input schema travel exactly as the definition carried them:
13
- that dictionary belongs to the Go tool registry (`T20`'s projection), not to
14
- this module.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from collections.abc import Awaitable, Callable
20
- from typing import Any
21
-
22
- # Sends one tool.call request (docs/claude-sidecar.md, "Sidecar to Longshot:
23
- # requests" -> "tool.call") and returns its result, {"content", "is_error"},
24
- # once the matching response arrives, keyed by tool_name and arguments alone.
25
- # session_id, turn_id and tool_id are ambient to whichever call_tool this
26
- # module is handed (one sidecar serves one session, I03) — the request/
27
- # response correlation by id belongs to protocol.py, not reinvented here.
28
- CallTool = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
29
-
30
- # Claude adds this prefix itself, from the MCP server name ("longshot"): a
31
- # definition arrives as "mcp__longshot__<tool>" and registers on the server
32
- # under "<tool>" alone.
33
- _MCP_PREFIX = "mcp__longshot__"
34
-
35
-
36
- def _bare_name(full_name: str) -> str:
37
- """The name a tool registers under on the "longshot" MCP server."""
38
- return full_name.removeprefix(_MCP_PREFIX)
39
-
40
-
41
- async def _forward(full_name: str, arguments: dict, call_tool: CallTool) -> dict:
42
- """Send one tool.call request and return its result. No branching on
43
- full_name, no local fallback, no timeout of its own: the request has no
44
- deadline by protocol v1, and that is T15's decision, not this slice's.
45
- """
46
- result = await call_tool(full_name, arguments)
47
- return {
48
- "content": [{"type": "text", "text": result.get("content", "")}],
49
- "is_error": bool(result.get("is_error", False)),
50
- }
51
-
52
-
53
- def _build_tool_specs(tool_definitions: list[dict], call_tool: CallTool) -> list[dict]:
54
- """One spec per definition, every one sharing _forward as its handler,
55
- bound to that definition's own full name (never re-derived, so a name
56
- that does not start with the expected prefix still forwards under its
57
- real full name). Kept free of any claude-agent-sdk import so this
58
- composition step — the whole of what this module decides — stays
59
- testable without the SDK installed; build_longshot_server below is the
60
- thin, SDK-facing wrapper around it.
61
- """
62
- specs = []
63
- for definition in tool_definitions:
64
- full_name = definition["name"]
65
-
66
- async def handler(arguments: dict, _full_name: str = full_name) -> dict:
67
- return await _forward(_full_name, arguments, call_tool)
68
-
69
- specs.append(
70
- {
71
- "name": _bare_name(full_name),
72
- "description": definition["description"],
73
- "input_schema": definition["input_schema"],
74
- "handler": handler,
75
- }
76
- )
77
- return specs
78
-
79
-
80
- def build_longshot_server(tool_definitions: list[dict], call_tool: CallTool) -> object:
81
- """Build the in-process "longshot" MCP server from the definitions that
82
- arrived on session.start. Every tool gets the same forwarding handler:
83
- this process implements none of them (I12). An empty tool_definitions is
84
- a normal outcome: the returned server simply declares no tools.
85
- """
86
- from claude_agent_sdk import create_sdk_mcp_server
87
- from claude_agent_sdk import tool as sdk_tool
88
-
89
- specs = _build_tool_specs(tool_definitions, call_tool)
90
- sdk_tools = [
91
- sdk_tool(spec["name"], spec["description"], spec["input_schema"])(
92
- spec["handler"]
93
- )
94
- for spec in specs
95
- ]
96
- return create_sdk_mcp_server("longshot", tools=sdk_tools)
@@ -1,89 +0,0 @@
1
- """T21: the sidecar's half of the normalised permission.request round trip.
2
-
3
- The sidecar carries no file-access level table
4
- (docs/tasks/runtime/claude-adapter/tasks/T21-claude-permissions.md,
5
- "Отображение уровней": "Таблицы уровней в Python нет и быть не должно").
6
- Every can_use_tool invocation is forwarded to Longshot as one
7
- permission.request frame and answered by whatever `behavior` Longshot's
8
- decide() sends back - this module never decides anything itself, it only
9
- carries the question and translates the answer.
10
-
11
- Two things live here, split so the first is testable without
12
- claude-agent-sdk installed at all (the same property events.py's
13
- map_message keeps, per its own module docstring):
14
-
15
- - the permission.request round trip (request_permission, and resolve
16
- re-exported beside it): the correlation itself is protocol.request /
17
- protocol.resolve, one registry shared with tool.call, so this module only
18
- names the op and lays out its params;
19
- - make_can_use_tool, which wraps that round trip into the callback shape
20
- ClaudeAgentOptions.can_use_tool expects and translates the wire's
21
- `behavior`/`message` into the SDK's own PermissionResultAllow /
22
- PermissionResultDeny. updated_input is never sent back on the wire and
23
- never read from the SDK side either: nobody rewrites a tool call's
24
- arguments (T21, "Нормализованный запрос разрешения").
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- from collections.abc import Callable
30
- from typing import Any
31
-
32
- # resolve is re-exported, not redefined: it is half of the primitive this
33
- # module's docstring names, and it now lives in protocol.py so tool.call
34
- # shares the one registry with it. Importing it here keeps the name this
35
- # module has always offered pointing at that one function.
36
- from protocol import Emit, request, resolve # noqa: F401
37
-
38
-
39
- async def request_permission(
40
- emit: Emit, session_id: str, turn_id: str, tool_name: str, tool_input: Any
41
- ) -> dict:
42
- """Sends one permission.request frame and awaits Longshot's decision.
43
-
44
- Longshot answers immediately and always (T21, "Правило отказа по
45
- умолчанию": no branch of the rule asks and no branch waits), so the only
46
- way this coroutine does not resolve is the channel going away entirely -
47
- the same fate as any other in-flight request when that happens, and
48
- protocol.fail_pending is where that fate is dealt out.
49
- """
50
- return await request(
51
- emit,
52
- "permission.request",
53
- {
54
- "session_id": session_id,
55
- "turn_id": turn_id,
56
- "tool_name": tool_name,
57
- "input": tool_input,
58
- },
59
- )
60
-
61
-
62
- def make_can_use_tool(emit: Emit, session_id: str, turn_id_of: Callable[[], str]):
63
- """Builds the can_use_tool callback ClaudeAgentOptions takes for one
64
- session. turn_id_of is called fresh on every invocation - never captured
65
- once - so the frame always names the turn actually in flight; a
66
- ToolPermissionContext carries no turn id of its own to read instead.
67
-
68
- claude_agent_sdk's own result types are imported inside the returned
69
- callback, not at module level: everything above this function is
70
- testable without the package installed at all, and this is the one place
71
- in the module that actually needs it.
72
- """
73
-
74
- async def handle_permission(tool_name: str, tool_input: Any, context: Any) -> Any:
75
- from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
76
-
77
- result = await request_permission(
78
- emit, session_id, turn_id_of(), tool_name, tool_input
79
- )
80
- if result.get("behavior") == "allow":
81
- return PermissionResultAllow()
82
- # interrupt is left at its default False: a denied tool call ends
83
- # that one call, not the turn it is inside (T21, "Отказ в разрешении
84
- # посреди активного turn: turn продолжается").
85
- return PermissionResultDeny(
86
- message=result.get("message") or f"{tool_name} was refused"
87
- )
88
-
89
- return handle_permission