muse-code-sdk 1.4.14__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.14/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.20}/PKG-INFO +93 -13
  2. muse_code_sdk-1.4.20/README.md +194 -0
  3. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/pyproject.toml +2 -2
  4. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/__init__.py +31 -7
  5. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/connection.py +59 -9
  6. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/discovery.py +5 -4
  7. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/spawn.py +2 -1
  8. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/errors.py +53 -4
  9. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/__init__.py +16 -2
  10. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/client.py +448 -28
  11. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/host_death.py +5 -1
  12. muse_code_sdk-1.4.20/src/muse_code/facade/run.py +208 -0
  13. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/session.py +575 -10
  14. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/turn_handle.py +126 -10
  15. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/turn_submit.py +129 -28
  16. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/session_fold.py +76 -5
  17. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/sync_facade.py +324 -27
  18. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20/src/muse_code_sdk.egg-info}/PKG-INFO +93 -13
  19. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/SOURCES.txt +16 -0
  20. muse_code_sdk-1.4.20/src/muse_code_sdk.egg-info/requires.txt +2 -0
  21. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_compatibility_row.py +250 -28
  22. muse_code_sdk-1.4.20/tests/test_connection_retryable_override.py +91 -0
  23. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_external_audience.py +23 -0
  24. muse_code_sdk-1.4.20/tests/test_facade_client_observe.py +594 -0
  25. muse_code_sdk-1.4.20/tests/test_facade_control_verbs.py +709 -0
  26. muse_code_sdk-1.4.20/tests/test_facade_host_exit_cause.py +245 -0
  27. muse_code_sdk-1.4.20/tests/test_facade_raw_command.py +320 -0
  28. muse_code_sdk-1.4.20/tests/test_facade_run.py +719 -0
  29. muse_code_sdk-1.4.20/tests/test_facade_session_events.py +434 -0
  30. muse_code_sdk-1.4.20/tests/test_facade_session_mcp.py +211 -0
  31. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_turn.py +38 -0
  32. muse_code_sdk-1.4.20/tests/test_facade_unqueue_retirement.py +238 -0
  33. muse_code_sdk-1.4.20/tests/test_facade_wire_queries.py +371 -0
  34. muse_code_sdk-1.4.20/tests/test_fingerprint_vectors.py +86 -0
  35. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_host_discovery.py +98 -1
  36. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_manifests.py +35 -12
  37. muse_code_sdk-1.4.20/tests/test_msp_py_fingerprint_derivation.py +490 -0
  38. muse_code_sdk-1.4.20/tests/test_msp_py_host_version_derivation.py +274 -0
  39. muse_code_sdk-1.4.20/tests/test_needs_input_wire_types.py +71 -0
  40. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_public_mirror_text.py +4 -1
  41. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_publish_shape.py +1 -1
  42. muse_code_sdk-1.4.20/tests/test_run_abort_busy_unqueue_real_binary.py +491 -0
  43. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_session_fold.py +155 -3
  44. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_source_closure.py +5 -3
  45. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_sync_facade.py +71 -38
  46. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_typing_gates.py +71 -0
  47. muse_code_sdk-1.4.14/README.md +0 -114
  48. muse_code_sdk-1.4.14/src/muse_code_sdk.egg-info/requires.txt +0 -2
  49. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/LICENSE +0 -0
  50. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/setup.cfg +0 -0
  51. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/__init__.py +0 -0
  52. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/validation.py +0 -0
  53. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/approval.py +0 -0
  54. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/discarded.py +0 -0
  55. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/gap_fill.py +0 -0
  56. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/host_start.py +0 -0
  57. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/__init__.py +0 -0
  58. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/item_store.py +0 -0
  59. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/state_store.py +0 -0
  60. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/pending/__init__.py +0 -0
  61. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/pending/pending_command_set.py +0 -0
  62. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/py.typed +0 -0
  63. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/replay.py +0 -0
  64. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  65. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  66. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_asyncio_cancellation.py +0 -0
  67. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_connection.py +0 -0
  68. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_connection_error_arms.py +0 -0
  69. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_docs_generation.py +0 -0
  70. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_approval.py +0 -0
  71. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_approval_resend.py +0 -0
  72. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_gap_fill.py +0 -0
  73. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_open_same_chunk.py +0 -0
  74. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_persistent_approval.py +0 -0
  75. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_required_selection.py +0 -0
  76. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_user_input_resend.py +0 -0
  77. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_item_store.py +0 -0
  78. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_muse_serve_child.py +0 -0
  79. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_pending_command_corpus.py +0 -0
  80. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_pending_command_set.py +0 -0
  81. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_pending_command_wire_binding.py +0 -0
  82. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_permission_state_wire_types.py +0 -0
  83. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_recovery_observation.py +0 -0
  84. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_spawn_handshake.py +0 -0
  85. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_spawn_host_lost.py +0 -0
  86. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_state_store.py +0 -0
  87. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_tolerance_corpus.py +0 -0
  88. {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_transcript_replay.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muse-code-sdk
3
- Version: 1.4.14
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.14
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.
@@ -61,29 +66,80 @@ path passed at spawn time.
61
66
  ```python
62
67
  import asyncio
63
68
 
64
- from muse_code import (
65
- MuseClient, MuseClientSpawnOptions, SendUserTurnOptions, StartSessionOptions,
66
- )
69
+ from muse_code import MuseClient, MuseClientSpawnOptions, StartSessionOptions
67
70
 
68
71
  async def main() -> None:
69
72
  client = await MuseClient.spawn(MuseClientSpawnOptions(
70
- muse_bin="muse",
73
+ muse_bin=None, # None finds `muse` on PATH; or pass a path
71
74
  args=["serve"],
72
75
  client_info={"name": "my-app", "version": "1.0.0"},
73
76
  ))
74
77
  session = await client.start_session(StartSessionOptions(workspace_root="."))
75
- turn = await session.send_user_turn(SendUserTurnOptions(
76
- input=[{"type": "text", "text": "Summarize this repository."}],
77
- composer_input="Summarize this repository.",
78
- ))
79
- async for item in turn.items():
80
- print(item)
81
- await turn.completed
78
+ result = await session.run("Summarize this repository.")
79
+ print(result.final_text)
82
80
  await client.close()
83
81
 
84
82
  asyncio.run(main())
85
83
  ```
86
84
 
85
+ `session.run` sends one turn and returns when the turn has ended, with the
86
+ outcome, the final answer text, the turn's items, and its token usage. A turn
87
+ that failed or was cancelled still returns; read `result.outcome` to tell. The
88
+ final answer is the last completed agent message that is not commentary. Pass a
89
+ list of input parts instead of a string, and turn options as keywords:
90
+ `await session.run(parts, reasoning_effort="high")`.
91
+
92
+ To stop it, cancel the task that awaits it, for example with
93
+ `asyncio.timeout(60)` on Python 3.11 or newer. A turn still waiting in the
94
+ queue is taken back, and a running turn is cancelled; `run` then waits for
95
+ the turn to end and re-raises `CancelledError`. `SyncSession.run` is the
96
+ blocking version, with no way to cancel.
97
+
98
+ To follow a turn as it runs, send it yourself and read its items (paste
99
+ inside `main()` above, before `await client.close()`; add
100
+ `SendUserTurnOptions` to the import):
101
+
102
+ ```python
103
+ turn = await session.send_user_turn(SendUserTurnOptions(
104
+ input=[{"type": "text", "text": "Summarize this repository."}],
105
+ composer_input="Summarize this repository.",
106
+ ))
107
+ async for item in turn.items():
108
+ print(item)
109
+ await turn.completed
110
+ ```
111
+
112
+ To stop a running turn, `await turn.interrupt()` (the stop gesture; pass
113
+ `retract=True` to also take the prompt back) or `await turn.cancel()`. Both are
114
+ requests: the turn is over when `turn.completed` settles. `turn.steer(...)`
115
+ adds input to the running turn, and `turn.unqueue()` takes back a queued one.
116
+ The session itself can `fork`, `compact`, `rename`, and `leave`, and switch its
117
+ model, reasoning effort, or approval mode (`set_model`, `set_reasoning_effort`,
118
+ `set_approval_mode`); `client.list_models()` lists the models it accepts.
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
+
87
143
  To run a session and the child agents it starts without approval prompts,
88
144
  start it under the built-in `:unrestricted` permission profile. Current
89
145
  releases admit every child of such a session without prompts; a child admitted
@@ -125,6 +181,30 @@ mode, but its child agents are capped by the session's permission profile.
125
181
  Under a prompting profile (such as `:ask-me`, whose approval is `on_request`),
126
182
  the children still ask.
127
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
+
128
208
  ## Documentation
129
209
 
130
210
  - Guides, cookbook recipes, and the protocol reference:
@@ -0,0 +1,194 @@
1
+ # `muse-code-sdk`
2
+
3
+ The Python SDK for Muse Code: an asyncio-first client for driving a Muse
4
+ Code agent over the Muse Session Protocol (MSP), with a thin synchronous
5
+ wrapper for scripts and notebooks.
6
+
7
+ - Open and resume sessions against a spawned `muse serve` host, or attach to
8
+ one you already run.
9
+ - Stream a turn's items and text deltas as async iterators; await the turn's
10
+ server-authored outcome.
11
+ - Stream what each incoming event changed in a session with
12
+ `session.events()`, to update a view incrementally.
13
+ - Answer tool-approval requests with a handler you register, or pass it as
14
+ `ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
15
+ its failures, so approvals still pending at resume reach it.
16
+ - Answer the agent's questions: list them with
17
+ `session.fold.pending_user_inputs()` and answer with
18
+ `session.answer_user_input`.
19
+ - A client-side fold keeps a live, typed view of the session — items, session
20
+ state, pending commands — with gap recovery after reconnects, and safe
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.
25
+ - `SyncMuseClient` mirrors the same surface with blocking verbs.
26
+
27
+ Status: developer preview. Experimental protocol surfaces may change.
28
+
29
+ The generated MSP wire types live in the companion package
30
+ [`muse-code-msp`](https://pypi.org/project/muse-code-msp/) and are never
31
+ restated here; it is installed with this package, pinned to the same version.
32
+ One further runtime dependency: `pydantic`, exact-pinned.
33
+ Python 3.10+. Fully typed (`py.typed`).
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ pip install muse-code-sdk
39
+ ```
40
+
41
+ You also need the Muse Code CLI: a `muse` binary on `PATH`, or an explicit
42
+ path passed at spawn time.
43
+
44
+ ## Use
45
+
46
+ ```python
47
+ import asyncio
48
+
49
+ from muse_code import MuseClient, MuseClientSpawnOptions, StartSessionOptions
50
+
51
+ async def main() -> None:
52
+ client = await MuseClient.spawn(MuseClientSpawnOptions(
53
+ muse_bin=None, # None finds `muse` on PATH; or pass a path
54
+ args=["serve"],
55
+ client_info={"name": "my-app", "version": "1.0.0"},
56
+ ))
57
+ session = await client.start_session(StartSessionOptions(workspace_root="."))
58
+ result = await session.run("Summarize this repository.")
59
+ print(result.final_text)
60
+ await client.close()
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ `session.run` sends one turn and returns when the turn has ended, with the
66
+ outcome, the final answer text, the turn's items, and its token usage. A turn
67
+ that failed or was cancelled still returns; read `result.outcome` to tell. The
68
+ final answer is the last completed agent message that is not commentary. Pass a
69
+ list of input parts instead of a string, and turn options as keywords:
70
+ `await session.run(parts, reasoning_effort="high")`.
71
+
72
+ To stop it, cancel the task that awaits it, for example with
73
+ `asyncio.timeout(60)` on Python 3.11 or newer. A turn still waiting in the
74
+ queue is taken back, and a running turn is cancelled; `run` then waits for
75
+ the turn to end and re-raises `CancelledError`. `SyncSession.run` is the
76
+ blocking version, with no way to cancel.
77
+
78
+ To follow a turn as it runs, send it yourself and read its items (paste
79
+ inside `main()` above, before `await client.close()`; add
80
+ `SendUserTurnOptions` to the import):
81
+
82
+ ```python
83
+ turn = await session.send_user_turn(SendUserTurnOptions(
84
+ input=[{"type": "text", "text": "Summarize this repository."}],
85
+ composer_input="Summarize this repository.",
86
+ ))
87
+ async for item in turn.items():
88
+ print(item)
89
+ await turn.completed
90
+ ```
91
+
92
+ To stop a running turn, `await turn.interrupt()` (the stop gesture; pass
93
+ `retract=True` to also take the prompt back) or `await turn.cancel()`. Both are
94
+ requests: the turn is over when `turn.completed` settles. `turn.steer(...)`
95
+ adds input to the running turn, and `turn.unqueue()` takes back a queued one.
96
+ The session itself can `fork`, `compact`, `rename`, and `leave`, and switch its
97
+ model, reasoning effort, or approval mode (`set_model`, `set_reasoning_effort`,
98
+ `set_approval_mode`); `client.list_models()` lists the models it accepts.
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
+
123
+ To run a session and the child agents it starts without approval prompts,
124
+ start it under the built-in `:unrestricted` permission profile. Current
125
+ releases admit every child of such a session without prompts; a child admitted
126
+ with a narrower authority would stay gated at it.
127
+
128
+ That profile is described, verbatim, as "No filesystem sandbox or approval
129
+ prompts; local commands have direct network access; project trust is
130
+ unchanged." It also sets the approval reviewer to none. Use it only for a
131
+ trusted workspace and trusted prompts. Never use it where the prompts or the
132
+ sandbox are what protects you.
133
+
134
+ Start it with that profile, and pass the same profile when you resume it.
135
+ Paste inside `main()` above, indented, before `await client.close()`, with the same client:
136
+
137
+ ```python
138
+ from muse_code import ResumeSessionOptions, StartSessionOptions
139
+
140
+ session = await client.start_session(StartSessionOptions(
141
+ workspace_root="/path/to/repo", # an absolute path
142
+ permission_profile=":unrestricted",
143
+ ))
144
+ saved_session_id = session.session_id
145
+ # ... later, in the same client:
146
+ session = await client.resume_session(ResumeSessionOptions(
147
+ session_id=saved_session_id,
148
+ permission_profile=":unrestricted",
149
+ ))
150
+ ```
151
+
152
+ On start, `require_permission_profile=True` makes the client prove that the
153
+ pick was applied, and raise `PermissionProfileNotApplied` if it cannot (a
154
+ failed `session/read` read-back raises its own error). A host
155
+ that does not report the committed selection fails that proof even when the
156
+ pick was applied (seen with Muse Code 1.4.12). Resume has no
157
+ `require_permission_profile`, so the client never verifies the pick there.
158
+
159
+ `approval_mode` alone is not enough: it changes the session's own approval
160
+ mode, but its child agents are capped by the session's permission profile.
161
+ Under a prompting profile (such as `:ask-me`, whose approval is `on_request`),
162
+ the children still ask.
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
+
188
+ ## Documentation
189
+
190
+ - Guides, cookbook recipes, and the protocol reference:
191
+ <https://meta-models.github.io/muse-code-sdk/>
192
+ - Python API reference:
193
+ <https://meta-models.github.io/muse-code-sdk/next/generated/python/>
194
+ - Source and issues: <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.14"
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.14"]
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
@@ -19,10 +19,15 @@ from __future__ import annotations
19
19
 
20
20
  from typing import Final
21
21
 
22
+ import muse_code_msp
23
+
22
24
  from .errors import (
23
25
  GapFillFailureReason,
26
+ MuseCapabilityNotGrantedError,
27
+ MuseFacadeOwnedMethodError,
24
28
  MuseForeignSessionError,
25
29
  MuseGapFillError,
30
+ MuseHostDiscoveryError,
26
31
  MuseSessionDiscardedError,
27
32
  PermissionProfileNotApplied,
28
33
  )
@@ -42,28 +47,37 @@ __all__ = [
42
47
  "HostDeathDischarge",
43
48
  "HostDeathNotification",
44
49
  "ItemStore",
50
+ "MuseCapabilityNotGrantedError",
45
51
  "MuseClient",
46
52
  "MuseClientOptions",
47
53
  "MuseClientSpawnOptions",
54
+ "MuseFacadeOwnedMethodError",
48
55
  "MuseForeignSessionError",
49
56
  "MuseGapFillError",
50
57
  "MuseHostDiedError",
58
+ "MuseHostDiscoveryError",
51
59
  "MuseHostStartError",
52
60
  "MuseSessionDiscardedError",
61
+ "NotificationFailureHandler",
62
+ "NotificationObserver",
53
63
  "PendingCommandSet",
54
64
  "PendingCommandView",
55
65
  "PermissionProfileNotApplied",
66
+ "ProtocolErrorObserver",
56
67
  "ResumeSessionOptions",
68
+ "RunResult",
57
69
  "SendUserTurnOptions",
58
70
  "Session",
59
71
  "SessionApplyOutcome",
60
72
  "SessionDurabilityProfile",
73
+ "SessionEvent",
61
74
  "SessionFold",
62
75
  "SessionFoldView",
63
76
  "SessionGapBuffered",
64
77
  "SessionGapOverlap",
65
78
  "SessionStateStore",
66
79
  "StartSessionOptions",
80
+ "SteerTurnOptions",
67
81
  "SyncMuseClient",
68
82
  "SyncSession",
69
83
  "SyncTurn",
@@ -71,6 +85,8 @@ __all__ = [
71
85
  "TransportEof",
72
86
  "Turn",
73
87
  "TurnOutcome",
88
+ "TurnUnqueueOutcome",
89
+ "ViewFrame",
74
90
  "is_abnormal_host_death",
75
91
  "is_launch_failure",
76
92
  "load_transcript",
@@ -79,20 +95,20 @@ __all__ = [
79
95
  "transcript_dirs",
80
96
  ]
81
97
 
82
- EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
83
- "sha256:c94c3d1f2f51e6424074b9ec43eac3eedcf532d3dcd77351ec1383a87d3049c6"
84
- )
98
+ EXPECTED_SCHEMA_FINGERPRINT: Final[str] = muse_code_msp.SCHEMA_FINGERPRINT
85
99
  """The stable schema-bundle fingerprint this SDK was built against.
86
100
 
87
- The one deliberately duplicated fact: ``clients/sdk-py/tests/test_manifests.py`` binds it to
88
- ``schema/msp/stable/manifest.json``, so a schema advance that forgets the
89
- Python SDK reds this lane instead of drifting silently.
101
+ No second port and no second copy: this is the generated package's stable
102
+ ``SCHEMA_FINGERPRINT``, derived at import from the schema bytes. The
103
+ manifests suite binds it to the value derived from the stable bundle, so a
104
+ drift between the port the package vendors and the shared derivation reds
105
+ that lane instead of passing silently.
90
106
  """
91
107
 
92
108
  # The lockstep rule: the release train bumps pyproject.toml, the
93
109
  # distribution-metadata authority; this constant is aligned at publication
94
110
  # time.
95
- __version__: Final[str] = "1.4.14"
111
+ __version__: Final[str] = "1.4.20"
96
112
 
97
113
  # The facade is imported last so the constants above are already bound: the
98
114
  # spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
@@ -112,19 +128,27 @@ from .facade import ( # noqa: E402
112
128
  MuseClientSpawnOptions,
113
129
  MuseHostDiedError,
114
130
  MuseHostStartError,
131
+ NotificationFailureHandler,
132
+ NotificationObserver,
115
133
  PendingCommandView,
134
+ ProtocolErrorObserver,
116
135
  ResumeSessionOptions,
136
+ RunResult,
117
137
  SendUserTurnOptions,
118
138
  Session,
119
139
  SessionApplyOutcome,
120
140
  SessionDurabilityProfile,
141
+ SessionEvent,
121
142
  SessionFoldView,
122
143
  SessionGapBuffered,
123
144
  SessionGapOverlap,
124
145
  StartSessionOptions,
146
+ SteerTurnOptions,
125
147
  TransportEof,
126
148
  Turn,
127
149
  TurnOutcome,
150
+ TurnUnqueueOutcome,
151
+ ViewFrame,
128
152
  is_abnormal_host_death,
129
153
  is_launch_failure,
130
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)
@@ -2,10 +2,11 @@
2
2
 
3
3
  The wheel carries no host binary and no code path assumes one. Exactly the
4
4
  two ruled tiers, first hit wins: an explicit ``muse_bin`` argument, then a
5
- ``muse`` on ``PATH``. The LIBRARY reads no environment variable (TS parity:
6
- the TS spawn module takes a required command; ``MUSE_BIN`` is a knob of the
7
- quickstart/cookbook HARNESSES, which read it themselves and pass the
8
- explicit parameter down). No discoverable host fails BEFORE any process is
5
+ ``muse`` on ``PATH`` (the same two tiers as the TS and Rust SDKs, and what
6
+ ``MuseClient.spawn`` applies when ``muse_bin`` is ``None``). The LIBRARY reads
7
+ no other environment variable: ``MUSE_BIN`` is a knob of the
8
+ quickstart/cookbook HARNESSES, which read it themselves and pass the explicit
9
+ parameter down. No discoverable host fails BEFORE any process is
9
10
  spawned, with an exact error naming the parameter and the compatibility
10
11
  page.
11
12
  """
@@ -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