@standardagents/code-plugin-sdk 1.0.0-alpha.1 → 1.0.0-alpha.11-headers.0

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/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 sidebar card under the `plugins` anchor, with an optional border. */
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' |
@@ -11,6 +14,10 @@ export interface ContributionDeclaration {
11
14
  id: string;
12
15
  kind: SurfaceKind;
13
16
  anchor: Anchor;
17
+ /** Sidebar card border. Defaults to true; false retains the title and content padding. */
18
+ border?: boolean;
19
+ /** One author-selected Nerd Font glyph; omitted on non-Nerd viewer tiers. */
20
+ icon?: string;
14
21
  title?: string;
15
22
  merge?: 'by-machine' | 'by-identity';
16
23
  width?: 'full' | 'half';
@@ -19,6 +26,10 @@ export interface ContributionDeclaration {
19
26
  chord?: string;
20
27
  pattern?: string;
21
28
  actionId?: string;
29
+ /** How a panel opens (panels), or how the panel named by `opens` opens (cards and commands). */
30
+ presentation?: Presentation;
31
+ /** The id of a declared panel that a card or command opens when activated. */
32
+ opens?: string;
22
33
  }
23
34
  /** The package.json standardPlugin field is read before any plugin code runs. */
24
35
  export interface PluginManifest {
@@ -62,7 +73,23 @@ export interface TextSpan {
62
73
  underline?: boolean;
63
74
  actionId?: string;
64
75
  }
76
+ /** Viewer-resolved grouping and ordered truncation for pane header/footer rows. */
77
+ export interface RowLayout {
78
+ projectTint?: boolean;
79
+ fields: Array<{
80
+ text: string;
81
+ icon?: string;
82
+ fallback?: string;
83
+ tone?: 'muted' | 'ok' | 'error' | 'info' | 'warning' | 'project';
84
+ surface?: boolean;
85
+ bold?: boolean;
86
+ gap?: number;
87
+ flex?: boolean;
88
+ shrink?: { priority: number; min: number; mode: 'start' | 'middle' | 'end' | 'hide' };
89
+ }>;
90
+ }
65
91
  export interface NativeRow {
92
+ layout?: RowLayout;
66
93
  id: string;
67
94
  identity?: string;
68
95
  providerRevision?: string;
@@ -72,17 +99,91 @@ export interface NativeRow {
72
99
  spark?: number[];
73
100
  divider?: boolean;
74
101
  }
75
- export type NativeContent = { kind: 'rows'; rows: NativeRow[] } |
76
- { kind: 'text'; lines: TextSpan[][] } |
77
- { kind: 'badge'; spans: TextSpan[]; actionId?: string };
102
+ export type RowsContent = { kind: 'rows'; rows: NativeRow[] };
103
+ export type TextContent = { kind: 'text'; lines: TextSpan[][] };
104
+ export type BadgeContent = { kind: 'badge'; spans: TextSpan[]; actionId?: string };
105
+ export type NativeContent = RowsContent | TextContent | BadgeContent;
106
+ /** `columns` sets canvas content width. Sidebar card borders fill the sidebar; columns and panes size to content. */
107
+ export interface CanvasThemeColor {
108
+ /** ANSI palette index 0..15; omitted uses the viewer's default foreground. */
109
+ source?: number;
110
+ /** Optional second ANSI palette index, mixed equally with source. */
111
+ mix?: number;
112
+ /** Blend against the viewer background, from 0 to 1. */
113
+ opacity: number;
114
+ }
78
115
  export interface CanvasSpec {
116
+ /** Card-only passive header text, right aligned and clipped to leave room for the title. Maximum 512 UTF-8 bytes. */
117
+ titleRight?: string;
118
+ /** Up to 32 canvas-local indexed color recipes. Keys are ANSI indexes 0..255. */
119
+ themeColors?: Record<number, CanvasThemeColor>;
79
120
  columns: number;
80
121
  rows: number;
81
122
  transparent?: boolean;
82
123
  shade?: number;
83
124
  captureInput?: boolean;
125
+ /** A bounded one-line string or styled spans shown while the pointer rests over the canvas. */
126
+ hover?: string | TextSpan[];
84
127
  }
85
- export type SurfaceContent = NativeContent | { kind: 'canvas'; canvas: CanvasSpec };
128
+ export type CanvasContent = { kind: 'canvas'; canvas: CanvasSpec };
129
+ /**
130
+ * What an `onInput` handler for a canvas receives. Keys arrive while the canvas
131
+ * declares `captureInput` and holds focus; focus loss arrives when the host
132
+ * stops showing it.
133
+ */
134
+ export type CanvasInputEvent =
135
+ | { kind: 'key'; key: string; phase: 'press' | 'repeat'; contributionId: string; entity?: EntityRef }
136
+ | { kind: 'focus'; focused: false; contributionId: string; entity?: EntityRef };
137
+
138
+ /** Semantic tones. The host maps each tone to the viewer's theme. */
139
+ export type Tone = 'ok' | 'info' | 'warn' | 'error' | 'muted' | 'accent' | 'pending' | 'bright';
140
+ export type TextWeight = 'normal' | 'bold' | 'dim';
141
+ export type Align = 'start' | 'center' | 'end';
142
+ /** The host sends `actionId` and `value` to the plugin; `opens` names a panel the host opens after the plugin accepts. */
143
+ export interface ViewAction { actionId: string; value?: string; opens?: string }
144
+ export interface ViewSpan { text: string; tone?: Tone; weight?: TextWeight; mono?: boolean }
145
+ export interface StackNode { type: 'stack'; gap?: number; children?: ViewNode[] }
146
+ export interface RowNode { type: 'row'; children?: ViewNode[]; align?: Align }
147
+ export interface DividerNode { type: 'divider'; label?: string }
148
+ /** Either `text` or `spans`; a node with both draws `text` first. */
149
+ export interface TextNode { type: 'text'; text?: string; spans?: ViewSpan[]; tone?: Tone; weight?: TextWeight; mono?: boolean }
150
+ export interface BadgeNode { type: 'badge'; label: string; tone?: Tone }
151
+ export interface DotNode { type: 'dot'; tone?: Tone }
152
+ export interface Meter { value: number; max: number; tone?: Tone; label?: string }
153
+ export interface ProgressNode extends Meter { type: 'progress' }
154
+ export interface SegmentsNode { type: 'segments'; items: Meter[] }
155
+ /** A boxed group inside a panel or section view. A card contribution's view cannot contain one. */
156
+ export interface CardNode { type: 'card'; title?: string; tone?: Tone; children?: ViewNode[] }
157
+ export interface StatNode { type: 'stat'; label: string; value: string; tone?: Tone; hint?: string }
158
+ /** `copy` marks a value the viewer can copy from the focused row. */
159
+ export interface KvItem { label: string; value: string; mono?: boolean; copy?: boolean }
160
+ export interface KvNode { type: 'kv'; items: KvItem[] }
161
+ export interface TabItem { id: string; label: string; sublabel?: string; tone?: Tone; count?: number; tag?: string }
162
+ /**
163
+ * With `filters` naming a table in the same view, the host shows only rows whose `tags` contain the
164
+ * chosen item's `tag`; an item without `tag` shows every row. `action` also notifies the plugin, and
165
+ * its value defaults to the chosen item id.
166
+ */
167
+ export interface TabsNode { type: 'tabs'; id: string; items: TabItem[]; filters?: string; action?: ViewAction }
168
+ export interface SelectOption { id: string; label: string; group?: string; count?: number; tag?: string }
169
+ export interface SelectNode { type: 'select'; id: string; label: string; options: SelectOption[]; filters?: string; action?: ViewAction }
170
+ /** Lower `priority` values stay visible longest when the host drops columns to fit. */
171
+ export interface TableColumn { id: string; label?: string; width?: 'fill' | number; maxWidth?: number; align?: Align; priority?: number }
172
+ /** A missing cell draws empty. `note` is a secondary line; `tags` feed host-side filters. */
173
+ export interface TableRow { id: string; cells?: Record<string, ViewNode>; tone?: Tone; note?: ViewSpan[]; tags?: string[]; action?: ViewAction }
174
+ export interface TableNode { type: 'table'; id: string; columns: TableColumn[]; rows?: TableRow[] }
175
+ export interface LogNode { type: 'log'; lines: string[] }
176
+ export interface ButtonNode { type: 'button'; label: string; action: ViewAction }
177
+ export type ViewNode = StackNode | RowNode | DividerNode | TextNode | BadgeNode | DotNode | ProgressNode |
178
+ SegmentsNode | CardNode | StatNode | KvNode | TabsNode | SelectNode | TableNode | LogNode | ButtonNode;
179
+ /** A host-rendered view tree. Cards, panels, and sections accept it. */
180
+ export interface ViewContent { kind: 'view'; root: ViewNode; /** Passive styled title for card views. */ title?: TextSpan[] }
181
+
182
+ /** Slots and overlays accept rows, text, or canvas. */
183
+ export type DrawnContent = RowsContent | TextContent | CanvasContent;
184
+ /** Sections, cards, and panels also accept a view. */
185
+ export type PanelContent = DrawnContent | ViewContent;
186
+ export type SurfaceContent = NativeContent | CanvasContent | ViewContent;
86
187
  export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
87
188
  export interface Disposable { dispose(): void }
88
189
  export type Cleanup = () => void | Promise<void>;
@@ -106,6 +207,7 @@ export interface PaneCreate {
106
207
  contributionId?: string;
107
208
  }
108
209
  export interface PaneResult { pane: EntityRef; operationId: string }
210
+ /** A view action delivers its string `value`; other selections may carry any JSON value. */
109
211
  export interface Selection { entity?: EntityRef; actionId: string; value?: Json }
110
212
  export interface LinkSelection extends Selection { url: string }
111
213
  export type ActionHandler = (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>;
@@ -147,6 +249,54 @@ export interface PluginEvent {
147
249
  data: Json;
148
250
  deliveryId?: string;
149
251
  }
252
+ /** The native release this machine runs. */
253
+ export type BuildInfo = {
254
+ version: string;
255
+ /** The 40-character lowercase commit SHA. */
256
+ commit: string;
257
+ /** The Git ref of the build's source, such as `refs/heads/main`, or null when it is not known. */
258
+ ref: string | null;
259
+ channel: 'branch' | 'canary' | 'production' | 'team' | null;
260
+ };
261
+ /** The account-wide build policy every machine in the fleet follows. */
262
+ export type FleetPolicy = {
263
+ mode: 'follow' | 'pin' | 'production' | null;
264
+ npmTag: string | null;
265
+ /** The Git ref of the followed channel, or null when it is not known. */
266
+ ref: string | null;
267
+ /** The pinned or target version, or null when it is not known. */
268
+ version: string | null;
269
+ };
270
+ /** A local project available to plugins on its owning machine. */
271
+ export interface PluginProjectContext { entity: EntityRef; name: string; path: string }
272
+ /** Pane context with its configured project launch directory. */
273
+ export interface PluginPaneContext { entity: EntityRef; projectId: string; name: string; cwd: string }
274
+ /** The `context.get` result. `build` and `fleet` are null when unknown. */
275
+ export type PluginContextInfo = {
276
+ accountId: string;
277
+ machineId: string;
278
+ projects: PluginProjectContext[];
279
+ panes: PluginPaneContext[];
280
+ /** False when local records could not be included or decoded. */
281
+ complete: boolean;
282
+ build?: BuildInfo | null;
283
+ fleet?: FleetPolicy | null;
284
+ };
285
+ /** Replaces the local pane/project context after an account change. */
286
+ export type PaneContextEventData = Pick<PluginContextInfo, 'machineId' | 'projects' | 'panes' | 'complete'>;
287
+ export interface PaneContextEvent extends PluginEvent {
288
+ name: 'pane-context';
289
+ data: PaneContextEventData;
290
+ }
291
+ /** The `data` of a `build-context` event. */
292
+ export type BuildContextEventData = {
293
+ build: BuildInfo | null;
294
+ fleet: FleetPolicy | null;
295
+ };
296
+ export interface BuildContextEvent extends PluginEvent {
297
+ name: 'build-context';
298
+ data: BuildContextEventData;
299
+ }
150
300
  export interface Popover {
151
301
  kind: 'chooser' | 'form' | 'confirm';
152
302
  title: string;
@@ -171,7 +321,8 @@ export interface OperationMap {
171
321
  'config.get': { input: Record<string, never>; output: Record<string, Json> };
172
322
  'state.get': { input: { key: string }; output: Json };
173
323
  'state.set': { input: { key: string; value: Json }; output: null };
174
- 'context.get': { input: Record<string, never>; output: Json };
324
+ 'state.keys': { input: Record<string, never>; output: string[] };
325
+ 'context.get': { input: Record<string, never>; output: PluginContextInfo };
175
326
  'popover.open': { input: Popover; output: { choiceId?: string; values?: Record<string, Json>; confirmationId?: string } | null };
176
327
  'canvas.write': { input: { key: ContributionKey; ansi: string }; output: null };
177
328
  'canvas.focus': { input: { key: ContributionKey; capture: boolean }; output: null };
@@ -186,8 +337,9 @@ export interface OperationMap {
186
337
  export type OperationName = keyof OperationMap;
187
338
  export type Operation = { [K in OperationName]: { op: K; args: OperationMap[K]['input'] } }[OperationName];
188
339
  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;
340
+ export interface Publisher<C extends SurfaceContent = SurfaceContent> extends Disposable {
341
+ /** Throws a PluginError when the content breaks a protocol bound or does not suit the contribution kind. */
342
+ replace(content: C): void;
191
343
  clear(): void;
192
344
  }
193
345
  export interface Canvas extends Publisher {
@@ -206,11 +358,13 @@ export interface PluginContext {
206
358
  readonly producer: Readonly<Producer>;
207
359
  readonly signal: AbortSignal;
208
360
  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;
361
+ section(id: string, entity?: EntityRef): Publisher<PanelContent>;
362
+ card(id: string, entity?: EntityRef): Publisher<PanelContent>;
363
+ slot(id: string, entity: EntityRef): Publisher<DrawnContent>;
364
+ badge(id: string, entity: EntityRef): Publisher<BadgeContent>;
365
+ panel(id: string, entity?: EntityRef): Publisher<PanelContent>;
366
+ overlay(id: string, entity?: EntityRef): Publisher<DrawnContent>;
367
+ /** Publishes plugin-drawn content to any section, card, slot, panel, or overlay declaration. */
214
368
  canvas(id: string, spec: CanvasSpec, entity?: EntityRef): Canvas;
215
369
  /** IDs match static manifest contributions. Options override declaration defaults. */
216
370
  menu(id: string, options: MenuOptions, handler: ActionHandler): Subscription;
@@ -224,9 +378,12 @@ export interface PluginContext {
224
378
  key(id: string, handler: ActionHandler): KeyRegistration;
225
379
  link(id: string, options: LinkOptions, handler: LinkHandler): Subscription;
226
380
  link(id: string, handler: LinkHandler): Subscription;
381
+ onEvent(name: 'pane-context', handler: (event: PaneContextEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
382
+ onEvent(name: 'build-context', handler: (event: BuildContextEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
227
383
  onEvent(name: string, handler: (event: PluginEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
228
384
  onHook(name: string, handler: (event: HookEvent, context: HandlerContext) => HookResult | Promise<HookResult>): Subscription;
229
385
  onAction(name: string, handler: (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>): Subscription;
386
+ /** For a canvas, `name` is its contribution ID and events are `CanvasInputEvent` values. */
230
387
  onInput(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
231
388
  onSelect(name: string, handler: (event: Selection, context: HandlerContext) => void | Promise<void>): Subscription;
232
389
  onResize(name: string, handler: (event: { columns: number; rows: number }, context: HandlerContext) => void | Promise<void>): Subscription;
@@ -248,9 +405,9 @@ export interface PluginContext {
248
405
  fetch(args: OperationMap['fetch']['input'], options?: RequestOptions): Promise<OperationMap['fetch']['output']>;
249
406
  secrets: { get(name: string, options?: RequestOptions): Promise<string | null> };
250
407
  config: { get(options?: RequestOptions): Promise<Record<string, Json>> };
251
- /** Local state for one machine. It is never shared with other machines. */
252
- state: { get(key: string, options?: RequestOptions): Promise<Json>; set(key: string, value: Json, options?: RequestOptions): Promise<null> };
253
- context: { get(options?: RequestOptions): Promise<Json> };
408
+ /** Per-plugin key and value storage, held in the user's account and shared across their machines: 256 keys per plugin, 64 KiB per value. `keys()` returns the sorted key names. */
409
+ state: { get(key: string, options?: RequestOptions): Promise<Json>; set(key: string, value: Json, options?: RequestOptions): Promise<null>; keys(options?: RequestOptions): Promise<string[]> };
410
+ context: { get(options?: RequestOptions): Promise<PluginContextInfo> };
254
411
  popover: { open(args: Popover, options?: RequestOptions): Promise<OperationMap['popover.open']['output']> };
255
412
  health: { set(args: OperationMap['health.set']['input'], options?: RequestOptions): Promise<null> };
256
413
  webhook: { ack(deliveryId: string, options?: RequestOptions): Promise<null> };
@@ -262,6 +419,39 @@ export interface PluginDefinition {
262
419
  export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
263
420
  export function validateManifest(value: unknown): Readonly<PluginManifest>;
264
421
  export class PluginError extends Error { code: string; constructor(code: string, message: string) }
422
+ export const PRESENTATIONS: readonly Presentation[];
423
+ export const TONES: readonly Tone[];
424
+ export const VIEW_LIMITS: Readonly<{ depth: 8; nodes: 4096; tableRows: 512; tableColumns: 12; items: 512; logLines: 2048; cardLines: 6; actionValueBytes: 512 }>;
425
+ /**
426
+ * Throws a PluginError when a view tree breaks a protocol bound or a daemon rule. Unknown node types pass.
427
+ * `kind: 'card'` adds the card rules; `manifest` requires each action `opens` to name one of its panels.
428
+ */
429
+ export function validateView(root: unknown, options?: { kind?: SurfaceKind; manifest?: Pick<PluginManifest, 'contributions'> }): void;
430
+ type Options<T> = Omit<T, 'type' | 'children'>;
431
+ /** Optional builders. Each returns the plain protocol JSON for one node; hand-written JSON is equivalent. */
432
+ export const ui: {
433
+ view(root: ViewNode, options?: { title?: TextSpan[] }): ViewContent;
434
+ action(actionId: string, options?: { value?: string; opens?: string }): ViewAction;
435
+ span(text: string, options?: Omit<ViewSpan, 'text'>): ViewSpan;
436
+ stack(children: ViewNode[], options?: Options<StackNode>): StackNode;
437
+ row(children: ViewNode[], options?: Options<RowNode>): RowNode;
438
+ divider(label?: string): DividerNode;
439
+ /** A string sets `text`; an array of spans sets `spans`. */
440
+ text(content: string | ViewSpan[], options?: Omit<TextNode, 'type' | 'text' | 'spans'>): TextNode;
441
+ badge(label: string, tone?: Tone): BadgeNode;
442
+ dot(tone?: Tone): DotNode;
443
+ progress(value: number, max: number, options?: Omit<Meter, 'value' | 'max'>): ProgressNode;
444
+ segments(items: Meter[]): SegmentsNode;
445
+ card(children: ViewNode[], options?: Options<CardNode>): CardNode;
446
+ stat(label: string, value: string, options?: Omit<StatNode, 'type' | 'label' | 'value'>): StatNode;
447
+ kv(items: KvItem[]): KvNode;
448
+ tabs(id: string, items: TabItem[], options?: Omit<TabsNode, 'type' | 'id' | 'items'>): TabsNode;
449
+ select(id: string, label: string, options: SelectOption[], extra?: Omit<SelectNode, 'type' | 'id' | 'label' | 'options'>): SelectNode;
450
+ table(id: string, columns: TableColumn[], rows?: TableRow[]): TableNode;
451
+ log(lines: string[]): LogNode;
452
+ /** `action` is an action object or an action id. */
453
+ button(label: string, action: ViewAction | string): ButtonNode;
454
+ };
265
455
 
266
456
  /** One plugin inside a collection. The path is relative to the source root. */
267
457
  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,14 +1,19 @@
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
- canvasColumns: 512, canvasRows: 256 })
15
+ canvasColumns: 512, canvasRows: 256, canvasHoverBytes: 512,
16
+ stateKeys: 256, stateValueBytes: 64 * 1024 })
12
17
 
13
18
  export class PluginError extends Error {
14
19
  constructor(code, message) { super(message); this.name = 'PluginError'; this.code = code }
@@ -71,14 +76,28 @@ export function validateManifest(value) {
71
76
  ensure(object(declaration) && identifier(declaration.id) && !ids.has(declaration.id) &&
72
77
  SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
73
78
  ids.add(declaration.id)
79
+ ensure(declaration.icon === undefined || (declaration.kind === 'card' && typeof declaration.icon === 'string' &&
80
+ /^[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]$/u.test(declaration.icon)),
81
+ 'invalid_manifest', 'Card icon must be one Nerd Font private-use glyph')
82
+ ensure(declaration.border === undefined || (typeof declaration.border === 'boolean' && declaration.kind === 'card'),
83
+ 'invalid_manifest', 'Only sidebar cards declare a boolean border preference')
74
84
  for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
75
- position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
85
+ position: ['top', 'after-open', 'before-danger', 'bottom'], presentation: PRESENTATIONS })) {
76
86
  ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
77
87
  }
78
88
  for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
79
89
  ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
80
90
  'invalid_manifest', `Invalid contribution ${key}`)
81
91
  }
92
+ ensure(declaration.opens === undefined || identifier(declaration.opens), 'invalid_manifest', 'Invalid contribution opens')
93
+ ensure(declaration.presentation === undefined || ['panel', 'card', 'command'].includes(declaration.kind),
94
+ 'invalid_manifest', 'Only panels, cards, and commands declare a presentation')
95
+ ensure(declaration.kind !== 'card' || declaration.anchor === 'plugins', 'invalid_manifest', 'Cards use the plugins anchor')
96
+ }
97
+ for (const declaration of manifest.contributions) {
98
+ ensure(declaration.opens === undefined ||
99
+ manifest.contributions.some(item => item.id === declaration.opens && item.kind === 'panel'),
100
+ 'invalid_manifest', 'Contribution opens must name a declared panel')
82
101
  }
83
102
  ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
84
103
  ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
package/src/protocol.mjs CHANGED
@@ -8,7 +8,7 @@ export const OPERATIONS = Object.freeze({
8
8
  'pane.input': 'panes', 'pane.focus': 'panes', 'pane.wait': 'panes',
9
9
  'project.create': 'projects', 'project.remove': 'projects',
10
10
  'notification.show': 'notifications', 'url.open': 'url', fetch: 'fetch',
11
- 'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null,
11
+ 'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null, 'state.keys': null,
12
12
  'context.get': null, 'popover.open': 'surfaces', 'canvas.write': 'surfaces',
13
13
  'canvas.focus': 'surfaces', 'subscription.add': null, 'subscription.remove': null,
14
14
  'health.set': null, 'webhook.ack': 'webhook',
@@ -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') {
@@ -0,0 +1,29 @@
1
+ import { ensure, object } from './manifest.mjs'
2
+ const forbidden = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
3
+ const validText = (v, n) => typeof v === 'string' && Buffer.byteLength(v) <= n && !forbidden.test(v)
4
+ const only = (v, keys) => Object.keys(v).every(key => keys.includes(key))
5
+ export function validateRowLayout(layout) {
6
+ const valid = object(layout) && only(layout, ['projectTint','fields'])
7
+ && (layout.projectTint === undefined || typeof layout.projectTint === 'boolean')
8
+ && Array.isArray(layout.fields) && layout.fields.length > 0 && layout.fields.length <= 32
9
+ ensure(valid, 'invalid_payload', 'Invalid row layout')
10
+ let bytes = 0
11
+ for (const f of layout.fields) {
12
+ ensure(object(f) && only(f, ['text','icon','fallback','tone','surface','bold','gap','flex','shrink'])
13
+ && validText(f.text, 2048)
14
+ && (f.icon === undefined || (typeof f.icon === 'string' && /^[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]$/u.test(f.icon)))
15
+ && (f.fallback === undefined || (validText(f.fallback,16) && /^[\x20-\x7e]*$/.test(f.fallback)))
16
+ && (f.tone === undefined || ['muted','ok','error','info','warning','project'].includes(f.tone))
17
+ && ['surface','bold','flex'].every(k=>f[k] === undefined || typeof f[k] === 'boolean')
18
+ && (f.gap === undefined || Number.isInteger(f.gap) && f.gap >= 0 && f.gap <= 8),
19
+ 'invalid_payload', 'Invalid row field')
20
+ bytes += Buffer.byteLength(f.text)
21
+ if (f.shrink !== undefined) {
22
+ const s = f.shrink
23
+ ensure(object(s) && only(s,['priority','min','mode']) && Number.isInteger(s.priority) && s.priority>=0 && s.priority<=255
24
+ && Number.isInteger(s.min) && s.min>=0 && s.min<=2048 && ['start','middle','end','hide'].includes(s.mode),
25
+ 'invalid_payload', 'Invalid row shrink policy')
26
+ }
27
+ }
28
+ ensure(bytes <= 8192, 'payload_too_large', 'Row layout text exceeds 8192 bytes')
29
+ }
package/src/runtime.mjs CHANGED
@@ -1,5 +1,7 @@
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'
4
+ import { validateRowLayout } from './row-layout.mjs'
3
5
 
4
6
  const always = Object.freeze({ kind: 'always' })
5
7
  const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
@@ -9,6 +11,43 @@ function boundedText(value, label, limit = 512) {
9
11
  !/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
10
12
  return value
11
13
  }
14
+ const HOVER_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
15
+ function validatePassiveText(value, limit = LIMITS.canvasHoverBytes, label = 'canvas hover') {
16
+ if (typeof value === 'string') {
17
+ ensure(value.trim().length > 0 && !HOVER_FORBIDDEN.test(value),
18
+ 'invalid_payload', `Invalid ${label} text`)
19
+ ensure(Buffer.byteLength(value) <= limit,
20
+ 'payload_too_large', `${label} text exceeds ${limit} bytes`)
21
+ return
22
+ }
23
+ ensure(Array.isArray(value), 'invalid_payload', `Invalid ${label} spans`)
24
+ ensure(value.length > 0, 'invalid_payload', `${label} spans must contain text`)
25
+ ensure(value.length <= 32, 'payload_too_large', `${label} spans exceed 32 spans`)
26
+ let text = ''
27
+ for (const span of value) {
28
+ ensure(object(span), 'invalid_payload', `Invalid ${label} span`)
29
+ ensure(typeof span.text === 'string' && !HOVER_FORBIDDEN.test(span.text),
30
+ 'invalid_payload', `Invalid ${label} span text`)
31
+ for (const field of ['foreground', 'background']) {
32
+ if (span[field] !== undefined) {
33
+ ensure(typeof span[field] === 'string' && span[field].trim().length > 0 &&
34
+ !HOVER_FORBIDDEN.test(span[field]), 'invalid_payload', `Invalid ${label} span ${field}`)
35
+ ensure(Buffer.byteLength(span[field]) <= 64,
36
+ 'payload_too_large', `${label} span ${field} exceeds 64 bytes`)
37
+ }
38
+ }
39
+ for (const field of ['bold', 'italic', 'underline']) {
40
+ ensure(span[field] === undefined || typeof span[field] === 'boolean',
41
+ 'invalid_payload', `Invalid ${label} span ${field}`)
42
+ }
43
+ ensure(span.actionId === undefined, 'invalid_payload', `${label} spans cannot contain actionId`)
44
+ text += span.text
45
+ }
46
+ ensure(text.trim().length > 0 && !HOVER_FORBIDDEN.test(text),
47
+ 'invalid_payload', `Invalid ${label} text`)
48
+ ensure(Buffer.byteLength(text) <= limit,
49
+ 'payload_too_large', `${label} text exceeds ${limit} bytes`)
50
+ }
12
51
  function validateEntity(entity) {
13
52
  ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
14
53
  boundedText(entity.id, 'entity id', 128)
@@ -40,14 +79,53 @@ function validateCondition(condition) {
40
79
  (condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
41
80
  return structuredClone(condition)
42
81
  }
43
- function validateContent(content) {
82
+ const CONTENT_KINDS = ['rows', 'text', 'badge', 'canvas', 'view']
83
+ /** Mirrors PluginSurfaceKind::accepts: canvas suits every drawn kind, view suits cards, panels and sections. */
84
+ export function acceptsContent(kind, content) {
85
+ if (['menu', 'command', 'key', 'link'].includes(kind)) return false
86
+ if (kind === 'badge') return content.kind === 'badge'
87
+ if (content.kind === 'badge') return false
88
+ return content.kind !== 'view' || ['section', 'card', 'panel'].includes(kind)
89
+ }
90
+ function validateContent(content, declaration, manifest) {
91
+ const { kind } = declaration
92
+ ensure(object(content) && CONTENT_KINDS.includes(content.kind), 'invalid_payload', 'Invalid surface content')
93
+ ensure(acceptsContent(kind, content), 'invalid_payload', `A ${kind} contribution cannot show ${content.kind} content`)
94
+ // The view walk bounds depth before serialization visits the tree.
95
+ if (content.kind === 'view') {
96
+ validateView(content.root, { kind: declaration.kind, manifest })
97
+ if (content.title !== undefined) {
98
+ ensure(kind === 'card' && Array.isArray(content.title), 'invalid_payload', 'Styled titles belong to card views')
99
+ validatePassiveText(content.title, 512, 'card title')
100
+ }
101
+ }
102
+ if (content.kind === 'rows') {
103
+ for (const row of content.rows ?? []) if (row.layout !== undefined) {
104
+ ensure(['pane.header', 'pane.footer'].includes(declaration.anchor), 'invalid_payload', 'Row layouts require a pane header or footer')
105
+ validateRowLayout(row.layout)
106
+ }
107
+ }
44
108
  jsonBytes(content)
45
- ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
46
109
  if (content.kind === 'canvas') {
47
- const { columns, rows, shade = 0 } = content.canvas ?? {}
110
+ const { columns, rows, shade = 0, hover, themeColors, titleRight } = content.canvas ?? {}
48
111
  ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
49
112
  Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
50
113
  Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
114
+ if (themeColors !== undefined) {
115
+ ensure(object(themeColors) && Object.keys(themeColors).length <= 32, 'invalid_payload', 'Invalid canvas theme colors')
116
+ for (const [index, recipe] of Object.entries(themeColors)) {
117
+ ensure(/^(0|[1-9][0-9]{0,2})$/.test(index) && Number(index) <= 255 && object(recipe) &&
118
+ Object.keys(recipe).every(key=>['source','mix','opacity'].includes(key)) &&
119
+ ['source','mix'].every(key=>recipe[key] === undefined || (Number.isInteger(recipe[key]) && recipe[key]>=0 && recipe[key]<16)) &&
120
+ Number.isFinite(recipe.opacity) && recipe.opacity>=0 && recipe.opacity<=1,
121
+ 'invalid_payload', 'Invalid canvas theme color recipe')
122
+ }
123
+ }
124
+ if (titleRight !== undefined) {
125
+ ensure(declaration.kind === 'card' && typeof titleRight === 'string', 'invalid_payload', 'Canvas titleRight requires a card and string')
126
+ validatePassiveText(titleRight, 512, 'card header')
127
+ }
128
+ if (hover !== undefined) validatePassiveText(hover)
51
129
  }
52
130
  }
53
131
 
@@ -102,7 +180,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
102
180
  const replace = content => {
103
181
  active()
104
182
  ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
105
- if (content !== null) validateContent(content)
183
+ if (content !== null) validateContent(content, declaration, manifest)
106
184
  peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
107
185
  }
108
186
  const publisher = { replace, clear: () => replace(null), dispose() {
@@ -292,7 +370,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
292
370
  const method = op => (args, options) => request(op, args, options)
293
371
  const context = Object.freeze({
294
372
  manifest, producer: peer.producer, signal: lifetime.signal, request,
295
- ...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
373
+ ...Object.fromEntries(['section', 'card', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
296
374
  (id, entity) => publish(kind, id, entity).publisher])),
297
375
  canvas(id, spec, entity) {
298
376
  const { publisher, key } = publish(null, id, entity)
@@ -321,7 +399,8 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
321
399
  secrets: Object.freeze({ get: (name, options) => request('secret.get', { name }, options) }),
322
400
  config: Object.freeze({ get: options => request('config.get', {}, options) }),
323
401
  state: Object.freeze({ get: (key, options) => request('state.get', { key }, options),
324
- set: (key, value, options) => request('state.set', { key, value }, options) }),
402
+ set: (key, value, options) => request('state.set', { key, value }, options),
403
+ keys: options => request('state.keys', {}, options) }),
325
404
  context: Object.freeze({ get: options => request('context.get', {}, options) }),
326
405
  popover: Object.freeze({ open: method('popover.open') }),
327
406
  health: Object.freeze({ set: method('health.set') }),
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>;