@ryuhq/sdk 0.0.5 → 0.1.2

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.
@@ -11,6 +11,36 @@
11
11
  * manifest types change).
12
12
  */
13
13
 
14
+ /**
15
+ * One selectable option for a [`SettingsFieldType::Select`] field.
16
+ *
17
+ * Accepts both spellings the desktop's `parseOptions` accepts: a bare string
18
+ * (value and label are the same) or an object with an explicit `label`. Keeping
19
+ * both is not indulgence — the bare-string form is what every hand-written
20
+ * manifest reaches for, and rejecting it would push authors into boilerplate for
21
+ * the common case.
22
+ */
23
+ export type SettingsFieldOption =
24
+ | string
25
+ | {
26
+ /**
27
+ * Display label. Absent = show the raw `value`.
28
+ */
29
+ label?: string | null;
30
+ /**
31
+ * The value persisted to the preference key.
32
+ */
33
+ value: string;
34
+ };
35
+ /**
36
+ * What a capability provider acts on — see [`ProvidesEntry::target`].
37
+ *
38
+ * Deliberately two coarse values rather than a taxonomy. The only question a user
39
+ * needs answered before swapping is "will this act on the machine in front of me,
40
+ * or somewhere else?", and a finer vocabulary (container / VM / cloud / another
41
+ * host) would be guesswork the manifests cannot honestly support.
42
+ */
43
+ export type ProviderTarget = "local-machine" | "remote-desktop";
14
44
  /**
15
45
  * A host surface a plugin can declare support for via `targets`.
16
46
  *
@@ -19,12 +49,12 @@
19
49
  * An **empty/absent** `targets` list means the plugin runs on *every* surface —
20
50
  * that is the backward-compatible default and MUST NOT be read as "hidden".
21
51
  */
22
- export type Surface = "gateway" | "core" | "desktop" | "island" | "mobile" | "extension" | "web" | "cli";
52
+ export type Surface = "gateway" | "core" | "desktop" | "island" | "mobile" | "extension" | "web" | "cli" | "unknown";
23
53
 
24
54
  /**
25
- * An installable Ryu App manifest (`plugin.json`).
55
+ * An installable Ryu App manifest (`manifest.json`).
26
56
  *
27
- * Modelled on Codex's `plugin.json` pattern: a thin descriptor that bundles one or
57
+ * Modelled on Codex's `manifest.json` pattern: a thin descriptor that bundles one or
28
58
  * more [`RunnableEntry`] items (agents, workflows, tools, skills, companions,
29
59
  * channels, engines, policies), lists the permission grants the app requires, and
30
60
  * optionally declares a Companion surface (an in-desktop overlay or sidebar panel).
@@ -36,6 +66,10 @@ export type Surface = "gateway" | "core" | "desktop" | "island" | "mobile" | "ex
36
66
  * [`crate::schema::validate_runnable`] function.
37
67
  */
38
68
  export interface PluginManifest {
69
+ /**
70
+ * Primary brand accent color, hex (Ryu extension: `accentColor`).
71
+ */
72
+ accentColor?: string | null;
39
73
  /**
40
74
  * Activation events that lazily wake the plugin — VS-Code `activationEvents`.
41
75
  * Recognised tokens: `"*"` (always active / eager), `"onStartup"`, `"onChat"`,
@@ -82,6 +116,12 @@ export interface PluginManifest {
82
116
  * [`ui_code_sha256`]: PluginManifest::ui_code_sha256
83
117
  */
84
118
  backend_sha256?: string | null;
119
+ /**
120
+ * Detail-page hero banner spec ({colors,style,seed}); opaque passthrough (Ryu ext).
121
+ */
122
+ banner?: {
123
+ [k: string]: unknown;
124
+ };
85
125
  /**
86
126
  * Human-readable capability strings (Ryu extension). When absent the detail
87
127
  * builder DERIVES these from `permission_grants` via
@@ -125,6 +165,29 @@ export interface PluginManifest {
125
165
  * Homepage/website URL (Claude `homepage`; emitted as `website`).
126
166
  */
127
167
  homepage?: string | null;
168
+ /**
169
+ * Icon-primitive id for the listing card (Ryu extension: `icon`). An
170
+ * Iconify/icons0 `prefix:name`, a bare Hugeicons name, or a URL — resolved by
171
+ * the shared `Icon` primitive. Distinct from `icon_url`: this is a GLYPH id the
172
+ * card masks with `currentColor`, `icon_url` is a raster logo. When absent the
173
+ * card falls back to `icon_url`, then a default glyph.
174
+ */
175
+ icon?: string | null;
176
+ /**
177
+ * CSS background for the icon square (Ryu extension: `iconBackground`).
178
+ */
179
+ iconBackground?: string | null;
180
+ /**
181
+ * Dithered-gradient background for the card's icon square (Ryu extension:
182
+ * `iconDither`). Opaque passthrough `{ from, to?, direction? }` mirroring
183
+ * dither-kit's `DitherGradient` props (`from`/`to` are a palette-colour name or
184
+ * a hue number, `direction` is up|down|left|right). Kept as raw JSON like
185
+ * `banner` so an untrusted/typo'd value never fails the manifest parse — the
186
+ * render layer validates and falls back before painting.
187
+ */
188
+ iconDither?: {
189
+ [k: string]: unknown;
190
+ };
128
191
  /**
129
192
  * Logo URL (contract key `iconUrl`; Ryu extension).
130
193
  */
@@ -141,6 +204,20 @@ export interface PluginManifest {
141
204
  * SPDX license identifier (Claude `license`).
142
205
  */
143
206
  license?: string | null;
207
+ /**
208
+ * Declarative **stdio MCP servers** this plugin registers into Core's MCP
209
+ * registry on enable and deregisters on disable/uninstall. Each entry is a
210
+ * [`McpServerDecl`] keyed by the server name the registry uses (the same key a
211
+ * user's `mcp.json` would use). This is the manifest-owned successor to Core's
212
+ * hardcoded built-in MCP servers: a plugin declares its server here instead of
213
+ * Core baking a `com.ryu.<app>` server into `builtin_servers()`. Empty for the
214
+ * common case (a plugin that ships no MCP server). A user `mcp.json` entry with
215
+ * the same name still wins (user-overrides-builtin precedence is preserved by
216
+ * the registry).
217
+ */
218
+ mcp_servers?: {
219
+ [k: string]: McpServerDecl;
220
+ };
144
221
  /**
145
222
  * Human-readable display name shown in the app store / launcher.
146
223
  */
@@ -238,6 +315,15 @@ export interface PluginManifest {
238
315
  * common case (no bundled process).
239
316
  */
240
317
  sidecars?: SidecarSpec[];
318
+ /**
319
+ * Provenance hint for the marketplace index: `"builtin"`, an `owner/repo`
320
+ * slug, or a git/raw URL an external plugin ships from. Absent ⇒ `"builtin"`.
321
+ * This is an index HINT only — Core derives the real trust tier from
322
+ * `plugins::builtins` membership at runtime, NOT from this field. Consumed by
323
+ * the marketplace generator (`tools/mirror-plugins.sh`) to populate each
324
+ * entry's `source`/`builtin` pair.
325
+ */
326
+ source?: string | null;
241
327
  /**
242
328
  * Per-surface support + UI declaration — the richer successor to [`targets`].
243
329
  *
@@ -311,17 +397,40 @@ export interface CompanionSurface {
311
397
  /**
312
398
  * VS-Code-style **contribution points** (`contributes` in `package.json`).
313
399
  *
314
- * Each field is a list of [`ContributionId`] references into the manifest's
315
- * `runnables`: the plugin *declares* that runnable `X` contributes to the
316
- * `commands`/`tools`/`agents`/… surface. This is declare-by-id, not a second
317
- * copy of the runnable — the loader cross-validates that every referenced id
318
- * exists in `runnables`, so a typo is caught at load.
400
+ * The original five surfaces (`commands`/`tools`/`agents`/`workflows`/`policies`)
401
+ * are lists of [`ContributionId`] references into the manifest's `runnables`: the
402
+ * plugin *declares* that runnable `X` contributes to that surface. This is
403
+ * declare-by-id, not a second copy of the runnable — the loader cross-validates
404
+ * that every referenced id exists in `runnables`, so a typo is caught at load.
405
+ *
406
+ * Most surfaces added since are **self-contained**: they carry their own payload
407
+ * and reference no runnable at all (`widgets`, `views`, `dock_panels`,
408
+ * `sidebar_sections`, `sidebar_buttons`, `settings_tabs`, `composer_controls`,
409
+ * `slash_commands`, `turn_hooks`, `tool_filters`, `lsp_servers`).
319
410
  *
320
411
  * # Extending
321
412
  *
322
- * Add a new surface = add a new `#[serde(default)] pub <surface>: Vec<ContributionId>`
323
- * field here. The cross-validation in [`Contributes::referenced_ids`] picks it
324
- * up automatically.
413
+ * Adding a surface is two decisions, and getting either wrong is silent:
414
+ *
415
+ * 1. **Id-reference or self-contained?** An id-reference surface is a
416
+ * `Vec<ContributionId>` and MUST be chained into [`Contributes::referenced_ids`]
417
+ * so the loader can catch a typo. A self-contained surface must be left OUT of
418
+ * it — every id in it names something other than a runnable (a PATH binary, a
419
+ * route, a tool namespace), so including it would reject every valid manifest.
420
+ * `referenced_ids` therefore covers exactly the five original surfaces and
421
+ * nothing else; that omission is deliberate, not an oversight to be tidied up.
422
+ * 2. **Core-interpreted or client-rendered?** If Core acts on the payload
423
+ * (`tool_filters`, `turn_hooks`, `widgets`, `lsp_servers`) it gets a fully typed
424
+ * struct, because a key Core does not know is by construction a key Core cannot
425
+ * act on. If a client shell renders it (`views`, `dock_panels`,
426
+ * `sidebar_sections`, `settings_tabs`, `composer_controls`) it stays opaque
427
+ * JSON, because deserializing into a struct here would DROP any key this Core
428
+ * build does not know about and a newer desktop would lose exactly the fields it
429
+ * was shipped to render.
430
+ *
431
+ * Client-rendered surfaces are then served, tagged with the owning plugin id, from
432
+ * `GET /api/plugins/contributions`. Core-interpreted ones deliberately are not —
433
+ * they are gathered at their own consumption site instead.
325
434
  */
326
435
  export interface Contributes {
327
436
  /**
@@ -334,12 +443,218 @@ export interface Contributes {
334
443
  commands?: ContributionId[];
335
444
  /**
336
445
  * Declarative **native** UI widgets the plugin contributes to the desktop
337
- * composer (e.g. a `toggle` that sets a `plugin_flags` entry, or a `chip`).
338
- * Core stores these verbatim and serves them via `GET /api/plugins/contributions`;
339
- * the desktop renders the known widget types. Opaque to Core (the renderer
340
- * owns interpretation) so new widget types need no Core change.
446
+ * composer. Core stores these verbatim and serves them via
447
+ * `GET /api/plugins/contributions` (tagged with the owning `plugin` id); the
448
+ * desktop renders the known control types. Opaque to Core (the renderer owns
449
+ * interpretation) so a new control type needs no Core change — an entry Core has
450
+ * never heard of is forwarded byte-for-byte, so a desktop newer than the node it
451
+ * talks to still gets everything it was shipped to render.
452
+ *
453
+ * # The control vocabulary
454
+ *
455
+ * Every entry is an object carrying `id`, a `type` discriminant, a `label` and a
456
+ * `flag`; the remaining keys belong to that type. `flag` is universal because the
457
+ * per-request `plugin_flags` map is the composer's ONLY channel to the turn — a
458
+ * control the turn hook cannot observe would do nothing. `type` is deliberately NOT
459
+ * an enum (same reasoning as [`ViewContribution::view`]): an unknown member must
460
+ * reach a newer shell intact rather than being rejected at load by an older Core.
461
+ * The vocabulary the desktop composer understands today:
462
+ *
463
+ * - `"toggle"` — a switch row in the composer "+" menu, with an optional
464
+ * `description`. Flipping it puts `flag: true` into `plugin_flags`. This is the
465
+ * original — and until now the ONLY — rendered type.
466
+ * - `"select"` — a menu/segmented picker. Carries an `options` array of
467
+ * `{ value, label, description?, icon? }` plus an optional `default`. The chosen
468
+ * `value` (a string, not a bool) lands in `plugin_flags[flag]`, so a plugin can
469
+ * offer modes ("fast" / "thorough") instead of on/off.
470
+ * - `"chip"` — an inline pill in the composer bar showing a LIVE value rather than
471
+ * a menu row. Carries an optional `icon` and a `source` (the same
472
+ * `@ryu/app-host/views` `ViewSource` a declarative view uses) the shell polls for
473
+ * the displayed text, and exposes/clears its value through `flag`. This is what a
474
+ * rich bespoke control (a recording indicator, a selected-clip pill) needs in
475
+ * order to stop being hand-written host code.
476
+ * - `"action"` — a button that DISPATCHES rather than holding state. Carries an
477
+ * optional `icon` and a `capability` (+ optional `args`) the shell invokes
478
+ * through the plugin's granted capability seam — never inline code, and never a
479
+ * capability the owning plugin was not granted — then marks `flag` so the turn
480
+ * hook sees that it fired.
481
+ *
482
+ * A control may also carry `placement` (`"menu"`, the default, or `"bar"`) and
483
+ * `order`; the renderer, not Core, decides what to do with an unknown key.
484
+ *
485
+ * Renderers MUST ignore an entry whose `type` they do not know (the desktop
486
+ * filters by `type`), so shipping a new control type degrades to "not shown on
487
+ * older shells" instead of breaking the composer.
341
488
  */
342
489
  composer_controls?: unknown[];
490
+ /**
491
+ * **Deletable data categories** the app owns — one "Delete all X" row in
492
+ * Settings → Danger Zone (see [`DataCategoryContribution`]).
493
+ *
494
+ * The danger zone used to be two hardcoded lists that had to be edited
495
+ * together: a `DataCategory` enum in Core and a `CATEGORIES` array carrying the
496
+ * user-facing copy in the closed desktop source. Monitors and Meetings are
497
+ * app-owned data, so both lists named apps — which meant a node where Monitors
498
+ * was never enabled still offered to delete monitors, and the count was always
499
+ * 0. Declaring the category here makes the owning app the single source of both
500
+ * its existence and its wording, and makes the row appear and disappear with
501
+ * the app instead of with a client-side feature-detect.
502
+ *
503
+ * # Core-interpreted, so a typed struct — and NOT on the contributions endpoint
504
+ *
505
+ * Core has to resolve the id to something that can actually count and delete
506
+ * the rows, so per this type's own doc comment this gets a typed struct rather
507
+ * than opaque JSON, and it is gathered at its consumption site
508
+ * (`GET /api/data/counts`, which serves each category's descriptor next to its
509
+ * live count) rather than at `GET /api/plugins/contributions` — the same
510
+ * disposition as [`Contributes::tool_filters`] and [`Contributes::lsp_servers`].
511
+ *
512
+ * # Declaration, not implementation
513
+ *
514
+ * A declared category is served only when Core knows how to clear it; an id
515
+ * Core does not implement is skipped with a warn rather than being offered as a
516
+ * button that 400s. That split is deliberate and not a stepping stone to a
517
+ * generic HTTP truncate: clearing monitors has to tear down each monitor's
518
+ * backing scheduler job, and clearing meetings has to broadcast on the meetings
519
+ * SSE stream, so a blind `DELETE /monitors` would leave jobs ticking forever.
520
+ * The manifest owns *whether the row exists and what it says*; Core owns *what
521
+ * deleting actually entails*.
522
+ */
523
+ data_categories?: DataCategoryContribution[];
524
+ /**
525
+ * App-registered **workspace dock panels** — a tab in the desktop's bottom or
526
+ * right dock (Terminal / Code Review / Browser / Simulator live there today).
527
+ * This is the seam that lets an app OWN its dock tab instead of the shell
528
+ * welding the app into a closed `TabKind` union: `@ryu/browser` and
529
+ * `@ryu/simulator` are apps, and their tabs are contributions, not enum
530
+ * variants. Self-contained + opaque `spec` (see [`DockPanelContribution`]), so a
531
+ * new panel capability needs no Core change; served + tagged with the owning
532
+ * `plugin` id at `GET /api/plugins/contributions`.
533
+ */
534
+ dock_panels?: DockPanelContribution[];
535
+ /**
536
+ * **App events this plugin emits** — the *provider* half of the hook system,
537
+ * and the mirror image of [`Contributes::turn_hooks`] (the *consumer* half).
538
+ *
539
+ * Core's own hook phases (`post_assistant_turn`, `pre_tool_use`, `context`, …)
540
+ * are a closed set built into `plugin_host`, so before this surface existed a
541
+ * plugin could only react to things happening *in a chat turn*. An app that
542
+ * owns a real-world lifecycle — a meeting ending, a workflow run failing, an
543
+ * alert firing — had no way to let anything else react to it. That forced the
544
+ * classic anti-pattern: every consumer polls the producer's HTTP routes, and
545
+ * every new integration is bespoke wiring between two apps that must both be
546
+ * changed.
547
+ *
548
+ * Declaring an event here makes it a first-class hook phase. Any other plugin
549
+ * consumes it by naming it in a `turn_hooks[].on`, and any workflow consumes it
550
+ * with an `event` trigger — neither the producer nor Core learns anything about
551
+ * the consumer. Apps therefore both **provide** and **consume** over one
552
+ * mechanism.
553
+ *
554
+ * # Ids are namespaced, and that is what makes collisions impossible
555
+ *
556
+ * Every id MUST be `<owning plugin id>#<event name>` — the owning half is
557
+ * checked against the manifest's own `id` at load, and the name half is
558
+ * `[a-z0-9][a-z0-9._-]*`. Because a Core phase name never contains `/`, an app
559
+ * literally cannot declare an event that shadows one, no reserved-word list
560
+ * required. It is also why the emit path can authorize purely from the
561
+ * manifest: the caller's authenticated plugin id must be the id in the event
562
+ * name, so an app can only ever emit its **own** events.
563
+ *
564
+ * # Core-interpreted, so a typed struct
565
+ *
566
+ * Core reads this table to authorize emits and to serve the event catalog, so
567
+ * per this type's own doc comment it gets a typed struct rather than opaque
568
+ * JSON. It names event strings rather than runnable ids, so it is
569
+ * **self-contained** and stays out of [`Contributes::referenced_ids`].
570
+ */
571
+ hook_events?: HookEventContribution[];
572
+ /**
573
+ * **Language servers** the plugin declares, keyed by server name — the
574
+ * agent-neutral mirror of Claude Code's `.lsp.json` / `lspServers`, so a config
575
+ * written for either host loads in the other:
576
+ *
577
+ * ```json
578
+ * "lsp_servers": {
579
+ * "go": { "command": "gopls", "args": ["serve"], "extensionToLanguage": { ".go": "go" } }
580
+ * }
581
+ * ```
582
+ *
583
+ * Only the container key is Ryu's (`lsp_servers`, snake_case like every sibling
584
+ * here); every key INSIDE a server entry is Claude's own camelCase spelling
585
+ * verbatim, because that body is what actually travels between the two hosts.
586
+ * No `lspServers` alias is accepted on purpose. `lsp_servers` — this exact
587
+ * spelling — is registered in the SDK's zod mirror (`ContributesSchema` in
588
+ * `packages/sdk/src/manifest.ts`), and that mirror STRIPS every key it does not
589
+ * list. An alias would therefore parse here and be silently deleted at
590
+ * `ryu pack` time, before the manifest is signed, which is a worse failure than
591
+ * a key that never parsed at all. One spelling, registered in both places.
592
+ *
593
+ * The plugin ships CONFIG ONLY, never the server binary — `command` is resolved
594
+ * from `PATH` at spawn time and a missing binary is a visible skip, not a load
595
+ * error. Core spawns and supervises these processes itself, so unlike the
596
+ * client-rendered surfaces above this one is fully typed
597
+ * ([`LspServerContribution`]) and is NOT served from
598
+ * `GET /api/plugins/contributions`; it is gathered at the spawn site, the same
599
+ * disposition as [`Contributes::tool_filters`].
600
+ *
601
+ * # Ordering is part of the contract
602
+ *
603
+ * Registration is **first-registration-wins per file extension**: if two enabled
604
+ * servers both claim `.go`, the first one registered owns it, the others never
605
+ * start for that extension, and the spawn site warns naming the owner. That rule
606
+ * is only reproducible if iteration order is, so this is a [`BTreeMap`] — it
607
+ * iterates lexicographically by server key, never in hash order and never in
608
+ * JSON authoring order. The full resolved invariant across a node is
609
+ * **(plugin enable order, then server key ascending)**.
610
+ *
611
+ * Note this makes the tie-break deterministic, not byte-identical to Claude
612
+ * Code's, which falls out of JS object insertion order. Two servers fighting
613
+ * over one extension is a misconfiguration in either host; what matters is that
614
+ * the same node always resolves it the same way and says who won.
615
+ */
616
+ lsp_servers?: {
617
+ [k: string]: LspServerContribution;
618
+ };
619
+ /**
620
+ * **Pi extensions** the plugin ships — TypeScript files the managed `ryu` (Pi)
621
+ * agent loads at process start:
622
+ *
623
+ * ```json
624
+ * "pi_extensions": [
625
+ * { "id": "shell", "file": "pi-extensions/ryu-shell.ts",
626
+ * "description": "background bash for the managed Pi agent" }
627
+ * ]
628
+ * ```
629
+ *
630
+ * Pi ships none of plan mode, sub-agents, permission prompts or background bash
631
+ * and says so deliberately in its own docs — "you can build or install those
632
+ * workflows as extensions or packages". This surface is that seam: the
633
+ * capabilities Core used to hardcode into the spawn path become plugins the user
634
+ * can enable and disable, and a third party can ship one at all.
635
+ *
636
+ * # This is UNSANDBOXED code, and the tier gate is not optional
637
+ *
638
+ * A [`Contributes::turn_hooks`] body runs in the deny-by-default Deno sandbox
639
+ * behind capability-gated `host.*` calls. A file named here runs **inside the Pi
640
+ * process** with full host privilege: the first-party ones spawn children and
641
+ * POST to Core. That is the same arbitrary-code-execution class as
642
+ * [`PluginManifest::mcp_servers`], so Core gates it identically — Core tier is
643
+ * auto-allowed, Community tier needs an operator-allowlisted grant, and the gate
644
+ * sits at the materializer, because writing the file is what makes it run.
645
+ *
646
+ * # Core-interpreted, so a typed struct — and NOT on the contributions endpoint
647
+ *
648
+ * Core resolves each `file` and projects it into the managed Pi's config dir, so
649
+ * per this type's own doc comment it gets a typed struct and is gathered at its
650
+ * consumption site (`pi_config::app_extensions`) rather than served from
651
+ * `GET /api/plugins/contributions` — the same disposition as
652
+ * [`Contributes::lsp_servers`].
653
+ *
654
+ * The `file` is deliberately NOT hydrated into an inline string the way a
655
+ * `code_file` is; see [`PluginManifest::pi_extension_refs`] for why.
656
+ */
657
+ pi_extensions?: PiExtensionContribution[];
343
658
  /**
344
659
  * Gateway policies the plugin contributes (referenced by runnable id).
345
660
  */
@@ -347,24 +662,71 @@ export interface Contributes {
347
662
  /**
348
663
  * Declarative settings tabs the plugin contributes (model pickers, text
349
664
  * fields bound to preference keys). Served + rendered the same way.
665
+ *
666
+ * The **contract** for each entry is [`SettingsTabContribution`] — that is what
667
+ * the published JSON Schema advertises (`schemars(with = …)`) and what the
668
+ * loader holds every manifest to at import (see `validate_settings_tab`), so a
669
+ * malformed tab is rejected with a diagnostic instead of reaching the desktop
670
+ * and being silently dropped by the renderer's defensive parser.
671
+ *
672
+ * The *stored* type stays `serde_json::Value` on purpose. `GET
673
+ * /api/plugins/contributions` tags each entry in place with its owning `plugin`
674
+ * id and forwards it verbatim; deserializing into the struct here would silently
675
+ * DROP any key this Core build does not know about, so a desktop newer than the
676
+ * node it talks to would lose exactly the fields it was shipped to render. Parse
677
+ * once at the validation chokepoint, forward the original bytes.
678
+ */
679
+ settings_tabs?: SettingsTabContribution[];
680
+ /**
681
+ * App-registered sidebar **buttons** — a single nav row (e.g. Memory →
682
+ * `/library/memory`). The button-shaped sibling of [`Contributes::sidebar_sections`]
683
+ * (no live list, just a label/icon + a client route). See [`SidebarButtonContribution`].
684
+ */
685
+ sidebar_buttons?: SidebarButtonContribution[];
686
+ /**
687
+ * App-registered sidebar **sections** — a header plus a live list of rows the
688
+ * shell fetches from a declared Core `/api/` path. Lets an app own its sidebar
689
+ * section (Canvas/Whiteboard/Meetings recent-doc lists) instead of the shell
690
+ * hardcoding it. Self-contained + opaque `spec` (see [`SidebarSectionContribution`]),
691
+ * so a new section capability needs no Core change; served + tagged with the
692
+ * owning `plugin` id at `GET /api/plugins/contributions`.
350
693
  */
351
- settings_tabs?: unknown[];
694
+ sidebar_sections?: SidebarSectionContribution[];
352
695
  /**
353
696
  * Slash commands the plugin contributes (e.g. `/goal`). The desktop maps the
354
697
  * command to a `plugin_flags`/message action; the plugin's turn hook reads
355
698
  * the resulting message. Served + rendered the same way.
356
699
  */
357
700
  slash_commands?: unknown[];
701
+ /**
702
+ * Tools this plugin wants **hidden** from the model's offered tool list —
703
+ * the declarative half of a tool firewall (see [`ToolFilterContribution`]).
704
+ *
705
+ * Purely declarative here: this contract defines and validates the shape, and
706
+ * the filter is applied where tools are offered to the model. Like
707
+ * [`Contributes::turn_hooks`] this is self-contained (the ids name tools from
708
+ * *other* plugins/servers by design — hiding your own tool is just not
709
+ * declaring it), so it is NOT cross-validated against `runnables`.
710
+ */
711
+ tool_filters?: ToolFilterContribution[];
358
712
  /**
359
713
  * Callable tools the plugin contributes (referenced by runnable id).
360
714
  */
361
715
  tools?: ContributionId[];
362
716
  /**
363
- * Chat turn hooks the plugin contributes — server-side logic that runs at a
364
- * turn boundary (e.g. `post_assistant_turn`) and returns a directive. These
365
- * are **self-contained** (they carry their own inline `code`), so they are
366
- * NOT cross-validated against `runnables` like the id-reference surfaces
367
- * above; the Core `plugin_host` runtime executes them in the sandbox.
717
+ * Hooks the plugin contributes — server-side logic that runs at a hook
718
+ * boundary and returns a directive. These are **self-contained** (they carry
719
+ * their own inline `code`), so they are NOT cross-validated against
720
+ * `runnables` like the id-reference surfaces above; the Core `plugin_host`
721
+ * runtime executes them in the sandbox.
722
+ *
723
+ * The field name is historical. It originally held only *chat* turn
724
+ * boundaries (`post_assistant_turn`, `pre_user_turn`); a hook's `on` is now
725
+ * any hook phase, including an **app event** another plugin declared in its
726
+ * [`Contributes::hook_events`] (`@example/meetings#meeting.ended`). It is
727
+ * deliberately NOT renamed: `turn_hooks` is load-bearing in every packaged
728
+ * manifest, the published JSON Schema, the SDK's TS mirror and the loader's
729
+ * invariant tests, and the rename would buy nothing but churn.
368
730
  */
369
731
  turn_hooks?: TurnHookContribution[];
370
732
  /**
@@ -410,18 +772,568 @@ export interface ContributionId {
410
772
  */
411
773
  title?: string | null;
412
774
  }
775
+ /**
776
+ * One **deletable data category** an app owns (see [`Contributes::data_categories`]).
777
+ *
778
+ * Everything the Danger Zone needs to draw and arm one destructive row, so the copy
779
+ * lives with the app whose data it describes rather than in the desktop's source.
780
+ */
781
+ export interface DataCategoryContribution {
782
+ /**
783
+ * The word the user must type to arm the delete. Absent = the [`noun`], which is
784
+ * the right default often enough that requiring it would just be ceremony.
785
+ * Matched case-insensitively by the client.
786
+ *
787
+ * [`noun`]: DataCategoryContribution::noun
788
+ */
789
+ confirm_word?: string | null;
790
+ /**
791
+ * Exactly what disappears, shown in the confirm dialog. Required, and required
792
+ * to be specific: this is the last thing the user reads before an irreversible
793
+ * delete, and "this cannot be undone" tells them nothing they did not know.
794
+ */
795
+ detail: string;
796
+ /**
797
+ * Stable id — this is the `category` a `POST /api/data/clear` names, so it is
798
+ * the app's half of the delete contract and renaming it breaks the button.
799
+ */
800
+ id: string;
801
+ /**
802
+ * Plural noun for the live count line ("42 monitors" / "No monitors") and the
803
+ * "N deleted" toast. Lower-case: it is used mid-sentence.
804
+ */
805
+ noun: string;
806
+ /**
807
+ * The destructive button label and confirm-dialog title ("Delete all monitors").
808
+ */
809
+ title: string;
810
+ }
811
+ /**
812
+ * One app-registered **workspace dock panel** — a tab in the desktop's bottom or
813
+ * right dock (see [`Contributes::dock_panels`]).
814
+ *
815
+ * The dock sibling of [`ViewContribution`] / [`SidebarSectionContribution`]: a typed
816
+ * envelope (`id` / `title` / `icon` / `placement`) around an OPAQUE description of
817
+ * what the tab renders. Core stores it verbatim, tags it with the owning `plugin` id
818
+ * at `GET /api/plugins/contributions`, and never interprets `panel` or `spec` — so a
819
+ * new panel capability is a renderer change, never a Core change.
820
+ *
821
+ * # The `panel` vocabulary
822
+ *
823
+ * `panel` is the render-mode discriminant the desktop's dock renderer switches on.
824
+ * It is a plain `String` (not an enum) for the same reason [`ViewContribution::view`]
825
+ * is: an unknown member must reach a newer shell intact rather than being rejected at
826
+ * load by an older Core. The vocabulary the desktop understands today:
827
+ *
828
+ * - `"companion"` — mount the app's sandboxed companion surface in the dock. The
829
+ * `spec` names it: `{ "companion": "<runnable id>" }`. This is the third-party
830
+ * path: an app ships one companion UI and can surface it in the dock, the sidebar,
831
+ * or a full tab without any host code.
832
+ * - `"view"` — render one of the plugin's own [`Contributes::views`] entries inside
833
+ * the dock chrome: `{ "view": "<view id>" }`. Data-only, drawn with the host's own
834
+ * `@ryu/ui` components, so a dock panel gets the Raycast tier for free.
835
+ * - `"native"` — the shell's OWN component, registered under `<plugin>/<id>`. This is
836
+ * the migration seam for first-party apps whose panel is hand-written React driving
837
+ * their sidecar through the ext-proxy (`@ryu/browser`, `@ryu/simulator`): the
838
+ * *component* stays in the shell, but its existence, label, icon and placement stop
839
+ * being a hardcoded `TabKind` variant and become the app's own declaration, so
840
+ * disabling the app removes the tab. An unknown `<plugin>/<id>` simply renders
841
+ * nothing — a native panel is never a code channel.
842
+ *
843
+ * The full `spec` shape is owned by the shared TS vocabulary (`@ryu/app-host/views`
844
+ * `DockPanelSpec`), NOT by this contract.
845
+ */
846
+ export interface DockPanelContribution {
847
+ /**
848
+ * Optional glyph id resolved by the shell's Icon primitive (Iconify/Hugeicons).
849
+ */
850
+ icon?: string | null;
851
+ /**
852
+ * Stable id for this panel within the plugin (the dock's tab key, namespaced by
853
+ * the shell as `plugin:<pluginId>:<id>` so two apps can reuse an id).
854
+ */
855
+ id: string;
856
+ /**
857
+ * Optional ordering hint within the dock's tab-type menu (lower = earlier).
858
+ */
859
+ order?: number | null;
860
+ /**
861
+ * The render-mode discriminant (`"companion"`, `"view"`, `"native"`, …). Opaque
862
+ * to Core; an unknown member is passed through so a newer shell can render it.
863
+ */
864
+ panel: string;
865
+ /**
866
+ * Which dock the panel opens in. Defaults to [`DockPanelPlacement::Bottom`], the
867
+ * drawer a terminal-shaped panel belongs in — and falls back to it for an
868
+ * unrecognised dock too, rather than failing the whole manifest
869
+ * (see [`deserialize_dock_panel_placement`]).
870
+ */
871
+ placement?: "bottom" | "right" | "both";
872
+ /**
873
+ * The payload for the render mode (`{ "companion": … }` / `{ "view": … }` / any
874
+ * future panel capability). Opaque to Core — the desktop dock renderer interprets
875
+ * it per `panel`. Absent = the mode needs no payload (the `"native"` case).
876
+ */
877
+ spec?: {
878
+ [k: string]: unknown;
879
+ };
880
+ /**
881
+ * Tab label shown on the dock tab strip and in the "new tab" menu.
882
+ */
883
+ title: string;
884
+ }
885
+ /**
886
+ * One **app event** a plugin declares it emits (a [`Contributes::hook_events`]
887
+ * row). This is a *declaration*, not code: the event is raised at runtime by the
888
+ * plugin's own sidecar calling the `events.emit` kernel capability, and Core
889
+ * checks the emit against this table.
890
+ *
891
+ * The payload the emitter sends is delivered to every consumer as `ctx.event`, so
892
+ * [`Self::payload_example`] is the contract a consumer author reads. Keep it
893
+ * honest — it is the only description of the payload anyone gets.
894
+ */
895
+ export interface HookEventContribution {
896
+ /**
897
+ * What the event means and, critically, *when* it fires — including whether it
898
+ * can fire more than once for the same subject.
899
+ */
900
+ description?: string | null;
901
+ /**
902
+ * The fully-qualified event id: `<owning plugin id>#<event name>`, e.g.
903
+ * `@example/meetings#meeting.ended`. Validated at load against the owning
904
+ * manifest's `id`; see [`Contributes::hook_events`] for why the namespace is
905
+ * mandatory rather than conventional.
906
+ *
907
+ * Name the event after **what happened**, in the past tense, never after who
908
+ * should react to it: a consumer that renames the producer's event to suit
909
+ * itself is exactly the coupling this surface removes. The house patterns are
910
+ * `x.started` / `x.ended` / `x.failed` for a lifecycle, `x.ready` for a
911
+ * produced artifact, and `x.created` / `x.updated` / `x.deleted` for state.
912
+ */
913
+ id: string;
914
+ /**
915
+ * An example of the payload delivered as `ctx.event`. Documentation, not a
916
+ * schema: Core forwards whatever the emitter sends verbatim and validates
917
+ * nothing beyond the size cap, so this exists for the human writing a consumer.
918
+ */
919
+ payload_example?: {
920
+ [k: string]: unknown;
921
+ };
922
+ /**
923
+ * Human-readable title for the event picker (workflow trigger UI, docs).
924
+ */
925
+ title: string;
926
+ }
927
+ /**
928
+ * One **language server** a plugin declares (see [`Contributes::lsp_servers`]).
929
+ *
930
+ * Field-for-field Claude Code's language-server config, camelCase on the wire, so
931
+ * the same JSON body loads in either host. Required by Claude's spec: `command`
932
+ * and `extensionToLanguage`. Everything else is optional and defaulted here to
933
+ * Claude's documented default.
934
+ *
935
+ * # Why `command` and `extensionToLanguage` are `#[serde(default)]` anyway
936
+ *
937
+ * They are required by the SPEC, not by serde, and that is deliberate. Claude Code
938
+ * **skips** a server whose config is invalid and starts the rest; making either
939
+ * field a non-defaulted serde field would instead turn a missing one into a parse
940
+ * error on the entire [`PluginManifest`], costing the plugin every runnable,
941
+ * sidecar and tool it ships over one broken language-server entry. Defaulting them
942
+ * is what makes the per-server skip reachable at all: the manifest parses, and
943
+ * [`LspServerContribution::validate`] reports the reason at the spawn site.
944
+ *
945
+ * Unknown keys are dropped rather than rejected (no `deny_unknown_fields`
946
+ * anywhere in this file), so a field from a newer Claude release costs a plugin
947
+ * nothing.
948
+ */
949
+ export interface LspServerContribution {
950
+ /**
951
+ * Arguments passed to [`command`](LspServerContribution::command)
952
+ * (e.g. `["serve"]` for `gopls`).
953
+ */
954
+ args?: string[];
955
+ /**
956
+ * The server executable, resolved from `PATH` at spawn time (`gopls`,
957
+ * `rust-analyzer`, `typescript-language-server`, …).
958
+ *
959
+ * The plugin ships the CONFIG, never the binary. A `command` that is not on
960
+ * `PATH` is a graceful skip with a visible reason — the user is told which
961
+ * server did not start and why, and the rest of the node is unaffected.
962
+ * Defaulted to `""` so a missing one is a skipped server, not a dead manifest
963
+ * (see the type doc).
964
+ */
965
+ command?: string;
966
+ /**
967
+ * Push this server's diagnostics into the model's context after edits. Defaults
968
+ * to **true** (Claude Code parity); same `default` caveat as
969
+ * [`restart_on_crash`](LspServerContribution::restart_on_crash).
970
+ */
971
+ diagnostics?: boolean;
972
+ /**
973
+ * Extra environment variables for the server process, merged over the inherited
974
+ * environment.
975
+ */
976
+ env?: {
977
+ [k: string]: string;
978
+ };
979
+ /**
980
+ * File extension → LSP language id (`{ ".go": "go" }`) — the map that decides
981
+ * which files this server handles, and the thing two servers can collide on.
982
+ *
983
+ * Claude Code authors keys with a leading dot and in lowercase; a hand-written
984
+ * manifest will not always. Compare through
985
+ * [`normalize_lsp_extension_key`] (or read
986
+ * [`normalized_extensions`](LspServerContribution::normalized_extensions))
987
+ * rather than indexing this map directly, so `go`, `.go` and `.GO` all resolve
988
+ * to the same entry. Empty ⇒ the server claims nothing and is skipped.
989
+ */
990
+ extensionToLanguage?: {
991
+ [k: string]: string;
992
+ };
993
+ /**
994
+ * Sent verbatim as `initializationOptions` in the LSP `initialize` request.
995
+ * Opaque JSON on purpose: the shape is the individual language server's, and
996
+ * Ryu is a courier for it, not an interpreter. Absent = send none.
997
+ */
998
+ initializationOptions?: {
999
+ [k: string]: unknown;
1000
+ };
1001
+ /**
1002
+ * Cap on automatic restarts before the server is left down. Absent = the spawn
1003
+ * site's own default; meaningless when
1004
+ * [`restart_on_crash`](LspServerContribution::restart_on_crash) is false.
1005
+ */
1006
+ maxRestarts?: number | null;
1007
+ /**
1008
+ * Restart the server when it exits unexpectedly. Defaults to **true** (Claude
1009
+ * Code parity).
1010
+ *
1011
+ * Note this needs an explicit default fn: a bare `#[serde(default)]` on a
1012
+ * `bool` yields `false` and would silently invert the documented behaviour.
1013
+ * Like [`McpServerDecl::enabled`] it carries no `skip_serializing_if`, so the
1014
+ * value always ships and a reader never has to know the default.
1015
+ */
1016
+ restartOnCrash?: boolean;
1017
+ /**
1018
+ * Sent verbatim as the payload of `workspace/didChangeConfiguration` once the
1019
+ * server is initialized. Opaque for the same reason as
1020
+ * [`initialization_options`](LspServerContribution::initialization_options).
1021
+ * Absent = send nothing.
1022
+ */
1023
+ settings?: {
1024
+ [k: string]: unknown;
1025
+ };
1026
+ /**
1027
+ * Milliseconds to wait for a clean `shutdown`/`exit` before killing the
1028
+ * process. Absent = the spawn site's own default.
1029
+ *
1030
+ * That default is the one place this type knowingly parts company with Claude
1031
+ * Code, whose reference says an unset `shutdownTimeout` means **no timeout
1032
+ * applies** — it waits on a wedged server indefinitely. Ryu's spawn sites
1033
+ * impose a finite one (5s in `assets/pi-extensions/ryu-lsp.ts`, documented at
1034
+ * the constant), because Pi is spawned per session and an unbounded wait would
1035
+ * hold every teardown open behind one unresponsive server. An explicitly
1036
+ * declared value is honoured verbatim, so a config written for either host
1037
+ * still behaves identically; only the *unset* case differs.
1038
+ */
1039
+ shutdownTimeout?: number | null;
1040
+ /**
1041
+ * Milliseconds to wait for `initialize` to come back before giving up on the
1042
+ * server. Absent = the spawn site's own default.
1043
+ */
1044
+ startupTimeout?: number | null;
1045
+ /**
1046
+ * How the host talks to the server: `"stdio"` (the default, and the only
1047
+ * transport Core implements today) or `"socket"`.
1048
+ *
1049
+ * A plain `String` and not an enum, matching this file's other discriminants
1050
+ * ([`ViewContribution::view`], [`DockPanelContribution::panel`]). The reason is
1051
+ * sharper here than for those: [`DockPanelPlacement`] can afford to coerce an
1052
+ * unrecognised value to its default because a panel opening in the wrong dock is
1053
+ * cosmetic, whereas coercing an unrecognised transport to `stdio` would spawn a
1054
+ * process and then speak a protocol it does not understand. The verbatim string
1055
+ * survives instead, and the spawn site refuses what it cannot drive — see
1056
+ * [`LspTransport`] and [`LspServerContribution::transport_kind`].
1057
+ */
1058
+ transport?: string;
1059
+ /**
1060
+ * Root directory the server is rooted at. Absent (the common case) = the
1061
+ * session's workspace root, which is why this is an `Option` rather than a
1062
+ * defaulted `String`: "unset, inherit the workspace" and "explicitly rooted
1063
+ * somewhere" are different instructions.
1064
+ */
1065
+ workspaceFolder?: string | null;
1066
+ }
1067
+ /**
1068
+ * One **Pi extension** a plugin ships (a [`Contributes::pi_extensions`] row).
1069
+ *
1070
+ * Carries a path, never a body: unlike [`TurnHookContribution`] there is no inline
1071
+ * `code` twin, because nothing downstream reads the source as a string.
1072
+ */
1073
+ export interface PiExtensionContribution {
1074
+ /**
1075
+ * Optional human-facing one-liner (what the extension adds to the agent).
1076
+ */
1077
+ description?: string | null;
1078
+ /**
1079
+ * Path to the TypeScript source, relative to the plugin root — exactly
1080
+ * `pi-extensions/<name>.ts`. See [`validate_pi_extension_path`].
1081
+ */
1082
+ file: string;
1083
+ /**
1084
+ * Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`).
1085
+ *
1086
+ * Part of the materialized file name, so it is what makes one plugin's
1087
+ * extensions distinguishable from another's on disk — and why it is validated
1088
+ * with the same alphabet as an event name rather than left free-form.
1089
+ */
1090
+ id: string;
1091
+ }
1092
+ /**
1093
+ * One **settings tab** a plugin contributes (see [`Contributes::settings_tabs`]).
1094
+ *
1095
+ * A tab is EITHER declarative (`fields`, rendered by the shared plugin-settings
1096
+ * renderer against Core's preference store) OR a named `view` the shell resolves to
1097
+ * a bespoke component — for an app whose settings genuinely cannot be expressed as
1098
+ * a list of fields. A tab with neither renders as an empty section, which the
1099
+ * desktop's defensive parser drops on the floor; the loader rejects it instead so
1100
+ * the author gets told.
1101
+ */
1102
+ export interface SettingsTabContribution {
1103
+ /**
1104
+ * The declarative fields this tab renders. Empty is only legal alongside a
1105
+ * `view`.
1106
+ */
1107
+ fields?: SettingsFieldContribution[];
1108
+ /**
1109
+ * Stable id for this tab within the plugin — the settings nav routes to it and
1110
+ * the renderer keys by it. Required: the desktop's fallback (`<plugin>.settings`)
1111
+ * collides the moment a plugin declares a second tab.
1112
+ */
1113
+ id: string;
1114
+ /**
1115
+ * Which settings dialog this tab lands in. Absent/unrecognised = `node`.
1116
+ */
1117
+ scope?: "node" | "user";
1118
+ /**
1119
+ * Header label for the section. Absent = `"Settings"`, matching the renderer.
1120
+ */
1121
+ title?: string;
1122
+ /**
1123
+ * A rich settings view this app ships instead of declarative `fields`. Opaque
1124
+ * here — the settings renderer owns the vocabulary and resolves the name to a
1125
+ * component (first-party) or a sandboxed UI (third-party).
1126
+ */
1127
+ view?: string | null;
1128
+ }
1129
+ /**
1130
+ * One configurable field inside a [`SettingsTabContribution`], bound to exactly
1131
+ * one preference key.
1132
+ *
1133
+ * `pref_key` is both the storage binding (`GET/PUT /api/preferences/:key`) **and**
1134
+ * the field's identity — the renderer keys its React elements by it — so two
1135
+ * fields sharing one `pref_key` inside a tab is a bug, not a shorthand, and the
1136
+ * loader rejects it.
1137
+ *
1138
+ * The `default`/`required`/`min`/`max`/`min_length`/`max_length` block is
1139
+ * validation metadata: declaring it is how a plugin gets its settings checked at
1140
+ * *import* instead of discovering at runtime that a user typed `"maybe"` into what
1141
+ * the hook reads as a number. It is cross-checked against `type` at load, because
1142
+ * validation metadata that is silently ignored (a `min` on a toggle) is worse than
1143
+ * none — it reads as a guarantee that was never enforced.
1144
+ */
1145
+ export interface SettingsFieldContribution {
1146
+ /**
1147
+ * Default value, in the field's own JSON type (bool for a toggle, number for
1148
+ * a number, string elsewhere) — NOT the stringified form preferences are
1149
+ * stored in, so a manifest stays readable and the type is checkable.
1150
+ */
1151
+ default?: {
1152
+ [k: string]: unknown;
1153
+ };
1154
+ /**
1155
+ * Helper caption shown under the field.
1156
+ */
1157
+ description?: string | null;
1158
+ /**
1159
+ * Display label. Absent = the renderer shows the `pref_key`.
1160
+ */
1161
+ label?: string | null;
1162
+ /**
1163
+ * Inclusive upper bound for a [`SettingsFieldType::Number`].
1164
+ */
1165
+ max?: number | null;
1166
+ /**
1167
+ * Maximum length for a text/textarea value.
1168
+ */
1169
+ max_length?: number | null;
1170
+ /**
1171
+ * Inclusive lower bound for a [`SettingsFieldType::Number`].
1172
+ */
1173
+ min?: number | null;
1174
+ /**
1175
+ * Minimum length for a text/textarea value.
1176
+ */
1177
+ min_length?: number | null;
1178
+ /**
1179
+ * Choices for a [`SettingsFieldType::Select`]; required for that type and
1180
+ * inert for every other one.
1181
+ */
1182
+ options?: SettingsFieldOption[];
1183
+ /**
1184
+ * Placeholder for text / model-picker inputs.
1185
+ */
1186
+ placeholder?: string | null;
1187
+ /**
1188
+ * The preference key this field reads/writes. Required, non-empty, and
1189
+ * restricted to a path-safe alphabet (it becomes a URL path segment).
1190
+ */
1191
+ pref_key: string;
1192
+ /**
1193
+ * Whether the user must supply a value (advisory: enforced by the renderer,
1194
+ * declared here so the contract is one place).
1195
+ */
1196
+ required?: boolean;
1197
+ /**
1198
+ * Granularity for a [`SettingsFieldType::Number`] — the increment its stepper
1199
+ * moves by, and the grid a typed value must land on.
1200
+ *
1201
+ * Distinct from [`Self::min`]/[`Self::max`], which bound the range: a value can
1202
+ * sit inside the range and still be meaningless at this field's resolution
1203
+ * (`0.5` where the setting counts whole pages). The renderer enforces it, so a
1204
+ * field that declares it rejects an off-grid value rather than persisting one
1205
+ * the plugin cannot use.
1206
+ */
1207
+ step?: number | null;
1208
+ /**
1209
+ * The control to render. Absent or unrecognised = a plain text input.
1210
+ */
1211
+ type?: "text" | "textarea" | "number" | "toggle" | "select" | "model_picker" | "agent_picker" | "secret";
1212
+ }
1213
+ /**
1214
+ * One app-registered **sidebar button** — a single nav row (the button-shaped
1215
+ * sibling of [`SidebarSectionContribution`]). No live list: just a label/icon and a
1216
+ * client route the shell opens with `openTab`. Migrates hardcoded header-chrome
1217
+ * buttons (e.g. Memory) to the owning app.
1218
+ */
1219
+ export interface SidebarButtonContribution {
1220
+ /**
1221
+ * Optional glyph id resolved by the shell's Icon primitive.
1222
+ */
1223
+ icon?: string | null;
1224
+ /**
1225
+ * Stable id for this button within the plugin.
1226
+ */
1227
+ id: string;
1228
+ /**
1229
+ * Optional placement hint among the sidebar buttons.
1230
+ */
1231
+ order?: number | null;
1232
+ /**
1233
+ * The client route this button opens (e.g. `"/library/memory"`).
1234
+ */
1235
+ target: string;
1236
+ /**
1237
+ * Button label.
1238
+ */
1239
+ title: string;
1240
+ }
1241
+ /**
1242
+ * One app-registered **sidebar section** — a header plus a live list of rows the
1243
+ * desktop's compact sidebar renderer draws (the app-owned replacement for the
1244
+ * hardcoded Canvas/Whiteboard/Meetings sections). A typed envelope around an opaque
1245
+ * `spec` (the `SidebarSectionSpec` in `@ryu/app-host/views`: a `ViewSource` for the
1246
+ * rows, an `itemTarget` route template for `openTab`, optional `itemActions` and a
1247
+ * `create` action). Core stores it verbatim and tags it with the owning `plugin` id;
1248
+ * the `spec` stays opaque so a new section capability is a renderer change, not a
1249
+ * Core change.
1250
+ */
1251
+ export interface SidebarSectionContribution {
1252
+ /**
1253
+ * Optional glyph id resolved by the shell's Icon primitive (Iconify/Hugeicons).
1254
+ */
1255
+ icon?: string | null;
1256
+ /**
1257
+ * Stable id for this section within the plugin (namespaced into the shell's
1258
+ * section key as `plugin:<pluginId>:<id>`).
1259
+ */
1260
+ id: string;
1261
+ /**
1262
+ * Optional placement hint among the sidebar sections (lower = higher up).
1263
+ */
1264
+ order?: number | null;
1265
+ /**
1266
+ * The opaque section spec (source/itemTarget/itemActions/create). Interpreted by
1267
+ * the desktop renderer, never by Core. Absent = a header with no rows.
1268
+ */
1269
+ spec?: {
1270
+ [k: string]: unknown;
1271
+ };
1272
+ /**
1273
+ * Header label shown in the sidebar and the Customize dialog.
1274
+ */
1275
+ title: string;
1276
+ }
1277
+ /**
1278
+ * One **tool filter**: a fully-qualified tool id a plugin wants withheld from the
1279
+ * model's offered tool list.
1280
+ *
1281
+ * Tools are namespaced `<server>__<tool>` (e.g. `browser__navigate`), so `tool`
1282
+ * must carry the namespace — a bare `navigate` would be ambiguous across servers
1283
+ * and is rejected at load. A **trailing** `*` is a prefix wildcard, which is how a
1284
+ * plugin withholds a whole server (`shadow__*`); it is the only wildcard position
1285
+ * allowed, because an interior or leading `*` invites a pattern that silently
1286
+ * matches far more than the author pictured.
1287
+ *
1288
+ * This type is declaration + validation only. The filter is **applied** where the
1289
+ * tool list is assembled for the model (the MCP offer site in
1290
+ * `apps/core/src/sidecar/mcp`), which calls [`ToolFilterContribution::matches`] so
1291
+ * the wildcard rule has exactly one implementation. Hiding a tool from the model
1292
+ * is not a security boundary — it does not revoke the capability, it only stops the
1293
+ * tool being advertised; enforcement stays with permissions and grants.
1294
+ */
1295
+ export interface ToolFilterContribution {
1296
+ /**
1297
+ * Why the plugin hides it — surfaced in the plugin's listing so a user can see
1298
+ * what a plugin is removing from the model's view before installing it.
1299
+ */
1300
+ reason?: string | null;
1301
+ /**
1302
+ * Fully-qualified tool id (`<server>__<tool>`), optionally ending in `*` to
1303
+ * hide every tool whose id starts with the preceding prefix.
1304
+ */
1305
+ tool: string;
1306
+ }
413
1307
  /**
414
1308
  * A server-side chat turn hook contributed by a plugin. The `code` is a JS body
415
1309
  * run in the plugin sandbox with `ctx` (the turn context) and `host` (the
416
1310
  * capability bridge: `host.sideModel`, `host.storage`, `host.log`) in scope; it
417
1311
  * returns a directive (`{kind:"none"}` | `{kind:"note",text}` |
418
1312
  * `{kind:"continue",text}`). See Core's `plugin_host`.
1313
+ *
1314
+ * The body is authored as a **file** ([`code_file`]) and hydrated into [`code`]
1315
+ * at parse time — see [`PluginManifest::hydrate_code_files`] for why the two
1316
+ * fields are a source-form/wire-form pair rather than alternatives.
1317
+ *
1318
+ * [`code`]: Self::code
1319
+ * [`code_file`]: Self::code_file
419
1320
  */
420
1321
  export interface TurnHookContribution {
421
1322
  /**
422
1323
  * The JS hook body executed in the sandbox (returns a directive).
1324
+ *
1325
+ * Empty in a **source** manifest that declares [`Self::code_file`] instead;
1326
+ * [`PluginManifest::hydrate_code_files`] fills it in before any consumer sees
1327
+ * the manifest, and [`PluginManifest::validate`] refuses a manifest where it
1328
+ * is still empty. Every read site therefore keeps reading exactly this field.
423
1329
  */
424
- code: string;
1330
+ code?: string;
1331
+ /**
1332
+ * Path to the file holding the hook body, relative to the plugin root
1333
+ * (`hooks/<name>.js`) — the authoring form. Mutually exclusive with
1334
+ * [`Self::code`]; see [`PluginManifest::hydrate_code_files`].
1335
+ */
1336
+ code_file?: string | null;
425
1337
  /**
426
1338
  * Stable id for this hook (for logging/audit), unique within the plugin.
427
1339
  */
@@ -546,6 +1458,51 @@ export interface EnginesReq {
546
1458
  */
547
1459
  ryu: string;
548
1460
  }
1461
+ /**
1462
+ * One declarative **stdio MCP server** a plugin registers (see
1463
+ * [`PluginManifest::mcp_servers`]).
1464
+ *
1465
+ * This is the manifest-side, dependency-free mirror of Core's runtime
1466
+ * `McpServerConfig`: pure data (schemars/serde only) so it can live in
1467
+ * kernel-contracts, with Core lowering it into its registry type on enable. A
1468
+ * server is spawned per request as `command args…` (stdio); `command_env` lets
1469
+ * the manifest name an env var Core resolves to an absolute binary path
1470
+ * (e.g. `RYU_GHOST_BIN`) so a downloaded `~/.ryu/bin` binary can override the
1471
+ * bare `command`.
1472
+ */
1473
+ export interface McpServerDecl {
1474
+ /**
1475
+ * Arguments passed to the command.
1476
+ */
1477
+ args?: string[];
1478
+ /**
1479
+ * Executable to spawn (e.g. `npx`, an absolute path, or a `~/.ryu/bin` name).
1480
+ */
1481
+ command: string;
1482
+ /**
1483
+ * Optional env var whose value, when set, OVERRIDES [`command`] with an
1484
+ * absolute binary path. Lets a plugin ship a bare `command` that Core repoints
1485
+ * at a profile-specific downloaded binary. Absent ⇒ use `command` verbatim.
1486
+ *
1487
+ * [`command`]: McpServerDecl::command
1488
+ */
1489
+ command_env?: string | null;
1490
+ /**
1491
+ * Optional human description for the MCP listing endpoint.
1492
+ */
1493
+ description?: string | null;
1494
+ /**
1495
+ * When false, the server is registered but skipped by list/call. Defaults to
1496
+ * true so a bare `{ command }` entry just works.
1497
+ */
1498
+ enabled?: boolean;
1499
+ /**
1500
+ * Extra environment variables for the server process.
1501
+ */
1502
+ env?: {
1503
+ [k: string]: string;
1504
+ };
1505
+ }
549
1506
  /**
550
1507
  * The single, typed, **deny-by-default** permission set a plugin manifest
551
1508
  * declares, lowered by Core to every sandbox backend.
@@ -614,6 +1571,12 @@ export interface ProvidesEntry {
614
1571
  * this against their [`Requires::capabilities`].
615
1572
  */
616
1573
  capability: string;
1574
+ /**
1575
+ * Preferred pick among the providers of a [`Self::selectable`] capability when
1576
+ * the user has set no override. At most one provider per capability may declare
1577
+ * it. Meaningless (and ignored) on a non-selectable capability.
1578
+ */
1579
+ default?: boolean;
617
1580
  /**
618
1581
  * The grant a consumer must hold (Gateway-approved) to invoke this capability
619
1582
  * via the broker. Absent = no extra grant beyond declaring the edge.
@@ -625,12 +1588,62 @@ export interface ProvidesEntry {
625
1588
  * cross-validates that the named sidecar declares a matching route.
626
1589
  */
627
1590
  route?: string | null;
1591
+ /**
1592
+ * Opt in to the **selectable** flavour: many providers of this capability may
1593
+ * be enabled at once and the user *picks* one, exactly like a local engine.
1594
+ *
1595
+ * A non-selectable capability (the original, strict flavour used by `rag` /
1596
+ * `engines`) treats a second enabled provider as an explicit
1597
+ * `BindingError::Ambiguous` refusal. A selectable one resolves deterministically
1598
+ * instead: user override > sole provider > the provider declaring
1599
+ * [`Self::default_provider`] > lexicographically-lowest provider id. The pick is
1600
+ * a pure function of the candidate set, so the disable-safety reconstruction
1601
+ * argument in Core's binding registry is unchanged.
1602
+ *
1603
+ * Selectability is a property of the *capability*, so every provider of a given
1604
+ * capability must agree on the flag; the loader rejects a mixed declaration.
1605
+ */
1606
+ selectable?: boolean;
628
1607
  /**
629
1608
  * The local `name` of one of this manifest's declared `sidecars` that serves
630
1609
  * the capability. The loader cross-validates it exists. Absent = an in-process
631
1610
  * capability with no dedicated sidecar (the broker declines to proxy it).
632
1611
  */
633
1612
  sidecar?: string | null;
1613
+ /**
1614
+ * WHAT this provider acts on, when the capability controls a machine or an
1615
+ * environment rather than answering a query.
1616
+ *
1617
+ * Exists because "swap the provider" quietly means two different things.
1618
+ * Swapping `web.search` from exa to tavily changes who answers; the question is
1619
+ * the same. Swapping `computer.control` from ghost to bytebot changes **which
1620
+ * computer gets typed on** — ghost drives the machine Ryu runs on, bytebot
1621
+ * drives the desktop `bytebotd` runs on (a containerized Linux desktop in the
1622
+ * shipped product). A picker that renders those two swaps identically is
1623
+ * telling the user something false, and until this field existed the
1624
+ * distinction lived only in a prose `description` that nothing structured
1625
+ * could read.
1626
+ *
1627
+ * Absent = not applicable or unspecified. That is the honest default for the
1628
+ * capabilities where locality is meaningless (`web.search`, `memory`, `rag`),
1629
+ * and it is deliberately NOT [`ProviderTarget::LocalMachine`]: defaulting to
1630
+ * "this machine" would silently mislabel every future hosted provider that
1631
+ * forgets to declare it.
1632
+ */
1633
+ target?: ProviderTarget | null;
1634
+ /**
1635
+ * Capability **verb → this provider's tool** bindings, the seam that keeps the
1636
+ * model-visible tool surface stable across a swap.
1637
+ *
1638
+ * The key is a canonical verb from the host's capability verb table (e.g.
1639
+ * `"web__search"`); the value names the provider's own registered tool plus the
1640
+ * argument/response mapping into the canonical shape. A provider that omits a
1641
+ * verb simply does not serve it — the facade reports the verb unavailable
1642
+ * rather than guessing.
1643
+ */
1644
+ tools?: {
1645
+ [k: string]: CapabilityToolBinding;
1646
+ };
634
1647
  /**
635
1648
  * The capability's own semver version (independent of the plugin version), so
636
1649
  * a consumer's [`CapabilityReq::min_version`] floor can be checked against the
@@ -638,6 +1651,198 @@ export interface ProvidesEntry {
638
1651
  */
639
1652
  version: string;
640
1653
  }
1654
+ /**
1655
+ * How one capability **verb** maps onto a concrete provider tool.
1656
+ *
1657
+ * The facade tool (`web__search`, `browser__navigate`, …) is registered by the host
1658
+ * from its canonical verb table; at call time it resolves the capability's bound
1659
+ * provider, reads this binding, renames the arguments, re-enters tool dispatch on
1660
+ * [`Self::tool`], and maps the response back. Swapping the provider therefore
1661
+ * changes neither the tool id nor its schema.
1662
+ */
1663
+ export interface CapabilityToolBinding {
1664
+ /**
1665
+ * Optional provider-shipped ADAPTER: JavaScript that maps this verb onto the
1666
+ * provider's tool when the shapes are too far apart for the declarative fields
1667
+ * above to bridge.
1668
+ *
1669
+ * The declarative path ([`Self::args`] … [`Self::response`]) stays the default
1670
+ * and covers the ~80% of providers that are a rename plus a field map: no code
1671
+ * review, no sandbox, no supply-chain surface, and a third party ships one file.
1672
+ * But some provider shapes no amount of JSON can express — an async job API that
1673
+ * must be polled (`POST /crawl` → job id → `GET /crawl/{id}`), a token vocabulary
1674
+ * that needs per-provider normalization, a body that must read a `pref:` value.
1675
+ * Growing the grammar one vendor quirk at a time pushed provider-specific logic
1676
+ * into shared kernel code; an adapter puts it back in the provider's own manifest.
1677
+ *
1678
+ * Present = the adapter REPLACES the declarative mapping for this verb: it
1679
+ * receives the canonical arguments and returns the canonical result, and
1680
+ * [`Self::args`] / [`Self::arg_template`] / [`Self::arg_clamp`] / [`Self::response`]
1681
+ * are not applied (the adapter is doing that job). [`Self::tool`] still names the
1682
+ * target and is still the ONLY tool the adapter can reach.
1683
+ */
1684
+ adapter?: CapabilityAdapter | null;
1685
+ /**
1686
+ * Per-argument numeric limits this provider can actually honour, keyed by the
1687
+ * **canonical** argument name (before any rename).
1688
+ *
1689
+ * Exists because canonical schemas describe what agents may ask for, while
1690
+ * providers differ in what they accept: `web__search.limit` allows up to 100,
1691
+ * but Brave's `count` maxes at 20. Without this, selecting Brave turns a
1692
+ * perfectly valid `limit: 50` into an upstream 4xx — the swap stops being
1693
+ * transparent, which is the entire point of the facade. Clamping is the right
1694
+ * resolution rather than erroring: the caller asked for "up to N", and fewer
1695
+ * results is a normal outcome, whereas a failed search is not.
1696
+ */
1697
+ arg_clamp?: {
1698
+ [k: string]: ArgBounds;
1699
+ };
1700
+ /**
1701
+ * Constant arguments merged into every call (provider-specific knobs the
1702
+ * canonical schema does not expose, e.g. `{"search_depth": "advanced"}`).
1703
+ */
1704
+ arg_defaults?: {
1705
+ [k: string]: unknown;
1706
+ };
1707
+ /**
1708
+ * A request-body TEMPLATE this provider needs, with `{canonical_arg}`
1709
+ * placeholders substituted from the call.
1710
+ *
1711
+ * `args` renames flat keys and `[]` wraps a scalar in an array; neither can build
1712
+ * a NESTED shape. Real APIs need them: Mem0's write endpoint takes
1713
+ * `messages: [{role, content}]`, so without a template the whole write half of
1714
+ * that provider is unbindable — which is precisely the gap that made Ryu's
1715
+ * memory bridges inert while Hermes, which writes per-provider adapter CODE, had
1716
+ * none. This closes it declaratively instead of admitting code per provider.
1717
+ *
1718
+ * A string that is EXACTLY `"{arg}"` is replaced by that argument's value with
1719
+ * its JSON type preserved (`5` stays a number); a string merely CONTAINING
1720
+ * `{arg}` interpolates as text. An argument consumed by the template is not also
1721
+ * passed through, so it cannot appear twice under two names.
1722
+ */
1723
+ arg_template?: {
1724
+ [k: string]: unknown;
1725
+ };
1726
+ /**
1727
+ * Canonical argument name → this provider's argument name. A canonical argument
1728
+ * with no entry is passed through under its own name; map it to the empty string
1729
+ * to drop it (the provider cannot express it).
1730
+ */
1731
+ args?: {
1732
+ [k: string]: string;
1733
+ };
1734
+ /**
1735
+ * Optional response normalization into the canonical result shape. Absent = the
1736
+ * provider's output is returned verbatim under `{ provider, raw }`.
1737
+ */
1738
+ response?: CapabilityResponseMap | null;
1739
+ /**
1740
+ * The provider's own fully-qualified tool id (e.g. `"exa__search"`,
1741
+ * `"app__firecrawl_scrape"`) that implements this verb.
1742
+ */
1743
+ tool: string;
1744
+ }
1745
+ /**
1746
+ * Provider-shipped JavaScript that maps one capability verb onto one provider tool.
1747
+ *
1748
+ * Runs in the SAME Deno sandbox as an `inline_deno` plugin tool, under the same
1749
+ * [`crate`-level] grant model: the providing plugin must hold `tool:execute`, so
1750
+ * shipping code is a visible, approvable act rather than a silent one.
1751
+ *
1752
+ * The program is handed:
1753
+ * - `input` — the canonical verb arguments, after layer defaults are applied.
1754
+ * - `defaults` — the provider's resolved `arg_defaults`, including any `pref:`
1755
+ * tokens already looked up. This is what lets an adapter read per-install
1756
+ * configuration a template could not (`arg_template` expands from the CALLER's
1757
+ * arguments, so it can never see a resolved preference).
1758
+ * - `callTool(args)` — invokes the provider's own [`CapabilityToolBinding::tool`]
1759
+ * and resolves to its raw response. It takes NO tool id: the target is fixed by
1760
+ * the manifest, so sandboxed code cannot redirect the call at another tool. An
1761
+ * adapter therefore grants no authority the declarative path did not already
1762
+ * grant — it is strictly the same single re-entry, expressed as code.
1763
+ *
1764
+ * It returns the canonical result shape, which the facade passes through unchanged.
1765
+ *
1766
+ * **Bounded by the sandbox wall-clock.** A run gets `DEFAULT_DEADLINE_SECS` of
1767
+ * active compute, and time spent awaiting a tool call counts against it. An
1768
+ * adapter that polls an async job must therefore treat "still running" as a normal
1769
+ * outcome to report, not something to wait out.
1770
+ */
1771
+ export interface CapabilityAdapter {
1772
+ /**
1773
+ * The adapter body. Evaluated as the tail of a sandbox program that has already
1774
+ * bound `input`, `defaults`, `callTool` and `callNamed`; it `return`s the
1775
+ * canonical result.
1776
+ *
1777
+ * Empty in a **source** manifest that declares [`Self::code_file`] instead;
1778
+ * [`PluginManifest::hydrate_code_files`] fills it in at parse time and
1779
+ * [`PluginManifest::validate`] refuses a manifest where it is still empty.
1780
+ */
1781
+ code?: string;
1782
+ /**
1783
+ * Path to the file holding the adapter body, relative to the plugin root
1784
+ * (`adapters/<verb>.js`) — the authoring form. Mutually exclusive with
1785
+ * [`Self::code`]; see [`PluginManifest::hydrate_code_files`].
1786
+ */
1787
+ code_file?: string | null;
1788
+ /**
1789
+ * ADDITIONAL provider tool ids this adapter may call, beyond
1790
+ * [`CapabilityToolBinding::tool`], reachable from the body as
1791
+ * `callNamed(id, args)`.
1792
+ *
1793
+ * Exists because a whole class of real APIs is two calls, not one: an async job
1794
+ * API starts work at one endpoint and reads the result from another
1795
+ * (`POST /crawl` → job id → `GET /crawl/{id}`). A single-tool adapter cannot
1796
+ * express that, so those providers would stay unbindable — the gap that
1797
+ * excluded every async API from every layer.
1798
+ *
1799
+ * This is an ALLOWLIST fixed by the manifest and checked host-side: a name not
1800
+ * listed here (and not [`CapabilityToolBinding::tool`]) is refused. Sandboxed
1801
+ * code chooses only *among* tools the provider declared, never a tool of its
1802
+ * own — which is what keeps the id-taking form from becoming an escalation seam.
1803
+ */
1804
+ tools?: string[];
1805
+ }
1806
+ /**
1807
+ * Inclusive numeric bounds a provider can honour for one canonical argument.
1808
+ * Integers, not floats. Every clampable canonical argument is a COUNT — result
1809
+ * limits, crawl depth, page caps — so `i64` is the honest type, and it keeps the
1810
+ * whole manifest tree `Eq` (a float would force `PartialEq`-only all the way up
1811
+ * through `ProvidesEntry` and `PluginManifest`) while avoiding float comparison.
1812
+ */
1813
+ export interface ArgBounds {
1814
+ /**
1815
+ * Largest value the provider accepts. Absent = no upper bound.
1816
+ */
1817
+ max?: number | null;
1818
+ /**
1819
+ * Smallest value the provider accepts. Absent = no lower bound.
1820
+ */
1821
+ min?: number | null;
1822
+ }
1823
+ /**
1824
+ * Normalizes one provider's response into the capability's canonical shape.
1825
+ *
1826
+ * Deliberately a flat rename table rather than a general transform language: the
1827
+ * canonical shapes are small and list-of-records shaped, and a manifest that can
1828
+ * run arbitrary extraction logic is a much larger trust surface.
1829
+ */
1830
+ export interface CapabilityResponseMap {
1831
+ /**
1832
+ * Canonical per-item field name → the provider's field name (dotted paths
1833
+ * allowed). Fields with no entry are dropped from the canonical item but remain
1834
+ * available under the item's `raw` key.
1835
+ */
1836
+ fields?: {
1837
+ [k: string]: string;
1838
+ };
1839
+ /**
1840
+ * Dotted path to the provider's result array within its response (e.g.
1841
+ * `"results"`, `"data.items"`). Absent = the response itself is the array, or —
1842
+ * when it is not an array — a single record.
1843
+ */
1844
+ results?: string | null;
1845
+ }
641
1846
  /**
642
1847
  * `requires` block — the plugin's **plugin-to-plugin** dependencies.
643
1848
  *
@@ -719,7 +1924,7 @@ export interface CapabilityReq {
719
1924
  min_version?: string | null;
720
1925
  }
721
1926
  /**
722
- * A single Runnable entry inside a `plugin.json` manifest.
1927
+ * A single Runnable entry inside a `manifest.json` manifest.
723
1928
  *
724
1929
  * Each entry carries the identity fields from [`crate::runnable::RunnableMeta`]
725
1930
  * plus an optional typed config blob. The `kind` field drives which config shape
@@ -945,6 +2150,20 @@ export interface SidecarSpec {
945
2150
  * How Core obtains and runs the process.
946
2151
  */
947
2152
  process: BinarySpec | ExternalRuntimeConfig1 | LocalProcessSpec | NodeProcessSpec;
2153
+ /**
2154
+ * Optional **model-provider** declaration: when present, this sidecar serves an
2155
+ * OpenAI-compatible endpoint and Core registers it as a selectable provider once
2156
+ * the process reports healthy, then deregisters it when the plugin is disabled or
2157
+ * uninstalled. This is what makes a third-party *auth bridge* possible without a
2158
+ * Core change: the plugin performs its own login/refresh, serves `/v1`, and
2159
+ * declares that fact here. Absent = the sidecar is not a model provider.
2160
+ *
2161
+ * A sidecar cannot self-register: it holds only `RYU_EXT_TOKEN` (scoped to the
2162
+ * ext-proxy hop and `/api/host/*`), and the host-RPC vocabulary has no
2163
+ * provider-registration method. Registration is therefore Core-side, driven by
2164
+ * this declaration.
2165
+ */
2166
+ provides_provider?: ProviderRegistrationSpec | null;
948
2167
  }
949
2168
  /**
950
2169
  * Declares the host-API grant subset a sidecar *process* may exercise via the
@@ -1069,6 +2288,47 @@ export interface LocalProcessSpec {
1069
2288
  export interface NodeProcessSpec {
1070
2289
  kind: "node";
1071
2290
  }
2291
+ /**
2292
+ * Declares that a [`SidecarSpec`] serves an OpenAI-compatible model endpoint Core
2293
+ * should register as a provider while the sidecar is healthy.
2294
+ *
2295
+ * Security posture: the declared [`id`] is validated against the built-in provider
2296
+ * table at registration and a collision is REFUSED, never merged. Without that guard
2297
+ * a plugin could claim a built-in id (`openai-codex`, `anthropic`) and silently
2298
+ * redirect the user's subscription traffic — and their live bearer token — to an
2299
+ * attacker-controlled `baseUrl`. Core also stamps [`OWNER_FIELD`] into the written
2300
+ * entry so deregistration can only ever remove an entry this plugin created.
2301
+ *
2302
+ * [`id`]: ProviderRegistrationSpec::id
2303
+ * [`OWNER_FIELD`]: crate::schema::PROVIDER_OWNER_FIELD
2304
+ */
2305
+ export interface ProviderRegistrationSpec {
2306
+ /**
2307
+ * Pi `api` type the endpoint speaks. Defaults to `"openai-completions"`.
2308
+ */
2309
+ api?: string | null;
2310
+ /**
2311
+ * Path prefix appended to `http://127.0.0.1:<port>` to form the provider's
2312
+ * `baseUrl`. Defaults to `"/v1"`.
2313
+ */
2314
+ base_path?: string | null;
2315
+ /**
2316
+ * Provider id as it appears in the model picker. Must not collide with a built-in
2317
+ * provider id, and must be a safe single token (lowercase alphanumerics, `-`, `_`).
2318
+ */
2319
+ id: string;
2320
+ /**
2321
+ * Human-readable label for the picker. Defaults to [`id`] when absent.
2322
+ *
2323
+ * [`id`]: ProviderRegistrationSpec::id
2324
+ */
2325
+ label?: string | null;
2326
+ /**
2327
+ * Optional model ids to seed the entry with, for an endpoint whose `GET /models`
2328
+ * discovery is unavailable or slow. Absent = rely on discovery.
2329
+ */
2330
+ models?: string[];
2331
+ }
1072
2332
  /**
1073
2333
  * One [`PluginManifest::surfaces`] entry: the support level plus an optional UI
1074
2334
  * descriptor the surface shell resolves (opaque here — pure data).
@@ -1083,7 +2343,7 @@ export interface SurfaceEntry {
1083
2343
  /**
1084
2344
  * How much of the plugin this surface supports.
1085
2345
  */
1086
- support?: "full" | "limited" | "list" | "commands" | "none";
2346
+ support?: "full" | "limited" | "list" | "commands" | "none" | "unknown";
1087
2347
  /**
1088
2348
  * Optional surface-specific UI descriptor (bundle id, mount point, …),
1089
2349
  * interpreted by the surface's app host. Opaque to the contract.