muse-code-sdk 1.4.10__tar.gz → 1.4.11__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 (70) hide show
  1. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/PKG-INFO +7 -2
  2. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/README.md +6 -1
  3. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/pyproject.toml +1 -1
  4. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/__init__.py +2 -2
  5. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/approval.py +74 -18
  6. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/client.py +92 -5
  7. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/gap_fill.py +6 -1
  8. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/session.py +128 -5
  9. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/fold/__init__.py +2 -0
  10. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/fold/session_fold.py +53 -8
  11. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/sync_facade.py +15 -1
  12. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/PKG-INFO +7 -2
  13. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/SOURCES.txt +3 -0
  14. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_facade_approval.py +184 -7
  15. muse_code_sdk-1.4.11/tests/test_facade_approval_resend.py +498 -0
  16. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_facade_gap_fill.py +59 -1
  17. muse_code_sdk-1.4.11/tests/test_facade_user_input_resend.py +384 -0
  18. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_public_mirror_text.py +10 -3
  19. muse_code_sdk-1.4.11/tests/test_recovery_observation.py +293 -0
  20. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_session_fold.py +147 -1
  21. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_sync_facade.py +29 -0
  22. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/LICENSE +0 -0
  23. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/setup.cfg +0 -0
  24. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/connection/__init__.py +0 -0
  25. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/connection/connection.py +0 -0
  26. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/connection/discovery.py +0 -0
  27. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/connection/spawn.py +0 -0
  28. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/connection/validation.py +0 -0
  29. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/errors.py +0 -0
  30. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/__init__.py +0 -0
  31. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/discarded.py +0 -0
  32. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/host_death.py +0 -0
  33. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/host_start.py +0 -0
  34. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/turn_handle.py +0 -0
  35. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/facade/turn_submit.py +0 -0
  36. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/fold/item_store.py +0 -0
  37. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/fold/state_store.py +0 -0
  38. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/pending/__init__.py +0 -0
  39. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/pending/pending_command_set.py +0 -0
  40. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/py.typed +0 -0
  41. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code/replay.py +0 -0
  42. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  43. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/requires.txt +0 -0
  44. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  45. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_asyncio_cancellation.py +0 -0
  46. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_compatibility_row.py +0 -0
  47. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_connection.py +0 -0
  48. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_connection_error_arms.py +0 -0
  49. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_docs_generation.py +0 -0
  50. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_external_audience.py +0 -0
  51. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_facade_open_same_chunk.py +0 -0
  52. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_facade_persistent_approval.py +0 -0
  53. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_facade_required_selection.py +0 -0
  54. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_facade_turn.py +0 -0
  55. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_host_discovery.py +0 -0
  56. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_item_store.py +0 -0
  57. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_manifests.py +0 -0
  58. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_muse_serve_child.py +0 -0
  59. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_pending_command_corpus.py +0 -0
  60. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_pending_command_set.py +0 -0
  61. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_pending_command_wire_binding.py +0 -0
  62. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_permission_state_wire_types.py +0 -0
  63. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_publish_shape.py +0 -0
  64. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_source_closure.py +0 -0
  65. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_spawn_handshake.py +0 -0
  66. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_spawn_host_lost.py +0 -0
  67. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_state_store.py +0 -0
  68. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_tolerance_corpus.py +0 -0
  69. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/tests/test_transcript_replay.py +0 -0
  70. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.11}/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.10
3
+ Version: 1.4.11
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
@@ -27,7 +27,12 @@ wrapper for scripts and notebooks.
27
27
  one you already run.
28
28
  - Stream a turn's items and text deltas as async iterators; await the turn's
29
29
  server-authored outcome.
30
- - Answer tool-approval requests with a handler you register.
30
+ - Answer tool-approval requests with a handler you register, or pass it as
31
+ `ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
32
+ its failures, so approvals still pending at resume reach it.
33
+ - Answer the agent's questions: list them with
34
+ `session.fold.pending_user_inputs()` and answer with
35
+ `session.answer_user_input`.
31
36
  - A client-side fold keeps a live, typed view of the session — items, session
32
37
  state, pending commands — with gap recovery after reconnects, and safe
33
38
  same-command retry that can never double-submit.
@@ -8,7 +8,12 @@ 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
- - Answer tool-approval requests with a handler you register.
11
+ - Answer tool-approval requests with a handler you register, or pass it as
12
+ `ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
13
+ its failures, so approvals still pending at resume reach it.
14
+ - Answer the agent's questions: list them with
15
+ `session.fold.pending_user_inputs()` and answer with
16
+ `session.answer_user_input`.
12
17
  - A client-side fold keeps a live, typed view of the session — items, session
13
18
  state, pending commands — with gap recovery after reconnects, and safe
14
19
  same-command retry that can never double-submit.
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "muse-code-sdk"
8
- version = "1.4.10"
8
+ version = "1.4.11"
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"
@@ -80,7 +80,7 @@ __all__ = [
80
80
  ]
81
81
 
82
82
  EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
83
- "sha256:5d3b43e5ccdb7dc4f5a32c380f5f2cde0f10e3059b93610afb2e73f73b1d914c"
83
+ "sha256:f282ad16e21520d0cda68cb190149b86e2b062eef1dd282749360c05aa3d1082"
84
84
  )
85
85
  """The stable schema-bundle fingerprint this SDK was built against.
86
86
 
@@ -92,7 +92,7 @@ Python SDK reds this lane instead of drifting silently.
92
92
  # The lockstep rule: the release train bumps pyproject.toml, the
93
93
  # distribution-metadata authority; this constant is aligned at publication
94
94
  # time.
95
- __version__: Final[str] = "1.4.10"
95
+ __version__: Final[str] = "1.4.11"
96
96
 
97
97
  # The facade is imported last so the constants above are already bound: the
98
98
  # spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
@@ -1,18 +1,17 @@
1
- """The approval round trip: inbound ``approval/requested`` → the consumer's
2
- handler → outbound ``approval/decide``. Port of the TypeScript SDK's
3
- approval router.
1
+ """The approval round trip: inbound ``approval/requested`` or
2
+ ``approval/request`` → the consumer's handler → outbound ``approval/decide``.
3
+ Port of the TypeScript SDK's approval router.
4
4
 
5
5
  Split out of ``session.py`` rather than added to it: the router owns a
6
6
  handler, a per-stage latch, and a decision-shaping guard chain that have
7
7
  nothing to do with folding a view, and ``Session`` was already the package's
8
8
  largest module.
9
9
 
10
- SHAPE NOTE, established from the bundle rather than assumed. ``approval/request``
11
- as a SERVER REQUEST is not enrolled for the view stream this router rides —
12
- the schema carries ``approval/requested`` as a NOTIFICATION — so the round
13
- trip is notification-in / command-out, which is exactly the pair the
14
- protocol names. The server-request form would be a protocol enrollment
15
- request upstream, never a local interface.
10
+ Two frames carry a request in: the ``approval/requested`` notification and
11
+ the ``approval/request`` server request, which the host sends beside it and
12
+ re-sends after a resume. A client-owned connection routes both here, and the
13
+ per-stage latch decides each stage once, whichever arrives first. The
14
+ decision goes out as the ``approval/decide`` command.
16
15
  """
17
16
 
18
17
  from __future__ import annotations
@@ -112,6 +111,22 @@ class UnofferedChoice:
112
111
  kind: Literal["unofferedChoice"] = "unofferedChoice"
113
112
 
114
113
 
114
+ @dataclass(frozen=True)
115
+ class FeedbackNotAccepted:
116
+ """The handler attached ``feedback`` to a choice whose ``acceptsFeedback``
117
+ is not true. The host would refuse that decision, so it is not sent; the
118
+ offered reject (the ``denied`` choice, else the ``abort`` one) is sent
119
+ next, so the approval closes. With neither offered, nothing is sent and the
120
+ approval stays open; a failure to send the reject is reported as
121
+ ``SubmitFailed``. Next time, answer without feedback, or pick one of
122
+ ``feedback_choice_ids``, the offered choices that accept it."""
123
+
124
+ approval_id: str
125
+ choice_id: str
126
+ feedback_choice_ids: tuple[str, ...]
127
+ kind: Literal["feedbackNotAccepted"] = "feedbackNotAccepted"
128
+
129
+
115
130
  @dataclass(frozen=True)
116
131
  class HandlerRaised:
117
132
  """The consumer's decision handler raised; nothing reached the wire."""
@@ -131,14 +146,15 @@ class SubmitFailed:
131
146
  kind: Literal["submitFailed"] = "submitFailed"
132
147
 
133
148
 
134
- ApprovalFailure = Union[UnofferedChoice, HandlerRaised, SubmitFailed]
149
+ ApprovalFailure = Union[UnofferedChoice, FeedbackNotAccepted, HandlerRaised, SubmitFailed]
135
150
  """Why one approval round trip did not complete.
136
151
 
137
152
  REPORTED, not raised: the trip is driven from a notification, so an exception
138
153
  would escape into the consumer's pump with nothing to catch it — the same
139
- hazard ``Session.apply`` already refuses a malformed frame for. Each arm names
140
- a DIFFERENT owner (the consumer's handler, the consumer's choice, the host),
141
- because the repair differs.
154
+ hazard ``Session.apply`` already refuses a malformed frame for. Each kind names
155
+ whose repair it is (the consumer's handler; the consumer's choice, for
156
+ ``unofferedChoice`` and ``feedbackNotAccepted``; the host), because the repair
157
+ differs.
142
158
  """
143
159
 
144
160
  ApprovalFailureHandler = Callable[[ApprovalFailure], None]
@@ -146,9 +162,10 @@ ApprovalFailureHandler = Callable[[ApprovalFailure], None]
146
162
 
147
163
 
148
164
  class ApprovalRouter:
149
- """Routes folded ``approval/requested`` frames, and ``approval/updated``
150
- frames that advance a pending approval's stage, to the consumer's handler
151
- and authors the ``approval/decide`` command."""
165
+ """Routes folded ``approval/requested`` frames, routed ``approval/request``
166
+ frames, and ``approval/updated`` frames that advance a pending approval's
167
+ stage, to the consumer's handler and authors the ``approval/decide``
168
+ command."""
152
169
 
153
170
  def __init__(self, session_id: str, connection: Connection | None) -> None:
154
171
  """Builds the router; ``connection`` is absent on a fold-only session.
@@ -194,7 +211,8 @@ class ApprovalRouter:
194
211
  self._on_failure = handler
195
212
 
196
213
  def requested(self, params: ApprovalRequestParams) -> Awaitable[None] | None:
197
- """An ``approval/requested`` folded. Returns the decision round trip to
214
+ """An ``approval/requested`` the fold kept pending, or a routed
215
+ ``approval/request``. Returns the decision round trip to
198
216
  await, or ``None`` when this call schedules nothing — no handler, an
199
217
  already-decided stage, or a fold-only session (whose ``SubmitFailed``
200
218
  is reported synchronously before returning).
@@ -327,7 +345,15 @@ class ApprovalRouter:
327
345
  available_choice_ids = tuple(
328
346
  choice["choiceId"] for choice in params["availableChoices"]
329
347
  )
330
- if choice_id not in available_choice_ids:
348
+ chosen = next(
349
+ (
350
+ choice
351
+ for choice in params["availableChoices"]
352
+ if choice["choiceId"] == choice_id
353
+ ),
354
+ None,
355
+ )
356
+ if chosen is None:
331
357
  # Select-never-create, enforced on THIS side of the
332
358
  # transport. An invented choice bounces -32052 a round trip later,
333
359
  # by which time the stage may have advanced — so the diagnosis a
@@ -341,6 +367,36 @@ class ApprovalRouter:
341
367
  )
342
368
  )
343
369
  return
370
+ if feedback is not None and chosen.get("acceptsFeedback") is not True:
371
+ # The host refuses feedback on any other choice (-32052) and
372
+ # keeps its approval open, so refuse it here and answer with the
373
+ # offered reject instead, the choice the Rust SDK sends too: the
374
+ # approval closes and the host's turn goes on. With neither a
375
+ # ``denied`` nor an ``abort`` choice, nothing is sent.
376
+ self._report(
377
+ FeedbackNotAccepted(
378
+ approval_id=params["approvalId"],
379
+ choice_id=choice_id,
380
+ feedback_choice_ids=tuple(
381
+ choice["choiceId"]
382
+ for choice in params["availableChoices"]
383
+ if choice.get("acceptsFeedback") is True
384
+ ),
385
+ )
386
+ )
387
+ reject = next(
388
+ (
389
+ choice
390
+ for decision in ("denied", "abort")
391
+ for choice in params["availableChoices"]
392
+ if choice.get("decision") == decision
393
+ ),
394
+ None,
395
+ )
396
+ if reject is None:
397
+ return
398
+ choice_id = reject["choiceId"]
399
+ feedback = None
344
400
 
345
401
  decide_params: dict[str, Any] = {
346
402
  "approvalId": params["approvalId"],
@@ -40,11 +40,12 @@ from muse_code_msp import (
40
40
  ClientInfo,
41
41
  InitializeParams,
42
42
  InitializeResult,
43
+ RecoveryMode,
43
44
  SessionResumeParams,
44
45
  SessionStartParams,
45
46
  )
46
47
 
47
- from ..connection.connection import Connection
48
+ from ..connection.connection import Connection, MspError
48
49
  from ..connection.spawn import (
49
50
  DEFAULT_SHUTDOWN_TIMEOUT_MS,
50
51
  ExitClassification,
@@ -63,6 +64,7 @@ from .host_death import (
63
64
  read_session_durability,
64
65
  survives_host_death,
65
66
  )
67
+ from .approval import ApprovalFailureHandler, ApprovalHandler
66
68
  from .session import Session, SessionOpening, _ResumeOpening, _StartOpening
67
69
  from .turn_submit import _camel
68
70
 
@@ -105,6 +107,8 @@ class StartSessionOptions:
105
107
  of proceeding on the host default. Client-local: never sent on
106
108
  the wire. Setting it without ``permission_profile`` is a usage
107
109
  error.
110
+ recovery_mode: Optional recovery selection (wire ``recoveryMode``);
111
+ omitted means no selection is sent.
108
112
  """
109
113
 
110
114
  approval_mode: str | None = None
@@ -115,6 +119,7 @@ class StartSessionOptions:
115
119
  model_id: str | None = None
116
120
  permission_profile: str | None = None
117
121
  require_permission_profile: bool = False
122
+ recovery_mode: RecoveryMode | None = None
118
123
 
119
124
 
120
125
  @dataclass
@@ -134,6 +139,18 @@ class ResumeSessionOptions:
134
139
  wire's ``permissionProfile`` member: the host restores the
135
140
  retained authority first, then applies the pick. Omitted means
136
141
  no override.
142
+ recovery_mode: Optional recovery selection (wire ``recoveryMode``);
143
+ omitted means no selection is sent.
144
+ on_approval: Answers this session's approvals from its first frame,
145
+ so an approval the host re-sends right after the resume reply
146
+ reaches it. The same contract as ``Session.on_approval``,
147
+ installed before the frames that arrived with the reply are
148
+ applied. Never sent on the wire.
149
+ on_approval_error: Hears this session's approval failures from its
150
+ first frame, as ``Session.on_approval_error`` does later, so a
151
+ failure for an approval that arrived with the resume reply is
152
+ heard whatever task the caller registers from. Never sent on the
153
+ wire.
137
154
  """
138
155
 
139
156
  session_id: str
@@ -141,6 +158,9 @@ class ResumeSessionOptions:
141
158
  exclude_items: bool | None = None
142
159
  history: Mapping[str, Any] | None = None
143
160
  permission_profile: str | None = None
161
+ recovery_mode: RecoveryMode | None = None
162
+ on_approval: ApprovalHandler | None = None
163
+ on_approval_error: ApprovalFailureHandler | None = None
144
164
 
145
165
 
146
166
  # Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
@@ -162,7 +182,10 @@ assert (
162
182
  ), "StartSessionOptions drifted from SessionStartParams — forward the new member"
163
183
 
164
184
  _RESUME_EXCLUDED = {"commandId", "config"}
165
- assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED == set(
185
+ _RESUME_OWNED = {"onApproval", "onApprovalError"}
186
+ assert (
187
+ {_camel(f.name) for f in fields(ResumeSessionOptions)} - _RESUME_OWNED
188
+ ) | _RESUME_EXCLUDED == set(
166
189
  SessionResumeParams.__annotations__
167
190
  ), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
168
191
 
@@ -279,6 +302,14 @@ class MuseClient(Generic[_I]):
279
302
  # spawn-built consumer hears no view frame. ``Connection`` holds ONE
280
303
  # notification handler — this client claims it.
281
304
  connection.on_notification(self._route)
305
+ # A question or approval still open when a session is resumed is
306
+ # re-sent as a ``userInput/request`` or ``approval/request`` server
307
+ # request. Each is routed and answered with the empty receipt ``{}``:
308
+ # a question as the ``userInput/requested`` twin it shares params with,
309
+ # so its session lists it, and an approval as itself, which the session
310
+ # routes to its approval handler. Every other server-request method
311
+ # keeps the ``-32601`` a bare ``Connection`` answers.
312
+ connection.on_server_request(self._serve_request)
282
313
  # Registered at construction, not at the first session: an EOF can land
283
314
  # before any session exists, and the flag has to be read at that moment.
284
315
  self._closed_watch = asyncio.ensure_future(self._watch_closed())
@@ -417,6 +448,8 @@ class MuseClient(Generic[_I]):
417
448
  params["workspaceRoots"] = options.workspace_roots
418
449
  if options.model_id is not None:
419
450
  params["modelId"] = options.model_id
451
+ if options.recovery_mode is not None:
452
+ params["recoveryMode"] = options.recovery_mode
420
453
  # The initial permission-profile selection, forwarded verbatim like the
421
454
  # other start members (None stays omitted).
422
455
  if options.permission_profile is not None:
@@ -488,9 +521,17 @@ class MuseClient(Generic[_I]):
488
521
  params["excludeItems"] = options.exclude_items
489
522
  if options.history is not None:
490
523
  params["history"] = options.history
524
+ if options.recovery_mode is not None:
525
+ params["recoveryMode"] = options.recovery_mode
491
526
  if options.permission_profile is not None:
492
527
  params["permissionProfile"] = options.permission_profile
493
- return await self._open("session/resume", params, _ResumeOpening)
528
+ return await self._open(
529
+ "session/resume",
530
+ params,
531
+ _ResumeOpening,
532
+ options.on_approval,
533
+ options.on_approval_error,
534
+ )
494
535
 
495
536
  async def close(self) -> None:
496
537
  """Shut the host down in an orderly way.
@@ -519,6 +560,8 @@ class MuseClient(Generic[_I]):
519
560
  verb: str,
520
561
  params: dict[str, Any],
521
562
  opening: Callable[[Mapping[str, Any]], SessionOpening],
563
+ on_approval: ApprovalHandler | None = None,
564
+ on_approval_error: ApprovalFailureHandler | None = None,
522
565
  ) -> Session[_I]:
523
566
  # The window ``_route`` holds unknown-session frames for: from the
524
567
  # request to the Session's registration. Counted, not flagged, so two
@@ -530,7 +573,10 @@ class MuseClient(Generic[_I]):
530
573
  raw = await self._connection.command(verb, params)
531
574
  result: Mapping[str, Any] = raw
532
575
  return self._open_session(
533
- str(result["session"]["sessionId"]), opening(result)
576
+ str(result["session"]["sessionId"]),
577
+ opening(result),
578
+ on_approval,
579
+ on_approval_error,
534
580
  )
535
581
  finally:
536
582
  self._opening -= 1
@@ -538,7 +584,11 @@ class MuseClient(Generic[_I]):
538
584
  self._held.clear()
539
585
 
540
586
  def _open_session(
541
- self, session_id: str, opening: SessionOpening
587
+ self,
588
+ session_id: str,
589
+ opening: SessionOpening,
590
+ on_approval: ApprovalHandler | None = None,
591
+ on_approval_error: ApprovalFailureHandler | None = None,
542
592
  ) -> Session[_I]:
543
593
  session: Session[_I] = Session(
544
594
  session_id,
@@ -547,6 +597,12 @@ class MuseClient(Generic[_I]):
547
597
  discarded=self._discarded,
548
598
  opening=opening,
549
599
  )
600
+ # Before the held frames below: an approval re-sent with the resume
601
+ # reply must find the handler, and its failure the observer.
602
+ if on_approval_error is not None:
603
+ session.on_approval_error(on_approval_error)
604
+ if on_approval is not None:
605
+ session.on_approval(on_approval)
550
606
  self._sessions.add(session)
551
607
  # The frames that named this session while its verb was in flight
552
608
  # fold NOW, in arrival order — synchronously, so nothing the pump
@@ -570,6 +626,37 @@ class MuseClient(Generic[_I]):
570
626
  "reattaching to it"
571
627
  )
572
628
 
629
+ async def _serve_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
630
+ # The connection runs this as its own task, so the twin can land after
631
+ # frames read in the same chunk. The same questions are listed, since
632
+ # the list is keyed by question id and a settled question stays
633
+ # settled; their order can differ when another question arrives in
634
+ # that chunk. The receipt does not depend on ``_route`` finding the
635
+ # session: a request read during an open is held before that session
636
+ # exists.
637
+ method = request.get("method")
638
+ if method == "userInput/request":
639
+ self._route(
640
+ {
641
+ "jsonrpc": "2.0",
642
+ "method": "userInput/requested",
643
+ "params": request.get("params"),
644
+ }
645
+ )
646
+ return {}
647
+ if method == "approval/request":
648
+ self._route(
649
+ {"jsonrpc": "2.0", "method": method, "params": request.get("params")}
650
+ )
651
+ return {}
652
+ raise MspError(
653
+ {
654
+ "code": -32601,
655
+ "message": f"method not found: {method}",
656
+ "data": {"kind": "methodNotFound", "retryable": False},
657
+ }
658
+ )
659
+
573
660
  def _route(self, notification: Mapping[str, Any]) -> None:
574
661
  # Deliver one view notification to the session(s) it names. A frame
575
662
  # naming NO session is dropped here rather than fed to every session
@@ -206,7 +206,8 @@ class GapFiller(Generic[_I]):
206
206
  """
207
207
  gap = self._options.fold.pending_gap
208
208
  # Only reachable if a caller invokes this with nothing outstanding; the
209
- # one production caller folds the marker first, which always sets it.
209
+ # one production caller folds the marker first, which sets it or, for
210
+ # a stale repeat, finds it already set.
210
211
  if gap is None:
211
212
  return None
212
213
  if self._filling:
@@ -489,6 +490,10 @@ class GapFiller(Generic[_I]):
489
490
 
490
491
 
491
492
  def _view_cursor_of(frame: WireFrame) -> str | None:
493
+ # A routed ``approval/request`` carries its notification's cursor but is
494
+ # not that event: matching it would spend the twin's overlap slot.
495
+ if frame.get("method") == "approval/request":
496
+ return None
492
497
  params = frame.get("params")
493
498
  cursor = params.get("viewCursor") if isinstance(params, dict) else None
494
499
  return cursor if isinstance(cursor, str) else None
@@ -26,6 +26,7 @@ from typing import (
26
26
  Literal,
27
27
  Mapping,
28
28
  Protocol,
29
+ Sequence,
29
30
  TypeVar,
30
31
  Union,
31
32
  cast,
@@ -39,16 +40,21 @@ from muse_code_msp import (
39
40
  ItemDeltaParams,
40
41
  TurnCompletedParams,
41
42
  TurnUnqueuedParams,
43
+ UserInputAnswer,
44
+ UserInputAnswerResult,
42
45
  )
43
46
 
44
- from ..connection.connection import Connection
47
+ from ..connection.connection import Connection, ProtocolError
45
48
  from ..fold.item_store import IgnoredStaleRevision
46
49
  from ..fold.session_fold import (
47
50
  ApprovalPending,
51
+ ApprovalResolvedFold,
48
52
  DeliveryGap,
49
53
  FoldItems,
50
54
  FoldOutcome,
51
55
  FoldSessionState,
56
+ IgnoredStaleFrame,
57
+ IgnoredUnrecognizedMethod,
52
58
  ItemFold,
53
59
  Params,
54
60
  PendingApproval,
@@ -101,6 +107,28 @@ _USER_MESSAGE = "userMessage"
101
107
  assert _USER_MESSAGE in ITEM_KIND_KNOWN_VALUES
102
108
 
103
109
 
110
+ def _is_routable_approval_request(request: Mapping[str, Any]) -> bool:
111
+ """True when a routed ``approval/request`` carries every member the approval
112
+ router reads: its ``approvalId``, the requirement's ``approvalId`` and
113
+ ``sourceIndex``, and each offered choice's ``choiceId``."""
114
+ if not isinstance(request.get("approvalId"), str):
115
+ return False
116
+ requirement = request.get("currentRequirementId")
117
+ choices = request.get("availableChoices")
118
+ if not isinstance(requirement, dict) or not isinstance(choices, list):
119
+ return False
120
+ index = requirement.get("sourceIndex")
121
+ return (
122
+ isinstance(requirement.get("approvalId"), str)
123
+ and isinstance(index, int)
124
+ and not isinstance(index, bool)
125
+ and all(
126
+ isinstance(choice, dict) and isinstance(choice.get("choiceId"), str)
127
+ for choice in choices
128
+ )
129
+ )
130
+
131
+
104
132
  class SessionFoldView(Protocol):
105
133
  """The fold as a consumer may READ it — the TS ``SessionFoldView`` allowlist.
106
134
 
@@ -336,6 +364,10 @@ class SessionApplyOutcome(Generic[_I]):
336
364
  apply that started the fill, never on the buffered frame's own
337
365
  return. A second ``view/gap`` landing mid-fill rides the same
338
366
  channel: it extends the running walk, so its own ``io`` is empty.
367
+ A repeat of a ``next`` already named while the hole is open folds
368
+ as ``IgnoredStaleFrame`` and extends nothing; after a failed fill
369
+ it still restarts that fill, and the restarted walk's work
370
+ settles on this frame's ``io``.
339
371
  io: The client->server I/O this event triggered, settled — resolves to
340
372
  the retirements that I/O produced. A SEPARATE channel from
341
373
  ``retirements`` because ``apply`` is synchronous by contract: a
@@ -397,6 +429,11 @@ class Session(Generic[_I]):
397
429
  self._discarded = discarded
398
430
  self._connection = connection
399
431
  self._fold: SessionFold = SessionFold()
432
+ # Approvals the fold holds a durable terminal for. Read by a re-sent
433
+ # ``approval/request``, which skips the fold, so it authors nothing for
434
+ # an approval already resolved; mirrors the fold's own record, keyed
435
+ # for O(1).
436
+ self._resolved_approval_ids: set[str] = set()
400
437
  self._pending: PendingCommandSet[_I] = PendingCommandSet(
401
438
  discarded_command_ids=(
402
439
  discarded.command_ids if discarded is not None else set()
@@ -545,7 +582,12 @@ class Session(Generic[_I]):
545
582
  # already reported synchronously) — appending an already-settled stub
546
583
  # instead would force ``_settled_io``'s gather arm, which needs a
547
584
  # running loop a fold-only ``apply`` may not have.
548
- if isinstance(outcome, DeliveryGap):
585
+ # A stale repeat of an earlier gap still restarts a failed fill, as
586
+ # every folded ``view/gap`` (a ``DeliveryGap`` or a stale repeat)
587
+ # reaches ``start()`` (a no-op while a fill runs).
588
+ if isinstance(outcome, DeliveryGap) or (
589
+ isinstance(outcome, IgnoredStaleFrame) and outcome.method == "view/gap"
590
+ ):
549
591
  fill = self._gaps.start()
550
592
  if fill is not None:
551
593
  sink.tasks.append(fill)
@@ -569,7 +611,32 @@ class Session(Generic[_I]):
569
611
  # everything downstream — the fold, the iterators, and the protocol
570
612
  # retirements.
571
613
  retirements: List[PendingRetirement[_I]] = []
614
+ # A re-sent ``approval/request`` server request skips the fold, which
615
+ # lists only the ``approval/requested`` notification, and reaches
616
+ # approval routing like that notification: the per-stage latch decides
617
+ # each stage once, and an approval already resolved authors nothing.
618
+ # Its handler task can land after the ``approval/resolved`` read with
619
+ # it, so in that case Python skips a request TypeScript would still
620
+ # route in arrival order. A request missing the members the router
621
+ # reads is dropped, never raised on, whichever path applies it: a held
622
+ # frame replayed inside ``resume_session`` included. A routed or
623
+ # dropped request reports ``IgnoredUnrecognizedMethod``, a sanctioned
624
+ # interim like ``session/started``'s (a dedicated outcome is tracked);
625
+ # one with no params dict falls through to the fold's
626
+ # ``IgnoredMissingParams``, like every other frame.
627
+ request = event.get("params")
628
+ if event.get("method") == "approval/request" and isinstance(request, dict):
629
+ if (
630
+ _is_routable_approval_request(request)
631
+ and request["approvalId"] not in self._resolved_approval_ids
632
+ ):
633
+ self._approval_requested(
634
+ cast(ApprovalRequestParams, request), sink.tasks
635
+ )
636
+ return IgnoredUnrecognizedMethod("approval/request")
572
637
  outcome = self._fold.apply(event)
638
+ if isinstance(outcome, ApprovalResolvedFold):
639
+ self._resolved_approval_ids.add(outcome.approval_id)
573
640
  # The fold drops a params-less frame as IgnoredMissingParams;
574
641
  # the arms below read ``params`` on the turn/item arms, so return early.
575
642
  method = str(event.get("method"))
@@ -627,9 +694,9 @@ class Session(Generic[_I]):
627
694
  )
628
695
  # ``view/gap`` deliberately routes to no turn: it names a hole in
629
696
  # DELIVERY, so what it moves is the fold's currency, and the recovery
630
- # it triggers is started by ``apply`` off the ``DeliveryGap`` verdict
631
- # rather than from here. Every other method is folded
632
- # but routed to no turn.
697
+ # it triggers is started by ``apply`` off the ``DeliveryGap`` verdict,
698
+ # or off a stale ``view/gap``'s ``IgnoredStaleFrame``, rather than from
699
+ # here. Every other method is folded but routed to no turn.
633
700
 
634
701
  # The retired entries are gone from the set, so their replay memory
635
702
  # goes with them.
@@ -782,6 +849,62 @@ class Session(Generic[_I]):
782
849
  :data:`~muse_code.facade.approval.ApprovalFailure`."""
783
850
  self._approvals.on_approval_error(handler)
784
851
 
852
+ # ---- user-input answers ---------------------------------------------------
853
+
854
+ async def answer_user_input(
855
+ self, user_input_id: str, answers: Sequence[UserInputAnswer]
856
+ ) -> UserInputAnswerResult:
857
+ """Answer a question this session lists in ``fold.pending_user_inputs()``.
858
+
859
+ Sends ``userInput/answer`` with one answer per question and returns
860
+ the host's admission. The question leaves the pending list only when
861
+ the server's ``userInput/settled`` arrives.
862
+
863
+ Args:
864
+ user_input_id: The question to answer.
865
+ answers: One answer per question.
866
+
867
+ Returns:
868
+ The host's admission of the answer.
869
+
870
+ Raises:
871
+ MuseSessionDiscardedError: The session was discarded after an
872
+ ephemeral host death; nothing is sent.
873
+ RuntimeError: The session was constructed fold-only; nothing is sent.
874
+ ProtocolError: The acknowledgement has no command id or names
875
+ another question.
876
+ """
877
+ # Refused before anything is sent, as ``send_user_turn`` is: a
878
+ # discarded session cannot be reattached.
879
+ if self._discharge is not None and self._discharge.kind == "discharged":
880
+ from ..errors import MuseSessionDiscardedError
881
+
882
+ raise MuseSessionDiscardedError(
883
+ f"session {self.session_id} was discarded after an ephemeral host "
884
+ "death; it cannot answer a question"
885
+ )
886
+ connection = self._submit.require_connection("answer_user_input")
887
+ # No explicit ``commandId``: ``Connection.command`` mints and stamps it,
888
+ # and refuses an ack that echoes a different one. An ack with none is
889
+ # refused here, with one naming another question.
890
+ ack = await connection.command(
891
+ "userInput/answer",
892
+ {
893
+ "sessionId": self.session_id,
894
+ "userInputId": user_input_id,
895
+ "answers": list(answers),
896
+ },
897
+ )
898
+ if (
899
+ not isinstance(ack.get("commandId"), str)
900
+ or ack.get("userInputId") != user_input_id
901
+ ):
902
+ raise ProtocolError(
903
+ f"userInput/answer acknowledged command {ack.get('commandId')} for "
904
+ f"question {ack.get('userInputId')}, expected question {user_input_id}"
905
+ )
906
+ return cast(UserInputAnswerResult, ack)
907
+
785
908
  # ---- consumer-driven pending-retirement verbs ---------------------------------
786
909
 
787
910
  def stop_retrying(self, command_id: str) -> PendingRetirement[_I] | None:
@@ -32,6 +32,7 @@ from .session_fold import (
32
32
  IgnoredUnrecognizedMethod,
33
33
  ItemDeltaFold,
34
34
  ItemFold,
35
+ MonitorInboxNotice,
35
36
  PendingApproval,
36
37
  SessionFold,
37
38
  SessionStateFold,
@@ -67,6 +68,7 @@ __all__ = [
67
68
  "ItemFold",
68
69
  "ItemLike",
69
70
  "ItemStore",
71
+ "MonitorInboxNotice",
70
72
  "PendingApproval",
71
73
  "Replaced",
72
74
  "SessionFold",