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.
- {muse_code_sdk-1.4.14/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.20}/PKG-INFO +93 -13
- muse_code_sdk-1.4.20/README.md +194 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/pyproject.toml +2 -2
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/__init__.py +31 -7
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/connection.py +59 -9
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/discovery.py +5 -4
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/spawn.py +2 -1
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/errors.py +53 -4
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/__init__.py +16 -2
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/client.py +448 -28
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/host_death.py +5 -1
- muse_code_sdk-1.4.20/src/muse_code/facade/run.py +208 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/session.py +575 -10
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/turn_handle.py +126 -10
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/turn_submit.py +129 -28
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/session_fold.py +76 -5
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/sync_facade.py +324 -27
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20/src/muse_code_sdk.egg-info}/PKG-INFO +93 -13
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/SOURCES.txt +16 -0
- muse_code_sdk-1.4.20/src/muse_code_sdk.egg-info/requires.txt +2 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_compatibility_row.py +250 -28
- muse_code_sdk-1.4.20/tests/test_connection_retryable_override.py +91 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_external_audience.py +23 -0
- muse_code_sdk-1.4.20/tests/test_facade_client_observe.py +594 -0
- muse_code_sdk-1.4.20/tests/test_facade_control_verbs.py +709 -0
- muse_code_sdk-1.4.20/tests/test_facade_host_exit_cause.py +245 -0
- muse_code_sdk-1.4.20/tests/test_facade_raw_command.py +320 -0
- muse_code_sdk-1.4.20/tests/test_facade_run.py +719 -0
- muse_code_sdk-1.4.20/tests/test_facade_session_events.py +434 -0
- muse_code_sdk-1.4.20/tests/test_facade_session_mcp.py +211 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_turn.py +38 -0
- muse_code_sdk-1.4.20/tests/test_facade_unqueue_retirement.py +238 -0
- muse_code_sdk-1.4.20/tests/test_facade_wire_queries.py +371 -0
- muse_code_sdk-1.4.20/tests/test_fingerprint_vectors.py +86 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_host_discovery.py +98 -1
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_manifests.py +35 -12
- muse_code_sdk-1.4.20/tests/test_msp_py_fingerprint_derivation.py +490 -0
- muse_code_sdk-1.4.20/tests/test_msp_py_host_version_derivation.py +274 -0
- muse_code_sdk-1.4.20/tests/test_needs_input_wire_types.py +71 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_public_mirror_text.py +4 -1
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_publish_shape.py +1 -1
- muse_code_sdk-1.4.20/tests/test_run_abort_busy_unqueue_real_binary.py +491 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_session_fold.py +155 -3
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_source_closure.py +5 -3
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_sync_facade.py +71 -38
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_typing_gates.py +71 -0
- muse_code_sdk-1.4.14/README.md +0 -114
- muse_code_sdk-1.4.14/src/muse_code_sdk.egg-info/requires.txt +0 -2
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/LICENSE +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/setup.cfg +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/__init__.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/connection/validation.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/approval.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/discarded.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/gap_fill.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/facade/host_start.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/__init__.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/item_store.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/fold/state_store.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/pending/__init__.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/pending/pending_command_set.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/py.typed +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code/replay.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_asyncio_cancellation.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_connection.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_connection_error_arms.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_docs_generation.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_approval.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_approval_resend.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_gap_fill.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_open_same_chunk.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_persistent_approval.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_required_selection.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_facade_user_input_resend.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_item_store.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_muse_serve_child.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_pending_command_corpus.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_pending_command_set.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_pending_command_wire_binding.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_permission_state_wire_types.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_recovery_observation.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_spawn_handshake.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_spawn_host_lost.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_state_store.py +0 -0
- {muse_code_sdk-1.4.14 → muse_code_sdk-1.4.20}/tests/test_tolerance_corpus.py +0 -0
- {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.
|
|
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.
|
|
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=
|
|
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
|
-
|
|
76
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
88
|
-
``
|
|
89
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|