plexi-sdk 0.4.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.
plexi_sdk/_app.py ADDED
@@ -0,0 +1,1077 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import sys
7
+ from typing import Any, Coroutine
8
+
9
+ from ._protocol import PROTOCOL_VERSION
10
+ from ._constants import _SDK_VERSION, BG as _DEFAULT_BG
11
+ from ._emitter import Emitter, _emit, _sync_hook_scope
12
+ from ._pipe import Pipe
13
+ from ._render_context import RenderContext
14
+
15
+
16
+ def _log_task_exception(task: asyncio.Task) -> None:
17
+ """Done callback for background tasks — logs unhandled exceptions."""
18
+ try:
19
+ exc = task.exception()
20
+ except (asyncio.CancelledError, asyncio.InvalidStateError):
21
+ return
22
+ if exc is not None:
23
+ sys.stderr.write(f"plexi_sdk: unhandled exception in background task: {exc}\n")
24
+
25
+
26
+ # Map egui's Debug-format key names to the documented canonical SDK names.
27
+ # The host sends "ArrowLeft" etc. (egui Key::ArrowLeft Debug repr); apps
28
+ # should use "left"/"right"/"up"/"down" as documented. Normalizing here
29
+ # means both forms work correctly — agents and humans can use the documented
30
+ # names without knowing egui's internal representation.
31
+ _KEY_ALIASES: "dict[str, str]" = {
32
+ "ArrowLeft": "left",
33
+ "ArrowRight": "right",
34
+ "ArrowUp": "up",
35
+ "ArrowDown": "down",
36
+ # egui Debug-format names → SDK canonical names
37
+ "Enter": "return",
38
+ "Escape": "escape",
39
+ "Backspace": "backspace",
40
+ "Tab": "tab",
41
+ # Space arrives as Event::Text(" "), not Event::Key
42
+ " ": "space",
43
+ }
44
+
45
+
46
+ def _normalize_key(key: str) -> str:
47
+ return _KEY_ALIASES.get(key, key)
48
+
49
+
50
+ # ── App base class ────────────────────────────────────────────────────────────
51
+
52
+ class App:
53
+ """
54
+ Base class for Plexi v3 apps. Subclass and override event handlers.
55
+
56
+ Override any of:
57
+
58
+ Awaited (block the event loop until they return):
59
+ on_init(ctx) — after Init handshake
60
+ on_render(ctx) — on each Render event
61
+ on_suspend() — on Suspend
62
+ on_resume() — on Resume
63
+ on_shutdown() — on Shutdown
64
+
65
+ Task (dispatched as asyncio tasks — event loop continues):
66
+ on_key(ctx, key, mods) — on Key event
67
+ on_click(ctx, x, y, button) — on Click event
68
+ on_mouse_down(ctx, x, y, button) — on MouseDown event
69
+ on_mouse_up(ctx, x, y, button) — on MouseUp event
70
+ on_mouse_move(ctx, x, y, buttons) — on MouseMove event
71
+ on_command(ctx, text) — on Command event
72
+ on_paste(ctx, text) — on Paste event
73
+ on_text_submitted(ctx, id, text) — on TextInput Enter press
74
+ on_pipe_message(ctx, pipe_id, payload) — on PipeMessage
75
+ on_path_changed(ctx, cwd) — on PathChanged
76
+ on_inject(ctx, payload) — on Inject event
77
+ on_nav_back(ctx, view_id) — on NavBack event
78
+ on_timer(ctx, timer_id) — on Timer event
79
+ on_scroll(ctx, id, offset_y) — on Scroll event
80
+ on_file_picked(ctx, request_id, paths) — on FilePicked event
81
+ on_file_pick_cancelled(ctx, request_id) — on FilePickCancelled
82
+ on_mcp_call(ctx, tool_name, arguments) — on MCP tool call
83
+
84
+ Fire-and-forget (no RenderContext — called outside a render frame):
85
+ on_pane_spawned(pane_id, request_id) — pane spawn succeeded
86
+ on_pane_spawn_error(reason, request_id) — pane spawn failed
87
+ on_context_state(state) — context state query result
88
+ on_midi_input_opened(pipe_id, port_id, port_name) — MIDI input opened
89
+
90
+ Task handlers are dispatched as asyncio tasks — the event loop does not
91
+ wait for them to complete before processing the next event. Declare them
92
+ ``async def`` whenever they do any I/O. Never call blocking operations
93
+ (time.sleep, requests.get, etc.) directly from these handlers; use
94
+ ``await asyncio.to_thread(fn)`` or ``threading.Thread`` + ``emit.run_sync()``.
95
+
96
+ Awaited handlers block the event loop until they return. Use ``await``-able
97
+ Emitter helpers freely; they do not deadlock because the stdin reader runs
98
+ as a concurrent task.
99
+
100
+ Fire-and-forget handlers do not receive a RenderContext because they are
101
+ dispatched outside a render frame.
102
+ """
103
+
104
+ # Background color applied automatically before each on_render call.
105
+ # Set to None to disable the default background and manage clearing manually.
106
+ default_background: "str | None" = _DEFAULT_BG
107
+
108
+ def __init__(self) -> None:
109
+ self._sdk_initialized: bool = True
110
+ self.app_id: str = ""
111
+ self.args: list[str] = sys.argv[1:]
112
+ self.workspace_root: str = ""
113
+ self.capabilities: list[str] = []
114
+ self.feature_flags: list[str] = []
115
+ self._rect: dict = {"x": 0.0, "y": 0.0, "w": 800.0, "h": 600.0}
116
+ self._compact_threshold: float = 280.0
117
+ self._regular_threshold: float = 480.0
118
+ # The running asyncio event loop. Set by run() before hooks are called.
119
+ # Background threads use this via emit.run_sync() to schedule coroutines.
120
+ self._loop: "asyncio.AbstractEventLoop | None" = None
121
+ # All pending-response maps now hold asyncio.Queue so the event loop
122
+ # coroutine can await them without blocking the stdin reader.
123
+ self._pending_capability: "dict[str, asyncio.Queue]" = {}
124
+ self._pending_secret: "dict[str, asyncio.Queue]" = {}
125
+ self._app_state: dict = {}
126
+ self._pending_http: "dict[str, asyncio.Queue]" = {}
127
+ # v3.x async image loading (#1354): awaits PlexiEvent::ImageLoaded keyed
128
+ # on handle UUID. Each entry is consumed by a single load_image() call.
129
+ self._pending_image: "dict[str, asyncio.Queue]" = {}
130
+ # v3.3 ai.query broker (#284): awaits PlexiEvent::AiResponse keyed
131
+ # on request_id. Each entry is consumed by a single ai_query() call.
132
+ self._pending_ai: "dict[str, asyncio.Queue]" = {}
133
+ # v3.4 CoreMIDI (#320): awaits PlexiEvent::MidiDevicesListed keyed
134
+ # on request_id. Each entry is consumed by a single list_midi_devices().
135
+ self._pending_midi_devices: "dict[str, asyncio.Queue]" = {}
136
+ # v3.4 audio device enumeration (#341): awaits PlexiEvent::AudioDevicesListed keyed
137
+ # on request_id. Each entry is consumed by a single list_audio_devices() call.
138
+ self._pending_audio_devices: "dict[str, asyncio.Queue]" = {}
139
+ # v3.4 video substrate (#345): awaits PlexiEvent::VideoOpenAck /
140
+ # VideoOpenError keyed on request_id. Each entry is consumed by a
141
+ # single open_video() call.
142
+ self._pending_video_open: "dict[str, asyncio.Queue]" = {}
143
+ self._pending_notify: "dict[str, asyncio.Queue]" = {}
144
+ # #310: non-blocking notify_*_async callbacks. Keyed on notify_id;
145
+ # each callable is invoked on the event thread when NotifyAction arrives.
146
+ self._pending_notify_callbacks: "dict[str, Any]" = {}
147
+ # v3.5 Canvas Terminal Binding Primitives (#78). Two response shapes:
148
+ # `linked_terminal_ready` carries an int pane_id; `command_preview`
149
+ # carries (command, would_run_in_cwd). Each async helper awaits
150
+ # its own keyed queue.
151
+ self._pending_linked_terminal: "dict[str, asyncio.Queue]" = {}
152
+ self._pending_command_preview: "dict[str, asyncio.Queue]" = {}
153
+ # RenderContext.measure_text: awaits PlexiEvent::TextMeasured keyed on request_id.
154
+ self._pending_measure_text: "dict[str, asyncio.Queue]" = {}
155
+ # RenderContext.measure_text_wrapped: awaits PlexiEvent::TextWrappedMeasured.
156
+ self._pending_measure_text_wrapped: "dict[str, asyncio.Queue]" = {}
157
+ self._pipes: dict[str, Pipe] = {}
158
+ self._last_render_time: "float | None" = None
159
+ self._consecutive_render_errors: int = 0
160
+ # Strong references to background asyncio tasks created by
161
+ # _dispatch_hook_task. Without this, CPython may GC a task before it
162
+ # completes. The done callback removes each task from the set.
163
+ self._background_tasks: "set[asyncio.Task]" = set()
164
+ # Pending text-input submissions keyed on TextInput `id`. The
165
+ # event-loop coroutine fills this when `PlexiEvent::TextSubmitted`
166
+ # arrives; `RenderContext.text_input` drains it during render.
167
+ # One pending value per id — a second submit before the app
168
+ # consumes the first overwrites (apps poll every frame, so
169
+ # this only matters in a perverse scheduling case).
170
+ self._text_submissions: dict[str, str] = {}
171
+ # v3.7 tool protocol (#399): tool_name → handler callable.
172
+ # Registered via @app.tool(...) decorator.
173
+ self._tool_handlers: dict[str, Any] = {}
174
+ # Keep the full declared tool set so repeated @app.tool decorator
175
+ # calls expose the cumulative list instead of replacing prior tools.
176
+ self._tool_defs: dict[str, dict] = {}
177
+ self.emit = Emitter(self)
178
+ # v3.x button primitive (#255): last known mouse position and buffered
179
+ # click events for ctx.button() hit-testing during on_render.
180
+ self._mx: float = 0.0
181
+ self._my: float = 0.0
182
+ self._click_buf: list[tuple[float, float]] = []
183
+ # Hold timer for ctx.button() active_fill (#1083): maps button id →
184
+ # monotonic timestamp at which the active state expires.
185
+ self._btn_active_until: dict[str, float] = {}
186
+
187
+ def __init_subclass__(cls, **kwargs: object) -> None:
188
+ super().__init_subclass__(**kwargs)
189
+ orig_init = cls.__dict__.get("__init__")
190
+ if orig_init is not None:
191
+ def wrapped(self_inner: "App", *args: Any, _orig: Any = orig_init, **kw: Any) -> None:
192
+ if not getattr(self_inner, "_sdk_initialized", False):
193
+ App.__init__(self_inner)
194
+ _orig(self_inner, *args, **kw)
195
+ cls.__init__ = wrapped # type: ignore[assignment]
196
+
197
+ # ── Override these ──────────────────────────────────────────────────────
198
+ # All hooks may be overridden as either `def` (sync) or `async def`.
199
+ # _dispatch_hook detects the type at call time — both are valid.
200
+ # Return type is `Coroutine[Any, Any, None] | None` so Pyright accepts
201
+ # both sync (`def` → returns None) and async (`async def` → returns
202
+ # Coroutine) overrides without reportIncompatibleMethodOverride.
203
+ def on_init(self, _ctx: RenderContext) -> "Coroutine[Any, Any, None] | None": return None
204
+ def on_render(self, _ctx: RenderContext) -> None: pass
205
+ def on_key(self, _ctx: RenderContext, _key: str, _mods: dict) -> "Coroutine[Any, Any, None] | None": return None
206
+ def on_click(self, _ctx: RenderContext, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
207
+ def on_mouse_down(self, _ctx: RenderContext, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
208
+ def on_mouse_up(self, _ctx: RenderContext, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
209
+ def on_mouse_move(self, _ctx: RenderContext, _x: float, _y: float, _buttons: list) -> "Coroutine[Any, Any, None] | None": return None
210
+ def on_command(self, _ctx: RenderContext, _text: str) -> "Coroutine[Any, Any, None] | None": return None
211
+ def on_paste(self, _ctx: RenderContext, _text: str) -> "Coroutine[Any, Any, None] | None": return None
212
+ def on_pipe_message(self, _ctx: RenderContext, _pipe_id: str, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
213
+ def on_path_changed(self, _ctx: RenderContext, _cwd: str) -> "Coroutine[Any, Any, None] | None": return None
214
+ def on_inject(self, _ctx: RenderContext, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
215
+ def on_nav_back(self, _ctx: RenderContext, _view_id: str) -> "Coroutine[Any, Any, None] | None":
216
+ """Called when the host emits ``NavBack`` — user pressed Cmd+[ or the
217
+ back arrow in the pane chrome. ``view_id`` is the view being navigated
218
+ *back to* (the new top of stack, or empty string for root).
219
+
220
+ The app should update its own view state to show ``view_id``, then call
221
+ ``ctx.emit.pop_nav()`` to remove the entry from the host stack.
222
+ """
223
+ return None
224
+ def on_app_spawned(self, _pane_id: int, _type_id: str) -> None: pass
225
+ def on_pane_spawned(self, _pane_id: int, _request_id: "str | None" = None) -> None:
226
+ """Called when a SpawnPane request succeeded (#592). Override to track the spawned pane."""
227
+
228
+ def on_pane_spawn_error(self, _reason: str, _request_id: "str | None" = None) -> None:
229
+ """Called when a SpawnPane request failed (#592). Override to handle the error."""
230
+
231
+ def on_context_state(self, _state: dict) -> None:
232
+ """Called when a QueryContextState response arrives (#1518).
233
+
234
+ ``_state`` is a dict with keys: context_id, name, path, status,
235
+ pane_count, panes (list of pane summaries), children (list of child
236
+ context ids).
237
+ """
238
+
239
+ def on_timer(self, _ctx: RenderContext, _timer_id: str) -> "Coroutine[Any, Any, None] | None": return None
240
+ def on_scroll(self, _ctx: RenderContext, _id: str, _offset_y: float) -> "Coroutine[Any, Any, None] | None": return None
241
+ def on_list_select(self, _ctx: "RenderContext", _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
242
+ """Called when a list_view selection changes via j/k/up/down."""
243
+ return None
244
+ def on_list_activate(self, _ctx: "RenderContext", _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
245
+ """Called when Enter is pressed on a selected list_view item."""
246
+ return None
247
+ def on_text_submitted(self, _ctx: RenderContext, _id: str, _text: str) -> "Coroutine[Any, Any, None] | None": return None
248
+ def on_file_picked(self, _ctx: RenderContext, _request_id: str, _paths: "list[str]") -> "Coroutine[Any, Any, None] | None":
249
+ """Called when the user selected one or more files in the picker.
250
+
251
+ ``_request_id`` matches the id passed to ``ctx.emit.open_file_picker``.
252
+ ``_paths`` is a list of absolute file paths chosen by the user.
253
+ """
254
+ return None
255
+ def on_file_pick_cancelled(self, _ctx: RenderContext, _request_id: str) -> "Coroutine[Any, Any, None] | None":
256
+ """Called when the user dismissed the file picker without selecting a file,
257
+ or if the ``fs.pick`` capability was not declared.
258
+ """
259
+ return None
260
+ """Called when the host updates the scroll offset for a BeginScroll region.
261
+
262
+ `id` matches the id passed to `ctx.begin_scroll`. `offset_y` is the new
263
+ vertical offset in logical pixels. Override to re-render content at the
264
+ new position.
265
+ """
266
+ def on_mcp_call(self, _ctx: RenderContext, _tool_name: str, _arguments: dict) -> "dict | Coroutine[Any, Any, dict] | None":
267
+ """Called when an external MCP client calls a tool declared in [app.mcp].
268
+
269
+ Override to handle the call and return a result dict. The result should
270
+ follow MCP CallToolResult schema: ``{"content": [{"type": "text", "text": "..."}]}``.
271
+ Return ``None`` to respond with a generic 'not implemented' error.
272
+ """
273
+ return None
274
+
275
+ def on_midi_input_opened(
276
+ self,
277
+ _pipe_id: str,
278
+ _port_id: str,
279
+ _port_name: str,
280
+ ) -> None:
281
+ """Override to react to a successful OpenMidiInput. Apps that just
282
+ want the byte stream typically read directly from the binary pipe
283
+ opened alongside this event — Plexi sends `pipe_opened` first."""
284
+ pass
285
+
286
+ # ── Tool protocol (#398, #399) ──────────────────────────────────────────
287
+
288
+ def tool(self, name: str, description: str, schema: dict,
289
+ timeout_ms: "int | None" = None) -> Any:
290
+ """Decorator — register a method as an AI-callable tool and expose it.
291
+
292
+ Tools are functions that AI agents can invoke by name. Each tool has a description
293
+ and a JSON schema defining its parameters.
294
+
295
+ Args:
296
+ name: Tool identifier; used by agents to invoke this tool
297
+ description: Human-readable description of what the tool does
298
+ schema: JSON schema object for tool parameters (type: "object" with properties)
299
+ timeout_ms: Optional timeout in milliseconds; None = no timeout
300
+
301
+ Returns:
302
+ A decorator function that registers the method as a tool.
303
+
304
+ Example::
305
+
306
+ @app.tool("increment", description="Increment counter", schema={
307
+ "type": "object",
308
+ "properties": {"n": {"type": "integer", "description": "Amount to increment"}},
309
+ "required": ["n"],
310
+ })
311
+ async def handle_increment(self, args):
312
+ n = args.get("n", 0)
313
+ self.counter += n
314
+ return {"new_value": self.counter}
315
+
316
+ The decorated method receives a dict of parsed arguments and can return any JSON-serializable
317
+ value or raise an exception (which becomes the tool's error response).
318
+ """
319
+ def decorator(fn: Any) -> Any:
320
+ self._tool_handlers[name] = fn
321
+ tool_def: dict = {
322
+ "name": name,
323
+ "description": description,
324
+ "input_schema": schema,
325
+ }
326
+ if timeout_ms is not None:
327
+ tool_def["timeout_ms"] = timeout_ms
328
+ self._tool_defs[name] = tool_def
329
+ self.emit.expose_tools(list(self._tool_defs.values()))
330
+ return fn
331
+ return decorator
332
+
333
+ def on_suspend(self) -> None: pass
334
+ def on_resume(self) -> None: pass
335
+ def on_shutdown(self) -> None: pass
336
+
337
+ # ── Internal ────────────────────────────────────────────────────────────
338
+
339
+ async def _handle_tool_call(self, ev: dict) -> None:
340
+ """Dispatch a ``PlexiEvent::ToolCall`` to the registered handler.
341
+
342
+ Sends ``DrawCommand::ToolResult`` with the return value (JSON-serialised)
343
+ or with an error string if the handler raises or is not registered.
344
+ """
345
+ call_id: str = ev.get("call_id", "")
346
+ name: str = ev.get("name", "")
347
+ input_json: str = ev.get("input_json", "{}")
348
+
349
+ try:
350
+ args = json.loads(input_json) if input_json else {}
351
+ except json.JSONDecodeError as exc:
352
+ _emit({
353
+ "type": "tool_result",
354
+ "call_id": call_id,
355
+ "output_json": None,
356
+ "error": f"tool_input_parse_error: {exc}",
357
+ })
358
+ return
359
+
360
+ handler = self._tool_handlers.get(name)
361
+ if handler is None:
362
+ _emit({
363
+ "type": "tool_result",
364
+ "call_id": call_id,
365
+ "output_json": None,
366
+ "error": f"tool_not_found: no handler registered for tool {name!r}",
367
+ })
368
+ return
369
+
370
+ try:
371
+ import inspect as _inspect
372
+ if _inspect.iscoroutinefunction(handler):
373
+ result = await handler(args)
374
+ else:
375
+ result = handler(args)
376
+ output_json = json.dumps(result) if result is not None else json.dumps({})
377
+ _emit({
378
+ "type": "tool_result",
379
+ "call_id": call_id,
380
+ "output_json": output_json,
381
+ "error": None,
382
+ })
383
+ except Exception as exc:
384
+ import traceback as _tb
385
+ _tb.print_exc()
386
+ _emit({
387
+ "type": "tool_result",
388
+ "call_id": call_id,
389
+ "output_json": None,
390
+ "error": f"tool_handler_error: {exc}",
391
+ })
392
+
393
+ async def _handle_mcp_tool_call(self, ev: dict) -> None:
394
+ """Dispatch a PlexiEvent::McpToolCall to on_mcp_call."""
395
+ call_id: str = ev.get("call_id", "")
396
+ tool_name: str = ev.get("tool_name", "")
397
+ arguments: dict = ev.get("arguments", {})
398
+
399
+ try:
400
+ import inspect as _inspect
401
+ if _inspect.iscoroutinefunction(self.on_mcp_call):
402
+ result = await self.on_mcp_call(self._make_ctx(), tool_name, arguments)
403
+ else:
404
+ result = self.on_mcp_call(self._make_ctx(), tool_name, arguments)
405
+
406
+ if result is None:
407
+ _emit({
408
+ "type": "mcp_tool_result",
409
+ "call_id": call_id,
410
+ "result": None,
411
+ "error": f"tool_not_implemented: {tool_name!r}",
412
+ })
413
+ else:
414
+ _emit({
415
+ "type": "mcp_tool_result",
416
+ "call_id": call_id,
417
+ "result": result,
418
+ "error": None,
419
+ })
420
+ except Exception as exc:
421
+ import traceback as _tb
422
+ _tb.print_exc()
423
+ _emit({
424
+ "type": "mcp_tool_result",
425
+ "call_id": call_id,
426
+ "result": None,
427
+ "error": f"mcp_tool_handler_error: {exc}",
428
+ })
429
+
430
+ def _take_text_submission(self, id: str) -> "str | None":
431
+ """Pop the most recent submission for `id` if one is queued, else None.
432
+
433
+ Called by `RenderContext.text_input` to surface a buffered
434
+ `TextSubmitted` value into the current frame's render call.
435
+ """
436
+ return self._text_submissions.pop(id, None)
437
+
438
+ def _make_ctx(self, frame_id: int = 0, elapsed: float = 0.0,
439
+ clicks: "list[tuple[float, float]] | None" = None) -> RenderContext:
440
+ ctx = RenderContext(
441
+ frame_id=frame_id,
442
+ rect=self._rect,
443
+ workspace_root=self.workspace_root,
444
+ capabilities=self.capabilities,
445
+ feature_flags=self.feature_flags,
446
+ app=self,
447
+ elapsed=elapsed,
448
+ clicks=clicks or [],
449
+ )
450
+ ctx._compact_threshold = self._compact_threshold
451
+ ctx._regular_threshold = self._regular_threshold
452
+ return ctx
453
+
454
+ def run(self) -> None:
455
+ """Start the PGAP v3 asyncio event loop. Blocks until Shutdown.
456
+
457
+ This is the entry point for all Plexi apps. Call it from your main block:
458
+
459
+ if __name__ == '__main__':
460
+ app = MyApp()
461
+ app.run()
462
+ """
463
+ if "--plexi-introspect" in sys.argv:
464
+ self._run_introspect()
465
+ return
466
+ sys.stdout.reconfigure(line_buffering=True) # type: ignore[union-attr]
467
+ asyncio.run(self._async_main())
468
+
469
+ def _run_introspect(self) -> None:
470
+ """Static capability check mode — called when launched with --plexi-introspect.
471
+
472
+ Inspects method bodies of this App subclass for emit.* / ctx.* calls
473
+ using AST analysis (not regex) to avoid false positives in docstrings.
474
+ Only scans methods defined in the subclass's own module (not base class).
475
+ Prints {"required_capabilities": [...]} to stdout, then exits.
476
+ """
477
+ import ast
478
+ import inspect
479
+ import json
480
+ import textwrap
481
+ from ._emitter import CAPABILITY_REGISTRY
482
+
483
+ required: set[str] = set()
484
+ app_module = type(self).__module__
485
+
486
+ for _name, method in inspect.getmembers(type(self), predicate=inspect.isfunction):
487
+ if getattr(method, "__module__", None) != app_module:
488
+ continue
489
+ try:
490
+ source = textwrap.dedent(inspect.getsource(method))
491
+ tree = ast.parse(source)
492
+ except (OSError, TypeError, SyntaxError, IndentationError):
493
+ continue
494
+ for node in ast.walk(tree):
495
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
496
+ continue
497
+ func = node.func
498
+ base: "str | None" = None
499
+ if isinstance(func.value, ast.Name):
500
+ base = func.value.id
501
+ elif (
502
+ isinstance(func.value, ast.Attribute)
503
+ and isinstance(func.value.value, ast.Name)
504
+ and func.value.value.id == "self"
505
+ ):
506
+ base = f"self.{func.value.attr}"
507
+ if base in ("self.emit", "self.ctx", "ctx", "emit"):
508
+ method_name = func.attr
509
+ if method_name in CAPABILITY_REGISTRY:
510
+ required.add(CAPABILITY_REGISTRY[method_name])
511
+
512
+ print(json.dumps({"required_capabilities": sorted(required)}), flush=True)
513
+ sys.exit(0)
514
+
515
+ async def _async_main(self) -> None:
516
+ """Asyncio entry point — two concurrent tasks to eliminate deadlocks.
517
+
518
+ The root cause of the old single-loop design: when the dispatcher
519
+ awaited a hook (e.g. on_init) that itself called a blocking helper
520
+ (e.g. request_linked_terminal), the event loop had no concurrent
521
+ stdin reader in flight. Nothing could deliver the response event
522
+ while the hook was suspended, causing a permanent deadlock.
523
+
524
+ Fix: split into two tasks that run concurrently on the same event loop.
525
+
526
+ _reader — always has a run_in_executor(readline) in flight.
527
+ Handles response events inline (put_nowait into pending
528
+ queues) so they can unblock awaiting hooks even while the
529
+ dispatcher is suspended.
530
+
531
+ _dispatcher — drains a hook_q, dispatches hook events sequentially.
532
+ Can safely await hooks because _reader is always running
533
+ alongside it and will deliver response events.
534
+
535
+ Response events MUST be handled inline in _reader — never enqueued —
536
+ so that hooks awaiting on pending queues can be unblocked even when
537
+ the dispatcher is suspended mid-hook.
538
+ """
539
+ loop = asyncio.get_running_loop()
540
+ self._loop = loop
541
+ hook_q: asyncio.Queue = asyncio.Queue()
542
+
543
+ async def _reader() -> None:
544
+ while True:
545
+ raw = await loop.run_in_executor(None, sys.stdin.readline)
546
+ if not raw:
547
+ # EOF — host closed stdin; signal dispatcher to shut down.
548
+ await hook_q.put({"type": "shutdown"})
549
+ return
550
+ raw = raw.strip()
551
+ if not raw:
552
+ continue
553
+ try:
554
+ ev = json.loads(raw)
555
+ except json.JSONDecodeError:
556
+ continue
557
+
558
+ t = ev.get("type", "")
559
+
560
+ # ── Response events: handled inline so they can unblock ──────
561
+ # hooks suspended in the dispatcher. These must NEVER go on
562
+ # hook_q — that would leave awaiting coroutines stuck forever.
563
+
564
+ if t == "capability_decision":
565
+ req_id = ev.get("request_id", "")
566
+ granted = ev.get("granted", False)
567
+ q = self._pending_capability.pop(req_id, None)
568
+ if q:
569
+ q.put_nowait(granted)
570
+
571
+ elif t == "secret_value":
572
+ key = ev.get("key", "")
573
+ value = ev.get("value")
574
+ q = self._pending_secret.pop(key, None)
575
+ if q:
576
+ q.put_nowait(value)
577
+
578
+ elif t == "http_response":
579
+ req_id = ev.get("request_id", "")
580
+ q = self._pending_http.pop(req_id, None)
581
+ if q:
582
+ if ev.get("error"):
583
+ q.put_nowait(("error", ev["error"]))
584
+ else:
585
+ q.put_nowait(("ok", ev.get("body", "")))
586
+
587
+ elif t == "image_loaded":
588
+ handle = ev.get("handle", "")
589
+ q = self._pending_image.pop(handle, None)
590
+ if q:
591
+ status = ev.get("status", "error")
592
+ message = ev.get("message")
593
+ q.put_nowait((status, message))
594
+
595
+ elif t == "ai_response":
596
+ # v3.3 ai.query broker (#284). Hand the whole event dict to
597
+ # `Emitter.ai_query` so it can split error vs success and
598
+ # attach token counts.
599
+ req_id = ev.get("request_id", "")
600
+ q = self._pending_ai.pop(req_id, None)
601
+ if q:
602
+ q.put_nowait(ev)
603
+ else:
604
+ import logging as _logging
605
+ _logging.warning(
606
+ f"ai_response: no pending request for req_id={req_id!r} — "
607
+ "response dropped (query may have timed out already)"
608
+ )
609
+
610
+ elif t == "midi_devices_listed":
611
+ # v3.4 CoreMIDI (#320). Forward to Emitter.list_midi_devices.
612
+ req_id = ev.get("request_id", "")
613
+ q = self._pending_midi_devices.pop(req_id, None)
614
+ if q:
615
+ q.put_nowait(ev)
616
+
617
+ elif t == "audio_devices_listed":
618
+ # v3.4 audio device enumeration (#341). Forward to Emitter.list_audio_devices.
619
+ req_id = ev.get("request_id", "")
620
+ q = self._pending_audio_devices.pop(req_id, None)
621
+ if q:
622
+ q.put_nowait(ev)
623
+
624
+ elif t == "video_open_ack":
625
+ # v3.4 video substrate (#345). Forward to Emitter.open_video().
626
+ req_id = str(ev.get("request_id", ""))
627
+ q = self._pending_video_open.pop(req_id, None)
628
+ if q:
629
+ q.put_nowait(ev)
630
+
631
+ elif t == "video_open_error":
632
+ # OpenVideo failed (capability denied, NotImplemented from the
633
+ # production stub, bad source). Forward the error event so
634
+ # `open_video()` can raise CapabilityDeniedError / RuntimeError.
635
+ req_id = str(ev.get("request_id", ""))
636
+ q = self._pending_video_open.pop(req_id, None)
637
+ if q:
638
+ q.put_nowait(ev)
639
+
640
+ elif t == "linked_terminal_ready":
641
+ # v3.5 #78. Forward the terminal_pane_id (int) to the
642
+ # awaiting helper. 0 = capability denied — the helper
643
+ # raises CapabilityDeniedError when it sees that.
644
+ req_id = ev.get("request_id", "")
645
+ q = self._pending_linked_terminal.pop(req_id, None)
646
+ if q:
647
+ q.put_nowait(int(ev.get("terminal_pane_id", 0)))
648
+
649
+ elif t == "command_preview":
650
+ # v3.5 #78. Forward (command, would_run_in_cwd) tuple to the
651
+ # awaiting helper. would_run_in_cwd is "" on capability denial.
652
+ req_id = ev.get("request_id", "")
653
+ q = self._pending_command_preview.pop(req_id, None)
654
+ if q:
655
+ q.put_nowait((
656
+ str(ev.get("command", "")),
657
+ str(ev.get("would_run_in_cwd", "")),
658
+ ))
659
+
660
+ elif t == "notify_action":
661
+ # notify_choice / notify_input: put the value back.
662
+ # notify / notify_and_wait: put action_label back.
663
+ # Esc cancel: return "__cancel__" so callers can check easily.
664
+ notify_id = ev.get("notify_id", "")
665
+ action_label = ev.get("action_label", "")
666
+ value = ev.get("value")
667
+ if action_label == "cancel":
668
+ result = "__cancel__"
669
+ elif value is not None:
670
+ result = value
671
+ else:
672
+ result = action_label or "acknowledge"
673
+ q = self._pending_notify.pop(notify_id, None)
674
+ if q:
675
+ q.put_nowait(result)
676
+ else:
677
+ cb = self._pending_notify_callbacks.pop(notify_id, None)
678
+ if cb is not None:
679
+ self.emit.info(f"notify_async: dispatching callback for {notify_id!r}")
680
+ try:
681
+ cb(result)
682
+ except Exception as exc:
683
+ sys.stderr.write(f"plexi_sdk: notify_async callback raised: {exc}\n")
684
+
685
+ elif t == "text_measured":
686
+ # Response to RenderContext.measure_text(). Forward (width, height)
687
+ # to the awaiting coroutine keyed on request_id.
688
+ req_id = ev.get("request_id", "")
689
+ q = self._pending_measure_text.pop(req_id, None)
690
+ if q:
691
+ q.put_nowait((
692
+ float(ev.get("width", 0.0)),
693
+ float(ev.get("height", 0.0)),
694
+ ))
695
+
696
+ elif t == "text_wrapped_measured":
697
+ req_id = ev.get("request_id", "")
698
+ q = self._pending_measure_text_wrapped.pop(req_id, None)
699
+ if q:
700
+ q.put_nowait(float(ev.get("height", 0.0)))
701
+
702
+ # ── Inline non-hook events (fast, no user code) ──────────────
703
+
704
+ elif t == "pipe_opened":
705
+ pipe_id = ev.get("pipe_id", "")
706
+ socket_path = ev.get("socket_path", "")
707
+ p = self._pipes.get(pipe_id)
708
+ if p:
709
+ p._on_opened(socket_path)
710
+
711
+ elif t == "pipe_overrun":
712
+ self.emit.warn(
713
+ f"pipe overrun pipe_id={ev.get('pipe_id')} "
714
+ f"dropped={ev.get('dropped_frames')}"
715
+ )
716
+
717
+ elif t == "midi_input_error":
718
+ # OpenMidiInput failed (capability denied, port_id not found,
719
+ # CoreMIDI error). Apps log this; the typical recovery is to
720
+ # surface the error in-pane and let the user pick a different
721
+ # port from list_midi_devices.
722
+ self.emit.warn(
723
+ f"midi_input_error pipe_id={ev.get('pipe_id')} "
724
+ f"error={ev.get('error')}"
725
+ )
726
+
727
+ elif t == "midi_send_error":
728
+ # SendMidi failed. Surfaces only on capability denial / open
729
+ # failure / coremidi error — successful sends produce no event.
730
+ self.emit.warn(
731
+ f"midi_send_error port_id={ev.get('port_id')} "
732
+ f"error={ev.get('error')}"
733
+ )
734
+
735
+ elif t == "text_submitted":
736
+ # Host-owned text input: the user pressed Enter on a
737
+ # `DrawCommand::TextInput` field. Stash the value keyed
738
+ # on the input id; `RenderContext.text_input(...)` will
739
+ # drain it on the next frame the app polls.
740
+ # Also dispatch on_text_submitted as a hook task if the
741
+ # app has overridden it — apps that use the event-handler
742
+ # path can do I/O cleanly without side effects in on_render.
743
+ tid = ev.get("id", "")
744
+ if tid:
745
+ value = ev.get("value", "")
746
+ self._text_submissions[tid] = value
747
+ if type(self).on_text_submitted is not App.on_text_submitted:
748
+ self._dispatch_hook_task(
749
+ self.on_text_submitted, self._make_ctx(), tid, value
750
+ )
751
+
752
+ elif t == "run_update":
753
+ pass # apps can override on_run_update if needed
754
+
755
+ # ── Hook events: forwarded to the dispatcher ─────────────────
756
+ else:
757
+ await hook_q.put(ev)
758
+
759
+ async def _dispatcher() -> None:
760
+ while True:
761
+ ev = await hook_q.get()
762
+ t = ev.get("type", "")
763
+
764
+ if t == "init":
765
+ proto = ev.get("protocol", "")
766
+ if not proto.startswith(PROTOCOL_VERSION):
767
+ sys.stderr.write(
768
+ f"plexi_sdk: unsupported protocol {proto!r}, expected {PROTOCOL_VERSION}\n"
769
+ )
770
+ sys.exit(1)
771
+ self.app_id = ev.get("app_id", "")
772
+ self.workspace_root = ev.get("workspace_root", "")
773
+ self.capabilities = ev.get("capabilities", [])
774
+ self.feature_flags = ev.get("feature_flags", [])
775
+ self._compact_threshold = ev.get("compact_threshold", 280.0)
776
+ self._regular_threshold = ev.get("regular_threshold", 480.0)
777
+ # Send Ready
778
+ features_used = [f for f in self.feature_flags
779
+ if f in ("pane_groups_v1",)]
780
+ _emit({"type": "ready", "sdk": SDK_ID, "features_used": features_used})
781
+ self.emit.info(f"sdk: default_background={self.default_background!r}")
782
+ await self._dispatch_hook(self.on_init, self._make_ctx())
783
+
784
+ elif t == "render":
785
+ import time as _time
786
+ now = _time.monotonic()
787
+ elapsed = (now - self._last_render_time) if self._last_render_time is not None else 0.0
788
+ self._last_render_time = now
789
+ frame_id = ev.get("frame_id", 0)
790
+ if "rect" in ev:
791
+ self._rect = ev["rect"]
792
+ elif "width" in ev:
793
+ # legacy compat
794
+ self._rect = {"x": 0.0, "y": 0.0,
795
+ "w": ev["width"], "h": ev["height"]}
796
+ pending_clicks = list(self._click_buf)
797
+ self._click_buf.clear()
798
+ ctx = self._make_ctx(frame_id, elapsed=elapsed, clicks=pending_clicks)
799
+ if self.default_background is not None:
800
+ ctx.clear(self.default_background)
801
+ try:
802
+ await self._dispatch_hook(self.on_render, ctx)
803
+ self._consecutive_render_errors = 0
804
+ except Exception as e:
805
+ self._consecutive_render_errors += 1
806
+ ctx.error(f"on_render exception: {e}")
807
+ if self._consecutive_render_errors >= 3:
808
+ import traceback as _tb
809
+ _tb.print_exc()
810
+ raise
811
+ ctx.frame_done()
812
+
813
+ elif t == "key":
814
+ ctx = self._make_ctx()
815
+ self._dispatch_hook_task(self.on_key, ctx, _normalize_key(ev.get("key", "")), ev.get("modifiers", {}))
816
+
817
+ elif t == "click":
818
+ self._click_buf.append((ev.get("x", 0.0), ev.get("y", 0.0)))
819
+ ctx = self._make_ctx()
820
+ self._dispatch_hook_task(self.on_click, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
821
+ ev.get("button", "primary"))
822
+
823
+ elif t == "mouse_down":
824
+ ctx = self._make_ctx()
825
+ await self._dispatch_hook(self.on_mouse_down, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
826
+ ev.get("button", "primary"))
827
+
828
+ elif t == "mouse_up":
829
+ ctx = self._make_ctx()
830
+ await self._dispatch_hook(self.on_mouse_up, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
831
+ ev.get("button", "primary"))
832
+
833
+ elif t == "mouse_move":
834
+ self._mx = ev.get("x", 0.0)
835
+ self._my = ev.get("y", 0.0)
836
+ ctx = self._make_ctx()
837
+ await self._dispatch_hook(self.on_mouse_move, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
838
+ ev.get("buttons", []))
839
+
840
+ elif t == "command":
841
+ ctx = self._make_ctx()
842
+ self._dispatch_hook_task(self.on_command, ctx, ev.get("text", ""))
843
+
844
+ elif t == "paste":
845
+ ctx = self._make_ctx()
846
+ self._dispatch_hook_task(self.on_paste, ctx, ev.get("text", ""))
847
+
848
+ elif t == "pipe_message":
849
+ ctx = self._make_ctx()
850
+ self._dispatch_hook_task(self.on_pipe_message, ctx, ev.get("pipe_id", ""), ev.get("payload"))
851
+
852
+ elif t == "path_changed":
853
+ ctx = self._make_ctx()
854
+ self._dispatch_hook_task(self.on_path_changed, ctx, ev.get("cwd", ""))
855
+
856
+ elif t == "suspend":
857
+ await self._dispatch_hook(self.on_suspend)
858
+
859
+ elif t == "resume":
860
+ await self._dispatch_hook(self.on_resume)
861
+
862
+ elif t == "shutdown":
863
+ # Cancel any pending ai_query waiters so their coroutines
864
+ # unblock immediately instead of waiting up to 35s for a
865
+ # response that will never arrive.
866
+ if self._pending_ai:
867
+ import logging as _logging
868
+ _logging.warning(
869
+ f"shutdown: cancelling {len(self._pending_ai)} in-flight "
870
+ f"ai_query request(s): {list(self._pending_ai.keys())}"
871
+ )
872
+ for _pending_q in self._pending_ai.values():
873
+ _pending_q.put_nowait(
874
+ {"error": "ai_query cancelled: app is shutting down"}
875
+ )
876
+ self._pending_ai.clear()
877
+ await self._dispatch_hook(self.on_shutdown)
878
+ return
879
+
880
+ elif t == "inject_state":
881
+ self._app_state = ev.get("payload") or {}
882
+ ctx = self._make_ctx()
883
+ self._dispatch_hook_task(self.on_inject, ctx, ev.get("payload", {}))
884
+
885
+ elif t == "midi_input_opened":
886
+ # Confirms an OpenMidiInput call landed a CoreMIDI source.
887
+ # Apps that care about "the port is now wired to my pipe"
888
+ # see this event after the corresponding PipeOpened — they
889
+ # can override on_midi_input_opened to react.
890
+ self._dispatch_hook_task(
891
+ self.on_midi_input_opened,
892
+ str(ev.get("pipe_id", "")),
893
+ str(ev.get("port_id", "")),
894
+ str(ev.get("port_name", "")),
895
+ )
896
+
897
+ elif t == "timer":
898
+ timer_id = ev.get("timer_id", "")
899
+ ctx = self._make_ctx()
900
+ self._dispatch_hook_task(self.on_timer, ctx, timer_id)
901
+
902
+ elif t == "scroll_offset":
903
+ # Host-managed scroll region (#446): the user scrolled inside
904
+ # a BeginScroll viewport. Forward to on_scroll so the app can
905
+ # store the new offset and re-render at the translated position.
906
+ scroll_id = ev.get("id", "")
907
+ offset_y = float(ev.get("offset_y", 0.0))
908
+ ctx = self._make_ctx()
909
+ try:
910
+ await self._dispatch_hook(self.on_scroll, ctx, scroll_id, offset_y)
911
+ except Exception as e:
912
+ sys.stderr.write(f"on_scroll handler raised: {e}\n")
913
+
914
+ elif t == "list_select":
915
+ _lid = ev.get("id")
916
+ _lidx = ev.get("index")
917
+ if _lid is None or _lidx is None:
918
+ sys.stderr.write(f"list_select event missing required fields: {ev}\n")
919
+ else:
920
+ ctx = self._make_ctx()
921
+ self._dispatch_hook_task(self.on_list_select, ctx, _lid, _lidx)
922
+
923
+ elif t == "list_activate":
924
+ _lid = ev.get("id")
925
+ _lidx = ev.get("index")
926
+ if _lid is None or _lidx is None:
927
+ sys.stderr.write(f"list_activate event missing required fields: {ev}\n")
928
+ else:
929
+ ctx = self._make_ctx()
930
+ self._dispatch_hook_task(self.on_list_activate, ctx, _lid, _lidx)
931
+
932
+ elif t == "app_spawned":
933
+ # Confirmation that a SpawnApp request succeeded. Apps that
934
+ # want to track the spawned pane can override on_app_spawned.
935
+ self._dispatch_hook_task(
936
+ self.on_app_spawned,
937
+ int(ev.get("pane_id", 0)),
938
+ str(ev.get("type_id", "")),
939
+ )
940
+
941
+ elif t == "pane_spawned":
942
+ req_id = ev.get("request_id")
943
+ self._dispatch_hook_task(
944
+ self.on_pane_spawned,
945
+ int(ev.get("pane_id", 0)),
946
+ str(req_id) if req_id is not None else None,
947
+ )
948
+
949
+ elif t == "pane_spawn_error":
950
+ req_id = ev.get("request_id")
951
+ self._dispatch_hook_task(
952
+ self.on_pane_spawn_error,
953
+ str(ev.get("reason", "")),
954
+ str(req_id) if req_id is not None else None,
955
+ )
956
+
957
+ elif t == "context_state_response":
958
+ self._dispatch_hook_task(
959
+ self.on_context_state,
960
+ ev.get("state", {}),
961
+ )
962
+
963
+ elif t == "nav_back":
964
+ # Navigation stack back event (#392). The host pops the top
965
+ # nav entry and sends this with the view_id the app should
966
+ # navigate back to (empty string = root view).
967
+ ctx = self._make_ctx()
968
+ await self._dispatch_hook(
969
+ self.on_nav_back, ctx, str(ev.get("view_id", ""))
970
+ )
971
+
972
+ elif t == "file_picked":
973
+ # File picker result (#514) — user selected one or more files.
974
+ request_id = str(ev.get("request_id", ""))
975
+ paths: list[str] = list(ev.get("paths", []))
976
+ ctx = self._make_ctx()
977
+ await self._dispatch_hook(self.on_file_picked, ctx, request_id, paths)
978
+
979
+ elif t == "file_pick_cancelled":
980
+ # File picker cancelled (#514) — dialog dismissed or capability denied.
981
+ request_id = str(ev.get("request_id", ""))
982
+ ctx = self._make_ctx()
983
+ await self._dispatch_hook(self.on_file_pick_cancelled, ctx, request_id)
984
+
985
+ elif t == "tool_call":
986
+ # v3.7 tool protocol (#399). Host asks this pane to execute
987
+ # a registered tool. Dispatched as a background task so it
988
+ # doesn't block the event loop while the handler runs.
989
+ self._dispatch_hook_task(self._handle_tool_call, ev)
990
+
991
+ elif t == "mcp_tool_call":
992
+ # MCP bridge (#958). External MCP client called a tool — dispatch to on_mcp_call.
993
+ self._dispatch_hook_task(self._handle_mcp_tool_call, ev)
994
+
995
+ reader_task = asyncio.create_task(_reader())
996
+ try:
997
+ await _dispatcher()
998
+ finally:
999
+ reader_task.cancel()
1000
+ for p in self._pipes.values():
1001
+ p.close()
1002
+ # _reader is blocked in run_in_executor(sys.stdin.readline) which
1003
+ # cannot be interrupted by task cancellation — the executor thread
1004
+ # stays alive until stdin EOF, which the host may not send within
1005
+ # the 2s shutdown window. os._exit() terminates immediately without
1006
+ # waiting for threads, avoiding the SIGTERM that would otherwise fire.
1007
+ import os as _os
1008
+ _os._exit(0)
1009
+
1010
+ async def _dispatch_hook(self, hook: "Any", *args: Any) -> None:
1011
+ """Dispatch a lifecycle hook and await its completion.
1012
+
1013
+ Use this for hooks where ordering matters — on_render (FrameDone must
1014
+ follow all draw commands), on_init (startup must complete before the
1015
+ first render), on_shutdown (clean-up must finish before exit).
1016
+
1017
+ Async hooks are awaited directly; they may call any ``await``-able
1018
+ Emitter helper without deadlock because _reader runs concurrently as
1019
+ a separate task and will deliver response events while this hook is
1020
+ suspended.
1021
+
1022
+ Sync hooks run on the event loop thread. They are safe for
1023
+ pure-compute / draw-command work (on_render). A sync hook that calls
1024
+ any blocking operation (time.sleep, requests.get, etc.) will freeze
1025
+ the entire event loop — use _dispatch_hook_task for input events where
1026
+ blocking is a realistic concern, or move blocking work to a thread via
1027
+ ``threading.Thread`` + ``emit.run_sync()``.
1028
+ """
1029
+ if inspect.iscoroutinefunction(hook):
1030
+ await hook(*args)
1031
+ else:
1032
+ with _sync_hook_scope():
1033
+ hook(*args)
1034
+
1035
+ def _dispatch_hook_task(self, hook: "Any", *args: Any) -> None:
1036
+ """Dispatch a lifecycle hook as a non-blocking background task.
1037
+
1038
+ Use this for input-driven hooks (on_key, on_click, on_command, etc.)
1039
+ where a slow or async handler must not stall the stdin reader or delay
1040
+ the next Render event.
1041
+
1042
+ Async hooks are scheduled as asyncio tasks via create_task — the
1043
+ dispatcher returns immediately and the hook runs concurrently on the
1044
+ same event loop. All ``await``-able Emitter helpers work normally.
1045
+
1046
+ Sync hooks that do not block are called directly on the event loop
1047
+ thread (zero overhead, same as before). Sync hooks that *do* block
1048
+ (time.sleep, requests.get, urllib calls, etc.) are the root cause of
1049
+ the deadlock described in issue #393. The correct fix is to declare
1050
+ the handler ``async def`` and use ``await asyncio.to_thread(fn)`` or
1051
+ ``await self.emit.http_get(url)`` for any I/O, or to kick off a
1052
+ ``threading.Thread`` and use ``emit.run_sync(...)`` to bridge back.
1053
+ Sync blocking is logged as a warning so the problem is surfaced at
1054
+ runtime rather than silently freezing the app.
1055
+
1056
+ Note: because tasks run concurrently, a queued on_key task may still
1057
+ be running when on_render fires. Apps with shared mutable state should
1058
+ use asyncio locks or confine mutations to on_render (the poll pattern).
1059
+ """
1060
+ if inspect.iscoroutinefunction(hook):
1061
+ task = asyncio.create_task(hook(*args))
1062
+ # Keep a strong reference so the GC doesn't collect the task before
1063
+ # it finishes. The done callback removes it from the set.
1064
+ self._background_tasks.add(task)
1065
+ task.add_done_callback(self._background_tasks.discard)
1066
+ task.add_done_callback(_log_task_exception)
1067
+ else:
1068
+ try:
1069
+ with _sync_hook_scope():
1070
+ hook(*args)
1071
+ except Exception as e:
1072
+ sys.stderr.write(f"plexi_sdk: sync hook {getattr(hook, '__name__', hook)!r} raised: {e}\n")
1073
+
1074
+
1075
+ # SDK_ID used in the ready handshake. Derived from the version constant so
1076
+ # __init__.py and _app.py stay in sync without a circular import.
1077
+ SDK_ID = f"plexi-sdk-py/{_SDK_VERSION}"