@standardagents/code-plugin-sdk 0.0.0-stub.0 → 1.0.0-alpha.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/README.md CHANGED
@@ -1,4 +1,91 @@
1
- # @standardagents/code-plugin-sdk
1
+ # Standard Code plugin SDK
2
2
 
3
- This version is a placeholder that reserves the package name. It contains no
4
- code. Use a later version to author Standard Code plugins.
3
+ `@standardagents/code-plugin-sdk` is the authoring package for Standard Code
4
+ plugins. Standard Code bundles the SDK runtime into its signed release. The
5
+ manifest `apiVersion` gates compatibility between a plugin and the product.
6
+
7
+ A plugin exports `definePlugin({ id, activate })` as its default export.
8
+ Its `package.json` includes a static `standardPlugin` manifest.
9
+
10
+ ```json
11
+ {
12
+ "name": "example-status",
13
+ "type": "module",
14
+ "peerDependencies": { "@standardagents/code-plugin-sdk": "^1.0.0-alpha.0" },
15
+ "devDependencies": { "@standardagents/code-plugin-sdk": "^1.0.0-alpha.0" },
16
+ "standardPlugin": {
17
+ "apiVersion": 1,
18
+ "id": "example-status",
19
+ "name": "Example status",
20
+ "version": "0.1.0",
21
+ "entry": "./index.ts",
22
+ "capabilities": ["surfaces"],
23
+ "contributions": [{ "id": "status", "kind": "section", "anchor": "plugins" }]
24
+ }
25
+ }
26
+ ```
27
+
28
+ ```ts
29
+ import { definePlugin } from '@standardagents/code-plugin-sdk'
30
+
31
+ export default definePlugin({
32
+ id: 'example-status',
33
+ activate(ctx) {
34
+ const section = ctx.section('status')
35
+ section.replace({ kind: 'rows', rows: [{ id: 'ready', spans: [{ text: 'Ready' }] }] })
36
+ },
37
+ })
38
+ ```
39
+
40
+ The SDK is a peer dependency. Inside Standard Code the exact specifier
41
+ `@standardagents/code-plugin-sdk` resolves to the bundled copy, so a plugin
42
+ never loads a second runtime from `node_modules`. Subpath imports of the SDK
43
+ package, such as `@standardagents/code-plugin-sdk/testing`, are refused when
44
+ the plugin runs inside Standard Code. The `testing` export serves the
45
+ plugin's own test suite.
46
+
47
+ The runtime disposes publishers, subscriptions, schedules and pending requests.
48
+ Plugins register additional cleanup through `ctx.onDispose` or an activation return value.
49
+ Handlers receive an abort signal.
50
+ Schedules allow one invocation at a time.
51
+
52
+ Capabilities control SDK and daemon operations.
53
+ Plugins retain Node access to files, networking and subprocesses under the user's identity.
54
+ Worker threads provide JavaScript fault isolation.
55
+ Memory exhaustion outside V8 limits and native crashes can affect the runner process.
56
+
57
+ ## Testing
58
+
59
+ `@standardagents/code-plugin-sdk/testing` exports `createHarness`.
60
+ It uses explicit fixture handlers and a manual clock.
61
+ `activate`, `emit`, `visibility`, `advance`, `flush` and `dispose` drive its runtime.
62
+ `trace`, `surfaces`, `subscriptions` and `resources` expose recorded behavior.
63
+ `drainTrace` clears the bounded trace.
64
+ The harness starts no subprocesses.
65
+
66
+ ## Supported interface
67
+
68
+ The public interface is the package's default export, its `testing` export,
69
+ and the declarations in `src/index.d.ts` and `src/testing.d.ts`.
70
+
71
+ The harness records the frames that the SDK runtime exchanges with its host,
72
+ and `harness.receive` accepts such a frame. That wire protocol between the
73
+ SDK runtime and the Standard Code plugin runner is visible through the
74
+ testing helpers, and it is not a supported public interface. Its frame
75
+ shapes, operation names, limits and version can change in any release
76
+ without notice. Plugin code and plugin tests should treat recorded frames as
77
+ opaque values and drive the plugin through `PluginContext` and the harness
78
+ methods.
79
+
80
+ Local `ctx.state` belongs to one machine.
81
+ The public context declarations are in `src/index.d.ts`.
82
+
83
+ ## Releases
84
+
85
+ The Standard Code build workflow publishes this package when the version in
86
+ `package.json` changes. Prereleases publish under the `next` dist-tag and
87
+ releases under `latest`.
88
+
89
+ ## License
90
+
91
+ MIT. See `LICENSE`.
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@standardagents/code-plugin-sdk",
3
- "version": "0.0.0-stub.0",
4
- "description": "Placeholder that reserves the Standard Code plugin SDK name. Real releases follow.",
3
+ "version": "1.0.0-alpha.0",
4
+ "type": "module",
5
+ "description": "Standard Code plugin authoring SDK",
5
6
  "license": "MIT",
6
- "files": ["README.md", "LICENSE"],
7
- "publishConfig": { "access": "public" }
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/standardagents/code-rs.git",
10
+ "directory": "packages/plugin-sdk"
11
+ },
12
+ "publishConfig": { "access": "public" },
13
+ "engines": { "node": ">=20" },
14
+ "exports": {
15
+ ".": { "types": "./src/index.d.ts", "import": "./src/index.mjs" },
16
+ "./testing": { "types": "./src/testing.d.ts", "import": "./src/testing.mjs" }
17
+ },
18
+ "files": ["src", "README.md", "LICENSE"],
19
+ "scripts": { "test": "node --test test/*.test.mjs" }
8
20
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,264 @@
1
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
2
+ export type Capability = 'surfaces' | 'events' | 'hooks' | 'panes' | 'projects' |
3
+ 'notifications' | 'url' | 'fetch' | 'secrets' | 'webhook';
4
+ export type SurfaceKind = 'section' | 'slot' | 'badge' | 'panel' | 'overlay' |
5
+ 'menu' | 'command' | 'key' | 'link';
6
+ export type MenuPosition = 'top' | 'after-open' | 'before-danger' | 'bottom';
7
+ export type Anchor = 'plugins' | 'machine.before' | 'machine.after' |
8
+ 'project.before' | 'project.after' | 'pane.header' | 'pane.footer' |
9
+ 'account' | 'machine' | 'project' | 'pane' | 'section';
10
+ export interface ContributionDeclaration {
11
+ id: string;
12
+ kind: SurfaceKind;
13
+ anchor: Anchor;
14
+ title?: string;
15
+ merge?: 'by-machine' | 'by-identity';
16
+ width?: 'full' | 'half';
17
+ position?: MenuPosition;
18
+ group?: string;
19
+ chord?: string;
20
+ pattern?: string;
21
+ actionId?: string;
22
+ }
23
+ /** The package.json standardPlugin field is read before any plugin code runs. */
24
+ export interface PluginManifest {
25
+ apiVersion: 1;
26
+ id: string;
27
+ name: string;
28
+ version: string;
29
+ entry: string;
30
+ singleton: boolean;
31
+ order: number;
32
+ capabilities: Capability[];
33
+ contributions: ContributionDeclaration[];
34
+ configSchema?: { [key: string]: Json };
35
+ hookTimeoutMs: number;
36
+ }
37
+ export type ManifestInput = Pick<PluginManifest, 'apiVersion' | 'id' | 'name' | 'version' | 'entry'> &
38
+ Partial<Omit<PluginManifest, 'apiVersion' | 'id' | 'name' | 'version' | 'entry'>>;
39
+ export interface EntityRef {
40
+ kind: 'account' | 'machine' | 'project' | 'pane' | 'section';
41
+ id: string;
42
+ machineId?: string;
43
+ /** Decimal strings preserve 64-bit unsigned values across the JSON boundary. */
44
+ generation?: string;
45
+ }
46
+ export interface Producer {
47
+ pluginId: string;
48
+ machineId: string;
49
+ epoch: string;
50
+ }
51
+ export interface ContributionKey {
52
+ contributionId: string;
53
+ anchor: Anchor;
54
+ entity?: EntityRef;
55
+ }
56
+ export interface TextSpan {
57
+ text: string;
58
+ foreground?: string;
59
+ background?: string;
60
+ bold?: boolean;
61
+ italic?: boolean;
62
+ underline?: boolean;
63
+ actionId?: string;
64
+ }
65
+ export interface NativeRow {
66
+ id: string;
67
+ identity?: string;
68
+ providerRevision?: string;
69
+ spans: TextSpan[];
70
+ actionId?: string;
71
+ meter?: { value: number; max: number; label?: string };
72
+ spark?: number[];
73
+ divider?: boolean;
74
+ }
75
+ export type NativeContent = { kind: 'rows'; rows: NativeRow[] } |
76
+ { kind: 'text'; lines: TextSpan[][] } |
77
+ { kind: 'badge'; spans: TextSpan[]; actionId?: string };
78
+ export interface CanvasSpec {
79
+ columns: number;
80
+ rows: number;
81
+ transparent?: boolean;
82
+ shade?: number;
83
+ captureInput?: boolean;
84
+ }
85
+ export type SurfaceContent = NativeContent | { kind: 'canvas'; canvas: CanvasSpec };
86
+ export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
87
+ export interface Disposable { dispose(): void }
88
+ export type Cleanup = () => void | Promise<void>;
89
+ export type Condition = { kind: 'always' } |
90
+ { kind: 'section-visible' | 'slot-visible' | 'panel-open'; contributionId: string; entity?: EntityRef };
91
+ export interface LaunchSpec {
92
+ version: 1;
93
+ argv: string[];
94
+ cwd: string;
95
+ env: Record<string, string>;
96
+ agent?: { id: string; prompt?: string; resumeId?: string; delivery: 'argv' | 'stdin' };
97
+ bootstrap?: { argv: string[]; on: 'create' };
98
+ }
99
+ export interface PaneCreate {
100
+ machineId: string;
101
+ projectId?: string;
102
+ launch: LaunchSpec;
103
+ title?: string;
104
+ presentation?: 'workspace' | 'popup';
105
+ restart?: 'never' | 'on-failure';
106
+ contributionId?: string;
107
+ }
108
+ export interface PaneResult { pane: EntityRef; operationId: string }
109
+ export interface Selection { entity?: EntityRef; actionId: string; value?: Json }
110
+ export interface LinkSelection extends Selection { url: string }
111
+ export type ActionHandler = (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>;
112
+ /** True consumes the URL; false allows the next matching handler to run. */
113
+ export type LinkHandler = (event: LinkSelection, context: HandlerContext) => boolean | Promise<boolean>;
114
+ export interface RegistrationOptions {
115
+ entity?: EntityRef;
116
+ actionId?: string;
117
+ title?: string;
118
+ condition?: Condition;
119
+ }
120
+ export interface MenuOptions extends RegistrationOptions { position?: MenuPosition }
121
+ export interface CommandOptions extends RegistrationOptions { group?: string }
122
+ /** Chords use the native serialized form, such as ctrl+k or ctrl+b w. */
123
+ export interface KeyOptions extends RegistrationOptions { chord?: string }
124
+ /** Patterns are bounded URL match expressions interpreted by the host. */
125
+ export interface LinkOptions extends RegistrationOptions { pattern?: string }
126
+ export interface GroupCommand extends RegistrationOptions { id: string; handler: ActionHandler }
127
+ /** Registration metadata travels with an action subscription. */
128
+ export type ActionRegistration = ContributionKey & { actionId: string; title: string } & (
129
+ { kind: 'menu'; position: MenuPosition } |
130
+ { kind: 'command'; group?: string } |
131
+ { kind: 'key'; chord: string } |
132
+ { kind: 'link'; pattern: string }
133
+ );
134
+ export interface HookEvent {
135
+ name: string;
136
+ entity?: EntityRef;
137
+ launch?: LaunchSpec;
138
+ ancestry: string[];
139
+ operationId: string;
140
+ deadlineMs: number;
141
+ }
142
+ export type HookResult = { decision: 'proceed'; launch?: LaunchSpec } |
143
+ { decision: 'cancel'; reason?: string };
144
+ export interface PluginEvent {
145
+ name: string;
146
+ entity?: EntityRef;
147
+ data: Json;
148
+ deliveryId?: string;
149
+ }
150
+ export interface Popover {
151
+ kind: 'chooser' | 'form' | 'confirm';
152
+ title: string;
153
+ message?: string;
154
+ entity?: EntityRef;
155
+ choices?: { id: string; label: string; destructive?: boolean }[];
156
+ fields?: { id: string; label: string; type: 'text' | 'secret' | 'boolean'; value?: Json }[];
157
+ }
158
+ export interface OperationMap {
159
+ 'pane.create': { input: PaneCreate; output: PaneResult };
160
+ 'pane.close': { input: { pane: EntityRef; confirmationId?: string }; output: { operationId: string } };
161
+ 'pane.restart': { input: { pane: EntityRef; launch?: LaunchSpec }; output: PaneResult };
162
+ 'pane.input': { input: { pane: EntityRef; text: string }; output: null };
163
+ 'pane.focus': { input: { pane: EntityRef }; output: null };
164
+ 'pane.wait': { input: { pane: EntityRef }; output: { exitCode: number | null; lastLine: string } };
165
+ 'project.create': { input: { machineId: string; path: string; name?: string }; output: EntityRef };
166
+ 'project.remove': { input: { project: EntityRef; confirmationId?: string }; output: { operationId: string } };
167
+ 'notification.show': { input: { title: string; message: string }; output: null };
168
+ 'url.open': { input: { url: string }; output: null };
169
+ 'fetch': { input: { url: string; method?: string; headers?: Record<string, string>; body?: string }; output: { status: number; headers: Record<string, string>; body: string } };
170
+ 'secret.get': { input: { name: string }; output: string | null };
171
+ 'config.get': { input: Record<string, never>; output: Record<string, Json> };
172
+ 'state.get': { input: { key: string }; output: Json };
173
+ 'state.set': { input: { key: string; value: Json }; output: null };
174
+ 'context.get': { input: Record<string, never>; output: Json };
175
+ 'popover.open': { input: Popover; output: { choiceId?: string; values?: Record<string, Json>; confirmationId?: string } | null };
176
+ 'canvas.write': { input: { key: ContributionKey; ansi: string }; output: null };
177
+ 'canvas.focus': { input: { key: ContributionKey; capture: boolean }; output: null };
178
+ 'subscription.add': { input: { id: string; name: string; condition: Condition } & (
179
+ { kind: 'action'; registration?: ActionRegistration } |
180
+ { kind: 'event' | 'hook' | 'input' | 'select' | 'resize' | 'activate' | 'deactivate' | 'visibility' }
181
+ ); output: null };
182
+ 'subscription.remove': { input: { id: string }; output: null };
183
+ 'health.set': { input: { status: 'healthy' | 'degraded' | 'error'; message?: string }; output: null };
184
+ 'webhook.ack': { input: { deliveryId: string }; output: null };
185
+ }
186
+ export type OperationName = keyof OperationMap;
187
+ export type Operation = { [K in OperationName]: { op: K; args: OperationMap[K]['input'] } }[OperationName];
188
+ 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;
191
+ clear(): void;
192
+ }
193
+ export interface Canvas extends Publisher {
194
+ write(ansi: string, options?: RequestOptions): Promise<null>;
195
+ focus(capture: boolean, options?: RequestOptions): Promise<null>;
196
+ }
197
+ export interface Subscription extends Disposable { ready: Promise<null> }
198
+ export interface KeyRegistration extends Subscription {
199
+ /** Replaces this subscription's chord after its initial registration is ready. */
200
+ /** One update may be pending per registration. */
201
+ update(chord: string, options?: RequestOptions): Promise<null>;
202
+ }
203
+ export interface HandlerContext { signal: AbortSignal }
204
+ export interface PluginContext {
205
+ readonly manifest: Readonly<PluginManifest>;
206
+ readonly producer: Readonly<Producer>;
207
+ readonly signal: AbortSignal;
208
+ 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;
214
+ canvas(id: string, spec: CanvasSpec, entity?: EntityRef): Canvas;
215
+ /** IDs match static manifest contributions. Options override declaration defaults. */
216
+ menu(id: string, options: MenuOptions, handler: ActionHandler): Subscription;
217
+ menu(id: string, handler: ActionHandler): Subscription;
218
+ /** Registers a palette command in its declared context and optional group. */
219
+ command(id: string, options: CommandOptions, handler: ActionHandler): Subscription;
220
+ command(id: string, handler: ActionHandler): Subscription;
221
+ /** Each command has a static contribution ID. Disposal removes the whole group. */
222
+ commandGroup(group: string, commands: readonly GroupCommand[]): Subscription;
223
+ key(id: string, options: KeyOptions, handler: ActionHandler): KeyRegistration;
224
+ key(id: string, handler: ActionHandler): KeyRegistration;
225
+ link(id: string, options: LinkOptions, handler: LinkHandler): Subscription;
226
+ link(id: string, handler: LinkHandler): Subscription;
227
+ onEvent(name: string, handler: (event: PluginEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
228
+ onHook(name: string, handler: (event: HookEvent, context: HandlerContext) => HookResult | Promise<HookResult>): Subscription;
229
+ onAction(name: string, handler: (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>): Subscription;
230
+ onInput(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
231
+ onSelect(name: string, handler: (event: Selection, context: HandlerContext) => void | Promise<void>): Subscription;
232
+ onResize(name: string, handler: (event: { columns: number; rows: number }, context: HandlerContext) => void | Promise<void>): Subscription;
233
+ onActivate(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
234
+ onDeactivate(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
235
+ schedule(intervalMs: number, handler: (context: HandlerContext) => void | Promise<void>, options?: { condition?: Condition; immediate?: boolean }): Disposable;
236
+ onDispose(cleanup: Cleanup): Disposable;
237
+ panes: {
238
+ create(args: PaneCreate, options?: RequestOptions): Promise<PaneResult>;
239
+ close(args: OperationMap['pane.close']['input'], options?: RequestOptions): Promise<{ operationId: string }>;
240
+ restart(args: OperationMap['pane.restart']['input'], options?: RequestOptions): Promise<PaneResult>;
241
+ input(args: OperationMap['pane.input']['input'], options?: RequestOptions): Promise<null>;
242
+ focus(args: OperationMap['pane.focus']['input'], options?: RequestOptions): Promise<null>;
243
+ wait(args: OperationMap['pane.wait']['input'], options?: RequestOptions): Promise<OperationMap['pane.wait']['output']>;
244
+ };
245
+ projects: { create(args: OperationMap['project.create']['input'], options?: RequestOptions): Promise<EntityRef>; remove(args: OperationMap['project.remove']['input'], options?: RequestOptions): Promise<{ operationId: string }> };
246
+ notifications: { show(args: OperationMap['notification.show']['input'], options?: RequestOptions): Promise<null> };
247
+ url: { open(url: string, options?: RequestOptions): Promise<null> };
248
+ fetch(args: OperationMap['fetch']['input'], options?: RequestOptions): Promise<OperationMap['fetch']['output']>;
249
+ secrets: { get(name: string, options?: RequestOptions): Promise<string | null> };
250
+ 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> };
254
+ popover: { open(args: Popover, options?: RequestOptions): Promise<OperationMap['popover.open']['output']> };
255
+ health: { set(args: OperationMap['health.set']['input'], options?: RequestOptions): Promise<null> };
256
+ webhook: { ack(deliveryId: string, options?: RequestOptions): Promise<null> };
257
+ }
258
+ export interface PluginDefinition {
259
+ id: string;
260
+ activate(context: PluginContext): void | Cleanup | Promise<void | Cleanup>;
261
+ }
262
+ export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
263
+ export function validateManifest(value: unknown): Readonly<PluginManifest>;
264
+ export class PluginError extends Error { code: string; constructor(code: string, message: string) }
package/src/index.mjs ADDED
@@ -0,0 +1,9 @@
1
+ import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
2
+
3
+ export { validateManifest, PluginError }
4
+
5
+ export function definePlugin(definition) {
6
+ ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
7
+ 'invalid_plugin', 'definePlugin requires an id and activate function')
8
+ return Object.freeze({ id: definition.id, activate: definition.activate })
9
+ }
@@ -0,0 +1,39 @@
1
+ import type { ContributionKey, SurfaceContent, Producer, Operation, Json, PluginManifest, PluginContext, PluginDefinition, RequestOptions, Condition } from './index.js';
2
+ export * from './index.js';
3
+ export const RUNNER_PROTOCOL_VERSION: 1;
4
+ export const PLUGIN_API_VERSION: 1;
5
+ export const PLUGIN_PEER_CAPABILITY: 'plugins.v1';
6
+ export const LIMITS: Readonly<Record<string, number>>;
7
+ export interface PreparedPlugin {
8
+ root: string;
9
+ entry: string;
10
+ manifest: PluginManifest;
11
+ manifestDigest: string;
12
+ }
13
+ export type HostOperation =
14
+ { op: 'runner.start' | 'runner.reload'; args: { root: string; manifestDigest: string; capabilities: string[]; linked?: boolean } } |
15
+ { op: 'runner.stop' | 'runner.diagnose' | 'runner.ping'; args: Record<string, never> } |
16
+ { op: 'runtime.invoke'; args: { subscriptionId: string; event: Json } } |
17
+ { op: 'runtime.visibility'; args: { conditions: Condition[] } };
18
+ export type Envelope = { version: 1; producer: Producer } & (
19
+ { kind: 'request'; id: string; operation: Operation | HostOperation; timeoutMs: number } |
20
+ { kind: 'response'; id: string; result: { ok: true; value: Json } | { ok: false; error: { code: string; message: string } } } |
21
+ { kind: 'cancel'; id: string } |
22
+ { kind: 'surface'; key: ContributionKey; sequence: string; content: SurfaceContent | null }
23
+ );
24
+ export interface Clock { now(): number; setTimeout(callback: () => void | Promise<void>, ms: number): unknown; clearTimeout(id: unknown): void }
25
+ export interface Runtime {
26
+ context: PluginContext;
27
+ receive(frame: Envelope): void;
28
+ activate(definition: PluginDefinition): Promise<void>;
29
+ dispose(): Promise<void>;
30
+ }
31
+ export function createRuntime(options: { manifest: PluginManifest; producer: Producer; send(frame: Envelope): void; clock?: Clock; instanceId?: string; onError?(error: Error): void }): Runtime;
32
+ export function authorize(operation: Operation, capabilities: string[]): void;
33
+ export function validateEnvelope(frame: unknown): Envelope;
34
+ export class RpcPeer {
35
+ 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
+ request(operation: Operation | HostOperation, options?: RequestOptions): Promise<Json>;
37
+ receive(frame: Envelope): void;
38
+ dispose(): void;
39
+ }
@@ -0,0 +1,5 @@
1
+ // Re-exports for the plugin runner, which imports this file by relative path.
2
+ // The package does not export this module; plugin code uses the public entry.
3
+ export * from './manifest.mjs'
4
+ export * from './protocol.mjs'
5
+ export { createRuntime } from './runtime.mjs'
@@ -0,0 +1,86 @@
1
+ export const CAPABILITIES = Object.freeze(['surfaces', 'events', 'hooks', 'panes', 'projects',
2
+ 'notifications', 'url', 'fetch', 'secrets', 'webhook'])
3
+ export const SURFACE_KINDS = Object.freeze(['section', 'slot', 'badge', 'panel', 'overlay',
4
+ 'menu', 'command', 'key', 'link'])
5
+ export const ANCHORS = Object.freeze(['plugins', 'machine.before', 'machine.after',
6
+ 'project.before', 'project.after', 'pane.header', 'pane.footer',
7
+ 'account', 'machine', 'project', 'pane', 'section'])
8
+ export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
9
+ pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
10
+ queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
11
+ canvasColumns: 512, canvasRows: 256 })
12
+
13
+ export class PluginError extends Error {
14
+ constructor(code, message) { super(message); this.name = 'PluginError'; this.code = code }
15
+ }
16
+ export function ensure(condition, code, message) {
17
+ if (!condition) throw new PluginError(code, message)
18
+ }
19
+ export function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
20
+ export function identifier(value) { return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) }
21
+ export function jsonBytes(value, limit = LIMITS.frameBytes) {
22
+ // Reject non-JSON values before serialization can silently change their meaning.
23
+ const seen = new Set()
24
+ function visit(item, depth) {
25
+ ensure(depth <= 32, 'invalid_payload', 'JSON nesting exceeds 32 levels')
26
+ if (item === null || typeof item === 'string' || typeof item === 'boolean') return
27
+ if (typeof item === 'number') { ensure(Number.isFinite(item), 'invalid_payload', 'JSON numbers must be finite'); return }
28
+ ensure(object(item) || Array.isArray(item), 'invalid_payload', 'Payload must contain JSON values')
29
+ ensure(!seen.has(item), 'invalid_payload', 'Payload contains a cycle')
30
+ ensure(Array.isArray(item) || [Object.prototype, null].includes(Object.getPrototypeOf(item)), 'invalid_payload', 'Payload must contain plain objects')
31
+ seen.add(item)
32
+ for (const child of Object.values(item)) visit(child, depth + 1)
33
+ seen.delete(item)
34
+ }
35
+ visit(value, 0)
36
+ const text = JSON.stringify(value)
37
+ ensure(Buffer.byteLength(text) <= limit, 'payload_too_large', `Payload exceeds ${limit} bytes`)
38
+ return text
39
+ }
40
+ function freeze(value) {
41
+ if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) }
42
+ return value
43
+ }
44
+ export function validateManifest(value) {
45
+ ensure(object(value), 'invalid_manifest', 'package.json must contain a standardPlugin object')
46
+ jsonBytes(value, LIMITS.manifestBytes)
47
+ const allowed = ['apiVersion', 'id', 'name', 'version', 'entry', 'singleton', 'order',
48
+ 'capabilities', 'contributions', 'configSchema', 'hookTimeoutMs']
49
+ ensure(Object.keys(value).every(key => allowed.includes(key)), 'invalid_manifest', 'Unknown manifest field')
50
+ const manifest = structuredClone({ singleton: false, order: 0, capabilities: [], contributions: [], hookTimeoutMs: 5000, ...value })
51
+ ensure(manifest.apiVersion === 1, 'incompatible_version', 'Plugin requires a different SDK API version')
52
+ ensure(identifier(manifest.id), 'invalid_manifest', 'Plugin id must be a stable identifier')
53
+ ensure(typeof manifest.name === 'string' && manifest.name.trim().length > 0 && manifest.name.length <= 128,
54
+ 'invalid_manifest', 'Plugin name is required and bounded to 128 characters')
55
+ ensure(typeof manifest.version === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version),
56
+ 'invalid_manifest', 'Plugin version must be semantic')
57
+ ensure(typeof manifest.entry === 'string' && manifest.entry.length <= 512 &&
58
+ !/[\\\u0000?#]/.test(manifest.entry) && !manifest.entry.startsWith('/') &&
59
+ manifest.entry.split('/').every(part => part && part !== '..') && /\.(?:mjs|cjs|js|mts|ts)$/.test(manifest.entry),
60
+ 'invalid_manifest', 'Plugin entry must be a relative JavaScript or TypeScript file')
61
+ ensure(typeof manifest.singleton === 'boolean' && Number.isSafeInteger(manifest.order) && Math.abs(manifest.order) <= 2147483647,
62
+ 'invalid_manifest', 'Invalid singleton or order')
63
+ ensure(Number.isSafeInteger(manifest.hookTimeoutMs) && manifest.hookTimeoutMs > 0 && manifest.hookTimeoutMs <= LIMITS.hookTimeoutMs,
64
+ 'invalid_manifest', 'Hook timeout must be between 1 and 60000 ms')
65
+ ensure(Array.isArray(manifest.capabilities) && manifest.capabilities.every(item => CAPABILITIES.includes(item)) &&
66
+ new Set(manifest.capabilities).size === manifest.capabilities.length, 'invalid_manifest', 'Unknown or repeated capability')
67
+ ensure(Array.isArray(manifest.contributions) && manifest.contributions.length <= LIMITS.contributions,
68
+ 'invalid_manifest', 'Too many contributions')
69
+ const ids = new Set()
70
+ for (const declaration of manifest.contributions) {
71
+ ensure(object(declaration) && identifier(declaration.id) && !ids.has(declaration.id) &&
72
+ SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
73
+ ids.add(declaration.id)
74
+ for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
75
+ position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
76
+ ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
77
+ }
78
+ for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
79
+ ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
80
+ 'invalid_manifest', `Invalid contribution ${key}`)
81
+ }
82
+ }
83
+ ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
84
+ ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
85
+ return freeze(manifest)
86
+ }
@@ -0,0 +1,166 @@
1
+ import { ensure, identifier, object, jsonBytes, LIMITS, PluginError } from './manifest.mjs'
2
+
3
+ export const RUNNER_PROTOCOL_VERSION = 1
4
+ export const PLUGIN_API_VERSION = 1
5
+ export const PLUGIN_PEER_CAPABILITY = 'plugins.v1'
6
+ export const OPERATIONS = Object.freeze({
7
+ 'pane.create': 'panes', 'pane.close': 'panes', 'pane.restart': 'panes',
8
+ 'pane.input': 'panes', 'pane.focus': 'panes', 'pane.wait': 'panes',
9
+ 'project.create': 'projects', 'project.remove': 'projects',
10
+ 'notification.show': 'notifications', 'url.open': 'url', fetch: 'fetch',
11
+ 'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null,
12
+ 'context.get': null, 'popover.open': 'surfaces', 'canvas.write': 'surfaces',
13
+ 'canvas.focus': 'surfaces', 'subscription.add': null, 'subscription.remove': null,
14
+ 'health.set': null, 'webhook.ack': 'webhook',
15
+ })
16
+ export const HOST_OPERATIONS = Object.freeze(['runner.start', 'runner.stop', 'runner.reload',
17
+ 'runner.diagnose', 'runner.ping', 'runtime.invoke', 'runtime.visibility'])
18
+
19
+ export function authorize(operation, capabilities) {
20
+ ensure(object(operation) && Object.hasOwn(OPERATIONS, operation.op) && object(operation.args),
21
+ 'unsupported_operation', 'Unknown SDK operation or invalid arguments')
22
+ let capability = OPERATIONS[operation.op]
23
+ if (operation.op === 'subscription.add') {
24
+ const { id, kind, name } = operation.args
25
+ ensure(identifier(id) && typeof name === 'string' && name.length <= 256 &&
26
+ ['event', 'hook', 'action', 'input', 'select', 'resize', 'activate', 'deactivate', 'visibility'].includes(kind),
27
+ 'invalid_payload', 'Invalid subscription')
28
+ capability = kind === 'hook' ? 'hooks' : kind === 'event' ? (name === 'webhook' ? 'webhook' : 'events') : 'surfaces'
29
+ }
30
+ ensure(!capability || capabilities.includes(capability), 'capability_denied', `Operation requires ${capability} capability`)
31
+ jsonBytes(operation)
32
+ }
33
+ export function validateProducer(producer) {
34
+ ensure(object(producer) && identifier(producer.pluginId) && typeof producer.machineId === 'string' &&
35
+ producer.machineId.length > 0 && producer.machineId.length <= 128 && typeof producer.epoch === 'string' &&
36
+ /^(0|[1-9]\d{0,19})$/.test(producer.epoch) && BigInt(producer.epoch) <= 18446744073709551615n,
37
+ 'invalid_payload', 'Invalid plugin producer identity')
38
+ return producer
39
+ }
40
+ export function sameProducer(a, b) {
41
+ return a.pluginId === b.pluginId && a.machineId === b.machineId && a.epoch === b.epoch
42
+ }
43
+ export function validateEnvelope(frame) {
44
+ jsonBytes(frame)
45
+ ensure(object(frame) && frame.version === RUNNER_PROTOCOL_VERSION, 'incompatible_version', 'Daemon and runner protocol versions differ')
46
+ validateProducer(frame.producer)
47
+ ensure(['request', 'response', 'cancel', 'surface'].includes(frame.kind), 'invalid_payload', 'Unknown runner frame kind')
48
+ if (frame.kind === 'surface') {
49
+ ensure(object(frame.key) && identifier(frame.key.contributionId) && typeof frame.sequence === 'string' &&
50
+ /^(0|[1-9]\d{0,19})$/.test(frame.sequence) && BigInt(frame.sequence) <= 18446744073709551615n &&
51
+ Object.hasOwn(frame, 'content'), 'invalid_payload', 'Invalid surface replacement')
52
+ } else {
53
+ ensure(typeof frame.id === 'string' && frame.id.length > 0 && frame.id.length <= 128, 'invalid_payload', 'Missing correlation id')
54
+ if (frame.kind === 'request') {
55
+ ensure(object(frame.operation) && (Object.hasOwn(OPERATIONS, frame.operation.op) || HOST_OPERATIONS.includes(frame.operation.op)) &&
56
+ object(frame.operation.args) && Number.isSafeInteger(frame.timeoutMs) && frame.timeoutMs > 0 && frame.timeoutMs <= LIMITS.hookTimeoutMs,
57
+ 'invalid_payload', 'Invalid runner request')
58
+ }
59
+ if (frame.kind === 'response') {
60
+ ensure(object(frame.result) && typeof frame.result.ok === 'boolean' &&
61
+ (frame.result.ok ? Object.hasOwn(frame.result, 'value') :
62
+ object(frame.result.error) && typeof frame.result.error.code === 'string' && typeof frame.result.error.message === 'string'),
63
+ 'invalid_payload', 'Invalid runner response')
64
+ }
65
+ }
66
+ return frame
67
+ }
68
+ export function wireError(error) {
69
+ return { code: error instanceof PluginError ? error.code : 'plugin_error',
70
+ message: String(error?.message ?? 'Plugin operation failed').slice(0, 1024) }
71
+ }
72
+ export const realClock = Object.freeze({ now: () => Date.now(), setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) })
73
+
74
+ /** A single correlation path for commands, actions and hooks. Send must refuse overflow synchronously. */
75
+ export class RpcPeer {
76
+ constructor({ producer, send, clock = realClock, onRequest, onSurface, onError = () => {}, idPrefix = 'host' }) {
77
+ this.producer = Object.freeze({ ...validateProducer(producer) })
78
+ this.send = send
79
+ this.clock = clock
80
+ this.onRequest = onRequest
81
+ this.onSurface = onSurface
82
+ this.onError = onError
83
+ this.pending = new Map()
84
+ this.incoming = new Map()
85
+ this.nextId = 0
86
+ this.idPrefix = idPrefix
87
+ this.closed = false
88
+ }
89
+ frame(kind, values) { return { version: RUNNER_PROTOCOL_VERSION, producer: this.producer, kind, ...values } }
90
+ transmit(frame) { validateEnvelope(frame); this.send(frame) }
91
+ request(operation, { signal, timeoutMs = LIMITS.requestTimeoutMs } = {}) {
92
+ if (this.closed) return Promise.reject(new PluginError('disposed', 'Plugin runtime is disposed'))
93
+ if (signal?.aborted) return Promise.reject(new PluginError('cancelled', 'Request was cancelled'))
94
+ if (this.pending.size >= LIMITS.pendingRequests) return Promise.reject(new PluginError('queue_full', 'Too many pending requests'))
95
+ const id = `${this.idPrefix}-${++this.nextId}`
96
+ return new Promise((resolve, reject) => {
97
+ let timer
98
+ const finish = (error, value) => {
99
+ if (!this.pending.delete(id)) return
100
+ this.clock.clearTimeout(timer)
101
+ signal?.removeEventListener('abort', cancel)
102
+ error ? reject(error) : resolve(value)
103
+ }
104
+ const cancelWith = code => {
105
+ finish(new PluginError(code, code === 'timeout' ? 'Request deadline elapsed' : 'Request was cancelled'))
106
+ try { this.transmit(this.frame('cancel', { id })) } catch (error) { this.onError(error) }
107
+ }
108
+ const cancel = () => cancelWith('cancelled')
109
+ this.pending.set(id, { finish })
110
+ signal?.addEventListener('abort', cancel, { once: true })
111
+ timer = this.clock.setTimeout(() => cancelWith('timeout'), timeoutMs)
112
+ try { this.transmit(this.frame('request', { id, operation, timeoutMs })) }
113
+ catch (error) { finish(error) }
114
+ })
115
+ }
116
+ receive(frame) {
117
+ if (this.closed) return
118
+ validateEnvelope(frame)
119
+ ensure(sameProducer(frame.producer, this.producer), 'stale_producer', 'Frame belongs to another plugin instance')
120
+ if (frame.kind === 'surface') { this.onSurface?.(frame); return }
121
+ if (frame.kind === 'response') {
122
+ const result = frame.result
123
+ this.pending.get(frame.id)?.finish(result.ok ? null : new PluginError(result.error.code, result.error.message), result.value)
124
+ return
125
+ }
126
+ if (frame.kind === 'cancel') { this.incoming.get(frame.id)?.cancel(); return }
127
+ ensure(!this.incoming.has(frame.id), 'duplicate_request', 'Request id is still in flight')
128
+ const respond = result => {
129
+ if (!this.closed) {
130
+ try { this.transmit(this.frame('response', { id: frame.id, result })) }
131
+ catch (error) { this.onError(error) }
132
+ }
133
+ }
134
+ if (this.incoming.size >= LIMITS.pendingRequests) {
135
+ respond({ ok: false, error: { code: 'queue_full', message: 'Too many incoming requests' } })
136
+ return
137
+ }
138
+ const controller = new AbortController()
139
+ // Retain the occupied slot until a cancelled handler settles. An uncooperative
140
+ // handler cannot turn repeated cancellation into unbounded concurrent work.
141
+ const cancel = () => { controller.abort(); this.clock.clearTimeout(timer) }
142
+ const timer = this.clock.setTimeout(() => {
143
+ cancel()
144
+ respond({ ok: false, error: { code: 'timeout', message: 'Handler deadline elapsed' } })
145
+ }, frame.timeoutMs)
146
+ this.incoming.set(frame.id, { cancel })
147
+ Promise.resolve().then(() => {
148
+ ensure(!controller.signal.aborted, 'cancelled', 'Request was cancelled')
149
+ ensure(typeof this.onRequest === 'function', 'unsupported_operation', 'No request handler')
150
+ return this.onRequest(frame.operation, { signal: controller.signal, timeoutMs: frame.timeoutMs })
151
+ }).then(value => {
152
+ if (!controller.signal.aborted) respond({ ok: true, value: value ?? null })
153
+ }, error => {
154
+ if (!controller.signal.aborted) respond({ ok: false, error: wireError(error) })
155
+ }).finally(() => { this.clock.clearTimeout(timer); this.incoming.delete(frame.id) })
156
+ }
157
+ dispose() {
158
+ if (this.closed) return
159
+ for (const [id, request] of this.pending) {
160
+ request.finish(new PluginError('disposed', 'Plugin runtime was disposed'))
161
+ try { this.transmit(this.frame('cancel', { id })) } catch (error) { this.onError(error) }
162
+ }
163
+ this.closed = true
164
+ for (const request of this.incoming.values()) request.cancel()
165
+ }
166
+ }
@@ -0,0 +1,354 @@
1
+ import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
2
+ import { RpcPeer, authorize, realClock } from './protocol.mjs'
3
+
4
+ const always = Object.freeze({ kind: 'always' })
5
+ const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
6
+ const entityKinds = ['account', 'machine', 'project', 'pane', 'section']
7
+ function boundedText(value, label, limit = 512) {
8
+ ensure(typeof value === 'string' && value.trim().length > 0 && value.length <= limit &&
9
+ !/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
10
+ return value
11
+ }
12
+ function validateEntity(entity) {
13
+ ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
14
+ boundedText(entity.id, 'entity id', 128)
15
+ if (entity.machineId !== undefined) boundedText(entity.machineId, 'machine id', 128)
16
+ if (entity.generation !== undefined) {
17
+ ensure(typeof entity.generation === 'string' && /^(0|[1-9]\d{0,19})$/.test(entity.generation) &&
18
+ BigInt(entity.generation) <= 18446744073709551615n, 'invalid_payload', 'Invalid entity generation')
19
+ }
20
+ return { kind: entity.kind, id: entity.id,
21
+ ...(entity.machineId !== undefined ? { machineId: entity.machineId } : {}),
22
+ ...(entity.generation !== undefined ? { generation: entity.generation } : {}) }
23
+ }
24
+ function validateChord(chord) {
25
+ boundedText(chord, 'key chord', 128)
26
+ const presses = chord.split(' ')
27
+ const namedKeys = ['space', 'enter', 'esc', 'tab', 'backspace', 'left', 'right', 'up', 'down', 'pageup', 'pagedown', 'home', 'end']
28
+ ensure(presses.length <= 2 && presses.every(press => {
29
+ const parts = press.split('+')
30
+ const modifiers = parts.filter(part => ['shift', 'alt', 'ctrl'].includes(part))
31
+ const keys = parts.filter(part => !['shift', 'alt', 'ctrl'].includes(part))
32
+ return new Set(modifiers).size === modifiers.length && keys.length === 1 &&
33
+ (namedKeys.includes(keys[0]) || Array.from(keys[0]).length === 1)
34
+ }), 'invalid_payload', 'Invalid native key chord')
35
+ return chord
36
+ }
37
+ function conditionKey(condition) { return JSON.stringify([condition.kind, condition.contributionId ?? null, condition.entity ?? null]) }
38
+ function validateCondition(condition) {
39
+ ensure(condition && ['always', 'section-visible', 'slot-visible', 'panel-open'].includes(condition.kind) &&
40
+ (condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
41
+ return structuredClone(condition)
42
+ }
43
+ function validateContent(content) {
44
+ jsonBytes(content)
45
+ ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
46
+ if (content.kind === 'canvas') {
47
+ const { columns, rows, shade = 0 } = content.canvas ?? {}
48
+ ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
49
+ Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
50
+ Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
51
+ }
52
+ }
53
+
54
+ export function createRuntime({ manifest: input, producer, send, clock = realClock, onError = () => {}, instanceId = '0' }) {
55
+ const manifest = validateManifest(input)
56
+ ensure(producer.pluginId === manifest.id, 'identity_mismatch', 'Manifest and producer ids differ')
57
+ const lifetime = new AbortController()
58
+ const subscriptions = new Map()
59
+ const schedules = new Set()
60
+ const cleanups = new Set()
61
+ const publishers = new Map()
62
+ let visibility = new Set()
63
+ let counter = 0
64
+ let sequence = 0n
65
+ let disposal
66
+ ensure(identifier(instanceId), 'invalid_payload', 'Invalid runtime instance id')
67
+ const peer = new RpcPeer({ producer, send, clock, onError, idPrefix: 'sdk', onRequest: async (operation, options) => {
68
+ if (operation.op === 'runtime.visibility') {
69
+ ensure(Array.isArray(operation.args.conditions) && operation.args.conditions.length <= LIMITS.contributions,
70
+ 'invalid_payload', 'Invalid visibility snapshot')
71
+ visibility = new Set(operation.args.conditions.map(condition => conditionKey(validateCondition(condition))))
72
+ for (const schedule of schedules) schedule.visibilityChanged(visibility)
73
+ return null
74
+ }
75
+ ensure(operation.op === 'runtime.invoke', 'unsupported_operation', 'Unsupported worker operation')
76
+ const subscription = subscriptions.get(operation.args.subscriptionId)
77
+ ensure(subscription, 'subscription_missing', 'Subscription was disposed')
78
+ if (subscription.condition.kind !== 'always' && !visibility.has(conditionKey(subscription.condition))) return null
79
+ const result = await subscription.handler(operation.args.event, { signal: options.signal })
80
+ if (subscription.kind === 'hook') {
81
+ ensure(result && ['proceed', 'cancel'].includes(result.decision), 'invalid_payload', 'Hook must return proceed or cancel')
82
+ }
83
+ return result ?? null
84
+ } })
85
+ function active() { ensure(!lifetime.signal.aborted, 'disposed', 'Plugin runtime is disposed') }
86
+ function request(op, args, options) {
87
+ try { active(); authorize({ op, args }, manifest.capabilities) }
88
+ catch (error) { onError(error); return Promise.reject(error) }
89
+ return peer.request({ op, args }, options)
90
+ }
91
+ function publish(kind, id, entity) {
92
+ active()
93
+ ensure(manifest.capabilities.includes('surfaces'), 'capability_denied', 'Publishing requires surfaces capability')
94
+ const declaration = manifest.contributions.find(item => item.id === id)
95
+ ensure(declaration && (!kind || declaration.kind === kind) && ANCHORS.includes(declaration.anchor),
96
+ 'contribution_missing', 'Surface must match its static contribution declaration')
97
+ const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity: structuredClone(entity) } : {}) }
98
+ const identity = JSON.stringify(key)
99
+ ensure(!publishers.has(identity), 'duplicate_contribution', 'Contribution publisher exists')
100
+ ensure(publishers.size < LIMITS.contributions, 'queue_full', 'Too many contribution publishers')
101
+ let disposed = false
102
+ const replace = content => {
103
+ active()
104
+ ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
105
+ if (content !== null) validateContent(content)
106
+ peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
107
+ }
108
+ const publisher = { replace, clear: () => replace(null), dispose() {
109
+ if (disposed) return
110
+ try { if (!lifetime.signal.aborted) replace(null) }
111
+ finally { disposed = true; publishers.delete(identity) }
112
+ } }
113
+ publishers.set(identity, publisher)
114
+ return { publisher, key }
115
+ }
116
+ function subscribe(kind, name, handler, condition = always, registration) {
117
+ active()
118
+ ensure(typeof handler === 'function', 'invalid_payload', 'Subscription requires a handler')
119
+ ensure(subscriptions.size < LIMITS.subscriptions, 'queue_full', 'Too many subscriptions')
120
+ condition = validateCondition(condition)
121
+ const id = `subscription-${instanceId}-${++counter}`
122
+ const args = { id, kind, name, condition, ...(registration ? { registration } : {}) }
123
+ authorize({ op: 'subscription.add', args }, manifest.capabilities)
124
+ const entry = { kind, handler, condition, registration }
125
+ subscriptions.set(id, entry)
126
+ let disposed = false
127
+ let updating = false
128
+ const remove = () => { if (!lifetime.signal.aborted) request('subscription.remove', { id }).catch(onError) }
129
+ const ready = request('subscription.add', args)
130
+ ready.then(() => { if (disposed) remove() }, error => {
131
+ disposed = true
132
+ subscriptions.delete(id)
133
+ remove()
134
+ onError(error)
135
+ })
136
+ const subscription = { ready, dispose() {
137
+ if (disposed) return
138
+ disposed = true
139
+ subscriptions.delete(id)
140
+ remove()
141
+ } }
142
+ if (registration?.kind === 'key') subscription.update = async (chord, options) => {
143
+ active()
144
+ ensure(!disposed, 'disposed', 'Key registration is disposed')
145
+ validateChord(chord)
146
+ ensure(!updating, 'request_in_flight', 'A key registration update is pending')
147
+ updating = true
148
+ try {
149
+ await ready
150
+ active()
151
+ ensure(!disposed, 'disposed', 'Key registration is disposed')
152
+ const next = { ...entry.registration, chord }
153
+ await request('subscription.add', { ...args, registration: next }, options)
154
+ if (!disposed) entry.registration = next
155
+ return null
156
+ } finally {
157
+ updating = false
158
+ if (disposed) remove()
159
+ }
160
+ }
161
+ return subscription
162
+ }
163
+ function prepareRegistration(kind, id, options, handler) {
164
+ active()
165
+ if (typeof options === 'function' && handler === undefined) { handler = options; options = {} }
166
+ ensure(identifier(id) && object(options) && typeof handler === 'function', 'invalid_payload', 'Invalid contribution registration')
167
+ jsonBytes(options, LIMITS.manifestBytes)
168
+ const declaration = manifest.contributions.find(item => item.id === id && item.kind === kind)
169
+ ensure(declaration, 'contribution_missing', 'Registration must match its static contribution declaration')
170
+ const actionId = options.actionId ?? declaration.actionId ?? id
171
+ ensure(identifier(actionId), 'invalid_payload', 'Invalid registration action id')
172
+ const registration = { contributionId: id, kind, anchor: declaration.anchor, actionId,
173
+ title: boundedText(options.title ?? declaration.title ?? id, 'registration title'),
174
+ ...(options.entity !== undefined ? { entity: validateEntity(options.entity) } : {}) }
175
+ if (kind === 'menu') {
176
+ registration.position = options.position ?? declaration.position ?? 'bottom'
177
+ ensure(menuPositions.includes(registration.position), 'invalid_payload', 'Invalid menu position')
178
+ } else if (kind === 'command') {
179
+ const group = options.group ?? declaration.group
180
+ if (group !== undefined) registration.group = boundedText(group, 'command group')
181
+ } else if (kind === 'key') {
182
+ registration.chord = validateChord(options.chord ?? declaration.chord)
183
+ } else if (kind === 'link') {
184
+ registration.pattern = boundedText(options.pattern ?? declaration.pattern, 'link pattern')
185
+ }
186
+ const condition = validateCondition(options.condition ?? always)
187
+ if (condition.entity !== undefined) condition.entity = validateEntity(condition.entity)
188
+ jsonBytes({ registration, condition }, LIMITS.manifestBytes)
189
+ const invoke = async (event, context) => {
190
+ ensure(object(event) && event.actionId === actionId, 'invalid_payload', 'Invalid registered action selection')
191
+ if (event.entity !== undefined) validateEntity(event.entity)
192
+ if (kind === 'link') boundedText(event.url, 'link URL', 8192)
193
+ const result = await handler(event, context)
194
+ if (kind === 'link') ensure(typeof result === 'boolean', 'invalid_payload', 'Link handlers must return a boolean')
195
+ return result
196
+ }
197
+ return { registration, handler: invoke, condition }
198
+ }
199
+ const registrationKey = registration => JSON.stringify([registration.contributionId, registration.entity ?? null])
200
+ function registerPrepared({ registration, handler, condition }) {
201
+ const identity = registrationKey(registration)
202
+ ensure(![...subscriptions.values()].some(entry => entry.registration && registrationKey(entry.registration) === identity),
203
+ 'duplicate_contribution', 'Contribution registration exists')
204
+ return subscribe('action', registration.actionId, handler, condition, registration)
205
+ }
206
+ function commandGroup(group, commands) {
207
+ active()
208
+ boundedText(group, 'command group')
209
+ ensure(Array.isArray(commands) && commands.length > 0 && commands.length <= LIMITS.contributions &&
210
+ subscriptions.size + commands.length <= LIMITS.subscriptions, 'queue_full', 'Invalid or excessive group commands')
211
+ const prepared = commands.map(command => {
212
+ ensure(object(command), 'invalid_payload', 'Invalid group command')
213
+ const { id, handler, ...options } = command
214
+ return prepareRegistration('command', id, { ...options, group }, handler)
215
+ })
216
+ const identities = new Set([...subscriptions.values()].filter(entry => entry.registration).map(entry => registrationKey(entry.registration)))
217
+ for (const { registration } of prepared) {
218
+ const identity = registrationKey(registration)
219
+ ensure(!identities.has(identity), 'duplicate_contribution', 'Contribution registration exists')
220
+ identities.add(identity)
221
+ }
222
+ const members = []
223
+ const dispose = () => { for (const member of members) member.dispose() }
224
+ try { for (const item of prepared) members.push(registerPrepared(item)) }
225
+ catch (error) { dispose(); throw error }
226
+ const ready = Promise.all(members.map(member => member.ready)).then(() => null)
227
+ ready.catch(dispose)
228
+ return { ready, dispose }
229
+ }
230
+ function schedule(intervalMs, handler, { condition = always, immediate = false } = {}) {
231
+ active()
232
+ ensure(Number.isFinite(intervalMs) && intervalMs >= 1000 / 30 && intervalMs <= 2147483647,
233
+ 'invalid_payload', 'Schedule interval must be between 1000/30 and 2147483647 ms')
234
+ ensure(typeof handler === 'function' && schedules.size < LIMITS.schedules, 'queue_full', 'Invalid or excessive schedules')
235
+ condition = validateCondition(condition)
236
+ const interest = condition.kind === 'always' ? null : subscribe('visibility', condition.contributionId, () => {}, condition)
237
+ const conditionIdentity = conditionKey(condition)
238
+ const controller = new AbortController()
239
+ let activeRun = null
240
+ let timer
241
+ const beginRun = () => {
242
+ const runController = new AbortController()
243
+ const cancelFromSchedule = () => runController.abort()
244
+ const cancelFromLifetime = () => runController.abort()
245
+ controller.signal.addEventListener('abort', cancelFromSchedule, { once: true })
246
+ lifetime.signal.addEventListener('abort', cancelFromLifetime, { once: true })
247
+ const run = {
248
+ controller: runController,
249
+ finish() {
250
+ controller.signal.removeEventListener('abort', cancelFromSchedule)
251
+ lifetime.signal.removeEventListener('abort', cancelFromLifetime)
252
+ if (activeRun?.controller === runController) activeRun = null
253
+ },
254
+ }
255
+ activeRun = run
256
+ return run
257
+ }
258
+ const tick = async () => {
259
+ if (controller.signal.aborted || lifetime.signal.aborted) return
260
+ const visible = condition.kind === 'always' || visibility.has(conditionIdentity)
261
+ if (!visible) {
262
+ timer = clock.setTimeout(tick, intervalMs)
263
+ return
264
+ }
265
+ const run = beginRun()
266
+ try {
267
+ await handler({ signal: run.controller.signal })
268
+ } catch (error) {
269
+ if (!run.controller.signal.aborted || condition.kind === 'always') onError(error)
270
+ } finally {
271
+ run.finish()
272
+ }
273
+ // Each schedule has at most one invocation in flight.
274
+ if (!controller.signal.aborted && !lifetime.signal.aborted) timer = clock.setTimeout(tick, intervalMs)
275
+ }
276
+ const disposable = {
277
+ visibilityChanged(nextVisibility) {
278
+ if (condition.kind !== 'always' && !nextVisibility.has(conditionIdentity)) activeRun?.controller.abort()
279
+ },
280
+ dispose() {
281
+ controller.abort()
282
+ activeRun?.controller.abort()
283
+ clock.clearTimeout(timer)
284
+ interest?.dispose()
285
+ schedules.delete(disposable)
286
+ },
287
+ }
288
+ schedules.add(disposable)
289
+ timer = clock.setTimeout(tick, immediate ? 0 : intervalMs)
290
+ return disposable
291
+ }
292
+ const method = op => (args, options) => request(op, args, options)
293
+ const context = Object.freeze({
294
+ manifest, producer: peer.producer, signal: lifetime.signal, request,
295
+ ...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
296
+ (id, entity) => publish(kind, id, entity).publisher])),
297
+ canvas(id, spec, entity) {
298
+ const { publisher, key } = publish(null, id, entity)
299
+ try { publisher.replace({ kind: 'canvas', canvas: spec }) }
300
+ catch (error) { publisher.dispose(); throw error }
301
+ return { ...publisher, write: (ansi, options) => request('canvas.write', { key, ansi }, options),
302
+ focus: (capture, options) => request('canvas.focus', { key, capture }, options) }
303
+ },
304
+ ...Object.fromEntries(['Event', 'Hook', 'Action', 'Input', 'Select', 'Resize', 'Activate', 'Deactivate'].map(name =>
305
+ [`on${name}`, (event, handler, condition) => subscribe(name.toLowerCase(), event, handler, condition)])),
306
+ ...Object.fromEntries(['menu', 'command', 'key', 'link'].map(kind => [kind,
307
+ (id, options, handler) => registerPrepared(prepareRegistration(kind, id, options, handler))])),
308
+ commandGroup,
309
+ schedule,
310
+ onDispose(cleanup) {
311
+ active()
312
+ ensure(typeof cleanup === 'function' && cleanups.size < LIMITS.subscriptions, 'queue_full', 'Invalid or excessive cleanup callbacks')
313
+ cleanups.add(cleanup)
314
+ return { dispose: () => { cleanups.delete(cleanup) } }
315
+ },
316
+ panes: Object.freeze(Object.fromEntries(['create', 'close', 'restart', 'input', 'focus', 'wait'].map(name => [name, method(`pane.${name}`)]))),
317
+ projects: Object.freeze({ create: method('project.create'), remove: method('project.remove') }),
318
+ notifications: Object.freeze({ show: method('notification.show') }),
319
+ url: Object.freeze({ open: (url, options) => request('url.open', { url }, options) }),
320
+ fetch: method('fetch'),
321
+ secrets: Object.freeze({ get: (name, options) => request('secret.get', { name }, options) }),
322
+ config: Object.freeze({ get: options => request('config.get', {}, options) }),
323
+ state: Object.freeze({ get: (key, options) => request('state.get', { key }, options),
324
+ set: (key, value, options) => request('state.set', { key, value }, options) }),
325
+ context: Object.freeze({ get: options => request('context.get', {}, options) }),
326
+ popover: Object.freeze({ open: method('popover.open') }),
327
+ health: Object.freeze({ set: method('health.set') }),
328
+ webhook: Object.freeze({ ack: (deliveryId, options) => request('webhook.ack', { deliveryId }, options) }),
329
+ })
330
+ return {
331
+ context, receive: frame => peer.receive(frame),
332
+ async activate(definition) {
333
+ active()
334
+ ensure(definition?.id === manifest.id && typeof definition.activate === 'function', 'identity_mismatch', 'Runtime plugin must match static manifest id')
335
+ const cleanup = await definition.activate(context)
336
+ if (typeof cleanup === 'function') {
337
+ if (lifetime.signal.aborted) await cleanup()
338
+ else context.onDispose(cleanup)
339
+ }
340
+ },
341
+ dispose() {
342
+ if (disposal) return disposal
343
+ // Remove owned surfaces before the lifetime signal prevents new work.
344
+ for (const publisher of publishers.values()) { try { publisher.dispose() } catch (error) { onError(error) } }
345
+ lifetime.abort()
346
+ for (const disposable of schedules) disposable.dispose()
347
+ peer.dispose()
348
+ subscriptions.clear()
349
+ disposal = Promise.allSettled([...cleanups].reverse().map(cleanup => Promise.resolve().then(cleanup)))
350
+ .then(results => { for (const result of results) if (result.status === 'rejected') onError(result.reason); cleanups.clear() })
351
+ return disposal
352
+ },
353
+ }
354
+ }
@@ -0,0 +1,24 @@
1
+ import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
2
+ import type { Envelope } from './internal.js';
3
+ export interface Harness {
4
+ context: PluginContext;
5
+ trace: unknown[];
6
+ surfaces: Map<string, Envelope>;
7
+ subscriptions: Map<string, Json>;
8
+ activate(definition: PluginDefinition): Promise<void>;
9
+ flush(): Promise<void>;
10
+ advance(milliseconds: number): Promise<void>;
11
+ emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
12
+ visibility(conditions: Condition[]): Promise<Json>;
13
+ receive(frame: Envelope): void;
14
+ drainTrace(): unknown[];
15
+ readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
16
+ dispose(): Promise<void>;
17
+ }
18
+ export function createHarness(options: {
19
+ manifest: ManifestInput;
20
+ machineId?: string;
21
+ epoch?: string;
22
+ now?: number;
23
+ handlers?: { [K in keyof OperationMap]?: (args: OperationMap[K]['input'], context: { signal: AbortSignal; timeoutMs: number }) => OperationMap[K]['output'] | Promise<OperationMap[K]['output']> };
24
+ }): Harness;
@@ -0,0 +1,104 @@
1
+ import { createRuntime } from './runtime.mjs'
2
+ import { RpcPeer } from './protocol.mjs'
3
+ import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
4
+
5
+ /** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
6
+ export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
7
+ const manifest = validateManifest(input)
8
+ const producer = { pluginId: manifest.id, machineId, epoch }
9
+ const trace = []
10
+ const surfaces = new Map()
11
+ const subscriptions = new Map()
12
+ const localState = new Map()
13
+ const timers = new Map()
14
+ let timerId = 0
15
+ let disposed = false
16
+ const clock = {
17
+ now: () => now,
18
+ setTimeout(callback, delay) { const id = ++timerId; timers.set(id, { due: now + delay, callback }); return id },
19
+ clearTimeout(id) { timers.delete(id) },
20
+ }
21
+ const record = event => {
22
+ ensure(trace.length < 10000, 'queue_full', 'Harness trace reached 10000 entries; drain it before continuing')
23
+ trace.push(event)
24
+ }
25
+ let runtime
26
+ const host = new RpcPeer({ producer, clock,
27
+ send(frame) { record({ type: 'host', frame: structuredClone(frame) }); runtime.receive(frame) },
28
+ onSurface(frame) {
29
+ const key = JSON.stringify(frame.key)
30
+ frame.content === null ? surfaces.delete(key) : surfaces.set(key, structuredClone(frame))
31
+ },
32
+ onRequest(operation, options) {
33
+ record({ type: 'command', operation: structuredClone(operation) })
34
+ if (handlers[operation.op]) return handlers[operation.op](operation.args, options)
35
+ const args = operation.args
36
+ switch (operation.op) {
37
+ case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
38
+ case 'subscription.remove': subscriptions.delete(args.id); return null
39
+ case 'state.get': return structuredClone(localState.get(args.key) ?? null)
40
+ case 'state.set':
41
+ ensure(localState.has(args.key) || localState.size < LIMITS.contributions, 'queue_full', 'Harness state is full')
42
+ localState.set(args.key, structuredClone(args.value)); return null
43
+ case 'config.get': return {}
44
+ case 'context.get': return { machineId }
45
+ case 'health.set': return null
46
+ default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
47
+ }
48
+ },
49
+ onError: error => record({ type: 'error', code: error.code, message: error.message }),
50
+ })
51
+ runtime = createRuntime({ manifest, producer, clock,
52
+ send(frame) { record({ type: 'plugin', frame: structuredClone(frame) }); host.receive(frame) },
53
+ onError: error => record({ type: 'error', code: error.code, message: error.message }),
54
+ })
55
+ async function flush() { for (let turn = 0; turn < 64; turn++) await Promise.resolve() }
56
+ return {
57
+ context: runtime.context, trace, surfaces, subscriptions,
58
+ activate: definition => runtime.activate(definition),
59
+ flush,
60
+ async advance(milliseconds) {
61
+ ensure(Number.isFinite(milliseconds) && milliseconds >= 0, 'invalid_payload', 'Advance requires a nonnegative duration')
62
+ const end = now + milliseconds
63
+ await flush()
64
+ let ticks = 0
65
+ while (true) {
66
+ const next = [...timers].filter(([, timer]) => timer.due <= end)
67
+ .sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0]
68
+ if (!next) break
69
+ ensure(++ticks <= 10000, 'queue_full', 'Advance exceeds the harness timer budget')
70
+ now = next[1].due
71
+ timers.delete(next[0])
72
+ const result = next[1].callback()
73
+ Promise.resolve(result).catch(error => record({ type: 'error', code: error.code, message: error.message }))
74
+ await flush()
75
+ }
76
+ now = end
77
+ await flush()
78
+ },
79
+ async emit(kind, name, event, options) {
80
+ await flush()
81
+ const results = []
82
+ for (const [subscriptionId, subscription] of subscriptions) {
83
+ if (subscription.kind === kind && subscription.name === name) {
84
+ results.push(await host.request({ op: 'runtime.invoke', args: { subscriptionId, event } }, options))
85
+ }
86
+ }
87
+ return results
88
+ },
89
+ visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
90
+ receive: frame => runtime.receive(frame),
91
+ drainTrace() { return trace.splice(0) },
92
+ get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
93
+ async dispose() {
94
+ if (disposed) return
95
+ disposed = true
96
+ await runtime.dispose()
97
+ host.dispose()
98
+ subscriptions.clear()
99
+ surfaces.clear()
100
+ timers.clear()
101
+ record({ type: 'disposed' })
102
+ },
103
+ }
104
+ }