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/_emitter.py ADDED
@@ -0,0 +1,1466 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import functools
5
+ import json
6
+ import sys
7
+ import threading
8
+ import uuid
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING, Any, Iterator
11
+
12
+ from ._protocol import AiResponse, MidiPortInfo, MidiDeviceList, AudioDeviceInfo, AudioDeviceList
13
+ from ._types import CapabilityDeniedError, VideoHandle
14
+
15
+ if TYPE_CHECKING:
16
+ from ._app import App
17
+ from ._pipe import Pipe
18
+
19
+ # ── Reserved notification shortcuts ──────────────────────────────────────────
20
+ # Keys reserved by the notification overlay for navigation. The host strips
21
+ # these if an app sends them, and the SDK warns at call time.
22
+ _RESERVED_SHORTCUTS = frozenset("jkhl") | frozenset("123456789")
23
+
24
+ # ── Capability registry ───────────────────────────────────────────────────────
25
+ # Maps Emitter/RenderContext method names to the capability string they require.
26
+ # Used by --plexi-introspect mode to report what capabilities an app uses.
27
+ CAPABILITY_REGISTRY: dict[str, str] = {
28
+ "http_get": "net.http",
29
+ "http_request": "net.http",
30
+ "load_image": "net.http",
31
+ "ai_query": "ai.query",
32
+ "secret_get": "secrets.get",
33
+ "get_secret": "secrets.get",
34
+ "open_file_picker": "fs.pick",
35
+ "spawn_pane": "panes.spawn",
36
+ "open_midi_input": "midi.in",
37
+ "send_midi": "midi.out",
38
+ "open_video": "video.playback",
39
+ "set_video_state": "video.playback",
40
+ "close_video": "video.playback",
41
+ "audio_capture": "audio.record",
42
+ "stop_audio_capture": "audio.record",
43
+ "audio_play": "audio.playback",
44
+ "set_timer": "timer",
45
+ "cancel_timer": "timer",
46
+ "pipe_open": "pipe.open",
47
+ "pipe_open_directed": "pipe.open",
48
+ "request_linked_terminal": "terminal.bindings",
49
+ "run_in_linked_terminal": "terminal.bindings",
50
+ "insert_path_token": "terminal.bindings",
51
+ "request_command_preview": "terminal.bindings",
52
+ "open_artifact": "terminal.bindings",
53
+ }
54
+
55
+ # ── Internal helpers ──────────────────────────────────────────────────────────
56
+ _LOCK = threading.Lock()
57
+
58
+ # Per-thread sentinel: True while a *sync* hook is executing on the event loop
59
+ # thread. Blocking emit methods check this and raise immediately instead of
60
+ # silently returning an un-awaited coroutine object.
61
+ _SYNC_HOOK_LOCAL: threading.local = threading.local()
62
+
63
+
64
+ @contextmanager
65
+ def _sync_hook_scope() -> Iterator[None]:
66
+ """Mark the current thread as executing a sync hook."""
67
+ old_active = getattr(_SYNC_HOOK_LOCAL, 'active', False)
68
+ _SYNC_HOOK_LOCAL.active = True
69
+ try:
70
+ yield
71
+ finally:
72
+ _SYNC_HOOK_LOCAL.active = old_active
73
+
74
+
75
+ def _blocking_emit_method(fn):
76
+ """Decorator: raises TypeError if a blocking emit is called from a sync hook.
77
+
78
+ Async hooks use ``await``, so the wrapper body runs, sentinel is clear,
79
+ and the inner coroutine is returned for the caller to await normally.
80
+ Sync hooks call without ``await`` — wrapper body runs, sentinel is set,
81
+ TypeError fires before any I/O is attempted.
82
+ Background threads (run_sync path) are on a different thread, so the
83
+ thread-local sentinel is always clear for them.
84
+ """
85
+ name = fn.__name__
86
+
87
+ @functools.wraps(fn)
88
+ def wrapper(self, *args, **kwargs):
89
+ if getattr(_SYNC_HOOK_LOCAL, 'active', False):
90
+ raise TypeError(
91
+ f"emit.{name}() must be called with 'await' from an 'async def' hook.\n"
92
+ f"Your hook is 'def' (sync) — blocking emit calls require 'async def'.\n"
93
+ f"Fix: change 'def on_init(self, ctx)' → 'async def on_init(self, ctx)'\n"
94
+ f" then call: result = await self.emit.{name}(...)"
95
+ )
96
+ return fn(self, *args, **kwargs)
97
+
98
+ return wrapper
99
+
100
+
101
+ def _log_background_task_error(task: "asyncio.Task[Any]") -> None:
102
+ """Done callback for schedule_task — logs unhandled exceptions to stderr."""
103
+ try:
104
+ exc = task.exception()
105
+ except (asyncio.CancelledError, asyncio.InvalidStateError):
106
+ return
107
+ if exc is not None:
108
+ sys.stderr.write(f"plexi_sdk: unhandled exception in schedule_task: {exc}\n")
109
+
110
+
111
+ def _emit(obj: dict) -> None:
112
+ """Thread-safe JSON line write to stdout."""
113
+ with _LOCK:
114
+ sys.stdout.write(json.dumps(obj) + "\n")
115
+ sys.stdout.flush()
116
+
117
+
118
+ def _make_async_queue() -> asyncio.Queue:
119
+ """Return a fresh asyncio.Queue for one pending request slot."""
120
+ return asyncio.Queue(maxsize=1)
121
+
122
+
123
+ # ── Emitter (always available, even outside a frame) ─────────────────────────
124
+
125
+ class Emitter:
126
+ """Emit out-of-frame commands to Plexi. Thread-safe."""
127
+
128
+ def __init__(self, app: "App"):
129
+ self._app = app
130
+
131
+ # Logging
132
+ def log(self, level: str, message: str) -> None:
133
+ _emit({"type": "log", "level": level, "message": message})
134
+
135
+ def info(self, message: str) -> None: self.log("info", message)
136
+ def warn(self, message: str) -> None: self.log("warn", message)
137
+ def error(self, message: str) -> None: self.log("error", message)
138
+ def debug(self, message: str) -> None: self.log("debug", message)
139
+
140
+ # Notifications — kind = "message" (plain text, one Acknowledge button).
141
+ #
142
+ # `priority` is REQUIRED on every notify call. Higher = more urgent.
143
+ # The host uses it to pick the next front-most notification after
144
+ # dismiss and to order the Cmd+] / Cmd+[ preview traversal. Typical
145
+ # values: 0 (background info), 50 (normal), 100 (important), 200
146
+ # (critical). No default — apps must pick deliberately.
147
+ #
148
+ # Scope (window / context / global) is NOT set by the app. It's declared
149
+ # in manifest.toml under [launch] notification_scope and resolved by the
150
+ # host at dispatch time. Apps just call notify(); the manifest controls
151
+ # whether the notification crosses window or context boundaries.
152
+ def notify(self, title: str, priority: int, body: str = "",
153
+ level: str = "info",
154
+ actions: "list | None" = None,
155
+ image_inline: "dict | None" = None,
156
+ image_pipe_id: "str | None" = None,
157
+ timeout_secs: "int | None" = None,
158
+ on_dismiss: "str | None" = None) -> None:
159
+ """Post a message notification. The modal shows title + body and a
160
+ single Acknowledge button; Enter / Space acknowledge, Esc dismisses
161
+ (unless required=True — use `notify_and_wait` for that flow).
162
+
163
+ `priority` is required (int, higher = more urgent).
164
+ `actions` is the legacy side-effect list (action_type =
165
+ resume_run | open_intent | run_command). It does NOT render UI.
166
+ `image_inline` (#74): {"mime": "image/png"|"image/jpeg",
167
+ "base64": str}. Decoded host-side; >50KB triggers a placeholder.
168
+ `image_pipe_id` (#74): pipe id of a binary ring carrying RGBA frames
169
+ prefixed with width/height u32-LE. Mutually exclusive with
170
+ `image_inline` — host warns + drops the pipe ref if both set.
171
+ """
172
+ payload = {"type": "notify", "level": level, "title": title,
173
+ "body": body, "kind": "message",
174
+ "priority": priority,
175
+ "actions": actions or []}
176
+ if image_inline is not None:
177
+ payload["image_inline"] = image_inline
178
+ if image_pipe_id is not None:
179
+ payload["image_pipe_id"] = image_pipe_id
180
+ if timeout_secs is not None:
181
+ payload["timeout_secs"] = int(timeout_secs)
182
+ if on_dismiss is not None:
183
+ payload["on_dismiss"] = on_dismiss
184
+ _emit(payload)
185
+
186
+ def run_sync(self, coro: "Any") -> Any:
187
+ """Run a coroutine from a background thread and return its result.
188
+
189
+ Use this when calling async Emitter methods from a non-async context
190
+ (e.g. inside a ``threading.Thread`` callback)::
191
+
192
+ def _worker(self) -> None:
193
+ result = self.emit.run_sync(self.emit.http_get(url))
194
+
195
+ Must NOT be called from within the event loop thread — it will deadlock.
196
+ Raises ``RuntimeError`` if there is no running event loop to dispatch to
197
+ (e.g. called before ``App.run()`` starts).
198
+ """
199
+ if getattr(_SYNC_HOOK_LOCAL, 'active', False):
200
+ raise RuntimeError(
201
+ "emit.run_sync() called from a sync hook (e.g. on_render) — "
202
+ "this deadlocks the event loop. Use emit.schedule_task(coro) "
203
+ "instead when you don't need the return value, or change the "
204
+ "hook to 'async def' and use 'await self.emit.method()'."
205
+ )
206
+ loop = self._app._loop
207
+ if loop is None:
208
+ raise RuntimeError(
209
+ "emit.run_sync() called before the event loop started. "
210
+ "Only call it after App.run() is running (e.g. from a "
211
+ "background thread started inside on_init or later)."
212
+ )
213
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
214
+ return future.result()
215
+
216
+ def schedule_task(self, coro: "Any") -> Any:
217
+ """Schedule a coroutine as a background asyncio task from any context.
218
+
219
+ Safe to call from sync hooks (on_render, on_key, etc.) or background
220
+ threads. Returns immediately without blocking. The coroutine runs in
221
+ the background; use this when you don't need the return value::
222
+
223
+ def on_render(self, ctx: RenderContext) -> None:
224
+ if self._btn.render(ctx):
225
+ self.emit.schedule_task(self._do_query())
226
+
227
+ Raises ``RuntimeError`` if the event loop hasn't started yet.
228
+ """
229
+ loop = self._app._loop
230
+ if loop is None:
231
+ raise RuntimeError(
232
+ "emit.schedule_task() called before the event loop started. "
233
+ "Only call it after App.run() is running (e.g. from on_init or later)."
234
+ )
235
+ try:
236
+ on_loop_thread = asyncio.get_running_loop() is loop
237
+ except RuntimeError:
238
+ on_loop_thread = False
239
+
240
+ if not on_loop_thread:
241
+ return asyncio.run_coroutine_threadsafe(coro, loop)
242
+ task = loop.create_task(coro)
243
+ # Strong reference prevents GC before the task completes.
244
+ self._app._background_tasks.add(task)
245
+ task.add_done_callback(self._app._background_tasks.discard)
246
+ task.add_done_callback(_log_background_task_error)
247
+ return task
248
+
249
+ # kind = "choice"
250
+ @_blocking_emit_method
251
+ async def notify_choice(self, title: str, options: list, priority: int,
252
+ body: str = "",
253
+ level: str = "info", required: bool = False,
254
+ image_inline: "dict | None" = None,
255
+ image_pipe_id: "str | None" = None,
256
+ timeout_secs: "int | None" = None,
257
+ on_dismiss: "str | None" = None) -> str:
258
+ """Post a choice notification and await until the user picks one.
259
+
260
+ `options` is a list of dicts: {"label": str, "value": str (optional),
261
+ "shortcut": str (optional, single char)}. If `value` is omitted, the
262
+ label is returned. Issue #74's structured-choice spec maps directly:
263
+ `key` → `shortcut`, `payload` → `value`.
264
+
265
+ `priority` is required (int, higher = more urgent).
266
+ `image_inline` (#74): {"mime", "base64"} — same shape as `notify`.
267
+ `image_pipe_id` (#74): pipe id of a binary ring carrying RGBA frames.
268
+ Returns the chosen option's value (or the label if no value set). If
269
+ `required=False` the user may cancel with Esc — this returns the string
270
+ `"__cancel__"`.
271
+
272
+ Await this from async hooks. From background threads use
273
+ ``self.emit.run_sync(self.emit.notify_choice(...))``.
274
+ """
275
+ # Warn if any option uses a reserved shortcut key.
276
+ import warnings
277
+ for opt in options:
278
+ sc = opt.get("shortcut", "")
279
+ if sc and sc.lower() in _RESERVED_SHORTCUTS:
280
+ warnings.warn(
281
+ f"notify_choice: shortcut {sc!r} on option {opt.get('label', '')!r} "
282
+ f"conflicts with notification navigation keys (j/k/h/l, 1-9). "
283
+ f"Use y/n or another letter. The host will strip it.",
284
+ stacklevel=2,
285
+ )
286
+ notify_id = str(uuid.uuid4())
287
+ q: asyncio.Queue[str] = _make_async_queue()
288
+ self._app._pending_notify[notify_id] = q
289
+ payload = {"type": "notify", "level": level, "title": title, "body": body,
290
+ "kind": "choice", "options": options, "required": required,
291
+ "priority": priority,
292
+ "notify_id": notify_id}
293
+ if image_inline is not None:
294
+ payload["image_inline"] = image_inline
295
+ if image_pipe_id is not None:
296
+ payload["image_pipe_id"] = image_pipe_id
297
+ if timeout_secs is not None:
298
+ payload["timeout_secs"] = int(timeout_secs)
299
+ if on_dismiss is not None:
300
+ payload["on_dismiss"] = on_dismiss
301
+ _emit(payload)
302
+ return await q.get()
303
+
304
+ # Convenience wrapper for the inline-image case (#74). Handles base64
305
+ # encoding + size-cap enforcement client-side so we never spend wire
306
+ # bytes on payloads the host will only reject.
307
+ @_blocking_emit_method
308
+ async def notify_with_image(self, title: str, body: str, image_bytes: bytes,
309
+ mime: str, priority: int,
310
+ level: str = "info",
311
+ choices: "list | None" = None) -> "str | None":
312
+ """Post a notification with an inline base64-encoded image attachment.
313
+
314
+ `image_bytes` must be ≤ 50_000 bytes — anything larger raises
315
+ `ValueError` here so the app sees a fast, local failure instead of
316
+ having the host silently render a placeholder. Use the pipe-ref
317
+ path (`emit.notify(..., image_pipe_id=...)`) for larger images.
318
+
319
+ `mime` must be `"image/png"` or `"image/jpeg"`.
320
+
321
+ `choices` is optional. When None this posts a fire-and-forget
322
+ message-kind notification (returns None). When set, this routes to
323
+ `notify_choice` and awaits until the user picks one (returns the
324
+ chosen value, or `"__cancel__"`).
325
+
326
+ Await this from async hooks. From background threads use
327
+ ``self.emit.run_sync(self.emit.notify_with_image(...))``.
328
+ """
329
+ if mime not in ("image/png", "image/jpeg"):
330
+ raise ValueError(f"notify_with_image() mime must be image/png or image/jpeg, got {mime!r}")
331
+ # 50 KB cap: matches the host's `MAX_INLINE_IMAGE_BYTES`. Apps that
332
+ # exceed this should use the pipe-ref path. Failing locally is the
333
+ # least surprising behaviour.
334
+ MAX_INLINE_BYTES = 50 * 1024
335
+ if len(image_bytes) > MAX_INLINE_BYTES:
336
+ raise ValueError(
337
+ f"notify_with_image(): image is {len(image_bytes)} bytes, "
338
+ f"exceeds {MAX_INLINE_BYTES}-byte cap. Use image_pipe_id for large images."
339
+ )
340
+ import base64
341
+ encoded = base64.standard_b64encode(image_bytes).decode("ascii")
342
+ image_inline = {"mime": mime, "base64": encoded}
343
+ if choices is None:
344
+ self.notify(title=title, body=body, level=level,
345
+ priority=priority, image_inline=image_inline)
346
+ return None
347
+ return await self.notify_choice(title=title, options=choices, body=body,
348
+ level=level, priority=priority,
349
+ image_inline=image_inline)
350
+
351
+ # kind = "input"
352
+ @_blocking_emit_method
353
+ async def notify_input(self, title: str, priority: int,
354
+ prompt: str = "", body: str = "",
355
+ level: str = "info", required: bool = False,
356
+ timeout_secs: "int | None" = None,
357
+ on_dismiss: "str | None" = None) -> str:
358
+ """Post an input notification and await until the user submits or
359
+ cancels. Returns the typed text (possibly empty), or "__cancel__" if
360
+ the user dismissed with Esc (only possible when required=False).
361
+
362
+ `priority` is required (int, higher = more urgent).
363
+
364
+ Await this from async hooks. From background threads use
365
+ ``self.emit.run_sync(self.emit.notify_input(...))``.
366
+ """
367
+ notify_id = str(uuid.uuid4())
368
+ q: asyncio.Queue[str] = _make_async_queue()
369
+ self._app._pending_notify[notify_id] = q
370
+ payload = {"type": "notify", "level": level, "title": title, "body": body,
371
+ "kind": "input", "input_prompt": prompt, "required": required,
372
+ "priority": priority,
373
+ "notify_id": notify_id}
374
+ if timeout_secs is not None:
375
+ payload["timeout_secs"] = int(timeout_secs)
376
+ if on_dismiss is not None:
377
+ payload["on_dismiss"] = on_dismiss
378
+ _emit(payload)
379
+ return await q.get()
380
+
381
+ @_blocking_emit_method
382
+ async def notify_and_wait(self, title: str, priority: int,
383
+ body: str = "", level: str = "info",
384
+ actions: "list | None" = None,
385
+ timeout_secs: "int | None" = None,
386
+ on_dismiss: "str | None" = None) -> str:
387
+ """Post a message notification and await until the user acknowledges
388
+ or cancels. Returns "acknowledge" on Enter/Space/button, "cancel" on Esc.
389
+
390
+ For richer interaction, use `notify_choice` or `notify_input`.
391
+ `priority` is required (int, higher = more urgent).
392
+ `actions` is the legacy server-side side-effect list.
393
+
394
+ Await this from async hooks. From background threads use
395
+ ``self.emit.run_sync(self.emit.notify_and_wait(...))``.
396
+ """
397
+ notify_id = str(uuid.uuid4())
398
+ q: asyncio.Queue[str] = _make_async_queue()
399
+ self._app._pending_notify[notify_id] = q
400
+ payload = {"type": "notify", "level": level, "title": title, "body": body,
401
+ "kind": "message", "actions": actions or [],
402
+ "priority": priority,
403
+ "notify_id": notify_id}
404
+ if timeout_secs is not None:
405
+ payload["timeout_secs"] = int(timeout_secs)
406
+ if on_dismiss is not None:
407
+ payload["on_dismiss"] = on_dismiss
408
+ _emit(payload)
409
+ return await q.get()
410
+
411
+ # ── Non-blocking (callback-based) notify variants (#310) ─────────────────
412
+ # Each method returns immediately with the notify_id (str). The callback
413
+ # `on_response` fires on the event thread when the user interacts.
414
+ # Registry entry is removed after the first invocation.
415
+
416
+ def notify_async(self, title: str, priority: int, body: str = "",
417
+ level: str = "info",
418
+ actions: "list | None" = None,
419
+ image_inline: "dict | None" = None,
420
+ image_pipe_id: "str | None" = None,
421
+ timeout_secs: "int | None" = None,
422
+ on_dismiss: "str | None" = None,
423
+ on_response: "Any" = None) -> str:
424
+ """Non-blocking notify_and_wait. Returns immediately with notify_id.
425
+
426
+ If on_response is None, behaves like fire-and-forget notify() — returns "".
427
+ If on_response is set, registers the callback; it receives "acknowledge"
428
+ or "__cancel__" when the user interacts.
429
+
430
+ `priority` is required (int, higher = more urgent).
431
+ """
432
+ if on_response is None:
433
+ self.notify(title=title, priority=priority, body=body, level=level,
434
+ actions=actions, image_inline=image_inline,
435
+ image_pipe_id=image_pipe_id, timeout_secs=timeout_secs,
436
+ on_dismiss=on_dismiss)
437
+ return ""
438
+ notify_id = str(uuid.uuid4())
439
+ self._app._pending_notify_callbacks[notify_id] = on_response
440
+ self.info(f"notify_async: registered callback for {notify_id!r}")
441
+ payload: dict = {"type": "notify", "level": level, "title": title,
442
+ "body": body, "kind": "message", "actions": actions or [],
443
+ "priority": priority, "notify_id": notify_id}
444
+ if image_inline is not None:
445
+ payload["image_inline"] = image_inline
446
+ if image_pipe_id is not None:
447
+ payload["image_pipe_id"] = image_pipe_id
448
+ if timeout_secs is not None:
449
+ payload["timeout_secs"] = int(timeout_secs)
450
+ if on_dismiss is not None:
451
+ payload["on_dismiss"] = on_dismiss
452
+ _emit(payload)
453
+ return notify_id
454
+
455
+ def notify_choice_async(self, title: str, options: list, priority: int,
456
+ body: str = "",
457
+ level: str = "info", required: bool = False,
458
+ image_inline: "dict | None" = None,
459
+ image_pipe_id: "str | None" = None,
460
+ timeout_secs: "int | None" = None,
461
+ on_dismiss: "str | None" = None,
462
+ on_response: "Any" = None) -> str:
463
+ """Non-blocking notify_choice. Returns immediately with notify_id.
464
+
465
+ `on_response` receives the chosen option's value (or label if no value
466
+ set), or "__cancel__" if the user dismissed (required=False only).
467
+ `priority` is required (int, higher = more urgent).
468
+ """
469
+ import warnings
470
+ for opt in options:
471
+ sc = opt.get("shortcut", "")
472
+ if sc and sc.lower() in _RESERVED_SHORTCUTS:
473
+ warnings.warn(
474
+ f"notify_choice_async: shortcut {sc!r} on option "
475
+ f"{opt.get('label', '')!r} conflicts with notification "
476
+ f"navigation keys (j/k/h/l, 1-9). Use y/n or another letter.",
477
+ stacklevel=2,
478
+ )
479
+ notify_id = str(uuid.uuid4())
480
+ if on_response is not None:
481
+ self._app._pending_notify_callbacks[notify_id] = on_response
482
+ self.info(f"notify_choice_async: registered callback for {notify_id!r}")
483
+ payload: dict = {"type": "notify", "level": level, "title": title,
484
+ "body": body, "kind": "choice", "options": options,
485
+ "required": required, "priority": priority,
486
+ "notify_id": notify_id}
487
+ if image_inline is not None:
488
+ payload["image_inline"] = image_inline
489
+ if image_pipe_id is not None:
490
+ payload["image_pipe_id"] = image_pipe_id
491
+ if timeout_secs is not None:
492
+ payload["timeout_secs"] = int(timeout_secs)
493
+ if on_dismiss is not None:
494
+ payload["on_dismiss"] = on_dismiss
495
+ _emit(payload)
496
+ return notify_id
497
+
498
+ def notify_input_async(self, title: str, priority: int,
499
+ prompt: str = "", body: str = "",
500
+ level: str = "info", required: bool = False,
501
+ timeout_secs: "int | None" = None,
502
+ on_dismiss: "str | None" = None,
503
+ on_response: "Any" = None) -> str:
504
+ """Non-blocking notify_input. Returns immediately with notify_id.
505
+
506
+ `on_response` receives the typed text (possibly empty), or "__cancel__"
507
+ if the user dismissed (required=False only).
508
+ `priority` is required (int, higher = more urgent).
509
+ """
510
+ notify_id = str(uuid.uuid4())
511
+ if on_response is not None:
512
+ self._app._pending_notify_callbacks[notify_id] = on_response
513
+ self.info(f"notify_input_async: registered callback for {notify_id!r}")
514
+ payload: dict = {"type": "notify", "level": level, "title": title,
515
+ "body": body, "kind": "input", "input_prompt": prompt,
516
+ "required": required, "priority": priority,
517
+ "notify_id": notify_id}
518
+ if timeout_secs is not None:
519
+ payload["timeout_secs"] = int(timeout_secs)
520
+ if on_dismiss is not None:
521
+ payload["on_dismiss"] = on_dismiss
522
+ _emit(payload)
523
+ return notify_id
524
+
525
+ def notify_and_wait_async(self, title: str, priority: int,
526
+ body: str = "", level: str = "info",
527
+ actions: "list | None" = None,
528
+ timeout_secs: "int | None" = None,
529
+ on_dismiss: "str | None" = None,
530
+ on_response: "Any" = None) -> str:
531
+ """Non-blocking notify_and_wait. Returns immediately with notify_id.
532
+
533
+ `on_response` receives "acknowledge" on Enter/Space/button, or
534
+ "__cancel__" on Esc.
535
+ `priority` is required (int, higher = more urgent).
536
+ """
537
+ notify_id = str(uuid.uuid4())
538
+ if on_response is not None:
539
+ self._app._pending_notify_callbacks[notify_id] = on_response
540
+ self.info(f"notify_and_wait_async: registered callback for {notify_id!r}")
541
+ payload: dict = {"type": "notify", "level": level, "title": title,
542
+ "body": body, "kind": "message", "actions": actions or [],
543
+ "priority": priority, "notify_id": notify_id}
544
+ if timeout_secs is not None:
545
+ payload["timeout_secs"] = int(timeout_secs)
546
+ if on_dismiss is not None:
547
+ payload["on_dismiss"] = on_dismiss
548
+ _emit(payload)
549
+ return notify_id
550
+
551
+ # Terminal commands (legacy back-compat)
552
+ def run_in_terminal(self, command: str) -> None:
553
+ _emit({"type": "run_in_terminal", "command": command})
554
+
555
+ def cd(self, path: str) -> None:
556
+ _emit({"type": "cd", "path": path})
557
+
558
+ def status_summary(self, text: str) -> None:
559
+ _emit({"type": "status_summary", "text": text})
560
+
561
+ # ── Navigation stack (#392) ─────────────────────────────────────────────
562
+
563
+ def push_nav(self, view_id: str, title: str) -> None:
564
+ """Push a new view onto the host navigation stack.
565
+
566
+ The host appends an entry keyed on `view_id` with display `title`
567
+ and shows a back arrow + `title` in the pane chrome while this view
568
+ is active. Cmd+[ (or clicking the back arrow) sends
569
+ ``PlexiEvent::NavBack { view_id }`` back to the app, where
570
+ ``view_id`` is the view being navigated *back to* (the entry below
571
+ the current top, or empty string for root).
572
+
573
+ The app is responsible for tracking its own internal view state and
574
+ calling ``pop_nav()`` after navigating back.
575
+ """
576
+ _emit({"type": "push_nav", "view_id": view_id, "title": title})
577
+
578
+ def pop_nav(self) -> None:
579
+ """Pop the current view off the host navigation stack.
580
+
581
+ Call this after the app has already rendered the previous view (e.g.
582
+ inside the ``on_nav_back`` handler). The host removes the top entry
583
+ from the stack; if the stack becomes empty the back arrow disappears.
584
+ """
585
+ _emit({"type": "pop_nav"})
586
+
587
+ # ── File picker (#514) ──────────────────────────────────────────────────
588
+
589
+ def open_file_picker(
590
+ self,
591
+ request_id: str,
592
+ filter: "list[str] | None" = None,
593
+ multiple: bool = False,
594
+ ) -> None:
595
+ """Show a native macOS file picker dialog. Requires ``fs.pick`` capability.
596
+
597
+ The host responds asynchronously with either:
598
+ - ``on_file_picked(ctx, request_id, paths)`` — one or more files selected
599
+ - ``on_file_pick_cancelled(ctx, request_id)`` — user dismissed the dialog
600
+
601
+ Args:
602
+ request_id: Caller-supplied correlation ID echoed back in the response.
603
+ filter: File extension whitelist without leading dots
604
+ (e.g. ``["mp4", "mov"]``). ``None`` or empty = all files.
605
+ multiple: Allow picking more than one file.
606
+ """
607
+ _emit({
608
+ "type": "open_file_picker",
609
+ "request_id": request_id,
610
+ "filter": filter or [],
611
+ "multiple": multiple,
612
+ })
613
+
614
+ def spawn_pane(
615
+ self,
616
+ type_id: str,
617
+ layout: str = "split_v",
618
+ args: "list[str] | None" = None,
619
+ pipe_id: "str | None" = None,
620
+ from_pane_id: "int | None" = None,
621
+ request_id: "str | None" = None,
622
+ target_context: "int | None" = None,
623
+ ) -> None:
624
+ """Request the host to open a new pane with the given app (#592).
625
+
626
+ Requires the ``panes.spawn`` capability. The host responds with
627
+ ``on_pane_spawned(pane_id, request_id)`` on success or
628
+ ``on_pane_spawn_error(reason, request_id)`` on failure.
629
+
630
+ Args:
631
+ type_id: App manifest id or ``"terminal"``.
632
+ layout: One of ``"split_v"`` (default, below), ``"split_h"`` (right),
633
+ ``"split_above"``, ``"split_left"``, ``"overlay"``.
634
+ args: Extra argv passed to the spawned app.
635
+ pipe_id: Optional. If set, the host appends ``--pipe=<id>`` to the
636
+ spawned app's args so it can reply via ``emit.pipe_send()``.
637
+ from_pane_id: Optional pane_id to split relative to instead of the
638
+ currently focused pane.
639
+ request_id: Optional correlation id echoed back in on_pane_spawned /
640
+ on_pane_spawn_error.
641
+ target_context: Optional context_id to spawn the pane into. Must be
642
+ the calling pane's own context or a descendant (#1518).
643
+ """
644
+ cmd: "dict[str, object]" = {
645
+ "type": "spawn_pane",
646
+ "type_id": type_id,
647
+ "layout": layout,
648
+ "args": args or [],
649
+ }
650
+ if pipe_id is not None:
651
+ cmd["pipe_id"] = pipe_id
652
+ if from_pane_id is not None:
653
+ cmd["from_pane_id"] = from_pane_id
654
+ if request_id is not None:
655
+ cmd["request_id"] = request_id
656
+ if target_context is not None:
657
+ cmd["target_context"] = target_context
658
+ _emit(cmd)
659
+
660
+ def query_context_state(self, context_id: int) -> None:
661
+ """Query the state of a context (#1518).
662
+
663
+ The host responds with ``on_context_state(state)`` containing pane
664
+ count, child contexts, and aggregate status. The requesting pane must
665
+ be in the queried context itself or in an ancestor context (visibility
666
+ boundary). Non-descendant queries are silently denied.
667
+
668
+ Args:
669
+ context_id: The context to query.
670
+ """
671
+ _emit({"type": "query_context_state", "context_id": context_id})
672
+
673
+ def cd_to(self, cwd: str) -> None:
674
+ """Request the host to cd all terminals in the same pane group to `cwd`."""
675
+ _emit({"type": "cd_request", "cwd": cwd})
676
+
677
+ def copy_to_clipboard(self, text: str) -> None:
678
+ """Write `text` to the OS clipboard via the host (issue #146).
679
+
680
+ Routed through `egui::Context::copy_text` so the platform backend
681
+ (NSPasteboard / X11 / Wayland / Win32) handles the actual write.
682
+ Synchronous from the app's perspective — no acknowledgement event.
683
+ No capability flag is required; clipboard writes are low-risk and
684
+ the app already chooses when to fire (key handler, button, etc.).
685
+ """
686
+ _emit({"type": "copy_to_clipboard", "text": text})
687
+
688
+
689
+ # ── Canvas Terminal Binding Primitives (#78) ────────────────────────────
690
+ #
691
+ # All five gate on the manifest capability `terminal.bindings`. Apps
692
+ # without it get either a sentinel response (for the request/response
693
+ # primitives) or a silent drop (for fire-and-forget primitives) — the
694
+ # host logs `capability denied` either way so the bug is debuggable.
695
+ #
696
+ # The lifecycle: call `request_linked_terminal()` once at startup
697
+ # (typically inside `on_init`) and stash the returned pane id. Pass it
698
+ # to every subsequent `run_in_linked_terminal` / `insert_path_token` /
699
+ # `request_command_preview` call.
700
+ @_blocking_emit_method
701
+ async def request_linked_terminal(
702
+ self,
703
+ cwd: "str | None" = None,
704
+ label: "str | None" = None,
705
+ ) -> int:
706
+ """Ask the host to open a fresh terminal pane next to this app and
707
+ return its `terminal_pane_id` (an integer). Awaits until the host
708
+ emits `LinkedTerminalReady`.
709
+
710
+ Raises `CapabilityDeniedError` if the manifest doesn't declare
711
+ `terminal.bindings`.
712
+
713
+ Await this from async hooks. From background threads use
714
+ ``self.emit.run_sync(self.emit.request_linked_terminal(...))``.
715
+ """
716
+ req_id = str(uuid.uuid4())
717
+ q: asyncio.Queue[int] = _make_async_queue()
718
+ self._app._pending_linked_terminal[req_id] = q
719
+ _emit({
720
+ "type": "request_linked_terminal",
721
+ "request_id": req_id,
722
+ "cwd": cwd,
723
+ "label": label,
724
+ })
725
+ pane_id = await q.get()
726
+ if pane_id == 0:
727
+ raise CapabilityDeniedError(
728
+ "request_linked_terminal: capability 'terminal.bindings' "
729
+ "not declared in manifest"
730
+ )
731
+ return int(pane_id)
732
+
733
+ def run_in_linked_terminal(
734
+ self,
735
+ terminal_pane_id: int,
736
+ command: str,
737
+ echo: bool = True,
738
+ ) -> None:
739
+ """Run `command` in the linked terminal. With `echo=True` the user
740
+ sees the command typed into the terminal; with `echo=False` the
741
+ signalled intent is silent execution (PTY-level echo is shell-
742
+ controlled — the flag is best-effort observational).
743
+
744
+ Fire-and-forget — capability denial drops silently with a host
745
+ log line."""
746
+ _emit({
747
+ "type": "run_in_linked_terminal",
748
+ "terminal_pane_id": int(terminal_pane_id),
749
+ "command": command,
750
+ "echo": bool(echo),
751
+ })
752
+
753
+ def insert_path_token(
754
+ self,
755
+ terminal_pane_id: int,
756
+ path: str,
757
+ mode: str = "replace",
758
+ ) -> None:
759
+ """Inject `path` at the linked terminal's cursor.
760
+
761
+ `mode = "replace"` — Ctrl-W (kill-word) before the path so the
762
+ shell readline removes the partial token.
763
+ `mode = "append"` — write the path verbatim.
764
+
765
+ The host POSIX-quotes the path when it contains shell metacharacters.
766
+ Fire-and-forget — capability denial drops silently with a host log
767
+ line."""
768
+ if mode not in ("replace", "append"):
769
+ raise ValueError(
770
+ f"insert_path_token: mode must be 'replace' or 'append', got {mode!r}"
771
+ )
772
+ _emit({
773
+ "type": "insert_path_token",
774
+ "terminal_pane_id": int(terminal_pane_id),
775
+ "path": path,
776
+ "mode": mode,
777
+ })
778
+
779
+ @_blocking_emit_method
780
+ async def request_command_preview(
781
+ self,
782
+ terminal_pane_id: int,
783
+ command: str,
784
+ ) -> "tuple[str, str]":
785
+ """Return `(command, would_run_in_cwd)` for `command` in the linked
786
+ terminal. Doesn't execute. Useful for confirmation modals before
787
+ destructive operations.
788
+
789
+ If the host denies the request, `would_run_in_cwd` is empty string;
790
+ the SDK does NOT raise — apps that want to be loud should check for
791
+ empty cwd themselves (the most common failure mode is a missing
792
+ capability, which the host already logs).
793
+
794
+ Await this from async hooks. From background threads use
795
+ ``self.emit.run_sync(self.emit.request_command_preview(...))``.
796
+ """
797
+ req_id = str(uuid.uuid4())
798
+ q: asyncio.Queue[tuple[str, str]] = _make_async_queue()
799
+ self._app._pending_command_preview[req_id] = q
800
+ _emit({
801
+ "type": "request_command_preview",
802
+ "request_id": req_id,
803
+ "terminal_pane_id": int(terminal_pane_id),
804
+ "command": command,
805
+ })
806
+ return await q.get()
807
+
808
+ def open_artifact(
809
+ self,
810
+ path: str,
811
+ mode: str = "open_in_pane",
812
+ ) -> None:
813
+ """Open a workspace artifact via the host.
814
+
815
+ Modes:
816
+ - `"open_in_pane"` — directories open the file browser
817
+ next to the app; files open with
818
+ the OS default app (v3.5).
819
+ - `"reveal_in_finder"` — `open -R <path>` on macOS.
820
+ - `"open_with_default"` — `open <path>` on macOS.
821
+
822
+ Fire-and-forget. Capability denial drops silently with a host log
823
+ line."""
824
+ if mode not in ("open_in_pane", "reveal_in_finder", "open_with_default"):
825
+ raise ValueError(
826
+ f"open_artifact: mode must be one of "
827
+ f"open_in_pane / reveal_in_finder / open_with_default, "
828
+ f"got {mode!r}"
829
+ )
830
+ _emit({"type": "open_artifact", "path": path, "mode": mode})
831
+
832
+ def schedule_render(self, after_ms: int = 16) -> None:
833
+ """Ask the host to send a new Render event after `after_ms` milliseconds.
834
+ Call at the end of on_render to sustain a game/animation loop.
835
+ 16 ms ≈ 60 fps. 32 ms ≈ 30 fps."""
836
+ _emit({"type": "schedule_render", "after_ms": after_ms})
837
+
838
+ # Runs
839
+ def run_get(self, intent: str, payload: Any = None) -> str:
840
+ run_id = str(uuid.uuid4())
841
+ _emit({"type": "run_get", "intent": intent,
842
+ "payload": payload or {}})
843
+ return run_id
844
+
845
+ # ── Async blocking helpers ────────────────────────────────────────────────
846
+ # Each of these is a coroutine — await them from async hooks, or call
847
+ # self.emit.run_sync(self.emit.method(...)) from a background thread.
848
+
849
+ @_blocking_emit_method
850
+ async def capability_request(self, capability: str) -> None:
851
+ """Request a runtime capability grant from the user. Returns None if granted.
852
+
853
+ Raises CapabilityDeniedError if the user denies the request. Callers should
854
+ wrap this in try/except CapabilityDeniedError to handle denial gracefully.
855
+
856
+ Await from async hooks. From background threads:
857
+ ``self.emit.run_sync(self.emit.capability_request(capability))``.
858
+ """
859
+ req_id = str(uuid.uuid4())
860
+ q: asyncio.Queue[bool] = _make_async_queue()
861
+ self._app._pending_capability[req_id] = q
862
+ _emit({"type": "capability_request", "request_id": req_id,
863
+ "capability": capability})
864
+ granted = await q.get()
865
+ if not granted:
866
+ raise CapabilityDeniedError(
867
+ f"capability_denied: user denied '{capability}'"
868
+ )
869
+
870
+ @_blocking_emit_method
871
+ async def secret_get(self, key: str) -> "str | None":
872
+ """Await until host returns the secret value (or None if denied).
873
+
874
+ From background threads:
875
+ ``self.emit.run_sync(self.emit.secret_get(key))``.
876
+ """
877
+ q: asyncio.Queue[str | None] = _make_async_queue()
878
+ self._app._pending_secret[key] = q
879
+ _emit({"type": "secret_get", "key": key})
880
+ return await q.get()
881
+
882
+ @_blocking_emit_method
883
+ async def get_secret(self, key: str) -> "str | None":
884
+ """Alias for secret_get(). Preferred name going forward."""
885
+ return await self.secret_get(key)
886
+
887
+ @_blocking_emit_method
888
+ async def http_get(self, url: str) -> str:
889
+ """HTTP GET brokered through the host. Requires net.http capability.
890
+ Raises RuntimeError on failure.
891
+
892
+ From background threads:
893
+ ``self.emit.run_sync(self.emit.http_get(url))``.
894
+ """
895
+ return await self.http_request(url)
896
+
897
+ @_blocking_emit_method
898
+ async def http_request(self, url: str, method: str = "GET",
899
+ headers: "dict[str, str] | None" = None,
900
+ body: "str | None" = None) -> str:
901
+ """HTTP request brokered through the host. Requires net.http capability.
902
+ Supports custom method, headers, and body. Raises RuntimeError on failure.
903
+
904
+ From background threads:
905
+ ``self.emit.run_sync(self.emit.http_request(...))``.
906
+ """
907
+ req_id = str(uuid.uuid4())
908
+ q: asyncio.Queue[tuple[str, str]] = _make_async_queue()
909
+ self._app._pending_http[req_id] = q
910
+ payload: dict = {"type": "http_request", "request_id": req_id,
911
+ "method": method, "url": url}
912
+ if headers:
913
+ payload["headers"] = headers
914
+ if body is not None:
915
+ payload["body"] = body
916
+ _emit(payload)
917
+ status, value = await q.get()
918
+ if status == "error":
919
+ raise RuntimeError(f"http_request {url!r}: {value}")
920
+ return value
921
+
922
+ @_blocking_emit_method
923
+ async def load_image(self, src: str) -> str:
924
+ """Request an async image fetch brokered through the host. Requires net.http capability.
925
+
926
+ Returns a stable handle ID. Pass the handle to ``ctx.image(handle, x, y, w, h)``
927
+ to render. While loading, ``ctx.image()`` renders a placeholder rect. On load
928
+ completion the host fires ``PlexiEvent::ImageLoaded`` which triggers a re-render.
929
+
930
+ Raises ``RuntimeError`` immediately if ``net.http`` is not declared in the manifest.
931
+ Raises ``RuntimeError`` if the fetch fails (network error, bad URL, decode error).
932
+
933
+ From background threads:
934
+ ``self.emit.run_sync(self.emit.load_image(url))``.
935
+ """
936
+ if "net.http" not in self._app.capabilities:
937
+ raise RuntimeError(
938
+ "load_image: net.http capability not declared in manifest — "
939
+ "add 'net.http' to [capabilities] in manifest.toml"
940
+ )
941
+ handle = str(uuid.uuid4())
942
+ q: asyncio.Queue[tuple[str, "str | None"]] = _make_async_queue()
943
+ self._app._pending_image[handle] = q
944
+ _emit({"type": "load_image", "handle": handle, "src": src})
945
+ status, message = await q.get()
946
+ if status == "error":
947
+ raise RuntimeError(f"load_image {src!r}: {message}")
948
+ return handle
949
+
950
+ @_blocking_emit_method
951
+ async def measure_text_wrapped(self, text: str, font_size: float,
952
+ max_width: float,
953
+ max_lines: "int | None" = None) -> float:
954
+ """Measure the height of text wrapped at max_width using real host font metrics.
955
+
956
+ If `max_lines` is set, clamps the result to that many rows.
957
+ Returns height in logical pixels. Requires a running app loop.
958
+ """
959
+ request_id = str(uuid.uuid4())
960
+ q: asyncio.Queue[float] = _make_async_queue()
961
+ self._app._pending_measure_text_wrapped[request_id] = q
962
+ payload: dict = {"type": "measure_text_wrapped", "request_id": request_id,
963
+ "text": text, "font_size": font_size, "max_width": max_width}
964
+ if max_lines is not None:
965
+ payload["max_lines"] = max_lines
966
+ _emit(payload)
967
+ try:
968
+ return await asyncio.wait_for(q.get(), timeout=10.0)
969
+ except asyncio.TimeoutError:
970
+ self._app._pending_measure_text_wrapped.pop(request_id, None)
971
+ raise RuntimeError(
972
+ f"measure_text_wrapped timed out after 10s (request_id={request_id!r})"
973
+ )
974
+
975
+ @_blocking_emit_method
976
+ async def ai_query(self, model_tier: str, system: str,
977
+ messages: "list[dict]",
978
+ tools: "list[dict] | None" = None) -> AiResponse:
979
+ """Call into the host's Plexi AI broker (#284). Requires the
980
+ `ai.query` capability declared in `manifest.toml`.
981
+
982
+ Args:
983
+ model_tier: one of "low" | "medium" | "high" — the host maps these
984
+ to Haiku / Sonnet / Opus respectively.
985
+ system: system prompt (may be empty string).
986
+ messages: list of `{"role": "user"|"assistant", "content": str}`
987
+ dicts. At least one message is required.
988
+ tools: optional extra tool defs to include in this query. Tools
989
+ exposed by other panes in the same context via ``@self.tool()``
990
+ or ``expose_tools()`` are automatically injected by the host —
991
+ you do not need to pass them here.
992
+
993
+ Returns:
994
+ AiResponse with `content`, `tokens_in`, `tokens_out`.
995
+
996
+ Raises:
997
+ CapabilityDeniedError: app didn't declare `ai.query` in its manifest
998
+ (or the host returned any other "capability denied" error).
999
+ RuntimeError: backend failed (e.g. missing API key, network error).
1000
+
1001
+ From background threads:
1002
+ ``self.emit.run_sync(self.emit.ai_query(...))``.
1003
+ """
1004
+ if model_tier not in ("low", "medium", "high"):
1005
+ raise ValueError(
1006
+ f"ai_query: model_tier must be one of low|medium|high, got {model_tier!r}"
1007
+ )
1008
+ req_id = str(uuid.uuid4())
1009
+ q: asyncio.Queue[dict] = _make_async_queue()
1010
+ self._app._pending_ai[req_id] = q
1011
+ payload: dict = {
1012
+ "type": "ai_query",
1013
+ "request_id": req_id,
1014
+ "model_tier": model_tier,
1015
+ "system": system,
1016
+ "messages": messages,
1017
+ "tools": tools or [],
1018
+ }
1019
+ _emit(payload)
1020
+ try:
1021
+ ev = await asyncio.wait_for(q.get(), timeout=35.0)
1022
+ except asyncio.TimeoutError:
1023
+ self._app._pending_ai.pop(req_id, None)
1024
+ raise RuntimeError(
1025
+ f"ai_query timed out after 35s (req_id={req_id!r}) — "
1026
+ "check OPENROUTER_API_KEY and network connectivity"
1027
+ )
1028
+ error = ev.get("error")
1029
+ if error is not None:
1030
+ if "capability denied" in error:
1031
+ raise CapabilityDeniedError(error)
1032
+ raise RuntimeError(f"ai_query failed: {error}")
1033
+ return AiResponse(
1034
+ content=ev.get("content") or "",
1035
+ tokens_in=int(ev.get("tokens_in", 0) or 0),
1036
+ tokens_out=int(ev.get("tokens_out", 0) or 0),
1037
+ )
1038
+
1039
+ def mcp_tool_result(self, call_id: str, result: "dict | None" = None, error: "str | None" = None) -> None:
1040
+ """Send a McpToolResult response for a pending McpToolCall.
1041
+
1042
+ One of ``result`` or ``error`` must be provided (not both, not neither).
1043
+ ``result`` should be a MCP CallToolResult dict, e.g.:
1044
+ ``{"content": [{"type": "text", "text": "done"}]}``.
1045
+ """
1046
+ if result is None and error is None:
1047
+ raise ValueError("mcp_tool_result: either 'result' or 'error' must be provided")
1048
+ if result is not None and error is not None:
1049
+ raise ValueError("mcp_tool_result: 'result' and 'error' are mutually exclusive")
1050
+ _emit({
1051
+ "type": "mcp_tool_result",
1052
+ "call_id": call_id,
1053
+ "result": result,
1054
+ "error": error,
1055
+ })
1056
+
1057
+ def expose_tools(self, tools: "list[dict]") -> None:
1058
+ """Declare callable tools to the host (#398, #399).
1059
+
1060
+ Each entry in `tools` must have:
1061
+ - ``name`` (str): unique tool identifier.
1062
+ - ``description`` (str): human-readable description for the LLM.
1063
+ - ``input_schema`` (dict): JSON Schema object (type=object).
1064
+ - ``timeout_ms`` (int, optional): max ms to wait for result (default 30 000).
1065
+
1066
+ The host registers these tools in the global registry. When an AI turn
1067
+ requests a tool call the host sends a ``PlexiEvent::ToolCall``; the SDK
1068
+ routes it to the handler registered via ``@app.tool(…)``.
1069
+ """
1070
+ _emit({"type": "expose_tools", "tools": tools})
1071
+
1072
+ @_blocking_emit_method
1073
+ async def list_midi_devices(self, timeout: float = 5.0) -> "MidiDeviceList":
1074
+ """Enumerate CoreMIDI input + output ports (#320).
1075
+ No capability gate — port names are publicly visible in Audio MIDI
1076
+ Setup. Requires `midi.in` / `midi.out` only on subsequent open/send.
1077
+
1078
+ Returns a MidiDeviceList with `inputs` and `outputs` lists of
1079
+ MidiPortInfo (id, name, default).
1080
+
1081
+ Raises RuntimeError on host enumeration failure or timeout.
1082
+
1083
+ From background threads:
1084
+ ``self.emit.run_sync(self.emit.list_midi_devices())``.
1085
+ """
1086
+ req_id = str(uuid.uuid4())
1087
+ q: asyncio.Queue[dict] = _make_async_queue()
1088
+ self._app._pending_midi_devices[req_id] = q
1089
+ _emit({"type": "list_midi_devices", "request_id": req_id})
1090
+ try:
1091
+ ev = await asyncio.wait_for(q.get(), timeout=timeout)
1092
+ except asyncio.TimeoutError:
1093
+ self._app._pending_midi_devices.pop(req_id, None)
1094
+ raise RuntimeError(
1095
+ f"list_midi_devices timed out after {timeout}s — host did not respond"
1096
+ )
1097
+ if ev.get("error"):
1098
+ raise RuntimeError(f"list_midi_devices failed: {ev.get('error')}")
1099
+ return MidiDeviceList(
1100
+ inputs=[
1101
+ MidiPortInfo(
1102
+ id=str(p.get("id", "")),
1103
+ name=str(p.get("name", "")),
1104
+ default=bool(p.get("default", False)),
1105
+ )
1106
+ for p in ev.get("inputs", [])
1107
+ ],
1108
+ outputs=[
1109
+ MidiPortInfo(
1110
+ id=str(p.get("id", "")),
1111
+ name=str(p.get("name", "")),
1112
+ default=bool(p.get("default", False)),
1113
+ )
1114
+ for p in ev.get("outputs", [])
1115
+ ],
1116
+ )
1117
+
1118
+ @_blocking_emit_method
1119
+ async def list_audio_devices(self, timeout: float = 5.0) -> "AudioDeviceList":
1120
+ """Enumerate CoreAudio input + output devices (#341).
1121
+ No capability gate — device names are publicly visible.
1122
+ Requires ``audio.record`` / ``audio.playback`` only on subsequent capture/play.
1123
+
1124
+ Returns an AudioDeviceList with ``inputs`` and ``outputs`` lists of
1125
+ AudioDeviceInfo (id, name, default).
1126
+
1127
+ Raises RuntimeError on host enumeration failure or timeout.
1128
+
1129
+ From background threads:
1130
+ ``self.emit.run_sync(self.emit.list_audio_devices())``.
1131
+ """
1132
+ req_id = str(uuid.uuid4())
1133
+ q: asyncio.Queue[dict] = _make_async_queue()
1134
+ self._app._pending_audio_devices[req_id] = q
1135
+ _emit({"type": "list_audio_devices", "request_id": req_id})
1136
+ try:
1137
+ ev = await asyncio.wait_for(q.get(), timeout=timeout)
1138
+ except asyncio.TimeoutError:
1139
+ self._app._pending_audio_devices.pop(req_id, None)
1140
+ raise RuntimeError(
1141
+ f"list_audio_devices timed out after {timeout}s — host did not respond"
1142
+ )
1143
+ if ev.get("error"):
1144
+ raise RuntimeError(f"list_audio_devices failed: {ev.get('error')}")
1145
+ return AudioDeviceList(
1146
+ inputs=[
1147
+ AudioDeviceInfo(
1148
+ id=str(p.get("id", "")),
1149
+ name=str(p.get("name", "")),
1150
+ default=bool(p.get("default", False)),
1151
+ )
1152
+ for p in ev.get("inputs", [])
1153
+ ],
1154
+ outputs=[
1155
+ AudioDeviceInfo(
1156
+ id=str(p.get("id", "")),
1157
+ name=str(p.get("name", "")),
1158
+ default=bool(p.get("default", False)),
1159
+ )
1160
+ for p in ev.get("outputs", [])
1161
+ ],
1162
+ )
1163
+
1164
+ def open_midi_input(self, port_id: str, pipe_id: str) -> "Pipe":
1165
+ """Open a CoreMIDI input port and pipe its MIDI byte streams into a
1166
+ binary pipe. Requires `midi.in` capability declared in manifest.toml.
1167
+
1168
+ Returns the Pipe handle. The host emits `PipeOpened` then
1169
+ `MidiInputOpened`; the Pipe auto-connects to its socket on
1170
+ `PipeOpened`. Apps then call `pipe.read_frame()` in a loop to
1171
+ receive 1–3 byte MIDI messages (channel-voice / system real-time).
1172
+
1173
+ Each pipe frame is one MIDI 1.0 message — apps don't need to parse
1174
+ message boundaries themselves.
1175
+ """
1176
+ # Import here to avoid circular import at module load time.
1177
+ from ._pipe import Pipe
1178
+ # Open the binary pipe FIRST so the App's `_pipes` registry has it
1179
+ # before PipeOpened arrives. The host emits PipeOpened then
1180
+ # MidiInputOpened in order; both events are received by the event
1181
+ # loop and forwarded to the Pipe / on_midi_input_opened handler.
1182
+ p = Pipe(pipe_id=pipe_id, mode="binary", direction="in", app=self._app)
1183
+ self._app._pipes[pipe_id] = p
1184
+ _emit({
1185
+ "type": "open_midi_input",
1186
+ "port_id": port_id,
1187
+ "pipe_id": pipe_id,
1188
+ })
1189
+ return p
1190
+
1191
+ def close_midi_input(self, port_id: str) -> None:
1192
+ """Close a previously-opened MIDI input port. The associated binary
1193
+ pipe drains and closes; the app should drop its Pipe handle."""
1194
+ _emit({"type": "close_midi_input", "port_id": port_id})
1195
+
1196
+ def send_midi(self, port_id: str, bytes_: "bytes | bytearray | list[int]") -> None:
1197
+ """Fire-and-forget send of one MIDI 1.0 byte stream to `port_id`.
1198
+ Requires `midi.out` capability. The host opens the output port lazily
1199
+ on the first send and reuses the handle afterwards.
1200
+
1201
+ `bytes_` is a 1–3 byte sequence: NoteOn `[0x90+ch, note, vel]`,
1202
+ NoteOff `[0x80+ch, note, vel]`, CC `[0xB0+ch, num, val]`, clock
1203
+ pulse `[0xF8]`, etc. Use `plexi_sdk.midi` helpers to construct
1204
+ messages with named arguments.
1205
+
1206
+ Successful sends produce no event; failures arrive as a logged
1207
+ `midi_send_error` warning. Apps that need explicit error handling
1208
+ should validate the destination via `list_midi_devices()` first.
1209
+ """
1210
+ if isinstance(bytes_, (bytes, bytearray)):
1211
+ data = list(bytes_)
1212
+ else:
1213
+ data = list(bytes_)
1214
+ # JSON wire shape: array of integers. The host accepts any ints in
1215
+ # 0..=255 and rejects empty arrays.
1216
+ _emit({"type": "send_midi", "port_id": port_id, "bytes": data})
1217
+
1218
+ @_blocking_emit_method
1219
+ async def open_video(
1220
+ self,
1221
+ source: str,
1222
+ pipe_id: str,
1223
+ timeout: float = 10.0,
1224
+ ) -> "VideoHandle":
1225
+ """Open a video decoder against `source` and return a VideoHandle
1226
+ carrying the negotiated width/height/fps/duration_ms plus the
1227
+ attached Pipe (#345). Requires `video.playback` capability.
1228
+
1229
+ **Pre-v1 note:** `video.playback` is not production-ready. The
1230
+ `file:///` decoder returns NotImplemented in all shipping builds
1231
+ until AVFoundation backing lands (#346). Only `mock://gradient`
1232
+ works today. Do not ship apps that depend on real video decoding.
1233
+
1234
+ Decoded frames flow as raw RGBA8 packets on the Pipe — one packet
1235
+ per video frame, length `width * height * 4`. Apps spin a reader
1236
+ thread that calls `handle.pipe.read_frame()` and either renders
1237
+ the bytes (if a frame-render API is available) or surfaces a
1238
+ frame counter.
1239
+
1240
+ Source schemes:
1241
+ - `mock://gradient` — procedural mock decoder (always works).
1242
+ - `file:///...` — production decoder; returns NotImplemented
1243
+ until #346 lands AVFoundation backing.
1244
+
1245
+ Raises CapabilityDeniedError if `video.playback` is not declared.
1246
+ Raises RuntimeError on any other failure (NotImplemented, bad
1247
+ source, decoder error, timeout).
1248
+
1249
+ From background threads:
1250
+ ``self.emit.run_sync(self.emit.open_video(source, pipe_id))``.
1251
+ """
1252
+ from ._pipe import Pipe
1253
+ from ._types import VideoHandle
1254
+ # Open the binary pipe FIRST so the App's `_pipes` registry has it
1255
+ # before PipeOpened arrives. The host emits PipeOpened, then
1256
+ # VideoOpenAck. The Pipe auto-connects to its socket on PipeOpened.
1257
+ pipe = Pipe(pipe_id=pipe_id, mode="binary", direction="in", app=self._app)
1258
+ self._app._pipes[pipe_id] = pipe
1259
+
1260
+ req_id = str(uuid.uuid4())
1261
+ q: asyncio.Queue[dict] = _make_async_queue()
1262
+ self._app._pending_video_open[req_id] = q
1263
+ _emit({
1264
+ "type": "open_video",
1265
+ "request_id": req_id,
1266
+ "source": source,
1267
+ "pipe_id": pipe_id,
1268
+ })
1269
+ try:
1270
+ ev = await asyncio.wait_for(q.get(), timeout=timeout)
1271
+ except asyncio.TimeoutError:
1272
+ self._app._pending_video_open.pop(req_id, None)
1273
+ raise RuntimeError(
1274
+ f"open_video timed out after {timeout}s — host did not respond"
1275
+ )
1276
+ error = ev.get("error")
1277
+ if error is not None:
1278
+ if "capability denied" in error:
1279
+ raise CapabilityDeniedError(error)
1280
+ raise RuntimeError(f"open_video failed: {error}")
1281
+ return VideoHandle(
1282
+ handle_id=int(ev.get("handle_id", 0)),
1283
+ width=int(ev.get("width", 0)),
1284
+ height=int(ev.get("height", 0)),
1285
+ fps=float(ev.get("fps", 0.0)),
1286
+ duration_ms=int(ev.get("duration_ms", 0)),
1287
+ pipe=pipe,
1288
+ )
1289
+
1290
+ def set_video_state(self, handle_id: int, state: str, position_ms: int = 0) -> None:
1291
+ """Drive playback for a video opened with `open_video` (#345).
1292
+ `state` is one of `"play"`, `"pause"`, `"seek"`. For `"seek"`,
1293
+ `position_ms` is the absolute target position in milliseconds.
1294
+ Fire-and-forget — no response event."""
1295
+ if state == "seek":
1296
+ payload: dict[str, Any] = {"kind": "seek", "position_ms": int(position_ms)}
1297
+ elif state == "play":
1298
+ payload = {"kind": "play"}
1299
+ elif state == "pause":
1300
+ payload = {"kind": "pause"}
1301
+ else:
1302
+ raise ValueError(
1303
+ f"set_video_state: unknown state {state!r} (expected play|pause|seek)"
1304
+ )
1305
+ _emit({
1306
+ "type": "set_video_state",
1307
+ "handle_id": int(handle_id),
1308
+ "state": payload,
1309
+ })
1310
+
1311
+ def close_video(self, handle_id: int) -> None:
1312
+ """Close a previously-opened video handle (#345). The host tears down
1313
+ the decoder and drains the binary pipe. No response event."""
1314
+ _emit({"type": "close_video", "handle_id": int(handle_id)})
1315
+
1316
+ def audio_capture(
1317
+ self,
1318
+ pipe_id: str,
1319
+ sample_rate: int = 48000,
1320
+ buffer_size: int = 512,
1321
+ device_id: "str | None" = None,
1322
+ ) -> "Pipe":
1323
+ """Start mic capture (#277). PCM frames stream as raw f32 PCM on
1324
+ a binary pipe — interleaved per-channel, ``sample_rate`` per
1325
+ channel per second.
1326
+
1327
+ Returns the binary Pipe immediately (negotiated values arrive
1328
+ async via ``PlexiEvent::AudioCaptureStarted`` and the host log;
1329
+ the app reads ``handle.read_frame()`` once it connects). Failure
1330
+ arrives as ``PlexiEvent::AudioCaptureError``.
1331
+
1332
+ If ``pipe_id`` is already registered (i.e. the app is restarting
1333
+ capture), the old Pipe is closed before the new one is created —
1334
+ prevents OSError on the reader thread's recv() from a stale socket.
1335
+
1336
+ Requires ``audio.in`` capability. Raises ``CapabilityDeniedError``
1337
+ only when the gate fires synchronously at the wire layer; the
1338
+ usual TCC mic-permission denial surfaces async on the first frame
1339
+ attempt as an ``AudioCaptureError`` event.
1340
+ """
1341
+ from ._pipe import Pipe
1342
+ # Close any existing pipe for this id before replacing — prevents
1343
+ # orphaned sockets when the app restarts capture with the same pipe_id.
1344
+ existing = self._app._pipes.pop(pipe_id, None)
1345
+ if existing is not None:
1346
+ existing.close()
1347
+ # Open the binary pipe FIRST so PipeOpened can attach when it
1348
+ # arrives — same shape as ``open_video``.
1349
+ pipe = Pipe(pipe_id=pipe_id, mode="binary", direction="in", app=self._app)
1350
+ self._app._pipes[pipe_id] = pipe
1351
+ _emit({
1352
+ "type": "audio_capture",
1353
+ "pipe_id": pipe_id,
1354
+ "device_id": device_id,
1355
+ "sample_rate": int(sample_rate),
1356
+ "buffer_size": int(buffer_size),
1357
+ })
1358
+ return pipe
1359
+
1360
+ def stop_audio_capture(self, pipe_id: str) -> None:
1361
+ """Stop an active audio capture and close its pipe (#862).
1362
+
1363
+ Closes the Pipe registered under ``pipe_id`` and removes it from
1364
+ the internal registry. The host cleans up the stale session on the
1365
+ next ``AudioCapture`` for the same ``pipe_id``. No host command is
1366
+ needed — closing the socket is sufficient.
1367
+
1368
+ Apps should join any reader thread after calling this to avoid
1369
+ reading from a closed socket::
1370
+
1371
+ self.emit.stop_audio_capture("mic")
1372
+ self._reader_thread.join(timeout=2.0)
1373
+
1374
+ No-op if ``pipe_id`` is not registered.
1375
+ """
1376
+ pipe = self._app._pipes.pop(pipe_id, None)
1377
+ if pipe is not None:
1378
+ pipe.close()
1379
+
1380
+ def audio_play(self, source: str, volume: float = 1.0) -> None:
1381
+ """Play an audio file via the host (rodio). Requires audio.playback capability.
1382
+
1383
+ source: absolute path to audio file (mp3, wav, ogg, etc.)
1384
+ volume: playback volume 0.0–1.0 (default 1.0)
1385
+
1386
+ Fire-and-forget — no response event. Playback stops when the pane
1387
+ closes; there is no explicit stop API yet.
1388
+ """
1389
+ _emit({
1390
+ "type": "audio_play",
1391
+ "source": source,
1392
+ "state": "playing",
1393
+ "volume": float(volume),
1394
+ })
1395
+
1396
+ def set_timer(self, timer_id: str, after_ms: int) -> None:
1397
+ """Fire PlexiEvent::Timer after after_ms milliseconds. Requires timer capability."""
1398
+ _emit({"type": "set_timer", "timer_id": timer_id, "after_ms": after_ms})
1399
+
1400
+ def cancel_timer(self, timer_id: str) -> None:
1401
+ """Cancel a pending timer set with set_timer()."""
1402
+ _emit({"type": "cancel_timer", "timer_id": timer_id})
1403
+
1404
+ def set_mouse_tracking(self, enabled: bool) -> None:
1405
+ """Enable or disable PlexiEvent::MouseMove delivery for this pane.
1406
+
1407
+ MouseMove is off by default to avoid flooding apps that don't need
1408
+ continuous pointer tracking. Call set_mouse_tracking(True) after
1409
+ on_init to start receiving on_mouse_move callbacks. Call with False
1410
+ to stop.
1411
+ """
1412
+ _emit({"type": "set_mouse_tracking", "enabled": enabled})
1413
+
1414
+ # Binary pipe
1415
+ def pipe_open(self, pipe_id: str, mode: str = "binary",
1416
+ direction: str = "in") -> "Pipe":
1417
+ """Open a typed pipe. Returns a Pipe handle. mode: json|binary. direction: in|out|duplex."""
1418
+ from ._pipe import Pipe
1419
+ p = Pipe(pipe_id=pipe_id, mode=mode, direction=direction, app=self._app)
1420
+ self._app._pipes[pipe_id] = p
1421
+ _emit({"type": "pipe_open", "pipe_id": pipe_id,
1422
+ "mode": mode, "direction": direction})
1423
+ return p
1424
+
1425
+ def pipe_open_directed(self, pipe_id: str, target_pane_id: int) -> "Pipe":
1426
+ """Open a *directed* JSON pipe to one specific target pane (#286).
1427
+
1428
+ Mirrors `pipe_open` but the host scopes `PipeMessage` delivery so
1429
+ only the caller and `target_pane_id` see traffic on `pipe_id` —
1430
+ peers that coincidentally `pipe_open` the same id stay isolated.
1431
+ Always JSON-mode duplex; `pipe.send(payload)` is symmetric on
1432
+ either end.
1433
+
1434
+ Capability: `pipe.open` (same as `pipe_open`).
1435
+ """
1436
+ from ._pipe import Pipe
1437
+ p = Pipe(pipe_id=pipe_id, mode="json", direction="duplex", app=self._app)
1438
+ self._app._pipes[pipe_id] = p
1439
+ _emit({
1440
+ "type": "pipe_open_directed",
1441
+ "pipe_id": pipe_id,
1442
+ "target_pane_id": int(target_pane_id),
1443
+ })
1444
+ return p
1445
+
1446
+ def pipe_send(self, pipe_id: str, payload: Any) -> None:
1447
+ """Send a JSON payload on an already-open pipe by id (#286).
1448
+
1449
+ Use this when you don't own a `Pipe` handle locally — for example
1450
+ when replying on a directed pipe a *peer* opened (you're the target;
1451
+ the host subscribed you, but the SDK doesn't auto-build a handle).
1452
+ Sends a `pipe_send` DrawCommand; the host routes per the existing
1453
+ directed-pipe pair table.
1454
+ """
1455
+ _emit({"type": "pipe_send", "pipe_id": pipe_id, "payload": payload})
1456
+
1457
+ def close_self(self) -> None:
1458
+ """Ask the host to close this app's pane gracefully.
1459
+
1460
+ Prefer this over sys.exit() — sys.exit() exits the process but the
1461
+ host marks the pane as Crashed and restart-loops watched apps.
1462
+ close_self() sends ControlCommand::CloseSelf; the host closes the
1463
+ pane via the normal wants_close path on the next frame.
1464
+ """
1465
+ _emit({"type": "close_self"})
1466
+