@ryuhq/sdk 0.0.5 → 0.0.17

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
  *
@@ -22,9 +52,9 @@
22
52
  export type Surface = "gateway" | "core" | "desktop" | "island" | "mobile" | "extension" | "web" | "cli";
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,145 @@ 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
+ * App-registered **workspace dock panels** — a tab in the desktop's bottom or
492
+ * right dock (Terminal / Code Review / Browser / Simulator live there today).
493
+ * This is the seam that lets an app OWN its dock tab instead of the shell
494
+ * welding the app into a closed `TabKind` union: `@ryu/browser` and
495
+ * `@ryu/simulator` are apps, and their tabs are contributions, not enum
496
+ * variants. Self-contained + opaque `spec` (see [`DockPanelContribution`]), so a
497
+ * new panel capability needs no Core change; served + tagged with the owning
498
+ * `plugin` id at `GET /api/plugins/contributions`.
499
+ */
500
+ dock_panels?: DockPanelContribution[];
501
+ /**
502
+ * **App events this plugin emits** — the *provider* half of the hook system,
503
+ * and the mirror image of [`Contributes::turn_hooks`] (the *consumer* half).
504
+ *
505
+ * Core's own hook phases (`post_assistant_turn`, `pre_tool_use`, `context`, …)
506
+ * are a closed set built into `plugin_host`, so before this surface existed a
507
+ * plugin could only react to things happening *in a chat turn*. An app that
508
+ * owns a real-world lifecycle — a meeting ending, a workflow run failing, an
509
+ * alert firing — had no way to let anything else react to it. That forced the
510
+ * classic anti-pattern: every consumer polls the producer's HTTP routes, and
511
+ * every new integration is bespoke wiring between two apps that must both be
512
+ * changed.
513
+ *
514
+ * Declaring an event here makes it a first-class hook phase. Any other plugin
515
+ * consumes it by naming it in a `turn_hooks[].on`, and any workflow consumes it
516
+ * with an `event` trigger — neither the producer nor Core learns anything about
517
+ * the consumer. Apps therefore both **provide** and **consume** over one
518
+ * mechanism.
519
+ *
520
+ * # Ids are namespaced, and that is what makes collisions impossible
521
+ *
522
+ * Every id MUST be `<owning plugin id>#<event name>` — the owning half is
523
+ * checked against the manifest's own `id` at load, and the name half is
524
+ * `[a-z0-9][a-z0-9._-]*`. Because a Core phase name never contains `/`, an app
525
+ * literally cannot declare an event that shadows one, no reserved-word list
526
+ * required. It is also why the emit path can authorize purely from the
527
+ * manifest: the caller's authenticated plugin id must be the id in the event
528
+ * name, so an app can only ever emit its **own** events.
529
+ *
530
+ * # Core-interpreted, so a typed struct
531
+ *
532
+ * Core reads this table to authorize emits and to serve the event catalog, so
533
+ * per this type's own doc comment it gets a typed struct rather than opaque
534
+ * JSON. It names event strings rather than runnable ids, so it is
535
+ * **self-contained** and stays out of [`Contributes::referenced_ids`].
536
+ */
537
+ hook_events?: HookEventContribution[];
538
+ /**
539
+ * **Language servers** the plugin declares, keyed by server name — the
540
+ * agent-neutral mirror of Claude Code's `.lsp.json` / `lspServers`, so a config
541
+ * written for either host loads in the other:
542
+ *
543
+ * ```json
544
+ * "lsp_servers": {
545
+ * "go": { "command": "gopls", "args": ["serve"], "extensionToLanguage": { ".go": "go" } }
546
+ * }
547
+ * ```
548
+ *
549
+ * Only the container key is Ryu's (`lsp_servers`, snake_case like every sibling
550
+ * here); every key INSIDE a server entry is Claude's own camelCase spelling
551
+ * verbatim, because that body is what actually travels between the two hosts.
552
+ * No `lspServers` alias is accepted on purpose. `lsp_servers` — this exact
553
+ * spelling — is registered in the SDK's zod mirror (`ContributesSchema` in
554
+ * `packages/sdk/src/manifest.ts`), and that mirror STRIPS every key it does not
555
+ * list. An alias would therefore parse here and be silently deleted at
556
+ * `ryu pack` time, before the manifest is signed, which is a worse failure than
557
+ * a key that never parsed at all. One spelling, registered in both places.
558
+ *
559
+ * The plugin ships CONFIG ONLY, never the server binary — `command` is resolved
560
+ * from `PATH` at spawn time and a missing binary is a visible skip, not a load
561
+ * error. Core spawns and supervises these processes itself, so unlike the
562
+ * client-rendered surfaces above this one is fully typed
563
+ * ([`LspServerContribution`]) and is NOT served from
564
+ * `GET /api/plugins/contributions`; it is gathered at the spawn site, the same
565
+ * disposition as [`Contributes::tool_filters`].
566
+ *
567
+ * # Ordering is part of the contract
568
+ *
569
+ * Registration is **first-registration-wins per file extension**: if two enabled
570
+ * servers both claim `.go`, the first one registered owns it, the others never
571
+ * start for that extension, and the spawn site warns naming the owner. That rule
572
+ * is only reproducible if iteration order is, so this is a [`BTreeMap`] — it
573
+ * iterates lexicographically by server key, never in hash order and never in
574
+ * JSON authoring order. The full resolved invariant across a node is
575
+ * **(plugin enable order, then server key ascending)**.
576
+ *
577
+ * Note this makes the tie-break deterministic, not byte-identical to Claude
578
+ * Code's, which falls out of JS object insertion order. Two servers fighting
579
+ * over one extension is a misconfiguration in either host; what matters is that
580
+ * the same node always resolves it the same way and says who won.
581
+ */
582
+ lsp_servers?: {
583
+ [k: string]: LspServerContribution;
584
+ };
343
585
  /**
344
586
  * Gateway policies the plugin contributes (referenced by runnable id).
345
587
  */
@@ -347,24 +589,71 @@ export interface Contributes {
347
589
  /**
348
590
  * Declarative settings tabs the plugin contributes (model pickers, text
349
591
  * fields bound to preference keys). Served + rendered the same way.
592
+ *
593
+ * The **contract** for each entry is [`SettingsTabContribution`] — that is what
594
+ * the published JSON Schema advertises (`schemars(with = …)`) and what the
595
+ * loader holds every manifest to at import (see `validate_settings_tab`), so a
596
+ * malformed tab is rejected with a diagnostic instead of reaching the desktop
597
+ * and being silently dropped by the renderer's defensive parser.
598
+ *
599
+ * The *stored* type stays `serde_json::Value` on purpose. `GET
600
+ * /api/plugins/contributions` tags each entry in place with its owning `plugin`
601
+ * id and forwards it verbatim; deserializing into the struct here would silently
602
+ * DROP any key this Core build does not know about, so a desktop newer than the
603
+ * node it talks to would lose exactly the fields it was shipped to render. Parse
604
+ * once at the validation chokepoint, forward the original bytes.
605
+ */
606
+ settings_tabs?: SettingsTabContribution[];
607
+ /**
608
+ * App-registered sidebar **buttons** — a single nav row (e.g. Memory →
609
+ * `/library/memory`). The button-shaped sibling of [`Contributes::sidebar_sections`]
610
+ * (no live list, just a label/icon + a client route). See [`SidebarButtonContribution`].
611
+ */
612
+ sidebar_buttons?: SidebarButtonContribution[];
613
+ /**
614
+ * App-registered sidebar **sections** — a header plus a live list of rows the
615
+ * shell fetches from a declared Core `/api/` path. Lets an app own its sidebar
616
+ * section (Canvas/Whiteboard/Meetings recent-doc lists) instead of the shell
617
+ * hardcoding it. Self-contained + opaque `spec` (see [`SidebarSectionContribution`]),
618
+ * so a new section capability needs no Core change; served + tagged with the
619
+ * owning `plugin` id at `GET /api/plugins/contributions`.
350
620
  */
351
- settings_tabs?: unknown[];
621
+ sidebar_sections?: SidebarSectionContribution[];
352
622
  /**
353
623
  * Slash commands the plugin contributes (e.g. `/goal`). The desktop maps the
354
624
  * command to a `plugin_flags`/message action; the plugin's turn hook reads
355
625
  * the resulting message. Served + rendered the same way.
356
626
  */
357
627
  slash_commands?: unknown[];
628
+ /**
629
+ * Tools this plugin wants **hidden** from the model's offered tool list —
630
+ * the declarative half of a tool firewall (see [`ToolFilterContribution`]).
631
+ *
632
+ * Purely declarative here: this contract defines and validates the shape, and
633
+ * the filter is applied where tools are offered to the model. Like
634
+ * [`Contributes::turn_hooks`] this is self-contained (the ids name tools from
635
+ * *other* plugins/servers by design — hiding your own tool is just not
636
+ * declaring it), so it is NOT cross-validated against `runnables`.
637
+ */
638
+ tool_filters?: ToolFilterContribution[];
358
639
  /**
359
640
  * Callable tools the plugin contributes (referenced by runnable id).
360
641
  */
361
642
  tools?: ContributionId[];
362
643
  /**
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.
644
+ * Hooks the plugin contributes — server-side logic that runs at a hook
645
+ * boundary and returns a directive. These are **self-contained** (they carry
646
+ * their own inline `code`), so they are NOT cross-validated against
647
+ * `runnables` like the id-reference surfaces above; the Core `plugin_host`
648
+ * runtime executes them in the sandbox.
649
+ *
650
+ * The field name is historical. It originally held only *chat* turn
651
+ * boundaries (`post_assistant_turn`, `pre_user_turn`); a hook's `on` is now
652
+ * any hook phase, including an **app event** another plugin declared in its
653
+ * [`Contributes::hook_events`] (`@example/meetings#meeting.ended`). It is
654
+ * deliberately NOT renamed: `turn_hooks` is load-bearing in every packaged
655
+ * manifest, the published JSON Schema, the SDK's TS mirror and the loader's
656
+ * invariant tests, and the rename would buy nothing but churn.
368
657
  */
369
658
  turn_hooks?: TurnHookContribution[];
370
659
  /**
@@ -410,18 +699,507 @@ export interface ContributionId {
410
699
  */
411
700
  title?: string | null;
412
701
  }
702
+ /**
703
+ * One app-registered **workspace dock panel** — a tab in the desktop's bottom or
704
+ * right dock (see [`Contributes::dock_panels`]).
705
+ *
706
+ * The dock sibling of [`ViewContribution`] / [`SidebarSectionContribution`]: a typed
707
+ * envelope (`id` / `title` / `icon` / `placement`) around an OPAQUE description of
708
+ * what the tab renders. Core stores it verbatim, tags it with the owning `plugin` id
709
+ * at `GET /api/plugins/contributions`, and never interprets `panel` or `spec` — so a
710
+ * new panel capability is a renderer change, never a Core change.
711
+ *
712
+ * # The `panel` vocabulary
713
+ *
714
+ * `panel` is the render-mode discriminant the desktop's dock renderer switches on.
715
+ * It is a plain `String` (not an enum) for the same reason [`ViewContribution::view`]
716
+ * is: an unknown member must reach a newer shell intact rather than being rejected at
717
+ * load by an older Core. The vocabulary the desktop understands today:
718
+ *
719
+ * - `"companion"` — mount the app's sandboxed companion surface in the dock. The
720
+ * `spec` names it: `{ "companion": "<runnable id>" }`. This is the third-party
721
+ * path: an app ships one companion UI and can surface it in the dock, the sidebar,
722
+ * or a full tab without any host code.
723
+ * - `"view"` — render one of the plugin's own [`Contributes::views`] entries inside
724
+ * the dock chrome: `{ "view": "<view id>" }`. Data-only, drawn with the host's own
725
+ * `@ryu/ui` components, so a dock panel gets the Raycast tier for free.
726
+ * - `"native"` — the shell's OWN component, registered under `<plugin>/<id>`. This is
727
+ * the migration seam for first-party apps whose panel is hand-written React driving
728
+ * their sidecar through the ext-proxy (`@ryu/browser`, `@ryu/simulator`): the
729
+ * *component* stays in the shell, but its existence, label, icon and placement stop
730
+ * being a hardcoded `TabKind` variant and become the app's own declaration, so
731
+ * disabling the app removes the tab. An unknown `<plugin>/<id>` simply renders
732
+ * nothing — a native panel is never a code channel.
733
+ *
734
+ * The full `spec` shape is owned by the shared TS vocabulary (`@ryu/app-host/views`
735
+ * `DockPanelSpec`), NOT by this contract.
736
+ */
737
+ export interface DockPanelContribution {
738
+ /**
739
+ * Optional glyph id resolved by the shell's Icon primitive (Iconify/Hugeicons).
740
+ */
741
+ icon?: string | null;
742
+ /**
743
+ * Stable id for this panel within the plugin (the dock's tab key, namespaced by
744
+ * the shell as `plugin:<pluginId>:<id>` so two apps can reuse an id).
745
+ */
746
+ id: string;
747
+ /**
748
+ * Optional ordering hint within the dock's tab-type menu (lower = earlier).
749
+ */
750
+ order?: number | null;
751
+ /**
752
+ * The render-mode discriminant (`"companion"`, `"view"`, `"native"`, …). Opaque
753
+ * to Core; an unknown member is passed through so a newer shell can render it.
754
+ */
755
+ panel: string;
756
+ /**
757
+ * Which dock the panel opens in. Defaults to [`DockPanelPlacement::Bottom`], the
758
+ * drawer a terminal-shaped panel belongs in — and falls back to it for an
759
+ * unrecognised dock too, rather than failing the whole manifest
760
+ * (see [`deserialize_dock_panel_placement`]).
761
+ */
762
+ placement?: "bottom" | "right" | "both";
763
+ /**
764
+ * The payload for the render mode (`{ "companion": … }` / `{ "view": … }` / any
765
+ * future panel capability). Opaque to Core — the desktop dock renderer interprets
766
+ * it per `panel`. Absent = the mode needs no payload (the `"native"` case).
767
+ */
768
+ spec?: {
769
+ [k: string]: unknown;
770
+ };
771
+ /**
772
+ * Tab label shown on the dock tab strip and in the "new tab" menu.
773
+ */
774
+ title: string;
775
+ }
776
+ /**
777
+ * One **app event** a plugin declares it emits (a [`Contributes::hook_events`]
778
+ * row). This is a *declaration*, not code: the event is raised at runtime by the
779
+ * plugin's own sidecar calling the `events.emit` kernel capability, and Core
780
+ * checks the emit against this table.
781
+ *
782
+ * The payload the emitter sends is delivered to every consumer as `ctx.event`, so
783
+ * [`Self::payload_example`] is the contract a consumer author reads. Keep it
784
+ * honest — it is the only description of the payload anyone gets.
785
+ */
786
+ export interface HookEventContribution {
787
+ /**
788
+ * What the event means and, critically, *when* it fires — including whether it
789
+ * can fire more than once for the same subject.
790
+ */
791
+ description?: string | null;
792
+ /**
793
+ * The fully-qualified event id: `<owning plugin id>#<event name>`, e.g.
794
+ * `@example/meetings#meeting.ended`. Validated at load against the owning
795
+ * manifest's `id`; see [`Contributes::hook_events`] for why the namespace is
796
+ * mandatory rather than conventional.
797
+ *
798
+ * Name the event after **what happened**, in the past tense, never after who
799
+ * should react to it: a consumer that renames the producer's event to suit
800
+ * itself is exactly the coupling this surface removes. The house patterns are
801
+ * `x.started` / `x.ended` / `x.failed` for a lifecycle, `x.ready` for a
802
+ * produced artifact, and `x.created` / `x.updated` / `x.deleted` for state.
803
+ */
804
+ id: string;
805
+ /**
806
+ * An example of the payload delivered as `ctx.event`. Documentation, not a
807
+ * schema: Core forwards whatever the emitter sends verbatim and validates
808
+ * nothing beyond the size cap, so this exists for the human writing a consumer.
809
+ */
810
+ payload_example?: {
811
+ [k: string]: unknown;
812
+ };
813
+ /**
814
+ * Human-readable title for the event picker (workflow trigger UI, docs).
815
+ */
816
+ title: string;
817
+ }
818
+ /**
819
+ * One **language server** a plugin declares (see [`Contributes::lsp_servers`]).
820
+ *
821
+ * Field-for-field Claude Code's language-server config, camelCase on the wire, so
822
+ * the same JSON body loads in either host. Required by Claude's spec: `command`
823
+ * and `extensionToLanguage`. Everything else is optional and defaulted here to
824
+ * Claude's documented default.
825
+ *
826
+ * # Why `command` and `extensionToLanguage` are `#[serde(default)]` anyway
827
+ *
828
+ * They are required by the SPEC, not by serde, and that is deliberate. Claude Code
829
+ * **skips** a server whose config is invalid and starts the rest; making either
830
+ * field a non-defaulted serde field would instead turn a missing one into a parse
831
+ * error on the entire [`PluginManifest`], costing the plugin every runnable,
832
+ * sidecar and tool it ships over one broken language-server entry. Defaulting them
833
+ * is what makes the per-server skip reachable at all: the manifest parses, and
834
+ * [`LspServerContribution::validate`] reports the reason at the spawn site.
835
+ *
836
+ * Unknown keys are dropped rather than rejected (no `deny_unknown_fields`
837
+ * anywhere in this file), so a field from a newer Claude release costs a plugin
838
+ * nothing.
839
+ */
840
+ export interface LspServerContribution {
841
+ /**
842
+ * Arguments passed to [`command`](LspServerContribution::command)
843
+ * (e.g. `["serve"]` for `gopls`).
844
+ */
845
+ args?: string[];
846
+ /**
847
+ * The server executable, resolved from `PATH` at spawn time (`gopls`,
848
+ * `rust-analyzer`, `typescript-language-server`, …).
849
+ *
850
+ * The plugin ships the CONFIG, never the binary. A `command` that is not on
851
+ * `PATH` is a graceful skip with a visible reason — the user is told which
852
+ * server did not start and why, and the rest of the node is unaffected.
853
+ * Defaulted to `""` so a missing one is a skipped server, not a dead manifest
854
+ * (see the type doc).
855
+ */
856
+ command?: string;
857
+ /**
858
+ * Push this server's diagnostics into the model's context after edits. Defaults
859
+ * to **true** (Claude Code parity); same `default` caveat as
860
+ * [`restart_on_crash`](LspServerContribution::restart_on_crash).
861
+ */
862
+ diagnostics?: boolean;
863
+ /**
864
+ * Extra environment variables for the server process, merged over the inherited
865
+ * environment.
866
+ */
867
+ env?: {
868
+ [k: string]: string;
869
+ };
870
+ /**
871
+ * File extension → LSP language id (`{ ".go": "go" }`) — the map that decides
872
+ * which files this server handles, and the thing two servers can collide on.
873
+ *
874
+ * Claude Code authors keys with a leading dot and in lowercase; a hand-written
875
+ * manifest will not always. Compare through
876
+ * [`normalize_lsp_extension_key`] (or read
877
+ * [`normalized_extensions`](LspServerContribution::normalized_extensions))
878
+ * rather than indexing this map directly, so `go`, `.go` and `.GO` all resolve
879
+ * to the same entry. Empty ⇒ the server claims nothing and is skipped.
880
+ */
881
+ extensionToLanguage?: {
882
+ [k: string]: string;
883
+ };
884
+ /**
885
+ * Sent verbatim as `initializationOptions` in the LSP `initialize` request.
886
+ * Opaque JSON on purpose: the shape is the individual language server's, and
887
+ * Ryu is a courier for it, not an interpreter. Absent = send none.
888
+ */
889
+ initializationOptions?: {
890
+ [k: string]: unknown;
891
+ };
892
+ /**
893
+ * Cap on automatic restarts before the server is left down. Absent = the spawn
894
+ * site's own default; meaningless when
895
+ * [`restart_on_crash`](LspServerContribution::restart_on_crash) is false.
896
+ */
897
+ maxRestarts?: number | null;
898
+ /**
899
+ * Restart the server when it exits unexpectedly. Defaults to **true** (Claude
900
+ * Code parity).
901
+ *
902
+ * Note this needs an explicit default fn: a bare `#[serde(default)]` on a
903
+ * `bool` yields `false` and would silently invert the documented behaviour.
904
+ * Like [`McpServerDecl::enabled`] it carries no `skip_serializing_if`, so the
905
+ * value always ships and a reader never has to know the default.
906
+ */
907
+ restartOnCrash?: boolean;
908
+ /**
909
+ * Sent verbatim as the payload of `workspace/didChangeConfiguration` once the
910
+ * server is initialized. Opaque for the same reason as
911
+ * [`initialization_options`](LspServerContribution::initialization_options).
912
+ * Absent = send nothing.
913
+ */
914
+ settings?: {
915
+ [k: string]: unknown;
916
+ };
917
+ /**
918
+ * Milliseconds to wait for a clean `shutdown`/`exit` before killing the
919
+ * process. Absent = the spawn site's own default.
920
+ *
921
+ * That default is the one place this type knowingly parts company with Claude
922
+ * Code, whose reference says an unset `shutdownTimeout` means **no timeout
923
+ * applies** — it waits on a wedged server indefinitely. Ryu's spawn sites
924
+ * impose a finite one (5s in `assets/pi-extensions/ryu-lsp.ts`, documented at
925
+ * the constant), because Pi is spawned per session and an unbounded wait would
926
+ * hold every teardown open behind one unresponsive server. An explicitly
927
+ * declared value is honoured verbatim, so a config written for either host
928
+ * still behaves identically; only the *unset* case differs.
929
+ */
930
+ shutdownTimeout?: number | null;
931
+ /**
932
+ * Milliseconds to wait for `initialize` to come back before giving up on the
933
+ * server. Absent = the spawn site's own default.
934
+ */
935
+ startupTimeout?: number | null;
936
+ /**
937
+ * How the host talks to the server: `"stdio"` (the default, and the only
938
+ * transport Core implements today) or `"socket"`.
939
+ *
940
+ * A plain `String` and not an enum, matching this file's other discriminants
941
+ * ([`ViewContribution::view`], [`DockPanelContribution::panel`]). The reason is
942
+ * sharper here than for those: [`DockPanelPlacement`] can afford to coerce an
943
+ * unrecognised value to its default because a panel opening in the wrong dock is
944
+ * cosmetic, whereas coercing an unrecognised transport to `stdio` would spawn a
945
+ * process and then speak a protocol it does not understand. The verbatim string
946
+ * survives instead, and the spawn site refuses what it cannot drive — see
947
+ * [`LspTransport`] and [`LspServerContribution::transport_kind`].
948
+ */
949
+ transport?: string;
950
+ /**
951
+ * Root directory the server is rooted at. Absent (the common case) = the
952
+ * session's workspace root, which is why this is an `Option` rather than a
953
+ * defaulted `String`: "unset, inherit the workspace" and "explicitly rooted
954
+ * somewhere" are different instructions.
955
+ */
956
+ workspaceFolder?: string | null;
957
+ }
958
+ /**
959
+ * One **settings tab** a plugin contributes (see [`Contributes::settings_tabs`]).
960
+ *
961
+ * A tab is EITHER declarative (`fields`, rendered by the shared plugin-settings
962
+ * renderer against Core's preference store) OR a named `view` the shell resolves to
963
+ * a bespoke component — for an app whose settings genuinely cannot be expressed as
964
+ * a list of fields. A tab with neither renders as an empty section, which the
965
+ * desktop's defensive parser drops on the floor; the loader rejects it instead so
966
+ * the author gets told.
967
+ */
968
+ export interface SettingsTabContribution {
969
+ /**
970
+ * The declarative fields this tab renders. Empty is only legal alongside a
971
+ * `view`.
972
+ */
973
+ fields?: SettingsFieldContribution[];
974
+ /**
975
+ * Stable id for this tab within the plugin — the settings nav routes to it and
976
+ * the renderer keys by it. Required: the desktop's fallback (`<plugin>.settings`)
977
+ * collides the moment a plugin declares a second tab.
978
+ */
979
+ id: string;
980
+ /**
981
+ * Which settings dialog this tab lands in. Absent/unrecognised = `node`.
982
+ */
983
+ scope?: "node" | "user";
984
+ /**
985
+ * Header label for the section. Absent = `"Settings"`, matching the renderer.
986
+ */
987
+ title?: string;
988
+ /**
989
+ * A rich settings view this app ships instead of declarative `fields`. Opaque
990
+ * here — the settings renderer owns the vocabulary and resolves the name to a
991
+ * component (first-party) or a sandboxed UI (third-party).
992
+ */
993
+ view?: string | null;
994
+ }
995
+ /**
996
+ * One configurable field inside a [`SettingsTabContribution`], bound to exactly
997
+ * one preference key.
998
+ *
999
+ * `pref_key` is both the storage binding (`GET/PUT /api/preferences/:key`) **and**
1000
+ * the field's identity — the renderer keys its React elements by it — so two
1001
+ * fields sharing one `pref_key` inside a tab is a bug, not a shorthand, and the
1002
+ * loader rejects it.
1003
+ *
1004
+ * The `default`/`required`/`min`/`max`/`min_length`/`max_length` block is
1005
+ * validation metadata: declaring it is how a plugin gets its settings checked at
1006
+ * *import* instead of discovering at runtime that a user typed `"maybe"` into what
1007
+ * the hook reads as a number. It is cross-checked against `type` at load, because
1008
+ * validation metadata that is silently ignored (a `min` on a toggle) is worse than
1009
+ * none — it reads as a guarantee that was never enforced.
1010
+ */
1011
+ export interface SettingsFieldContribution {
1012
+ /**
1013
+ * Default value, in the field's own JSON type (bool for a toggle, number for
1014
+ * a number, string elsewhere) — NOT the stringified form preferences are
1015
+ * stored in, so a manifest stays readable and the type is checkable.
1016
+ */
1017
+ default?: {
1018
+ [k: string]: unknown;
1019
+ };
1020
+ /**
1021
+ * Helper caption shown under the field.
1022
+ */
1023
+ description?: string | null;
1024
+ /**
1025
+ * Display label. Absent = the renderer shows the `pref_key`.
1026
+ */
1027
+ label?: string | null;
1028
+ /**
1029
+ * Inclusive upper bound for a [`SettingsFieldType::Number`].
1030
+ */
1031
+ max?: number | null;
1032
+ /**
1033
+ * Maximum length for a text/textarea value.
1034
+ */
1035
+ max_length?: number | null;
1036
+ /**
1037
+ * Inclusive lower bound for a [`SettingsFieldType::Number`].
1038
+ */
1039
+ min?: number | null;
1040
+ /**
1041
+ * Minimum length for a text/textarea value.
1042
+ */
1043
+ min_length?: number | null;
1044
+ /**
1045
+ * Choices for a [`SettingsFieldType::Select`]; required for that type and
1046
+ * inert for every other one.
1047
+ */
1048
+ options?: SettingsFieldOption[];
1049
+ /**
1050
+ * Placeholder for text / model-picker inputs.
1051
+ */
1052
+ placeholder?: string | null;
1053
+ /**
1054
+ * The preference key this field reads/writes. Required, non-empty, and
1055
+ * restricted to a path-safe alphabet (it becomes a URL path segment).
1056
+ */
1057
+ pref_key: string;
1058
+ /**
1059
+ * Whether the user must supply a value (advisory: enforced by the renderer,
1060
+ * declared here so the contract is one place).
1061
+ */
1062
+ required?: boolean;
1063
+ /**
1064
+ * Granularity for a [`SettingsFieldType::Number`] — the increment its stepper
1065
+ * moves by, and the grid a typed value must land on.
1066
+ *
1067
+ * Distinct from [`Self::min`]/[`Self::max`], which bound the range: a value can
1068
+ * sit inside the range and still be meaningless at this field's resolution
1069
+ * (`0.5` where the setting counts whole pages). The renderer enforces it, so a
1070
+ * field that declares it rejects an off-grid value rather than persisting one
1071
+ * the plugin cannot use.
1072
+ */
1073
+ step?: number | null;
1074
+ /**
1075
+ * The control to render. Absent or unrecognised = a plain text input.
1076
+ */
1077
+ type?: "text" | "textarea" | "number" | "toggle" | "select" | "model_picker" | "agent_picker" | "secret";
1078
+ }
1079
+ /**
1080
+ * One app-registered **sidebar button** — a single nav row (the button-shaped
1081
+ * sibling of [`SidebarSectionContribution`]). No live list: just a label/icon and a
1082
+ * client route the shell opens with `openTab`. Migrates hardcoded header-chrome
1083
+ * buttons (e.g. Memory) to the owning app.
1084
+ */
1085
+ export interface SidebarButtonContribution {
1086
+ /**
1087
+ * Optional glyph id resolved by the shell's Icon primitive.
1088
+ */
1089
+ icon?: string | null;
1090
+ /**
1091
+ * Stable id for this button within the plugin.
1092
+ */
1093
+ id: string;
1094
+ /**
1095
+ * Optional placement hint among the sidebar buttons.
1096
+ */
1097
+ order?: number | null;
1098
+ /**
1099
+ * The client route this button opens (e.g. `"/library/memory"`).
1100
+ */
1101
+ target: string;
1102
+ /**
1103
+ * Button label.
1104
+ */
1105
+ title: string;
1106
+ }
1107
+ /**
1108
+ * One app-registered **sidebar section** — a header plus a live list of rows the
1109
+ * desktop's compact sidebar renderer draws (the app-owned replacement for the
1110
+ * hardcoded Canvas/Whiteboard/Meetings sections). A typed envelope around an opaque
1111
+ * `spec` (the `SidebarSectionSpec` in `@ryu/app-host/views`: a `ViewSource` for the
1112
+ * rows, an `itemTarget` route template for `openTab`, optional `itemActions` and a
1113
+ * `create` action). Core stores it verbatim and tags it with the owning `plugin` id;
1114
+ * the `spec` stays opaque so a new section capability is a renderer change, not a
1115
+ * Core change.
1116
+ */
1117
+ export interface SidebarSectionContribution {
1118
+ /**
1119
+ * Optional glyph id resolved by the shell's Icon primitive (Iconify/Hugeicons).
1120
+ */
1121
+ icon?: string | null;
1122
+ /**
1123
+ * Stable id for this section within the plugin (namespaced into the shell's
1124
+ * section key as `plugin:<pluginId>:<id>`).
1125
+ */
1126
+ id: string;
1127
+ /**
1128
+ * Optional placement hint among the sidebar sections (lower = higher up).
1129
+ */
1130
+ order?: number | null;
1131
+ /**
1132
+ * The opaque section spec (source/itemTarget/itemActions/create). Interpreted by
1133
+ * the desktop renderer, never by Core. Absent = a header with no rows.
1134
+ */
1135
+ spec?: {
1136
+ [k: string]: unknown;
1137
+ };
1138
+ /**
1139
+ * Header label shown in the sidebar and the Customize dialog.
1140
+ */
1141
+ title: string;
1142
+ }
1143
+ /**
1144
+ * One **tool filter**: a fully-qualified tool id a plugin wants withheld from the
1145
+ * model's offered tool list.
1146
+ *
1147
+ * Tools are namespaced `<server>__<tool>` (e.g. `browser__navigate`), so `tool`
1148
+ * must carry the namespace — a bare `navigate` would be ambiguous across servers
1149
+ * and is rejected at load. A **trailing** `*` is a prefix wildcard, which is how a
1150
+ * plugin withholds a whole server (`shadow__*`); it is the only wildcard position
1151
+ * allowed, because an interior or leading `*` invites a pattern that silently
1152
+ * matches far more than the author pictured.
1153
+ *
1154
+ * This type is declaration + validation only. The filter is **applied** where the
1155
+ * tool list is assembled for the model (the MCP offer site in
1156
+ * `apps/core/src/sidecar/mcp`), which calls [`ToolFilterContribution::matches`] so
1157
+ * the wildcard rule has exactly one implementation. Hiding a tool from the model
1158
+ * is not a security boundary — it does not revoke the capability, it only stops the
1159
+ * tool being advertised; enforcement stays with permissions and grants.
1160
+ */
1161
+ export interface ToolFilterContribution {
1162
+ /**
1163
+ * Why the plugin hides it — surfaced in the plugin's listing so a user can see
1164
+ * what a plugin is removing from the model's view before installing it.
1165
+ */
1166
+ reason?: string | null;
1167
+ /**
1168
+ * Fully-qualified tool id (`<server>__<tool>`), optionally ending in `*` to
1169
+ * hide every tool whose id starts with the preceding prefix.
1170
+ */
1171
+ tool: string;
1172
+ }
413
1173
  /**
414
1174
  * A server-side chat turn hook contributed by a plugin. The `code` is a JS body
415
1175
  * run in the plugin sandbox with `ctx` (the turn context) and `host` (the
416
1176
  * capability bridge: `host.sideModel`, `host.storage`, `host.log`) in scope; it
417
1177
  * returns a directive (`{kind:"none"}` | `{kind:"note",text}` |
418
1178
  * `{kind:"continue",text}`). See Core's `plugin_host`.
1179
+ *
1180
+ * The body is authored as a **file** ([`code_file`]) and hydrated into [`code`]
1181
+ * at parse time — see [`PluginManifest::hydrate_code_files`] for why the two
1182
+ * fields are a source-form/wire-form pair rather than alternatives.
1183
+ *
1184
+ * [`code`]: Self::code
1185
+ * [`code_file`]: Self::code_file
419
1186
  */
420
1187
  export interface TurnHookContribution {
421
1188
  /**
422
1189
  * The JS hook body executed in the sandbox (returns a directive).
1190
+ *
1191
+ * Empty in a **source** manifest that declares [`Self::code_file`] instead;
1192
+ * [`PluginManifest::hydrate_code_files`] fills it in before any consumer sees
1193
+ * the manifest, and [`PluginManifest::validate`] refuses a manifest where it
1194
+ * is still empty. Every read site therefore keeps reading exactly this field.
1195
+ */
1196
+ code?: string;
1197
+ /**
1198
+ * Path to the file holding the hook body, relative to the plugin root
1199
+ * (`hooks/<name>.js`) — the authoring form. Mutually exclusive with
1200
+ * [`Self::code`]; see [`PluginManifest::hydrate_code_files`].
423
1201
  */
424
- code: string;
1202
+ code_file?: string | null;
425
1203
  /**
426
1204
  * Stable id for this hook (for logging/audit), unique within the plugin.
427
1205
  */
@@ -546,6 +1324,51 @@ export interface EnginesReq {
546
1324
  */
547
1325
  ryu: string;
548
1326
  }
1327
+ /**
1328
+ * One declarative **stdio MCP server** a plugin registers (see
1329
+ * [`PluginManifest::mcp_servers`]).
1330
+ *
1331
+ * This is the manifest-side, dependency-free mirror of Core's runtime
1332
+ * `McpServerConfig`: pure data (schemars/serde only) so it can live in
1333
+ * kernel-contracts, with Core lowering it into its registry type on enable. A
1334
+ * server is spawned per request as `command args…` (stdio); `command_env` lets
1335
+ * the manifest name an env var Core resolves to an absolute binary path
1336
+ * (e.g. `RYU_GHOST_BIN`) so a downloaded `~/.ryu/bin` binary can override the
1337
+ * bare `command`.
1338
+ */
1339
+ export interface McpServerDecl {
1340
+ /**
1341
+ * Arguments passed to the command.
1342
+ */
1343
+ args?: string[];
1344
+ /**
1345
+ * Executable to spawn (e.g. `npx`, an absolute path, or a `~/.ryu/bin` name).
1346
+ */
1347
+ command: string;
1348
+ /**
1349
+ * Optional env var whose value, when set, OVERRIDES [`command`] with an
1350
+ * absolute binary path. Lets a plugin ship a bare `command` that Core repoints
1351
+ * at a profile-specific downloaded binary. Absent ⇒ use `command` verbatim.
1352
+ *
1353
+ * [`command`]: McpServerDecl::command
1354
+ */
1355
+ command_env?: string | null;
1356
+ /**
1357
+ * Optional human description for the MCP listing endpoint.
1358
+ */
1359
+ description?: string | null;
1360
+ /**
1361
+ * When false, the server is registered but skipped by list/call. Defaults to
1362
+ * true so a bare `{ command }` entry just works.
1363
+ */
1364
+ enabled?: boolean;
1365
+ /**
1366
+ * Extra environment variables for the server process.
1367
+ */
1368
+ env?: {
1369
+ [k: string]: string;
1370
+ };
1371
+ }
549
1372
  /**
550
1373
  * The single, typed, **deny-by-default** permission set a plugin manifest
551
1374
  * declares, lowered by Core to every sandbox backend.
@@ -614,6 +1437,12 @@ export interface ProvidesEntry {
614
1437
  * this against their [`Requires::capabilities`].
615
1438
  */
616
1439
  capability: string;
1440
+ /**
1441
+ * Preferred pick among the providers of a [`Self::selectable`] capability when
1442
+ * the user has set no override. At most one provider per capability may declare
1443
+ * it. Meaningless (and ignored) on a non-selectable capability.
1444
+ */
1445
+ default?: boolean;
617
1446
  /**
618
1447
  * The grant a consumer must hold (Gateway-approved) to invoke this capability
619
1448
  * via the broker. Absent = no extra grant beyond declaring the edge.
@@ -625,12 +1454,62 @@ export interface ProvidesEntry {
625
1454
  * cross-validates that the named sidecar declares a matching route.
626
1455
  */
627
1456
  route?: string | null;
1457
+ /**
1458
+ * Opt in to the **selectable** flavour: many providers of this capability may
1459
+ * be enabled at once and the user *picks* one, exactly like a local engine.
1460
+ *
1461
+ * A non-selectable capability (the original, strict flavour used by `rag` /
1462
+ * `engines`) treats a second enabled provider as an explicit
1463
+ * `BindingError::Ambiguous` refusal. A selectable one resolves deterministically
1464
+ * instead: user override > sole provider > the provider declaring
1465
+ * [`Self::default_provider`] > lexicographically-lowest provider id. The pick is
1466
+ * a pure function of the candidate set, so the disable-safety reconstruction
1467
+ * argument in Core's binding registry is unchanged.
1468
+ *
1469
+ * Selectability is a property of the *capability*, so every provider of a given
1470
+ * capability must agree on the flag; the loader rejects a mixed declaration.
1471
+ */
1472
+ selectable?: boolean;
628
1473
  /**
629
1474
  * The local `name` of one of this manifest's declared `sidecars` that serves
630
1475
  * the capability. The loader cross-validates it exists. Absent = an in-process
631
1476
  * capability with no dedicated sidecar (the broker declines to proxy it).
632
1477
  */
633
1478
  sidecar?: string | null;
1479
+ /**
1480
+ * WHAT this provider acts on, when the capability controls a machine or an
1481
+ * environment rather than answering a query.
1482
+ *
1483
+ * Exists because "swap the provider" quietly means two different things.
1484
+ * Swapping `web.search` from exa to tavily changes who answers; the question is
1485
+ * the same. Swapping `computer.control` from ghost to bytebot changes **which
1486
+ * computer gets typed on** — ghost drives the machine Ryu runs on, bytebot
1487
+ * drives the desktop `bytebotd` runs on (a containerized Linux desktop in the
1488
+ * shipped product). A picker that renders those two swaps identically is
1489
+ * telling the user something false, and until this field existed the
1490
+ * distinction lived only in a prose `description` that nothing structured
1491
+ * could read.
1492
+ *
1493
+ * Absent = not applicable or unspecified. That is the honest default for the
1494
+ * capabilities where locality is meaningless (`web.search`, `memory`, `rag`),
1495
+ * and it is deliberately NOT [`ProviderTarget::LocalMachine`]: defaulting to
1496
+ * "this machine" would silently mislabel every future hosted provider that
1497
+ * forgets to declare it.
1498
+ */
1499
+ target?: ProviderTarget | null;
1500
+ /**
1501
+ * Capability **verb → this provider's tool** bindings, the seam that keeps the
1502
+ * model-visible tool surface stable across a swap.
1503
+ *
1504
+ * The key is a canonical verb from the host's capability verb table (e.g.
1505
+ * `"web__search"`); the value names the provider's own registered tool plus the
1506
+ * argument/response mapping into the canonical shape. A provider that omits a
1507
+ * verb simply does not serve it — the facade reports the verb unavailable
1508
+ * rather than guessing.
1509
+ */
1510
+ tools?: {
1511
+ [k: string]: CapabilityToolBinding;
1512
+ };
634
1513
  /**
635
1514
  * The capability's own semver version (independent of the plugin version), so
636
1515
  * a consumer's [`CapabilityReq::min_version`] floor can be checked against the
@@ -638,6 +1517,198 @@ export interface ProvidesEntry {
638
1517
  */
639
1518
  version: string;
640
1519
  }
1520
+ /**
1521
+ * How one capability **verb** maps onto a concrete provider tool.
1522
+ *
1523
+ * The facade tool (`web__search`, `browser__navigate`, …) is registered by the host
1524
+ * from its canonical verb table; at call time it resolves the capability's bound
1525
+ * provider, reads this binding, renames the arguments, re-enters tool dispatch on
1526
+ * [`Self::tool`], and maps the response back. Swapping the provider therefore
1527
+ * changes neither the tool id nor its schema.
1528
+ */
1529
+ export interface CapabilityToolBinding {
1530
+ /**
1531
+ * Optional provider-shipped ADAPTER: JavaScript that maps this verb onto the
1532
+ * provider's tool when the shapes are too far apart for the declarative fields
1533
+ * above to bridge.
1534
+ *
1535
+ * The declarative path ([`Self::args`] … [`Self::response`]) stays the default
1536
+ * and covers the ~80% of providers that are a rename plus a field map: no code
1537
+ * review, no sandbox, no supply-chain surface, and a third party ships one file.
1538
+ * But some provider shapes no amount of JSON can express — an async job API that
1539
+ * must be polled (`POST /crawl` → job id → `GET /crawl/{id}`), a token vocabulary
1540
+ * that needs per-provider normalization, a body that must read a `pref:` value.
1541
+ * Growing the grammar one vendor quirk at a time pushed provider-specific logic
1542
+ * into shared kernel code; an adapter puts it back in the provider's own manifest.
1543
+ *
1544
+ * Present = the adapter REPLACES the declarative mapping for this verb: it
1545
+ * receives the canonical arguments and returns the canonical result, and
1546
+ * [`Self::args`] / [`Self::arg_template`] / [`Self::arg_clamp`] / [`Self::response`]
1547
+ * are not applied (the adapter is doing that job). [`Self::tool`] still names the
1548
+ * target and is still the ONLY tool the adapter can reach.
1549
+ */
1550
+ adapter?: CapabilityAdapter | null;
1551
+ /**
1552
+ * Per-argument numeric limits this provider can actually honour, keyed by the
1553
+ * **canonical** argument name (before any rename).
1554
+ *
1555
+ * Exists because canonical schemas describe what agents may ask for, while
1556
+ * providers differ in what they accept: `web__search.limit` allows up to 100,
1557
+ * but Brave's `count` maxes at 20. Without this, selecting Brave turns a
1558
+ * perfectly valid `limit: 50` into an upstream 4xx — the swap stops being
1559
+ * transparent, which is the entire point of the facade. Clamping is the right
1560
+ * resolution rather than erroring: the caller asked for "up to N", and fewer
1561
+ * results is a normal outcome, whereas a failed search is not.
1562
+ */
1563
+ arg_clamp?: {
1564
+ [k: string]: ArgBounds;
1565
+ };
1566
+ /**
1567
+ * Constant arguments merged into every call (provider-specific knobs the
1568
+ * canonical schema does not expose, e.g. `{"search_depth": "advanced"}`).
1569
+ */
1570
+ arg_defaults?: {
1571
+ [k: string]: unknown;
1572
+ };
1573
+ /**
1574
+ * A request-body TEMPLATE this provider needs, with `{canonical_arg}`
1575
+ * placeholders substituted from the call.
1576
+ *
1577
+ * `args` renames flat keys and `[]` wraps a scalar in an array; neither can build
1578
+ * a NESTED shape. Real APIs need them: Mem0's write endpoint takes
1579
+ * `messages: [{role, content}]`, so without a template the whole write half of
1580
+ * that provider is unbindable — which is precisely the gap that made Ryu's
1581
+ * memory bridges inert while Hermes, which writes per-provider adapter CODE, had
1582
+ * none. This closes it declaratively instead of admitting code per provider.
1583
+ *
1584
+ * A string that is EXACTLY `"{arg}"` is replaced by that argument's value with
1585
+ * its JSON type preserved (`5` stays a number); a string merely CONTAINING
1586
+ * `{arg}` interpolates as text. An argument consumed by the template is not also
1587
+ * passed through, so it cannot appear twice under two names.
1588
+ */
1589
+ arg_template?: {
1590
+ [k: string]: unknown;
1591
+ };
1592
+ /**
1593
+ * Canonical argument name → this provider's argument name. A canonical argument
1594
+ * with no entry is passed through under its own name; map it to the empty string
1595
+ * to drop it (the provider cannot express it).
1596
+ */
1597
+ args?: {
1598
+ [k: string]: string;
1599
+ };
1600
+ /**
1601
+ * Optional response normalization into the canonical result shape. Absent = the
1602
+ * provider's output is returned verbatim under `{ provider, raw }`.
1603
+ */
1604
+ response?: CapabilityResponseMap | null;
1605
+ /**
1606
+ * The provider's own fully-qualified tool id (e.g. `"exa__search"`,
1607
+ * `"app__firecrawl_scrape"`) that implements this verb.
1608
+ */
1609
+ tool: string;
1610
+ }
1611
+ /**
1612
+ * Provider-shipped JavaScript that maps one capability verb onto one provider tool.
1613
+ *
1614
+ * Runs in the SAME Deno sandbox as an `inline_deno` plugin tool, under the same
1615
+ * [`crate`-level] grant model: the providing plugin must hold `tool:execute`, so
1616
+ * shipping code is a visible, approvable act rather than a silent one.
1617
+ *
1618
+ * The program is handed:
1619
+ * - `input` — the canonical verb arguments, after layer defaults are applied.
1620
+ * - `defaults` — the provider's resolved `arg_defaults`, including any `pref:`
1621
+ * tokens already looked up. This is what lets an adapter read per-install
1622
+ * configuration a template could not (`arg_template` expands from the CALLER's
1623
+ * arguments, so it can never see a resolved preference).
1624
+ * - `callTool(args)` — invokes the provider's own [`CapabilityToolBinding::tool`]
1625
+ * and resolves to its raw response. It takes NO tool id: the target is fixed by
1626
+ * the manifest, so sandboxed code cannot redirect the call at another tool. An
1627
+ * adapter therefore grants no authority the declarative path did not already
1628
+ * grant — it is strictly the same single re-entry, expressed as code.
1629
+ *
1630
+ * It returns the canonical result shape, which the facade passes through unchanged.
1631
+ *
1632
+ * **Bounded by the sandbox wall-clock.** A run gets `DEFAULT_DEADLINE_SECS` of
1633
+ * active compute, and time spent awaiting a tool call counts against it. An
1634
+ * adapter that polls an async job must therefore treat "still running" as a normal
1635
+ * outcome to report, not something to wait out.
1636
+ */
1637
+ export interface CapabilityAdapter {
1638
+ /**
1639
+ * The adapter body. Evaluated as the tail of a sandbox program that has already
1640
+ * bound `input`, `defaults`, `callTool` and `callNamed`; it `return`s the
1641
+ * canonical result.
1642
+ *
1643
+ * Empty in a **source** manifest that declares [`Self::code_file`] instead;
1644
+ * [`PluginManifest::hydrate_code_files`] fills it in at parse time and
1645
+ * [`PluginManifest::validate`] refuses a manifest where it is still empty.
1646
+ */
1647
+ code?: string;
1648
+ /**
1649
+ * Path to the file holding the adapter body, relative to the plugin root
1650
+ * (`adapters/<verb>.js`) — the authoring form. Mutually exclusive with
1651
+ * [`Self::code`]; see [`PluginManifest::hydrate_code_files`].
1652
+ */
1653
+ code_file?: string | null;
1654
+ /**
1655
+ * ADDITIONAL provider tool ids this adapter may call, beyond
1656
+ * [`CapabilityToolBinding::tool`], reachable from the body as
1657
+ * `callNamed(id, args)`.
1658
+ *
1659
+ * Exists because a whole class of real APIs is two calls, not one: an async job
1660
+ * API starts work at one endpoint and reads the result from another
1661
+ * (`POST /crawl` → job id → `GET /crawl/{id}`). A single-tool adapter cannot
1662
+ * express that, so those providers would stay unbindable — the gap that
1663
+ * excluded every async API from every layer.
1664
+ *
1665
+ * This is an ALLOWLIST fixed by the manifest and checked host-side: a name not
1666
+ * listed here (and not [`CapabilityToolBinding::tool`]) is refused. Sandboxed
1667
+ * code chooses only *among* tools the provider declared, never a tool of its
1668
+ * own — which is what keeps the id-taking form from becoming an escalation seam.
1669
+ */
1670
+ tools?: string[];
1671
+ }
1672
+ /**
1673
+ * Inclusive numeric bounds a provider can honour for one canonical argument.
1674
+ * Integers, not floats. Every clampable canonical argument is a COUNT — result
1675
+ * limits, crawl depth, page caps — so `i64` is the honest type, and it keeps the
1676
+ * whole manifest tree `Eq` (a float would force `PartialEq`-only all the way up
1677
+ * through `ProvidesEntry` and `PluginManifest`) while avoiding float comparison.
1678
+ */
1679
+ export interface ArgBounds {
1680
+ /**
1681
+ * Largest value the provider accepts. Absent = no upper bound.
1682
+ */
1683
+ max?: number | null;
1684
+ /**
1685
+ * Smallest value the provider accepts. Absent = no lower bound.
1686
+ */
1687
+ min?: number | null;
1688
+ }
1689
+ /**
1690
+ * Normalizes one provider's response into the capability's canonical shape.
1691
+ *
1692
+ * Deliberately a flat rename table rather than a general transform language: the
1693
+ * canonical shapes are small and list-of-records shaped, and a manifest that can
1694
+ * run arbitrary extraction logic is a much larger trust surface.
1695
+ */
1696
+ export interface CapabilityResponseMap {
1697
+ /**
1698
+ * Canonical per-item field name → the provider's field name (dotted paths
1699
+ * allowed). Fields with no entry are dropped from the canonical item but remain
1700
+ * available under the item's `raw` key.
1701
+ */
1702
+ fields?: {
1703
+ [k: string]: string;
1704
+ };
1705
+ /**
1706
+ * Dotted path to the provider's result array within its response (e.g.
1707
+ * `"results"`, `"data.items"`). Absent = the response itself is the array, or —
1708
+ * when it is not an array — a single record.
1709
+ */
1710
+ results?: string | null;
1711
+ }
641
1712
  /**
642
1713
  * `requires` block — the plugin's **plugin-to-plugin** dependencies.
643
1714
  *
@@ -719,7 +1790,7 @@ export interface CapabilityReq {
719
1790
  min_version?: string | null;
720
1791
  }
721
1792
  /**
722
- * A single Runnable entry inside a `plugin.json` manifest.
1793
+ * A single Runnable entry inside a `manifest.json` manifest.
723
1794
  *
724
1795
  * Each entry carries the identity fields from [`crate::runnable::RunnableMeta`]
725
1796
  * plus an optional typed config blob. The `kind` field drives which config shape
@@ -945,6 +2016,20 @@ export interface SidecarSpec {
945
2016
  * How Core obtains and runs the process.
946
2017
  */
947
2018
  process: BinarySpec | ExternalRuntimeConfig1 | LocalProcessSpec | NodeProcessSpec;
2019
+ /**
2020
+ * Optional **model-provider** declaration: when present, this sidecar serves an
2021
+ * OpenAI-compatible endpoint and Core registers it as a selectable provider once
2022
+ * the process reports healthy, then deregisters it when the plugin is disabled or
2023
+ * uninstalled. This is what makes a third-party *auth bridge* possible without a
2024
+ * Core change: the plugin performs its own login/refresh, serves `/v1`, and
2025
+ * declares that fact here. Absent = the sidecar is not a model provider.
2026
+ *
2027
+ * A sidecar cannot self-register: it holds only `RYU_EXT_TOKEN` (scoped to the
2028
+ * ext-proxy hop and `/api/host/*`), and the host-RPC vocabulary has no
2029
+ * provider-registration method. Registration is therefore Core-side, driven by
2030
+ * this declaration.
2031
+ */
2032
+ provides_provider?: ProviderRegistrationSpec | null;
948
2033
  }
949
2034
  /**
950
2035
  * Declares the host-API grant subset a sidecar *process* may exercise via the
@@ -1069,6 +2154,47 @@ export interface LocalProcessSpec {
1069
2154
  export interface NodeProcessSpec {
1070
2155
  kind: "node";
1071
2156
  }
2157
+ /**
2158
+ * Declares that a [`SidecarSpec`] serves an OpenAI-compatible model endpoint Core
2159
+ * should register as a provider while the sidecar is healthy.
2160
+ *
2161
+ * Security posture: the declared [`id`] is validated against the built-in provider
2162
+ * table at registration and a collision is REFUSED, never merged. Without that guard
2163
+ * a plugin could claim a built-in id (`openai-codex`, `anthropic`) and silently
2164
+ * redirect the user's subscription traffic — and their live bearer token — to an
2165
+ * attacker-controlled `baseUrl`. Core also stamps [`OWNER_FIELD`] into the written
2166
+ * entry so deregistration can only ever remove an entry this plugin created.
2167
+ *
2168
+ * [`id`]: ProviderRegistrationSpec::id
2169
+ * [`OWNER_FIELD`]: crate::schema::PROVIDER_OWNER_FIELD
2170
+ */
2171
+ export interface ProviderRegistrationSpec {
2172
+ /**
2173
+ * Pi `api` type the endpoint speaks. Defaults to `"openai-completions"`.
2174
+ */
2175
+ api?: string | null;
2176
+ /**
2177
+ * Path prefix appended to `http://127.0.0.1:<port>` to form the provider's
2178
+ * `baseUrl`. Defaults to `"/v1"`.
2179
+ */
2180
+ base_path?: string | null;
2181
+ /**
2182
+ * Provider id as it appears in the model picker. Must not collide with a built-in
2183
+ * provider id, and must be a safe single token (lowercase alphanumerics, `-`, `_`).
2184
+ */
2185
+ id: string;
2186
+ /**
2187
+ * Human-readable label for the picker. Defaults to [`id`] when absent.
2188
+ *
2189
+ * [`id`]: ProviderRegistrationSpec::id
2190
+ */
2191
+ label?: string | null;
2192
+ /**
2193
+ * Optional model ids to seed the entry with, for an endpoint whose `GET /models`
2194
+ * discovery is unavailable or slow. Absent = rely on discovery.
2195
+ */
2196
+ models?: string[];
2197
+ }
1072
2198
  /**
1073
2199
  * One [`PluginManifest::surfaces`] entry: the support level plus an optional UI
1074
2200
  * descriptor the surface shell resolves (opaque here — pure data).