muse-code-sdk 1.3.0__py3-none-any.whl

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.
@@ -0,0 +1,444 @@
1
+ """``MuseClient`` — the SS7.1 facade's session-opening half (spec 638
2
+ FR-638-019a / T030, FR-638-019d / T033). Port of
3
+ ``clients/sdk-ts/src/facade/client.ts``.
4
+
5
+ It turns the two v1 session verbs into correctly-shaped ``session/start`` /
6
+ ``session/resume`` params and hands back a WIRED ``Session``. It frames
7
+ nothing and mints nothing: ``Connection.command()`` stamps the ``commandId``
8
+ from the single injected mint, so INV-013's single-minter property is
9
+ preserved by DELEGATION rather than re-implemented here.
10
+
11
+ It owns two facts no other layer holds, which is why T030's obligations (b)
12
+ and (c) live here rather than on ``Session``:
13
+
14
+ - WHETHER A CLOSE WAS OURS. ``Connection`` is duplex-generic; a transport
15
+ cannot tell a peer's hang-up from its own shutdown. SS2.13.3b counts
16
+ "process exit OR transport EOF" as a death, so somebody has to know the
17
+ difference, and only the layer that owns ``close()`` does.
18
+ - WHAT AN EARLIER DEATH DISCARDED. :class:`DiscardedSessions` is
19
+ client-scoped, so a ``commandId`` refusal and a withheld reattach survive
20
+ the ``Session`` object that died.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ from dataclasses import dataclass, fields
27
+ from typing import Any, Awaitable, Generic, Mapping, Sequence, Set, TypeVar
28
+
29
+ from muse_code_msp import (
30
+ ClientCapabilities,
31
+ ClientInfo,
32
+ InitializeParams,
33
+ InitializeResult,
34
+ SessionResumeParams,
35
+ SessionStartParams,
36
+ )
37
+
38
+ from ..connection.connection import Connection
39
+ from ..connection.spawn import (
40
+ DEFAULT_SHUTDOWN_TIMEOUT_MS,
41
+ ExitClassification,
42
+ MuseServeChild,
43
+ OnStderr,
44
+ SpawnedMspConnection,
45
+ spawn_msp_connection,
46
+ )
47
+ from ..errors import MuseSessionDiscardedError
48
+ from .discarded import DiscardedSessions
49
+ from .host_death import (
50
+ SessionDurabilityProfile,
51
+ TransportEof,
52
+ is_abnormal_host_death,
53
+ read_session_durability,
54
+ survives_host_death,
55
+ )
56
+ from .session import Session, SessionOpening, _ResumeOpening, _StartOpening
57
+ from .turn_submit import _camel
58
+
59
+ _I = TypeVar("_I")
60
+
61
+ # OPTIONAL MEANS OMITTED, NEVER null (tdd SS1.2): every member the two option
62
+ # builders can leave unset is optional-non-nullable in the generated params,
63
+ # so an unset member is dropped from the frame rather than nulled.
64
+
65
+
66
+ @dataclass
67
+ class StartSessionOptions:
68
+ """Caller-facing options for ``session/start`` (tdd SS2.5.1).
69
+
70
+ Composed from the generated ``SessionStartParams`` (idiomatic snake_case,
71
+ C-638-1). ``config`` is excluded as well as ``commandId`` until the facade
72
+ implements the required ``sessionMcp`` grant check and forwarding.
73
+
74
+ Attributes:
75
+ approval_mode: The approval enforcement mode to start under; omitted
76
+ = the host's default.
77
+ provider_id: The provider to serve the session with; omitted = the
78
+ host's default.
79
+ session_id: A caller-chosen session id; omitted = the host mints one.
80
+ workspace_root: The workspace the session works in; omitted = the
81
+ host decides.
82
+ model_id: The model to serve the session with; omitted = the
83
+ provider's default.
84
+ """
85
+
86
+ approval_mode: str | None = None
87
+ provider_id: str | None = None
88
+ session_id: str | None = None
89
+ workspace_root: str | None = None
90
+ model_id: str | None = None
91
+
92
+
93
+ @dataclass
94
+ class ResumeSessionOptions:
95
+ """Caller-facing options for ``session/resume`` (tdd SS2.5.2).
96
+
97
+ ``config`` remains excluded until the same capability-checked forwarding
98
+ as ``session/start`` lands.
99
+
100
+ Attributes:
101
+ session_id: The session to resume.
102
+ cursor: Resume from this view cursor; omitted = from genesis.
103
+ exclude_items: Ask the host to omit item bodies from the history.
104
+ history: The history preference forwarded as the wire's ``history``
105
+ member.
106
+ """
107
+
108
+ session_id: str
109
+ cursor: str | None = None
110
+ exclude_items: bool | None = None
111
+ history: Mapping[str, Any] | None = None
112
+
113
+
114
+ # Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
115
+ # RESUME_FORWARDED (client.ts). A schema regen that adds a member to the
116
+ # generated params but not to the options dataclass reds THIS import (and thus
117
+ # every test), so a regenerated member cannot silently fail to reach the wire
118
+ # (INV-638-02). The excluded members are the ones the facade owns rather than
119
+ # forwards: `commandId` (the connection is the single minter) and `config`
120
+ # (temporarily withheld until capability-checked forwarding lands).
121
+ _START_EXCLUDED = {"commandId", "config"}
122
+ assert {_camel(f.name) for f in fields(StartSessionOptions)} | _START_EXCLUDED == set(
123
+ SessionStartParams.__annotations__
124
+ ), "StartSessionOptions drifted from SessionStartParams — forward the new member"
125
+
126
+ _RESUME_EXCLUDED = {"commandId", "config"}
127
+ assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED == set(
128
+ SessionResumeParams.__annotations__
129
+ ), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
130
+
131
+
132
+ @dataclass
133
+ class MuseClientOptions:
134
+ """Options for building a ``MuseClient`` around a connection you own.
135
+
136
+ :meth:`MuseClient.spawn` fills these in for you; supply them yourself only
137
+ when you compose the client with your own transport and handshake.
138
+
139
+ Attributes:
140
+ durability: The handshake's ``sessionDurability``, read through
141
+ :func:`~muse_code.facade.host_death.read_session_durability`.
142
+ REQUIRED for the same reason ``Session``'s is.
143
+ host: The owned host, set by :meth:`MuseClient.spawn`. A client built
144
+ around a connection somebody else owns has none, and says so by
145
+ omitting it — which is why ``close()`` falls back to closing just
146
+ the connection.
147
+ """
148
+
149
+ durability: SessionDurabilityProfile
150
+ host: SpawnedMspConnection | None = None
151
+
152
+
153
+ @dataclass
154
+ class MuseClientSpawnOptions:
155
+ """What :meth:`MuseClient.spawn` needs: the host binary, plus the
156
+ handshake identity.
157
+
158
+ Attributes:
159
+ muse_bin: The host binary to run.
160
+ client_info: Client identification, forwarded verbatim into
161
+ ``initialize`` (SS1.4).
162
+ args: Passed through verbatim (a bare ``muse`` is the TUI — pass
163
+ ``["serve"]`` for the MSP host).
164
+ cwd: Working directory for the host.
165
+ env: The host's environment (inherited when ``None``).
166
+ capabilities: Capability posture; absent means all defaults.
167
+ on_stderr: Raw stderr chunks, drained from birth, never parsed
168
+ (INV-638-04 carrying INV-010).
169
+ shutdown_timeout_ms: See ``MuseServeChild.spawn``.
170
+ """
171
+
172
+ muse_bin: str
173
+ client_info: ClientInfo
174
+ args: Sequence[str] | None = None
175
+ cwd: str | None = None
176
+ env: Mapping[str, str] | None = None
177
+ capabilities: ClientCapabilities | None = None
178
+ on_stderr: OnStderr | None = None
179
+ shutdown_timeout_ms: int | None = None
180
+
181
+
182
+ class MuseClient(Generic[_I]):
183
+ """Your entry point to the SDK.
184
+
185
+ Spawn a host with :meth:`spawn`, then open conversations with
186
+ :meth:`start_session` or :meth:`resume_session`. The client owns the
187
+ connection: it routes every server event to the session it belongs to and
188
+ reports a host death to all of them. Call :meth:`close` to shut the host
189
+ down cleanly.
190
+ """
191
+
192
+ def __init__(self, connection: Connection, options: MuseClientOptions) -> None:
193
+ """Builds a client over a connection you own.
194
+
195
+ Args:
196
+ connection: A ready :class:`~muse_code.connection.Connection`.
197
+ options: The durability profile and the optional owned host.
198
+ """
199
+ self._connection = connection
200
+ self._durability = options.durability
201
+ # Client-owned, never injected: SS2.13.3b is a one-client obligation
202
+ # and no current consumer spans two clients over one host lineage, so a
203
+ # sharing knob would be declared-ahead surface (Constitution XI).
204
+ self._discarded = DiscardedSessions()
205
+ self._sessions: Set[Session[_I]] = set()
206
+ host = options.host
207
+ self._child: MuseServeChild | None = host.child if host is not None else None
208
+ self._initialize_result: InitializeResult | None = (
209
+ host.initialize_result if host is not None else None
210
+ )
211
+ # Set by close() BEFORE the connection is torn down: the same EOF is an
212
+ # orderly SS2.1.2 shutdown when we caused it and an abnormal death when
213
+ # we did not, and nothing below this layer can tell.
214
+ self._closing = False
215
+ # Latched by an abnormal EOF, so a later resume_session can be withheld.
216
+ self._host_died = False
217
+ # THE ONE INBOUND PUMP (FR-638-019): ``Session.apply`` is the only
218
+ # event entry point and ``_connection`` is private, so without this a
219
+ # spawn-built consumer hears no view frame. ``Connection`` holds ONE
220
+ # notification handler — this client claims it.
221
+ connection.on_notification(self._route)
222
+ # Registered at construction, not at the first session: an EOF can land
223
+ # before any session exists, and the flag has to be read at that moment.
224
+ self._closed_watch = asyncio.ensure_future(self._watch_closed())
225
+
226
+ @staticmethod
227
+ async def spawn(options: MuseClientSpawnOptions) -> "MuseClient[Any]":
228
+ """Spawn an owned host, run the SS1.4 handshake, and hand back a client
229
+ whose durability profile came from that handshake (FR-638-019a).
230
+
231
+ The profile is READ rather than asked of the caller, which is the whole
232
+ reason this factory exists beside the bare constructor: SS2.13.1 makes
233
+ the absent/unrecognized distinction load-bearing, and a caller
234
+ re-deriving it by hand can get it wrong.
235
+
236
+ Args:
237
+ options: The host binary and handshake identity.
238
+
239
+ Returns:
240
+ A wired client, its connection already initialized.
241
+ """
242
+ handshake = await spawn_msp_connection(
243
+ options.muse_bin,
244
+ args=tuple(options.args) if options.args is not None else (),
245
+ cwd=options.cwd,
246
+ env=options.env,
247
+ on_stderr=options.on_stderr,
248
+ # ``spawn_msp_connection``'s default IS this constant, so passing it
249
+ # when the caller left the option unset is byte-identical to the
250
+ # bare call and keeps the parameter's type an ``int`` for mypy.
251
+ shutdown_timeout_ms=(
252
+ DEFAULT_SHUTDOWN_TIMEOUT_MS
253
+ if options.shutdown_timeout_ms is None
254
+ else options.shutdown_timeout_ms
255
+ ),
256
+ )
257
+ init_params: InitializeParams = {"clientInfo": options.client_info}
258
+ if options.capabilities is not None:
259
+ init_params["capabilities"] = options.capabilities
260
+ try:
261
+ spawned = await handshake.initialize(init_params)
262
+ except BaseException:
263
+ # A failed handshake must not leak the process it already spawned.
264
+ # (``initialize`` already closes on its own failure paths; this is
265
+ # the belt-and-braces close for anything that escapes it.)
266
+ try:
267
+ await handshake.close()
268
+ except BaseException:
269
+ pass
270
+ raise
271
+ return MuseClient(
272
+ spawned.connection,
273
+ MuseClientOptions(
274
+ durability=read_session_durability(spawned.initialize_result),
275
+ host=spawned,
276
+ ),
277
+ )
278
+
279
+ @property
280
+ def initialize_result(self) -> InitializeResult:
281
+ """The handshake result, when this client was built by :meth:`spawn`.
282
+
283
+ A plain error, not a typed one: reading this off a client not built by
284
+ ``spawn`` is API misuse, not a protocol STATE an embedder branches on.
285
+
286
+ Raises:
287
+ RuntimeError: This client was not built by ``spawn``.
288
+ """
289
+ if self._initialize_result is None:
290
+ raise RuntimeError(
291
+ "initialize_result is only available on a client built by MuseClient.spawn"
292
+ )
293
+ return self._initialize_result
294
+
295
+ @property
296
+ def exit(self) -> Awaitable[ExitClassification]:
297
+ """The host's SS2.11 exit row, when this client was built by
298
+ :meth:`spawn`.
299
+
300
+ Raises:
301
+ RuntimeError: This client was not built by ``spawn``.
302
+ """
303
+ if self._child is None:
304
+ raise RuntimeError(
305
+ "exit is only available on a client built by MuseClient.spawn"
306
+ )
307
+ return self._child.exit
308
+
309
+ @property
310
+ def durability(self) -> SessionDurabilityProfile:
311
+ """The durability profile every session this client opens inherits."""
312
+ return self._durability
313
+
314
+ async def start_session(
315
+ self, options: StartSessionOptions | None = None
316
+ ) -> Session[_I]:
317
+ """Open a new root session (tdd SS2.5.1)."""
318
+ options = options or StartSessionOptions()
319
+ params: dict[str, Any] = {}
320
+ if options.approval_mode is not None:
321
+ params["approvalMode"] = options.approval_mode
322
+ if options.provider_id is not None:
323
+ params["providerId"] = options.provider_id
324
+ if options.session_id is not None:
325
+ params["sessionId"] = options.session_id
326
+ if options.workspace_root is not None:
327
+ params["workspaceRoot"] = options.workspace_root
328
+ if options.model_id is not None:
329
+ params["modelId"] = options.model_id
330
+ raw = await self._connection.command("session/start", params)
331
+ result: Mapping[str, Any] = raw
332
+ # Keyed by the id the SERVER named, never the one the caller asked for:
333
+ # ``sessionId`` is a REQUEST on start, and a session keyed by a hopeful
334
+ # id would reject every one of its own events as foreign.
335
+ return self._open_session(
336
+ str(result["session"]["sessionId"]),
337
+ _StartOpening(result=result),
338
+ )
339
+
340
+ async def resume_session(self, options: ResumeSessionOptions) -> Session[_I]:
341
+ """Load an existing session and subscribe this connection to its view
342
+ (tdd SS2.5.2).
343
+
344
+ WITHHELD after an ephemeral host death (SS2.13.3b clause 2, "do not
345
+ attempt to reattach"), and refused on THIS side of the transport: a
346
+ reattach that reaches the wire has already violated the clause,
347
+ whatever the server answers.
348
+ """
349
+ self._assert_reattach_allowed(options.session_id)
350
+ params: dict[str, Any] = {"sessionId": options.session_id}
351
+ if options.cursor is not None:
352
+ params["cursor"] = options.cursor
353
+ if options.exclude_items is not None:
354
+ params["excludeItems"] = options.exclude_items
355
+ if options.history is not None:
356
+ params["history"] = options.history
357
+ raw = await self._connection.command("session/resume", params)
358
+ result: Mapping[str, Any] = raw
359
+ return self._open_session(
360
+ str(result["session"]["sessionId"]),
361
+ _ResumeOpening(result=result),
362
+ )
363
+
364
+ async def close(self) -> None:
365
+ """Shut the host down in an orderly way (SS2.1.2).
366
+
367
+ The EOF this close produces is NOT a death, which is what
368
+ ``_closing`` records. ``_closing`` covers only the EOF; it must not
369
+ swallow the EXIT ROW: when the host ignores stdin EOF, the child close
370
+ escalates to SIGTERM/SIGKILL and the exit classifies as a crash — an
371
+ abnormal death under SS2.13.3b no matter who started the close, and one
372
+ no transport event will report because ``_closing`` already claimed the
373
+ EOF. So the close's own classification is forwarded to every session;
374
+ ``host_exited`` answers ``notADeath`` for a clean shutdown, keeping the
375
+ orderly arm inert.
376
+ """
377
+ self._closing = True
378
+ if self._child is not None:
379
+ exit_classification: ExitClassification = await self._child.close()
380
+ if is_abnormal_host_death(exit_classification):
381
+ for session in self._sessions:
382
+ session.host_exited(exit_classification)
383
+ return
384
+ await self._connection.close()
385
+
386
+ def _open_session(
387
+ self, session_id: str, opening: SessionOpening
388
+ ) -> Session[_I]:
389
+ session: Session[_I] = Session(
390
+ session_id,
391
+ self._durability,
392
+ connection=self._connection,
393
+ discarded=self._discarded,
394
+ opening=opening,
395
+ )
396
+ self._sessions.add(session)
397
+ return session
398
+
399
+ def _assert_reattach_allowed(self, session_id: str) -> None:
400
+ # Two facts, both recorded by an ephemeral discharge: THIS session was
401
+ # discarded, or this client's own ephemeral host died (nothing on the
402
+ # other end to reattach TO, whatever id is asked for).
403
+ if session_id in self._discarded.session_ids:
404
+ raise MuseSessionDiscardedError(
405
+ f"session {session_id} was discarded after an ephemeral host "
406
+ "death; it cannot be resumed"
407
+ )
408
+ if self._host_died and not survives_host_death(self._durability):
409
+ raise MuseSessionDiscardedError(
410
+ "this client's ephemeral host died; SS2.13.3b forbids "
411
+ "reattaching to it"
412
+ )
413
+
414
+ def _route(self, notification: Mapping[str, Any]) -> None:
415
+ # Deliver one view notification to the session(s) it names. A frame
416
+ # naming NO session is dropped here rather than fed to every session,
417
+ # and a frame naming an UNKNOWN session is dropped for the same reason
418
+ # — routing it anywhere would trip the foreign-frame throw.
419
+ params = notification.get("params")
420
+ named = params.get("sessionId") if isinstance(params, dict) else None
421
+ if not isinstance(named, str):
422
+ return
423
+ for session in self._sessions:
424
+ if session.session_id == named:
425
+ session.apply(notification)
426
+
427
+ async def _watch_closed(self) -> None:
428
+ try:
429
+ await self._connection.closed
430
+ except asyncio.CancelledError:
431
+ return
432
+ self._transport_closed()
433
+
434
+ def _transport_closed(self) -> None:
435
+ # The transport reached EOF. If this client did not cause it, that is
436
+ # SS2.13.3b's second death notification (T030 obligation b), and every
437
+ # session discharges through the SAME ``host_exited`` path the process
438
+ # exit uses — including its latch, so whichever notification arrives
439
+ # second replays the first one's report.
440
+ if self._closing:
441
+ return
442
+ self._host_died = True
443
+ for session in self._sessions:
444
+ session.host_exited(TransportEof())
@@ -0,0 +1,45 @@
1
+ """What an ephemeral host death discarded, remembered ACROSS the sessions one
2
+ client opened (spec 638 FR-638-019d carrying spec 14990 T030 obligation (c);
3
+ tdd SS2.13.3b). Port of ``clients/sdk-ts/src/facade/discarded.ts``.
4
+
5
+ SS2.13.3b's five-clause MUST includes "do not attempt to reattach" and "do
6
+ not replay the session's ``commandId``\\ s". Both are statements about what
7
+ the CLIENT does next, and "next" outlives the session object that died:
8
+
9
+ - :class:`~muse_code.pending.PendingCommandSet` already refuses a replay of
10
+ an id IT discarded, but its memory is per-instance, so a FRESH ``Session``
11
+ — the natural thing to build after a host dies — knew nothing and would
12
+ replay those ids at the new host. That is the exactly-once violation the
13
+ clause exists to prevent.
14
+ - nothing at all held the discarded ``sessionId``, so ``resume_session`` had
15
+ no fact to withhold on.
16
+
17
+ This is client state, never a wire shape and never durable (INV-638-05): it
18
+ lives exactly as long as the ``MuseClient`` that owns it. It is deliberately
19
+ NOT a module-global — two clients in one process are two independent trust
20
+ boundaries, and a process-wide set would let one client's dead host silence
21
+ another's live one.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+
27
+ class DiscardedSessions:
28
+ """The two sets, exposed directly rather than behind add/has pairs.
29
+
30
+ :class:`~muse_code.pending.PendingCommandSet` takes the ``command_ids``
31
+ set BY REFERENCE and writes to it from ``discard_ephemeral()``, so a
32
+ wrapper would have to re-expose a mutable seam anyway; a ``set[str]`` is
33
+ the honest shape, and it is already the shape that class uses internally.
34
+
35
+ Attributes:
36
+ command_ids: ``commandId``\\ s an ephemeral discharge retired. Never
37
+ replayed again.
38
+ session_ids: ``sessionId``\\ s whose ephemeral host died. Never
39
+ reattached.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ """Builds an empty pair of discard sets."""
44
+ self.command_ids: set[str] = set()
45
+ self.session_ids: set[str] = set()