@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,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()
|