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

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
@@ -11,8 +11,8 @@ Its `package.json` includes a static `standardPlugin` manifest.
11
11
  {
12
12
  "name": "example-status",
13
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" },
14
+ "peerDependencies": { "@standardagents/code-plugin-sdk": "^1.0.0-alpha.4-hover.1" },
15
+ "devDependencies": { "@standardagents/code-plugin-sdk": "^1.0.0-alpha.4-hover.1" },
16
16
  "standardPlugin": {
17
17
  "apiVersion": 1,
18
18
  "id": "example-status",
@@ -44,6 +44,20 @@ package, such as `@standardagents/code-plugin-sdk/testing`, are refused when
44
44
  the plugin runs inside Standard Code. The `testing` export serves the
45
45
  plugin's own test suite.
46
46
 
47
+ The complete rendering contract is in [REFERENCE.md](./REFERENCE.md). It
48
+ covers rows, styled text, badges, cards, slots, panels, overlays, host-rendered
49
+ views, ANSI canvases, canvas hover text, input focus, popover/column/pane
50
+ presentations, remote read-only behavior, and the frontend surfaces that are
51
+ still planned.
52
+
53
+ Plugin source imports the default package entry. Plugin tests may import
54
+ `@standardagents/code-plugin-sdk/testing` for `createHarness`. Runner internals,
55
+ raw host request envelopes, and internal module paths are outside the public
56
+ package exports. First-party plugins use these same public entries and receive
57
+ the same manifest validation, capability checks, request bounds, and remote
58
+ ownership rules as third-party plugins. A plugin ID does not grant a host
59
+ operation or fetch exception.
60
+
47
61
  The runtime disposes publishers, subscriptions, schedules and pending requests.
48
62
  Plugins register additional cleanup through `ctx.onDispose` or an activation return value.
49
63
  Handlers receive an abort signal.
@@ -148,7 +162,7 @@ The harness starts no subprocesses.
148
162
 
149
163
  ## Supported interface
150
164
 
151
- The public interface is the package's default export, its `testing` export,
165
+ The public interface is the package root export, its `testing` export,
152
166
  the declarations in `src/index.d.ts` and `src/testing.d.ts`, and the
153
167
  `standard-plugin` command's `check` and `pack` behavior.
154
168
 
@@ -161,14 +175,18 @@ without notice. Plugin code and plugin tests should treat recorded frames as
161
175
  opaque values and drive the plugin through `PluginContext` and the harness
162
176
  methods.
163
177
 
164
- Local `ctx.state` belongs to one machine.
178
+ `ctx.state` is per-plugin key and value storage. Standard Code stores these
179
+ values in the user's account, so every machine the user has reads and writes
180
+ the same keys. A plugin holds at most 256 keys, and one value is at most
181
+ 64 KiB of JSON.
165
182
  The public context declarations are in `src/index.d.ts`.
166
183
 
167
184
  ## Releases
168
185
 
169
186
  The Standard Code build workflow publishes this package when the version in
170
- `package.json` changes. Prereleases publish under the `next` dist-tag and
171
- releases under `latest`.
187
+ `package.json` changes. Main prereleases publish under `next` and main releases
188
+ under `latest`. Feature branches publish under their product branch tag. Authors
189
+ can install a published prerelease by its exact version.
172
190
 
173
191
  ## License
174
192
 
package/REFERENCE.md ADDED
@@ -0,0 +1,433 @@
1
+ # Plugin SDK reference
2
+
3
+ The public declarations live in
4
+ `packages/plugin-sdk/src/index.d.ts`. This page groups the stable concepts for
5
+ plugin authors.
6
+
7
+ The bundled SDK and the npm package use the same public declarations. This
8
+ reference describes SDK `1.0.0-alpha.4-hover.1`. Plugins should declare the
9
+ version published for their product build in both `peerDependencies` and
10
+ `devDependencies`; the manifest `apiVersion` remains `1`.
11
+
12
+ ## Definition and context
13
+
14
+ `definePlugin({ id, activate })` creates the default plugin export. The
15
+ activation function receives a `PluginContext` with the immutable manifest,
16
+ producer identity, abort signal, typed request function, surface publishers,
17
+ subscriptions, schedules, lifecycle operations, and host capabilities.
18
+
19
+ `section`, `card`, `slot`, `badge`, `panel`, and `overlay` each return a
20
+ publisher for a declared contribution of that kind; `canvas(id, spec)`
21
+ returns a canvas publisher for any declaration that draws content.
22
+ Publishers expose `replace(content)` and `clear()`. A canvas publisher also
23
+ exposes `write(ansi)` and `focus(capture)`. Registration objects return a
24
+ disposable subscription. Key registrations support one pending chord update.
25
+
26
+ ## Manifest
27
+
28
+ `PluginManifest` contains `apiVersion`, `id`, `name`, `version`, `entry`,
29
+ `singleton`, `order`, `capabilities`, `contributions`, and `hookTimeoutMs`.
30
+ Contribution declarations contain a stable ID, surface kind, anchor, title,
31
+ merge mode, width, menu position, palette group, chord, URL pattern, and
32
+ action ID as appropriate.
33
+
34
+ A `card` declaration draws a boxed card in the sidebar. `validateManifest`
35
+ refuses a card whose anchor is not `plugins`. Two optional fields control
36
+ panels:
37
+
38
+ - `presentation` is `popover` (the default), `column`, or `pane`. On a panel
39
+ it sets how the panel opens. On a card or command it sets how the panel
40
+ named by `opens` opens. Other kinds cannot declare a presentation.
41
+ - `opens` is the ID of a panel that a card or command opens when the viewer
42
+ activates it. `validateManifest` refuses an `opens` value that does not name
43
+ a declared panel.
44
+
45
+ `PRESENTATIONS` lists the accepted presentation values.
46
+
47
+ An anchor determines the entity that owns a contribution. Machine anchors use
48
+ a machine entity, project anchors use a project entity, and pane anchors use a
49
+ pane entity. The `plugins` and `account` anchors are account-level surfaces;
50
+ `section` may use a section entity. A machine contribution normally omits its
51
+ generation so the host resolves the current generation. The host rejects a
52
+ contribution whose entity belongs to another machine or project.
53
+
54
+ ## Sources and collections
55
+
56
+ A plugin installs from a Git repository or an npm package. A source is one
57
+ plugin or a collection. `PluginCollection` is the parsed
58
+ `standard-plugins.json` at the source root: `schema` is `1` and `plugins`
59
+ holds 1 to 256 `PluginCollectionEntry` values. Each entry has an `id` in the
60
+ plugin id pattern and a relative POSIX `path` with no leading slash, no
61
+ backslash, and no empty, `.`, or `..` segment. Ids and paths are unique.
62
+ `standard plugin install <source> --plugin <id>` selects one entry.
63
+
64
+ `validateCollection(value)` returns a frozen collection or throws a
65
+ `PluginError` with code `invalid_collection`.
66
+ `resolveCollection({ collection, packageJson })` returns the validated
67
+ collection when a file is present; otherwise a valid `standardPlugin`
68
+ manifest in `packageJson` yields one entry at path `""`. `COLLECTION_FILE`
69
+ is the file name.
70
+
71
+ ## Dependencies and publishing
72
+
73
+ A plugin with `dependencies` or `optionalDependencies` ships a lockfile
74
+ beside its `package.json`. `lockfileRequirement({ packageJson, sourceKind })`
75
+ returns `{ required, dependencies, lockfiles }`: for `"npm"` the accepted
76
+ name is `npm-shrinkwrap.json`; for `"git"` it is `package-lock.json` or
77
+ `npm-shrinkwrap.json`. The SDK itself stays under `peerDependencies`.
78
+
79
+ `checkPackageForPublish({ packageJson, files, sourceKind, requireManifest,
80
+ expectedId })` is pure and returns `PublishProblem` values with a `code` and
81
+ a `message`. Codes cover a missing lockfile, the SDK under `dependencies`, a
82
+ missing peer declaration, a missing or invalid manifest, an entry absent from
83
+ `files`, an id that differs from `expectedId`, and a private package.
84
+
85
+ The package's `standard-plugin` command applies these rules to a directory:
86
+ `check [dir] [--source npm|git]` prints problems and exits with status 1
87
+ when it finds one, and `pack [dir]` runs `check`, runs `npm shrinkwrap` when
88
+ dependencies lack `npm-shrinkwrap.json`, and then prints the
89
+ `npm pack --dry-run` file list.
90
+
91
+ ## Content
92
+
93
+ Native content uses rows, styled text, or badges. A row can carry an identity,
94
+ provider revision, styled spans, an action ID, a meter, spark samples, and a
95
+ divider marker. Canvas content declares canonical columns, rows, transparency,
96
+ shade, input capture, and optional hover metadata.
97
+
98
+ View content, `{ kind: 'view', root }`, is a tree of host-rendered nodes. The
99
+ host draws it with the viewer's theme, symbols, focus, and hit testing.
100
+
101
+ Each contribution kind accepts a fixed set of content kinds, and
102
+ `replace(content)` throws a `PluginError` for any other pairing:
103
+
104
+ | Contribution | Rows, text | Canvas | View | Badge |
105
+ |---|---|---|---|---|
106
+ | `section`, `card`, `panel` | yes | yes | yes | no |
107
+ | `slot`, `overlay` | yes | yes | no | no |
108
+ | `badge` | no | no | no | yes |
109
+ | `menu`, `command`, `key`, `link` | no | no | no | no |
110
+
111
+ ### Custom interfaces with canvas
112
+
113
+ Canvas is the fully supported path for a plugin that draws its own
114
+ interface. A section, card, or panel can show a canvas in every
115
+ presentation: a sidebar card, a pop-over, a column, or a plugin pane.
116
+ `canvas.columns` is the intrinsic width that the host uses to size the card,
117
+ column, or plugin pane. The view helpers below are optional, and a plugin can
118
+ combine a view card with a canvas panel.
119
+
120
+ `write(ansi)` draws into a grid that the host keeps for the canvas. The grid
121
+ keeps its cells between writes, so a plugin that redraws a whole frame starts
122
+ it with `\x1b[2J\x1b[H`. The host honours these sequences:
123
+
124
+ - SGR (`CSI … m`): reset, bold, dim, italic, underline, inverse, hidden,
125
+ strikethrough, their resets, the 8 and 16 colour forms, and `38`/`48`
126
+ with `5;n` or `2;r;g;b` (colon forms too).
127
+ - Cursor positioning: `CUP`/`HVP` (`H`, `f`), `CUU`, `CUD`, `CUF`, `CUB`,
128
+ `CNL`, `CPL`, `CHA`, and `VPA`, clamped to the canvas.
129
+ - Erasing: `ED` (`J`), `EL` (`K`), and `ECH` (`X`), with the current
130
+ background colour.
131
+ - Carriage return, line feed (which also returns to the first column),
132
+ tab, and backspace.
133
+
134
+ The host drops every other escape and control sequence, including private
135
+ modes, OSC (titles, hyperlinks, clipboard), DCS, APC, PM, and SOS, and every
136
+ C0, C1, and bidirectional formatting character. Text past the right edge is
137
+ clipped, and a line feed on the last row stays on that row: a canvas never
138
+ wraps or scrolls. Wide characters take two cells. Cells in default colours
139
+ take the viewer's theme, and a blank default cell shows the surface under the
140
+ canvas.
141
+
142
+ `hover` is an optional one-line string. It must be non-empty after trimming,
143
+ fit within 512 UTF-8 bytes, and contain no C0, C1, DEL, or bidirectional
144
+ formatting character. `Canvas.replace` publishes the canvas metadata, so a
145
+ plugin can update the hover text with another `replace` call while retaining
146
+ the same canvas grid.
147
+
148
+ On the current frontend, live canvases in `machine.before` and
149
+ `machine.after` slots are hover targets. The viewer waits 300 milliseconds
150
+ while the pointer remains over the canvas, then shows the text in a single
151
+ line owned by the host. Leaving the canvas dismisses it immediately. Hover
152
+ does not focus the canvas or change its geometry. The dwell timer and pointer
153
+ state belong to each viewer, so remote viewers can see hover text for a
154
+ machine slot independently.
155
+
156
+ `focus(true)` asks for keyboard input and needs `captureInput: true` in the
157
+ canvas spec. While the canvas is shown in a pop-over, column, or plugin pane
158
+ and holds focus, the host sends every key except Escape and
159
+ Control, Alt, or Super chords to `onInput(id, handler)` subscriptions as
160
+ `{ kind: 'key', key, phase, contributionId, entity? }`. `key` is the typed
161
+ character or one of `up`, `down`, `left`, `right`, `enter`, `tab`,
162
+ `backspace`, `delete`, `escape`, `space`, `home`, `end`, `pageup`,
163
+ `pagedown`, or `f1`-`f12`; `phase` is `press` or `repeat`. When the canvas
164
+ stops being shown, for example after Escape, input subscriptions receive
165
+ `{ kind: 'focus', focused: false }` and `onDeactivate(id, handler)`
166
+ subscriptions run. The plugin calls `focus(true)` again to take keys back.
167
+ Sidebar cards and machine slots show only canvases without `captureInput`.
168
+ Local frontends receive canvas cells and send input. Remote viewers receive
169
+ the canvas grids and hover metadata, but remote actions, commands, panel
170
+ visibility changes, and canvas input are read-only until transport support is
171
+ added.
172
+
173
+ ## View nodes
174
+
175
+ Every node has a `type`. Field names use camelCase, and tone, weight,
176
+ alignment, and width keywords are lowercase.
177
+
178
+ | Type | Fields |
179
+ |---|---|
180
+ | `stack` | `gap?`, `children` |
181
+ | `row` | `children`, `align?` |
182
+ | `divider` | `label?` |
183
+ | `text` | `text?` or `spans?`, `tone?`, `weight?`, `mono?` |
184
+ | `badge` | `label`, `tone?` |
185
+ | `dot` | `tone?` |
186
+ | `progress` | `value`, `max`, `tone?`, `label?` |
187
+ | `segments` | `items`: `{ value, max, tone?, label? }` |
188
+ | `card` | `title?`, `tone?`, `children` |
189
+ | `stat` | `label`, `value`, `tone?`, `hint?` |
190
+ | `kv` | `items`: `{ label, value, mono?, copy? }` |
191
+ | `tabs` | `id`, `items`, `filters?`, `action?` |
192
+ | `select` | `id`, `label`, `options`, `filters?`, `action?` |
193
+ | `table` | `id`, `columns`, `rows` |
194
+ | `log` | `lines` |
195
+ | `button` | `label`, `action` |
196
+
197
+ Tones are `ok`, `info`, `warn`, `error`, `muted`, `accent`, `pending`, and
198
+ `bright` (`TONES`). Weights are `normal`, `bold`, and `dim`. Alignments are
199
+ `start`, `center`, and `end`.
200
+
201
+ A table column has an `id`, an optional `label`, a `width` of `'fill'` or a
202
+ cell count, an optional `maxWidth` and `align`, and a `priority`. The host
203
+ drops the columns with the highest `priority` values first on a narrow
204
+ surface. A table row has an `id`, `cells` keyed by column ID, and optional
205
+ `tone`, `note` spans, `tags`, and `action`.
206
+
207
+ Tab items and select options carry an `id`, a `label`, an optional `count`,
208
+ and an optional `tag`. When `filters` names a table in the same view, the
209
+ host shows the rows whose `tags` contain the chosen item's `tag`. An item
210
+ without a `tag` shows every row. The host filters and counts for each viewer
211
+ without a plugin round trip.
212
+
213
+ A view action is `{ actionId, value?, opens? }`. The host sends `actionId`
214
+ and the string `value` to the plugin, so an `onAction(actionId, handler)`
215
+ handler receives `event.value`. One action ID with a per-row value replaces
216
+ one subscription per row. When `opens` names a panel, the host opens that
217
+ panel after the plugin accepts the action. A `tabs` or `select` action value
218
+ defaults to the chosen item ID.
219
+
220
+ A node type that the host does not know draws nothing, so a newer SDK can
221
+ add node types.
222
+
223
+ ### Card views
224
+
225
+ A card shows a short summary. Its view can contain `stack`, `row`, `text`,
226
+ `badge`, `dot`, `progress`, `segments`, `stat`, and `divider` nodes, and it
227
+ is at most 6 lines tall. A `stack` is as tall as its children plus `gap`
228
+ lines between them. A `row` is as tall as its tallest child. Each other
229
+ allowed node is 1 line, and an unknown node is 0 lines. Put `card`, `kv`,
230
+ `tabs`, `select`, `table`, `log`, and `button` nodes in a panel. A card that
231
+ shows a canvas uses the general canvas bounds.
232
+
233
+ ### Bounds
234
+
235
+ `validateView(root)` applies the bounds that the host applies, and
236
+ `replace(content)` calls it for view content. `VIEW_LIMITS` holds the
237
+ numbers.
238
+
239
+ - A tree nests at most 8 nodes deep and holds at most 4,096 nodes. Table
240
+ cells count toward both limits.
241
+ - A table has at most 512 rows and 12 columns.
242
+ - No string contains a C0 or C1 control character, DEL, or a bidirectional
243
+ formatting character.
244
+ - Every number is finite.
245
+ - The serialized tree fits in one 256 KiB frame.
246
+ - An action ID and an action `opens` value are plugin identifiers, and an
247
+ action `value` is at most 512 UTF-8 bytes.
248
+ - An action `opens` value names a panel that the plugin declares.
249
+ - A card view follows the card rules above.
250
+
251
+ `validateView(root, { kind, manifest })` applies the card rules when `kind`
252
+ is `'card'` and the `opens` rule when `manifest` is present.
253
+
254
+ ### View helpers
255
+
256
+ `ui` builds nodes as plain JSON. The helpers are optional; hand-written JSON
257
+ with the same fields is equivalent. Options that are `undefined` are left
258
+ out.
259
+
260
+ ```js
261
+ import { definePlugin, ui } from '@standardagents/code-plugin-sdk'
262
+
263
+ export default definePlugin({
264
+ id: 'builds-monitor',
265
+ activate(ctx) {
266
+ const card = ctx.card('summary')
267
+ card.replace(ui.view(ui.stack([
268
+ ui.stat('Queue', '3', { tone: 'pending' }),
269
+ ui.table('builds', [{ id: 'version', label: 'Version' }], [{
270
+ id: 'b-1',
271
+ cells: { version: ui.text('0.4.12', { weight: 'bold' }) },
272
+ action: ui.action('open-build', { value: 'b-1', opens: 'build-detail' }),
273
+ }]),
274
+ ui.button('Open dashboard', 'open-dashboard'),
275
+ ])))
276
+ ctx.onAction('open-build', event => { /* event.value is 'b-1' */ })
277
+ },
278
+ })
279
+ ```
280
+
281
+ | Helper | Result |
282
+ |---|---|
283
+ | `ui.view(root)` | `{ kind: 'view', root }` |
284
+ | `ui.action(actionId, { value, opens })` | an action |
285
+ | `ui.span(text, { tone, weight, mono })` | a span |
286
+ | `ui.stack(children, { gap })`, `ui.row(children, { align })` | a layout node |
287
+ | `ui.card(children, { title, tone })` | a card node |
288
+ | `ui.divider(label)` | a divider |
289
+ | `ui.text(textOrSpans, { tone, weight, mono })` | a text node; an array sets `spans` |
290
+ | `ui.badge(label, tone)`, `ui.dot(tone)` | a badge or dot |
291
+ | `ui.progress(value, max, { tone, label })`, `ui.segments(items)` | a meter |
292
+ | `ui.stat(label, value, { tone, hint })`, `ui.kv(items)` | a value display |
293
+ | `ui.tabs(id, items, { filters, action })` | a tab strip |
294
+ | `ui.select(id, label, options, { filters, action })` | a drop-down |
295
+ | `ui.table(id, columns, rows)` | a table |
296
+ | `ui.log(lines)` | a log |
297
+ | `ui.button(label, action)` | a button; `action` is an action or an action ID |
298
+
299
+ `packages/plugin-sdk/test/fixtures/view-builds-monitor.json` holds a complete
300
+ view that matches the protocol's round-trip test.
301
+
302
+ ### Current frontend coverage
303
+
304
+ Sections, plugin cards, machine slots, canvas surfaces, host-rendered views,
305
+ global command palette entries, and the three
306
+ panel presentations are implemented. A popover is content-sized within the
307
+ modal area. A column attaches to the sidebar and resizes the workspace. A
308
+ plugin pane has a tab and focus, drag, close, and reveal behavior; its width
309
+ follows content and it does not create a tmux pane or resize a terminal.
310
+
311
+ Project slot anchors, standalone badges attached to hierarchy rows, pane
312
+ header/footer anchors, menu registrations, key registrations, link registrations,
313
+ and entity-scoped command palette entries have protocol and composition support
314
+ but do not have connected production frontend surfaces. Badge nodes inside a
315
+ view tree are rendered. The `overlay`
316
+ contribution kind has protocol validation, while general workspace overlay
317
+ placement and input remain unfinished. Use a supported panel presentation for
318
+ current interactive canvases.
319
+
320
+ ## Actions and subscriptions
321
+
322
+ Commands, menus, keys, and links carry stable action IDs. Actions receive a
323
+ selection and handler context with an abort signal. Link handlers return a
324
+ boolean that controls matching-handler traversal.
325
+
326
+ Global command registrations appear in the command palette. Menu, key, and
327
+ link registrations are accepted and stored by the SDK and daemon, while their
328
+ frontend menus, global key dispatch, and link list are still planned. An
329
+ entity-scoped command also has no current palette surface. `url.open` is the
330
+ supported host operation for opening a URL.
331
+
332
+ Event, hook, input, select, resize, activate, deactivate, and visibility
333
+ subscriptions use bounded conditions. Schedules allow `always`,
334
+ `section-visible`, `slot-visible`, or `panel-open` conditions. One schedule
335
+ invocation runs at a time.
336
+
337
+ ## Typed requests
338
+
339
+ The operation map covers pane and project lifecycle, notifications, URL and
340
+ fetch access, secrets, configuration, account state, context, popovers, canvas
341
+ write and focus, subscription changes, health, and webhook acknowledgements.
342
+ Each operation has a typed input and output. `RequestOptions` accepts an abort
343
+ signal and a timeout.
344
+
345
+ The public SDK applies the same capability checks and request bounds to every
346
+ plugin. Plugin IDs do not grant fetch, pane, project, or secret exceptions.
347
+ The current native host connects fetch, secrets, configuration, account state,
348
+ context, URL opening, subscription changes, health, canvas writes, and canvas
349
+ focus. Pane/project lifecycle requests, notifications, `popover.open`, and
350
+ webhook acknowledgements have public types but currently return an unsupported
351
+ operation error through the production context. Declaring a capability does
352
+ not supply an unfinished host adapter.
353
+
354
+ The shared fetch policy currently permits HTTPS GET requests to
355
+ `builds.standardcode.ai` on port 443 under `/admin/api/` or `/builds/`. Requests
356
+ have no body, user-info, or fragment. Allowed request headers are authorization,
357
+ range, accept, and cache-control. The response body limit is 16 MiB. These
358
+ bounds apply to every fetch-authorized plugin. Plugin code retains normal
359
+ Node.js access to files, networking, and subprocesses under the user's identity;
360
+ capabilities govern SDK/host operations and do not provide OS sandboxing.
361
+
362
+ ## Build context
363
+
364
+ `context.get` returns a `PluginContextInfo`:
365
+
366
+ ```json
367
+ {
368
+ "accountId": "account-id",
369
+ "machineId": "machine-id",
370
+ "build": {
371
+ "version": "0.14.1-branch.3bd7f23c.98a422",
372
+ "commit": "<40 hex digits>",
373
+ "ref": "refs/heads/feat/sidebar-plugins",
374
+ "channel": "branch"
375
+ },
376
+ "fleet": {
377
+ "mode": "follow",
378
+ "npmTag": "branch-feat-sidebar-plugins-3bd7f23c-b-6e049b24f5928544",
379
+ "ref": "refs/heads/feat/sidebar-plugins",
380
+ "version": "0.14.1-branch.3bd7f23c.98a422"
381
+ }
382
+ }
383
+ ```
384
+
385
+ `build` describes the native release that this machine runs. It is `null` for
386
+ a development binary without release metadata.
387
+
388
+ - `channel` is `branch` for branch and issue builds, `canary` for `main`
389
+ builds, `production` for a stable version, and `team` for a legacy team
390
+ release.
391
+ - `ref` is `refs/heads/main` for canary and team builds and
392
+ `refs/tags/vX.Y.Z` for production builds. A branch build reports its sealed
393
+ branch name.
394
+
395
+ `fleet` describes the account build policy. It is `null` when the account has
396
+ no build policy.
397
+
398
+ - `mode` is `follow` for a followed npm tag, `production` for the `latest`
399
+ tag, and `pin` for an exact version.
400
+ - `npmTag` is the followed tag, or `null` for a pin.
401
+ - `version` is the pinned version, or the target of the current rollout, or
402
+ the active account version. It is `null` when none is known.
403
+ - `ref` is `refs/heads/main` for `canary`. A branch or issue tag has a `ref`
404
+ only when it names the branch of the running build. The daemon does not
405
+ read the network to resolve other branch names, so `ref` is `null` for them.
406
+
407
+ Older daemons return only `accountId` and `machineId`. Treat a missing
408
+ `build` or `fleet` as `null`.
409
+
410
+ A plugin with the `events` capability can subscribe with
411
+ `onEvent('build-context', handler)`. The daemon delivers a `BuildContextEvent`
412
+ when `build` or `fleet` changes. Its `data` holds the new `build` and `fleet`
413
+ values in the shape above. Other account changes do not deliver the event, and
414
+ the daemon does not deliver it when a plugin starts. Read `context.get` at
415
+ activation for the initial values. The daemon pushes each change, so a plugin
416
+ needs no schedule to track the followed channel.
417
+
418
+ ## Hooks and delivery
419
+
420
+ Hook dispatch and durable webhook delivery are planned host integrations.
421
+ The public contract defines hook events with an entity, launch specification,
422
+ ancestry, operation ID, and deadline. A hook returns `proceed` with an optional replacement launch or
423
+ `cancel` with a bounded reason. Webhook events carry a delivery ID for
424
+ acknowledgement and replay-safe handling.
425
+
426
+ ## Testing harness
427
+
428
+ `@standardagents/code-plugin-sdk/testing` provides `createHarness`. A harness drives
429
+ activation, event delivery, visibility, manual time, flushing, and disposal.
430
+ It records traces, surface replacement, subscriptions, and resources without
431
+ starting subprocesses. `harness.surface(id, entity?)` returns the current
432
+ content of a published contribution, including view and canvas content, or
433
+ `undefined` after the contribution clears.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standardagents/code-plugin-sdk",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.4-hover.1",
4
4
  "type": "module",
5
5
  "description": "Standard Code plugin authoring SDK",
6
6
  "license": "MIT",
@@ -16,6 +16,6 @@
16
16
  "./testing": { "types": "./src/testing.d.ts", "import": "./src/testing.mjs" }
17
17
  },
18
18
  "bin": { "standard-plugin": "./bin/standard-plugin.mjs" },
19
- "files": ["src", "bin", "README.md", "LICENSE"],
19
+ "files": ["src", "bin", "README.md", "REFERENCE.md", "LICENSE"],
20
20
  "scripts": { "test": "node --test test/*.test.mjs" }
21
21
  }
package/src/index.d.ts CHANGED
@@ -90,8 +90,18 @@ export interface CanvasSpec {
90
90
  transparent?: boolean;
91
91
  shade?: number;
92
92
  captureInput?: boolean;
93
+ /** A bounded one-line string shown while the pointer rests over the canvas. */
94
+ hover?: string;
93
95
  }
94
96
  export type CanvasContent = { kind: 'canvas'; canvas: CanvasSpec };
97
+ /**
98
+ * What an `onInput` handler for a canvas receives. Keys arrive while the canvas
99
+ * declares `captureInput` and holds focus; focus loss arrives when the host
100
+ * stops showing it.
101
+ */
102
+ export type CanvasInputEvent =
103
+ | { kind: 'key'; key: string; phase: 'press' | 'repeat'; contributionId: string; entity?: EntityRef }
104
+ | { kind: 'focus'; focused: false; contributionId: string; entity?: EntityRef };
95
105
 
96
106
  /** Semantic tones. The host maps each tone to the viewer's theme. */
97
107
  export type Tone = 'ok' | 'info' | 'warn' | 'error' | 'muted' | 'accent' | 'pending' | 'bright';
@@ -207,6 +217,40 @@ export interface PluginEvent {
207
217
  data: Json;
208
218
  deliveryId?: string;
209
219
  }
220
+ /** The native release this machine runs. */
221
+ export type BuildInfo = {
222
+ version: string;
223
+ /** The 40-character lowercase commit SHA. */
224
+ commit: string;
225
+ /** The Git ref of the build's source, such as `refs/heads/main`, or null when it is not known. */
226
+ ref: string | null;
227
+ channel: 'branch' | 'canary' | 'production' | 'team' | null;
228
+ };
229
+ /** The account-wide build policy every machine in the fleet follows. */
230
+ export type FleetPolicy = {
231
+ mode: 'follow' | 'pin' | 'production' | null;
232
+ npmTag: string | null;
233
+ /** The Git ref of the followed channel, or null when it is not known. */
234
+ ref: string | null;
235
+ /** The pinned or target version, or null when it is not known. */
236
+ version: string | null;
237
+ };
238
+ /** The `context.get` result. `build` and `fleet` are null when unknown. */
239
+ export type PluginContextInfo = {
240
+ accountId: string;
241
+ machineId: string;
242
+ build?: BuildInfo | null;
243
+ fleet?: FleetPolicy | null;
244
+ };
245
+ /** The `data` of a `build-context` event. */
246
+ export type BuildContextEventData = {
247
+ build: BuildInfo | null;
248
+ fleet: FleetPolicy | null;
249
+ };
250
+ export interface BuildContextEvent extends PluginEvent {
251
+ name: 'build-context';
252
+ data: BuildContextEventData;
253
+ }
210
254
  export interface Popover {
211
255
  kind: 'chooser' | 'form' | 'confirm';
212
256
  title: string;
@@ -231,7 +275,7 @@ export interface OperationMap {
231
275
  'config.get': { input: Record<string, never>; output: Record<string, Json> };
232
276
  'state.get': { input: { key: string }; output: Json };
233
277
  'state.set': { input: { key: string; value: Json }; output: null };
234
- 'context.get': { input: Record<string, never>; output: Json };
278
+ 'context.get': { input: Record<string, never>; output: PluginContextInfo };
235
279
  'popover.open': { input: Popover; output: { choiceId?: string; values?: Record<string, Json>; confirmationId?: string } | null };
236
280
  'canvas.write': { input: { key: ContributionKey; ansi: string }; output: null };
237
281
  'canvas.focus': { input: { key: ContributionKey; capture: boolean }; output: null };
@@ -287,9 +331,11 @@ export interface PluginContext {
287
331
  key(id: string, handler: ActionHandler): KeyRegistration;
288
332
  link(id: string, options: LinkOptions, handler: LinkHandler): Subscription;
289
333
  link(id: string, handler: LinkHandler): Subscription;
334
+ onEvent(name: 'build-context', handler: (event: BuildContextEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
290
335
  onEvent(name: string, handler: (event: PluginEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
291
336
  onHook(name: string, handler: (event: HookEvent, context: HandlerContext) => HookResult | Promise<HookResult>): Subscription;
292
337
  onAction(name: string, handler: (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>): Subscription;
338
+ /** For a canvas, `name` is its contribution ID and events are `CanvasInputEvent` values. */
293
339
  onInput(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
294
340
  onSelect(name: string, handler: (event: Selection, context: HandlerContext) => void | Promise<void>): Subscription;
295
341
  onResize(name: string, handler: (event: { columns: number; rows: number }, context: HandlerContext) => void | Promise<void>): Subscription;
@@ -311,9 +357,9 @@ export interface PluginContext {
311
357
  fetch(args: OperationMap['fetch']['input'], options?: RequestOptions): Promise<OperationMap['fetch']['output']>;
312
358
  secrets: { get(name: string, options?: RequestOptions): Promise<string | null> };
313
359
  config: { get(options?: RequestOptions): Promise<Record<string, Json>> };
314
- /** Local state for one machine. It is never shared with other machines. */
360
+ /** 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. */
315
361
  state: { get(key: string, options?: RequestOptions): Promise<Json>; set(key: string, value: Json, options?: RequestOptions): Promise<null> };
316
- context: { get(options?: RequestOptions): Promise<Json> };
362
+ context: { get(options?: RequestOptions): Promise<PluginContextInfo> };
317
363
  popover: { open(args: Popover, options?: RequestOptions): Promise<OperationMap['popover.open']['output']> };
318
364
  health: { set(args: OperationMap['health.set']['input'], options?: RequestOptions): Promise<null> };
319
365
  webhook: { ack(deliveryId: string, options?: RequestOptions): Promise<null> };
@@ -327,7 +373,7 @@ export function validateManifest(value: unknown): Readonly<PluginManifest>;
327
373
  export class PluginError extends Error { code: string; constructor(code: string, message: string) }
328
374
  export const PRESENTATIONS: readonly Presentation[];
329
375
  export const TONES: readonly Tone[];
330
- export const VIEW_LIMITS: Readonly<{ depth: 8; nodes: 4096; tableRows: 512; tableColumns: 12; cardLines: 6; actionValueBytes: 512 }>;
376
+ export const VIEW_LIMITS: Readonly<{ depth: 8; nodes: 4096; tableRows: 512; tableColumns: 12; items: 512; logLines: 2048; cardLines: 6; actionValueBytes: 512 }>;
331
377
  /**
332
378
  * Throws a PluginError when a view tree breaks a protocol bound or a daemon rule. Unknown node types pass.
333
379
  * `kind: 'card'` adds the card rules; `manifest` requires each action `opens` to name one of its panels.
package/src/manifest.mjs CHANGED
@@ -12,7 +12,8 @@ export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
12
12
  responseFrameBytes: 64 * 1024 * 1024,
13
13
  pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
14
14
  queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
15
- canvasColumns: 512, canvasRows: 256 })
15
+ canvasColumns: 512, canvasRows: 256, canvasHoverBytes: 512,
16
+ stateKeys: 256, stateValueBytes: 64 * 1024 })
16
17
 
17
18
  export class PluginError extends Error {
18
19
  constructor(code, message) { super(message); this.name = 'PluginError'; this.code = code }
package/src/runtime.mjs CHANGED
@@ -10,6 +10,13 @@ function boundedText(value, label, limit = 512) {
10
10
  !/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
11
11
  return value
12
12
  }
13
+ const HOVER_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
14
+ function validateCanvasHover(value) {
15
+ ensure(typeof value === 'string' && value.trim().length > 0 && !HOVER_FORBIDDEN.test(value),
16
+ 'invalid_payload', 'Invalid canvas hover text')
17
+ ensure(Buffer.byteLength(value) <= LIMITS.canvasHoverBytes,
18
+ 'payload_too_large', `Canvas hover text exceeds ${LIMITS.canvasHoverBytes} bytes`)
19
+ }
13
20
  function validateEntity(entity) {
14
21
  ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
15
22
  boundedText(entity.id, 'entity id', 128)
@@ -57,10 +64,11 @@ function validateContent(content, declaration, manifest) {
57
64
  if (content.kind === 'view') validateView(content.root, { kind: declaration.kind, manifest })
58
65
  jsonBytes(content)
59
66
  if (content.kind === 'canvas') {
60
- const { columns, rows, shade = 0 } = content.canvas ?? {}
67
+ const { columns, rows, shade = 0, hover } = content.canvas ?? {}
61
68
  ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
62
69
  Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
63
70
  Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
71
+ if (hover !== undefined) validateCanvasHover(hover)
64
72
  }
65
73
  }
66
74
 
package/src/testing.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRuntime } from './runtime.mjs'
2
2
  import { RpcPeer } from './protocol.mjs'
3
- import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
3
+ import { PluginError, ensure, jsonBytes, LIMITS, validateManifest } from './manifest.mjs'
4
4
 
5
5
  /** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
6
6
  export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
@@ -9,7 +9,7 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
9
9
  const trace = []
10
10
  const surfaces = new Map()
11
11
  const subscriptions = new Map()
12
- const localState = new Map()
12
+ const accountState = new Map()
13
13
  const timers = new Map()
14
14
  let timerId = 0
15
15
  let disposed = false
@@ -36,12 +36,15 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
36
36
  switch (operation.op) {
37
37
  case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
38
38
  case 'subscription.remove': subscriptions.delete(args.id); return null
39
- case 'state.get': return structuredClone(localState.get(args.key) ?? null)
39
+ case 'state.get': return structuredClone(accountState.get(args.key) ?? null)
40
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
41
+ // The product stores these values in the account: 256 keys per
42
+ // plugin, 64 KiB per value.
43
+ ensure(accountState.has(args.key) || accountState.size < LIMITS.stateKeys, 'queue_full', 'Harness state holds 256 keys for this plugin')
44
+ jsonBytes(args.value, LIMITS.stateValueBytes)
45
+ accountState.set(args.key, structuredClone(args.value)); return null
43
46
  case 'config.get': return {}
44
- case 'context.get': return { machineId }
47
+ case 'context.get': return { accountId: 'test-account', machineId, build: null, fleet: null }
45
48
  case 'health.set': return null
46
49
  default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
47
50
  }
package/src/view.mjs CHANGED
@@ -2,8 +2,10 @@ import { LIMITS, ensure, identifier, jsonBytes, object } from './manifest.mjs'
2
2
 
3
3
  // Mirrors crates/standard-protocol/src/plugin_view.rs. The host applies the
4
4
  // same bounds; checking here reports a mistake at the replace() call.
5
+ // items and logLines bound the list-shaped nodes: the host lays every entry
6
+ // out again on each paint, so a long list costs a frame, not just memory.
5
7
  export const VIEW_LIMITS = Object.freeze({ depth: 8, nodes: 4096, tableRows: 512, tableColumns: 12,
6
- cardLines: 6, actionValueBytes: 512 })
8
+ items: 512, logLines: 2048, cardLines: 6, actionValueBytes: 512 })
7
9
  export const TONES = Object.freeze(['ok', 'info', 'warn', 'error', 'muted', 'accent', 'pending', 'bright'])
8
10
  const WEIGHTS = ['normal', 'bold', 'dim']
9
11
  const ALIGNS = ['start', 'center', 'end']
@@ -38,6 +40,15 @@ function entries(value, field, optional, check) {
38
40
  check(item)
39
41
  }
40
42
  }
43
+ function bounded(value, field, limit, optional = false) {
44
+ const values = list(value, field, optional)
45
+ ensure(values.length <= limit, 'invalid_payload', `Plugin view ${field} has more than ${limit} entries`)
46
+ return values
47
+ }
48
+ // A list-shaped node's entries, held to the bound the host applies.
49
+ function boundedEntries(value, field, check) {
50
+ entries(bounded(value, field, VIEW_LIMITS.items), field, false, check)
51
+ }
41
52
  function spans(value, optional) {
42
53
  entries(value, 'spans', optional, span => {
43
54
  text(span.text, 'span text')
@@ -77,7 +88,7 @@ const NODES = {
77
88
  badge(node) { text(node.label, 'label'); tone(node.tone) },
78
89
  dot(node) { tone(node.tone) },
79
90
  progress: meter,
80
- segments(node) { entries(node.items, 'items', false, meter) },
91
+ segments(node) { boundedEntries(node.items, 'items', meter) },
81
92
  card(node, depth, count) { optionalText(node.title, 'title'); tone(node.tone); children(node, depth, count) },
82
93
  stat(node) {
83
94
  text(node.label, 'label')
@@ -86,7 +97,7 @@ const NODES = {
86
97
  optionalText(node.hint, 'hint')
87
98
  },
88
99
  kv(node) {
89
- entries(node.items, 'items', false, item => {
100
+ boundedEntries(node.items, 'items', item => {
90
101
  text(item.label, 'label')
91
102
  text(item.value, 'value')
92
103
  flag(item.mono, 'mono')
@@ -97,7 +108,7 @@ const NODES = {
97
108
  text(node.id, 'id')
98
109
  optionalText(node.filters, 'filters')
99
110
  action(node.action)
100
- entries(node.items, 'items', false, item => {
111
+ boundedEntries(node.items, 'items', item => {
101
112
  text(item.id, 'id')
102
113
  text(item.label, 'label')
103
114
  optionalText(item.sublabel, 'sublabel')
@@ -111,7 +122,7 @@ const NODES = {
111
122
  text(node.label, 'label')
112
123
  optionalText(node.filters, 'filters')
113
124
  action(node.action)
114
- entries(node.options, 'options', false, option => {
125
+ boundedEntries(node.options, 'options', option => {
115
126
  text(option.id, 'id')
116
127
  text(option.label, 'label')
117
128
  optionalText(option.group, 'group')
@@ -137,7 +148,7 @@ const NODES = {
137
148
  text(row.id, 'row id')
138
149
  tone(row.tone)
139
150
  spans(row.note, true)
140
- for (const tag of list(row.tags, 'tags', true)) text(tag, 'tag')
151
+ for (const tag of bounded(row.tags, 'tags', VIEW_LIMITS.items, true)) text(tag, 'tag')
141
152
  action(row.action)
142
153
  ensure(row.cells === undefined || object(row.cells), 'invalid_payload', 'Plugin table cells must be an object')
143
154
  for (const [column, cell] of Object.entries(row.cells ?? {})) {
@@ -146,7 +157,7 @@ const NODES = {
146
157
  }
147
158
  })
148
159
  },
149
- log(node) { for (const line of list(node.lines, 'lines')) text(line, 'log line') },
160
+ log(node) { for (const line of bounded(node.lines, 'lines', VIEW_LIMITS.logLines)) text(line, 'log line') },
150
161
  button(node) { text(node.label, 'label'); action(node.action, false) },
151
162
  }
152
163