muse-code-sdk 1.4.15__tar.gz → 1.4.20__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 (88) hide show
  1. {muse_code_sdk-1.4.15/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.20}/PKG-INFO +54 -2
  2. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/README.md +52 -0
  3. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/pyproject.toml +2 -2
  4. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/__init__.py +11 -1
  5. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/connection/connection.py +59 -9
  6. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/connection/spawn.py +2 -1
  7. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/errors.py +7 -5
  8. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/__init__.py +10 -0
  9. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/client.py +241 -19
  10. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/host_death.py +5 -1
  11. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/session.py +109 -9
  12. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/sync_facade.py +69 -21
  13. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20/src/muse_code_sdk.egg-info}/PKG-INFO +54 -2
  14. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/SOURCES.txt +5 -0
  15. muse_code_sdk-1.4.20/src/muse_code_sdk.egg-info/requires.txt +2 -0
  16. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_compatibility_row.py +5 -3
  17. muse_code_sdk-1.4.20/tests/test_connection_retryable_override.py +91 -0
  18. muse_code_sdk-1.4.20/tests/test_facade_client_observe.py +594 -0
  19. muse_code_sdk-1.4.20/tests/test_facade_host_exit_cause.py +245 -0
  20. muse_code_sdk-1.4.20/tests/test_facade_raw_command.py +320 -0
  21. muse_code_sdk-1.4.20/tests/test_facade_session_events.py +434 -0
  22. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_wire_queries.py +59 -5
  23. muse_code_sdk-1.4.20/tests/test_needs_input_wire_types.py +71 -0
  24. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_sync_facade.py +26 -0
  25. muse_code_sdk-1.4.15/src/muse_code_sdk.egg-info/requires.txt +0 -2
  26. muse_code_sdk-1.4.15/tests/test_facade_raw_command.py +0 -162
  27. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/LICENSE +0 -0
  28. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/setup.cfg +0 -0
  29. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/connection/__init__.py +0 -0
  30. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/connection/discovery.py +0 -0
  31. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/connection/validation.py +0 -0
  32. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/approval.py +0 -0
  33. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/discarded.py +0 -0
  34. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/gap_fill.py +0 -0
  35. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/host_start.py +0 -0
  36. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/run.py +0 -0
  37. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/turn_handle.py +0 -0
  38. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/facade/turn_submit.py +0 -0
  39. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/fold/__init__.py +0 -0
  40. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/fold/item_store.py +0 -0
  41. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/fold/session_fold.py +0 -0
  42. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/fold/state_store.py +0 -0
  43. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/pending/__init__.py +0 -0
  44. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/pending/pending_command_set.py +0 -0
  45. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/py.typed +0 -0
  46. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code/replay.py +0 -0
  47. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  48. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  49. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_asyncio_cancellation.py +0 -0
  50. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_connection.py +0 -0
  51. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_connection_error_arms.py +0 -0
  52. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_docs_generation.py +0 -0
  53. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_external_audience.py +0 -0
  54. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_approval.py +0 -0
  55. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_approval_resend.py +0 -0
  56. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_control_verbs.py +0 -0
  57. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_gap_fill.py +0 -0
  58. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_open_same_chunk.py +0 -0
  59. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_persistent_approval.py +0 -0
  60. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_required_selection.py +0 -0
  61. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_run.py +0 -0
  62. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_session_mcp.py +0 -0
  63. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_turn.py +0 -0
  64. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_unqueue_retirement.py +0 -0
  65. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_facade_user_input_resend.py +0 -0
  66. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_fingerprint_vectors.py +0 -0
  67. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_host_discovery.py +0 -0
  68. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_item_store.py +0 -0
  69. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_manifests.py +0 -0
  70. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_msp_py_fingerprint_derivation.py +0 -0
  71. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_msp_py_host_version_derivation.py +0 -0
  72. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_muse_serve_child.py +0 -0
  73. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_pending_command_corpus.py +0 -0
  74. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_pending_command_set.py +0 -0
  75. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_pending_command_wire_binding.py +0 -0
  76. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_permission_state_wire_types.py +0 -0
  77. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_public_mirror_text.py +0 -0
  78. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_publish_shape.py +0 -0
  79. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_recovery_observation.py +0 -0
  80. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_run_abort_busy_unqueue_real_binary.py +0 -0
  81. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_session_fold.py +0 -0
  82. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_source_closure.py +0 -0
  83. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_spawn_handshake.py +0 -0
  84. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_spawn_host_lost.py +0 -0
  85. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_state_store.py +0 -0
  86. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_tolerance_corpus.py +0 -0
  87. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/tests/test_transcript_replay.py +0 -0
  88. {muse_code_sdk-1.4.15 → muse_code_sdk-1.4.20}/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.15
3
+ Version: 1.4.20
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
@@ -15,7 +15,7 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: pydantic==2.13.5
18
- Requires-Dist: muse-code-msp==1.4.15
18
+ Requires-Dist: muse-code-msp==1.4.20
19
19
  Dynamic: license-file
20
20
 
21
21
  # `muse-code-sdk`
@@ -28,6 +28,8 @@ wrapper for scripts and notebooks.
28
28
  one you already run.
29
29
  - Stream a turn's items and text deltas as async iterators; await the turn's
30
30
  server-authored outcome.
31
+ - Stream what each incoming event changed in a session with
32
+ `session.events()`, to update a view incrementally.
31
33
  - Answer tool-approval requests with a handler you register, or pass it as
32
34
  `ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
33
35
  its failures, so approvals still pending at resume reach it.
@@ -37,6 +39,9 @@ wrapper for scripts and notebooks.
37
39
  - A client-side fold keeps a live, typed view of the session — items, session
38
40
  state, pending commands — with gap recovery after reconnects, and safe
39
41
  same-command retry that can never double-submit.
42
+ - Observe the connection itself: every notification the host sends (including
43
+ ones that name no session, such as `usage/changed`), messages the SDK
44
+ rejects as malformed, and the connection closing.
40
45
  - `SyncMuseClient` mirrors the same surface with blocking verbs.
41
46
 
42
47
  Status: developer preview. Experimental protocol surfaces may change.
@@ -112,6 +117,29 @@ The session itself can `fork`, `compact`, `rename`, and `leave`, and switch its
112
117
  model, reasoning effort, or approval mode (`set_model`, `set_reasoning_effort`,
113
118
  `set_approval_mode`); `client.list_models()` lists the models it accepts.
114
119
 
120
+ To render incrementally, read `session.events()`: every event the session
121
+ folds from then on, with what it changed. Read the current state first, then
122
+ subscribe before the next `await`, and nothing is missed, including the events
123
+ that arrived before `start_session` or `resume_session` returned:
124
+
125
+ ```python
126
+ import contextlib
127
+
128
+ render_all(session.fold) # the state so far
129
+ async with contextlib.aclosing(session.events()) as events:
130
+ async for event in events:
131
+ # `event.item` is the item as of this event; `session.fold` may be ahead.
132
+ render(event.frame["method"], event.outcome.fold, event.item)
133
+ ```
134
+
135
+ Each event arrives once, in order, including events the SDK fetches to fill a
136
+ gap in delivery. `aclosing` stops listening when the loop ends or breaks (or
137
+ call `aclose()`). The stream ends when an ephemeral host dies and raises
138
+ `MuseHostDiedError` when a durable one does, as `turn.items()` does. Reading
139
+ it changes nothing else: `turn.unqueue()` still returns the queued input it
140
+ takes back, and that input is not repeated on an event. `SyncSession.events()`
141
+ is the blocking version: `for event in session.events(): ...`.
142
+
115
143
  To run a session and the child agents it starts without approval prompts,
116
144
  start it under the built-in `:unrestricted` permission profile. Current
117
145
  releases admit every child of such a session without prompts; a child admitted
@@ -153,6 +181,30 @@ mode, but its child agents are capped by the session's permission profile.
153
181
  Under a prompting profile (such as `:ask-me`, whose approval is `on_request`),
154
182
  the children still ask.
155
183
 
184
+ ## Observe the connection
185
+
186
+ The client routes each event to its session, and also lets you listen to the
187
+ raw feed. Each `on_*` call returns a function that removes that one listener:
188
+
189
+ ```python
190
+ unsubscribe = client.on_notification(
191
+ lambda notification: print(notification["method"], notification.get("params"))
192
+ )
193
+ client.on_notification_error(lambda error, notification: print("listener failed:", error))
194
+ client.on_protocol_error(lambda error: print("rejected frame:", error.line))
195
+ await client.closed # resolves when the connection closes, for any reason
196
+ unsubscribe()
197
+ ```
198
+
199
+ A notification for a session you opened reaches your listener after the
200
+ session has applied it, except during a gap fill (the event is folded when
201
+ the fill completes) or while `start_session`/`resume_session` is still running
202
+ (it is folded before the call returns). Listeners run as frames are read, so keep them quick;
203
+ an `async def` listener runs as a task. An exception from a listener goes to
204
+ `on_notification_error` and never disconnects the client. `SyncMuseClient`
205
+ has the same three `on_*` methods (listeners run while a blocking call is in
206
+ progress) and `wait_closed()` in place of `closed`.
207
+
156
208
  ## Documentation
157
209
 
158
210
  - Guides, cookbook recipes, and the protocol reference:
@@ -8,6 +8,8 @@ wrapper for scripts and notebooks.
8
8
  one you already run.
9
9
  - Stream a turn's items and text deltas as async iterators; await the turn's
10
10
  server-authored outcome.
11
+ - Stream what each incoming event changed in a session with
12
+ `session.events()`, to update a view incrementally.
11
13
  - Answer tool-approval requests with a handler you register, or pass it as
12
14
  `ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
13
15
  its failures, so approvals still pending at resume reach it.
@@ -17,6 +19,9 @@ wrapper for scripts and notebooks.
17
19
  - A client-side fold keeps a live, typed view of the session — items, session
18
20
  state, pending commands — with gap recovery after reconnects, and safe
19
21
  same-command retry that can never double-submit.
22
+ - Observe the connection itself: every notification the host sends (including
23
+ ones that name no session, such as `usage/changed`), messages the SDK
24
+ rejects as malformed, and the connection closing.
20
25
  - `SyncMuseClient` mirrors the same surface with blocking verbs.
21
26
 
22
27
  Status: developer preview. Experimental protocol surfaces may change.
@@ -92,6 +97,29 @@ The session itself can `fork`, `compact`, `rename`, and `leave`, and switch its
92
97
  model, reasoning effort, or approval mode (`set_model`, `set_reasoning_effort`,
93
98
  `set_approval_mode`); `client.list_models()` lists the models it accepts.
94
99
 
100
+ To render incrementally, read `session.events()`: every event the session
101
+ folds from then on, with what it changed. Read the current state first, then
102
+ subscribe before the next `await`, and nothing is missed, including the events
103
+ that arrived before `start_session` or `resume_session` returned:
104
+
105
+ ```python
106
+ import contextlib
107
+
108
+ render_all(session.fold) # the state so far
109
+ async with contextlib.aclosing(session.events()) as events:
110
+ async for event in events:
111
+ # `event.item` is the item as of this event; `session.fold` may be ahead.
112
+ render(event.frame["method"], event.outcome.fold, event.item)
113
+ ```
114
+
115
+ Each event arrives once, in order, including events the SDK fetches to fill a
116
+ gap in delivery. `aclosing` stops listening when the loop ends or breaks (or
117
+ call `aclose()`). The stream ends when an ephemeral host dies and raises
118
+ `MuseHostDiedError` when a durable one does, as `turn.items()` does. Reading
119
+ it changes nothing else: `turn.unqueue()` still returns the queued input it
120
+ takes back, and that input is not repeated on an event. `SyncSession.events()`
121
+ is the blocking version: `for event in session.events(): ...`.
122
+
95
123
  To run a session and the child agents it starts without approval prompts,
96
124
  start it under the built-in `:unrestricted` permission profile. Current
97
125
  releases admit every child of such a session without prompts; a child admitted
@@ -133,6 +161,30 @@ mode, but its child agents are capped by the session's permission profile.
133
161
  Under a prompting profile (such as `:ask-me`, whose approval is `on_request`),
134
162
  the children still ask.
135
163
 
164
+ ## Observe the connection
165
+
166
+ The client routes each event to its session, and also lets you listen to the
167
+ raw feed. Each `on_*` call returns a function that removes that one listener:
168
+
169
+ ```python
170
+ unsubscribe = client.on_notification(
171
+ lambda notification: print(notification["method"], notification.get("params"))
172
+ )
173
+ client.on_notification_error(lambda error, notification: print("listener failed:", error))
174
+ client.on_protocol_error(lambda error: print("rejected frame:", error.line))
175
+ await client.closed # resolves when the connection closes, for any reason
176
+ unsubscribe()
177
+ ```
178
+
179
+ A notification for a session you opened reaches your listener after the
180
+ session has applied it, except during a gap fill (the event is folded when
181
+ the fill completes) or while `start_session`/`resume_session` is still running
182
+ (it is folded before the call returns). Listeners run as frames are read, so keep them quick;
183
+ an `async def` listener runs as a task. An exception from a listener goes to
184
+ `on_notification_error` and never disconnects the client. `SyncMuseClient`
185
+ has the same three `on_*` methods (listeners run while a blocking call is in
186
+ progress) and `wait_closed()` in place of `closed`.
187
+
136
188
  ## Documentation
137
189
 
138
190
  - Guides, cookbook recipes, and the protocol reference:
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "muse-code-sdk"
8
- version = "1.4.15"
8
+ version = "1.4.20"
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"
@@ -18,7 +18,7 @@ license-files = ["LICENSE"]
18
18
  # `pip install muse-code-sdk` must import, and the release bump moves this
19
19
  # package's version and its muse-code-msp pin together; pydantic bumps stay
20
20
  # reviewed PRs (test_publish_shape pins the pairing).
21
- dependencies = ["pydantic==2.13.5", "muse-code-msp==1.4.15"]
21
+ dependencies = ["pydantic==2.13.5", "muse-code-msp==1.4.20"]
22
22
  classifiers = [
23
23
  # Alpha states the withheld promise: publication is permitted, but the
24
24
  # stability promise and conformance claim stay withheld — experimental
@@ -58,15 +58,19 @@ __all__ = [
58
58
  "MuseHostDiscoveryError",
59
59
  "MuseHostStartError",
60
60
  "MuseSessionDiscardedError",
61
+ "NotificationFailureHandler",
62
+ "NotificationObserver",
61
63
  "PendingCommandSet",
62
64
  "PendingCommandView",
63
65
  "PermissionProfileNotApplied",
66
+ "ProtocolErrorObserver",
64
67
  "ResumeSessionOptions",
65
68
  "RunResult",
66
69
  "SendUserTurnOptions",
67
70
  "Session",
68
71
  "SessionApplyOutcome",
69
72
  "SessionDurabilityProfile",
73
+ "SessionEvent",
70
74
  "SessionFold",
71
75
  "SessionFoldView",
72
76
  "SessionGapBuffered",
@@ -82,6 +86,7 @@ __all__ = [
82
86
  "Turn",
83
87
  "TurnOutcome",
84
88
  "TurnUnqueueOutcome",
89
+ "ViewFrame",
85
90
  "is_abnormal_host_death",
86
91
  "is_launch_failure",
87
92
  "load_transcript",
@@ -103,7 +108,7 @@ that lane instead of passing silently.
103
108
  # The lockstep rule: the release train bumps pyproject.toml, the
104
109
  # distribution-metadata authority; this constant is aligned at publication
105
110
  # time.
106
- __version__: Final[str] = "1.4.15"
111
+ __version__: Final[str] = "1.4.20"
107
112
 
108
113
  # The facade is imported last so the constants above are already bound: the
109
114
  # spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
@@ -123,13 +128,17 @@ from .facade import ( # noqa: E402
123
128
  MuseClientSpawnOptions,
124
129
  MuseHostDiedError,
125
130
  MuseHostStartError,
131
+ NotificationFailureHandler,
132
+ NotificationObserver,
126
133
  PendingCommandView,
134
+ ProtocolErrorObserver,
127
135
  ResumeSessionOptions,
128
136
  RunResult,
129
137
  SendUserTurnOptions,
130
138
  Session,
131
139
  SessionApplyOutcome,
132
140
  SessionDurabilityProfile,
141
+ SessionEvent,
133
142
  SessionFoldView,
134
143
  SessionGapBuffered,
135
144
  SessionGapOverlap,
@@ -139,6 +148,7 @@ from .facade import ( # noqa: E402
139
148
  Turn,
140
149
  TurnOutcome,
141
150
  TurnUnqueueOutcome,
151
+ ViewFrame,
142
152
  is_abnormal_host_death,
143
153
  is_launch_failure,
144
154
  read_session_durability,
@@ -101,6 +101,11 @@ class MspError(Exception):
101
101
  class ProtocolError(Exception):
102
102
  """A local framing/correlation violation, never a server-authored error.
103
103
 
104
+ When a call fails because a host ``MuseClient.spawn`` started died, the
105
+ error it raises (usually this class; the transport's own error when a
106
+ write failed) has a ``MuseHostDiedError`` ``__cause__`` saying how the
107
+ host ended, so check ``__cause__`` alone.
108
+
104
109
  Attributes:
105
110
  line: The offending inbound line, preserved as evidence and bounded
106
111
  in UTF-8 bytes, when one exists.
@@ -296,6 +301,10 @@ class Connection:
296
301
  self._buffer_bytes = 0
297
302
  self._dropping_oversized = False
298
303
  self._finished = False
304
+ self._read_peer_end_cause: (
305
+ Callable[[], asyncio.Future[BaseException | None] | None] | None
306
+ ) = None
307
+ self._end_cause: asyncio.Future[BaseException | None] | None = None
299
308
  self._closed: asyncio.Future[None] = self._loop.create_future()
300
309
  self._read_task = self._loop.create_task(self._read_loop())
301
310
 
@@ -324,6 +333,22 @@ class Connection:
324
333
  """
325
334
  return self._submit_tail
326
335
 
336
+ def _peer_end_cause(
337
+ self, read: Callable[[], asyncio.Future[BaseException | None] | None]
338
+ ) -> None:
339
+ """Friend seam: lets the owner of the peer process say, once when the
340
+ connection finishes, why the peer is gone.
341
+
342
+ Module-private, like the TS twin's Symbol fence. A future ``read``
343
+ returns settles (never with an exception) with the ``__cause__`` given
344
+ to every call the connection then fails; ``None`` keeps the bare error
345
+ and its timing.
346
+ """
347
+ self._read_peer_end_cause = read
348
+ # The host can die between the handshake and this registration.
349
+ if self._finished and self._end_cause is None:
350
+ self._end_cause = read()
351
+
327
352
  def mint_command_id(self) -> str:
328
353
  """Takes one id from THIS connection's single command-id minter.
329
354
 
@@ -352,7 +377,7 @@ class Connection:
352
377
  """
353
378
  outcome: asyncio.Future[Params] = self._loop.create_future()
354
379
  if self._finished:
355
- outcome.set_exception(ProtocolError("connection is closed"))
380
+ self._fail_with_end_cause(outcome, ProtocolError("connection is closed"))
356
381
  return outcome
357
382
  request_id = self._mint_request_id()
358
383
  if not _valid_request_id(request_id):
@@ -376,8 +401,7 @@ class Connection:
376
401
  error = done.exception()
377
402
  if error is not None:
378
403
  self._pending.pop(key, None)
379
- if not outcome.done():
380
- outcome.set_exception(error)
404
+ self._fail_with_end_cause(outcome, error)
381
405
 
382
406
  write_outcome.add_done_callback(on_write_done)
383
407
  return outcome
@@ -440,7 +464,8 @@ class Connection:
440
464
  """Sends one logical command with the same-``commandId`` retry contract.
441
465
 
442
466
  Same-id retries on the retryable pair (``backpressured`` /
443
- ``overloaded``) with the bounded full-jitter budget; a value-identical
467
+ ``overloaded``) with the bounded full-jitter budget, unless the error
468
+ carries an explicit ``retryable: false``; a value-identical
444
469
  ack settles a replay; ``inputTooLarge`` (and every other error) is
445
470
  never auto-retried.
446
471
 
@@ -529,9 +554,12 @@ class Connection:
529
554
  remembered.ack = dict(ack)
530
555
  return ack
531
556
  except MspError as error:
532
- retryable_nothing_admitted = (
533
- error.code == -32001 and error.kind == "overloaded"
534
- ) or (error.code == -32031 and error.kind == "backpressured")
557
+ # A present data.retryable overrides the code's table default,
558
+ # so an explicit False stops the retry; absent keeps it.
559
+ retryable_nothing_admitted = error.retryable is not False and (
560
+ (error.code == -32001 and error.kind == "overloaded")
561
+ or (error.code == -32031 and error.kind == "backpressured")
562
+ )
535
563
  if not retryable_nothing_admitted or attempt == attempts:
536
564
  raise
537
565
  await delay(attempt, error)
@@ -878,6 +906,27 @@ class Connection:
878
906
  if error is not None:
879
907
  self._finish(error)
880
908
 
909
+ def _fail_with_end_cause(
910
+ self, outcome: asyncio.Future[Any], error: BaseException
911
+ ) -> None:
912
+ """Fails ``outcome`` with ``error``, first giving it the peer's end
913
+ cause when the owner reported one."""
914
+ end_cause = self._end_cause
915
+ if end_cause is None or error.__cause__ is not None:
916
+ if not outcome.done():
917
+ outcome.set_exception(error)
918
+ return
919
+
920
+ def settle(done: asyncio.Future[BaseException | None]) -> None:
921
+ if not done.cancelled():
922
+ cause = done.result()
923
+ if cause is not None:
924
+ error.__cause__ = cause
925
+ if not outcome.done():
926
+ outcome.set_exception(error)
927
+
928
+ end_cause.add_done_callback(settle)
929
+
881
930
  def _protocol_error(self, error: ProtocolError) -> None:
882
931
  if self._protocol_error_handler is not None:
883
932
  self._protocol_error_handler(error)
@@ -891,9 +940,10 @@ class Connection:
891
940
  if isinstance(reason, BaseException)
892
941
  else ProtocolError(str(reason))
893
942
  )
943
+ if self._read_peer_end_cause is not None:
944
+ self._end_cause = self._read_peer_end_cause()
894
945
  for pending in self._pending.values():
895
- if not pending.future.done():
896
- pending.future.set_exception(error)
946
+ self._fail_with_end_cause(pending.future, error)
897
947
  self._pending.clear()
898
948
  if not self._closed.done():
899
949
  self._closed.set_result(None)
@@ -55,7 +55,8 @@ SIGTERM_GRACE_MS = 2_000
55
55
  its whole drain window, so this covers only a signal handler's final flush.
56
56
  Also bounds ``_classified``'s post-exit stderr-drain wait (py3.10 settles
57
57
  ``wait()`` at the reap, before pipe EOF), so tuning it down also shrinks the
58
- window for capturing a host's final stderr evidence."""
58
+ window for capturing a host's final stderr evidence. And it bounds how long a
59
+ call failed by a host death waits for the exit."""
59
60
  OnStderr = Callable[[str], None]
60
61
 
61
62
 
@@ -117,9 +117,10 @@ class PermissionProfileNotApplied(Exception):
117
117
 
118
118
 
119
119
  class MuseFacadeOwnedMethodError(Exception):
120
- """``MuseClient.command`` was asked to send a method whose state the
121
- facade owns. Sending it raw would bypass that state, so it is refused
122
- before anything is written; use the facade verb instead.
120
+ """``MuseClient.command`` or ``MuseClient.request`` was asked to send a
121
+ method whose state the facade owns. Sending it raw would bypass that
122
+ state, so it is refused before anything is written; use the facade verb
123
+ instead.
123
124
 
124
125
  Attributes:
125
126
  method: The refused wire method.
@@ -133,7 +134,8 @@ class MuseFacadeOwnedMethodError(Exception):
133
134
  """
134
135
  super().__init__(
135
136
  f"{method} is sent by the facade itself and cannot go through "
136
- "MuseClient.command; use the facade verb that owns it"
137
+ "MuseClient.command or MuseClient.request; use the facade verb that "
138
+ "owns it"
137
139
  )
138
140
  self.method: str = method
139
141
 
@@ -205,7 +207,7 @@ class MuseValidationError(ValueError):
205
207
  self.__cause__ = cause
206
208
 
207
209
 
208
- COMPATIBILITY_PAGE_URL = "https://meta-models.github.io/muse-code-sdk/compatibility/"
210
+ COMPATIBILITY_PAGE_URL = "https://meta-models.github.io/muse-code-sdk/latest/compatibility/"
209
211
  """The developer-docs compatibility page the discovery errors cite.
210
212
 
211
213
  The Python row there is the customer-facing statement of the supported
@@ -24,6 +24,9 @@ from .client import (
24
24
  MuseClient,
25
25
  MuseClientOptions,
26
26
  MuseClientSpawnOptions,
27
+ NotificationFailureHandler,
28
+ NotificationObserver,
29
+ ProtocolErrorObserver,
27
30
  ResumeSessionOptions,
28
31
  StartSessionOptions,
29
32
  )
@@ -45,10 +48,12 @@ from .session import (
45
48
  Session,
46
49
  SessionApplyOutcome,
47
50
  SessionApplyRefusal,
51
+ SessionEvent,
48
52
  SessionFoldView,
49
53
  SessionGapBuffered,
50
54
  SessionGapOverlap,
51
55
  SessionOpening,
56
+ ViewFrame,
52
57
  )
53
58
  from .run import RunResult
54
59
  from .turn_handle import FoldedItem, Turn, TurnOutcome, TurnUnqueueOutcome, is_launch_failure
@@ -70,7 +75,10 @@ __all__ = [
70
75
  "MuseClientSpawnOptions",
71
76
  "MuseHostDiedError",
72
77
  "MuseHostStartError",
78
+ "NotificationFailureHandler",
79
+ "NotificationObserver",
73
80
  "PendingCommandView",
81
+ "ProtocolErrorObserver",
74
82
  "ResumeSessionOptions",
75
83
  "RunResult",
76
84
  "SendUserTurnOptions",
@@ -78,6 +86,7 @@ __all__ = [
78
86
  "SessionApplyOutcome",
79
87
  "SessionApplyRefusal",
80
88
  "SessionDurabilityProfile",
89
+ "SessionEvent",
81
90
  "SessionFoldView",
82
91
  "SessionGapBuffered",
83
92
  "SessionGapOverlap",
@@ -88,6 +97,7 @@ __all__ = [
88
97
  "Turn",
89
98
  "TurnOutcome",
90
99
  "TurnUnqueueOutcome",
100
+ "ViewFrame",
91
101
  "is_abnormal_host_death",
92
102
  "is_launch_failure",
93
103
  "read_session_durability",