plexi-sdk 0.1.4__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,1516 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import sys
7
+ import traceback
8
+ from typing import Any, Coroutine
9
+
10
+ from ._protocol import PROTOCOL_VERSION
11
+ from ._constants import _SDK_VERSION
12
+ from ._emitter import Emitter, _emit, _sync_hook_scope
13
+ from ._pipe import Pipe
14
+ from ._render_context import RenderContext
15
+
16
+
17
+ # ── Arg descriptor ────────────────────────────────────────────────────────────
18
+
19
+ _MISSING: Any = object()
20
+
21
+
22
+ class Arg:
23
+ """Declare a typed launch argument on an App subclass.
24
+
25
+ The SDK parses sys.argv before on_init runs and sets the resolved value as
26
+ an instance attribute with the same name as the class-level declaration.
27
+
28
+ Usage::
29
+
30
+ class MyApp(App):
31
+ repo_dir: Arg[str | None] = Arg("--repo-dir", default=lambda ctx: ctx.workspace_root)
32
+ limit: Arg[int] = Arg("--limit", type=int, default=100)
33
+ count: Arg[int] = Arg(positional=True, type=int, default=10)
34
+
35
+ async def on_init(self):
36
+ print(self.repo_dir) # already resolved
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *flags: str,
42
+ positional: bool = False,
43
+ type: Any = None,
44
+ default: Any = _MISSING,
45
+ dest: "str | None" = None,
46
+ nargs: Any = None,
47
+ ) -> None:
48
+ self.flags = flags
49
+ self.positional = positional
50
+ self.arg_type = type
51
+ self.default = default
52
+ self.dest = dest
53
+ self.nargs = nargs
54
+
55
+ def __class_getitem__(cls, _item: Any) -> "type[Arg]":
56
+ return cls
57
+
58
+
59
+ def _log_task_exception(task: asyncio.Task) -> None:
60
+ """Done callback for background tasks — logs unhandled exceptions."""
61
+ try:
62
+ exc = task.exception()
63
+ except (asyncio.CancelledError, asyncio.InvalidStateError):
64
+ return
65
+ if exc is not None:
66
+ sys.stderr.write(f"plexi_sdk: unhandled exception in background task: {exc}\n")
67
+
68
+
69
+ def _emit_fatal_error(exc: BaseException) -> None:
70
+ """Report an unrecoverable SDK/app exception over PGAP before exiting."""
71
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
72
+ message = f"{type(exc).__name__}: {exc}"
73
+ try:
74
+ _emit({"type": "fatal_error", "message": message, "traceback": tb})
75
+ finally:
76
+ sys.stderr.write(tb)
77
+ sys.stderr.flush()
78
+
79
+
80
+ # Map egui's Debug-format key names to the documented canonical SDK names.
81
+ # The host sends "ArrowLeft" etc. (egui Key::ArrowLeft Debug repr); apps
82
+ # should use "left"/"right"/"up"/"down" as documented. Normalizing here
83
+ # means both forms work correctly — agents and humans can use the documented
84
+ # names without knowing egui's internal representation.
85
+ _KEY_ALIASES: "dict[str, str]" = {
86
+ "ArrowLeft": "left",
87
+ "ArrowRight": "right",
88
+ "ArrowUp": "up",
89
+ "ArrowDown": "down",
90
+ # egui Debug-format names → SDK canonical names
91
+ "Enter": "return",
92
+ "Escape": "escape",
93
+ "Backspace": "backspace",
94
+ "Tab": "tab",
95
+ # Space arrives as Event::Text(" "), not Event::Key
96
+ " ": "space",
97
+ # Printable symbols arrive as raw chars via Event::Text
98
+ "-": "minus",
99
+ "=": "equals",
100
+ "+": "plus",
101
+ "[": "open_bracket",
102
+ "]": "close_bracket",
103
+ "\\": "backslash",
104
+ ";": "semicolon",
105
+ "'": "quote",
106
+ "`": "backtick",
107
+ ",": "comma",
108
+ ".": "period",
109
+ "/": "slash",
110
+ }
111
+
112
+
113
+ def _normalize_key(key: str) -> str:
114
+ return _KEY_ALIASES.get(key, key)
115
+
116
+
117
+ # ── Host-persisted state proxy ─────────────────────────────────────────────────
118
+
119
+ class _AppStateProxy:
120
+ """Returned by App.state — read/write the host-persisted state dict."""
121
+ __slots__ = ("_app",)
122
+
123
+ def __init__(self, app: "App") -> None:
124
+ self._app = app
125
+
126
+ def get(self, key: str, default: Any = None) -> Any:
127
+ return self._app._app_state.get(key, default)
128
+
129
+ def all(self) -> dict:
130
+ return dict(self._app._app_state)
131
+
132
+ def save(self, payload: dict) -> None:
133
+ self._app._app_state = dict(payload)
134
+ _emit({"type": "save_app_state", "payload": payload})
135
+
136
+
137
+ # ── App base class ────────────────────────────────────────────────────────────
138
+
139
+ class App:
140
+ """
141
+ Base class for Plexi v3 apps. Subclass and override event handlers.
142
+
143
+ Override any of:
144
+
145
+ Awaited (block the event loop until they return):
146
+ on_init() — after Init handshake
147
+ on_render(ctx) — on each Render event (ctx IS the drawing surface)
148
+ on_suspend() — on Suspend
149
+ on_resume() — on Resume
150
+ on_shutdown() — on Shutdown
151
+
152
+ Task (dispatched as asyncio tasks — event loop continues):
153
+ on_key(key, mods) — on Key event
154
+ on_click(x, y, button) — on Click event
155
+ on_mouse_down(x, y, button, mods={}) — on MouseDown event
156
+ on_mouse_up(x, y, button, mods={}) — on MouseUp event
157
+ on_mouse_move(x, y, buttons, mods={}) — on MouseMove event
158
+ on_command(text) — on Command event
159
+ on_paste(text) — on Paste event
160
+ on_component_event(node_id, event_type, payload)
161
+ — on L1 ComponentEvent (button click, input change)
162
+ on_text_changed(id, text) — on TextInput live edit
163
+ on_text_input_key(id, key, mods) — on TextInput Tab/up/down/Escape
164
+ on_text_submitted(id, text) — on TextInput Enter press
165
+ on_pipe_message(pipe_id, payload) — on PipeMessage
166
+ on_path_changed(cwd) — on PathChanged
167
+ on_inject(payload) — on Inject event
168
+ on_nav_back(view_id) — on NavBack event
169
+ on_timer(timer_id) — on Timer event
170
+ on_scroll(id, offset_y) — on Scroll event
171
+ on_file_picked(request_id, paths) — on FilePicked event
172
+ on_file_pick_cancelled(request_id) — on FilePickCancelled
173
+ on_mcp_call(tool_name, arguments) — on MCP tool call
174
+
175
+ Fire-and-forget (no RenderContext — called outside a render frame):
176
+ on_pane_spawned(pane_id, request_id) — pane spawn succeeded
177
+ on_pane_spawn_error(reason, request_id) — pane spawn failed
178
+ on_context_state(state) — context state query result
179
+ on_midi_input_opened(pipe_id, port_id, port_name) — MIDI input opened
180
+
181
+ Task handlers are dispatched as asyncio tasks — the event loop does not
182
+ wait for them to complete before processing the next event. Declare them
183
+ ``async def`` whenever they do any I/O. Never call blocking operations
184
+ (time.sleep, requests.get, etc.) directly from these handlers; use
185
+ ``await asyncio.to_thread(fn)`` or ``threading.Thread`` + ``emit.run_sync()``.
186
+
187
+ Awaited handlers block the event loop until they return. Use ``await``-able
188
+ Emitter helpers freely; they do not deadlock because the stdin reader runs
189
+ as a concurrent task.
190
+
191
+ Fire-and-forget handlers do not receive a RenderContext because they are
192
+ dispatched outside a render frame.
193
+ """
194
+
195
+ # Populated by __init_subclass__ when Arg fields are declared on a subclass.
196
+ _arg_specs: "list[tuple[str, Arg]]" = []
197
+
198
+ # Background color applied automatically before each on_render call.
199
+ # Set to None to disable the default background and manage clearing manually.
200
+ # "__theme__" (default) resolves to ctx.theme.bg at render time.
201
+ default_background: "str | None" = "__theme__"
202
+
203
+ def __init__(self) -> None:
204
+ self._sdk_initialized: bool = True
205
+ self.app_id: str = ""
206
+ self.workspace_root: str = ""
207
+ self.capabilities: list[str] = []
208
+ self.feature_flags: list[str] = []
209
+ self.launch_args: list[str] = []
210
+ self._rect: dict = {"x": 0.0, "y": 0.0, "w": 800.0, "h": 600.0}
211
+ self._compact_threshold: float = 280.0
212
+ self._regular_threshold: float = 480.0
213
+ # The running asyncio event loop. Set by run() before hooks are called.
214
+ # Background threads use this via emit.run_sync() to schedule coroutines.
215
+ self._loop: "asyncio.AbstractEventLoop | None" = None
216
+ # All pending-response maps now hold asyncio.Queue so the event loop
217
+ # coroutine can await them without blocking the stdin reader.
218
+ self._pending_capability: "dict[str, asyncio.Queue]" = {}
219
+ self._pending_secret: "dict[str, asyncio.Queue]" = {}
220
+ self._app_state: dict = {}
221
+ self._pending_http: "dict[str, asyncio.Queue]" = {}
222
+ # v3.x async image loading (#1354): awaits PlexiEvent::ImageLoaded keyed
223
+ # on handle UUID. Each entry is consumed by a single load_image() call.
224
+ self._pending_image: "dict[str, asyncio.Queue]" = {}
225
+ # v3.3 ai.query broker (#284): awaits PlexiEvent::AiResponse keyed
226
+ # on request_id. Each entry is consumed by a single ai_query() call.
227
+ self._pending_ai: "dict[str, asyncio.Queue]" = {}
228
+ # v3.4 CoreMIDI (#320): awaits PlexiEvent::MidiDevicesListed keyed
229
+ # on request_id. Each entry is consumed by a single list_midi_devices().
230
+ self._pending_midi_devices: "dict[str, asyncio.Queue]" = {}
231
+ # v3.4 audio device enumeration (#341): awaits PlexiEvent::AudioDevicesListed keyed
232
+ # on request_id. Each entry is consumed by a single list_audio_devices() call.
233
+ self._pending_audio_devices: "dict[str, asyncio.Queue]" = {}
234
+ # v3.4 video substrate (#345): awaits PlexiEvent::VideoOpenAck /
235
+ # VideoOpenError keyed on request_id. Each entry is consumed by a
236
+ # single open_video() call.
237
+ self._pending_video_open: "dict[str, asyncio.Queue]" = {}
238
+ self._pending_notify: "dict[str, asyncio.Queue]" = {}
239
+ # #310: non-blocking notify_*_async callbacks. Keyed on notify_id;
240
+ # each callable is invoked on the event thread when NotifyAction arrives.
241
+ self._pending_notify_callbacks: "dict[str, Any]" = {}
242
+ # v3.5 Canvas Terminal Binding Primitives (#78). Two response shapes:
243
+ # `linked_terminal_ready` carries an int pane_id; `command_preview`
244
+ # carries (command, would_run_in_cwd). Each async helper awaits
245
+ # its own keyed queue.
246
+ self._pending_linked_terminal: "dict[str, asyncio.Queue]" = {}
247
+ self._pending_command_preview: "dict[str, asyncio.Queue]" = {}
248
+ # RenderContext.measure_text: awaits PlexiEvent::TextMeasured keyed on request_id.
249
+ self._pending_measure_text: "dict[str, asyncio.Queue]" = {}
250
+ # RenderContext.measure_text_wrapped: awaits PlexiEvent::TextWrappedMeasured.
251
+ self._pending_measure_text_wrapped: "dict[str, asyncio.Queue]" = {}
252
+ self._pipes: dict[str, Pipe] = {}
253
+ self._last_render_time: "float | None" = None
254
+ self._consecutive_render_errors: int = 0
255
+ # Strong references to background asyncio tasks created by
256
+ # _dispatch_hook_task. Without this, CPython may GC a task before it
257
+ # completes. The done callback removes each task from the set.
258
+ self._background_tasks: "set[asyncio.Task]" = set()
259
+ # Pending text-input submissions keyed on TextInput `id`. The
260
+ # event-loop coroutine fills this when `PlexiEvent::TextSubmitted`
261
+ # arrives; `RenderContext.text_input` drains it during render.
262
+ # One pending value per id — a second submit before the app
263
+ # consumes the first overwrites (apps poll every frame, so
264
+ # this only matters in a perverse scheduling case).
265
+ self._text_submissions: dict[str, str] = {}
266
+ # v3.7 tool protocol (#399): tool_name → handler callable.
267
+ # Registered via @app.tool(...) decorator.
268
+ self._tool_handlers: dict[str, Any] = {}
269
+ # Keep the full declared tool set so repeated @app.tool decorator
270
+ # calls expose the cumulative list instead of replacing prior tools.
271
+ self._tool_defs: dict[str, dict] = {}
272
+ self.emit = Emitter(self)
273
+ # v3.x button primitive (#255): last known mouse position and buffered
274
+ # click events for ctx.button() hit-testing during on_render.
275
+ self._mx: float = 0.0
276
+ self._my: float = 0.0
277
+ self._click_buf: list[tuple[float, float]] = []
278
+ # Hold timer for ctx.button() active_fill (#1083): maps button id →
279
+ # monotonic timestamp at which the active state expires.
280
+ self._btn_active_until: dict[str, float] = {}
281
+ # Scroll consumers registered by components during the last completed
282
+ # render frame. Populated from ctx._scroll_consumers after on_render (#1802).
283
+ self._scroll_consumers: list = []
284
+
285
+ @property
286
+ def w(self) -> float:
287
+ return self._rect["w"]
288
+
289
+ @property
290
+ def h(self) -> float:
291
+ return self._rect["h"]
292
+
293
+ @property
294
+ def state(self) -> "_AppStateProxy":
295
+ """Host-persisted state. Use self.state.get/save instead of ctx.load_state/save_state."""
296
+ return _AppStateProxy(self)
297
+
298
+ def set_pip_status(self, status: str) -> None:
299
+ """Report this app's pip status — the pane's activity dot.
300
+
301
+ ``status`` is ``"green"``, ``"yellow"``, or ``"red"``. Fire-and-forget;
302
+ overrides the host's derived activity for this pane until you set it
303
+ again. No capability required; an unknown value is dropped with a warning.
304
+ """
305
+ self.emit.set_pip_status(status)
306
+
307
+ def __init_subclass__(cls, **kwargs: object) -> None:
308
+ super().__init_subclass__(**kwargs)
309
+
310
+ # Collect Arg descriptors declared directly on this class
311
+ own_specs: "list[tuple[str, Arg]]" = [
312
+ (name, value)
313
+ for name, value in cls.__dict__.items()
314
+ if isinstance(value, Arg)
315
+ ]
316
+ # Merge with parent's specs (own overrides parent entries with the same name)
317
+ parent_specs: "list[tuple[str, Arg]]" = list(getattr(cls, "_arg_specs", []))
318
+ if own_specs:
319
+ own_names = {n for n, _ in own_specs}
320
+ cls._arg_specs = [(n, s) for n, s in parent_specs if n not in own_names] + own_specs
321
+ else:
322
+ cls._arg_specs = parent_specs
323
+
324
+ orig_init = cls.__dict__.get("__init__")
325
+ if orig_init is not None:
326
+ def wrapped(self_inner: "App", *args: Any, _orig: Any = orig_init, **kw: Any) -> None:
327
+ if not getattr(self_inner, "_sdk_initialized", False):
328
+ App.__init__(self_inner)
329
+ _orig(self_inner, *args, **kw)
330
+ cls.__init__ = wrapped # type: ignore[assignment]
331
+
332
+ # ── Override these ──────────────────────────────────────────────────────
333
+ # All hooks may be overridden as either `def` (sync) or `async def`.
334
+ # _dispatch_hook detects the type at call time — both are valid.
335
+ # Return type is `Coroutine[Any, Any, None] | None` so Pyright accepts
336
+ # both sync (`def` → returns None) and async (`async def` → returns
337
+ # Coroutine) overrides without reportIncompatibleMethodOverride.
338
+ def on_init(self) -> "Coroutine[Any, Any, None] | None": return None
339
+ def on_render(self, _ctx: RenderContext) -> None: pass
340
+ def on_key(self, _key: str, _mods: dict) -> "Coroutine[Any, Any, None] | None":
341
+ return None
342
+ def on_escape(self) -> "bool | Coroutine[Any, Any, bool]":
343
+ """Called when Escape is pressed. Return True if you handled it (e.g.
344
+ dismissed a modal, exited a sub-page). Return False to let the
345
+ framework close the app. Default: False (close)."""
346
+ return False
347
+ def on_click(self, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
348
+ def on_mouse_down(self, _x: float, _y: float, _button: str, _mods: dict = {}) -> "Coroutine[Any, Any, None] | None": return None
349
+ def on_mouse_up(self, _x: float, _y: float, _button: str, _mods: dict = {}) -> "Coroutine[Any, Any, None] | None": return None
350
+ def on_mouse_move(self, _x: float, _y: float, _buttons: list, _mods: dict = {}) -> "Coroutine[Any, Any, None] | None": return None
351
+ def on_command(self, _text: str) -> "Coroutine[Any, Any, None] | None": return None
352
+ def on_paste(self, _text: str) -> "Coroutine[Any, Any, None] | None": return None
353
+ def on_pipe_message(self, _pipe_id: str, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
354
+ def on_path_changed(self, _cwd: str) -> "Coroutine[Any, Any, None] | None": return None
355
+ def on_inject(self, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
356
+ def on_nav_back(self, _view_id: str) -> "Coroutine[Any, Any, None] | None":
357
+ """Called when the host emits ``NavBack`` — user pressed Cmd+[ or the
358
+ back arrow in the pane chrome. ``view_id`` is the view being navigated
359
+ *back to* (the new top of stack, or empty string for root).
360
+
361
+ The app should update its own view state to show ``view_id``, then call
362
+ ``self.emit.pop_nav()`` to remove the entry from the host stack.
363
+ """
364
+ return None
365
+ def on_app_spawned(self, _pane_id: int, _type_id: str) -> None: pass
366
+ def on_pane_spawned(self, _pane_id: int, _request_id: "str | None" = None) -> None:
367
+ """Called when a SpawnPane request succeeded (#592). Override to track the spawned pane."""
368
+
369
+ def on_pane_spawn_error(self, _reason: str, _request_id: "str | None" = None) -> None:
370
+ """Called when a SpawnPane request failed (#592). Override to handle the error."""
371
+
372
+ def on_context_state(self, _state: dict) -> None:
373
+ """Called when a QueryContextState response arrives (#1518).
374
+
375
+ ``_state`` is a dict with keys: context_id, name, path, status,
376
+ pane_count, panes (list of pane summaries), children (list of child
377
+ context ids).
378
+ """
379
+
380
+ def on_timer(self, _timer_id: str) -> "Coroutine[Any, Any, None] | None": return None
381
+ def on_scroll(self, _id: str, _offset_y: float) -> "Coroutine[Any, Any, None] | None": return None
382
+ def on_scroll_delta(self, _delta_y: float) -> "Coroutine[Any, Any, None] | None": return None
383
+ def on_list_select(self, _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
384
+ """Called when a list_view selection changes via j/k/up/down."""
385
+ return None
386
+ def on_list_activate(self, _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
387
+ """Called when Enter is pressed on a selected list_view item."""
388
+ return None
389
+ def on_component_event(self, _node_id: str, _event_type: str, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
390
+ def on_text_changed(self, _id: str, _text: str) -> "Coroutine[Any, Any, None] | None": return None
391
+ def on_text_input_key(self, _id: str, _key: str, _mods: dict) -> "Coroutine[Any, Any, None] | None": return None
392
+ def on_text_submitted(self, _id: str, _text: str) -> "Coroutine[Any, Any, None] | None": return None
393
+ def on_file_picked(self, _request_id: str, _paths: "list[str]") -> "Coroutine[Any, Any, None] | None":
394
+ """Called when the user selected one or more files in the picker.
395
+
396
+ ``_request_id`` matches the id passed to ``self.emit.open_file_picker``.
397
+ ``_paths`` is a list of absolute file paths chosen by the user.
398
+ """
399
+ return None
400
+ def on_file_pick_cancelled(self, _request_id: str) -> "Coroutine[Any, Any, None] | None":
401
+ """Called when the user dismissed the file picker without selecting a file,
402
+ or if the ``fs.pick`` capability was not declared.
403
+ """
404
+ return None
405
+ """Called when the host updates the scroll offset for a BeginScroll region.
406
+
407
+ `id` matches the id passed to `ctx.begin_scroll`. `offset_y` is the new
408
+ vertical offset in logical pixels. Override to re-render content at the
409
+ new position.
410
+ """
411
+ def on_mcp_call(self, _tool_name: str, _arguments: dict) -> "dict | Coroutine[Any, Any, dict] | None":
412
+ """Called when an external MCP client calls a tool declared in [app.mcp].
413
+
414
+ Override to handle the call and return a result dict. The result should
415
+ follow MCP CallToolResult schema: ``{"content": [{"type": "text", "text": "..."}]}``.
416
+ Return ``None`` to respond with a generic 'not implemented' error.
417
+ """
418
+ return None
419
+
420
+ def on_ai_stream_chunk(self, _request_id: str, _delta: str, _done: bool) -> None:
421
+ """Called for each incremental token chunk from a streaming ai_query response.
422
+
423
+ Fired before the final ``ai_response`` event so apps can display tokens
424
+ as they arrive. ``_delta`` is the incremental text; ``_done`` is ``True``
425
+ on the last chunk before ``AiResponse`` fires.
426
+
427
+ Override to stream tokens into a display buffer::
428
+
429
+ def on_ai_stream_chunk(self, request_id, delta, done):
430
+ self.streaming_text += delta
431
+ self.emit.schedule_render()
432
+
433
+ Default: no-op. Apps that only care about the final result can ignore this.
434
+ """
435
+
436
+ def on_ai_thinking_chunk(self, _request_id: str, _delta: str, _done: bool) -> None:
437
+ """Called for each incremental reasoning ("thinking") chunk from a
438
+ streaming ai_query response against a reasoning model.
439
+
440
+ ``_delta`` is incremental reasoning text, carried separately from the
441
+ answer text delivered via :meth:`on_ai_stream_chunk`. Override to show
442
+ a live thinking indicator::
443
+
444
+ def on_ai_thinking_chunk(self, request_id, delta, done):
445
+ self.thinking_text += delta
446
+ self.emit.schedule_render()
447
+
448
+ Default: no-op.
449
+ """
450
+
451
+ def on_midi_input_opened(
452
+ self,
453
+ _pipe_id: str,
454
+ _port_id: str,
455
+ _port_name: str,
456
+ ) -> None:
457
+ """Override to react to a successful OpenMidiInput. Apps that just
458
+ want the byte stream typically read directly from the binary pipe
459
+ opened alongside this event — Plexi sends `pipe_opened` first."""
460
+ pass
461
+
462
+ # ── Tool protocol (#398, #399) ──────────────────────────────────────────
463
+
464
+ def tool(self, name: str, description: str, schema: dict,
465
+ timeout_ms: "int | None" = None) -> Any:
466
+ """Decorator — register a method as an AI-callable tool and expose it.
467
+
468
+ Tools are functions that AI agents can invoke by name. Each tool has a description
469
+ and a JSON schema defining its parameters.
470
+
471
+ Args:
472
+ name: Tool identifier; used by agents to invoke this tool
473
+ description: Human-readable description of what the tool does
474
+ schema: JSON schema object for tool parameters (type: "object" with properties)
475
+ timeout_ms: Optional timeout in milliseconds; None = no timeout
476
+
477
+ Returns:
478
+ A decorator function that registers the method as a tool.
479
+
480
+ Example::
481
+
482
+ @app.tool("increment", description="Increment counter", schema={
483
+ "type": "object",
484
+ "properties": {"n": {"type": "integer", "description": "Amount to increment"}},
485
+ "required": ["n"],
486
+ })
487
+ async def handle_increment(self, args):
488
+ n = args.get("n", 0)
489
+ self.counter += n
490
+ return {"new_value": self.counter}
491
+
492
+ The decorated method receives a dict of parsed arguments and can return any JSON-serializable
493
+ value or raise an exception (which becomes the tool's error response).
494
+ """
495
+ def decorator(fn: Any) -> Any:
496
+ self._tool_handlers[name] = fn
497
+ tool_def: dict = {
498
+ "name": name,
499
+ "description": description,
500
+ "input_schema": schema,
501
+ }
502
+ if timeout_ms is not None:
503
+ tool_def["timeout_ms"] = timeout_ms
504
+ self._tool_defs[name] = tool_def
505
+ self.emit.expose_tools(list(self._tool_defs.values()))
506
+ return fn
507
+ return decorator
508
+
509
+ def view(self) -> object | None:
510
+ """Override to return a declarative component tree.
511
+
512
+ Return ``None`` (the default) to use the flat draw-command path via
513
+ ``on_render``. When overridden, the host renderer will walk the returned
514
+ tree instead of calling ``on_render``.
515
+
516
+ ``UiNode`` is defined in ``plexi_sdk.ui`` (epic #1897, task A2).
517
+ """
518
+ return None
519
+
520
+ def on_suspend(self) -> None: pass
521
+ def on_resume(self) -> None: pass
522
+ def on_shutdown(self) -> None: pass
523
+
524
+ # ── Undo flow (docs/prm/undo-and-app-events.md) ─────────────────────────
525
+
526
+ def on_rollback_verify(self, _checkpoint_id: str, _resource_id: str,
527
+ _expected_revision: str) -> "str | None":
528
+ """Answer a host rollback verification with the resource's *current*
529
+ revision string. Apps that emit reversible events (``rollback_token``)
530
+ MUST override this; returning ``None`` (the default) reports an empty
531
+ revision, which never matches — rollback is safely blocked."""
532
+ return None
533
+
534
+ def on_rollback_apply(self, _checkpoint_id: str, _resource_id: str,
535
+ _rollback_token: str) -> "Coroutine[Any, Any, None] | None":
536
+ """Apply a verified rollback: undo the mutation identified by
537
+ ``rollback_token`` and emit the matching reversal event
538
+ (e.g. ``move.undone``). Default: no-op."""
539
+ return None
540
+
541
+ # ── Internal ────────────────────────────────────────────────────────────
542
+
543
+ async def _handle_rollback_verify(self, ev: dict) -> None:
544
+ """Dispatch ``PlexiEvent::RollbackVerify`` and answer the host with
545
+ ``AppRequest::RollbackVerifyResult``."""
546
+ checkpoint_id = str(ev.get("checkpoint_id", ""))
547
+ resource_id = str(ev.get("resource_id", ""))
548
+ expected = str(ev.get("expected_revision", ""))
549
+ try:
550
+ import inspect as _inspect
551
+ result = self.on_rollback_verify(checkpoint_id, resource_id, expected)
552
+ if _inspect.iscoroutine(result):
553
+ result = await result
554
+ except Exception as exc:
555
+ self.emit.error(f"on_rollback_verify failed: {exc}")
556
+ result = None
557
+ if result is None:
558
+ self.emit.warn(
559
+ f"rollback_verify for {checkpoint_id!r}: on_rollback_verify not "
560
+ "implemented — reporting empty revision (rollback will be blocked)"
561
+ )
562
+ result = ""
563
+ self.emit.rollback_verify_result(checkpoint_id, str(result))
564
+
565
+ async def _handle_tool_call(self, ev: dict) -> None:
566
+ """Dispatch a ``PlexiEvent::ToolCall`` to the registered handler.
567
+
568
+ Sends ``DrawCommand::ToolResult`` with the return value (JSON-serialised)
569
+ or with an error string if the handler raises or is not registered.
570
+ """
571
+ call_id: str = ev.get("call_id", "")
572
+ name: str = ev.get("name", "")
573
+ input_json: str = ev.get("input_json", "{}")
574
+
575
+ try:
576
+ args = json.loads(input_json) if input_json else {}
577
+ except json.JSONDecodeError as exc:
578
+ _emit({
579
+ "type": "tool_result",
580
+ "call_id": call_id,
581
+ "output_json": None,
582
+ "error": f"tool_input_parse_error: {exc}",
583
+ })
584
+ return
585
+
586
+ handler = self._tool_handlers.get(name)
587
+ if handler is None:
588
+ _emit({
589
+ "type": "tool_result",
590
+ "call_id": call_id,
591
+ "output_json": None,
592
+ "error": f"tool_not_found: no handler registered for tool {name!r}",
593
+ })
594
+ return
595
+
596
+ # Events emitted while this handler runs carry the caller's broker
597
+ # identity as `caused_by` (see _emitter._current_tool_caller) so the
598
+ # host can attribute them to this tool call.
599
+ from ._emitter import _current_tool_caller
600
+ caller_token = _current_tool_caller.set(ev.get("caller_id") or None)
601
+ try:
602
+ import inspect as _inspect
603
+ if _inspect.iscoroutinefunction(handler):
604
+ result = await handler(args)
605
+ else:
606
+ result = handler(args)
607
+ output_json = json.dumps(result) if result is not None else json.dumps({})
608
+ _emit({
609
+ "type": "tool_result",
610
+ "call_id": call_id,
611
+ "output_json": output_json,
612
+ "error": None,
613
+ })
614
+ except Exception as exc:
615
+ import traceback as _tb
616
+ _tb.print_exc()
617
+ _emit({
618
+ "type": "tool_result",
619
+ "call_id": call_id,
620
+ "output_json": None,
621
+ "error": f"tool_handler_error: {exc}",
622
+ })
623
+ finally:
624
+ _current_tool_caller.reset(caller_token)
625
+
626
+ async def _handle_mcp_tool_call(self, ev: dict) -> None:
627
+ """Dispatch a PlexiEvent::McpToolCall to on_mcp_call."""
628
+ call_id: str = ev.get("call_id", "")
629
+ tool_name: str = ev.get("tool_name", "")
630
+ arguments: dict = ev.get("arguments", {})
631
+
632
+ try:
633
+ import inspect as _inspect
634
+ if _inspect.iscoroutinefunction(self.on_mcp_call):
635
+ result = await self.on_mcp_call(tool_name, arguments)
636
+ else:
637
+ result = self.on_mcp_call(tool_name, arguments)
638
+
639
+ if result is None:
640
+ _emit({
641
+ "type": "mcp_tool_result",
642
+ "call_id": call_id,
643
+ "result": None,
644
+ "error": f"tool_not_implemented: {tool_name!r}",
645
+ })
646
+ else:
647
+ _emit({
648
+ "type": "mcp_tool_result",
649
+ "call_id": call_id,
650
+ "result": result,
651
+ "error": None,
652
+ })
653
+ except Exception as exc:
654
+ import traceback as _tb
655
+ _tb.print_exc()
656
+ _emit({
657
+ "type": "mcp_tool_result",
658
+ "call_id": call_id,
659
+ "result": None,
660
+ "error": f"mcp_tool_handler_error: {exc}",
661
+ })
662
+
663
+ def _take_text_submission(self, id: str) -> "str | None":
664
+ """Pop the most recent submission for `id` if one is queued, else None.
665
+
666
+ Called by `RenderContext.text_input` to surface a buffered
667
+ `TextSubmitted` value into the current frame's render call.
668
+ """
669
+ return self._text_submissions.pop(id, None)
670
+
671
+ def _make_ctx(self, frame_id: int = 0, elapsed: float = 0.0,
672
+ clicks: "list[tuple[float, float]] | None" = None) -> RenderContext:
673
+ ctx = RenderContext(
674
+ frame_id=frame_id,
675
+ rect=self._rect,
676
+ workspace_root=self.workspace_root,
677
+ capabilities=self.capabilities,
678
+ feature_flags=self.feature_flags,
679
+ app=self,
680
+ elapsed=elapsed,
681
+ clicks=clicks or [],
682
+ )
683
+ ctx._compact_threshold = self._compact_threshold
684
+ ctx._regular_threshold = self._regular_threshold
685
+ return ctx
686
+
687
+ def _parse_launch_args(self, ctx: RenderContext) -> None:
688
+ """Parse sys.argv against declared Arg specs and set resolved instance attributes.
689
+
690
+ Called automatically before on_init when the class declares Arg fields.
691
+ Lambda defaults receive ctx and are resolved here.
692
+ """
693
+ import argparse as _argparse
694
+ specs: "list[tuple[str, Arg]]" = type(self)._arg_specs
695
+ if not specs:
696
+ return
697
+
698
+ parser = _argparse.ArgumentParser(add_help=False)
699
+ lambda_defaults: "dict[str, Any]" = {}
700
+
701
+ for attr_name, arg_spec in specs:
702
+ dest = arg_spec.dest or attr_name
703
+ is_lambda = callable(arg_spec.default) and not isinstance(arg_spec.default, type)
704
+ if is_lambda:
705
+ raw_default: Any = None
706
+ lambda_defaults[attr_name] = arg_spec.default
707
+ elif arg_spec.default is _MISSING:
708
+ raw_default = None
709
+ else:
710
+ raw_default = arg_spec.default
711
+
712
+ if arg_spec.positional:
713
+ pkw: "dict[str, Any]" = {}
714
+ if arg_spec.nargs is not None:
715
+ pkw["nargs"] = arg_spec.nargs
716
+ pkw["default"] = raw_default if raw_default is not None else []
717
+ elif arg_spec.default is not _MISSING:
718
+ # Explicit default → optional positional
719
+ pkw["nargs"] = "?"
720
+ pkw["default"] = raw_default
721
+ # else: no nargs, no default → required positional (argparse enforces)
722
+ if arg_spec.arg_type is not None and arg_spec.arg_type is not bool:
723
+ pkw["type"] = arg_spec.arg_type
724
+ parser.add_argument(dest, **pkw)
725
+ else:
726
+ if not arg_spec.flags:
727
+ continue
728
+ okw: "dict[str, Any]" = {"dest": dest, "default": raw_default}
729
+ if arg_spec.arg_type is bool:
730
+ okw["action"] = "store_true"
731
+ elif arg_spec.arg_type is not None:
732
+ okw["type"] = arg_spec.arg_type
733
+ if arg_spec.nargs is not None:
734
+ okw["nargs"] = arg_spec.nargs
735
+ parser.add_argument(*arg_spec.flags, **okw)
736
+
737
+ argv = [a for a in sys.argv[1:] if a != "--plexi-introspect"]
738
+ try:
739
+ ns, _ = parser.parse_known_args(argv)
740
+ except SystemExit as e:
741
+ sys.exit(e.code)
742
+
743
+ for attr_name, arg_spec in specs:
744
+ dest = arg_spec.dest or attr_name
745
+ value = getattr(ns, dest, None)
746
+
747
+ if value is None and arg_spec.default is _MISSING and not arg_spec.positional:
748
+ flag = arg_spec.flags[0] if arg_spec.flags else attr_name
749
+ sys.stderr.write(f"{flag}: required argument missing\n")
750
+ sys.exit(1)
751
+
752
+ if value is None and attr_name in lambda_defaults:
753
+ value = lambda_defaults[attr_name](ctx)
754
+
755
+ setattr(self, attr_name, value)
756
+ self.emit.info(
757
+ f"args: parsed {len(specs)} arg(s): "
758
+ + ", ".join(f"{n}={getattr(self, n)!r}" for n, _ in specs)
759
+ )
760
+
761
+ def run(self) -> None:
762
+ """Start the PGAP v3 asyncio event loop. Blocks until Shutdown.
763
+
764
+ This is the entry point for all Plexi apps. Call it from your main block:
765
+
766
+ if __name__ == '__main__':
767
+ app = MyApp()
768
+ app.run()
769
+ """
770
+ if not getattr(self, "_sdk_initialized", False):
771
+ raise RuntimeError(
772
+ f"{type(self).__name__}.run() called but SDK was not initialized. "
773
+ "Your __init__ must call super().__init__() first, or omit __init__ entirely. "
774
+ "Example:\n"
775
+ " class MyApp(App):\n"
776
+ " def __init__(self):\n"
777
+ " super().__init__()\n"
778
+ " self.my_state = {}"
779
+ )
780
+ if "--plexi-introspect" in sys.argv:
781
+ self._run_introspect()
782
+ return
783
+ sys.stdout.reconfigure(line_buffering=True) # type: ignore[union-attr]
784
+ asyncio.run(self._async_main())
785
+
786
+ def _run_introspect(self) -> None:
787
+ """Static capability check mode — called when launched with --plexi-introspect.
788
+
789
+ Inspects method bodies of this App subclass for emit.* / ctx.* calls
790
+ using AST analysis (not regex) to avoid false positives in docstrings.
791
+ Only scans methods defined in the subclass's own module (not base class).
792
+ Prints {"required_capabilities": [...]} to stdout, then exits.
793
+ """
794
+ import ast
795
+ import inspect
796
+ import json
797
+ import textwrap
798
+ from ._emitter import CAPABILITY_REGISTRY
799
+
800
+ required: set[str] = set()
801
+ app_module = type(self).__module__
802
+
803
+ for _name, method in inspect.getmembers(type(self), predicate=inspect.isfunction):
804
+ if getattr(method, "__module__", None) != app_module:
805
+ continue
806
+ try:
807
+ source = textwrap.dedent(inspect.getsource(method))
808
+ tree = ast.parse(source)
809
+ except (OSError, TypeError, SyntaxError, IndentationError):
810
+ continue
811
+ for node in ast.walk(tree):
812
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
813
+ continue
814
+ func = node.func
815
+ base: "str | None" = None
816
+ if isinstance(func.value, ast.Name):
817
+ base = func.value.id
818
+ elif (
819
+ isinstance(func.value, ast.Attribute)
820
+ and isinstance(func.value.value, ast.Name)
821
+ and func.value.value.id == "self"
822
+ ):
823
+ base = f"self.{func.value.attr}"
824
+ if base in ("self.emit", "self.ctx", "ctx", "emit"):
825
+ method_name = func.attr
826
+ if method_name in CAPABILITY_REGISTRY:
827
+ required.add(CAPABILITY_REGISTRY[method_name])
828
+
829
+ print(json.dumps({"required_capabilities": sorted(required)}), flush=True)
830
+ sys.exit(0)
831
+
832
+ async def _async_main(self) -> None:
833
+ """Asyncio entry point — two concurrent tasks to eliminate deadlocks.
834
+
835
+ The root cause of the old single-loop design: when the dispatcher
836
+ awaited a hook (e.g. on_init) that itself called a blocking helper
837
+ (e.g. request_linked_terminal), the event loop had no concurrent
838
+ stdin reader in flight. Nothing could deliver the response event
839
+ while the hook was suspended, causing a permanent deadlock.
840
+
841
+ Fix: split into two tasks that run concurrently on the same event loop.
842
+
843
+ _reader — always has a run_in_executor(readline) in flight.
844
+ Handles response events inline (put_nowait into pending
845
+ queues) so they can unblock awaiting hooks even while the
846
+ dispatcher is suspended.
847
+
848
+ _dispatcher — drains a hook_q, dispatches hook events sequentially.
849
+ Can safely await hooks because _reader is always running
850
+ alongside it and will deliver response events.
851
+
852
+ Response events MUST be handled inline in _reader — never enqueued —
853
+ so that hooks awaiting on pending queues can be unblocked even when
854
+ the dispatcher is suspended mid-hook.
855
+ """
856
+ loop = asyncio.get_running_loop()
857
+ self._loop = loop
858
+ hook_q: asyncio.Queue = asyncio.Queue()
859
+
860
+ async def _reader() -> None:
861
+ while True:
862
+ raw = await loop.run_in_executor(None, sys.stdin.readline)
863
+ if not raw:
864
+ # EOF — host closed stdin; signal dispatcher to shut down.
865
+ await hook_q.put({"type": "shutdown"})
866
+ return
867
+ raw = raw.strip()
868
+ if not raw:
869
+ continue
870
+ try:
871
+ ev = json.loads(raw)
872
+ except json.JSONDecodeError:
873
+ continue
874
+
875
+ t = ev.get("type", "")
876
+
877
+ # ── Response events: handled inline so they can unblock ──────
878
+ # hooks suspended in the dispatcher. These must NEVER go on
879
+ # hook_q — that would leave awaiting coroutines stuck forever.
880
+
881
+ if t == "capability_decision":
882
+ req_id = ev.get("request_id", "")
883
+ granted = ev.get("granted", False)
884
+ q = self._pending_capability.pop(req_id, None)
885
+ if q:
886
+ q.put_nowait(granted)
887
+ if granted:
888
+ cap = ev.get("capability", "")
889
+ if cap and cap not in self.capabilities:
890
+ self.capabilities.append(cap)
891
+
892
+ elif t == "secret_value":
893
+ key = ev.get("key", "")
894
+ value = ev.get("value")
895
+ q = self._pending_secret.pop(key, None)
896
+ if q:
897
+ q.put_nowait(value)
898
+
899
+ elif t == "http_response":
900
+ req_id = ev.get("request_id", "")
901
+ q = self._pending_http.pop(req_id, None)
902
+ if q:
903
+ if ev.get("error"):
904
+ q.put_nowait(("error", ev["error"]))
905
+ else:
906
+ q.put_nowait(("ok", ev.get("body", "")))
907
+
908
+ elif t == "image_loaded":
909
+ handle = ev.get("handle", "")
910
+ q = self._pending_image.pop(handle, None)
911
+ if q:
912
+ status = ev.get("status", "error")
913
+ message = ev.get("message")
914
+ q.put_nowait((status, message))
915
+
916
+ elif t == "ai_stream_chunk":
917
+ # Incremental chunk from a streaming ai_query response.
918
+ # Reasoning ("thinking") deltas dispatch to
919
+ # on_ai_thinking_chunk; text deltas to on_ai_stream_chunk.
920
+ reasoning = ev.get("reasoning")
921
+ if reasoning is not None:
922
+ if type(self).on_ai_thinking_chunk is not App.on_ai_thinking_chunk:
923
+ self._dispatch_hook_task(
924
+ self.on_ai_thinking_chunk,
925
+ str(ev.get("request_id", "")),
926
+ str(reasoning),
927
+ bool(ev.get("done", False)),
928
+ )
929
+ elif type(self).on_ai_stream_chunk is not App.on_ai_stream_chunk:
930
+ self._dispatch_hook_task(
931
+ self.on_ai_stream_chunk,
932
+ str(ev.get("request_id", "")),
933
+ str(ev.get("delta", "")),
934
+ bool(ev.get("done", False)),
935
+ )
936
+
937
+ elif t == "ai_response":
938
+ # v3.3 ai.query broker (#284). Hand the whole event dict to
939
+ # `Emitter.ai_query` so it can split error vs success and
940
+ # attach token counts.
941
+ req_id = ev.get("request_id", "")
942
+ q = self._pending_ai.pop(req_id, None)
943
+ if q:
944
+ q.put_nowait(ev)
945
+ else:
946
+ import logging as _logging
947
+ _logging.warning(
948
+ f"ai_response: no pending request for req_id={req_id!r} — "
949
+ "response dropped (query may have timed out already)"
950
+ )
951
+
952
+ elif t == "midi_devices_listed":
953
+ # v3.4 CoreMIDI (#320). Forward to Emitter.list_midi_devices.
954
+ req_id = ev.get("request_id", "")
955
+ q = self._pending_midi_devices.pop(req_id, None)
956
+ if q:
957
+ q.put_nowait(ev)
958
+
959
+ elif t == "audio_devices_listed":
960
+ # v3.4 audio device enumeration (#341). Forward to Emitter.list_audio_devices.
961
+ req_id = ev.get("request_id", "")
962
+ q = self._pending_audio_devices.pop(req_id, None)
963
+ if q:
964
+ q.put_nowait(ev)
965
+
966
+ elif t == "video_open_ack":
967
+ # v3.4 video substrate (#345). Forward to Emitter.open_video().
968
+ req_id = str(ev.get("request_id", ""))
969
+ q = self._pending_video_open.pop(req_id, None)
970
+ if q:
971
+ q.put_nowait(ev)
972
+
973
+ elif t == "video_open_error":
974
+ # OpenVideo failed (capability denied, NotImplemented from the
975
+ # production stub, bad source). Forward the error event so
976
+ # `open_video()` can raise CapabilityDeniedError / RuntimeError.
977
+ req_id = str(ev.get("request_id", ""))
978
+ q = self._pending_video_open.pop(req_id, None)
979
+ if q:
980
+ q.put_nowait(ev)
981
+
982
+ elif t == "linked_terminal_ready":
983
+ # v3.5 #78. Forward the terminal_pane_id (int) to the
984
+ # awaiting helper. 0 = capability denied — the helper
985
+ # raises CapabilityDeniedError when it sees that.
986
+ req_id = ev.get("request_id", "")
987
+ q = self._pending_linked_terminal.pop(req_id, None)
988
+ if q:
989
+ q.put_nowait(int(ev.get("terminal_pane_id", 0)))
990
+
991
+ elif t == "command_preview":
992
+ # v3.5 #78. Forward (command, would_run_in_cwd) tuple to the
993
+ # awaiting helper. would_run_in_cwd is "" on capability denial.
994
+ req_id = ev.get("request_id", "")
995
+ q = self._pending_command_preview.pop(req_id, None)
996
+ if q:
997
+ q.put_nowait((
998
+ str(ev.get("command", "")),
999
+ str(ev.get("would_run_in_cwd", "")),
1000
+ ))
1001
+
1002
+ elif t == "notify_action":
1003
+ # notify_choice / notify_input: put the value back.
1004
+ # notify / notify_and_wait: put action_label back.
1005
+ # Esc cancel: return "__cancel__" so callers can check easily.
1006
+ notify_id = ev.get("notify_id", "")
1007
+ action_label = ev.get("action_label", "")
1008
+ value = ev.get("value")
1009
+ if action_label == "cancel":
1010
+ result = "__cancel__"
1011
+ elif value is not None:
1012
+ result = value
1013
+ else:
1014
+ result = action_label or "acknowledge"
1015
+ q = self._pending_notify.pop(notify_id, None)
1016
+ if q:
1017
+ q.put_nowait(result)
1018
+ else:
1019
+ cb = self._pending_notify_callbacks.pop(notify_id, None)
1020
+ if cb is not None:
1021
+ self.emit.info(f"notify_async: dispatching callback for {notify_id!r}")
1022
+ try:
1023
+ cb(result)
1024
+ except Exception as exc:
1025
+ sys.stderr.write(f"plexi_sdk: notify_async callback raised: {exc}\n")
1026
+
1027
+ elif t == "text_measured":
1028
+ # Response to RenderContext.measure_text(). Forward (width, height)
1029
+ # to the awaiting coroutine keyed on request_id.
1030
+ req_id = ev.get("request_id", "")
1031
+ q = self._pending_measure_text.pop(req_id, None)
1032
+ if q:
1033
+ q.put_nowait((
1034
+ float(ev.get("width", 0.0)),
1035
+ float(ev.get("height", 0.0)),
1036
+ ))
1037
+
1038
+ elif t == "text_wrapped_measured":
1039
+ req_id = ev.get("request_id", "")
1040
+ q = self._pending_measure_text_wrapped.pop(req_id, None)
1041
+ if q:
1042
+ q.put_nowait(float(ev.get("height", 0.0)))
1043
+
1044
+ # ── Inline non-hook events (fast, no user code) ──────────────
1045
+
1046
+ elif t == "pipe_opened":
1047
+ pipe_id = ev.get("pipe_id", "")
1048
+ socket_path = ev.get("socket_path", "")
1049
+ p = self._pipes.get(pipe_id)
1050
+ if p:
1051
+ p._on_opened(socket_path)
1052
+
1053
+ elif t == "pipe_overrun":
1054
+ self.emit.warn(
1055
+ f"pipe overrun pipe_id={ev.get('pipe_id')} "
1056
+ f"dropped={ev.get('dropped_frames')}"
1057
+ )
1058
+
1059
+ elif t == "midi_input_error":
1060
+ # OpenMidiInput failed (capability denied, port_id not found,
1061
+ # CoreMIDI error). Apps log this; the typical recovery is to
1062
+ # surface the error in-pane and let the user pick a different
1063
+ # port from list_midi_devices.
1064
+ self.emit.warn(
1065
+ f"midi_input_error pipe_id={ev.get('pipe_id')} "
1066
+ f"error={ev.get('error')}"
1067
+ )
1068
+
1069
+ elif t == "midi_send_error":
1070
+ # SendMidi failed. Surfaces only on capability denial / open
1071
+ # failure / coremidi error — successful sends produce no event.
1072
+ self.emit.warn(
1073
+ f"midi_send_error port_id={ev.get('port_id')} "
1074
+ f"error={ev.get('error')}"
1075
+ )
1076
+
1077
+ elif t == "text_submitted":
1078
+ # Host-owned text input: the user pressed Enter on a
1079
+ # `DrawCommand::TextInput` field. Stash the value keyed
1080
+ # on the input id; `RenderContext.text_input(...)` will
1081
+ # drain it on the next frame the app polls.
1082
+ # Also dispatch on_text_submitted as a hook task if the
1083
+ # app has overridden it — apps that use the event-handler
1084
+ # path can do I/O cleanly without side effects in on_render.
1085
+ tid = ev.get("id", "")
1086
+ if tid:
1087
+ value = ev.get("value", "")
1088
+ self._text_submissions[tid] = value
1089
+ if type(self).on_text_submitted is not App.on_text_submitted:
1090
+ self._dispatch_hook_task(
1091
+ self.on_text_submitted, tid, value
1092
+ )
1093
+
1094
+ elif t == "text_changed":
1095
+ tid = ev.get("id", "")
1096
+ if tid and type(self).on_text_changed is not App.on_text_changed:
1097
+ self._dispatch_hook_task(
1098
+ self.on_text_changed, tid, ev.get("value", "")
1099
+ )
1100
+
1101
+ elif t == "text_input_key":
1102
+ tid = ev.get("id", "")
1103
+ if tid and type(self).on_text_input_key is not App.on_text_input_key:
1104
+ self._dispatch_hook_task(
1105
+ self.on_text_input_key,
1106
+ tid,
1107
+ _normalize_key(ev.get("key", "")),
1108
+ ev.get("modifiers", {}),
1109
+ )
1110
+
1111
+ elif t == "run_update":
1112
+ pass # apps can override on_run_update if needed
1113
+
1114
+ # ── Hook events: forwarded to the dispatcher ─────────────────
1115
+ else:
1116
+ await hook_q.put(ev)
1117
+
1118
+ async def _dispatcher() -> None:
1119
+ while True:
1120
+ ev = await hook_q.get()
1121
+ t = ev.get("type", "")
1122
+
1123
+ if t == "init":
1124
+ proto = ev.get("protocol", "")
1125
+ if not proto.startswith(PROTOCOL_VERSION):
1126
+ sys.stderr.write(
1127
+ f"plexi_sdk: unsupported protocol {proto!r}, expected {PROTOCOL_VERSION}\n"
1128
+ )
1129
+ sys.exit(1)
1130
+ self.app_id = ev.get("app_id", "")
1131
+ self.workspace_root = ev.get("workspace_root", "")
1132
+ self.capabilities = ev.get("capabilities", [])
1133
+ self.feature_flags = ev.get("feature_flags", [])
1134
+ self.launch_args = ev.get("args", [])
1135
+ self._compact_threshold = ev.get("compact_threshold", 280.0)
1136
+ self._regular_threshold = ev.get("regular_threshold", 480.0)
1137
+ # Apply host theme (light/dark + user overrides) so app-drawn
1138
+ # chrome matches the host. Mutates the shared singleton in place.
1139
+ from ._theme import theme as _theme
1140
+ _theme.update_from(ev.get("theme"))
1141
+ # Send Ready
1142
+ features_used = [f for f in self.feature_flags
1143
+ if f in ("pane_groups_v1",)]
1144
+ _emit({"type": "ready", "sdk": SDK_ID, "features_used": features_used})
1145
+ self.emit.info(f"sdk: default_background={self.default_background!r}")
1146
+ # Pre-populate app state if the host provided it (headless --state injection)
1147
+ init_state = ev.get("state")
1148
+ if init_state and isinstance(init_state, dict):
1149
+ self._app_state = dict(init_state)
1150
+ if getattr(type(self), "_arg_specs", None):
1151
+ self._parse_launch_args(self._make_ctx())
1152
+ await self._dispatch_hook(self.on_init)
1153
+
1154
+ elif t == "render":
1155
+ import time as _time
1156
+ now = _time.monotonic()
1157
+ elapsed = (now - self._last_render_time) if self._last_render_time is not None else 0.0
1158
+ self._last_render_time = now
1159
+ frame_id = ev.get("frame_id", 0)
1160
+ if "rect" in ev:
1161
+ self._rect = ev["rect"]
1162
+ pending_clicks = list(self._click_buf)
1163
+ self._click_buf.clear()
1164
+ ctx = self._make_ctx(frame_id, elapsed=elapsed, clicks=pending_clicks)
1165
+ if self.default_background is not None:
1166
+ bg = ctx.theme.bg if self.default_background == "__theme__" else self.default_background
1167
+ ctx.clear(bg)
1168
+ try:
1169
+ if type(self).view is not App.view:
1170
+ tree = self.view()
1171
+ if tree is not None:
1172
+ ctx.render(tree)
1173
+ else:
1174
+ await self._dispatch_hook(self.on_render, ctx)
1175
+ self._consecutive_render_errors = 0
1176
+ except Exception as e:
1177
+ self._consecutive_render_errors += 1
1178
+ ctx.error(f"on_render exception: {e}")
1179
+ if self._consecutive_render_errors >= 3:
1180
+ import traceback as _tb
1181
+ _tb.print_exc()
1182
+ raise
1183
+ # Snapshot scroll consumers registered during this frame (#1802).
1184
+ self._scroll_consumers = list(ctx._scroll_consumers)
1185
+ ctx.frame_done()
1186
+
1187
+ elif t == "key":
1188
+ key = _normalize_key(ev.get("key", ""))
1189
+ if key == "escape":
1190
+ result = self.on_escape()
1191
+ if inspect.isawaitable(result):
1192
+ handled = await result
1193
+ else:
1194
+ handled = result
1195
+ if not handled:
1196
+ self.emit.close_self()
1197
+ else:
1198
+ self._dispatch_hook_task(self.on_key, key, ev.get("modifiers", {}))
1199
+
1200
+ elif t == "click":
1201
+ self._click_buf.append((ev.get("x", 0.0), ev.get("y", 0.0)))
1202
+ self._dispatch_hook_task(self.on_click, ev.get("x", 0.0), ev.get("y", 0.0),
1203
+ ev.get("button", "primary"))
1204
+
1205
+ elif t == "mouse_down":
1206
+ await self._dispatch_hook(self.on_mouse_down, ev.get("x", 0.0), ev.get("y", 0.0),
1207
+ ev.get("button", "primary"), ev.get("modifiers", {}))
1208
+
1209
+ elif t == "mouse_up":
1210
+ await self._dispatch_hook(self.on_mouse_up, ev.get("x", 0.0), ev.get("y", 0.0),
1211
+ ev.get("button", "primary"), ev.get("modifiers", {}))
1212
+
1213
+ elif t == "mouse_move":
1214
+ self._mx = ev.get("x", 0.0)
1215
+ self._my = ev.get("y", 0.0)
1216
+ await self._dispatch_hook(self.on_mouse_move, ev.get("x", 0.0), ev.get("y", 0.0),
1217
+ ev.get("buttons", []), ev.get("modifiers", {}))
1218
+
1219
+ elif t == "command":
1220
+ self._dispatch_hook_task(self.on_command, ev.get("text", ""))
1221
+
1222
+ elif t == "paste":
1223
+ self._dispatch_hook_task(self.on_paste, ev.get("text", ""))
1224
+
1225
+ elif t == "pipe_message":
1226
+ self._dispatch_hook_task(self.on_pipe_message, ev.get("pipe_id", ""), ev.get("payload"))
1227
+
1228
+ elif t == "path_changed":
1229
+ self._dispatch_hook_task(self.on_path_changed, ev.get("cwd", ""))
1230
+
1231
+ elif t == "suspend":
1232
+ await self._dispatch_hook(self.on_suspend)
1233
+
1234
+ elif t == "resume":
1235
+ await self._dispatch_hook(self.on_resume)
1236
+
1237
+ elif t == "shutdown":
1238
+ # Cancel any pending ai_query waiters so their coroutines
1239
+ # unblock immediately instead of waiting up to 35s for a
1240
+ # response that will never arrive.
1241
+ if self._pending_ai:
1242
+ import logging as _logging
1243
+ _logging.warning(
1244
+ f"shutdown: cancelling {len(self._pending_ai)} in-flight "
1245
+ f"ai_query request(s): {list(self._pending_ai.keys())}"
1246
+ )
1247
+ for _pending_q in self._pending_ai.values():
1248
+ _pending_q.put_nowait(
1249
+ {"error": "ai_query cancelled: app is shutting down"}
1250
+ )
1251
+ self._pending_ai.clear()
1252
+ await self._dispatch_hook(self.on_shutdown)
1253
+ return
1254
+
1255
+ elif t == "inject_state":
1256
+ self._app_state = ev.get("payload") or {}
1257
+ self._dispatch_hook_task(self.on_inject, ev.get("payload", {}))
1258
+
1259
+ elif t == "midi_input_opened":
1260
+ # Confirms an OpenMidiInput call landed a CoreMIDI source.
1261
+ # Apps that care about "the port is now wired to my pipe"
1262
+ # see this event after the corresponding PipeOpened — they
1263
+ # can override on_midi_input_opened to react.
1264
+ self._dispatch_hook_task(
1265
+ self.on_midi_input_opened,
1266
+ str(ev.get("pipe_id", "")),
1267
+ str(ev.get("port_id", "")),
1268
+ str(ev.get("port_name", "")),
1269
+ )
1270
+
1271
+ elif t == "timer":
1272
+ timer_id = ev.get("timer_id", "")
1273
+ self._dispatch_hook_task(self.on_timer, timer_id)
1274
+
1275
+ elif t == "scroll_offset":
1276
+ # Host-managed scroll region (#446): the user scrolled inside
1277
+ # a BeginScroll viewport. Forward to on_scroll so the app can
1278
+ # store the new offset and re-render at the translated position.
1279
+ scroll_id = ev.get("id", "")
1280
+ offset_y = float(ev.get("offset_y", 0.0))
1281
+ try:
1282
+ await self._dispatch_hook(self.on_scroll, scroll_id, offset_y)
1283
+ except Exception as e:
1284
+ sys.stderr.write(f"on_scroll handler raised: {e}\n")
1285
+
1286
+ elif t == "scroll":
1287
+ # Raw wheel delta for SDK Scrollable containers (#1794).
1288
+ # Fires when the cursor is over the app pane but not over a
1289
+ # host-managed BeginScroll region or ListView.
1290
+ #
1291
+ # Component routing (#1802): if any component registered scroll
1292
+ # interest during the last render, dispatch to each and schedule
1293
+ # a re-render. Falls through to on_scroll_delta only when no
1294
+ # components are registered, preserving backward compatibility.
1295
+ delta_y = float(ev.get("delta_y", 0.0))
1296
+ if self._scroll_consumers:
1297
+ for _consumer in self._scroll_consumers:
1298
+ try:
1299
+ _consumer.handle_scroll(delta_y)
1300
+ except Exception as e:
1301
+ sys.stderr.write(
1302
+ f"scroll consumer {type(_consumer).__name__} handle_scroll raised: {e}\n"
1303
+ )
1304
+ self.emit.schedule_render()
1305
+ else:
1306
+ try:
1307
+ await self._dispatch_hook(self.on_scroll_delta, delta_y)
1308
+ except Exception as e:
1309
+ sys.stderr.write(f"on_scroll_delta handler raised: {e}\n")
1310
+
1311
+ elif t == "theme":
1312
+ from ._theme import theme as _theme
1313
+ colors = ev.get("colors")
1314
+ _theme.update_from(colors)
1315
+ self.emit.info(f"theme: applied update with {len(colors or {})} role override(s)")
1316
+
1317
+ elif t == "list_select":
1318
+ _lid = ev.get("id")
1319
+ _lidx = ev.get("index")
1320
+ if _lid is None or _lidx is None:
1321
+ sys.stderr.write(f"list_select event missing required fields: {ev}\n")
1322
+ else:
1323
+ self._dispatch_hook_task(self.on_list_select, _lid, _lidx)
1324
+
1325
+ elif t == "list_activate":
1326
+ _lid = ev.get("id")
1327
+ _lidx = ev.get("index")
1328
+ if _lid is None or _lidx is None:
1329
+ sys.stderr.write(f"list_activate event missing required fields: {ev}\n")
1330
+ else:
1331
+ self._dispatch_hook_task(self.on_list_activate, _lid, _lidx)
1332
+
1333
+ elif t == "app_spawned":
1334
+ # Confirmation that a SpawnApp request succeeded. Apps that
1335
+ # want to track the spawned pane can override on_app_spawned.
1336
+ self._dispatch_hook_task(
1337
+ self.on_app_spawned,
1338
+ int(ev.get("pane_id", 0)),
1339
+ str(ev.get("type_id", "")),
1340
+ )
1341
+
1342
+ elif t == "pane_spawned":
1343
+ req_id = ev.get("request_id")
1344
+ self._dispatch_hook_task(
1345
+ self.on_pane_spawned,
1346
+ int(ev.get("pane_id", 0)),
1347
+ str(req_id) if req_id is not None else None,
1348
+ )
1349
+
1350
+ elif t == "pane_spawn_error":
1351
+ req_id = ev.get("request_id")
1352
+ self._dispatch_hook_task(
1353
+ self.on_pane_spawn_error,
1354
+ str(ev.get("reason", "")),
1355
+ str(req_id) if req_id is not None else None,
1356
+ )
1357
+
1358
+ elif t == "context_state_response":
1359
+ self._dispatch_hook_task(
1360
+ self.on_context_state,
1361
+ ev.get("state", {}),
1362
+ )
1363
+
1364
+ elif t == "nav_back":
1365
+ # Navigation stack back event (#392). The host pops the top
1366
+ # nav entry and sends this with the view_id the app should
1367
+ # navigate back to (empty string = root view).
1368
+ await self._dispatch_hook(
1369
+ self.on_nav_back, str(ev.get("view_id", ""))
1370
+ )
1371
+
1372
+ elif t == "file_picked":
1373
+ # File picker result (#514) — user selected one or more files.
1374
+ request_id = str(ev.get("request_id", ""))
1375
+ paths: list[str] = list(ev.get("paths", []))
1376
+ await self._dispatch_hook(self.on_file_picked, request_id, paths)
1377
+
1378
+ elif t == "file_pick_cancelled":
1379
+ # File picker cancelled (#514) — dialog dismissed or capability denied.
1380
+ request_id = str(ev.get("request_id", ""))
1381
+ await self._dispatch_hook(self.on_file_pick_cancelled, request_id)
1382
+
1383
+ elif t == "component_event":
1384
+ self._dispatch_hook_task(
1385
+ self.on_component_event,
1386
+ ev.get("node_id", ""),
1387
+ ev.get("event_type", ""),
1388
+ ev.get("payload"),
1389
+ )
1390
+
1391
+ elif t == "tool_call":
1392
+ # v3.7 tool protocol (#399). Host asks this pane to execute
1393
+ # a registered tool. Dispatched as a background task so it
1394
+ # doesn't block the event loop while the handler runs.
1395
+ self._dispatch_hook_task(self._handle_tool_call, ev)
1396
+
1397
+ elif t == "mcp_tool_call":
1398
+ # MCP bridge (#958). External MCP client called a tool — dispatch to on_mcp_call.
1399
+ self._dispatch_hook_task(self._handle_mcp_tool_call, ev)
1400
+
1401
+ elif t == "rollback_verify":
1402
+ # Undo flow (docs/prm/undo-and-app-events.md). Host asks
1403
+ # whether the resource is still at the checkpoint's
1404
+ # expected revision; the answer gates RollbackApply.
1405
+ self._dispatch_hook_task(self._handle_rollback_verify, ev)
1406
+
1407
+ elif t == "rollback_apply":
1408
+ # Verified rollback instruction — apply the app-owned
1409
+ # rollback identified by rollback_token.
1410
+ self._dispatch_hook_task(
1411
+ self.on_rollback_apply,
1412
+ ev.get("checkpoint_id", ""),
1413
+ ev.get("resource_id", ""),
1414
+ ev.get("rollback_token", ""),
1415
+ )
1416
+
1417
+ reader_task = asyncio.create_task(_reader())
1418
+ exit_code = 0
1419
+ try:
1420
+ await _dispatcher()
1421
+ except SystemExit as e:
1422
+ exit_code = int(e.code) if isinstance(e.code, int) else 1
1423
+ if exit_code != 0:
1424
+ _emit_fatal_error(e)
1425
+ except BaseException as e:
1426
+ exit_code = 1
1427
+ _emit_fatal_error(e)
1428
+ finally:
1429
+ reader_task.cancel()
1430
+ for p in self._pipes.values():
1431
+ p.close()
1432
+ # _reader is blocked in run_in_executor(sys.stdin.readline) which
1433
+ # cannot be interrupted by task cancellation — the executor thread
1434
+ # stays alive until stdin EOF, which the host may not send within
1435
+ # the 2s shutdown window. os._exit() terminates immediately without
1436
+ # waiting for threads, avoiding the SIGTERM that would otherwise fire.
1437
+ import os as _os
1438
+ _os._exit(exit_code)
1439
+
1440
+ async def _dispatch_hook(self, hook: "Any", *args: Any) -> None:
1441
+ """Dispatch a lifecycle hook and await its completion.
1442
+
1443
+ Use this for hooks where ordering matters — on_render (FrameDone must
1444
+ follow all draw commands), on_init (startup must complete before the
1445
+ first render), on_shutdown (clean-up must finish before exit).
1446
+
1447
+ Async hooks are awaited directly; they may call any ``await``-able
1448
+ Emitter helper without deadlock because _reader runs concurrently as
1449
+ a separate task and will deliver response events while this hook is
1450
+ suspended.
1451
+
1452
+ Sync hooks run on the event loop thread. They are safe for
1453
+ pure-compute / draw-command work (on_render). A sync hook that calls
1454
+ any blocking operation (time.sleep, requests.get, etc.) will freeze
1455
+ the entire event loop — use _dispatch_hook_task for input events where
1456
+ blocking is a realistic concern, or move blocking work to a thread via
1457
+ ``threading.Thread`` + ``emit.run_sync()``.
1458
+ """
1459
+ try:
1460
+ if inspect.iscoroutinefunction(hook):
1461
+ await hook(*args)
1462
+ else:
1463
+ with _sync_hook_scope():
1464
+ hook(*args)
1465
+ except TypeError as exc:
1466
+ if "must be called with 'await' from an 'async def' hook" in str(exc):
1467
+ raise
1468
+ hook_name = getattr(hook, "__qualname__", getattr(hook, "__name__", repr(hook)))
1469
+ raise TypeError(f"{hook_name} failed: {exc}") from exc
1470
+ except BaseException as exc:
1471
+ hook_name = getattr(hook, "__qualname__", getattr(hook, "__name__", repr(hook)))
1472
+ raise RuntimeError(f"{hook_name} failed: {exc}") from exc
1473
+
1474
+ def _dispatch_hook_task(self, hook: "Any", *args: Any) -> None:
1475
+ """Dispatch a lifecycle hook as a non-blocking background task.
1476
+
1477
+ Use this for input-driven hooks (on_key, on_click, on_command, etc.)
1478
+ where a slow or async handler must not stall the stdin reader or delay
1479
+ the next Render event.
1480
+
1481
+ Async hooks are scheduled as asyncio tasks via create_task — the
1482
+ dispatcher returns immediately and the hook runs concurrently on the
1483
+ same event loop. All ``await``-able Emitter helpers work normally.
1484
+
1485
+ Sync hooks that do not block are called directly on the event loop
1486
+ thread (zero overhead, same as before). Sync hooks that *do* block
1487
+ (time.sleep, requests.get, urllib calls, etc.) are the root cause of
1488
+ the deadlock described in issue #393. The correct fix is to declare
1489
+ the handler ``async def`` and use ``await asyncio.to_thread(fn)`` or
1490
+ ``await self.emit.http_get(url)`` for any I/O, or to kick off a
1491
+ ``threading.Thread`` and use ``emit.run_sync(...)`` to bridge back.
1492
+ Sync blocking is logged as a warning so the problem is surfaced at
1493
+ runtime rather than silently freezing the app.
1494
+
1495
+ Note: because tasks run concurrently, a queued on_key task may still
1496
+ be running when on_render fires. Apps with shared mutable state should
1497
+ use asyncio locks or confine mutations to on_render (the poll pattern).
1498
+ """
1499
+ if inspect.iscoroutinefunction(hook):
1500
+ task = asyncio.create_task(hook(*args))
1501
+ # Keep a strong reference so the GC doesn't collect the task before
1502
+ # it finishes. The done callback removes it from the set.
1503
+ self._background_tasks.add(task)
1504
+ task.add_done_callback(self._background_tasks.discard)
1505
+ task.add_done_callback(_log_task_exception)
1506
+ else:
1507
+ try:
1508
+ with _sync_hook_scope():
1509
+ hook(*args)
1510
+ except Exception as e:
1511
+ sys.stderr.write(f"plexi_sdk: sync hook {getattr(hook, '__name__', hook)!r} raised: {e}\n")
1512
+
1513
+
1514
+ # SDK_ID used in the ready handshake. Derived from the version constant so
1515
+ # __init__.py and _app.py stay in sync without a circular import.
1516
+ SDK_ID = f"plexi-sdk-py/{_SDK_VERSION}"