muse-code-sdk 1.4.2__tar.gz → 1.4.6__tar.gz

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.
Files changed (67) hide show
  1. {muse_code_sdk-1.4.2/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.6}/PKG-INFO +1 -1
  2. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/pyproject.toml +1 -1
  3. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/__init__.py +6 -2
  4. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/spawn.py +17 -0
  5. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/errors.py +33 -0
  6. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/__init__.py +2 -0
  7. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/approval.py +121 -4
  8. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/client.py +182 -22
  9. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/gap_fill.py +13 -0
  10. muse_code_sdk-1.4.6/src/muse_code/facade/host_start.py +115 -0
  11. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/session.py +23 -0
  12. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/turn_handle.py +4 -4
  13. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/turn_submit.py +11 -1
  14. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/session_fold.py +52 -6
  15. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
  16. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/SOURCES.txt +6 -0
  17. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_compatibility_row.py +69 -0
  18. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_approval.py +394 -19
  19. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_gap_fill.py +102 -0
  20. muse_code_sdk-1.4.6/tests/test_facade_open_same_chunk.py +265 -0
  21. muse_code_sdk-1.4.6/tests/test_facade_required_selection.py +305 -0
  22. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_turn.py +71 -0
  23. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_muse_serve_child.py +40 -0
  24. muse_code_sdk-1.4.6/tests/test_permission_state_wire_types.py +174 -0
  25. muse_code_sdk-1.4.6/tests/test_public_mirror_text.py +1844 -0
  26. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_publish_shape.py +26 -0
  27. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_session_fold.py +79 -2
  28. muse_code_sdk-1.4.6/tests/test_spawn_host_lost.py +295 -0
  29. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/LICENSE +0 -0
  30. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/README.md +0 -0
  31. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/setup.cfg +0 -0
  32. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/__init__.py +0 -0
  33. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/connection.py +0 -0
  34. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/discovery.py +0 -0
  35. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/validation.py +0 -0
  36. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/discarded.py +0 -0
  37. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/host_death.py +0 -0
  38. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/__init__.py +0 -0
  39. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/item_store.py +0 -0
  40. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/state_store.py +0 -0
  41. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/pending/__init__.py +0 -0
  42. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/pending/pending_command_set.py +0 -0
  43. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/py.typed +0 -0
  44. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/replay.py +0 -0
  45. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/sync_facade.py +0 -0
  46. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  47. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/requires.txt +0 -0
  48. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  49. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_asyncio_cancellation.py +0 -0
  50. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_connection.py +0 -0
  51. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_connection_error_arms.py +0 -0
  52. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_docs_generation.py +0 -0
  53. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_external_audience.py +0 -0
  54. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_persistent_approval.py +0 -0
  55. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_host_discovery.py +0 -0
  56. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_item_store.py +0 -0
  57. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_manifests.py +0 -0
  58. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_pending_command_corpus.py +0 -0
  59. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_pending_command_set.py +0 -0
  60. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_pending_command_wire_binding.py +0 -0
  61. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_source_closure.py +0 -0
  62. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_spawn_handshake.py +0 -0
  63. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_state_store.py +0 -0
  64. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_sync_facade.py +0 -0
  65. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_tolerance_corpus.py +0 -0
  66. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_transcript_replay.py +0 -0
  67. {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_typing_gates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muse-code-sdk
3
- Version: 1.4.2
3
+ Version: 1.4.6
4
4
  Summary: Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/meta-models/muse-code-sdk
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "muse-code-sdk"
8
- version = "1.4.2"
8
+ version = "1.4.6"
9
9
  description = "Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -24,6 +24,7 @@ from .errors import (
24
24
  MuseForeignSessionError,
25
25
  MuseGapFillError,
26
26
  MuseSessionDiscardedError,
27
+ PermissionProfileNotApplied,
27
28
  )
28
29
  from .fold import ItemStore, SessionFold, SessionStateStore
29
30
  from .pending import PendingCommandSet
@@ -47,9 +48,11 @@ __all__ = [
47
48
  "MuseForeignSessionError",
48
49
  "MuseGapFillError",
49
50
  "MuseHostDiedError",
51
+ "MuseHostStartError",
50
52
  "MuseSessionDiscardedError",
51
53
  "PendingCommandSet",
52
54
  "PendingCommandView",
55
+ "PermissionProfileNotApplied",
53
56
  "ResumeSessionOptions",
54
57
  "SendUserTurnOptions",
55
58
  "Session",
@@ -77,7 +80,7 @@ __all__ = [
77
80
  ]
78
81
 
79
82
  EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
80
- "sha256:61afea3112e0906e9dc3a536144278a74cb4b36fc6e20901a91d4432ba3568e2"
83
+ "sha256:eae467ab60cb9a5d5af4de616830f065e59375c987fd59269c8cc33ed2a1137f"
81
84
  )
82
85
  """The stable schema-bundle fingerprint this SDK was built against.
83
86
 
@@ -89,7 +92,7 @@ Python SDK reds this lane instead of drifting silently.
89
92
  # The lockstep rule: the release train bumps pyproject.toml, the
90
93
  # distribution-metadata authority; this constant is aligned at publication
91
94
  # time.
92
- __version__: Final[str] = "1.4.2"
95
+ __version__: Final[str] = "1.4.6"
93
96
 
94
97
  # The facade is imported last so the constants above are already bound: the
95
98
  # spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
@@ -108,6 +111,7 @@ from .facade import ( # noqa: E402
108
111
  MuseClientOptions,
109
112
  MuseClientSpawnOptions,
110
113
  MuseHostDiedError,
114
+ MuseHostStartError,
111
115
  PendingCommandView,
112
116
  ResumeSessionOptions,
113
117
  SendUserTurnOptions,
@@ -39,6 +39,13 @@ from .validation import parse_initialize_result, parse_spawn_options
39
39
  if TYPE_CHECKING:
40
40
  from muse_code_msp import InitializeParams, InitializeResult, RequestId
41
41
 
42
+ _CREATE_NO_WINDOW = 0x0800_0000
43
+ """Win32 ``CREATE_NO_WINDOW`` process-creation flag (``subprocess`` defines it
44
+ only on Windows builds). A spawned ``muse serve`` is never meant to be seen,
45
+ so on Windows the SDK starts it without a console window: a windowless host
46
+ (a GUI app, a service) would otherwise get a stray console on every spawn."""
47
+
48
+
42
49
  DEFAULT_STDERR_MAX_BYTES = 8 * 1024
43
50
  DEFAULT_STDERR_MAX_LINES = 100
44
51
  DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000
@@ -817,6 +824,9 @@ class MuseServeChild:
817
824
  env=env,
818
825
  shutdown_timeout_ms=shutdown_timeout_ms,
819
826
  )
827
+ # POSIX: the child leads its own process group so the close ladder can
828
+ # signal the group. Windows: no process group, and no console window
829
+ # (CPython accepts ``creationflags=0`` on POSIX).
820
830
  owns_process_group = sys.platform != "win32"
821
831
  child = await asyncio.create_subprocess_exec(
822
832
  options.command,
@@ -827,6 +837,7 @@ class MuseServeChild:
827
837
  cwd=options.cwd,
828
838
  env=options.env,
829
839
  start_new_session=owns_process_group,
840
+ creationflags=0 if owns_process_group else _CREATE_NO_WINDOW,
830
841
  )
831
842
  return MuseServeChild(
832
843
  child,
@@ -897,6 +908,11 @@ class MspHandshake:
897
908
  self._transport = child._transport
898
909
  self._connection = connection
899
910
  self._started = False
911
+ # Did ``initialize`` get an answer before the handshake failed? Read
912
+ # by the facade, never a public member: it lets ``MuseClient.spawn``
913
+ # say truthfully that a host died AFTER answering, while the
914
+ # ``initialized`` notification was being sent.
915
+ self._answered = False
900
916
  self._transport._adopt_flush_source(connection._submission_tail)
901
917
 
902
918
  @property
@@ -957,6 +973,7 @@ class MspHandshake:
957
973
  # stays a no-op.
958
974
  try:
959
975
  raw: Any = await self._connection.request("initialize", dict(params))
976
+ self._answered = True
960
977
  # The contract frame seam: the members this
961
978
  # handshake reads validate through a pydantic model, so a
962
979
  # malformed frame is the typed MuseValidationError — the seam
@@ -82,6 +82,39 @@ class MuseGapFillError(Exception):
82
82
  self.__cause__ = cause
83
83
 
84
84
 
85
+ class PermissionProfileNotApplied(Exception):
86
+ """A required session-start permission-profile selection the client could
87
+ not prove applied: the start result carried no ``permissionState``, and
88
+ the ``session/read`` read-back served a different profile — or none at
89
+ all. Never a silent default: the session stays open and the caller
90
+ decides (set/retry or close) via ``session_id``, with no auto-close and
91
+ no auto-set.
92
+
93
+ Attributes:
94
+ requested: The profile id the caller required.
95
+ served: The committed profile id the read-back served, or None when
96
+ unknown.
97
+ session_id: The open session, so the caller can set, retry, or close it.
98
+ """
99
+
100
+ def __init__(self, requested: str, served: str | None, session_id: str) -> None:
101
+ """Builds the refusal.
102
+
103
+ Args:
104
+ requested: The profile id the caller required.
105
+ served: The committed profile id served, or None when unknown.
106
+ session_id: The open session's id.
107
+ """
108
+ served_text = "unknown" if served is None else repr(served)
109
+ super().__init__(
110
+ f"required permissionProfile {requested!r} was not applied:"
111
+ f" the committed selection is {served_text} (session {session_id})"
112
+ )
113
+ self.requested: str = requested
114
+ self.served: str | None = served
115
+ self.session_id: str = session_id
116
+
117
+
85
118
  ValidationSeam = Literal["initializeResult", "spawnOptions"]
86
119
  """Which validating pydantic seam refused its input.
87
120
 
@@ -39,6 +39,7 @@ from .host_death import (
39
39
  is_abnormal_host_death,
40
40
  read_session_durability,
41
41
  )
42
+ from .host_start import MuseHostStartError
42
43
  from .session import (
43
44
  PendingCommandView,
44
45
  Session,
@@ -67,6 +68,7 @@ __all__ = [
67
68
  "MuseClientOptions",
68
69
  "MuseClientSpawnOptions",
69
70
  "MuseHostDiedError",
71
+ "MuseHostStartError",
70
72
  "PendingCommandView",
71
73
  "ResumeSessionOptions",
72
74
  "SendUserTurnOptions",
@@ -17,11 +17,16 @@ request upstream, never a local interface.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import asyncio
20
21
  import inspect
21
22
  from dataclasses import dataclass
22
23
  from typing import Any, Awaitable, Callable, Literal, Union
23
24
 
24
- from muse_code_msp import ApprovalDecideParams, ApprovalRequestParams
25
+ from muse_code_msp import (
26
+ ApprovalDecideParams,
27
+ ApprovalRequestParams,
28
+ ApprovalUpdatedParams,
29
+ )
25
30
 
26
31
  from ..connection.connection import Connection
27
32
 
@@ -37,6 +42,40 @@ assert _DECIDE_FORWARDED | {"commandId"} == (
37
42
  ApprovalDecideParams.__required_keys__ | ApprovalDecideParams.__optional_keys__
38
43
  ), "approval/decide grew a member — forward it through ApprovalRouter"
39
44
 
45
+ # The refreshed-request merge in ``ApprovalRouter.updated``, listed by SOURCE:
46
+ # stage-scoped members come from the ``approval/updated`` frame, identity
47
+ # members from the retained request (stable across the stages of one
48
+ # approval). A regenerated member, required or optional, fails this import
49
+ # until it is given a side; otherwise it would silently vanish from every
50
+ # later stage's request.
51
+ _REFRESH_FROM_UPDATE = frozenset(
52
+ {
53
+ "availableChoices",
54
+ "currentRequirementId",
55
+ "sessionId",
56
+ "sourceRange",
57
+ "subagentOrigin",
58
+ "subject",
59
+ "viewCursor",
60
+ }
61
+ )
62
+ _REFRESH_FROM_REQUEST = frozenset(
63
+ {
64
+ "approvalId",
65
+ "itemId",
66
+ "judgeEscalated",
67
+ "protectedWrite",
68
+ "rawArgs",
69
+ "taskId",
70
+ "toolCallId",
71
+ "toolName",
72
+ "turnId",
73
+ }
74
+ )
75
+ assert _REFRESH_FROM_UPDATE | _REFRESH_FROM_REQUEST == (
76
+ ApprovalRequestParams.__required_keys__ | ApprovalRequestParams.__optional_keys__
77
+ ), "approval/requested grew a member — give it a side in ApprovalRouter.updated"
78
+
40
79
 
41
80
  @dataclass(frozen=True)
42
81
  class ApprovalDecisionInput:
@@ -107,7 +146,8 @@ ApprovalFailureHandler = Callable[[ApprovalFailure], None]
107
146
 
108
147
 
109
148
  class ApprovalRouter:
110
- """Routes folded ``approval/requested`` frames to the consumer's handler
149
+ """Routes folded ``approval/requested`` frames, and ``approval/updated``
150
+ frames that advance a pending approval's stage, to the consumer's handler
111
151
  and authors the ``approval/decide`` command."""
112
152
 
113
153
  def __init__(self, session_id: str, connection: Connection | None) -> None:
@@ -131,6 +171,14 @@ class ApprovalRouter:
131
171
  # ``requirementId`` and therefore a new key, so "ask the consumer once
132
172
  # per stage" holds absolutely.
133
173
  self._decided_stages: set[str] = set()
174
+ # Approvals whose own decide ack said ``terminal: true``: the host
175
+ # closed the whole approval, so a trailing stage update names nobody's
176
+ # question.
177
+ self._terminal_approvals: set[str] = set()
178
+ # The decide each approval has on the wire, set when its ack lands.
179
+ # An update read in the same chunk as a ``terminal: true`` ack is
180
+ # folded before that decide resumes, so the update must wait for it.
181
+ self._in_flight: dict[str, asyncio.Event] = {}
134
182
 
135
183
  def on_approval(self, handler: ApprovalHandler) -> None:
136
184
  """Register the decision callback.
@@ -189,6 +237,63 @@ class ApprovalRouter:
189
237
  return None
190
238
  return self._decide(connection, params, handler)
191
239
 
240
+ def updated(
241
+ self,
242
+ requested: ApprovalRequestParams,
243
+ params: ApprovalUpdatedParams,
244
+ ) -> Awaitable[None] | None:
245
+ """An ``approval/updated`` folded onto a STILL-PENDING approval.
246
+
247
+ A multi-stage approval advances ``currentRequirementId`` through this
248
+ frame alone on the notification plane, so waiting for a re-issued
249
+ ``approval/requested`` would leave the new stage undecided and the turn
250
+ pending. The update carries none of the request's identity members,
251
+ but the fold retains the original request and those members are stable
252
+ across the stages of one approval, so the merged request is honest.
253
+ Routing it through :meth:`requested` reuses the per-stage latch: an
254
+ update naming an already-decided stage authors nothing.
255
+ """
256
+ # ``alreadyTerminal``: the host holds a durable terminal whose resolve
257
+ # frame has not landed yet; a decision against it can only bounce.
258
+ if params["change"]["kind"] == "alreadyTerminal":
259
+ return None
260
+ if requested["approvalId"] in self._terminal_approvals:
261
+ return None
262
+ refreshed: ApprovalRequestParams = {
263
+ "approvalId": requested["approvalId"],
264
+ "availableChoices": params["availableChoices"],
265
+ "currentRequirementId": params["currentRequirementId"],
266
+ "itemId": requested["itemId"],
267
+ "judgeEscalated": requested["judgeEscalated"],
268
+ "protectedWrite": requested["protectedWrite"],
269
+ "rawArgs": requested["rawArgs"],
270
+ "sessionId": params["sessionId"],
271
+ "sourceRange": params["sourceRange"],
272
+ "subject": params["subject"],
273
+ "taskId": requested["taskId"],
274
+ "toolCallId": requested["toolCallId"],
275
+ "toolName": requested["toolName"],
276
+ "turnId": requested["turnId"],
277
+ "viewCursor": params["viewCursor"],
278
+ }
279
+ # Omitted rather than nulled when absent, like ``feedback`` below.
280
+ if "subagentOrigin" in params:
281
+ refreshed["subagentOrigin"] = params["subagentOrigin"]
282
+ acked = self._in_flight.get(requested["approvalId"])
283
+ if acked is not None:
284
+ return self._after_ack(acked, refreshed)
285
+ return self.requested(refreshed)
286
+
287
+ async def _after_ack(
288
+ self, acked: asyncio.Event, refreshed: ApprovalRequestParams
289
+ ) -> None:
290
+ await acked.wait()
291
+ if refreshed["approvalId"] in self._terminal_approvals:
292
+ return
293
+ decided = self.requested(refreshed)
294
+ if decided is not None:
295
+ await decided
296
+
192
297
  @staticmethod
193
298
  def _stage_key(params: ApprovalRequestParams) -> str:
194
299
  requirement = params["currentRequirementId"]
@@ -252,14 +357,26 @@ class ApprovalRouter:
252
357
  if feedback is not None:
253
358
  decide_params["feedback"] = feedback
254
359
 
360
+ # The wait covers only this round trip: an update for this approval
361
+ # read before the ack lands waits for it, while a handler still open
362
+ # or a decide that never started blocks nobody.
363
+ approval_id = params["approvalId"]
364
+ acked = asyncio.Event()
365
+ self._in_flight[approval_id] = acked
255
366
  try:
256
367
  # No explicit ``command_id``: unlike ``send_user_turn``, nothing
257
368
  # here needs the id before the ack, so ``Connection.command()``
258
369
  # mints and stamps it — and its own same-id retry then reuses that
259
370
  # id for free.
260
- await connection.command("approval/decide", decide_params)
371
+ result = await connection.command("approval/decide", decide_params)
372
+ if result.get("terminal") is True:
373
+ self._terminal_approvals.add(approval_id)
261
374
  except Exception as error: # noqa: BLE001 - reported, never escapes
262
- self._report(SubmitFailed(approval_id=params["approvalId"], error=error))
375
+ self._report(SubmitFailed(approval_id=approval_id, error=error))
376
+ finally:
377
+ acked.set()
378
+ if self._in_flight.get(approval_id) is acked:
379
+ del self._in_flight[approval_id]
263
380
 
264
381
  def _report(self, failure: ApprovalFailure) -> None:
265
382
  handler = self._on_failure
@@ -23,7 +23,17 @@ from __future__ import annotations
23
23
 
24
24
  import asyncio
25
25
  from dataclasses import dataclass, fields
26
- from typing import Any, Awaitable, Generic, List, Mapping, Sequence, Set, TypeVar
26
+ from typing import (
27
+ Any,
28
+ Awaitable,
29
+ Callable,
30
+ Generic,
31
+ List,
32
+ Mapping,
33
+ Sequence,
34
+ Set,
35
+ TypeVar,
36
+ )
27
37
 
28
38
  from muse_code_msp import (
29
39
  ClientCapabilities,
@@ -43,8 +53,9 @@ from ..connection.spawn import (
43
53
  SpawnedMspConnection,
44
54
  spawn_msp_connection,
45
55
  )
46
- from ..errors import MuseSessionDiscardedError
56
+ from ..errors import MuseSessionDiscardedError, PermissionProfileNotApplied
47
57
  from .discarded import DiscardedSessions
58
+ from .host_start import MuseHostStartError, keeps_its_own_error
48
59
  from .host_death import (
49
60
  SessionDurabilityProfile,
50
61
  TransportEof,
@@ -82,6 +93,18 @@ class StartSessionOptions:
82
93
  ``workspace_root`` is also given the two must agree.
83
94
  model_id: The model to serve the session with; omitted = the
84
95
  provider's default.
96
+ permission_profile: The permission profile to start the session
97
+ under (wire ``permissionProfile``): a profile id the host offers;
98
+ omitted = the host's configured default. An id the host does not
99
+ offer refuses the start before the session exists.
100
+ require_permission_profile: Require the ``permission_profile``
101
+ selection: when true, the client proves the
102
+ committed selection equals the requested id — from the start
103
+ result's ``session.permissionState``, else a ``session/read``
104
+ read-back — and raises ``PermissionProfileNotApplied`` instead
105
+ of proceeding on the host default. Client-local: never sent on
106
+ the wire. Setting it without ``permission_profile`` is a usage
107
+ error.
85
108
  """
86
109
 
87
110
  approval_mode: str | None = None
@@ -90,6 +113,8 @@ class StartSessionOptions:
90
113
  workspace_root: str | None = None
91
114
  workspace_roots: List[str] | None = None
92
115
  model_id: str | None = None
116
+ permission_profile: str | None = None
117
+ require_permission_profile: bool = False
93
118
 
94
119
 
95
120
  @dataclass
@@ -116,12 +141,18 @@ class ResumeSessionOptions:
116
141
  # Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
117
142
  # RESUME_FORWARDED (client.ts). A schema regen that adds a member to the
118
143
  # generated params but not to the options dataclass reds THIS import (and thus
119
- # every test), so a regenerated member cannot silently fail to reach the wire
120
- #. The excluded members are the ones the facade owns rather than
121
- # forwards: `commandId` (the connection is the single minter) and `config`
122
- # (temporarily withheld until capability-checked forwarding lands).
144
+ # every test), so a regenerated member cannot silently fail to reach the wire.
145
+ # The excluded members are the ones the facade owns rather than forwards:
146
+ # `commandId` (the connection is the single minter) and `config`
147
+ # (temporarily withheld until capability-checked forwarding lands). The owned
148
+ # members are options-only posture the wire never carries (client-local
149
+ # `requirePermissionProfile`, the required-selection check): subtracted from
150
+ # the options side, so a regen that adds a same-named wire member reds here.
123
151
  _START_EXCLUDED = {"commandId", "config"}
124
- assert {_camel(f.name) for f in fields(StartSessionOptions)} | _START_EXCLUDED == set(
152
+ _START_OWNED = {"requirePermissionProfile"}
153
+ assert (
154
+ {_camel(f.name) for f in fields(StartSessionOptions)} - _START_OWNED
155
+ ) | _START_EXCLUDED == set(
125
156
  SessionStartParams.__annotations__
126
157
  ), "StartSessionOptions drifted from SessionStartParams — forward the new member"
127
158
 
@@ -131,6 +162,21 @@ assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED
131
162
  ), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
132
163
 
133
164
 
165
+ def _committed_profile_id(snapshot: Any) -> str | None:
166
+ """The committed profile id a session snapshot claims, or None when the
167
+ snapshot is silent (the stable bundle strips the member; absent and null
168
+ both mean unknown, never applied). Structural on purpose: an unknown wire
169
+ shape reads as unknown rather than raising.
170
+ """
171
+ if not isinstance(snapshot, Mapping):
172
+ return None
173
+ state = snapshot.get("permissionState")
174
+ if not isinstance(state, Mapping):
175
+ return None
176
+ profile_id = state.get("profileId")
177
+ return profile_id if isinstance(profile_id, str) else None
178
+
179
+
134
180
  @dataclass
135
181
  class MuseClientOptions:
136
182
  """Options for building a ``MuseClient`` around a connection you own.
@@ -204,6 +250,14 @@ class MuseClient(Generic[_I]):
204
250
  # sharing knob would be declared-ahead surface (Constitution XI).
205
251
  self._discarded = DiscardedSessions()
206
252
  self._sessions: Set[Session[_I]] = set()
253
+ # Frames naming a session this client has not registered YET, held
254
+ # only while a ``session/start``/``session/resume`` is in flight: the
255
+ # host flushes the resume replay BEFORE the result, and the coroutine
256
+ # awaiting that result resumes only after the whole chunk has been
257
+ # routed. ``_route`` holds, ``_open_session`` drains,
258
+ # ``_open`` clears when the last open settles.
259
+ self._opening = 0
260
+ self._held: dict[str, List[Mapping[str, Any]]] = {}
207
261
  host = options.host
208
262
  self._child: MuseServeChild | None = host.child if host is not None else None
209
263
  self._initialize_result: InitializeResult | None = (
@@ -239,6 +293,19 @@ class MuseClient(Generic[_I]):
239
293
 
240
294
  Returns:
241
295
  A wired client, its connection already initialized.
296
+
297
+ Raises:
298
+ MuseHostStartError: The host ended before the handshake
299
+ completed; it carries the exit classification row, the
300
+ bounded stderr tail, and ``answered_initialize`` (``True``
301
+ when the host answered ``initialize`` and died while this
302
+ client sent ``initialized``). A host whose answer was itself
303
+ the failure — its own error, a fingerprint mismatch or a
304
+ malformed result — keeps that error; an ``initialize`` frame
305
+ this client cannot encode (for example a non-UTF-8
306
+ ``client_info``) keeps its ``ProtocolError``; a cancellation
307
+ stays a cancellation; and a binary that cannot be started
308
+ raises the operating system's own error from the spawn call.
242
309
  """
243
310
  handshake = await spawn_msp_connection(
244
311
  options.muse_bin,
@@ -260,7 +327,7 @@ class MuseClient(Generic[_I]):
260
327
  init_params["capabilities"] = options.capabilities
261
328
  try:
262
329
  spawned = await handshake.initialize(init_params)
263
- except BaseException:
330
+ except BaseException as error:
264
331
  # A failed handshake must not leak the process it already spawned.
265
332
  # (``initialize`` already closes on its own failure paths; this is
266
333
  # the belt-and-braces close for anything that escapes it.)
@@ -268,7 +335,22 @@ class MuseClient(Generic[_I]):
268
335
  await handshake.close()
269
336
  except BaseException:
270
337
  pass
271
- raise
338
+ # A cancellation or interrupt is the caller's, a frame that never
339
+ # left this process is the caller's, and a host that ANSWERED was
340
+ # not lost: decided before the exit is read, so our own close
341
+ # ladder can never dress any of them up as a death.
342
+ if not isinstance(error, Exception) or keeps_its_own_error(error):
343
+ raise
344
+ # The host was lost: before answering, or after a usable answer
345
+ # while we sent ``initialized`` (the error then says so). close()
346
+ # ended at its observed exit, so the row is settled: raise it with
347
+ # the stderr tail instead of the transport's first symptom.
348
+ child = handshake.child
349
+ raise MuseHostStartError(
350
+ await child.exit,
351
+ child.stderr_tail,
352
+ answered_initialize=handshake._answered,
353
+ ) from error
272
354
  return MuseClient(
273
355
  spawned.connection,
274
356
  MuseClientOptions(
@@ -330,15 +412,61 @@ class MuseClient(Generic[_I]):
330
412
  params["workspaceRoots"] = options.workspace_roots
331
413
  if options.model_id is not None:
332
414
  params["modelId"] = options.model_id
333
- raw = await self._connection.command("session/start", params)
334
- result: Mapping[str, Any] = raw
415
+ # The initial permission-profile selection, forwarded verbatim like the
416
+ # other start members (None stays omitted).
417
+ if options.permission_profile is not None:
418
+ params["permissionProfile"] = options.permission_profile
419
+ # Required selection: a caller that requires its selection proves it,
420
+ # never assuming the host default. The flag without the member is a
421
+ # usage error, raised pre-wire.
422
+ required = options.require_permission_profile is True
423
+ if required and options.permission_profile is None:
424
+ raise ValueError(
425
+ "require_permission_profile needs permission_profile:"
426
+ " require the selection you pass"
427
+ )
335
428
  # Keyed by the id the SERVER named, never the one the caller asked for:
336
429
  # ``sessionId`` is a REQUEST on start, and a session keyed by a hopeful
337
430
  # id would reject every one of its own events as foreign.
338
- return self._open_session(
339
- str(result["session"]["sessionId"]),
340
- _StartOpening(result=result),
431
+ session = await self._open("session/start", params, _StartOpening)
432
+ if not required:
433
+ return session
434
+ assert options.permission_profile is not None # the usage guard above
435
+ return await self._prove_selection(session, options.permission_profile)
436
+
437
+ async def _prove_selection(
438
+ self, session: Session[_I], requested: str
439
+ ) -> Session[_I]:
440
+ """Prove a required start selection: the start result's own
441
+ ``session.permissionState`` first (absent = unknown,
442
+ never applied); else a point-in-time ``session/read``, which never
443
+ subscribes. Only ``profileId == requested`` proceeds; anything else
444
+ raises ``PermissionProfileNotApplied`` BEFORE any turn is sent. The
445
+ refused session is dropped from the client (no orphan keeps folding
446
+ for an id the caller can never reach — the SDK has no set/close-by-id
447
+ verb); the error's ``session_id`` is the caller's handle to resume via
448
+ ``session_id`` or start again. A failed read-back propagates: the
449
+ client still never proceeds on the default.
450
+ """
451
+ opening = session.opening
452
+ started = (
453
+ _committed_profile_id(opening.result["session"])
454
+ if isinstance(opening, _StartOpening)
455
+ else None
456
+ )
457
+ if started is not None:
458
+ if started == requested:
459
+ return session
460
+ self._sessions.discard(session)
461
+ raise PermissionProfileNotApplied(requested, started, session.session_id)
462
+ read = await self._connection.command(
463
+ "session/read", {"sessionId": session.session_id}
341
464
  )
465
+ served = _committed_profile_id(read.get("session"))
466
+ if served == requested:
467
+ return session
468
+ self._sessions.discard(session)
469
+ raise PermissionProfileNotApplied(requested, served, session.session_id)
342
470
 
343
471
  async def resume_session(self, options: ResumeSessionOptions) -> Session[_I]:
344
472
  """Load an existing session and subscribe this connection to its view.
@@ -355,12 +483,7 @@ class MuseClient(Generic[_I]):
355
483
  params["excludeItems"] = options.exclude_items
356
484
  if options.history is not None:
357
485
  params["history"] = options.history
358
- raw = await self._connection.command("session/resume", params)
359
- result: Mapping[str, Any] = raw
360
- return self._open_session(
361
- str(result["session"]["sessionId"]),
362
- _ResumeOpening(result=result),
363
- )
486
+ return await self._open("session/resume", params, _ResumeOpening)
364
487
 
365
488
  async def close(self) -> None:
366
489
  """Shut the host down in an orderly way.
@@ -384,6 +507,29 @@ class MuseClient(Generic[_I]):
384
507
  return
385
508
  await self._connection.close()
386
509
 
510
+ async def _open(
511
+ self,
512
+ verb: str,
513
+ params: dict[str, Any],
514
+ opening: Callable[[Mapping[str, Any]], SessionOpening],
515
+ ) -> Session[_I]:
516
+ # The window ``_route`` holds unknown-session frames for: from the
517
+ # request to the Session's registration. Counted, not flagged, so two
518
+ # concurrent opens keep it open until the LAST settles; cleared on
519
+ # settle so a frame that named no session this client opened is held
520
+ # no longer than the open it rode in with.
521
+ self._opening += 1
522
+ try:
523
+ raw = await self._connection.command(verb, params)
524
+ result: Mapping[str, Any] = raw
525
+ return self._open_session(
526
+ str(result["session"]["sessionId"]), opening(result)
527
+ )
528
+ finally:
529
+ self._opening -= 1
530
+ if self._opening == 0:
531
+ self._held.clear()
532
+
387
533
  def _open_session(
388
534
  self, session_id: str, opening: SessionOpening
389
535
  ) -> Session[_I]:
@@ -395,6 +541,11 @@ class MuseClient(Generic[_I]):
395
541
  opening=opening,
396
542
  )
397
543
  self._sessions.add(session)
544
+ # The frames that named this session while its verb was in flight
545
+ # fold NOW, in arrival order — synchronously, so nothing the pump
546
+ # routes next can overtake them.
547
+ for held in self._held.pop(session_id, ()):
548
+ session.apply(held)
398
549
  return session
399
550
 
400
551
  def _assert_reattach_allowed(self, session_id: str) -> None:
@@ -414,16 +565,25 @@ class MuseClient(Generic[_I]):
414
565
 
415
566
  def _route(self, notification: Mapping[str, Any]) -> None:
416
567
  # Deliver one view notification to the session(s) it names. A frame
417
- # naming NO session is dropped here rather than fed to every session,
418
- # and a frame naming an UNKNOWN session is dropped for the same reason
568
+ # naming NO session is dropped here rather than fed to every session
419
569
  # — routing it anywhere would trip the foreign-frame throw.
420
570
  params = notification.get("params")
421
571
  named = params.get("sessionId") if isinstance(params, dict) else None
422
572
  if not isinstance(named, str):
423
573
  return
574
+ delivered = False
424
575
  for session in self._sessions:
425
576
  if session.session_id == named:
426
577
  session.apply(notification)
578
+ delivered = True
579
+ # An UNKNOWN session with no open in flight is dropped, for the same
580
+ # reason as a nameless frame.
581
+ if delivered or self._opening == 0:
582
+ return
583
+ # An open is in flight and this may be ITS session, named by the
584
+ # server before the awaiting coroutine has run: hold it for
585
+ # ``_open_session`` to fold, or for ``_open`` to clear.
586
+ self._held.setdefault(named, []).append(notification)
427
587
 
428
588
  async def _watch_closed(self) -> None:
429
589
  try: