optio-cursor 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,56 @@
1
+ """optio-cursor — run Cursor CLI (cursor-agent) as an optio task."""
2
+
3
+ import logging as _logging
4
+
5
+ from optio_agents import HookContext, HookContextProtocol
6
+ from optio_host import (
7
+ HostCommandError,
8
+ RunResult,
9
+ SSHConfig,
10
+ )
11
+
12
+ from optio_cursor.seed_manifest import (
13
+ CURSOR_CRED_MANIFEST,
14
+ CURSOR_SEED_MANIFEST,
15
+ CURSOR_SEED_SUFFIX,
16
+ delete_seed,
17
+ list_seeds,
18
+ purge_seed,
19
+ )
20
+ from optio_cursor.session import create_cursor_task, run_cursor_session
21
+ from optio_cursor.types import (
22
+ CursorTaskConfig,
23
+ DeliverableCallback,
24
+ HookCallback,
25
+ SeedProvider,
26
+ SeedUnavailableError,
27
+ )
28
+ from optio_cursor.verify import verify_and_refresh_seed
29
+
30
+
31
+ # asyncssh emits per-connection INFO lines that flood worker stdout
32
+ # once an SSH-backed session starts. Quiet by default.
33
+ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
34
+
35
+
36
+ __all__ = [
37
+ "create_cursor_task",
38
+ "run_cursor_session",
39
+ "CursorTaskConfig",
40
+ "DeliverableCallback",
41
+ "HookCallback",
42
+ "SSHConfig",
43
+ "HookContext",
44
+ "HookContextProtocol",
45
+ "HostCommandError",
46
+ "RunResult",
47
+ "CURSOR_SEED_MANIFEST",
48
+ "CURSOR_CRED_MANIFEST",
49
+ "CURSOR_SEED_SUFFIX",
50
+ "delete_seed",
51
+ "list_seeds",
52
+ "purge_seed",
53
+ "SeedProvider",
54
+ "SeedUnavailableError",
55
+ "verify_and_refresh_seed",
56
+ ]
@@ -0,0 +1,516 @@
1
+ """CursorConversation — engine-side driver for one headless Cursor session
2
+ over the Agent Client Protocol (ACP): JSON-RPC 2.0 over the stdin/stdout of
3
+ ``cursor-agent acp``.
4
+
5
+ The session body launches ``cursor-agent [--model M] [--force] acp`` via
6
+ ``host.launch_subprocess(stdin=True)``, attaches the handle here, starts
7
+ ``run_reader()``, runs ``bootstrap()`` (the ACP handshake), publishes this
8
+ object via ``ctx.publish_result``, and waits until the subprocess ends.
9
+
10
+ Event payloads are transparent: every parsed stdout JSON-RPC object is fanned
11
+ out to ``on_event`` subscribers as a dict, unmodified. Synthetic events use
12
+ the ``x-optio-`` type prefix. Adapted from optio-grok's GrokConversation —
13
+ both agents speak the same public ACP protocol.
14
+
15
+ ============================================================================
16
+ ACP WIRE FACTS for `cursor-agent acp` (JSON-RPC 2.0 over stdio).
17
+ Provenance per shape:
18
+ [cursor-verified] — pinned by a live UNAUTHENTICATED handshake probe of
19
+ the real `cursor-agent acp` on this host.
20
+ [grok-pinned, cursor runtime-unverified] — copied from optio-grok's
21
+ LIVE-pinned ACP shapes (grok 0.2.81; see
22
+ optio_grok/conversation.py). Cursor implements the
23
+ same public ACP protocol; a logged-in prompt-cycle
24
+ probe was NOT possible (host `cursor-agent status` =
25
+ "Not logged in"). Runtime confirmation deferred to
26
+ the demo stage — tracked in design doc §7 item 3.
27
+ ============================================================================
28
+
29
+ Methods present in the cursor binary [cursor-verified]:
30
+ session/new, session/load, session/prompt, session/cancel, session/update,
31
+ session/set_model, session/request_permission, authenticate.
32
+
33
+ Client -> agent REQUESTS (have `id`, expect a `result`):
34
+ * ``initialize`` {protocolVersion:1, clientCapabilities:{…}} ->
35
+ [cursor-verified] {protocolVersion:1, agentCapabilities:{loadSession:
36
+ true, promptCapabilities:{image:true}, sessionCapabilities:{list:{}}},
37
+ authMethods:[{id:"cursor_login"}]}.
38
+ * ``session/new`` {cwd, mcpServers:[]} -> {sessionId, models, _meta}.
39
+ [grok-pinned, cursor runtime-unverified]
40
+ * ``session/prompt`` {sessionId, prompt:[{type:"text", text}]} ->
41
+ **THIS RESPONSE IS THE TURN-END SIGNAL**: {stopReason:"end_turn" |
42
+ "cancelled" | …}. A denied/aborted turn returns stopReason:"cancelled".
43
+ [grok-pinned, cursor runtime-unverified]
44
+
45
+ Agent -> client NOTIFICATIONS (no `id`): ``session/update`` with
46
+ ``params.update.sessionUpdate`` ∈: [grok-pinned, cursor runtime-unverified]
47
+ * ``agent_message_chunk`` — {update:{sessionUpdate, content:{type:"text",
48
+ text}}}. Concatenate per turn -> the final answer (on_message).
49
+ * ``agent_thought_chunk`` — same shape; reasoning, NOT folded into answer.
50
+ * ``tool_call`` — {update:{sessionUpdate, toolCallId, title,
51
+ rawInput, …}}.
52
+ * ``tool_call_update`` — {update:{sessionUpdate, toolCallId, kind, title,
53
+ content:[…], rawInput, status}}.
54
+ * ``plan`` / ``available_commands_update`` / ``user_message_chunk`` and any
55
+ vendor-prefixed notifications — passed through untouched to on_event.
56
+
57
+ Agent -> client REQUESTS (have `id` AND `method`, WE must respond):
58
+ * ``session/request_permission`` {sessionId, toolCall:{toolCallId, kind,
59
+ title, rawInput}, options:[{optionId, name, kind}]}. Option `kind` ∈
60
+ {allow_once, allow_always, reject_once, reject_always}. ANSWER with
61
+ ``result``:
62
+ allow -> {outcome:{outcome:"selected", optionId:<an allow_* option>}}
63
+ deny -> {outcome:{outcome:"selected", optionId:<a reject_* option>}}
64
+ or {outcome:{outcome:"cancelled"}} if no reject option.
65
+ (Only appears when the client does NOT advertise the relevant capability;
66
+ we advertise neither terminal nor fs write, so cursor runs its own tools
67
+ and asks here — that is the permission gate seam.)
68
+ [grok-pinned, cursor runtime-unverified]
69
+ * ``terminal/create`` / ``fs/*`` — only if we advertise those capabilities
70
+ (we do not); answered with a JSON-RPC method-not-found error defensively.
71
+ [grok-pinned, cursor runtime-unverified]
72
+
73
+ Client -> agent CANCEL: ``session/cancel`` {sessionId} is a NOTIFICATION
74
+ (no `id`, no ack). It makes the in-flight ``session/prompt`` return
75
+ stopReason:"cancelled" — that response is the interrupt's completion signal.
76
+ [grok-pinned, cursor runtime-unverified]
77
+
78
+ Cursor-specific divergences from grok (for Task 1/2):
79
+ * Subprocess is ``cursor-agent [--model M] [--force] acp`` — no
80
+ ``--no-leader``/``stdio`` args; ``--force`` is the auto-approve analogue
81
+ of grok's ``--always-approve`` (acceptance by the acp subcommand is
82
+ runtime-unverified — fall back to answering session/request_permission
83
+ allow-all client-side if rejected).
84
+ * authMethods id is ``cursor_login`` (grok differs). [cursor-verified]
85
+ ============================================================================
86
+ """
87
+
88
+ from __future__ import annotations
89
+
90
+ import asyncio
91
+ import json
92
+ import logging
93
+
94
+ from optio_agents.conversation import (
95
+ ConversationClosed,
96
+ PermissionDecision,
97
+ PermissionRequest,
98
+ )
99
+
100
+ _LOG = logging.getLogger(__name__)
101
+
102
+ # ACP option `kind` prefixes for allow / reject decisions.
103
+ _ALLOW_KINDS = ("allow_once", "allow_always", "allow")
104
+ _REJECT_KINDS = ("reject_once", "reject_always", "reject")
105
+
106
+
107
+ class CursorConversation:
108
+ """Implements optio_agents.conversation.Conversation for Cursor (ACP)."""
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ cwd: str,
114
+ agent_label: str = "cursor",
115
+ permission_gate: bool = False,
116
+ mcp_servers: list | None = None,
117
+ ) -> None:
118
+ self._cwd = cwd
119
+ self._agent_label = agent_label
120
+ # When False, session/request_permission is answered with a defensive
121
+ # deny instead of being queued for a handler.
122
+ self._permission_gate = permission_gate
123
+ self._mcp_servers = mcp_servers or []
124
+ self._handle = None
125
+ self._session_id: str | None = None
126
+ # ACP model block from session/new (see models.py). Captured at
127
+ # bootstrap so the session can populate the picker without a separate
128
+ # (auth-gated) `cursor-agent models` subprocess.
129
+ self.session_models: dict | None = None
130
+ self.current_model_id: str | None = None
131
+ self._pending = 0 # user turns awaiting their result
132
+ self._closed = asyncio.Event()
133
+ self._close_reason: str | None = None
134
+ # Cooperative-shutdown request towards the owning task body.
135
+ self.close_requested = asyncio.Event()
136
+ self._write_lock = asyncio.Lock()
137
+ self._event_queue: asyncio.Queue[dict] = asyncio.Queue()
138
+ self._event_handlers: list = []
139
+ self._message_handlers: list = []
140
+ self._permission_handler = None
141
+ self._queued_permission_requests: list[dict] = []
142
+ # JSON-RPC id bookkeeping.
143
+ self._next_id = 0
144
+ self._req_futures: dict[int, asyncio.Future] = {} # handshake requests
145
+ self._prompt_ids: set[int] = set() # session/prompt turns
146
+ # Accumulates agent_message_chunk text for the current turn.
147
+ self._answer_parts: list[str] = []
148
+ self._dispatcher_task: asyncio.Task | None = None
149
+
150
+ # -- wiring ------------------------------------------------------------
151
+
152
+ def attach(self, handle) -> None:
153
+ """Attach the live ProcessHandle (must have been launched with
154
+ stdin=True)."""
155
+ if handle.stdin is None:
156
+ raise ValueError(
157
+ "CursorConversation.attach: handle has no stdin writer; launch "
158
+ "the subprocess with stdin=True"
159
+ )
160
+ self._handle = handle
161
+
162
+ async def bootstrap(self) -> None:
163
+ """Run the ACP handshake: ``initialize`` then ``session/new``.
164
+
165
+ Requires ``run_reader()`` to already be running (it routes the
166
+ responses back to the futures created here). We advertise NEITHER the
167
+ terminal NOR fs-write client capability, so cursor executes its own
168
+ tools and surfaces approval via ``session/request_permission`` (the
169
+ gate seam) instead of delegating tool execution to us.
170
+ """
171
+ await self._request("initialize", {
172
+ "protocolVersion": 1,
173
+ "clientCapabilities": {
174
+ "fs": {"readTextFile": False, "writeTextFile": False},
175
+ "terminal": False,
176
+ },
177
+ })
178
+ resp = await self._request("session/new", {
179
+ "cwd": self._cwd,
180
+ "mcpServers": self._mcp_servers,
181
+ })
182
+ result = (resp or {}).get("result") or {}
183
+ self._session_id = result.get("sessionId")
184
+ if not self._session_id:
185
+ raise RuntimeError(
186
+ f"cursor ACP session/new returned no sessionId: {result!r}"
187
+ )
188
+ models = result.get("models")
189
+ if isinstance(models, dict):
190
+ self.session_models = models
191
+ self.current_model_id = models.get("currentModelId")
192
+
193
+ async def reset_session(self) -> str | None:
194
+ """Start a FRESH ACP session (drops the current session's chat context)
195
+ without re-initializing. Used after the startup model probe so its
196
+ throwaway "capital of Hungary" turns never leak into the operator's
197
+ conversation. Returns the ABANDONED session id (so the caller can purge
198
+ its on-disk records, which cursor persists under $HOME and would
199
+ otherwise be snapshot-captured and rediscovered on resume). Best-effort:
200
+ on failure the existing session is kept and None is returned."""
201
+ old = self._session_id
202
+ try:
203
+ resp = await self._request("session/new", {
204
+ "cwd": self._cwd,
205
+ "mcpServers": self._mcp_servers,
206
+ })
207
+ except Exception: # noqa: BLE001 — a reset failure just keeps the session
208
+ _LOG.exception("cursor conversation: reset_session failed")
209
+ return None
210
+ result = (resp or {}).get("result") or {}
211
+ sid = result.get("sessionId")
212
+ if sid:
213
+ self._session_id = sid
214
+ models = result.get("models")
215
+ if isinstance(models, dict):
216
+ self.session_models = models
217
+ self.current_model_id = models.get("currentModelId")
218
+ return old if (old and old != self._session_id) else None
219
+
220
+ async def run_reader(self) -> None:
221
+ """Drain stdout until EOF; route JSON-RPC messages. Owned by the
222
+ session body; ends when the subprocess ends."""
223
+ self._dispatcher_task = asyncio.create_task(self._dispatch_loop())
224
+ try:
225
+ async for raw in self._handle.stdout:
226
+ line = (
227
+ raw.decode("utf-8", errors="replace")
228
+ if isinstance(raw, bytes) else str(raw)
229
+ ).strip()
230
+ if not line:
231
+ continue
232
+ try:
233
+ obj = json.loads(line)
234
+ except ValueError:
235
+ _LOG.warning("cursor conversation: unparseable line: %.200s", line)
236
+ self._event_queue.put_nowait(
237
+ {"type": "x-optio-unparseable", "line": line},
238
+ )
239
+ continue
240
+ self._route(obj)
241
+ finally:
242
+ await self._finish("process ended")
243
+
244
+ def _route(self, obj: dict) -> None:
245
+ rid = obj.get("id")
246
+ method = obj.get("method")
247
+ if method is None and rid is not None and ("result" in obj or "error" in obj):
248
+ # Response to one of OUR requests.
249
+ if rid in self._req_futures:
250
+ fut = self._req_futures.pop(rid)
251
+ if not fut.done():
252
+ fut.set_result(obj)
253
+ elif rid in self._prompt_ids:
254
+ # session/prompt response == turn end.
255
+ self._prompt_ids.discard(rid)
256
+ self._pending = max(0, self._pending - 1)
257
+ text = "".join(self._answer_parts)
258
+ self._answer_parts = []
259
+ self._fire_message(text)
260
+ elif method is not None and rid is not None:
261
+ # Agent -> client REQUEST that we must answer.
262
+ if method == "session/request_permission":
263
+ self._on_permission(obj)
264
+ else:
265
+ # Unadvertised capability (terminal/create, fs/*): decline so
266
+ # cursor falls back to running the tool itself.
267
+ asyncio.ensure_future(self._write_json({
268
+ "jsonrpc": "2.0", "id": rid,
269
+ "error": {"code": -32601,
270
+ "message": f"optio cursor client does not implement {method}"},
271
+ }))
272
+ elif method == "session/update":
273
+ self._on_session_update(obj)
274
+ # else: other agent notifications (plan, vendor-prefixed, …) — pass
275
+ # through only.
276
+ self._event_queue.put_nowait(obj)
277
+
278
+ def _on_session_update(self, obj: dict) -> None:
279
+ update = (obj.get("params") or {}).get("update") or {}
280
+ if update.get("sessionUpdate") == "agent_message_chunk":
281
+ text = ((update.get("content") or {}).get("text")) or ""
282
+ if text:
283
+ self._answer_parts.append(text)
284
+
285
+ # -- event fan-out -----------------------------------------------------
286
+
287
+ async def _dispatch_loop(self) -> None:
288
+ while True:
289
+ obj = await self._event_queue.get()
290
+ for handler in list(self._event_handlers):
291
+ await self._call_handler(handler, obj, "on_event")
292
+
293
+ async def _call_handler(self, handler, arg, label: str) -> None:
294
+ try:
295
+ result = handler(arg)
296
+ if asyncio.iscoroutine(result):
297
+ await result
298
+ except Exception: # noqa: BLE001 — subscriber bugs never kill the driver
299
+ _LOG.exception("cursor conversation: %s handler raised", label)
300
+
301
+ def _fire_message(self, text: str) -> None:
302
+ for handler in list(self._message_handlers):
303
+ asyncio.ensure_future(self._call_handler(handler, text, "on_message"))
304
+
305
+ # -- permission gate ----------------------------------------------------
306
+
307
+ def _on_permission(self, obj: dict) -> None:
308
+ if not self._permission_gate:
309
+ _LOG.warning(
310
+ "cursor conversation: session/request_permission received with "
311
+ "permission_gate off; denying defensively",
312
+ )
313
+ asyncio.ensure_future(self._answer_permission_decision(
314
+ obj, PermissionDecision(
315
+ behavior="deny",
316
+ message="optio harness: permission gate not enabled",
317
+ ),
318
+ ))
319
+ return
320
+ if self._permission_handler is None:
321
+ # Queue until a handler is registered; the turn blocks agent-side,
322
+ # which closes the publish/registration race.
323
+ self._queued_permission_requests.append(obj)
324
+ return
325
+ asyncio.ensure_future(self._answer_permission(obj))
326
+
327
+ async def _answer_permission(self, obj: dict) -> None:
328
+ params = obj.get("params") or {}
329
+ tool_call = params.get("toolCall") or {}
330
+ request = PermissionRequest(
331
+ tool_name=tool_call.get("title") or tool_call.get("kind") or "",
332
+ input=tool_call.get("rawInput") or {},
333
+ raw=obj,
334
+ )
335
+ try:
336
+ decision = await self._permission_handler(request)
337
+ except Exception: # noqa: BLE001
338
+ _LOG.exception("cursor conversation: permission handler raised; denying")
339
+ decision = PermissionDecision(
340
+ behavior="deny",
341
+ message="optio harness: permission handler failed",
342
+ )
343
+ await self._answer_permission_decision(obj, decision)
344
+
345
+ async def _answer_permission_decision(
346
+ self, obj: dict, decision: PermissionDecision,
347
+ ) -> None:
348
+ params = obj.get("params") or {}
349
+ options = params.get("options") or []
350
+ wanted = _ALLOW_KINDS if decision.behavior == "allow" else _REJECT_KINDS
351
+ option_id = None
352
+ for opt in options:
353
+ if (opt.get("kind") or "").lower() in wanted:
354
+ option_id = opt.get("optionId")
355
+ break
356
+ if option_id is not None:
357
+ outcome = {"outcome": "selected", "optionId": option_id}
358
+ else:
359
+ # No matching option (e.g. deny with no reject_* option): cancelling
360
+ # the request is ACP's abort path.
361
+ outcome = {"outcome": "cancelled"}
362
+ await self._write_json({
363
+ "jsonrpc": "2.0", "id": obj.get("id"),
364
+ "result": {"outcome": outcome},
365
+ })
366
+
367
+ # -- Conversation protocol surface --------------------------------------
368
+
369
+ async def send(self, text: str) -> None:
370
+ if self._closed.is_set():
371
+ raise ConversationClosed(self._close_reason or "conversation closed")
372
+ if self._session_id is None:
373
+ raise RuntimeError("CursorConversation.send before bootstrap() completed")
374
+ self._next_id += 1
375
+ rid = self._next_id
376
+ self._prompt_ids.add(rid)
377
+ self._pending += 1
378
+ try:
379
+ await self._write_json({
380
+ "jsonrpc": "2.0", "id": rid, "method": "session/prompt",
381
+ "params": {
382
+ "sessionId": self._session_id,
383
+ "prompt": [{"type": "text", "text": text}],
384
+ },
385
+ })
386
+ except Exception:
387
+ self._prompt_ids.discard(rid)
388
+ self._pending = max(0, self._pending - 1)
389
+ await self._finish("stdin write failed")
390
+ raise
391
+
392
+ def on_event(self, handler):
393
+ self._event_handlers.append(handler)
394
+ return lambda: self._event_handlers.remove(handler)
395
+
396
+ def on_message(self, handler):
397
+ self._message_handlers.append(handler)
398
+ return lambda: self._message_handlers.remove(handler)
399
+
400
+ def on_permission_request(self, handler):
401
+ self._permission_handler = handler
402
+ queued, self._queued_permission_requests = (
403
+ self._queued_permission_requests, [],
404
+ )
405
+ for obj in queued:
406
+ asyncio.ensure_future(self._answer_permission(obj))
407
+
408
+ def _unsub() -> None:
409
+ if self._permission_handler is handler:
410
+ self._permission_handler = None
411
+ return _unsub
412
+
413
+ def is_pending(self) -> bool:
414
+ return self._pending > 0
415
+
416
+ async def interrupt(self) -> None:
417
+ if self._closed.is_set():
418
+ raise ConversationClosed(self._close_reason or "conversation closed")
419
+ if self._pending == 0 or self._session_id is None:
420
+ return
421
+ # session/cancel is a notification (no id); the in-flight prompt
422
+ # response carrying stopReason:"cancelled" is the completion signal.
423
+ await self._write_json({
424
+ "jsonrpc": "2.0", "method": "session/cancel",
425
+ "params": {"sessionId": self._session_id},
426
+ })
427
+
428
+ def request_model_change(self, model: str) -> None:
429
+ """Switch model mid-conversation INLINE via a ``session/set_model``
430
+ ACP request (no process restart) — grok's live-pinned mechanism,
431
+ [grok-pinned, cursor runtime-unverified]; the method is present in
432
+ the cursor binary. See models.py for the probe record + the
433
+ restart-based fallback. Synchronous surface (the listener calls it
434
+ without await): schedules the ACP write and updates the model
435
+ optimistically."""
436
+ if self._closed.is_set():
437
+ raise ConversationClosed(self._close_reason or "conversation closed")
438
+ if self._session_id is None:
439
+ raise RuntimeError(
440
+ "CursorConversation.request_model_change before bootstrap() completed"
441
+ )
442
+ self.current_model_id = model
443
+ asyncio.ensure_future(self._set_model(model))
444
+
445
+ async def _set_model(self, model: str) -> None:
446
+ try:
447
+ await self._request("session/set_model", {
448
+ "sessionId": self._session_id, "modelId": model,
449
+ })
450
+ except ConversationClosed:
451
+ pass # a swap racing the close is a no-op
452
+ except Exception: # noqa: BLE001 — never let a set_model bug kill the driver
453
+ _LOG.exception("cursor conversation: session/set_model failed")
454
+
455
+ async def set_active_model(self, model: str) -> None:
456
+ """Await a ``session/set_model`` round-trip so the NEXT prompt uses
457
+ ``model``. Used by the startup model probe (model_probe.probe_models);
458
+ the interactive UI path uses the fire-and-forget request_model_change."""
459
+ await self._set_model(model)
460
+ self.current_model_id = model
461
+
462
+ async def close(self) -> None:
463
+ self.close_requested.set()
464
+
465
+ @property
466
+ def closed(self) -> bool:
467
+ return self._closed.is_set()
468
+
469
+ # -- internals -----------------------------------------------------------
470
+
471
+ async def _request(self, method: str, params: dict) -> dict:
472
+ """Send a client->agent request and await its response (handshake only)."""
473
+ self._next_id += 1
474
+ rid = self._next_id
475
+ fut: asyncio.Future = asyncio.get_event_loop().create_future()
476
+ self._req_futures[rid] = fut
477
+ await self._write_json({
478
+ "jsonrpc": "2.0", "id": rid, "method": method, "params": params,
479
+ })
480
+ return await fut
481
+
482
+ async def _write_json(self, obj: dict) -> None:
483
+ await self._write_bytes((json.dumps(obj) + "\n").encode("utf-8"))
484
+
485
+ async def _write_bytes(self, data: bytes) -> None:
486
+ async with self._write_lock:
487
+ stdin = self._handle.stdin
488
+ stdin.write(data)
489
+ drain = getattr(stdin, "drain", None)
490
+ if drain is not None:
491
+ await drain()
492
+
493
+ async def _finish(self, reason: str) -> None:
494
+ if self._closed.is_set():
495
+ return
496
+ self._closed.set()
497
+ self._close_reason = reason
498
+ # Fail any in-flight handshake requests.
499
+ for fut in self._req_futures.values():
500
+ if not fut.done():
501
+ fut.set_exception(ConversationClosed(reason))
502
+ self._req_futures.clear()
503
+ self._event_queue.put_nowait({"type": "x-optio-closed", "reason": reason})
504
+ # Stop the dispatcher, then drain whatever it left so subscribers are
505
+ # guaranteed to see the final x-optio-closed event.
506
+ if self._dispatcher_task is not None:
507
+ self._dispatcher_task.cancel()
508
+ try:
509
+ await self._dispatcher_task
510
+ except asyncio.CancelledError:
511
+ pass
512
+ self._dispatcher_task = None
513
+ while not self._event_queue.empty():
514
+ obj = self._event_queue.get_nowait()
515
+ for handler in list(self._event_handlers):
516
+ await self._call_handler(handler, obj, "on_event")