@standardagents/code-plugin-sdk 1.0.0-alpha.1 → 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standardagents/code-plugin-sdk",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.2",
4
4
  "type": "module",
5
5
  "description": "Standard Code plugin authoring SDK",
6
6
  "license": "MIT",
package/src/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
2
2
  export type Capability = 'surfaces' | 'events' | 'hooks' | 'panes' | 'projects' |
3
3
  'notifications' | 'url' | 'fetch' | 'secrets' | 'webhook';
4
- export type SurfaceKind = 'section' | 'slot' | 'badge' | 'panel' | 'overlay' |
4
+ /** `card` draws a boxed card in the sidebar under the `plugins` anchor. */
5
+ export type SurfaceKind = 'section' | 'card' | 'slot' | 'badge' | 'panel' | 'overlay' |
5
6
  'menu' | 'command' | 'key' | 'link';
7
+ /** How a panel opens. Absent means `popover`. */
8
+ export type Presentation = 'popover' | 'column' | 'pane';
6
9
  export type MenuPosition = 'top' | 'after-open' | 'before-danger' | 'bottom';
7
10
  export type Anchor = 'plugins' | 'machine.before' | 'machine.after' |
8
11
  'project.before' | 'project.after' | 'pane.header' | 'pane.footer' |
@@ -19,6 +22,10 @@ export interface ContributionDeclaration {
19
22
  chord?: string;
20
23
  pattern?: string;
21
24
  actionId?: string;
25
+ /** How a panel opens (panels), or how the panel named by `opens` opens (cards and commands). */
26
+ presentation?: Presentation;
27
+ /** The id of a declared panel that a card or command opens when activated. */
28
+ opens?: string;
22
29
  }
23
30
  /** The package.json standardPlugin field is read before any plugin code runs. */
24
31
  export interface PluginManifest {
@@ -72,9 +79,11 @@ export interface NativeRow {
72
79
  spark?: number[];
73
80
  divider?: boolean;
74
81
  }
75
- export type NativeContent = { kind: 'rows'; rows: NativeRow[] } |
76
- { kind: 'text'; lines: TextSpan[][] } |
77
- { kind: 'badge'; spans: TextSpan[]; actionId?: string };
82
+ export type RowsContent = { kind: 'rows'; rows: NativeRow[] };
83
+ export type TextContent = { kind: 'text'; lines: TextSpan[][] };
84
+ export type BadgeContent = { kind: 'badge'; spans: TextSpan[]; actionId?: string };
85
+ export type NativeContent = RowsContent | TextContent | BadgeContent;
86
+ /** `columns` is also the intrinsic width of a card, column, or plugin pane that shows the canvas. */
78
87
  export interface CanvasSpec {
79
88
  columns: number;
80
89
  rows: number;
@@ -82,7 +91,57 @@ export interface CanvasSpec {
82
91
  shade?: number;
83
92
  captureInput?: boolean;
84
93
  }
85
- export type SurfaceContent = NativeContent | { kind: 'canvas'; canvas: CanvasSpec };
94
+ export type CanvasContent = { kind: 'canvas'; canvas: CanvasSpec };
95
+
96
+ /** Semantic tones. The host maps each tone to the viewer's theme. */
97
+ export type Tone = 'ok' | 'info' | 'warn' | 'error' | 'muted' | 'accent' | 'pending' | 'bright';
98
+ export type TextWeight = 'normal' | 'bold' | 'dim';
99
+ export type Align = 'start' | 'center' | 'end';
100
+ /** The host sends `actionId` and `value` to the plugin; `opens` names a panel the host opens after the plugin accepts. */
101
+ export interface ViewAction { actionId: string; value?: string; opens?: string }
102
+ export interface ViewSpan { text: string; tone?: Tone; weight?: TextWeight; mono?: boolean }
103
+ export interface StackNode { type: 'stack'; gap?: number; children?: ViewNode[] }
104
+ export interface RowNode { type: 'row'; children?: ViewNode[]; align?: Align }
105
+ export interface DividerNode { type: 'divider'; label?: string }
106
+ /** Either `text` or `spans`; a node with both draws `text` first. */
107
+ export interface TextNode { type: 'text'; text?: string; spans?: ViewSpan[]; tone?: Tone; weight?: TextWeight; mono?: boolean }
108
+ export interface BadgeNode { type: 'badge'; label: string; tone?: Tone }
109
+ export interface DotNode { type: 'dot'; tone?: Tone }
110
+ export interface Meter { value: number; max: number; tone?: Tone; label?: string }
111
+ export interface ProgressNode extends Meter { type: 'progress' }
112
+ export interface SegmentsNode { type: 'segments'; items: Meter[] }
113
+ /** A boxed group inside a panel or section view. A card contribution's view cannot contain one. */
114
+ export interface CardNode { type: 'card'; title?: string; tone?: Tone; children?: ViewNode[] }
115
+ export interface StatNode { type: 'stat'; label: string; value: string; tone?: Tone; hint?: string }
116
+ /** `copy` marks a value the viewer can copy from the focused row. */
117
+ export interface KvItem { label: string; value: string; mono?: boolean; copy?: boolean }
118
+ export interface KvNode { type: 'kv'; items: KvItem[] }
119
+ export interface TabItem { id: string; label: string; sublabel?: string; tone?: Tone; count?: number; tag?: string }
120
+ /**
121
+ * With `filters` naming a table in the same view, the host shows only rows whose `tags` contain the
122
+ * chosen item's `tag`; an item without `tag` shows every row. `action` also notifies the plugin, and
123
+ * its value defaults to the chosen item id.
124
+ */
125
+ export interface TabsNode { type: 'tabs'; id: string; items: TabItem[]; filters?: string; action?: ViewAction }
126
+ export interface SelectOption { id: string; label: string; group?: string; count?: number; tag?: string }
127
+ export interface SelectNode { type: 'select'; id: string; label: string; options: SelectOption[]; filters?: string; action?: ViewAction }
128
+ /** Lower `priority` values stay visible longest when the host drops columns to fit. */
129
+ export interface TableColumn { id: string; label?: string; width?: 'fill' | number; maxWidth?: number; align?: Align; priority?: number }
130
+ /** A missing cell draws empty. `note` is a secondary line; `tags` feed host-side filters. */
131
+ export interface TableRow { id: string; cells?: Record<string, ViewNode>; tone?: Tone; note?: ViewSpan[]; tags?: string[]; action?: ViewAction }
132
+ export interface TableNode { type: 'table'; id: string; columns: TableColumn[]; rows?: TableRow[] }
133
+ export interface LogNode { type: 'log'; lines: string[] }
134
+ export interface ButtonNode { type: 'button'; label: string; action: ViewAction }
135
+ export type ViewNode = StackNode | RowNode | DividerNode | TextNode | BadgeNode | DotNode | ProgressNode |
136
+ SegmentsNode | CardNode | StatNode | KvNode | TabsNode | SelectNode | TableNode | LogNode | ButtonNode;
137
+ /** A host-rendered view tree. Cards, panels, and sections accept it. */
138
+ export interface ViewContent { kind: 'view'; root: ViewNode }
139
+
140
+ /** Slots and overlays accept rows, text, or canvas. */
141
+ export type DrawnContent = RowsContent | TextContent | CanvasContent;
142
+ /** Sections, cards, and panels also accept a view. */
143
+ export type PanelContent = DrawnContent | ViewContent;
144
+ export type SurfaceContent = NativeContent | CanvasContent | ViewContent;
86
145
  export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
87
146
  export interface Disposable { dispose(): void }
88
147
  export type Cleanup = () => void | Promise<void>;
@@ -106,6 +165,7 @@ export interface PaneCreate {
106
165
  contributionId?: string;
107
166
  }
108
167
  export interface PaneResult { pane: EntityRef; operationId: string }
168
+ /** A view action delivers its string `value`; other selections may carry any JSON value. */
109
169
  export interface Selection { entity?: EntityRef; actionId: string; value?: Json }
110
170
  export interface LinkSelection extends Selection { url: string }
111
171
  export type ActionHandler = (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>;
@@ -186,8 +246,9 @@ export interface OperationMap {
186
246
  export type OperationName = keyof OperationMap;
187
247
  export type Operation = { [K in OperationName]: { op: K; args: OperationMap[K]['input'] } }[OperationName];
188
248
  export type Request = <K extends OperationName>(op: K, args: OperationMap[K]['input'], options?: RequestOptions) => Promise<OperationMap[K]['output']>;
189
- export interface Publisher extends Disposable {
190
- replace(content: SurfaceContent): void;
249
+ export interface Publisher<C extends SurfaceContent = SurfaceContent> extends Disposable {
250
+ /** Throws a PluginError when the content breaks a protocol bound or does not suit the contribution kind. */
251
+ replace(content: C): void;
191
252
  clear(): void;
192
253
  }
193
254
  export interface Canvas extends Publisher {
@@ -206,11 +267,13 @@ export interface PluginContext {
206
267
  readonly producer: Readonly<Producer>;
207
268
  readonly signal: AbortSignal;
208
269
  request: Request;
209
- section(id: string, entity?: EntityRef): Publisher;
210
- slot(id: string, entity: EntityRef): Publisher;
211
- badge(id: string, entity: EntityRef): Publisher;
212
- panel(id: string, entity?: EntityRef): Publisher;
213
- overlay(id: string, entity?: EntityRef): Publisher;
270
+ section(id: string, entity?: EntityRef): Publisher<PanelContent>;
271
+ card(id: string, entity?: EntityRef): Publisher<PanelContent>;
272
+ slot(id: string, entity: EntityRef): Publisher<DrawnContent>;
273
+ badge(id: string, entity: EntityRef): Publisher<BadgeContent>;
274
+ panel(id: string, entity?: EntityRef): Publisher<PanelContent>;
275
+ overlay(id: string, entity?: EntityRef): Publisher<DrawnContent>;
276
+ /** Publishes plugin-drawn content to any section, card, slot, panel, or overlay declaration. */
214
277
  canvas(id: string, spec: CanvasSpec, entity?: EntityRef): Canvas;
215
278
  /** IDs match static manifest contributions. Options override declaration defaults. */
216
279
  menu(id: string, options: MenuOptions, handler: ActionHandler): Subscription;
@@ -262,6 +325,39 @@ export interface PluginDefinition {
262
325
  export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
263
326
  export function validateManifest(value: unknown): Readonly<PluginManifest>;
264
327
  export class PluginError extends Error { code: string; constructor(code: string, message: string) }
328
+ export const PRESENTATIONS: readonly Presentation[];
329
+ export const TONES: readonly Tone[];
330
+ export const VIEW_LIMITS: Readonly<{ depth: 8; nodes: 4096; tableRows: 512; tableColumns: 12; cardLines: 6; actionValueBytes: 512 }>;
331
+ /**
332
+ * Throws a PluginError when a view tree breaks a protocol bound or a daemon rule. Unknown node types pass.
333
+ * `kind: 'card'` adds the card rules; `manifest` requires each action `opens` to name one of its panels.
334
+ */
335
+ export function validateView(root: unknown, options?: { kind?: SurfaceKind; manifest?: Pick<PluginManifest, 'contributions'> }): void;
336
+ type Options<T> = Omit<T, 'type' | 'children'>;
337
+ /** Optional builders. Each returns the plain protocol JSON for one node; hand-written JSON is equivalent. */
338
+ export const ui: {
339
+ view(root: ViewNode): ViewContent;
340
+ action(actionId: string, options?: { value?: string; opens?: string }): ViewAction;
341
+ span(text: string, options?: Omit<ViewSpan, 'text'>): ViewSpan;
342
+ stack(children: ViewNode[], options?: Options<StackNode>): StackNode;
343
+ row(children: ViewNode[], options?: Options<RowNode>): RowNode;
344
+ divider(label?: string): DividerNode;
345
+ /** A string sets `text`; an array of spans sets `spans`. */
346
+ text(content: string | ViewSpan[], options?: Omit<TextNode, 'type' | 'text' | 'spans'>): TextNode;
347
+ badge(label: string, tone?: Tone): BadgeNode;
348
+ dot(tone?: Tone): DotNode;
349
+ progress(value: number, max: number, options?: Omit<Meter, 'value' | 'max'>): ProgressNode;
350
+ segments(items: Meter[]): SegmentsNode;
351
+ card(children: ViewNode[], options?: Options<CardNode>): CardNode;
352
+ stat(label: string, value: string, options?: Omit<StatNode, 'type' | 'label' | 'value'>): StatNode;
353
+ kv(items: KvItem[]): KvNode;
354
+ tabs(id: string, items: TabItem[], options?: Omit<TabsNode, 'type' | 'id' | 'items'>): TabsNode;
355
+ select(id: string, label: string, options: SelectOption[], extra?: Omit<SelectNode, 'type' | 'id' | 'label' | 'options'>): SelectNode;
356
+ table(id: string, columns: TableColumn[], rows?: TableRow[]): TableNode;
357
+ log(lines: string[]): LogNode;
358
+ /** `action` is an action object or an action id. */
359
+ button(label: string, action: ViewAction | string): ButtonNode;
360
+ };
265
361
 
266
362
  /** One plugin inside a collection. The path is relative to the source root. */
267
363
  export interface PluginCollectionEntry {
package/src/index.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
1
+ import { PRESENTATIONS, ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
2
2
  import { COLLECTION_FILE, resolveCollection, validateCollection } from './collection.mjs'
3
3
  import { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement } from './publish.mjs'
4
4
 
5
- export { validateManifest, PluginError }
5
+ export { PRESENTATIONS, validateManifest, PluginError }
6
6
  export { COLLECTION_FILE, resolveCollection, validateCollection }
7
7
  export { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement }
8
+ export { TONES, VIEW_LIMITS, ui, validateView } from './view.mjs'
8
9
 
9
10
  export function definePlugin(definition) {
10
11
  ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
package/src/internal.d.ts CHANGED
@@ -30,7 +30,8 @@ export interface Runtime {
30
30
  }
31
31
  export function createRuntime(options: { manifest: PluginManifest; producer: Producer; send(frame: Envelope): void; clock?: Clock; instanceId?: string; onError?(error: Error): void }): Runtime;
32
32
  export function authorize(operation: Operation, capabilities: string[]): void;
33
- export function validateEnvelope(frame: unknown): Envelope;
33
+ export function frameLimit(frame: unknown, inbound: boolean): number;
34
+ export function validateEnvelope(frame: unknown, options?: { inbound?: boolean }): Envelope;
34
35
  export class RpcPeer {
35
36
  constructor(options: { producer: Producer; send(frame: Envelope): void; clock?: Clock; idPrefix?: string; onRequest?(operation: Operation | HostOperation, options: { signal: AbortSignal; timeoutMs: number }): Promise<Json> | Json; onSurface?(frame: Envelope): void; onError?(error: Error): void });
36
37
  request(operation: Operation | HostOperation, options?: RequestOptions): Promise<Json>;
package/src/manifest.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  export const CAPABILITIES = Object.freeze(['surfaces', 'events', 'hooks', 'panes', 'projects',
2
2
  'notifications', 'url', 'fetch', 'secrets', 'webhook'])
3
- export const SURFACE_KINDS = Object.freeze(['section', 'slot', 'badge', 'panel', 'overlay',
3
+ export const SURFACE_KINDS = Object.freeze(['section', 'card', 'slot', 'badge', 'panel', 'overlay',
4
4
  'menu', 'command', 'key', 'link'])
5
+ export const PRESENTATIONS = Object.freeze(['popover', 'column', 'pane'])
5
6
  export const ANCHORS = Object.freeze(['plugins', 'machine.before', 'machine.after',
6
7
  'project.before', 'project.after', 'pane.header', 'pane.footer',
7
8
  'account', 'machine', 'project', 'pane', 'section'])
9
+ // responseFrameBytes admits a host response carrying a 16 MiB fetch body after
10
+ // JSON escaping (PLUGIN_FETCH_RESULT_MAX_BYTES in standardd).
8
11
  export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
12
+ responseFrameBytes: 64 * 1024 * 1024,
9
13
  pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
10
14
  queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
11
15
  canvasColumns: 512, canvasRows: 256 })
@@ -72,13 +76,22 @@ export function validateManifest(value) {
72
76
  SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
73
77
  ids.add(declaration.id)
74
78
  for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
75
- position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
79
+ position: ['top', 'after-open', 'before-danger', 'bottom'], presentation: PRESENTATIONS })) {
76
80
  ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
77
81
  }
78
82
  for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
79
83
  ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
80
84
  'invalid_manifest', `Invalid contribution ${key}`)
81
85
  }
86
+ ensure(declaration.opens === undefined || identifier(declaration.opens), 'invalid_manifest', 'Invalid contribution opens')
87
+ ensure(declaration.presentation === undefined || ['panel', 'card', 'command'].includes(declaration.kind),
88
+ 'invalid_manifest', 'Only panels, cards, and commands declare a presentation')
89
+ ensure(declaration.kind !== 'card' || declaration.anchor === 'plugins', 'invalid_manifest', 'Cards use the plugins anchor')
90
+ }
91
+ for (const declaration of manifest.contributions) {
92
+ ensure(declaration.opens === undefined ||
93
+ manifest.contributions.some(item => item.id === declaration.opens && item.kind === 'panel'),
94
+ 'invalid_manifest', 'Contribution opens must name a declared panel')
82
95
  }
83
96
  ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
84
97
  ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
package/src/protocol.mjs CHANGED
@@ -40,8 +40,12 @@ export function validateProducer(producer) {
40
40
  export function sameProducer(a, b) {
41
41
  return a.pluginId === b.pluginId && a.machineId === b.machineId && a.epoch === b.epoch
42
42
  }
43
- export function validateEnvelope(frame) {
44
- jsonBytes(frame)
43
+ /** Only an inbound response may exceed the frame limit, so a fetch body can reach the plugin. */
44
+ export function frameLimit(frame, inbound) {
45
+ return inbound && object(frame) && frame.kind === 'response' ? LIMITS.responseFrameBytes : LIMITS.frameBytes
46
+ }
47
+ export function validateEnvelope(frame, { inbound = false } = {}) {
48
+ jsonBytes(frame, frameLimit(frame, inbound))
45
49
  ensure(object(frame) && frame.version === RUNNER_PROTOCOL_VERSION, 'incompatible_version', 'Daemon and runner protocol versions differ')
46
50
  validateProducer(frame.producer)
47
51
  ensure(['request', 'response', 'cancel', 'surface'].includes(frame.kind), 'invalid_payload', 'Unknown runner frame kind')
@@ -115,7 +119,7 @@ export class RpcPeer {
115
119
  }
116
120
  receive(frame) {
117
121
  if (this.closed) return
118
- validateEnvelope(frame)
122
+ validateEnvelope(frame, { inbound: true })
119
123
  ensure(sameProducer(frame.producer, this.producer), 'stale_producer', 'Frame belongs to another plugin instance')
120
124
  if (frame.kind === 'surface') { this.onSurface?.(frame); return }
121
125
  if (frame.kind === 'response') {
package/src/runtime.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
2
2
  import { RpcPeer, authorize, realClock } from './protocol.mjs'
3
+ import { validateView } from './view.mjs'
3
4
 
4
5
  const always = Object.freeze({ kind: 'always' })
5
6
  const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
@@ -40,9 +41,21 @@ function validateCondition(condition) {
40
41
  (condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
41
42
  return structuredClone(condition)
42
43
  }
43
- function validateContent(content) {
44
+ const CONTENT_KINDS = ['rows', 'text', 'badge', 'canvas', 'view']
45
+ /** Mirrors PluginSurfaceKind::accepts: canvas suits every drawn kind, view suits cards, panels and sections. */
46
+ export function acceptsContent(kind, content) {
47
+ if (['menu', 'command', 'key', 'link'].includes(kind)) return false
48
+ if (kind === 'badge') return content.kind === 'badge'
49
+ if (content.kind === 'badge') return false
50
+ return content.kind !== 'view' || ['section', 'card', 'panel'].includes(kind)
51
+ }
52
+ function validateContent(content, declaration, manifest) {
53
+ const { kind } = declaration
54
+ ensure(object(content) && CONTENT_KINDS.includes(content.kind), 'invalid_payload', 'Invalid surface content')
55
+ ensure(acceptsContent(kind, content), 'invalid_payload', `A ${kind} contribution cannot show ${content.kind} content`)
56
+ // The view walk bounds depth before serialization visits the tree.
57
+ if (content.kind === 'view') validateView(content.root, { kind: declaration.kind, manifest })
44
58
  jsonBytes(content)
45
- ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
46
59
  if (content.kind === 'canvas') {
47
60
  const { columns, rows, shade = 0 } = content.canvas ?? {}
48
61
  ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
@@ -102,7 +115,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
102
115
  const replace = content => {
103
116
  active()
104
117
  ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
105
- if (content !== null) validateContent(content)
118
+ if (content !== null) validateContent(content, declaration, manifest)
106
119
  peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
107
120
  }
108
121
  const publisher = { replace, clear: () => replace(null), dispose() {
@@ -292,7 +305,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
292
305
  const method = op => (args, options) => request(op, args, options)
293
306
  const context = Object.freeze({
294
307
  manifest, producer: peer.producer, signal: lifetime.signal, request,
295
- ...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
308
+ ...Object.fromEntries(['section', 'card', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
296
309
  (id, entity) => publish(kind, id, entity).publisher])),
297
310
  canvas(id, spec, entity) {
298
311
  const { publisher, key } = publish(null, id, entity)
package/src/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
1
+ import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions, EntityRef, SurfaceContent } from './index.js';
2
2
  import type { Envelope } from './internal.js';
3
3
  export interface Harness {
4
4
  context: PluginContext;
@@ -11,6 +11,8 @@ export interface Harness {
11
11
  emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
12
12
  visibility(conditions: Condition[]): Promise<Json>;
13
13
  receive(frame: Envelope): void;
14
+ /** The current content of a published contribution, or undefined when it is clear. */
15
+ surface(id: string, entity?: EntityRef): SurfaceContent | undefined;
14
16
  drainTrace(): unknown[];
15
17
  readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
16
18
  dispose(): Promise<void>;
package/src/testing.mjs CHANGED
@@ -88,6 +88,13 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
88
88
  },
89
89
  visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
90
90
  receive: frame => runtime.receive(frame),
91
+ /** The current content of a published contribution, or undefined when it is clear. */
92
+ surface(id, entity) {
93
+ const declaration = manifest.contributions.find(item => item.id === id)
94
+ if (!declaration) return undefined
95
+ const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity } : {}) }
96
+ return structuredClone(surfaces.get(JSON.stringify(key))?.content)
97
+ },
91
98
  drainTrace() { return trace.splice(0) },
92
99
  get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
93
100
  async dispose() {
package/src/view.mjs ADDED
@@ -0,0 +1,246 @@
1
+ import { LIMITS, ensure, identifier, jsonBytes, object } from './manifest.mjs'
2
+
3
+ // Mirrors crates/standard-protocol/src/plugin_view.rs. The host applies the
4
+ // same bounds; checking here reports a mistake at the replace() call.
5
+ export const VIEW_LIMITS = Object.freeze({ depth: 8, nodes: 4096, tableRows: 512, tableColumns: 12,
6
+ cardLines: 6, actionValueBytes: 512 })
7
+ export const TONES = Object.freeze(['ok', 'info', 'warn', 'error', 'muted', 'accent', 'pending', 'bright'])
8
+ const WEIGHTS = ['normal', 'bold', 'dim']
9
+ const ALIGNS = ['start', 'center', 'end']
10
+ // C0, DEL, C1, and the bidirectional formatting characters that can reorder terminal output.
11
+ const FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
12
+
13
+ const invalid = message => ensure(false, 'invalid_payload', message)
14
+ function text(value, field) {
15
+ if (typeof value !== 'string') invalid(`Plugin view ${field} must be a string`)
16
+ ensure(!FORBIDDEN.test(value), 'invalid_payload', 'Plugin view text contains a control or bidirectional character')
17
+ }
18
+ function optionalText(value, field) { if (value !== undefined) text(value, field) }
19
+ function choice(value, choices, field) {
20
+ ensure(value === undefined || choices.includes(value), 'invalid_payload', `Invalid plugin view ${field}`)
21
+ }
22
+ function tone(value) { choice(value, TONES, 'tone') }
23
+ function integer(value, max, field) {
24
+ ensure(value === undefined || (Number.isInteger(value) && value >= 0 && value <= max), 'invalid_payload', `Invalid plugin view ${field}`)
25
+ }
26
+ function flag(value, field) { ensure(value === undefined || typeof value === 'boolean', 'invalid_payload', `Invalid plugin view ${field}`) }
27
+ function number(value) {
28
+ ensure(typeof value === 'number' && Number.isFinite(value), 'invalid_payload', 'Plugin view number is not finite')
29
+ }
30
+ function list(value, field, optional = false) {
31
+ if (optional && value === undefined) return []
32
+ ensure(Array.isArray(value), 'invalid_payload', `Plugin view ${field} must be an array`)
33
+ return value
34
+ }
35
+ function entries(value, field, optional, check) {
36
+ for (const item of list(value, field, optional)) {
37
+ ensure(object(item), 'invalid_payload', `Plugin view ${field} must contain objects`)
38
+ check(item)
39
+ }
40
+ }
41
+ function spans(value, optional) {
42
+ entries(value, 'spans', optional, span => {
43
+ text(span.text, 'span text')
44
+ tone(span.tone)
45
+ choice(span.weight, WEIGHTS, 'weight')
46
+ flag(span.mono, 'mono')
47
+ })
48
+ }
49
+ function action(value, optional = true) {
50
+ if (optional && value === undefined) return
51
+ ensure(object(value), 'invalid_payload', 'Plugin view action must be an object')
52
+ text(value.actionId, 'action id')
53
+ optionalText(value.value, 'action value')
54
+ optionalText(value.opens, 'action opens')
55
+ }
56
+ function meter(item) {
57
+ number(item.value)
58
+ number(item.max)
59
+ tone(item.tone)
60
+ optionalText(item.label, 'label')
61
+ }
62
+ function children(node, depth, count) {
63
+ for (const child of list(node.children, 'children', true)) walk(child, depth + 1, count)
64
+ }
65
+
66
+ const NODES = {
67
+ stack(node, depth, count) { integer(node.gap, 255, 'gap'); children(node, depth, count) },
68
+ row(node, depth, count) { choice(node.align, ALIGNS, 'align'); children(node, depth, count) },
69
+ divider(node) { optionalText(node.label, 'label') },
70
+ text(node) {
71
+ optionalText(node.text, 'text')
72
+ spans(node.spans, true)
73
+ tone(node.tone)
74
+ choice(node.weight, WEIGHTS, 'weight')
75
+ flag(node.mono, 'mono')
76
+ },
77
+ badge(node) { text(node.label, 'label'); tone(node.tone) },
78
+ dot(node) { tone(node.tone) },
79
+ progress: meter,
80
+ segments(node) { entries(node.items, 'items', false, meter) },
81
+ card(node, depth, count) { optionalText(node.title, 'title'); tone(node.tone); children(node, depth, count) },
82
+ stat(node) {
83
+ text(node.label, 'label')
84
+ text(node.value, 'value')
85
+ tone(node.tone)
86
+ optionalText(node.hint, 'hint')
87
+ },
88
+ kv(node) {
89
+ entries(node.items, 'items', false, item => {
90
+ text(item.label, 'label')
91
+ text(item.value, 'value')
92
+ flag(item.mono, 'mono')
93
+ flag(item.copy, 'copy')
94
+ })
95
+ },
96
+ tabs(node) {
97
+ text(node.id, 'id')
98
+ optionalText(node.filters, 'filters')
99
+ action(node.action)
100
+ entries(node.items, 'items', false, item => {
101
+ text(item.id, 'id')
102
+ text(item.label, 'label')
103
+ optionalText(item.sublabel, 'sublabel')
104
+ tone(item.tone)
105
+ integer(item.count, 0xffffffff, 'count')
106
+ optionalText(item.tag, 'tag')
107
+ })
108
+ },
109
+ select(node) {
110
+ text(node.id, 'id')
111
+ text(node.label, 'label')
112
+ optionalText(node.filters, 'filters')
113
+ action(node.action)
114
+ entries(node.options, 'options', false, option => {
115
+ text(option.id, 'id')
116
+ text(option.label, 'label')
117
+ optionalText(option.group, 'group')
118
+ integer(option.count, 0xffffffff, 'count')
119
+ optionalText(option.tag, 'tag')
120
+ })
121
+ },
122
+ table(node, depth, count) {
123
+ const columns = list(node.columns, 'columns')
124
+ const rows = list(node.rows, 'rows', true)
125
+ ensure(columns.length <= VIEW_LIMITS.tableColumns, 'invalid_payload', 'Plugin table has more than 12 columns')
126
+ ensure(rows.length <= VIEW_LIMITS.tableRows, 'invalid_payload', 'Plugin table has more than 512 rows')
127
+ text(node.id, 'id')
128
+ entries(columns, 'columns', false, column => {
129
+ text(column.id, 'column id')
130
+ optionalText(column.label, 'column label')
131
+ if (column.width !== 'fill') integer(column.width, 0xffff, 'column width')
132
+ integer(column.maxWidth, 0xffff, 'column maxWidth')
133
+ choice(column.align, ALIGNS, 'align')
134
+ integer(column.priority, 255, 'column priority')
135
+ })
136
+ entries(rows, 'rows', false, row => {
137
+ text(row.id, 'row id')
138
+ tone(row.tone)
139
+ spans(row.note, true)
140
+ for (const tag of list(row.tags, 'tags', true)) text(tag, 'tag')
141
+ action(row.action)
142
+ ensure(row.cells === undefined || object(row.cells), 'invalid_payload', 'Plugin table cells must be an object')
143
+ for (const [column, cell] of Object.entries(row.cells ?? {})) {
144
+ text(column, 'cell column')
145
+ walk(cell, depth + 1, count)
146
+ }
147
+ })
148
+ },
149
+ log(node) { for (const line of list(node.lines, 'lines')) text(line, 'log line') },
150
+ button(node) { text(node.label, 'label'); action(node.action, false) },
151
+ }
152
+
153
+ function walk(node, depth, count) {
154
+ ensure(depth <= VIEW_LIMITS.depth, 'invalid_payload', 'Plugin view nests deeper than 8 nodes')
155
+ ensure(++count.nodes <= VIEW_LIMITS.nodes, 'invalid_payload', 'Plugin view has more than 4096 nodes')
156
+ ensure(object(node) && typeof node.type === 'string', 'invalid_payload', 'Plugin view node requires a type')
157
+ // A node type this SDK does not know draws nothing on hosts that do not know it either.
158
+ if (Object.hasOwn(NODES, node.type)) NODES[node.type](node, depth, count)
159
+ }
160
+
161
+ // The daemon checks below mirror crates/standardd/src/plugin_view_content.rs.
162
+
163
+ /** Visits the actions a viewer can activate, in the nodes the daemon searches. */
164
+ function visitActions(node, visit) {
165
+ switch (node.type) {
166
+ case 'stack': case 'row': case 'card':
167
+ for (const child of node.children ?? []) visitActions(child, visit)
168
+ break
169
+ case 'button': visit(node.action); break
170
+ case 'tabs': case 'select': if (node.action !== undefined) visit(node.action); break
171
+ case 'table':
172
+ for (const row of node.rows ?? []) {
173
+ if (row.action !== undefined) visit(row.action)
174
+ for (const cell of Object.values(row.cells ?? {})) visitActions(cell, visit)
175
+ }
176
+ break
177
+ }
178
+ }
179
+
180
+ const ONE_LINE = ['text', 'badge', 'dot', 'progress', 'segments', 'stat', 'divider']
181
+ const PANEL_ONLY = ['card', 'kv', 'tabs', 'select', 'table', 'log', 'button']
182
+ /** Cards hold one-line summaries arranged by stacks and rows; unknown nodes draw nothing. */
183
+ function cardLines(node) {
184
+ if (node.type === 'stack') {
185
+ const children = node.children ?? []
186
+ return children.reduce((total, child) => total + cardLines(child), (node.gap ?? 0) * Math.max(children.length - 1, 0))
187
+ }
188
+ if (node.type === 'row') return (node.children ?? []).reduce((tallest, child) => Math.max(tallest, cardLines(child)), 0)
189
+ if (ONE_LINE.includes(node.type)) return 1
190
+ ensure(!PANEL_ONLY.includes(node.type), 'invalid_payload', `A card view cannot contain a ${node.type} node`)
191
+ return 0
192
+ }
193
+
194
+ /**
195
+ * Throws a PluginError when a view tree breaks a protocol bound or a rule the
196
+ * serving daemon applies. `kind: 'card'` adds the card rules, and `manifest`
197
+ * requires every action `opens` to name one of its panels.
198
+ */
199
+ export function validateView(root, { kind, manifest } = {}) {
200
+ walk(root, 1, { nodes: 0 })
201
+ jsonBytes(root, LIMITS.frameBytes)
202
+ visitActions(root, action => {
203
+ ensure(identifier(action.actionId), 'invalid_payload', 'Plugin view action id must be a plugin identifier')
204
+ ensure(action.value === undefined || Buffer.byteLength(action.value) <= VIEW_LIMITS.actionValueBytes,
205
+ 'payload_too_large', 'Plugin view action value exceeds 512 bytes')
206
+ ensure(action.opens === undefined || identifier(action.opens), 'invalid_payload', 'Plugin view action opens must be a plugin identifier')
207
+ ensure(action.opens === undefined || !manifest ||
208
+ manifest.contributions.some(item => item.id === action.opens && item.kind === 'panel'),
209
+ 'invalid_payload', 'Plugin view action opens must name a declared panel')
210
+ })
211
+ if (kind === 'card') {
212
+ ensure(cardLines(root) <= VIEW_LIMITS.cardLines, 'payload_too_large', 'A card view is taller than 6 lines')
213
+ }
214
+ }
215
+
216
+ function compact(value) {
217
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
218
+ }
219
+ const node = (type, fields) => compact({ type, ...fields })
220
+
221
+ /** Optional builders. Each returns the plain protocol JSON for one node. */
222
+ export const ui = Object.freeze({
223
+ view: root => ({ kind: 'view', root }),
224
+ action: (actionId, { value, opens } = {}) => compact({ actionId, value, opens }),
225
+ span: (value, { tone, weight, mono } = {}) => compact({ text: value, tone, weight, mono }),
226
+ stack: (items, { gap } = {}) => node('stack', { gap, children: items }),
227
+ row: (items, { align } = {}) => node('row', { children: items, align }),
228
+ divider: label => node('divider', { label }),
229
+ /** A string sets `text`; an array of spans sets `spans`. */
230
+ text: (content, { tone, weight, mono } = {}) =>
231
+ node('text', { ...(Array.isArray(content) ? { spans: content.map(compact) } : { text: content }), tone, weight, mono }),
232
+ badge: (label, tone) => node('badge', { label, tone }),
233
+ dot: tone => node('dot', { tone }),
234
+ progress: (value, max, { tone, label } = {}) => node('progress', { value, max, tone, label }),
235
+ segments: items => node('segments', { items: items.map(compact) }),
236
+ card: (items, { title, tone } = {}) => node('card', { title, tone, children: items }),
237
+ stat: (label, value, { tone, hint } = {}) => node('stat', { label, value, tone, hint }),
238
+ kv: items => node('kv', { items: items.map(compact) }),
239
+ tabs: (id, items, { filters, action } = {}) => node('tabs', { id, items: items.map(compact), filters, action }),
240
+ select: (id, label, options, { filters, action } = {}) =>
241
+ node('select', { id, label, options: options.map(compact), filters, action }),
242
+ table: (id, columns, rows = []) => node('table', { id, columns: columns.map(compact), rows: rows.map(compact) }),
243
+ log: lines => node('log', { lines }),
244
+ /** `action` is an action object or an action id. */
245
+ button: (label, action) => node('button', { label, action: typeof action === 'string' ? { actionId: action } : action }),
246
+ })