@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,855 +0,0 @@
1
- """The one live Claude session this sidecar serves.
2
-
3
- Builds ClaudeAgentOptions and drives ClaudeSDKClient for the four
4
- session-lifecycle operations main.py dispatches here: session.start,
5
- session.resume, session.read, session.close. session.start and
6
- session.resume connect the client and answer from what they already hold,
7
- never from a `query()`; the one `query()` in this module is submit's, below.
8
- What T17-S01 adds is the one persistent
9
- receive_messages() task events.py owns: start() and resume() launch it right
10
- after connect() succeeds, and disconnect() stops it before the client itself
11
- disconnects (_start_stream, _stop_stream below) — this is the exact one point
12
- T17-S01's allowed zone grants in this file.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import asyncio
18
- import contextlib
19
- import sys
20
- import uuid
21
- from collections.abc import Callable
22
- from typing import Any
23
-
24
- from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, get_session_messages
25
-
26
- from events import StreamState, begin_turn, run_stream, session_ready
27
- from history import serialize_message
28
- from mcpserver import CallTool, build_longshot_server
29
- from permissions import make_can_use_tool
30
- from protocol import Code, Emit, fail_pending, request, write_frame
31
-
32
- # The one permission_mode value both sides of the sidecar channel refuse: it
33
- # is the mode of an unattended, isolated run with nobody to answer a
34
- # permission prompt (internal/runners/claude/claude.go:119) and must never
35
- # reach the coordinator's own session (I08).
36
- FORBIDDEN_PERMISSION_MODE = "bypassPermissions"
37
-
38
- # Copied verbatim from internal/backend/claude/testdata/error_forbiddenValue.json
39
- # (T15-S02, step 4, "Пустое и запрещённое значение"): T16 and T21 quote this
40
- # sentence rather than invent their own.
41
- _FORBIDDEN_PERMISSION_MODE_MESSAGE = (
42
- 'permission_mode "{mode}" is forbidden on the coordinator session: it is '
43
- "the mode of a worker in an isolated working directory and is not "
44
- "carried over to the coordinator"
45
- )
46
-
47
- # The permission_mode vocabulary of the vendor CLI this sidecar drives, and
48
- # the one place it is written down: the wire carries Claude's own spelling,
49
- # not a Longshot file-access level (docs/claude-sidecar.md, "session.start"),
50
- # so a mode that never went through the adapter's level->mode table
51
- # (internal/backend/claude/permissions.go) reaches the CLI verbatim and kills
52
- # it - `claude --permission-mode read-only` exits with "argument 'read-only'
53
- # is invalid". Refusing it here turns that dead channel into one refused
54
- # request.
55
- #
56
- # The first six are the choices the CLI itself names in that error message.
57
- # "default" is the seventh: the CLI accepts it (verified against
58
- # `claude --permission-mode default --version`, CLI 2.1.263) and the SDK's
59
- # own PermissionMode literal lists it, it is simply absent from the error's
60
- # choice list. bypassPermissions is a member because this is the CLI's
61
- # vocabulary, not Longshot's policy - check_permission_mode refuses it above
62
- # this check, for its own reason.
63
- ALLOWED_PERMISSION_MODES = (
64
- "acceptEdits",
65
- "auto",
66
- "bypassPermissions",
67
- "manual",
68
- "dontAsk",
69
- "plan",
70
- "default",
71
- )
72
-
73
- # The two input.submit modes this sidecar serves. Both map to the same
74
- # `query()` call: the distinction is for logs and refusal, not a different
75
- # operation (docs/claude-sidecar.md, "input.submit"). Which of the two is
76
- # legal against a session with a turn already running is the adapter's
77
- # question, not this module's — internal/backend/claude/submit.go refuses
78
- # mode turn against an active session with ErrSessionBusy, and a second copy
79
- # of that guard here would be a second place to keep it right.
80
- SERVED_SUBMIT_MODES = ("turn", "steer")
81
-
82
- # The two modes the wire declares and this sidecar refuses, each with the
83
- # reason it is refused. Kept as data, one entry per mode, so a refusal names
84
- # why rather than only what: a mode this side merely has not been taught
85
- # reads the same as one it must never serve, and these two must never serve.
86
- _UNSERVED_SUBMIT_MODES = {
87
- "next-turn": (
88
- "next-turn is served by Longshot's own mailbox, not by this sidecar; accepting it "
89
- "here would mean holding a queue of pending input, which is state this side must "
90
- "not hold (I12)"
91
- ),
92
- "external-wake": (
93
- "external-wake needs a channel notification, which this vendor accepts but never "
94
- "delivers into the session (F01, measured-constraints.md section 4), so there is "
95
- "nothing this side could serve honestly"
96
- ),
97
- }
98
-
99
-
100
- class Refusal(Exception):
101
- """A protocol refusal raised by this module. main.py's dispatcher turns
102
- it into a `{code, message}` response; nothing in this module writes to
103
- the wire directly, so it stays testable without a codec or a socket."""
104
-
105
- def __init__(self, code: str, message: str) -> None:
106
- super().__init__(message)
107
- self.code = code
108
- self.message = message
109
-
110
-
111
- def make_call_tool(
112
- emit: Emit,
113
- session_id: str,
114
- turn_id_of: Callable[[], str],
115
- tool_id_of: Callable[[str], str],
116
- ) -> CallTool:
117
- """Builds the `call_tool` this build hands to build_longshot_server.
118
-
119
- A real round trip now: one `tool.call` request out, and the awaited
120
- result of the matching `kind: "response"` back, over the one
121
- correlation registry protocol.py owns and permission.request shares.
122
- Nothing is translated on the way back — the wire's result is
123
- `{content, is_error}` and that is exactly what mcpserver._forward
124
- reads, so a Longshot-side tool failure arrives as a tool result the
125
- model can read (`is_error: true` with the failure text as `content`)
126
- rather than as an exception (docs/claude-sidecar.md, "tool.call").
127
-
128
- A factory rather than a module-level function, for the same reason
129
- make_can_use_tool is one: the frame names the session and the turn, and
130
- those belong to a Session instance. turn_id_of and tool_id_of are
131
- called fresh per call, never captured once, so a second turn's call
132
- never carries the first turn's ids.
133
- """
134
-
135
- async def call_tool(tool_name: str, arguments: dict) -> dict:
136
- return await request(
137
- emit,
138
- "tool.call",
139
- {
140
- "session_id": session_id,
141
- "turn_id": turn_id_of(),
142
- "tool_id": tool_id_of(tool_name),
143
- "tool_name": tool_name,
144
- "arguments": arguments,
145
- },
146
- )
147
-
148
- return call_tool
149
-
150
-
151
- def check_permission_mode(mode: str) -> None:
152
- """Enforces I08 for any frame that carries permission_mode:
153
- session.start, session.resume and permission.set (T15's three points).
154
- Two different facts, two different codes
155
- (docs/claude-sidecar.md, "Empty versus forbidden value"): an empty mode
156
- is a required member left unfilled, not a default; bypassPermissions is
157
- a well-formed, meaningful value this side refuses by policy, not a value
158
- it fails to understand.
159
-
160
- A third fact, and a third code: a mode outside the vendor CLI's own
161
- vocabulary (ALLOWED_PERMISSION_MODES) is `unsupportedMode` - "this side
162
- cannot", which is exactly the reading the pair of codes above is reserved
163
- against (docs/claude-sidecar.md, "Empty versus forbidden value":
164
- bypassPermissions is refused because the sidecar can and must not).
165
- Passing such a mode through instead reaches the CLI verbatim and ends the
166
- process, and a dead channel says less than a refusal naming the value.
167
-
168
- One guard in one place, not three copies at three call sites: T21 calls
169
- this unchanged when it adds the permission.set handler.
170
- """
171
- if not mode:
172
- raise Refusal(
173
- Code.MALFORMED_REQUEST,
174
- "a non-empty permission_mode is required; the frame carried none",
175
- )
176
- if mode == FORBIDDEN_PERMISSION_MODE:
177
- raise Refusal(
178
- Code.FORBIDDEN_VALUE, _FORBIDDEN_PERMISSION_MODE_MESSAGE.format(mode=mode)
179
- )
180
- if mode not in ALLOWED_PERMISSION_MODES:
181
- raise Refusal(
182
- Code.UNSUPPORTED_MODE,
183
- f"permission_mode {mode!r} is not a mode the Claude CLI accepts; it accepts "
184
- f"one of {', '.join(ALLOWED_PERMISSION_MODES)}",
185
- )
186
-
187
-
188
- def build_options(
189
- params: dict,
190
- resume_native_id: str | None = None,
191
- session_id: str | None = None,
192
- **extra: Any,
193
- ) -> ClaudeAgentOptions:
194
- """Builds ClaudeAgentOptions from session.start/session.resume params.
195
-
196
- - The partial-messages flag is set unconditionally, True, on every call.
197
- Nothing in T16 reads it, and that is not prematurity: the flag belongs
198
- to how the session is constructed, not to how it is read, and adding
199
- it later would mean reopening a session T17 is already serving.
200
- - `resume=resume_native_id` only when resume_native_id is given, never
201
- as an empty-string default.
202
- - `session_id=session_id` only on session.start, and never together with
203
- `resume`: it is the CLI's `--session-id`, which names the session being
204
- created, while `--resume` names one that already exists.
205
- - The settings-sources option is set to an empty list, cutting off the
206
- operator's own hooks, permissions and MCP servers
207
- (`~/.claude/settings.json`): the SDK's equivalent of the runner's
208
- `--setting-sources ""` (internal/runners/claude/claude.go:231-235).
209
- Those sources are not the coordinator's to inherit.
210
- - The tool-projection and permission-callback options are not hardcoded
211
- in the kwargs dict below: T20 and T21 layer them in through **extra
212
- from the call site instead, because both are session-specific (a
213
- can_use_tool bound to one session's id and emit function, an mcp
214
- endpoint per session) and this function is the one place
215
- session.start and session.resume already agree on for what every
216
- session needs regardless of which one it is.
217
- - This module never turns session persistence off, in any form: without
218
- a persisted session there is no native_id, and without native_id
219
- there is no session.resume.
220
- """
221
- permission_mode = params.get("permission_mode") or ""
222
- check_permission_mode(permission_mode)
223
-
224
- kwargs: dict = {
225
- "cwd": params.get("cwd") or None,
226
- "model": params.get("model") or None,
227
- "effort": params.get("effort") or None,
228
- "system_prompt": params.get("system_prompt") or None,
229
- "permission_mode": permission_mode,
230
- "include_partial_messages": True,
231
- "setting_sources": [],
232
- # withheld_tools becomes disallowed_tools (claude_agent_sdk 0.2.160:
233
- # --disallowedTools). Read here rather than via **extra: it is a plain
234
- # session parameter like model and system_prompt, not a closure bound
235
- # to one session, and session.start and session.resume both need it.
236
- "disallowed_tools": list(params.get("withheld_tools") or []),
237
- **extra,
238
- }
239
- if resume_native_id:
240
- kwargs["resume"] = resume_native_id
241
- elif session_id:
242
- kwargs["session_id"] = session_id
243
- return ClaudeAgentOptions(**kwargs)
244
-
245
-
246
- async def _connect(client: ClaudeSDKClient) -> None:
247
- """Opens the vendor session, and turns any failure to open it into a
248
- refusal of the request rather than the end of the channel.
249
-
250
- main.py's dispatcher catches Refusal and nothing else, on purpose: the
251
- refusal code is per-operation, and a bare `except Exception` there would
252
- swallow a genuine bug in every handler. So the translation happens here,
253
- at the one place a vendor session is opened - and both session.start and
254
- session.resume open one, so both go through this function rather than
255
- each growing its own copy of the same guard.
256
-
257
- Everything that can go wrong in this environment goes wrong precisely
258
- here: an expired or absent credential, a CLI the SDK cannot launch, a
259
- permission_mode the CLI rejects (check_permission_mode catches the modes
260
- we know of, not a future CLI's change of vocabulary). Every one of those
261
- used to print a Python traceback and exit mid-channel, leaving Longshot
262
- with a dead sidecar instead of a sentence naming the cause.
263
-
264
- `internal` is the code, from docs/claude-sidecar.md's refusal table: "the
265
- side refused for its own reason; the reason is in `message`". It is the
266
- same code this build already answers with for an operation it cannot
267
- serve in this environment.
268
-
269
- Nothing needs cleaning up on the way out: the SDK's own connect() wraps
270
- its work in `except BaseException: await self.disconnect(); raise`
271
- (claude_agent_sdk/client.py), so a subprocess that did spawn before the
272
- failure is already gone. Nor is any of this session's state touched -
273
- every self._* assignment in start()/resume() happens after connect()
274
- returns, so a refused open leaves the Session untouched and a second
275
- start() on it opens normally.
276
-
277
- `except Exception`, not BaseException: asyncio.CancelledError is not a
278
- failure to open a session, and turning a cancellation into a refusal
279
- would answer a frame the loop is being torn down under.
280
- """
281
- try:
282
- await client.connect()
283
- except Exception as exc:
284
- raise Refusal(
285
- Code.INTERNAL,
286
- f"the Claude session could not be opened in this environment: "
287
- f"{type(exc).__name__}: {exc}",
288
- ) from exc
289
-
290
-
291
- async def _one_stdin_message(message: dict):
292
- """Wraps one stdin message as the AsyncIterable[dict] form of
293
- ClaudeSDKClient.query().
294
-
295
- The string form of query() builds the message itself
296
- (claude_agent_sdk/client.py) and leaves no way to set its `uuid`; the
297
- async-iterable form writes each dict through as given, setting only
298
- `session_id` on it when the dict carries none. That `uuid` is the whole
299
- reason this form is used: it is the one field a client can set that
300
- round-trips as the carrier (measured-constraints.md section 2 — a custom
301
- field name is stripped).
302
- """
303
- yield message
304
-
305
-
306
- def _emit_frame(frame: dict) -> None:
307
- """Writes one event frame to the real frame stream.
308
-
309
- sys.__stdout__, not sys.stdout: main.py's preamble captures the real
310
- stream as `_frames` and then points sys.stdout at sys.stderr, so that an
311
- accidental print() lands in diagnostics instead of corrupting the frame
312
- stream. sys.__stdout__ is set once by the interpreter at start-up and is
313
- untouched by that reassignment (reconfigure() mutates the object in
314
- place, it does not replace it), so it is this module's own way to reach
315
- the same stream without importing main.py — this module has no other
316
- handle to it, and T17-S01's allowed zone is this file alone.
317
- """
318
- write_frame(sys.__stdout__, frame)
319
-
320
-
321
- def mint_native_id() -> str:
322
- """Mints the backend-native session id session.start will open with.
323
-
324
- Longshot needs native_id in session.start's result (docs/claude-sidecar.md,
325
- "session.start": required), and it needs it without a turn: T16 opens and
326
- closes a session while spending no inference. The SDK gives no way to
327
- observe a CLI-assigned session id at that point — connect() returns the
328
- initialize control response, which carries no session id, and the
329
- `system`/`init` message that does carry one is only emitted once a turn
330
- starts, on the receive_messages() stream T17 owns.
331
-
332
- So the id is supplied rather than observed: ClaudeAgentOptions.session_id
333
- becomes the CLI's `--session-id`, and the session the CLI opens *is* that
334
- id, which is also the id `--resume` takes back on session.resume. A uuid4
335
- is what the CLI expects that flag to carry.
336
- """
337
- return str(uuid.uuid4())
338
-
339
-
340
- class Session:
341
- """The one live Claude session this sidecar serves. It holds nothing
342
- durable: every field arrived from Longshot and arrives again on resume
343
- (invariant I12)."""
344
-
345
- def __init__(self) -> None:
346
- self._client: ClaudeSDKClient | None = None
347
- self._session_id: str | None = None
348
- self._native_id: str = ""
349
- self._cwd: str = ""
350
- self._model: str = ""
351
- self._effort: str = ""
352
- self._permission_mode: str = ""
353
- # The one persistent receive_messages() task T17-S01 owns: started
354
- # the moment a session opens (start/resume, right after connect()
355
- # succeeds) and stopped the moment it closes (disconnect(), which
356
- # both close() and main.py's EOF finally block already call
357
- # unconditionally). This is the one point T17-S01's allowed zone
358
- # grants in this file; events.py owns everything the task does
359
- # while it runs.
360
- self._stream_task: asyncio.Task | None = None
361
- # The read loop's own StreamState, kept so can_use_tool's turn_id_of
362
- # accessor (permissions.py) can read the turn actually in flight
363
- # instead of a second, separately-updated copy of the same fact
364
- # (I11). None before _start_stream runs.
365
- self._stream_state: StreamState | None = None
366
-
367
- def _current_turn_id(self) -> str:
368
- """The turn in flight right now, per the read loop's own state.
369
- Empty before a turn starts or before the stream is wired up at all -
370
- both are legal, and a permission.request minted in either window
371
- carries an empty turn_id rather than a guessed one."""
372
- return (
373
- self._stream_state.current_turn_id if self._stream_state else None
374
- ) or ""
375
-
376
- def _tool_id_for(self, tool_name: str) -> str:
377
- """The tool-call id a `tool.call` for tool_name carries.
378
-
379
- docs/claude-sidecar.md requires it to correlate with the
380
- `tool.started` this side already emitted, and that event carries the
381
- SDK's own ToolUseBlock id (events.py, _map_assistant). The MCP
382
- handler is never told that id, so it is looked up instead: the read
383
- loop's tool_names map holds every started-but-not-completed call as
384
- tool_use_id -> tool_name, and the entry for this name is that call.
385
- A minted id is the fallback, so the required member is never empty,
386
- for the window where the assistant message has not been mapped yet.
387
- """
388
- # First match by name, and two calls to the same tool outstanding
389
- # at once could therefore get each other's id. Correlating exactly
390
- # needs the id at the MCP seam, which this vendor does not hand the
391
- # handler, so the upgrade is a vendor change, not a better lookup.
392
- names = self._stream_state.tool_names if self._stream_state else {}
393
- for tool_use_id, started_name in names.items():
394
- if started_name == tool_name:
395
- return tool_use_id
396
- return str(uuid.uuid4())
397
-
398
- def _require_unopened(self) -> None:
399
- """One sidecar serves one session (I03). Opening a second one over a
400
- live client would orphan that client's `claude` process and leave the
401
- first session's next request refused as someone else's, so the
402
- second open is refused instead; Longshot spawns a sidecar per session
403
- (internal/backend/claude/pool.go)."""
404
- if self._client is not None:
405
- raise Refusal(
406
- Code.INTERNAL,
407
- f"this sidecar already serves session {self._session_id!r}; "
408
- "a second session needs a sidecar of its own",
409
- )
410
-
411
- def _require_serving(self, session_id: str) -> None:
412
- served = self._session_id or ""
413
- if session_id != served:
414
- raise Refusal(
415
- Code.UNKNOWN_SESSION,
416
- f"this sidecar is serving {served!r}, not {session_id!r}",
417
- )
418
-
419
- def ready_event(self) -> dict:
420
- """The session.ready frame for the session this sidecar now serves.
421
-
422
- Built here rather than emitted here: the frame must reach stdout
423
- *after* session.start's own result frame, because that result is how
424
- the caller learns the session id this event names. main.py's
425
- dispatcher owns both writes and is therefore the only place that can
426
- guarantee the order (see its _READY_AFTER).
427
- """
428
- return session_ready(self._session_id or "", self._native_id, self._model)
429
-
430
- def _start_result(self) -> dict:
431
- return {
432
- "session_id": self._session_id,
433
- "native_id": self._native_id,
434
- "model": self._model,
435
- "effort": self._effort,
436
- "permission_mode": self._permission_mode,
437
- }
438
-
439
- async def start(self, params: dict) -> dict:
440
- check_permission_mode(params.get("permission_mode") or "")
441
- self._require_unopened()
442
- if params.get("mcp_endpoint"):
443
- # mcp_endpoint is T20's: accepting and ignoring it would silently
444
- # drop the session's tool set rather than say so.
445
- raise Refusal(
446
- Code.INTERNAL, "mcp_endpoint is served by T20, not by this build"
447
- )
448
-
449
- native_id = mint_native_id()
450
- # Minted before the session is open, not after: can_use_tool needs
451
- # this session's Longshot-facing id at construction time, and a
452
- # session that fails to connect never gets to self._session_id at
453
- # all - the local variable carries it in the meantime.
454
- session_id = str(uuid.uuid4())
455
- options = build_options(
456
- params,
457
- session_id=native_id,
458
- can_use_tool=make_can_use_tool(
459
- _emit_frame, session_id, self._current_turn_id
460
- ),
461
- )
462
- # mcp_servers is set here, not inside build_options: T20 adds it on
463
- # top of that function, not inside it (build_options's own
464
- # docstring). tool_definitions is session.start's own additive
465
- # `tools` field (T20-S01), which session.resume carries identically -
466
- # resume() below builds the same projection the same way, so a
467
- # change to one is a change to both; an absent or empty list is a
468
- # normal outcome and yields a server that declares no tools.
469
- #
470
- # It composes with can_use_tool above rather than replacing it: a
471
- # projected mcp__longshot__* tool is still routed through the same
472
- # approval hook every other tool is, and Longshot's own decide()
473
- # answers it (T21). Nothing here disables that hook - that is what
474
- # bypassPermissions would have done, and it is never sent.
475
- tool_definitions = params.get("tools") or []
476
- options.mcp_servers = {
477
- "longshot": build_longshot_server(
478
- tool_definitions,
479
- make_call_tool(
480
- _emit_frame, session_id, self._current_turn_id, self._tool_id_for
481
- ),
482
- )
483
- }
484
- client = ClaudeSDKClient(options)
485
- await _connect(client)
486
-
487
- self._client = client
488
- # The Longshot-facing session_id: this sidecar mints it because
489
- # session.start's params carry no session_id for it to echo (T15's
490
- # frozen params table). One sidecar serves one session (I03), so
491
- # whatever id is minted here is the session's id from now on.
492
- self._session_id = session_id
493
- self._native_id = native_id
494
- self._cwd = params.get("cwd", "")
495
- self._model = params.get("model", "")
496
- self._effort = params.get("effort", "")
497
- self._permission_mode = params["permission_mode"]
498
- self._start_stream(client)
499
- return self._start_result()
500
-
501
- async def resume(self, params: dict) -> dict:
502
- check_permission_mode(params.get("permission_mode") or "")
503
- self._require_unopened()
504
-
505
- native_id = params.get("native_id") or ""
506
- if not native_id:
507
- raise Refusal(
508
- Code.MALFORMED_REQUEST,
509
- "session.resume requires a non-empty native_id; the frame carried none",
510
- )
511
-
512
- session_id = params["session_id"]
513
- options = build_options(
514
- params,
515
- resume_native_id=native_id,
516
- can_use_tool=make_can_use_tool(
517
- _emit_frame, session_id, self._current_turn_id
518
- ),
519
- )
520
- # The same projection start() builds, from session.resume's own
521
- # additive `tools` field: the tool surface belongs to the session,
522
- # and a resumed session is the same session. Without this a resumed
523
- # coordinator sees no Longshot tools at all and silently loses its
524
- # ability to delegate. Nothing is remembered from a previous
525
- # session here on purpose - one sidecar serves one session (I03) and
526
- # a resume may arrive at a fresh process, so the surface has to
527
- # travel on this frame; an absent or empty list is a normal outcome
528
- # and yields a server that declares no tools, exactly as in start().
529
- tool_definitions = params.get("tools") or []
530
- options.mcp_servers = {
531
- "longshot": build_longshot_server(
532
- tool_definitions,
533
- make_call_tool(
534
- _emit_frame, session_id, self._current_turn_id, self._tool_id_for
535
- ),
536
- )
537
- }
538
- client = ClaudeSDKClient(options)
539
- await _connect(client)
540
-
541
- # The resumed session keeps its id: fork_session is left off, so the
542
- # CLI continues `--resume=<native_id>` under that same id rather than
543
- # branching a new one. There is nothing to observe back and compare
544
- # (see mint_native_id): the SDK exposes no session id at connect.
545
-
546
- self._client = client
547
- self._session_id = session_id
548
- self._native_id = native_id
549
- self._cwd = params.get("cwd", "")
550
- self._model = params.get("model", "")
551
- self._effort = params.get("effort", "")
552
- self._permission_mode = params["permission_mode"]
553
- self._start_stream(client)
554
- return self._start_result()
555
-
556
- def _start_stream(self, client: ClaudeSDKClient) -> None:
557
- """Starts the one persistent receive_messages() task for this
558
- session, as a background asyncio.Task on the process's single event
559
- loop (main.py's `_loop`, already running by the time this is
560
- called). T17-S01's whole allowed edit here: launch on open, stop on
561
- close (_stop_stream, called from disconnect()); events.py's
562
- run_stream owns the loop shape and the mapping."""
563
- state = StreamState(session_id=self._session_id or "")
564
- self._stream_state = state
565
- self._stream_task = asyncio.ensure_future(
566
- run_stream(client, state, _emit_frame)
567
- )
568
-
569
- async def _stop_stream(self) -> None:
570
- """Cancels the persistent read task and waits for it to actually
571
- stop, so disconnect() never returns while it is still running
572
- against a client that is about to go away."""
573
- task, self._stream_task = self._stream_task, None
574
- if task is None:
575
- return
576
- task.cancel()
577
- with contextlib.suppress(asyncio.CancelledError):
578
- await task
579
-
580
- def read(self, session_id: str) -> dict:
581
- """Returns what the live session holds, and changes nothing: no
582
- reconnect, no SDK call. Holding session_id, native_id, cwd, model,
583
- effort and permission_mode in memory does not break I12 — the set
584
- arrived from Longshot and arrives again on session.resume."""
585
- self._require_serving(session_id)
586
- return {
587
- "session_id": self._session_id,
588
- "native_id": self._native_id,
589
- "cwd": self._cwd,
590
- "model": self._model,
591
- }
592
-
593
- def _require_live_client(self, session_id: str) -> ClaudeSDKClient:
594
- """The live client for a session this sidecar serves, or a refusal.
595
-
596
- _require_serving answers "is this my session"; this answers "is it
597
- still connected", which is a different fact with the same code: a
598
- session that was closed keeps its id (close() clears only the
599
- client), so every operation that needs a client of its own has to
600
- ask both.
601
- """
602
- self._require_serving(session_id)
603
- if self._client is None:
604
- raise Refusal(
605
- Code.UNKNOWN_SESSION,
606
- f"session {session_id!r} is not connected; there is no live client to serve this request",
607
- )
608
- return self._client
609
-
610
- async def submit(self, params: dict) -> dict:
611
- """T18's input.submit: one message into the live session.
612
-
613
- Sent through the async-iterable form of query() (_one_stdin_message
614
- above) rather than the string form, for one reason: `uuid`. Longshot
615
- mints a carrier per delivery and this side sets it as the stdin
616
- message's `uuid`, the one field measured to round-trip
617
- (measured-constraints.md section 2). No carrier means no `uuid` key
618
- at all, not an empty one - the CLI mints its own then, and an empty
619
- string would be a value this side invented.
620
-
621
- Both served modes make the same call. `turn` against a session with
622
- a turn already running is refused by the adapter
623
- (internal/backend/claude/submit.go, ErrSessionBusy) and not here:
624
- query() folds rather than queueing (measured-constraints.md section
625
- 1), so the guard belongs where the observed turn state lives, and
626
- one copy of it is the whole point.
627
-
628
- turn.started is not built here. events.begin_turn is the one place a
629
- turn id is minted (I11); this path calls it with the carrier and
630
- writes back whatever it returns, which is exactly one frame for a
631
- `turn` and nothing at all for a `steer` that folded into a turn
632
- already running.
633
- """
634
- session_id = params.get("session_id") or ""
635
- client = self._require_live_client(session_id)
636
-
637
- mode = params.get("mode") or ""
638
- if mode not in SERVED_SUBMIT_MODES:
639
- reason = _UNSERVED_SUBMIT_MODES.get(
640
- mode,
641
- "this sidecar serves mode "
642
- + " and ".join(SERVED_SUBMIT_MODES)
643
- + f", and knows no mode {mode!r}",
644
- )
645
- raise Refusal(
646
- Code.UNSUPPORTED_MODE,
647
- f"input.submit mode {mode!r} is not served: {reason}",
648
- )
649
-
650
- text = params.get("text") or ""
651
- if not text:
652
- raise Refusal(
653
- Code.MALFORMED_REQUEST,
654
- "input.submit requires a non-empty text; the frame carried none",
655
- )
656
-
657
- carrier = params.get("carrier") or ""
658
- message: dict = {
659
- "type": "user",
660
- "message": {"role": "user", "content": text},
661
- "parent_tool_use_id": None,
662
- }
663
- if carrier:
664
- message["uuid"] = carrier
665
- await client.query(_one_stdin_message(message))
666
-
667
- # After the write, not before: the CLI cannot have produced a single
668
- # frame of this turn until the message reaches it, so nothing of
669
- # this turn can precede turn.started on the wire - while a
670
- # turn.started emitted ahead of a write that then failed would
671
- # announce a turn that never started.
672
- frames: list = (
673
- begin_turn(self._stream_state, carrier)
674
- if self._stream_state is not None
675
- else []
676
- )
677
- for frame in frames:
678
- _emit_frame(frame)
679
-
680
- result: dict = {"accepted": True}
681
- if carrier:
682
- # Echoed because this side actually set it as the message's
683
- # uuid, not because it was received: an echo is a claim, and
684
- # what is not set is not claimed.
685
- result["carrier"] = carrier
686
- if frames:
687
- # The turn id is set only when this call actually started a turn:
688
- # a steer folded into a running turn returns no id, because begin_turn
689
- # returned [] and made no claim. Same "what is not set is not claimed"
690
- # rule as carrier above. The Go adapter uses this id to close the
691
- # admission gate before turn.started arrives, eliminating the N24 race
692
- # window on the step-carrier path.
693
- result["turn_id"] = self._stream_state.current_turn_id
694
- return result
695
-
696
- async def interrupt(self, session_id: str) -> dict:
697
- """T18's turn.interrupt: stop the turn in flight.
698
-
699
- The active turn comes from the read loop's own StreamState, the same
700
- cursor every event's turn_id is taken from (I11) - this method keeps
701
- no second record of it and names no turn on the wire, because
702
- client.interrupt() takes neither a session nor a turn argument.
703
-
704
- `interrupted: true` means the interrupt reached the CLI and the CLI
705
- accepted it: interrupt() sends a control request and waits for the
706
- CLI's control response (claude_agent_sdk Query._send_control_request),
707
- raising on an error or a timeout. It does not wait for the turn to
708
- end - the turn's own end is observed as turn.completed, from the
709
- ResultMessage on the stream, which is the one turn boundary there
710
- is. So a true here is not a claim that the turn has already stopped,
711
- and the adapter treats false and a refusal alike as an error rather
712
- than reading either as "stopped anyway"
713
- (internal/backend/claude/interrupt.go).
714
- """
715
- client = self._require_live_client(session_id)
716
- if not self._current_turn_id():
717
- raise Refusal(
718
- Code.NO_ACTIVE_TURN,
719
- f"session {session_id!r} has no turn in flight to interrupt",
720
- )
721
- await client.interrupt()
722
- return {"interrupted": True}
723
-
724
- async def set_model(self, params: dict) -> dict:
725
- """T18's model.set: the model of a live session, changed and
726
- confirmed.
727
-
728
- A non-empty effort is refused rather than dropped. This vendor
729
- exposes no reasoning-effort control on a live session at all:
730
- ClaudeAgentOptions carries `effort` for the session it opens, and
731
- ClaudeSDKClient has set_model and set_permission_mode but no
732
- equivalent for effort (SDK 0.2.152). Applying the model and staying
733
- silent about the effort would be a silent semantic substitution -
734
- the caller asked for two changes and would be told both happened -
735
- which is the same trade internal/backend/claude/submit.go already
736
- refuses for mode: one retry costs less than a lie.
737
-
738
- The remembered model is updated after the SDK call, not before, so a
739
- change that failed does not read back as applied through
740
- session.read (the order ApplyFileAccess keeps on the Go side, T11).
741
- """
742
- session_id = params.get("session_id") or ""
743
- client = self._require_live_client(session_id)
744
-
745
- model = params.get("model") or ""
746
- if not model:
747
- raise Refusal(
748
- Code.MALFORMED_REQUEST,
749
- "model.set requires a non-empty model; the frame carried none",
750
- )
751
- effort = params.get("effort") or ""
752
- if effort:
753
- raise Refusal(
754
- Code.UNSUPPORTED_MODE,
755
- f"effort {effort!r} cannot be applied: this vendor exposes no reasoning-effort "
756
- "control on a live session, and applying the model alone would report a change "
757
- "to effort that never happened. Send model.set with an empty effort to change "
758
- "the model only",
759
- )
760
-
761
- await client.set_model(model)
762
- self._model = model
763
- # effort is reported empty, the wire's "none reported": this side
764
- # changed none and observes none, and the session's own remembered
765
- # effort is what it opened with, not something this call confirmed.
766
- return {"model": self._model, "effort": ""}
767
-
768
- async def set_permission_mode(self, session_id: str, permission_mode: str) -> dict:
769
- """T21's live half of permission.set. The guard already ran in
770
- main.py's dispatcher (check_permission_mode, ahead of this call), so
771
- this only carries a mode Longshot already vetted to the SDK and then
772
- remembers it - in that order, so a call that fails against the SDK
773
- does not get remembered as applied, matching ApplyFileAccess's own
774
- rule on the Go side (T11)."""
775
- self._require_serving(session_id)
776
- if self._client is None:
777
- raise Refusal(
778
- Code.UNKNOWN_SESSION,
779
- f"session {session_id!r} is not connected; there is no live client to change the mode of",
780
- )
781
- await self._client.set_permission_mode(permission_mode)
782
- self._permission_mode = permission_mode
783
- return {"permission_mode": permission_mode}
784
-
785
- async def history(self, params: dict) -> dict:
786
- """Answers session.history: one page of the persisted transcript,
787
- oldest to newest, cursor addressing a message position rather than a
788
- page (T22-S01, docs/claude-sidecar.md "session.history"). Grouping
789
- the messages into turns, assigning a turn status and renaming an
790
- item type all happen in Longshot's adapter
791
- (internal/backend/claude/history.go), never here: this sidecar
792
- reports content, the adapter decides meaning (I12).
793
-
794
- cursor is the index of the first message this page starts at, and
795
- next_cursor is the index the following page would start at - the
796
- same "position, not page" contract session.history promises. limit
797
- <= 0 means "the sidecar's own default", which this build reads as
798
- "the whole transcript in one page": Claude's own limit is expressed
799
- as a message count, not a byte budget, and there is no public
800
- get_session_messages() paging of its own to defer to.
801
- """
802
- session_id = params.get("session_id") or ""
803
- self._require_serving(session_id)
804
-
805
- cursor = params.get("cursor") or ""
806
- limit = params.get("limit") or 0
807
- start = int(cursor) if cursor else 0
808
-
809
- messages = await get_session_messages(self._native_id, directory=self._cwd)
810
- items = [serialize_message(m) for m in messages]
811
-
812
- end = len(items) if limit <= 0 else min(start + limit, len(items))
813
- has_more = end < len(items)
814
- return {
815
- "items": items[start:end],
816
- "next_cursor": str(end) if has_more else "",
817
- "has_more": has_more,
818
- }
819
-
820
- async def close(self, session_id: str) -> dict:
821
- """Disconnects and returns {}. Idempotent: a second close on the
822
- same session_id disconnects nothing (there is nothing left to
823
- disconnect) and still answers {}, because self._session_id is never
824
- cleared here — only self._client is."""
825
- self._require_serving(session_id)
826
- await self.disconnect()
827
- return {}
828
-
829
- async def disconnect(self) -> None:
830
- """Disconnects the live ClaudeSDKClient, if one is connected. Called
831
- from close() and unconditionally from main.py's finally block on
832
- EOF, so it must be safe to call when nothing is connected — that is
833
- the mechanism behind T16's "no orphan `claude` process".
834
-
835
- The stream task is stopped first, before the client itself
836
- disconnects: it is reading that client's receive_messages(), and
837
- stopping the reader before the reader's source is the order that
838
- does not depend on how the client behaves once it is gone.
839
-
840
- Every request this side still has outstanding is failed before any
841
- of that, because this is the moment the channel those requests are
842
- waiting on goes away: an awaited tool.call would otherwise hold the
843
- SDK's own tool handler forever, and client.disconnect() below waits
844
- on it. Failing first means the waiter is already resolving while the
845
- loop turns inside the awaits that follow.
846
- """
847
- fail_pending(
848
- "the session was closed while the tool call was still outstanding; "
849
- f"session {self._session_id or ''!r} is gone and its calls cannot be answered"
850
- )
851
- await self._stop_stream()
852
- if self._client is None:
853
- return
854
- client, self._client = self._client, None
855
- await client.disconnect()