@capybara-hq/plugin-sdk 0.1.0-rc.8 → 0.1.0-rc.9

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- generated:extension-point -->
4
4
  - Name: `sandbox` (a ref is `sandbox@<major>`)
5
- - Runs an environment's instance and keeps its files for agents' commands (Docker, E2B).
5
+ - Runs a device's instance and keeps its files for agents' commands (Docker, E2B).
6
6
  - Providers: one at a time
7
7
  - Enabled per org: no
8
8
  - Brokered by the platform: yes (extensions use it only through `ctx`)
@@ -10,6 +10,6 @@
10
10
 
11
11
  Contract: SandboxProvider (`sandbox.ts`)
12
12
 
13
- The kernel accepts only `sandbox@2`. An environment is one instance plus a file system that outlives it. Implement the file system (`createFileSystem`, `destroyFileSystem`, `destroyFileSystemByName`), the instance on it (`create`, which throws `FileSystemGoneError` when the file system is gone instead of making an empty one; `resume`, `suspend`; `destroy` and `destroyByName`, which drop the instance and keep the files), and the instance's `exec`, `readFile`, `writeFile`, `stat`, `killAll` and `killTagged` (`concurrentExec` is required: several conversations share one instance). Aborting a command must kill it. Locking, suspension timing and destruction are the kernel's job, not yours. Never publish a docker socket proxy on a host address.
13
+ The kernel accepts only `sandbox@2`. A device is one instance plus a file system that outlives it. Implement the file system (`createFileSystem`, `destroyFileSystem`, `destroyFileSystemByName`), the instance on it (`create`, which throws `FileSystemGoneError` when the file system is gone instead of making an empty one; `resume`, `suspend`; `destroy` and `destroyByName`, which drop the instance and keep the files), and the instance's `exec`, `readFile`, `writeFile`, `stat`, `killAll` and `killTagged` (`concurrentExec` is required: several conversations share one instance). Aborting a command must kill it. Locking, suspension timing and destruction are the kernel's job, not yours. Never publish a docker socket proxy on a host address.
14
14
 
15
15
  See `extensions` for the rules every extension follows.
@@ -44,7 +44,7 @@ look like `channel@1`: name and major version. The registered points:
44
44
  <!-- generated:extension-points -->
45
45
  | Extension point | Providers | Per org | What it is |
46
46
  |---|---|---|---|
47
- | `sandbox` | single | no | Runs an environment's instance and keeps its files for agents' commands (Docker, E2B). |
47
+ | `sandbox` | single | no | Runs a device's instance and keeps its files for agents' commands (Docker, E2B). |
48
48
  | `config-source` | single | no | Supplies deployment configuration from an external store such as Nacos. |
49
49
  | `secret-store` | single | no | Stores and resolves secrets (connector credentials, secret config) outside the database. |
50
50
  | `object-store` | single | no | Stores files and artifacts in S3-compatible object storage, with presigned uploads. |
package/dist/ctx.d.ts CHANGED
@@ -198,15 +198,20 @@ export interface ExtensionContext {
198
198
  * 界面贡献的运行时(清单 `ui` + `permissions.ui`):
199
199
  * - `view` / `action`:只能在 `setup` 期间注册,id 是清单里 kind view 的贡献 id;
200
200
  * `action` 收到视图里按钮或表单提交(`args.actionId` 区分是哪一个);
201
- * - `invalidate`:告诉内核「这个贡献变了」,打开着它的网页会重新取视图(经 bus,跨副本);
201
+ * - `invalidate`:告诉内核「这个贡献变了」,打开着它的网页会重新取视图(经 bus,跨副本);a
202
+ * `platform.page` contribution takes no `orgId` (it refreshes the admin console), every other
203
+ * contribution requires one;
202
204
  * - `verifyEmbedToken`:校验 embed 页面拿到的嵌入令牌,只认签给本插件、未过期的,且此刻插件仍在该组织启用、
203
- * 调用者仍是 active 成员(组织页面还要求 member 以上),否则返回 null;返回的 `role` 是此刻的角色。
205
+ * 调用者仍是 active 成员,角色仍满足位置的 minRole,否则返回 null;返回的 `role` 是此刻的角色。A
206
+ * platform-scope token (`scope: "platform"`, no `orgId`) verifies only for a `platform.page`
207
+ * contribution while the account is still an active super admin.
204
208
  */
205
209
  ui: {
206
210
  view(id: string, render: (args: UiRenderArgs) => Promise<UiView>): void;
207
211
  action(id: string, handler: (args: UiActionArgs) => Promise<UiActionResult>): void;
208
212
  invalidate(target: {
209
- orgId: string;
213
+ /** Required, except for a `platform.page` contribution, where it must be omitted. */
214
+ orgId?: string;
210
215
  conversationId?: string;
211
216
  contribution: string;
212
217
  }): Promise<void>;
@@ -14,7 +14,11 @@ export interface ExtensionManifest {
14
14
  optional?: ExtensionPointRef[];
15
15
  /** 权限清单(治理而非沙箱)。 */
16
16
  permissions?: ExtensionPermissions;
17
- /** 界面贡献:对话页标签与组织页面;id 须同时列在 `permissions.ui` 里。 */
17
+ /**
18
+ * UI contributions, each in a slot of `UI_SLOTS`; every id is also listed in `permissions.ui`.
19
+ * Kept exactly as written: structural problems fail the extension, while a contribution to an
20
+ * unknown slot or version is only dropped when the host resolves the slots (`resolveUiSlots`).
21
+ */
18
22
  ui?: UiContribution[];
19
23
  config?: {
20
24
  deployment?: z.ZodTypeAny;
@@ -78,6 +78,7 @@ export function defineExtension(m) {
78
78
  if (errs.length > 0)
79
79
  fail(`invalid permissions: ${errs.join("; ")}`);
80
80
  }
81
+ // structural ui problems only; slot resolution never fails the extension (UI slots spec §3.2)
81
82
  const uiErrs = validateUiContributions(m.ui, m.permissions);
82
83
  if (uiErrs.length > 0)
83
84
  fail(`invalid ui: ${uiErrs.join("; ")}`);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Device protocol v1: the WebSocket frames between the kernel and a `capybara-device` program
3
- * (a connected machine that serves as an environment). Pure types and hand-written guards.
3
+ * (a connected machine that serves as a device). Pure types and hand-written guards.
4
4
  */
5
5
  import { type ExecResult, type SandboxActor, type SandboxLimits, type SandboxRoots } from "./sandbox.js";
6
6
  /** The protocol version carried in `X-Capybara-Device-Protocol` and the hello frame. */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Device protocol v1: the WebSocket frames between the kernel and a `capybara-device` program
3
- * (a connected machine that serves as an environment). Pure types and hand-written guards.
3
+ * (a connected machine that serves as a device). Pure types and hand-written guards.
4
4
  */
5
5
  import { validateSandboxPathShape, } from "./sandbox.js";
6
6
  /** The protocol version carried in `X-Capybara-Device-Protocol` and the hello frame. */
@@ -8,7 +8,7 @@ const info = (majors, description, cardinality, brokered = false, orgScoped = fa
8
8
  });
9
9
  /** The registered extension points. */
10
10
  export const EXTENSION_POINTS = {
11
- sandbox: info([2], "Runs an environment's instance and keeps its files for agents' commands (Docker, E2B).", "single", true),
11
+ sandbox: info([2], "Runs a device's instance and keeps its files for agents' commands (Docker, E2B).", "single", true),
12
12
  "config-source": info([1], "Supplies deployment configuration from an external store such as Nacos.", "single"),
13
13
  "secret-store": info([1], "Stores and resolves secrets (connector credentials, secret config) outside the database.", "single", true),
14
14
  "object-store": info([1], "Stores files and artifacts in S3-compatible object storage, with presigned uploads.", "single", true),
@@ -7,7 +7,7 @@ export declare const FILE_PREVIEW_MAX_BYTES: number;
7
7
  /**
8
8
  * One previewer of a `file-preview@1` provider: which files it previews and how. It renders either a
9
9
  * view (`render`, drawn by the web app like any `UiView`) or an embed page (`embed`, the id of a ui
10
- * contribution `{ kind: "embed", place: "file-preview" }` of the same extension, which receives the
10
+ * contribution `{ kind: "embed", slot: "file.preview" }` of the same extension, which receives the
11
11
  * file over the message bridge). Exactly one of the two.
12
12
  */
13
13
  export interface FilePreviewer {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const SDK_VERSION = "0.1.0-rc.8";
1
+ export declare const SDK_VERSION = "0.1.0-rc.9";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
@@ -34,4 +34,6 @@ export * from "./theme-tokens.generated.js";
34
34
  export * from "./tool.js";
35
35
  export * from "./tool-key.js";
36
36
  export * from "./ui.js";
37
+ export * from "./ui-resolve.js";
38
+ export * from "./ui-slots.js";
37
39
  export * from "./web.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const SDK_VERSION = "0.1.0-rc.8";
1
+ export const SDK_VERSION = "0.1.0-rc.9";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
@@ -34,4 +34,6 @@ export * from "./theme-tokens.generated.js";
34
34
  export * from "./tool.js";
35
35
  export * from "./tool-key.js";
36
36
  export * from "./ui.js";
37
+ export * from "./ui-resolve.js";
38
+ export * from "./ui-slots.js";
37
39
  export * from "./web.js";
@@ -62,9 +62,14 @@ export declare class ExtensionPointUnavailableError extends Error {
62
62
  /** 校验权限清单,返回错误列表(空表示合法)。 */
63
63
  export declare function validatePermissions(plugin: string, p: unknown): string[];
64
64
  /**
65
- * 校验清单 `ui` 并与权限清单交叉核对,返回错误列表(空表示合法):
66
- * 每个贡献的 id 恰好出现在 `permissions.ui.views`(kind view)或 `permissions.ui.embeds`(kind embed)里,
67
- * `permissions.ui` 里也不能有清单 `ui` 没有的 id;embed 的 path 以 `/ui/` 开头并在 `permissions.http` 中声明为 GET。
68
- * 假定 `permissions` 已经过 {@link validatePermissions}。
65
+ * Checks the structure of the manifest's `ui` and cross-checks it with the permission manifest;
66
+ * returns the problems (empty = valid). Only structural problems (UI slots spec §3.2): each
67
+ * contribution's id is listed in exactly `permissions.ui.views` (kind view) or
68
+ * `permissions.ui.embeds` (kind embed), and `permissions.ui` lists no id the `ui` lacks; an embed's
69
+ * path starts with `/ui/` and is declared in `permissions.http` as GET; exactly one of `slot` (a
70
+ * well-formed slot reference) and `place` (a known place); for a slot the catalog knows at the
71
+ * version named, a kind it accepts and, unless an alias, the icon it requires. An unknown slot id or version is not a
72
+ * problem here: `resolveUiSlots` drops that contribution and the rest of the extension loads.
73
+ * Assumes `permissions` passed {@link validatePermissions}.
69
74
  */
70
75
  export declare function validateUiContributions(ui: unknown, permissions: unknown): string[];
@@ -1,5 +1,6 @@
1
1
  import { POLICY_HOOKS } from "./hooks.js";
2
2
  import { UI_ICONS } from "./ui.js";
3
+ import { PLACE_SLOTS, parseSlotRef, uiSlot } from "./ui-slots.js";
3
4
  /** hook 挂载点(封闭列表,见总设计 2.4)。 */
4
5
  export const HOOK_MOUNT_POINTS = [
5
6
  "session.start",
@@ -168,20 +169,18 @@ export function validatePermissions(plugin, p) {
168
169
  }
169
170
  const UI_ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
170
171
  const UI_TITLE_MAX = 100;
171
- const UI_PLACES = [
172
- "conversation",
173
- "panel",
174
- "section",
175
- "inline",
176
- "page",
177
- "file-preview",
178
- ];
172
+ const UI_PLACES = Object.keys(PLACE_SLOTS);
179
173
  const UI_EMBED_PREFIX = "/ui/";
180
174
  /**
181
- * 校验清单 `ui` 并与权限清单交叉核对,返回错误列表(空表示合法):
182
- * 每个贡献的 id 恰好出现在 `permissions.ui.views`(kind view)或 `permissions.ui.embeds`(kind embed)里,
183
- * `permissions.ui` 里也不能有清单 `ui` 没有的 id;embed 的 path 以 `/ui/` 开头并在 `permissions.http` 中声明为 GET。
184
- * 假定 `permissions` 已经过 {@link validatePermissions}。
175
+ * Checks the structure of the manifest's `ui` and cross-checks it with the permission manifest;
176
+ * returns the problems (empty = valid). Only structural problems (UI slots spec §3.2): each
177
+ * contribution's id is listed in exactly `permissions.ui.views` (kind view) or
178
+ * `permissions.ui.embeds` (kind embed), and `permissions.ui` lists no id the `ui` lacks; an embed's
179
+ * path starts with `/ui/` and is declared in `permissions.http` as GET; exactly one of `slot` (a
180
+ * well-formed slot reference) and `place` (a known place); for a slot the catalog knows at the
181
+ * version named, a kind it accepts and, unless an alias, the icon it requires. An unknown slot id or version is not a
182
+ * problem here: `resolveUiSlots` drops that contribution and the rest of the extension loads.
183
+ * Assumes `permissions` passed {@link validatePermissions}.
185
184
  */
186
185
  export function validateUiContributions(ui, permissions) {
187
186
  const perms = isObj(permissions) ? permissions : {};
@@ -219,22 +218,46 @@ export function validateUiContributions(ui, permissions) {
219
218
  if (!title ||
220
219
  [title.zh, title.en].some((t) => typeof t !== "string" || t === "" || t.length > UI_TITLE_MAX))
221
220
  errs.push(`ui "${id}" title needs non-empty zh and en of at most ${UI_TITLE_MAX} characters`);
222
- if (!UI_PLACES.includes(c.place))
221
+ // the slot it names, when the slot / place is well formed (known to the catalog or not)
222
+ let slotRef = null;
223
+ if ((c.slot === undefined) === (c.place === undefined)) {
224
+ errs.push(`ui "${id}" needs exactly one of slot and place`);
225
+ }
226
+ else if (c.slot !== undefined) {
227
+ slotRef = typeof c.slot === "string" ? parseSlotRef(c.slot) : null;
228
+ if (slotRef === null)
229
+ errs.push(`ui "${id}" slot must be a slot reference like "org.page" or "org.page@1"`);
230
+ }
231
+ else if (!UI_PLACES.includes(c.place)) {
223
232
  errs.push(`ui "${id}" place must be one of ${UI_PLACES.join(", ")}`);
233
+ }
234
+ else {
235
+ slotRef = { id: PLACE_SLOTS[c.place], version: 1 };
236
+ }
237
+ if (c.after !== undefined && typeof c.after !== "string")
238
+ errs.push(`ui "${id}" after must be a string`);
224
239
  if (c.kind !== "view" && c.kind !== "embed") {
225
240
  errs.push(`ui "${id}" kind must be view or embed`);
226
241
  return;
227
242
  }
228
243
  if (c.icon !== undefined && !UI_ICONS.includes(c.icon))
229
244
  errs.push(`ui "${id}" icon must be one of ${UI_ICONS.join(", ")}`);
245
+ // the slot checks describe only the catalog's version: another version is left to
246
+ // resolveUiSlots, which drops it (unsupported-version) without failing the extension
247
+ const known = slotRef === null ? undefined : uiSlot(slotRef.id);
248
+ const spec = known !== undefined && known.version === slotRef?.version ? known : undefined;
249
+ if (spec !== undefined) {
250
+ if (!spec.accepts.includes(c.kind))
251
+ errs.push(`ui "${id}" slot ${spec.id} does not accept kind ${c.kind}`);
252
+ if (spec.icon === "required" && c.icon === undefined && c.aliasOf === undefined)
253
+ errs.push(`ui "${id}" slot ${spec.id} needs an icon`);
254
+ }
230
255
  if (c.aliasOf !== undefined)
231
256
  aliases.push({ id, kind: c.kind, target: c.aliasOf });
232
257
  if (seen.has(id))
233
258
  errs.push(`ui has duplicate id "${id}"`);
234
259
  seen.set(id, c.kind);
235
260
  if (c.kind === "view") {
236
- if (c.place === "file-preview")
237
- errs.push(`ui "${id}" place file-preview is only allowed for kind embed`);
238
261
  if (c.path !== undefined)
239
262
  errs.push(`ui "${id}" path is only allowed for kind embed`);
240
263
  if (!views.includes(id))
package/dist/sandbox.d.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * primitives below; the kernel builds `glob` / `grep` / `edit` from `exec` plus `readFile` /
5
5
  * `writeFile`.
6
6
  *
7
- * `@2` separates an environment's **file system** from its **instance**: the file system
8
- * (`createFileSystem`) lives until the environment is deleted (`destroyFileSystem`); instances
7
+ * `@2` separates a device's **file system** from its **instance**: the file system
8
+ * (`createFileSystem`) lives until the device is deleted (`destroyFileSystem`); instances
9
9
  * (`create`) attach to it and come and go (`destroy` drops the instance and keeps the files).
10
10
  */
11
11
  /** Sandbox extension point ref. */
@@ -38,7 +38,7 @@ export interface SandboxHandle {
38
38
  }
39
39
  /** The roots of an instance, falling back to {@link DEFAULT_SANDBOX_ROOTS}. */
40
40
  export declare function rootsOf(h: SandboxHandle): SandboxRoots;
41
- /** The provider's file system handle (an environment's files, independent of any instance). */
41
+ /** The provider's file system handle (a device's files, independent of any instance). */
42
42
  export interface FileSystemHandle {
43
43
  /** The provider's file system id. */
44
44
  id: string;
@@ -74,7 +74,7 @@ export declare function isSandboxGoneError(e: unknown): e is SandboxGoneError;
74
74
  /**
75
75
  * The file system is definitely gone on the provider's side (volume removed, E2B sandbox killed).
76
76
  * `create` throws it instead of attaching the instance to a new, empty file system; the kernel then
77
- * reports the environment's files as lost rather than handing out a silently empty workspace.
77
+ * reports the device's files as lost rather than handing out a silently empty workspace.
78
78
  */
79
79
  export declare class FileSystemGoneError extends Error {
80
80
  readonly fileSystemGone: true;
@@ -93,8 +93,8 @@ export declare const SANDBOX_TAG_RE: RegExp;
93
93
  /**
94
94
  * The `sandbox@2` implementation (provided by a sandbox plugin such as Docker or E2B).
95
95
  *
96
- * **File systems and instances.** `createFileSystem` makes an environment's files; they live until
97
- * `destroyFileSystem` (the environment is deleted). `create` makes an instance attached to an
96
+ * **File systems and instances.** `createFileSystem` makes a device's files; they live until
97
+ * `destroyFileSystem` (the device is deleted). `create` makes an instance attached to an
98
98
  * existing file system; `destroy` / `destroyByName` drop the instance only and keep the files, and
99
99
  * `suspend` keeps them too. When the file system is gone, `create` must throw
100
100
  * {@link FileSystemGoneError} and never create an empty one in its place (a Docker provider checks
@@ -126,7 +126,7 @@ export declare const SANDBOX_TAG_RE: RegExp;
126
126
  * `exitCode: null`, `timedOut: false` (and the output collected so far), it does not reject. An
127
127
  * `exec` called with an already aborted signal rejects at once.
128
128
  *
129
- * **Concurrent use** (required in `@2`): the sessions of one environment share its instance, so the
129
+ * **Concurrent use** (required in `@2`): the sessions of one device share its instance, so the
130
130
  * kernel calls `exec`, `readFile`, `writeFile` and `stat` concurrently on one instance. Those four
131
131
  * methods must be safe to call concurrently. Each `exec` labels its command with its `tag`, and an
132
132
  * aborted `exec` kills only its own command.
@@ -135,12 +135,12 @@ export interface SandboxProvider {
135
135
  /** `exec` / `readFile` / `writeFile` / `stat` may run concurrently on one instance (required). */
136
136
  readonly concurrentExec: true;
137
137
  /**
138
- * Whether the provider isolates the sessions of one environment from each other inside the
138
+ * Whether the provider isolates the sessions of one device from each other inside the
139
139
  * instance (a declared capability; absent means false).
140
140
  */
141
141
  readonly sessionIsolation?: boolean;
142
142
  /**
143
- * Creates an environment's file system. `name` looks like `cap-env-<environmentId>`. If a file
143
+ * Creates a device's file system. `name` looks like `cap-device-<deviceId>`. If a file
144
144
  * system with this name already exists, return it.
145
145
  */
146
146
  createFileSystem(args: {
package/dist/sandbox.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * primitives below; the kernel builds `glob` / `grep` / `edit` from `exec` plus `readFile` /
5
5
  * `writeFile`.
6
6
  *
7
- * `@2` separates an environment's **file system** from its **instance**: the file system
8
- * (`createFileSystem`) lives until the environment is deleted (`destroyFileSystem`); instances
7
+ * `@2` separates a device's **file system** from its **instance**: the file system
8
+ * (`createFileSystem`) lives until the device is deleted (`destroyFileSystem`); instances
9
9
  * (`create`) attach to it and come and go (`destroy` drops the instance and keeps the files).
10
10
  */
11
11
  /** Sandbox extension point ref. */
@@ -45,7 +45,7 @@ export function isSandboxGoneError(e) {
45
45
  /**
46
46
  * The file system is definitely gone on the provider's side (volume removed, E2B sandbox killed).
47
47
  * `create` throws it instead of attaching the instance to a new, empty file system; the kernel then
48
- * reports the environment's files as lost rather than handing out a silently empty workspace.
48
+ * reports the device's files as lost rather than handing out a silently empty workspace.
49
49
  */
50
50
  export class FileSystemGoneError extends Error {
51
51
  fileSystemGone = true;
package/dist/tool.d.ts CHANGED
@@ -72,9 +72,9 @@ export interface ToolResult {
72
72
  display?: ToolDisplay;
73
73
  /**
74
74
  * Optional: an inline card under this call in the conversation: `view` is a ui contribution of
75
- * the same extension with `place: "inline"`, rendered with `card: { callId, key }`. Display only
76
- * (recorded on the `tool_result` event, never in the model's context); an unknown view is dropped
77
- * with a warning and the result is kept.
75
+ * the same extension in the `conversation.tool-result.card` slot, rendered with
76
+ * `card: { callId, key }`. Display only (recorded on the `tool_result` event, never in the model's
77
+ * context); an unknown view is dropped with a warning and the result is kept.
78
78
  */
79
79
  card?: {
80
80
  view: string;
@@ -0,0 +1,40 @@
1
+ import type { UiContribution } from "./ui.js";
2
+ import { type UiSlotSpec } from "./ui-slots.js";
3
+ /** A contribution whose slot the catalog knows at the version it names (UI slots spec §3.2). */
4
+ export interface ResolvedUiContribution {
5
+ contribution: UiContribution;
6
+ slot: UiSlotSpec;
7
+ version: number;
8
+ /**
9
+ * For a slot with anchors: the anchor it goes after (the contribution's own `after` when it is one
10
+ * of the slot's anchors, else the slot's first anchor). Absent for a slot without anchors.
11
+ */
12
+ after?: string;
13
+ }
14
+ /** Why a contribution was dropped: its slot is not in the catalog, or not at that version. */
15
+ export type UiDropReason = "unknown-slot" | "unsupported-version";
16
+ /** A contribution left out of the loaded extension; `ref` is the slot reference it names. */
17
+ export interface DroppedUiContribution {
18
+ id: string;
19
+ ref: string;
20
+ reason: UiDropReason;
21
+ }
22
+ /** The slot reference a contribution names: its `slot`, or the slot of its deprecated `place`. */
23
+ export declare function slotRefOf(c: UiContribution): string;
24
+ /**
25
+ * Resolves the slots of a manifest's `ui` against the catalog (UI slots spec §3.2). Assumes
26
+ * `validateUiContributions` passed; never throws. A contribution to an unknown slot or to a version
27
+ * other than the slot's is dropped (an alias whose target is dropped is dropped with the target's
28
+ * reason and its own `ref`); an `after` that is not an anchor of the slot is listed in
29
+ * `unknownAnchors` and the contribution keeps the slot's default position. `deprecatedPlace` lists
30
+ * the ids that still say `place`. Both lists keep manifest order.
31
+ */
32
+ export declare function resolveUiSlots(ui: readonly UiContribution[] | undefined): {
33
+ resolved: ResolvedUiContribution[];
34
+ dropped: DroppedUiContribution[];
35
+ unknownAnchors: {
36
+ id: string;
37
+ after: string;
38
+ }[];
39
+ deprecatedPlace: string[];
40
+ };
@@ -0,0 +1,54 @@
1
+ import { PLACE_SLOTS, parseSlotRef, uiSlot } from "./ui-slots.js";
2
+ /** The slot reference a contribution names: its `slot`, or the slot of its deprecated `place`. */
3
+ export function slotRefOf(c) {
4
+ // biome-ignore lint/style/noNonNullAssertion: validateUiContributions requires slot or place
5
+ return c.slot ?? PLACE_SLOTS[c.place];
6
+ }
7
+ /**
8
+ * Resolves the slots of a manifest's `ui` against the catalog (UI slots spec §3.2). Assumes
9
+ * `validateUiContributions` passed; never throws. A contribution to an unknown slot or to a version
10
+ * other than the slot's is dropped (an alias whose target is dropped is dropped with the target's
11
+ * reason and its own `ref`); an `after` that is not an anchor of the slot is listed in
12
+ * `unknownAnchors` and the contribution keeps the slot's default position. `deprecatedPlace` lists
13
+ * the ids that still say `place`. Both lists keep manifest order.
14
+ */
15
+ export function resolveUiSlots(ui) {
16
+ const list = ui ?? [];
17
+ const own = new Map();
18
+ for (const c of list) {
19
+ const ref = parseSlotRef(slotRefOf(c));
20
+ const slot = ref === null ? undefined : uiSlot(ref.id);
21
+ own.set(c.id, ref === null || slot === undefined
22
+ ? { ok: false, reason: "unknown-slot" }
23
+ : ref.version !== slot.version
24
+ ? { ok: false, reason: "unsupported-version" }
25
+ : { ok: true, slot, version: ref.version });
26
+ }
27
+ const resolved = [];
28
+ const dropped = [];
29
+ const unknownAnchors = [];
30
+ const deprecatedPlace = [];
31
+ for (const c of list) {
32
+ if (c.slot === undefined && c.place !== undefined)
33
+ deprecatedPlace.push(c.id);
34
+ const mine = own.get(c.id);
35
+ const target = c.aliasOf === undefined ? undefined : own.get(c.aliasOf);
36
+ const r = mine.ok && target !== undefined && !target.ok ? target : mine;
37
+ if (!r.ok) {
38
+ dropped.push({ id: c.id, ref: slotRefOf(c), reason: r.reason });
39
+ continue;
40
+ }
41
+ const anchors = r.slot.anchors ?? [];
42
+ if (c.after !== undefined && !anchors.includes(c.after)) {
43
+ unknownAnchors.push({ id: c.id, after: c.after });
44
+ }
45
+ const after = c.after !== undefined && anchors.includes(c.after) ? c.after : anchors[0];
46
+ resolved.push({
47
+ contribution: c,
48
+ slot: r.slot,
49
+ version: r.version,
50
+ ...(anchors.length > 0 && { after }),
51
+ });
52
+ }
53
+ return { resolved, dropped, unknownAnchors, deprecatedPlace };
54
+ }
@@ -0,0 +1,43 @@
1
+ import type { UiKind, UiPlace } from "./ui.js";
2
+ export type UiSlotScope = "conversation" | "agent" | "org" | "platform" | "file";
3
+ export type UiSlotRole = "guest" | "member" | "admin" | "superAdmin";
4
+ /** Every slot id of the catalog, in catalog order (UI slots spec §3). */
5
+ export declare const UI_SLOT_IDS: readonly ["conversation.panel", "conversation.properties.section", "conversation.tool-result.card", "org.page", "file.preview", "agent.detail.section", "org.settings.section", "platform.page"];
6
+ export type UiSlotId = (typeof UI_SLOT_IDS)[number];
7
+ /** One slot of the catalog: where extension UI may go and what it receives (UI slots spec §3.1). */
8
+ export interface UiSlotSpec {
9
+ id: UiSlotId;
10
+ /** Bumped only on an incompatible change of the slot's contract. */
11
+ version: number;
12
+ /** Path relative to `apps/web/src` of the component that renders the slot. */
13
+ owner: string;
14
+ scope: UiSlotScope;
15
+ accepts: readonly UiKind[];
16
+ cardinality: "list";
17
+ minRole: UiSlotRole;
18
+ /** Context ids passed to render (`conversationId`, `agentId`, …). */
19
+ args: readonly string[];
20
+ density: "dense" | "regular";
21
+ icon: "required" | "optional" | "none";
22
+ /** Built-in parts a contribution may be placed after; the first is the default. */
23
+ anchors?: readonly string[];
24
+ limits?: {
25
+ maxShown?: number;
26
+ };
27
+ description: {
28
+ en: string;
29
+ zh: string;
30
+ };
31
+ }
32
+ export declare const UI_SLOTS: readonly UiSlotSpec[];
33
+ export declare function uiSlot(id: string): UiSlotSpec | undefined;
34
+ /** Reads `<id>` or `<id>@<version>` (version 1 when omitted); does not check the catalog. */
35
+ export declare function parseSlotRef(ref: string): {
36
+ id: string;
37
+ version: number;
38
+ } | null;
39
+ /** @deprecated step A of the UI slots spec §4 */
40
+ export declare const PLACE_SLOTS: Readonly<Record<UiPlace, UiSlotId>>;
41
+ export declare const UI_ARRANGEABLE_SLOTS: readonly UiSlotId[];
42
+ export declare const UI_CONTRIBUTION_KEY_RE: RegExp;
43
+ export declare function uiContributionKey(plugin: string, id: string): string;
@@ -0,0 +1,168 @@
1
+ /** Every slot id of the catalog, in catalog order (UI slots spec §3). */
2
+ export const UI_SLOT_IDS = [
3
+ "conversation.panel",
4
+ "conversation.properties.section",
5
+ "conversation.tool-result.card",
6
+ "org.page",
7
+ "file.preview",
8
+ "agent.detail.section",
9
+ "org.settings.section",
10
+ "platform.page",
11
+ ];
12
+ const SLOT_DATA = [
13
+ {
14
+ id: "conversation.panel",
15
+ version: 1,
16
+ owner: "components/conversation/RightArea.tsx",
17
+ scope: "conversation",
18
+ accepts: ["view", "embed"],
19
+ cardinality: "list",
20
+ minRole: "guest",
21
+ args: ["conversationId", "item"],
22
+ density: "regular",
23
+ icon: "optional",
24
+ description: {
25
+ en: "A panel beside the conversation, opened from the right area.",
26
+ zh: "对话旁的面板,从右侧区域打开。",
27
+ },
28
+ },
29
+ {
30
+ id: "conversation.properties.section",
31
+ version: 1,
32
+ owner: "components/conversation/PropertiesSections.tsx",
33
+ scope: "conversation",
34
+ accepts: ["view", "embed"],
35
+ cardinality: "list",
36
+ minRole: "guest",
37
+ args: ["conversationId"],
38
+ density: "dense",
39
+ icon: "optional",
40
+ anchors: ["context", "files", "background"],
41
+ description: {
42
+ en: "A section in the conversation properties list.",
43
+ zh: "对话属性列表中的一个分区。",
44
+ },
45
+ },
46
+ {
47
+ id: "conversation.tool-result.card",
48
+ version: 1,
49
+ owner: "components/conversation/RightArea.tsx",
50
+ scope: "conversation",
51
+ accepts: ["view", "embed"],
52
+ cardinality: "list",
53
+ minRole: "guest",
54
+ args: ["conversationId", "card"],
55
+ density: "regular",
56
+ icon: "optional",
57
+ description: {
58
+ en: "A card a tool result opens in the conversation.",
59
+ zh: "工具结果在对话中打开的卡片。",
60
+ },
61
+ },
62
+ {
63
+ id: "org.page",
64
+ version: 1,
65
+ owner: "app/(app)/p/[plugin]/[id]/view.tsx",
66
+ scope: "org",
67
+ accepts: ["view", "embed"],
68
+ cardinality: "list",
69
+ minRole: "member",
70
+ args: [],
71
+ density: "regular",
72
+ icon: "optional",
73
+ description: {
74
+ en: "A full page in the organization navigation.",
75
+ zh: "组织导航中的完整页面。",
76
+ },
77
+ },
78
+ {
79
+ id: "file.preview",
80
+ version: 1,
81
+ owner: "components/conversation/FilePanel.tsx",
82
+ scope: "file",
83
+ accepts: ["embed"],
84
+ cardinality: "list",
85
+ minRole: "guest",
86
+ args: ["conversationId", "fileId"],
87
+ density: "regular",
88
+ icon: "optional",
89
+ description: {
90
+ en: "A previewer for a file of the conversation.",
91
+ zh: "对话中文件的预览器。",
92
+ },
93
+ },
94
+ {
95
+ id: "agent.detail.section",
96
+ version: 1,
97
+ owner: "components/agent/AgentPage.tsx",
98
+ scope: "agent",
99
+ accepts: ["view", "embed"],
100
+ cardinality: "list",
101
+ minRole: "guest",
102
+ args: ["agentId"],
103
+ density: "regular",
104
+ icon: "optional",
105
+ description: {
106
+ en: "A section on the detail page of an agent.",
107
+ zh: "智能体详情页中的一个分区。",
108
+ },
109
+ },
110
+ {
111
+ id: "org.settings.section",
112
+ version: 1,
113
+ owner: "app/(app)/settings/organization/view.tsx",
114
+ scope: "org",
115
+ accepts: ["view", "embed"],
116
+ cardinality: "list",
117
+ minRole: "admin",
118
+ args: [],
119
+ density: "regular",
120
+ icon: "optional",
121
+ description: {
122
+ en: "A section on the organization settings page.",
123
+ zh: "组织设置页中的一个分区。",
124
+ },
125
+ },
126
+ {
127
+ id: "platform.page",
128
+ version: 1,
129
+ owner: "app/(app)/admin/p/[plugin]/[id]/view.tsx",
130
+ scope: "platform",
131
+ accepts: ["view", "embed"],
132
+ cardinality: "list",
133
+ minRole: "superAdmin",
134
+ args: [],
135
+ density: "regular",
136
+ icon: "optional",
137
+ description: {
138
+ en: "A full page in the platform administration.",
139
+ zh: "平台管理中的完整页面。",
140
+ },
141
+ },
142
+ ];
143
+ export const UI_SLOTS = SLOT_DATA;
144
+ export function uiSlot(id) {
145
+ return UI_SLOTS.find((s) => s.id === id);
146
+ }
147
+ const SLOT_REF_RE = /^([a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)(?:@([1-9][0-9]{0,2}))?$/;
148
+ /** Reads `<id>` or `<id>@<version>` (version 1 when omitted); does not check the catalog. */
149
+ export function parseSlotRef(ref) {
150
+ const m = SLOT_REF_RE.exec(ref);
151
+ if (!m)
152
+ return null;
153
+ return { id: m[1], version: m[2] === undefined ? 1 : Number(m[2]) };
154
+ }
155
+ /** @deprecated step A of the UI slots spec §4 */
156
+ export const PLACE_SLOTS = {
157
+ panel: "conversation.panel",
158
+ conversation: "conversation.panel",
159
+ section: "conversation.properties.section",
160
+ inline: "conversation.tool-result.card",
161
+ page: "org.page",
162
+ "file-preview": "file.preview",
163
+ };
164
+ export const UI_ARRANGEABLE_SLOTS = UI_SLOT_IDS.filter((id) => id !== "file.preview");
165
+ export const UI_CONTRIBUTION_KEY_RE = /^[a-z][a-z0-9-]{1,40}\/[a-z][a-z0-9-]{0,39}$/;
166
+ export function uiContributionKey(plugin, id) {
167
+ return `${plugin}/${id}`;
168
+ }
package/dist/ui.d.ts CHANGED
@@ -1,18 +1,13 @@
1
1
  import type { Locale } from "./cards.js";
2
2
  import { type FormAnswer, type InputForm } from "./tool.js";
3
+ import type { UiSlotId, UiSlotScope } from "./ui-slots.js";
3
4
  /**
4
- * Where a ui contribution goes (UI refresh spec §5.3):
5
- * - `panel`: a right panel of the conversation page, opened from the extension's icon in the title
6
- * bar (one at a time);
7
- * - `section`: a section of the conversation's properties panel;
8
- * - `inline`: an inline card in the conversation, hanging off a tool result (`ToolResult.card`);
9
- * - `page`: an org page under "Extensions";
10
- * - `file-preview`: an embed previewer of `file-preview@1` (embed only, see `FilePreviewer.embed`);
11
- * - `conversation`: the old right-side tab; it means `panel` (see {@link normalizePlace}).
5
+ * The places of the first UI design, kept as a deprecated input (UI slots spec §4, step A): each maps
6
+ * to a slot through `PLACE_SLOTS` (`panel` and `conversation` → `conversation.panel`, `section` →
7
+ * `conversation.properties.section`, `inline` → `conversation.tool-result.card`, `page` →
8
+ * `org.page`, `file-preview` → `file.preview`).
12
9
  */
13
10
  export type UiPlace = "conversation" | "panel" | "section" | "inline" | "page" | "file-preview";
14
- /** A place with the old `conversation` mapped to `panel`. */
15
- export declare function normalizePlace(place: UiPlace): Exclude<UiPlace, "conversation">;
16
11
  /**
17
12
  * Icons a contribution may use (design system icon names; `puzzle` is the default and is drawn as
18
13
  * the design `extension` icon). The title-bar button of a panel shows it.
@@ -20,8 +15,9 @@ export declare function normalizePlace(place: UiPlace): Exclude<UiPlace, "conver
20
15
  export declare const UI_ICONS: readonly ["list-checks", "layout-grid", "file", "chart", "calendar", "users", "bot", "book", "message-square", "puzzle"];
21
16
  export type UiIcon = (typeof UI_ICONS)[number];
22
17
  /**
23
- * An "open" target: a `panel` contribution of the same extension, scrolled to the block or item
24
- * whose `id` equals `item` (the panel's render receives it as {@link UiRenderArgs.item}).
18
+ * An "open" target: a `conversation.panel` contribution of the same extension, scrolled to the
19
+ * block or item whose `id` equals `item` (the panel's render receives it as
20
+ * {@link UiRenderArgs.item}).
25
21
  */
26
22
  export interface UiOpen {
27
23
  contribution: string;
@@ -39,7 +35,19 @@ export interface UiContribution {
39
35
  zh: string;
40
36
  en: string;
41
37
  };
42
- place: UiPlace;
38
+ /**
39
+ * The slot it goes in: `"<id>"` (version 1) or `"<id>@<n>"`, an id of `UI_SLOTS`. Exactly one of
40
+ * `slot` and `place`. An unknown slot or version does not fail the extension: the contribution is
41
+ * dropped (see `resolveUiSlots`).
42
+ */
43
+ slot?: string;
44
+ /** @deprecated use slot (UI slots spec §4, step A) */
45
+ place?: UiPlace;
46
+ /**
47
+ * One of the slot's `anchors` to be placed after; omitted (or not an anchor of the slot) = the
48
+ * slot's first anchor.
49
+ */
50
+ after?: string;
43
51
  kind: UiKind;
44
52
  /** 仅 embed:插件路由下 `/ui/` 开头的路径,须在 `permissions.http` 中声明为 GET。 */
45
53
  path?: string;
@@ -150,20 +158,41 @@ export declare const UI_LIMITS: {
150
158
  */
151
159
  export declare function validateUiView(v: unknown): string[];
152
160
  /**
153
- * 调用者在组织内的角色(由高到低 owner / admin / member / guest)。`place: "page"` 的贡献只给 member 以上
154
- * (内核对 guest 返回 404,插件收不到 guest 的组织页面请求);对话位置的贡献给能访问该对话的任何角色。
161
+ * The caller's role in the org (owner / admin / member / guest, highest first). A slot's `minRole`
162
+ * decides who may use its contributions, then its scope's rule (the kernel answers 404 to anyone
163
+ * else, so the extension never sees such a request): `org.page` member and up,
164
+ * `org.settings.section` admin and up; conversation slots any role that can read the
165
+ * conversation; `agent.detail.section` any role that can read the agent (read-only viewers
166
+ * included: tighten on `role` if you must).
155
167
  */
156
168
  export type UiRole = "owner" | "admin" | "member" | "guest";
157
- /** 内核调用插件 `render` 时传入的参数(身份由内核鉴权后给出,插件拿不到用户的会话凭据)。 */
158
- export interface UiRenderArgs {
159
- orgId: string;
169
+ /** The render args every scope shares (identity comes from the kernel's auth, never the caller's credentials). */
170
+ export interface UiRenderArgsBase {
160
171
  accountId: string;
172
+ /** The catalog slot the contribution resolved to. */
173
+ slot: UiSlotId;
174
+ locale: Locale;
175
+ signal: AbortSignal;
176
+ /**
177
+ * The extension's effective deployment config: for an org, the platform's deployment config
178
+ * with the org's copy applied to the overridable fields (`orgAccess(…, "override")`), or the
179
+ * platform's config (equal to `ctx.config`) while the org follows it; for the platform scope,
180
+ * the platform's config. Use it, not `ctx.config`, for work done for this org. Optional in the
181
+ * type so older extensions compile; the kernel always passes it.
182
+ */
183
+ config?: unknown;
184
+ }
185
+ /** 内核调用插件 `render` 时传入的参数,组织内的位置(会话、智能体、组织、文件)。 */
186
+ export interface UiOrgRenderArgs extends UiRenderArgsBase {
187
+ /** The scope of {@link slot}: which of the context ids below are set. */
188
+ scope: Exclude<UiSlotScope, "platform">;
189
+ orgId: string;
161
190
  /** 调用者此刻在 `orgId` 的角色(内核按成员关系填,插件可据此收紧按钮等,见 {@link UiRole})。 */
162
191
  role: UiRole;
163
192
  /** 仅对话视图。 */
164
193
  conversationId?: string;
165
- locale: Locale;
166
- signal: AbortSignal;
194
+ /** Only an agent view (`agent.detail.section`): an agent of `orgId` the caller can read. */
195
+ agentId?: string;
167
196
  /** A panel opened from an open target: the block or item `id` to scroll to. */
168
197
  item?: string;
169
198
  /**
@@ -175,21 +204,36 @@ export interface UiRenderArgs {
175
204
  callId: string;
176
205
  key?: string;
177
206
  };
178
- /**
179
- * The extension's effective deployment config for this org: the platform's deployment config
180
- * with the org's copy applied to the overridable fields (`orgAccess(…, "override")`), or the
181
- * platform's config (equal to `ctx.config`) while the org follows it. Use it, not `ctx.config`,
182
- * for work done for this org. Optional in the type so older extensions compile; the kernel
183
- * always passes it.
184
- */
185
- config?: unknown;
186
207
  }
187
- /** 视图里的按钮或表单提交:`actionId` 来自块;表单提交时带 `answer`。 */
188
- export interface UiActionArgs extends UiRenderArgs {
208
+ /**
209
+ * The render args of a `platform.page` contribution: rendered for a super admin in the admin
210
+ * console, with no org (and so no org role, conversation or agent).
211
+ */
212
+ export interface UiPlatformRenderArgs extends UiRenderArgsBase {
213
+ scope: "platform";
214
+ orgId?: undefined;
215
+ role: "superAdmin";
216
+ conversationId?: undefined;
217
+ agentId?: undefined;
218
+ item?: undefined;
219
+ card?: undefined;
220
+ }
221
+ /** 内核调用插件 `render` 时传入的参数:narrow on `scope` (`"platform"` has no `orgId`). */
222
+ export type UiRenderArgs = UiOrgRenderArgs | UiPlatformRenderArgs;
223
+ /** What a button or form submit adds to the render args. */
224
+ export interface UiActionInput {
189
225
  actionId: string;
190
226
  payload?: unknown;
191
227
  answer?: FormAnswer;
192
228
  }
229
+ /** An action of an org-scope view. */
230
+ export interface UiOrgActionArgs extends UiOrgRenderArgs, UiActionInput {
231
+ }
232
+ /** An action of a `platform.page` view. */
233
+ export interface UiPlatformActionArgs extends UiPlatformRenderArgs, UiActionInput {
234
+ }
235
+ /** 视图里的按钮或表单提交:`actionId` 来自块;表单提交时带 `answer`。 */
236
+ export type UiActionArgs = UiOrgActionArgs | UiPlatformActionArgs;
193
237
  /** 动作结果:一条提示,或一份新的视图。 */
194
238
  export type UiActionResult = {
195
239
  toast?: {
@@ -199,21 +243,55 @@ export type UiActionResult = {
199
243
  } | {
200
244
  view: UiView;
201
245
  };
202
- /** 嵌入令牌里的声明(经 `ctx.ui.verifyEmbedToken` 校验后可信)。`exp` 为 Unix 秒。 */
203
- export interface EmbedClaims {
246
+ /** The embed claims both scopes share. `exp` 为 Unix 秒。 */
247
+ export interface EmbedClaimsBase {
204
248
  plugin: string;
205
- orgId: string;
206
249
  accountId: string;
250
+ contribution: string;
251
+ exp: number;
252
+ }
253
+ /** An org-scope embed token's claims (every slot but `platform.page`). */
254
+ export interface OrgEmbedClaims extends EmbedClaimsBase {
255
+ scope: "org";
256
+ orgId: string;
207
257
  /** 调用者的角色:签发时写入;`ctx.ui.verifyEmbedToken` 返回校验时刻的当前角色。 */
208
258
  role: UiRole;
209
259
  conversationId?: string;
260
+ /** Only an agent embed's token (`agent.detail.section`): the agent it was issued for. */
261
+ agentId?: string;
210
262
  /** Only a `file-preview` embed's token: the one file (of `conversationId`) it previews. */
211
263
  fileId?: string;
212
- contribution: string;
213
- exp: number;
214
264
  }
265
+ /** A `platform.page` embed token's claims: a super admin's, with no org, conversation, agent or file. */
266
+ export interface PlatformEmbedClaims extends EmbedClaimsBase {
267
+ scope: "platform";
268
+ orgId?: undefined;
269
+ role: "superAdmin";
270
+ conversationId?: undefined;
271
+ agentId?: undefined;
272
+ fileId?: undefined;
273
+ }
274
+ /**
275
+ * 嵌入令牌里的声明(经 `ctx.ui.verifyEmbedToken` 校验后可信): narrow on `scope` (`"platform"` has no
276
+ * `orgId` and `role` `"superAdmin"`).
277
+ */
278
+ export type EmbedClaims = OrgEmbedClaims | PlatformEmbedClaims;
215
279
  /** 外层页面与嵌入 iframe 之间 `postMessage` 协议的版本标识。 */
216
280
  export declare const EMBED_PROTOCOL = "capybara-embed/1";
281
+ /**
282
+ * What an embed's `init` says about where it is shown, narrowed on `scope` like {@link EmbedClaims}:
283
+ * an org's page, panel, section or previewer (`orgId`, and the conversation when there is one), or
284
+ * a platform page of the admin console (no org).
285
+ */
286
+ export type EmbedInitContext = {
287
+ scope: "org";
288
+ orgId: string;
289
+ conversationId?: string;
290
+ } | {
291
+ scope: "platform";
292
+ orgId?: undefined;
293
+ conversationId?: undefined;
294
+ };
217
295
  /** 外层页面发给 iframe 的消息。 */
218
296
  export type EmbedParentMessage = {
219
297
  protocol: typeof EMBED_PROTOCOL;
@@ -222,10 +300,7 @@ export type EmbedParentMessage = {
222
300
  locale: Locale;
223
301
  theme: "dark" | "light";
224
302
  tokens: Record<string, string>;
225
- context: {
226
- orgId: string;
227
- conversationId?: string;
228
- };
303
+ context: EmbedInitContext;
229
304
  } | {
230
305
  protocol: typeof EMBED_PROTOCOL;
231
306
  type: "token";
package/dist/ui.js CHANGED
@@ -1,9 +1,5 @@
1
1
  import { validateInputForm } from "./tool.js";
2
2
  import { isHttpUrl } from "./url.js";
3
- /** A place with the old `conversation` mapped to `panel`. */
4
- export function normalizePlace(place) {
5
- return place === "conversation" ? "panel" : place;
6
- }
7
3
  /**
8
4
  * Icons a contribution may use (design system icon names; `puzzle` is the default and is drawn as
9
5
  * the design `extension` icon). The title-bar button of a panel shows it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-sdk",
3
- "version": "0.1.0-rc.8",
3
+ "version": "0.1.0-rc.9",
4
4
  "description": "Capybara plugin SDK: the types and pure functions plugins are written against",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {