@rackbops/ac-plugin-contract 2.0.0-alpha.56 → 2.0.0-alpha.58

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/AUTHORING.md CHANGED
@@ -121,7 +121,8 @@ Rules the host enforces, at validation or load time:
121
121
  declares. Every other slot, `notify@1` included, is open to an installed plugin.
122
122
  - **`hostApiVersion`** must equal the host's `HOST_API_VERSION` (currently `1`); a mismatch is rejected.
123
123
  - **`kind: "in-process"`** must declare a `server` entry — the *loader* records an error and skips a plugin that
124
- doesn't (the schema itself requires only `id`, `hostApiVersion` and `kind`). `contributes` and every sub-field
124
+ doesn't, and the install gate (`ac plugins install`, the Admin Plugins card) refuses to stage one (the schema
125
+ itself requires only `id`, `hostApiVersion` and `kind`). `contributes` and every sub-field
125
126
  is optional in the schema — contribute only the points you use; each contribution's own required fields are
126
127
  schema-enforced.
127
128
  - **Jobs** (#137, #580): a job's **`timeoutSec`** must be at least `1` and at most `2147483` (the schema's
@@ -170,6 +171,13 @@ export const createPlugin: CreatePlugin = (host) => ({
170
171
  })
171
172
  ```
172
173
 
174
+ When loading fails, the plugin is errored and unwound (#785). A `createPlugin` that throws, or that returns
175
+ anything but an object with an `activate()` method, and an `activate()` that throws or has not settled within
176
+ 30 seconds (as does an import whose top-level `await` has not), record the plugin's error in `GET /api/plugins`.
177
+ Everything the plugin had already registered is then undone: its routes answer 404, its actions and event
178
+ subscriptions are dropped, and a slot it provided is released (except `auth@1`, which stays bound so the host
179
+ is never left open). Register last whatever must not exist when a later step fails.
180
+
173
181
  - **Route** — `host.routes.register(method, path, handler)`. The handler is a web-standard
174
182
  `(request: Request) => Response | Promise<Response>`, mounted at `/api/x/<id><path>`. You may only register a
175
183
  `(method, path)` you declared in `contributes.routes` — an undeclared route throws. **Never answer `502` or
@@ -185,6 +193,15 @@ export const createPlugin: CreatePlugin = (host) => ({
185
193
  can act on any status from 400 up — so the check on the deployed console is what proves it. Otherwise a 4xx or
186
194
  `500`. `packages/host/src/no-gateway-status.test.ts` scans every bundled plugin's `src/` and the host's, and
187
195
  fails naming the file and line (an installed third-party plugin is not covered).
196
+ The host sends your `Response` as you built it, with one exception (#831): an HTML document (a `Content-Type`
197
+ of `text/html` or `application/xhtml+xml`) that states no framing policy of its own gets the console pages'
198
+ default, `X-Frame-Options: SAMEORIGIN` and a `Content-Security-Policy: frame-ancestors 'self'` policy, so no
199
+ other site can frame it. If you already send a `Content-Security-Policy` without a `frame-ancestors` directive,
200
+ yours is kept exactly as it is and the framing policy is added beside it as a second policy (the browser
201
+ enforces each). A route that wants a different framing policy states it, with a `frame-ancestors` directive in
202
+ its `Content-Security-Policy` or with its own `X-Frame-Options`, and the host then leaves the response exactly
203
+ as it is, as it does `provider-artifacts`' `GET /render/*` and its stricter policy (#700). Any other content
204
+ type is never touched.
188
205
  - **Action** — `host.actions.register({ id, run })`. The button, `confirm`/`danger` and the modal `fields` all
189
206
  come from the manifest; here you supply only `run(input)`. (The action's manifest metadata drives the shell
190
207
  UI; registering a `run` for an id the manifest omits simply leaves it without a button.) Opening the modal is
@@ -224,6 +241,17 @@ export const createPlugin: CreatePlugin = (host) => ({
224
241
  success, keeping a separate `.../error` key for a failure and throwing the agent's reason verbatim
225
242
  so the run is honestly recorded failed -- store, log, throw, in that order, never masking the
226
243
  agent's text with a storage error of your own.
244
+ An agent job that calls a verb declares the capabilities those verbs need in the job's own
245
+ `capabilities` (#843; each must also be in the plugin's top-level `capabilities` and match the
246
+ capability grammar `^agent(\.[a-z][a-z0-9-]*)+$`, or the manifest is refused). The host then never dispatches the job to a paired machine that has not granted the plugin
247
+ every one of them: that machine is still probed, but it is skipped, with no run recorded, no task, no
248
+ `job.finished` event and no failure notified. Its row on the Admin Scheduled tasks card reads
249
+ `skipped: not granted: <capability>`; the Overview sched card leaves it out of the job's badge.
250
+ Absent or empty means every paired machine, as before the field existed, which is right for a job
251
+ that calls no verb (`hello.machines`). A host released before #843 refuses a manifest that declares
252
+ the field, with `manifest schema: /contributes/jobs/<n> must NOT have additional properties`, and that
253
+ refuses the whole plugin, not just the job: declare it only when your plugin requires a host that
254
+ carries it. See §7.
227
255
  - **Setting** — read/write with `host.settings.get(key)` / `host.settings.set(key, value)`. Both are async;
228
256
  `get` returns the manifest `default` when unset.
229
257
  - **Console settings** (#506) — `host.consoleSettings?.get(key)`, read-only, optional. Unlike `Setting`
@@ -853,11 +881,51 @@ others are the console's problem to describe differently: `unknown-machine` (a s
853
881
  reachable this way, whatever the path spelling), `pin` (the agent's certificate changed since
854
882
  pairing), `network`, `http` (an unexpected status the console doesn't special-case).
855
883
 
884
+ An agent job declares the capabilities its own verbs need, in the job's `capabilities` (#843). Each one
885
+ must also be in the top-level list and match the capability grammar (`agent.` and lowercase segments).
886
+ The host checks them against the machine's grants before it dispatches the job, so a machine that has not
887
+ granted them is skipped rather than refused: no failed run, no `job failed` notification, and the Admin
888
+ Scheduled tasks card reads `skipped: not granted: <capability>` for that machine (the Overview sched card
889
+ leaves the machine out of the job's badge). `plugins/scans` is the example: each of its four jobs calls
890
+ one verb, `POST /v1/scan/<kind>`, and declares exactly the capability that verb needs. A host released
891
+ before #843 refuses a manifest that declares the field, with
892
+ `manifest schema: /contributes/jobs/<n> must NOT have additional properties`, which refuses the whole
893
+ plugin.
894
+
895
+ ```json
896
+ "capabilities": ["agent.scan.context-pressure", "agent.scan.subagent-cost"],
897
+ "contributes": {
898
+ "jobs": [
899
+ {
900
+ "id": "scan.context-pressure",
901
+ "schedule": "40 3 * * *",
902
+ "timeoutSec": 990,
903
+ "runOn": "agent",
904
+ "capabilities": ["agent.scan.context-pressure"]
905
+ }
906
+ ]
907
+ }
908
+ ```
909
+
910
+ List every capability any of the job's verbs needs (a job that reads with `agent.git.read` and pulls with
911
+ `agent.git.pull` lists both). A job that lists too few is dispatched to a machine that then refuses the
912
+ verb, which is the failure this field exists to prevent. A `grant` refusal can still reach your `runJob`
913
+ when a grant is revoked while the run is in flight, so keep handling it.
914
+
856
915
  A verb that can genuinely run long (a scan, a build) needs a fifth, optional `call` argument:
857
916
  `host.agent.call(machineId, method, path, body, { timeoutMs })` sets how long the console waits for
858
917
  the agent's response *headers* (undici's `headersTimeout`; the default is 300s). Pass the verb's own
859
918
  limit plus a margin -- `scans` passes `agent limit + 30s` -- so the agent's own `504` reaches the
860
- console before the console gives up and reports a bare `network` timeout instead.
919
+ console before the console gives up and reports a bare `network` timeout instead. From a job, also
920
+ pass the run's signal, `{ signal: ctx.signal }` (#701): when the run is timed out the request is
921
+ cancelled, and `call` resolves `{ ok: false, error: { kind: "network", reason } }` instead of waiting
922
+ on the agent. The same goes for GitHub calls through `host.github.octokit()`: pass
923
+ `request: { signal: ctx.signal }` to each call, but not to `octokit.paginate`, which hands its pages
924
+ no `request` options; bind the signal to the route instead,
925
+ `octokit.paginate(route.defaults({ request: { signal: ctx.signal } }), params)` (#701). Unlike
926
+ `agent.call`, which resolves `{ ok: false }`, an aborted Octokit call rejects (the `AbortError`, with
927
+ `status: 500` set on it), and an abort that lands while the body is read resolves with an empty
928
+ `data`, so check `ctx.signal.aborted` after the call too.
861
929
 
862
930
  ### Streaming a job into the tasks drawer
863
931
 
@@ -868,20 +936,41 @@ job's stream is `GET /v1/toolchain/upgrade/:jobId/events`, under its own capabil
868
936
  reaches through its optional `path`: `{ label, path: "/v1/toolchain/upgrade/<jobId>/events" }`, a route
869
937
  of the shape `/v1/<segments>/<jobId>/events` naming that same `jobId`, anything else rejecting) through the same
870
938
  pinned client, registers a new host task for
871
- `label`, and relays every `line`/`done` frame into it as the job runs; it resolves `{ taskId }` once
872
- the agent has confirmed the stream is open (a `200`), or rejects (starting no task) on anything
873
- else -- an invalid `jobId`, an unknown machine, or a non-`200` from the agent (a `404`, a `403`
874
- refusal, ...), each surfaced the same way `call` surfaces them. The relay itself runs detached from
875
- that promise, so nothing in a plugin's own code touches the stream at all:
939
+ `label`, and relays every `line`/`done` frame into it as the job runs; it resolves
940
+ `{ taskId, finished }` once the agent has confirmed the stream is open (a `200`), or rejects
941
+ (starting no task) on anything else -- an invalid `jobId`, an unknown machine, or a non-`200` from
942
+ the agent (a `404`, a `403` refusal, ...), each surfaced the same way `call` surfaces them. The relay
943
+ itself runs detached from that promise, so nothing in a plugin's own code touches the stream at all.
944
+ It resolves usually while the job is still running (a job that fails fast can end before its stream
945
+ is opened, and the agent then replays its `done` at once), so it says the job started, not that it
946
+ succeeded. `finished` (#733) is the job's end. It is **optional**: the first host release that
947
+ contains #733 (after 2.0.0-alpha.56) supplies it; 2.0.0-alpha.56 and older do not, so a consumer must
948
+ handle `undefined`. It settles with the end the relay finished the task with, the one the tasks
949
+ drawer shows: `{ status: "done" }` only for the agent's `done` frame with `status: "done"`, and
950
+ `{ status: "failed", reason }` for every other end. `reason` is `job ended with status failed` (or
951
+ `job ended with an unknown status`) when the agent said the job failed, and
952
+ `stream ended before done: <message>`, the drawer's last line without its brackets, when the stream
953
+ closed or errored first or the `done` frame did not parse, so the job's own failure and a lost stream
954
+ read differently. It never rejects. It never settles when the relay sees no end at all: the host
955
+ stopping while the job runs (the relay stops with the process), or a hung job. It does not settle
956
+ while the connection is silently gone, and never if it does not come back (a reboot, a lost NAT
957
+ mapping): no FIN or RST reaches the host, and the stream has no body timeout, no keepalive and no
958
+ agent heartbeat.
876
959
 
877
960
  ```ts
878
961
  const { jobId } = await execResult // however the plugin's own verb call started the job
879
- const { taskId } = await host.agent.streamJob(machineId, jobId, { label: `code: ${prompt}` })
962
+ const { taskId, finished } = await host.agent.streamJob(machineId, jobId, { label: `code: ${prompt}` })
880
963
  // taskId names the task now streaming in the tasks drawer -- the action can simply return it.
964
+ // Don't await `finished` in the action: the modal would wait for the whole job. It is absent on an
965
+ // older host, and a throw in the handler must not become an unhandled rejection.
966
+ void finished
967
+ ?.then((end) => host.events.emit("my.run", { ...end, taskId }))
968
+ .catch((err) => host.log.warn(`my.run end not emitted: ${String(err)}`))
881
969
  ```
882
970
 
883
971
  `plugins/claude-runner` (#54) is the reference: `task.code` and `routine.rerun` call `exec/claude`
884
- then `streamJob` and return the task id, never touching SSE framing themselves. The `agents` plugin's
972
+ then `streamJob` and return the task id, never touching SSE framing themselves, and emit
973
+ `claude.run` `done` or `failed` from `finished` (#733). The `agents` plugin's
885
974
  `toolchain.upgrade` action (#675) is the example of a verb with its own events route: it starts the
886
975
  job with `host.agent.call`, remembers the job id per machine (the confirm modal's settled event carries no
887
976
  result, so a panel cannot read it from there) and relays the stream with `path`.
@@ -901,13 +990,21 @@ interface AttentionItem {
901
990
  source: string // your own plugin id -- the rollup shows it as the row's source
902
991
  text: string // always a counted sentence: "3 open actions in the ledger", never "some actions"
903
992
  badge?: string
904
- href?: string // an external link; wins over `target` when both are set
993
+ href?: string // an external link; wins over `target` when both are set. Linked only
994
+ // when its scheme is http:, https:, mailto: or tel: (#722); any other
995
+ // href is not linked and `target` is used instead
905
996
  target?: string // an in-app destination: "widget:<cardId>" or "panel:<pluginId>"
906
997
  hint?: string // the exact command to run, or the reason -- shown as a tooltip/expander
907
998
  action?: { kind: "issue"; repo?: string; title: string; body: string } // opens provider-github's issue.create, seeded
908
999
  }
909
1000
  ```
910
1001
 
1002
+ The rollup is lenient with a row it cannot use as typed (#722): it drops a row only when the row is not
1003
+ an object or its `id` or `text` is not a string. Otherwise it normalises: a missing or non-string
1004
+ `source` becomes your plugin id, a `severity` other than the three ranks and renders as `"info"`, a
1005
+ number `badge` or `hint` becomes its string, and an object or array one is left out (the row stays).
1006
+ A `target` that is not a string is ignored.
1007
+
911
1008
  **Own the fact, own the row.** The rollup itself derives nothing -- it only merges what contributors
912
1009
  already computed. If your plugin is the one that KNOWS a fact is wrong (a failed job, a stale scan, a
913
1010
  stuck push), your plugin is the one that turns it into a row, at the severity and with the hint that
@@ -1033,7 +1130,7 @@ what it's for.
1033
1130
  | Panel | `panels[]` (`id`, `title`, `route`, `export`, `nav?`, `order?`, `isolation?`) | a component export in `ui.js` |
1034
1131
  | Card | `cards[]` (`id`, `title`, `sizes`, `export`) | a component export in `ui.js` |
1035
1132
  | Action | `actions[]` (`id`, `scope`, `title?`, `confirm?`, `danger?`, `fields?`) | `host.actions.register({ id, run })` |
1036
- | Job | `jobs[]` (`id`, `schedule`, `timeoutSec`, `runOn`) | `runJob(ctx)` dispatching on `ctx.jobId` |
1133
+ | Job | `jobs[]` (`id`, `schedule`, `timeoutSec`, `runOn`, `minAgentVersion?`, `capabilities?`) | `runJob(ctx)` dispatching on `ctx.jobId` |
1037
1134
  | Route | `routes[]` (`method`, `path`) | `host.routes.register(method, path, handler)` |
1038
1135
  | Setting | `settings[]` (`key`, `type`, `default?`) | read via `host.settings.get/set` |
1039
1136
  | Attention | `attention: true` | `host.routes.register("GET", "/attention", handler)` -> `{ ok: true, items }` |
@@ -18,6 +18,12 @@ export interface AttentionAction {
18
18
  title: string;
19
19
  body: string;
20
20
  }
21
+ /** One needs-attention row. The rollup card (`plugins/overview`) is lenient with a contributor's
22
+ * JSON (#722): it drops a row only when the row is not an object or its `id` or `text` is not a
23
+ * string. Otherwise it normalises: a `source` that is missing or not a string becomes the id of the
24
+ * plugin the row was fetched from; a `severity` other than the three ranks and renders as `"info"`;
25
+ * a number `badge` or `hint` becomes its string, and an object or array one is left out (the row
26
+ * is kept). */
21
27
  export interface AttentionItem {
22
28
  /** Unique within the contributing plugin; the rollup's dedupe key is `${source}:${id}`. */
23
29
  id: string;
@@ -27,15 +33,22 @@ export interface AttentionItem {
27
33
  /** Always a counted sentence (1.x's `attCount`, e.g. "1 scheduled job run failed"). */
28
34
  text: string;
29
35
  badge?: string;
30
- /** An external link; wins over `target` when both are present (1.x's own precedence). */
36
+ /** An external link; wins over `target` when both are present (1.x's own precedence). Resolved
37
+ * against the console's page, it is linked only when its scheme is `http:`, `https:`, `mailto:`
38
+ * or `tel:` (#722): any other `href` (`javascript:`, `data:`, `blob:`, `vbscript:`, `file:`, a
39
+ * custom scheme, one that does not parse) renders no link, and the row uses `target` instead, as
40
+ * if `href` were absent. */
31
41
  href?: string;
32
42
  /** An in-app destination: `widget:<cardId>` flashes/scrolls to a sibling card on Overview,
33
- * `panel:<pluginId>` navigates to that plugin's panel. */
43
+ * `panel:<pluginId>` navigates to that plugin's panel (its first panel route, or `/<pluginId>`
44
+ * when the console knows none). A value that is not a string is ignored. */
34
45
  target?: string;
35
46
  /** The exact command to run, or the reason, shown as a tooltip/expander (1.x's `hint`). */
36
47
  hint?: string;
37
48
  action?: AttentionAction;
38
49
  }
50
+ /** `GET /api/x/<id>/attention`'s answer. Each of `items` is read as `AttentionItem` describes,
51
+ * including which malformed rows the rollup drops and which it normalises (#722). */
39
52
  export interface AttentionResponse {
40
53
  ok: true;
41
54
  items: AttentionItem[];
@@ -9,7 +9,9 @@
9
9
  import type { Octokit } from "@octokit/rest";
10
10
  import type { HttpMethod } from "./manifest.js";
11
11
  import type { AgentCallOutcome, JobMachineRun, MachineView } from "./views.js";
12
- /** A typed, per-plugin settings accessor (backed by the host's settings overlay). */
12
+ /** A typed, per-plugin settings accessor: this plugin's own rows in the host's `plugin_settings` table, a
13
+ * key with no stored value reading as its manifest `default` (`contributes.settings[]`). Not the
14
+ * host-global settings, which `consoleSettings` reads. */
13
15
  export interface SettingsService {
14
16
  get<T = unknown>(key: string): Promise<T | undefined>;
15
17
  set(key: string, value: unknown): Promise<void>;
@@ -19,7 +21,9 @@ export interface SettingsService {
19
21
  * distinct from `SettingsService` above, which is per-plugin. Only a key on the editable-settings
20
22
  * whitelist (`settings-schema.ts`) is readable here; any other key resolves to `undefined`, so a
21
23
  * plugin can never reach an arbitrary host-global key through this surface. The value returned is
22
- * the EFFECTIVE one (an `AC_<KEY>` env override included), the same value the Settings card shows.
24
+ * the EFFECTIVE one (an `AC_<KEY>` env override included), the same value the Settings card shows,
25
+ * except that a whitelisted key with no stored value and no env var resolves to `undefined` here
26
+ * while the card shows its declared default (an empty string), so treat the two alike.
23
27
  */
24
28
  export interface ConsoleSettingsService {
25
29
  get<T = unknown>(key: string): Promise<T | undefined>;
@@ -33,7 +37,8 @@ export interface StorageService {
33
37
  /** Register/trigger the plugin's contributed jobs and read their last result. */
34
38
  export interface JobsService {
35
39
  /** Run a contributed job now (the manual-trigger endpoint). For a `runOn: "agent"` job this resolves
36
- * once every currently paired machine has either run or been queued. */
40
+ * once every currently paired machine has been run, queued, or skipped (a missing grant, #843, or a
41
+ * `minAgentVersion` floor). */
37
42
  trigger(jobId: string): Promise<void>;
38
43
  /** The last run of a job, if it has run — for an agent job, the newest recorded run across every
39
44
  * machine. */
@@ -48,14 +53,14 @@ export interface JobsService {
48
53
  * scoped to the calling plugin's own jobs; this is the needs-attention rollup's in-process read of
49
54
  * every OTHER loaded plugin's job health, so `overview`'s `/attention` route never has to make an
50
55
  * HTTP call back into its own host. Discloses nothing `GET /api/jobs/:pluginId/:jobId/runs`
51
- * doesn't already serve publicly -- just a non-HTTP path to the same data. Optional: only
52
- * `overview` calls it today. */
56
+ * doesn't already serve publicly -- just a non-HTTP path to the same data. Optional: `overview`'s
57
+ * rollup and `ac-notify-discord`'s digest call it. */
53
58
  machineRunsOf?(pluginId: string, jobId: string): Promise<JobMachineRun[]>;
54
59
  }
55
- /** #78: every loaded in-process plugin's own declared jobs -- lets the needs-attention rollup
56
- * discover what to poll via `jobs.machineRunsOf` without a second manifest fetch or an HTTP
57
- * self-loop. Host-internal discovery only (unlike `PluginView`, never served over HTTP as its own
58
- * route); optional on `HostApi` since only `overview` uses it. */
60
+ /** #78: every running in-process plugin's own declared jobs -- lets the needs-attention rollup (and
61
+ * `ac-notify-discord`'s digest) discover what to poll via `jobs.machineRunsOf` without a second
62
+ * manifest fetch or an HTTP self-loop. Host-internal discovery only (unlike `PluginView`, never served
63
+ * over HTTP as its own route); optional on `HostApi`, since only those two use it. */
59
64
  export interface PluginJobsService {
60
65
  list(): {
61
66
  pluginId: string;
@@ -70,19 +75,31 @@ export interface JobDeclaration {
70
75
  }
71
76
  /** The typed event bus (e.g. `issue.changed`, `job.finished`, `artifact.rendered`). */
72
77
  export interface EventsService {
78
+ /** Calls every subscriber of `type` synchronously, in subscription order, and never awaits one.
79
+ * A subscriber that throws, or returns a promise that rejects, is logged by the host and stops
80
+ * neither the others nor the emitter (#728). */
73
81
  emit(type: string, payload: unknown): void;
74
- /** Subscribe; returns an unsubscribe function. */
75
- on(type: string, handler: (payload: unknown) => void): () => void;
82
+ /** Subscribe; returns an unsubscribe function. The handler may be `async` (#728): when it
83
+ * returns a promise (or any thenable) the host attaches a rejection handler, so a rejection is
84
+ * logged, never unhandled; any other return value is ignored. The return type is `unknown`,
85
+ * not `void | Promise<void>`, because only `unknown` still admits every handler the earlier
86
+ * `=> void` did (`(p) => list.push(p)` returns a number). */
87
+ on(type: string, handler: (payload: unknown) => unknown): () => void;
76
88
  }
77
89
  /** One shared, rate-limit-aware GitHub client (Octokit; PAT from env, #127). Consumers make every
78
90
  * GitHub call through `octokit()` rather than constructing a second client. */
79
91
  export interface GithubService {
80
92
  /** The authenticated login, cached. */
81
93
  viewer(): Promise<string>;
82
- /** The one shared Octokit REST client — consumers make all GitHub calls through this. */
94
+ /** The one shared Octokit REST client — consumers make all GitHub calls through this. It
95
+ * authenticates with the console's PAT (`AC_GITHUB_TOKEN`), and `octokit().auth()` returns that
96
+ * token to whichever plugin asks: no `env[]` declaration is needed, and nothing hides it (#784;
97
+ * in-process plugins are trusted code, not sandboxed). */
83
98
  octokit(): Octokit;
84
- /** Remaining primary-rate-limit budget from the last response's headers, or undefined before any
85
- * call. */
99
+ /** Remaining primary-rate-limit budget from the last response whose three `x-ratelimit-*` headers
100
+ * are all finite numbers (and the reset a valid time), a failed response's included (#784: a 403 or
101
+ * 429 carrying `x-ratelimit-remaining: 0` reads as 0 here, not as the last successful count), or
102
+ * undefined before any such response. A response with a header missing or malformed changes nothing. */
86
103
  rateLimit(): {
87
104
  remaining: number;
88
105
  limit: number;
@@ -107,27 +124,55 @@ export interface AgentService {
107
124
  * 300 s). A long-running verb -- a scan -- passes the verb's own limit plus a margin, so the
108
125
  * agent's own timeout answer arrives before the console gives up. */
109
126
  timeoutMs?: number;
127
+ /** Abandons the call when it aborts (#701): a job passes its run's `ctx.signal`, so a run the
128
+ * host has timed out stops waiting on the agent. The request is cancelled on the wire and the
129
+ * call resolves `{ ok: false, error: { kind: "network", reason } }`, `reason` being the
130
+ * signal's `reason.message` when that reason is an `Error`, else `"aborted"`. */
131
+ signal?: AbortSignal;
110
132
  }): Promise<AgentCallOutcome<T>>;
111
133
  /**
112
134
  * Open a paired machine's job event stream (`GET /v1/jobs/:jobId/events`, #54) and relay it into a
113
135
  * new host task: every agent `line` frame becomes a `task.log`, the `done` frame finishes it --
114
136
  * the tasks drawer streams the machine's job with no new UI (decision 12/20). Resolves with the new
115
- * task's id once the stream is confirmed open (the agent answered `200`); the relay itself then runs
116
- * detached and can never surface here. Rejects (starting no task) on an invalid `jobId`, an unknown
117
- * machine, or any non-`200` response from the agent (a `404` unknown job, a `403` refusal, ...) --
118
- * the same failure shapes `call` throws for a bad path, surfaced the same way.
137
+ * task's id once the stream is confirmed open (the agent answered `200`), usually while the job is
138
+ * still running (a job that fails fast can end before its stream is opened, and the agent then
139
+ * replays its `done` at once); the relay itself then runs detached, and its failures never reject
140
+ * here (see `finished`). Rejects (starting no task) on an invalid `jobId`, an unknown machine, or any
141
+ * non-`200` response from the agent (a `404` unknown job, a `403` refusal, ...) -- the same failure
142
+ * shapes `call` throws for a bad path, surfaced the same way.
119
143
  *
120
144
  * `path` (#675) is the events route of a job that does not live under `/v1/jobs`: the toolchain
121
145
  * upgrade's is `/v1/toolchain/upgrade/<jobId>/events`, readable only by a plugin holding that verb's own
122
146
  * capability. Omitted, it is `/v1/jobs/<jobId>/events` exactly as before. It is not a free path: it must be
123
147
  * `/v1/<one or more lowercase segments>/<jobId>/events` with this call's own `jobId`, and carry no query,
124
148
  * fragment, escape or dot segment; anything else rejects (starting no task, making no request).
149
+ *
150
+ * `finished` (#733) says how the job ended. The first host release that contains #733 (after
151
+ * 2.0.0-alpha.56) supplies it; 2.0.0-alpha.56 and older do not, so a consumer must handle
152
+ * `undefined` (it is optional for that reason, and so an out-of-repo plugin's own test fake still
153
+ * compiles). It settles with the status the relay finished the task with, so it always agrees with
154
+ * the tasks drawer: `{ status: "done" }` for the agent's `done` frame carrying `status: "done"`, and
155
+ * `{ status: "failed", reason }` for any other end. `reason` tells the job's own failure apart from
156
+ * the console losing sight of it: `job ended with status failed` (or `job ended with an unknown
157
+ * status`) when the agent's `done` frame says so, and `stream ended before done: <message>`, the
158
+ * tasks drawer's last line without its brackets, when the stream closed or errored before its
159
+ * `done` frame, or that frame did not parse. It never rejects. It never settles when the relay sees
160
+ * no end at all: when the host stops while the job runs (the relay stops with the process), or when
161
+ * the job hangs. It does not settle while the connection is silently gone, and never if it does not
162
+ * come back (a reboot, a lost NAT mapping): with the machine asleep or the network gone, no FIN or
163
+ * RST reaches the host, and the stream has no body timeout, no keepalive and no agent heartbeat.
125
164
  */
126
165
  streamJob(machineId: string, jobId: string, opts: {
127
166
  label: string;
128
167
  path?: string;
129
168
  }): Promise<{
130
169
  taskId: string;
170
+ finished?: Promise<{
171
+ status: "done";
172
+ } | {
173
+ status: "failed";
174
+ reason: string;
175
+ }>;
131
176
  }>;
132
177
  }
133
178
  /** Proxy to a sidecar plugin's HTTP surface (the host handles health + base URL). */
@@ -135,7 +180,8 @@ export interface SidecarService {
135
180
  /** The base URL the host proxies to for a sidecar plugin. */
136
181
  baseUrl(pluginId: string): string;
137
182
  }
138
- /** Register a confirm-gated action; the shell builds its button + modal from the manifest data. */
183
+ /** Register an action's `run`; the shell builds its button + modal from the manifest data, and opening
184
+ * the modal is the action's one confirmation (#530). */
139
185
  export interface ActionsService {
140
186
  register(action: {
141
187
  id: string;
@@ -193,13 +239,21 @@ export interface SlotRegistry {
193
239
  * there for good (#97). */
194
240
  resolve<T = unknown>(slot: string, version: number): T | null;
195
241
  }
196
- /** A logger prefixed with the plugin id. */
242
+ /** A logger prefixed with the plugin id. A plain-object first arg is the line's structured fields; an
243
+ * `Error` first arg is logged as its own enumerable properties (`code`, `status`, ...) plus
244
+ * `{ error: <message>, stack }`; every other arg is kept, in order, under `args` (an `Error` there, or as a
245
+ * field's value, as its own properties plus `message` and `stack`); a field named `time`, `level`, `msg`
246
+ * or `plugin` is renamed `field_<key>`, so it cannot relabel the line. A call never throws: a value JSON
247
+ * cannot write (a `BigInt`, a circular structure, a throwing getter) is logged as a string (#786). */
197
248
  export interface LogService {
198
249
  info(message: string, ...args: unknown[]): void;
199
250
  warn(message: string, ...args: unknown[]): void;
200
251
  error(message: string, ...args: unknown[]): void;
201
252
  }
202
- /** Read declared env keys only (the manifest's `env[]`). */
253
+ /** Read declared env keys only (the manifest's `env[]`). This scopes what a plugin reads; it is not a
254
+ * secrecy boundary (#784): the plugin writes its own `env[]`, nothing restricts which keys it declares
255
+ * (another plugin's, or a host-owned one like `AC_GITHUB_TOKEN`), and in-process plugin code can read
256
+ * `process.env` directly. */
203
257
  export interface EnvService {
204
258
  get(key: string): string | undefined;
205
259
  }
@@ -226,7 +280,8 @@ export interface HostApi {
226
280
  actions: ActionsService;
227
281
  log: LogService;
228
282
  env: EnvService;
229
- /** #78: see `PluginJobsService`. Optional -- only `overview`'s needs-attention rollup uses it. */
283
+ /** #78: see `PluginJobsService`. Optional -- `overview`'s rollup and `ac-notify-discord`'s digest use
284
+ * it. */
230
285
  plugins?: PluginJobsService;
231
286
  /** #84: see `SlotRegistry`. */
232
287
  slots: SlotRegistry;
@@ -254,6 +309,11 @@ export interface JobRunContext {
254
309
  }
255
310
  /** A plugin instance. `createPlugin(host)` is pure; I/O only in `activate()` (design §4 lifecycle). */
256
311
  export interface Plugin {
312
+ /** Register routes, actions, subscriptions and slot providers. A plugin whose `activate()` throws, or
313
+ * has not settled within the host's load deadline (30 s), is errored and everything it registered is
314
+ * undone: its routes answer 404, its actions and subscriptions are dropped, a slot it bound is
315
+ * released (except `auth`), #785. The same holds for a `createPlugin` that throws or returns no
316
+ * object with an `activate` method. */
257
317
  activate(): void | Promise<void>;
258
318
  /**
259
319
  * Run one of the plugin's contributed jobs, dispatched by `ctx.jobId` (#31's scheduler). Optional —
@@ -323,10 +383,12 @@ export interface BrowserHostApi {
323
383
  * rather than followed -- `credentials: "same-origin"` alone would not stop the request itself, or a
324
384
  * write's `X-AC-CSRF` token, from reaching a third-party origin. Credentials are included
325
385
  * (`same-origin`); a non-GET/HEAD/OPTIONS request carries the `X-AC-CSRF` token fetched once from
326
- * `GET /api/config` (#85) and cached -- defense-in-depth alongside the Origin check #85 already
327
- * enforces, not itself required yet. Panels read a provider's routes (e.g.
328
- * `/api/x/provider-github/…`) through this rather than a bare `fetch`. Returns the raw `Response`;
329
- * the caller parses it.
386
+ * `GET /api/config` (#85) and cached. The host requires that token only where its `requireCsrf`
387
+ * middleware is mounted (`POST /api/attachments`, `POST`/`DELETE /api/plugins/indexes`, the
388
+ * `/api/plugins/install/*` writes); every other write, a plugin's own `/api/x/<id>/…` routes
389
+ * included, has the Origin check #85 enforces on every mutating request as its CSRF defence.
390
+ * Panels read a provider's routes (e.g. `/api/x/provider-github/…`) through this rather than a
391
+ * bare `fetch`. Returns the raw `Response`; the caller parses it.
330
392
  */
331
393
  fetch(path: string, init?: RequestInit): Promise<Response>;
332
394
  /**
@@ -91,13 +91,16 @@ export interface ActionField {
91
91
  */
92
92
  optionsFrom?: ActionOptionsSource;
93
93
  }
94
- /** A confirm-gated action; the shell builds its button + modal from these fields (#34). */
94
+ /** An action; the shell builds its button + modal from these fields (#34), and opening the modal is the
95
+ * action's one confirmation (#530). */
95
96
  export interface ActionContribution {
96
97
  id: string;
97
98
  /** Button/label text; the shell falls back to `id` when omitted. */
98
99
  title?: string;
99
100
  /** What the action operates on, e.g. "issue" | "report" | "global". */
100
101
  scope: string;
102
+ /** Accepted, but inert since #530: the modal is the one confirmation, so neither it nor the palette
103
+ * asks for a second click. */
101
104
  confirm?: boolean;
102
105
  danger?: boolean;
103
106
  /** The form the shell builds the action's modal from; omit for a no-input action. */
@@ -108,7 +111,12 @@ export type JobRunOn = "host" | "agent";
108
111
  /** A scheduled job. */
109
112
  export interface JobContribution {
110
113
  id: string;
111
- /** 5-field cron expression. */
114
+ /**
115
+ * 5-field cron expression (the host's `jobs/cron.ts` grammar). Not validated when the plugin loads
116
+ * (#784): a schedule that does not parse is logged at warn when the scheduler starts and the job is
117
+ * never scheduled, while the plugin itself loads as usual. Every bundled plugin's schedules are
118
+ * parsed in CI.
119
+ */
112
120
  schedule: string;
113
121
  /**
114
122
  * Per-run timeout in seconds. Must be at least 1: a zero or negative value would fire the timeout at
@@ -125,6 +133,14 @@ export interface JobContribution {
125
133
  * `runOn: "host"` job. Additive; absent means every paired machine is always dispatched, as
126
134
  * before this field existed. */
127
135
  minAgentVersion?: string;
136
+ /** The host-agent capabilities this job's verbs need on a machine (#843). A `runOn: "agent"` job is
137
+ * not dispatched to a paired machine that has not granted this plugin every one of them. That
138
+ * machine's row records `skipped` with reason `not granted: <capability>[, ...]` instead: no run is
139
+ * recorded, no `job.finished` is emitted and no failure is notified. Each must be declared in the
140
+ * plugin's top-level `capabilities` (the host refuses the manifest otherwise). Ignored for a
141
+ * `runOn: "host"` job. Additive: absent or empty dispatches to every paired machine, as before this
142
+ * field existed. A host released before this field refuses a manifest that declares it. */
143
+ capabilities?: string[];
128
144
  }
129
145
  /** HTTP method for a contributed route. */
130
146
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -168,18 +184,27 @@ export interface Contributes {
168
184
  /** #78: the plugin serves `GET /api/x/<id>/attention` -> `{ ok: true, items: AttentionItem[] }`
169
185
  * (`attention.ts`). The `overview` plugin's rollup card discovers contributors via
170
186
  * `PluginView.attention` (`views.ts`) rather than a second manifest read. Additive; always `true`
171
- * when present (a boolean, not a route list, since the path is fixed). */
187
+ * when present (a boolean, not a route list, since the path is fixed). The plugin must also declare
188
+ * that route in `routes` and register it; the host checks neither at load (#784; every bundled
189
+ * plugin's is checked in CI), and the rollup counts a contributor whose route fails as not loaded. */
172
190
  attention?: true;
173
191
  }
174
- /** A declared environment variable (field names shared verbatim with the discord-bot index). */
192
+ /**
193
+ * A declared environment variable (field names shared verbatim with the discord-bot index). Declaring
194
+ * a key is what lets `HostApi.env.get` read it. `format` and `required` are metadata only: the host
195
+ * checks a value against neither (#784), so a plugin that depends on one checks its own value.
196
+ */
175
197
  export interface EnvVar {
176
198
  key: string;
177
- /** Anchored regex the value must match, e.g. "^[0-9]+$". */
199
+ /** Anchored regex the value is expected to match, e.g. "^[0-9]+$". Not enforced by the host; every
200
+ * bundled plugin's compiles and is anchored (checked in CI). */
178
201
  format?: string;
202
+ /** Whether the plugin needs a value. Not enforced by the host: a missing value reads `undefined`. */
179
203
  required?: boolean;
180
204
  secret?: boolean;
181
205
  }
182
- /** A sidecar plugin's image and health path. Present when `kind === "sidecar"`. */
206
+ /** A sidecar plugin's image and health path, expected when `kind === "sidecar"`. The host reads only
207
+ * `healthPath` (its health poll) and never `image` (the compose file names the sidecar's image). */
183
208
  export interface SidecarSpec {
184
209
  image: string;
185
210
  healthPath: string;
@@ -199,9 +224,11 @@ export interface AcPluginManifest {
199
224
  server?: string;
200
225
  /** Frontend ESM entry; omitted by a plugin that contributes no UI. */
201
226
  frontend?: string;
202
- /** Image + health path; required when `kind: "sidecar"`. */
227
+ /** Image + health path, expected when `kind: "sidecar"`. Not enforced at load (#784): without it the
228
+ * health poll uses `/healthz`. Every bundled sidecar plugin's is checked in CI. */
203
229
  sidecar?: SidecarSpec;
204
- /** Host-agent capabilities the plugin may use, each granted per machine in Admin (E4). */
230
+ /** Host-agent capabilities the plugin may use, each granted per machine in Admin (E4). A job's own
231
+ * `capabilities` (#843) is a subset of this list. */
205
232
  capabilities?: string[];
206
233
  contributes?: Contributes;
207
234
  env?: EnvVar[];
package/dist/slots.d.ts CHANGED
@@ -84,7 +84,16 @@ export interface LabelStandard {
84
84
  type: Label[];
85
85
  exclusive: string[];
86
86
  }
87
- /** `issues@1`: list/search/labels/people/mutate over forge-agnostic records. */
87
+ /**
88
+ * `issues@1`: list/search/labels/people/mutate over forge-agnostic records.
89
+ *
90
+ * Declared, not bound (#784): `provider-github` declares `provides: issues@1`, and that declaration is
91
+ * what lets its consumers (`issues-board`, `issues-classic`) activate, but it binds no implementation of
92
+ * this interface, so `host.slots.resolve("issues", 1)` answers `null`. The consumers read
93
+ * `provider-github`'s HTTP routes (`/api/x/provider-github/…`, through `host.fetch`) instead, which
94
+ * answer the record shapes above. A plugin that wants this interface in-process gets `null` until a
95
+ * provider binds one.
96
+ */
88
97
  export interface IssuesProvider {
89
98
  /** List issues for a repo in a given state. */
90
99
  listIssues(repo: RepoRef, state: "open" | "closed" | "all"): Promise<IssueRecord[]>;
@@ -180,7 +189,9 @@ export interface Identity {
180
189
  * model roles (Cloudflare Access does not); an empty array is an identity that holds none (#102). */
181
190
  roles?: string[];
182
191
  }
183
- /** `auth@1`: resolve the identity for a request, or null in open mode. */
192
+ /** `auth@1`: resolve the identity for a request. A bound provider answering `null` makes the host refuse
193
+ * the request with a 401 (on every route but `/healthz` and `/api/config`); open mode is the host with
194
+ * NO provider bound, not a provider answering `null`. */
184
195
  export interface AuthProvider {
185
196
  /** Resolve the identity from a request's headers, or null when unauthenticated. */
186
197
  identify(headers: Record<string, string | undefined>): Promise<Identity | null>;
@@ -195,6 +206,12 @@ export interface NotifyMessage {
195
206
  }
196
207
  /** `notify@1`: fan out a message to this provider's channel. */
197
208
  export interface NotifyProvider {
198
- send(message: NotifyMessage): Promise<void>;
209
+ /** `opts.signal` (#701) is the caller's abort: a job that posts through its own provider passes its
210
+ * run's `ctx.signal`, and a provider should then abandon the send and reject. The host's failure
211
+ * fan-out passes none. Optional, so a provider written against `send(message)` alone still
212
+ * satisfies this interface. */
213
+ send(message: NotifyMessage, opts?: {
214
+ signal?: AbortSignal;
215
+ }): Promise<void>;
199
216
  }
200
217
  //# sourceMappingURL=slots.d.ts.map
package/dist/views.d.ts CHANGED
@@ -40,9 +40,11 @@ export interface PluginCardView {
40
40
  export: string;
41
41
  }
42
42
  /**
43
- * A confirm-gated action the shell builds a button + form from (#34). Unlike panels/cards, an action is
43
+ * An action the shell builds a button + form from (#34); opening its modal is its one confirmation (#530).
44
+ * Unlike panels/cards, an action is
44
45
  * pure data (no `export`), so it is surfaced for any loadable plugin regardless of whether it ships a
45
- * frontend bundle. `confirm`/`danger` are normalised to booleans and `fields` to an array for the shell.
46
+ * frontend bundle. `confirm`/`danger` are normalised to booleans and `fields` to an array for the shell (`confirm` is inert
47
+ * since #530).
46
48
  */
47
49
  export interface PluginActionView {
48
50
  id: string;
@@ -59,9 +61,10 @@ export interface PluginActionView {
59
61
  * :pluginId/:jobId/runs`'s response already exposes it per call, and this card's row model
60
62
  * (`SchedRow.runOn`) plumbs it through end to end even though its current render doesn't surface it
61
63
  * (found in review, round 2 — a candidate for a future small UI addition, not a bug).
62
- * `timeoutSec`/`minAgentVersion` are deliberately left off instead, independent of that: the runs
63
- * route's `skipped` reason already reflects a `minAgentVersion` gate as a per-machine fact, so
64
- * duplicating the threshold itself here would have no reader.
64
+ * `timeoutSec`/`minAgentVersion`/`capabilities` are deliberately left off instead, independent of
65
+ * that: the runs route's `skipped` reason already reflects a `minAgentVersion` gate or a missing grant
66
+ * (#843) as a per-machine fact, so duplicating the threshold or the capability list here would have no
67
+ * reader.
65
68
  */
66
69
  export interface PluginJobView {
67
70
  id: string;
@@ -72,8 +75,8 @@ export interface PluginJobView {
72
75
  /**
73
76
  * Where a plugin was discovered from (#183): `bundled` under the image's own `plugins/` dir, `installed`
74
77
  * under an operator-installed-plugins dir. Derived from the scan root a plugin was found under, never
75
- * from the manifest — a plugin cannot declare itself first-party. Until the install path exists (E2's
76
- * #35 follow-up) every plugin is `bundled`.
78
+ * from the manifest — a plugin cannot declare itself first-party. `installed` is the operator-installed
79
+ * root (`AC_INSTALLED_PLUGINS_DIR`, #451), filled by the install path (#452).
77
80
  */
78
81
  export type PluginOrigin = "bundled" | "installed";
79
82
  export interface PluginView {
@@ -173,10 +176,17 @@ export interface JobMachineRun {
173
176
  queuedAt: string;
174
177
  reason: string;
175
178
  };
176
- /** This machine's paired agent version compares lower than the job's declared `minAgentVersion`
177
- * (#372, #80) -- computed live from the machine's CURRENT version every call, never persisted, so
178
- * it always reflects "would a dispatch right now skip this machine", not a stale historical fact.
179
- * Independent of `lastRun`/`queued`, which can still be set from before the machine fell behind. */
179
+ /** A dispatch right now would skip this machine, for one of two causes: it has not granted the
180
+ * plugin every capability the job declares (#843; reason `not granted: <capability>[, ...]`, the
181
+ * missing ones in declared order), or its paired agent version compares lower than the job's
182
+ * declared `minAgentVersion` (#372, #80; reason `agent <version> < <min>`). A missing grant wins
183
+ * when both hold. Computed live every call from the machine's CURRENT grants and version, never
184
+ * persisted, so it always reflects "would a dispatch right now skip this machine", not a stale
185
+ * historical fact. Independent of `lastRun`/`queued`. Either can be set from before the skip
186
+ * applied, and a `queued` row with a version cause can also be written WHILE the skip applies: an
187
+ * unreachable machine is queued before any floor is judged, since the floor is judged only on a
188
+ * version a probe just reported. A grant skip is judged before the unreachable branch, so it never
189
+ * writes one and clears any it finds. */
180
190
  skipped?: {
181
191
  reason: string;
182
192
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ac-plugin-contract",
3
- "version": "2.0.0-alpha.56",
3
+ "version": "2.0.0-alpha.58",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The artifact-console plugin ABI for out-of-repo plugin authors: HostApi, the acPlugin manifest contribution points, the generated manifest JSON Schema, and the @ac/host ambient browser declaration.",
package/schema.json CHANGED
@@ -26,14 +26,14 @@
26
26
  },
27
27
  "sidecar": {
28
28
  "$ref": "#/definitions/SidecarSpec",
29
- "description": "Image + health path; required when `kind: \"sidecar\"`."
29
+ "description": "Image + health path, expected when `kind: \"sidecar\"`. Not enforced at load (#784): without it the health poll uses `/healthz`. Every bundled sidecar plugin's is checked in CI."
30
30
  },
31
31
  "capabilities": {
32
32
  "type": "array",
33
33
  "items": {
34
34
  "type": "string"
35
35
  },
36
- "description": "Host-agent capabilities the plugin may use, each granted per machine in Admin (E4)."
36
+ "description": "Host-agent capabilities the plugin may use, each granted per machine in Admin (E4). A job's own `capabilities` (#843) is a subset of this list."
37
37
  },
38
38
  "contributes": {
39
39
  "$ref": "#/definitions/Contributes"
@@ -76,7 +76,7 @@
76
76
  "healthPath"
77
77
  ],
78
78
  "additionalProperties": false,
79
- "description": "A sidecar plugin's image and health path. Present when `kind === \"sidecar\"`."
79
+ "description": "A sidecar plugin's image and health path, expected when `kind === \"sidecar\"`. The host reads only `healthPath` (its health poll) and never `image` (the compose file names the sidecar's image)."
80
80
  },
81
81
  "Contributes": {
82
82
  "type": "object",
@@ -138,7 +138,7 @@
138
138
  "attention": {
139
139
  "type": "boolean",
140
140
  "const": true,
141
- "description": "#78: the plugin serves `GET /api/x/<id>/attention` -> `{ ok: true, items: AttentionItem[] }` (`attention.ts`). The `overview` plugin's rollup card discovers contributors via `PluginView.attention` (`views.ts`) rather than a second manifest read. Additive; always `true` when present (a boolean, not a route list, since the path is fixed)."
141
+ "description": "#78: the plugin serves `GET /api/x/<id>/attention` -> `{ ok: true, items: AttentionItem[] }` (`attention.ts`). The `overview` plugin's rollup card discovers contributors via `PluginView.attention` (`views.ts`) rather than a second manifest read. Additive; always `true` when present (a boolean, not a route list, since the path is fixed). The plugin must also declare that route in `routes` and register it; the host checks neither at load (#784; every bundled plugin's is checked in CI), and the rollup counts a contributor whose route fails as not loaded."
142
142
  }
143
143
  },
144
144
  "additionalProperties": false,
@@ -229,7 +229,8 @@
229
229
  "description": "What the action operates on, e.g. \"issue\" | \"report\" | \"global\"."
230
230
  },
231
231
  "confirm": {
232
- "type": "boolean"
232
+ "type": "boolean",
233
+ "description": "Accepted, but inert since #530: the modal is the one confirmation, so neither it nor the palette asks for a second click."
233
234
  },
234
235
  "danger": {
235
236
  "type": "boolean"
@@ -247,7 +248,7 @@
247
248
  "scope"
248
249
  ],
249
250
  "additionalProperties": false,
250
- "description": "A confirm-gated action; the shell builds its button + modal from these fields (#34)."
251
+ "description": "An action; the shell builds its button + modal from these fields (#34), and opening the modal is the action's one confirmation (#530)."
251
252
  },
252
253
  "ActionField": {
253
254
  "type": "object",
@@ -344,7 +345,7 @@
344
345
  },
345
346
  "schedule": {
346
347
  "type": "string",
347
- "description": "5-field cron expression."
348
+ "description": "5-field cron expression (the host's `jobs/cron.ts` grammar). Not validated when the plugin loads (#784): a schedule that does not parse is logged at warn when the scheduler starts and the job is never scheduled, while the plugin itself loads as usual. Every bundled plugin's schedules are parsed in CI."
348
349
  },
349
350
  "timeoutSec": {
350
351
  "type": "number",
@@ -358,6 +359,13 @@
358
359
  "minAgentVersion": {
359
360
  "type": "string",
360
361
  "description": "A `runOn: \"agent\"` job is not dispatched to a machine whose paired agent version compares lower than this (#372, #80); the run row records the skip with its reason instead. Ignored for a `runOn: \"host\"` job. Additive; absent means every paired machine is always dispatched, as before this field existed."
362
+ },
363
+ "capabilities": {
364
+ "type": "array",
365
+ "items": {
366
+ "type": "string"
367
+ },
368
+ "description": "The host-agent capabilities this job's verbs need on a machine (#843). A `runOn: \"agent\"` job is not dispatched to a paired machine that has not granted this plugin every one of them. That machine's row records `skipped` with reason `not granted: <capability>[, ...]` instead: no run is recorded, no `job.finished` is emitted and no failure is notified. Each must be declared in the plugin's top-level `capabilities` (the host refuses the manifest otherwise). Ignored for a `runOn: \"host\"` job. Additive: absent or empty dispatches to every paired machine, as before this field existed. A host released before this field refuses a manifest that declares it."
361
369
  }
362
370
  },
363
371
  "required": [
@@ -483,10 +491,11 @@
483
491
  },
484
492
  "format": {
485
493
  "type": "string",
486
- "description": "Anchored regex the value must match, e.g. \"^[0-9]+$\"."
494
+ "description": "Anchored regex the value is expected to match, e.g. \"^[0-9]+$\". Not enforced by the host; every bundled plugin's compiles and is anchored (checked in CI)."
487
495
  },
488
496
  "required": {
489
- "type": "boolean"
497
+ "type": "boolean",
498
+ "description": "Whether the plugin needs a value. Not enforced by the host: a missing value reads `undefined`."
490
499
  },
491
500
  "secret": {
492
501
  "type": "boolean"
@@ -496,7 +505,7 @@
496
505
  "key"
497
506
  ],
498
507
  "additionalProperties": false,
499
- "description": "A declared environment variable (field names shared verbatim with the discord-bot index)."
508
+ "description": "A declared environment variable (field names shared verbatim with the discord-bot index). Declaring a key is what lets `HostApi.env.get` read it. `format` and `required` are metadata only: the host checks a value against neither (#784), so a plugin that depends on one checks its own value."
500
509
  }
501
510
  }
502
511
  }