@kaiba-cloud/orbital 0.0.0-stage → 0.0.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.
@@ -0,0 +1,1026 @@
1
+ # Ports: each port of `PluginPorts`
2
+
3
+ Each section has the same parts:
4
+
5
+ - **For**: what the port does for the node.
6
+ - **When**: when the node calls each method.
7
+ - **Config**: how `config.yaml` chooses the plugin for the port.
8
+ - **Interface**: the TypeScript interface, as `ports.ts` in `@kaiba-cloud/orbital` declares it.
9
+ - **Remote methods**: the JSON-RPC methods for a plugin out of process, with their named params. The SDK maps them to your object. You need them only when you write a plugin without the SDK.
10
+ - **Example**: a minimal port that passes `orbital plugin check`.
11
+
12
+ Every type comes from `@kaiba-cloud/orbital-sdk`. Each argument and each answer
13
+ is plain JSON. A method that the interface marks with `?` is optional. Leave it
14
+ out, and the manifest says that the plugin does not provide it.
15
+
16
+ The order of plugins: first the plugins of `config.yaml`, in the order of
17
+ `plugins:`, then the built-in plugins `orbital`, `local`, `static`, `git` and
18
+ `folder`. "In plugin order" below means this order. So your plugin answers a
19
+ hook such as `sessions.create` before a built-in plugin does.
20
+
21
+ ## `directory`
22
+
23
+ **For.** Give the node agents, skills and blueprints that are not files of the
24
+ definition. Examples: a registry of agents, or the agents of other machines.
25
+
26
+ **When.**
27
+
28
+ - `agents(workspaceDir)`: the node lists its agents, for `agent list`, for the
29
+ catalog and for `agentPeers`. A definition agent with the same name hides a
30
+ directory agent.
31
+ - `lookup(ref)`: a run names an agent that the definition does not have. The
32
+ node asks each directory in order. The first answer that is not null wins.
33
+ - `agent(id, session)`: a session of an agent from this directory starts. The
34
+ answer gives its harness, egress and bootstrap. The node reads these
35
+ `bootstrap` keys: `systemPrompt`, `agentModel`, `provider`, `harness` and
36
+ `skills` (a list of `ResolvedSkill`).
37
+ - `skill(slug, session)`: an agent names a skill that the definition does not
38
+ have. The first answer that is not null wins.
39
+ - `blueprint(id, session)` and `blueprintFile(id, path, session)`: a session of
40
+ an agent from this directory calls `GET /orbital/blueprints/<id>` or
41
+ `GET /orbital/blueprints/<id>/file?path=<path>` on the node.
42
+ - `announce(catalog)`: once, after the node serves and its channels listen. The
43
+ catalog holds the teams, the agents, the live sessions and the started teams
44
+ of the node. A directory that other machines read uses it to publish this
45
+ node.
46
+
47
+ **Config.** `directory: <plugin>` or `directory: [<plugin>, ...]`. Without the
48
+ key, the node uses each plugin that provides a directory, in plugin order.
49
+
50
+ **Interface.**
51
+
52
+ ```ts
53
+ interface Directory {
54
+ agents(workspaceDir: string): Promise<DirectoryAgentSummary[]>;
55
+ lookup(ref: string): Promise<DirectoryAgentRef | null>;
56
+ agent(id: string, session: SessionInfo): Promise<ResolvedAgent | null>;
57
+ skill(slug: string, session: SessionInfo): Promise<ResolvedSkill | null>;
58
+ blueprint?(id: string, session: SessionInfo): Promise<BlueprintContent | null>;
59
+ blueprintFile?(id: string, path: string, session: SessionInfo): Promise<BlueprintFile | null>;
60
+ announce?(catalog: MachineCatalog): Promise<void>;
61
+ }
62
+ interface DirectoryAgentSummary { name: string; id: string | null; description: string | null }
63
+ interface DirectoryAgentRef { id: string; name: string }
64
+ interface ResolvedAgent { id: string; name: string; harness: Harness | null; bootstrap: Record<string, unknown>; egress: string[] }
65
+ interface ResolvedSkill { slug: string; name: string; description: string | null; loadMode?: string; contentBase64: string; files: Array<{ path: string; contentBase64: string }> }
66
+ interface BlueprintContent { id: string; contentBase64: string; files: Array<{ path: string; size: number; hash: string | null }> }
67
+ interface BlueprintFile { path: string; mimeType: string | null; size: number; contentBase64: string }
68
+ ```
69
+
70
+ **Remote methods.** `directory.agents {workspaceDir}`, `directory.lookup {ref}`,
71
+ `directory.agent {id, session}`, `directory.skill {slug, session}`,
72
+ `directory.blueprint {id, session}`, `directory.blueprintFile {id, path, session}`,
73
+ `directory.announce {catalog}`.
74
+
75
+ **Example.**
76
+
77
+ ```ts
78
+ const agents = [{ id: 'rev-1', name: 'reviewer', description: 'Reviews a pull request.', prompt: 'You review code.' }];
79
+
80
+ ports: {
81
+ directory: {
82
+ agents: async () => agents.map(({ id, name, description }) => ({ id, name, description })),
83
+ lookup: async (ref) => agents.find((one) => one.name === ref || one.id === ref) ?? null,
84
+ agent: async (id) => {
85
+ const found = agents.find((one) => one.id === id);
86
+ return found ? { id: found.id, name: found.name, harness: null, egress: [], bootstrap: { systemPrompt: found.prompt } } : null;
87
+ },
88
+ skill: async () => null,
89
+ },
90
+ },
91
+ ```
92
+
93
+ The check: `agents()` answers `{ name, id, description }` items, and `lookup()`
94
+ and `skill()` of an unknown name answer null.
95
+
96
+ ## `placements`
97
+
98
+ **For.** Run a session somewhere other than this node: another machine, a
99
+ cloud service, or a node behind a channel. The key of the record is the
100
+ placement kind. A placement is `<kind>` or `<kind>:<name>`, such as
101
+ `lan:build-box`.
102
+
103
+ **When.** A new session gets a placement other than `local` from one of these:
104
+
105
+ - the member entry in `teams/<team>/CHARTER.md`: `members: [{ name: coder, placement: lan:build-box }]`;
106
+ - the `placement` parameter of `agentDispatch`;
107
+ - a `sessions.create` answer `{ kind: 'placement', placement }` (see `sessions`).
108
+
109
+ Then the node calls `endpoint(placement)`. It sends the A2A `SendMessage` of
110
+ the turn to the answer, and follows the task until it ends. A later turn of the
111
+ same session goes to the same placement.
112
+
113
+ - An `A2aEndpoint` answer: the node posts JSON-RPC to `url` with `headers`. A
114
+ node of orbital serves A2A at `<node url>/a2a` and takes its own secret as
115
+ `authorization: Bearer <token>`.
116
+ - A `ChannelRoute` answer: the node sends the turn on that channel to `to`.
117
+ - `decides: true`: the endpoint chooses the target itself. The node does not
118
+ put the placement in the message metadata (`orbital/placement`). With
119
+ `decides: false`, the node puts it there, so the far side can place the
120
+ session.
121
+
122
+ **Config.** `placement: { <kind>: <plugin> }`. Without an entry for a kind, the
123
+ node uses the first plugin that serves the kind. A placement kind that no
124
+ plugin serves refuses the session start.
125
+
126
+ **Interface.**
127
+
128
+ ```ts
129
+ interface Placement {
130
+ endpoint(placement: string): Promise<PlacementTarget>;
131
+ readonly decides?: boolean;
132
+ }
133
+ type PlacementTarget = A2aEndpoint | ChannelRoute;
134
+ interface A2aEndpoint { url: string; headers: Record<string, string> }
135
+ interface ChannelRoute { channel: string; config: Record<string, unknown>; to: Address }
136
+ ```
137
+
138
+ **Remote methods.** `placement.endpoint {placement}`. The SDK sends the call to
139
+ the placement whose key is the part before `:`.
140
+
141
+ **Example.**
142
+
143
+ ```ts
144
+ ports: {
145
+ placements: {
146
+ lan: {
147
+ decides: true,
148
+ endpoint: async (placement) => {
149
+ const name = placement.slice('lan:'.length);
150
+ const peer = peers.get(name);
151
+ if (!peer) throw new Error(`No peer ${name} on this network.`);
152
+ return { url: `${peer.url}/a2a`, headers: { authorization: `Bearer ${peer.token}` } };
153
+ },
154
+ },
155
+ },
156
+ },
157
+ ```
158
+
159
+ The check needs a fixture: `{"placements":{"lan":"lan:build-box"}}`. Without
160
+ it, the check skips.
161
+
162
+ ## `policy`
163
+
164
+ **For.** Allow, deny or ask before the node does an action.
165
+
166
+ **When.** Before each action of these kinds:
167
+
168
+ | `kind` | When | Fields |
169
+ |---|---|---|
170
+ | `tool.call` | A tool call of a session | `sessionId`, `tool`, `target`, `name`, `input` |
171
+ | `session.start` | A session starts | `agent`, `placement`, `harness`, `parentSessionId`, `workspaceDir` |
172
+ | `session.contribution` | A `sessions.open` hook asks for egress | `sessionId`, `plugin`, `egress` |
173
+ | `message.send` | One agent sends a message to another | `fromSessionId`, `toSessionId`, `rootSessionId`, `hops`, `at` |
174
+ | `model.use` | A model call, also `host.completeModel` | `sessionId`, `model`, `plugin` |
175
+ | `plugin.load` | A plugin starts (see the note below) | `plugin`, `network`, `secrets`, `models` |
176
+ | `channel.receive` | A channel delivers a message | `channel`, `from`, `to`, `human` |
177
+ | `action.submit` | An agent submits an action type | `sessionId`, `actionType`, `actor` |
178
+ | `mission.start` | A mission starts | `team`, `mission`, `plugin`, `event`, `key` |
179
+
180
+ Note: the node checks `plugin.load` while it starts the plugins, before any
181
+ policy engine runs. In this version no engine sees that action from a node.
182
+
183
+ The node asks each engine in order. One `deny` refuses the action. An `ask`
184
+ waits for a person on the team's channel. An engine with
185
+ `deterministic: false`, such as a model, can only deny or ask: the node reads
186
+ its `allow` as no answer.
187
+
188
+ **Config.** `policy: [orbital, <plugin>, ...]`, in the order to ask. Each entry
189
+ is a plugin name or `{ plugin: <name>, ...settings }`. Without the key, the node
190
+ asks each plugin that provides a policy, in plugin order. `orbital` is the
191
+ built-in engine. It limits the messages between agents.
192
+
193
+ **Interface.**
194
+
195
+ ```ts
196
+ interface PolicyEngine {
197
+ readonly deterministic: boolean;
198
+ decide(action: PolicyAction): Promise<PolicyDecision>;
199
+ }
200
+ type PolicyDecision =
201
+ | { verdict: 'allow' }
202
+ | { verdict: 'deny'; reason: string; policy: string }
203
+ | { verdict: 'ask'; question: string; policy: string };
204
+ ```
205
+
206
+ **Remote methods.** `policy.decide {action}`.
207
+
208
+ **Example.**
209
+
210
+ ```ts
211
+ ports: {
212
+ policy: {
213
+ deterministic: true,
214
+ decide: async (action) => {
215
+ if (action.kind === 'tool.call' && action.tool === 'shell' && action.target.includes('git push')) {
216
+ return { verdict: 'deny', reason: 'Pushes go through review.', policy: 'push-guard' };
217
+ }
218
+ if (action.kind === 'session.start' && action.placement !== 'local') {
219
+ return { verdict: 'ask', question: `Start ${action.agent} on ${action.placement}?`, policy: 'push-guard' };
220
+ }
221
+ return { verdict: 'allow' };
222
+ },
223
+ },
224
+ },
225
+ ```
226
+
227
+ The check: each answer is `allow`, `deny` with a reason, or `ask` with a
228
+ question. For a model as the judge, see the policy recipe in
229
+ [recipes.md](recipes.md).
230
+
231
+ ## `telemetry`
232
+
233
+ **For.** Send the usage of each turn, spans and the events of the node to a
234
+ dashboard or a metrics system.
235
+
236
+ **When.**
237
+
238
+ - `usage(session, report)`: after each turn, with the tokens of each model. The
239
+ session gives `node`, `team`, `mission`, `agent` and `parentSessionId`.
240
+ - `span(span)`: for each span the node records, such as an action call.
241
+ - `event(event)`: for each event of the node, in order: `node.up`, `node.down`,
242
+ `session.started`, `session.ended`, `job.dispatched`, `job.ended`,
243
+ `mission.state`, `turn.started`, `turn.ended`. A `turn.ended` holds the usage
244
+ of the turn. A sub-agent of a lead whose type names a team member is a job of
245
+ that member, with `via` the lead.
246
+
247
+ Each event has `v` (`orbital.telemetry/v1`), `seq`, `id`, `at` and `node`.
248
+ `seq` goes up by one for each event of the node. The node never waits for a
249
+ sink. It keeps at most 1000 calls for each sink, drops the oldest past that,
250
+ and warns with the count. A call that takes more than 10 seconds counts as
251
+ failed.
252
+
253
+ To hold a live graph, take `host.snapshot()` once when the service starts. Then
254
+ apply only the events with a `seq` above its `sequence`. A gap in `seq` means
255
+ that the sink dropped events: take a new snapshot. See
256
+ [host-api.md](host-api.md#snapshot) and the dashboard recipe in
257
+ [recipes.md](recipes.md).
258
+
259
+ **Config.** `telemetry: <plugin>` or a list. Without the key, the node sends to
260
+ each plugin that provides telemetry.
261
+
262
+ **Interface.**
263
+
264
+ ```ts
265
+ interface TelemetrySink {
266
+ usage?(session: SessionInfo, report: UsageReport): Promise<void>;
267
+ span?(span: TelemetrySpan): Promise<void>;
268
+ event?(event: OrbitalEvent): Promise<void>;
269
+ }
270
+ interface UsageReport { harness: Harness; models: ModelUsage[] }
271
+ interface TelemetrySpan { name: string; start: number; end: number; attributes: Record<string, string | number | boolean>; status: 'ok' | 'error' }
272
+ ```
273
+
274
+ **Remote methods.** `telemetry.usage {session, report}`, `telemetry.span {span}`,
275
+ `telemetry.event {event}`.
276
+
277
+ **Example.**
278
+
279
+ ```ts
280
+ ports: {
281
+ telemetry: {
282
+ usage: async (session, report) => send({ node: session.node, team: session.team, mission: session.mission, models: report.models }),
283
+ event: async (event) => send(event),
284
+ },
285
+ },
286
+ ```
287
+
288
+ `orbital plugin new <name> --port telemetry` writes a sink that keeps the
289
+ graph. The JSON Schema of an event and of a snapshot are in the `schemas`
290
+ folder of `@kaiba-cloud/orbital`. `orbital telemetry watch` prints the
291
+ same events, and `orbital telemetry snapshot` prints the same snapshot.
292
+
293
+ ## `sessions`
294
+
295
+ **For.** Take part in the life of each session: choose where a new session
296
+ runs, give a session environment, commands, egress and prompt text, and receive
297
+ its events.
298
+
299
+ **When.**
300
+
301
+ - `create(start)`: a new session is about to start on this node, with no
302
+ placement chosen. Answer `{ kind: 'id', sessionId }` to give the session its
303
+ id: the plugin then owns the session. Answer `{ kind: 'placement', placement }`
304
+ to start it on a placement (see `placements`). Answer null to pass. The node
305
+ asks each plugin in plugin order. The first answer that is not null wins.
306
+ - `open(session)`: a turn of the session starts on this node. The answer adds to
307
+ the turn:
308
+ - `env`: variables for the harness. The first plugin that sets a name wins.
309
+ - `shims`: `name: command`. The node writes an executable `name` on the
310
+ turn's PATH that runs `command`.
311
+ - `egress`: hosts the sandbox lets the turn reach. A `session.contribution`
312
+ policy action decides it first.
313
+ - `promptBlock`: text the node adds to the system prompt.
314
+ - `sinks`: names of event sinks. The node then calls `deliver` for each one.
315
+ - `close(session)`: the turn ends, or `open` of a later plugin failed. The node
316
+ closes the plugins in reverse order.
317
+ - `deliver(sink, sessionId, events)`: after the node writes new events to the
318
+ log of a session that has the sink. `events` holds the events after the last
319
+ one the sink took. Answer the `sequence` of the last event you took. The node
320
+ sends the rest again on the next call.
321
+
322
+ **Config.** None. The node calls the hooks of every enabled plugin.
323
+
324
+ **Interface.**
325
+
326
+ ```ts
327
+ interface SessionHooks {
328
+ create?(start: SessionStartRequest): Promise<SessionStartAnswer | null>;
329
+ open?(session: SessionInfo): Promise<SessionContribution | null>;
330
+ close?(session: SessionInfo): Promise<void>;
331
+ deliver?(sink: string, sessionId: string, events: readonly SessionEvent[]): Promise<number>;
332
+ }
333
+ interface SessionStartRequest { agent: string; parentSessionId: string | null; team: string | null; definitionAgent: boolean; declaration: Record<string, unknown> | null; register: boolean }
334
+ type SessionStartAnswer = { kind: 'id'; sessionId: string } | { kind: 'placement'; placement: string };
335
+ interface SessionContribution { env?: Record<string, string>; shims?: Record<string, string>; egress?: string[]; promptBlock?: string; sinks?: string[] }
336
+ interface SessionInfo {
337
+ sessionId: string; node: string; agent: string; agentId: string | null; agentSource: string | null;
338
+ declaration: Record<string, unknown> | null; parentSessionId: string | null; workspaceDir: string;
339
+ build: string | null; harness: Harness; team: string | null; mission: string | null;
340
+ scratchpad: string | null; memory: string | null; missionScratchpad: string | null;
341
+ }
342
+ ```
343
+
344
+ **Remote methods.** `sessions.create {start}`, `sessions.open {session}`,
345
+ `sessions.close {session}`, `sessions.deliver {sink, sessionId, events}`.
346
+
347
+ **Example.**
348
+
349
+ ```ts
350
+ ports: {
351
+ sessions: {
352
+ open: async (session) => ({
353
+ env: { TICKET_SESSION: session.sessionId },
354
+ promptBlock: `You work for the team ${session.team ?? 'none'}.`,
355
+ sinks: ['audit'],
356
+ }),
357
+ deliver: async (_sink, sessionId, events) => {
358
+ await appendAudit(sessionId, events);
359
+ return events.at(-1)?.sequence ?? 0;
360
+ },
361
+ },
362
+ },
363
+ ```
364
+
365
+ The check opens a session and requires `env` values to be strings and
366
+ `egress` to be a list of hosts.
367
+
368
+ ## `tools`
369
+
370
+ **For.** Host tools that sessions of the node call, such as a search in a
371
+ company system.
372
+
373
+ **When.** A session of the node calls `POST <node url>/orbital/tools/<name>`
374
+ with its session token. The node first asks the `actions` port. Then it asks
375
+ each `tools` port in plugin order. The first answer that is not null wins. A
376
+ null answer means "not my tool".
377
+
378
+ The agent tools server of the node lists only the action tools. To make a
379
+ hosted tool known to an agent, tell the agent in a `sessions.open`
380
+ `promptBlock`, or give it a command through `shims`.
381
+
382
+ **Config.** None. The node uses the tools of every enabled plugin. The names in
383
+ `names` must not clash with an action tool: a clash refuses the call.
384
+
385
+ **Interface.**
386
+
387
+ ```ts
388
+ interface ToolGateway {
389
+ readonly names: readonly string[];
390
+ call(name: string, input: Record<string, unknown>, session: SessionInfo): Promise<HostedToolResult | null>;
391
+ }
392
+ interface HostedToolResult { text: string; isError: boolean; structuredContent?: JsonObject }
393
+ ```
394
+
395
+ **Remote methods.** `tools.call {name, input, session}`.
396
+
397
+ **Example.**
398
+
399
+ ```ts
400
+ ports: {
401
+ tools: {
402
+ names: ['ticket-get'],
403
+ call: async (name, input) => {
404
+ if (name !== 'ticket-get') return null;
405
+ const ticket = await fetchTicket(String(input.id));
406
+ return { text: JSON.stringify(ticket), isError: false, structuredContent: ticket };
407
+ },
408
+ },
409
+ },
410
+ ```
411
+
412
+ The check: `call()` of an unknown tool answers null.
413
+
414
+ ## `channels`
415
+
416
+ **For.** Carry A2A messages between people, teams and nodes: Slack, Discord,
417
+ email, a file. The key of the record is the channel id.
418
+
419
+ **When.**
420
+
421
+ - `listen(bindings)`: when the node starts serving, with the bindings of the
422
+ started teams and of `channels:` in `config.yaml`. A later call replaces the
423
+ bindings. After a restart of the plugin, the node calls it again.
424
+ - `send(envelope, binding)`: the node sends a message on the binding, such as an
425
+ answer or a question that waits for a person.
426
+
427
+ A message that arrives on the service goes to `host.deliver(inbound)`.
428
+
429
+ **Config.** A team names the channel in `teams/<team>/channels.yaml` as
430
+ `type: <id>`. The other keys of the entry are the binding's `config`. A node
431
+ names its own channels under `channels:` in `config.yaml`. The node uses the
432
+ first plugin that provides the id.
433
+
434
+ **Interface.**
435
+
436
+ ```ts
437
+ interface Channel {
438
+ readonly capabilities: ChannelCapabilities;
439
+ send(envelope: ChannelEnvelope, binding: ChannelBinding): Promise<{ ref: string; thread: string | null }>;
440
+ listen(bindings: readonly ChannelBinding[]): Promise<void>;
441
+ }
442
+ interface ChannelCapabilities { live: boolean; human: boolean; threads: boolean; durable: boolean; offline: boolean }
443
+ interface ChannelBinding { id: string; channel: string; config: Record<string, unknown>; team: string | null; ade: string }
444
+ interface ChannelEnvelope { message: Message; from: Address; to: Address; thread: string | null }
445
+ interface Address { kind: 'agent' | 'team' | 'session' | 'person' | 'node'; name: string; ade?: string }
446
+ interface InboundDelivery { channel: string; bindingId: string; envelope: ChannelEnvelope }
447
+ ```
448
+
449
+ **Remote methods.** `channel.send {channel, envelope, binding}`,
450
+ `channel.listen {channel, bindings}`. `channel` is the channel id.
451
+
452
+ **Example.**
453
+
454
+ ```ts
455
+ const sent = new Map<string, { ref: string; thread: string | null }>();
456
+ let bindings: ChannelBinding[] = [];
457
+
458
+ ports: {
459
+ channels: {
460
+ discord: {
461
+ capabilities: { live: true, human: true, threads: true, durable: true, offline: false },
462
+ send: async (envelope, binding) => {
463
+ const known = sent.get(envelope.message.messageId);
464
+ if (known) return known;
465
+ const ref = await post(binding.config.channel, envelope);
466
+ const answer = { ref, thread: envelope.thread ?? ref };
467
+ sent.set(envelope.message.messageId, answer);
468
+ return answer;
469
+ },
470
+ listen: async (given) => { bindings = [...given]; },
471
+ },
472
+ },
473
+ },
474
+ ```
475
+
476
+ - `capabilities`: `live` (it pushes), `human` (a person reads and writes),
477
+ `threads`, `durable`, `offline` (it works with no network outside the LAN).
478
+ - A second send of the same message id must answer the first ref and send
479
+ nothing.
480
+ - A message id must be stable: the node drops a second delivery of the same id.
481
+ - Put an answer in the thread of its question.
482
+ - Fixture for the send check:
483
+ `{"channels":{"discord":{"id":"node:0","channel":"discord","config":{},"team":null,"ade":"conformance"}}}`.
484
+
485
+ The Slack recipe in [recipes.md](recipes.md) adds the service that listens.
486
+
487
+ ## `store`
488
+
489
+ **For.** Keep session state off this machine, such as in R2 or S3.
490
+
491
+ **When.** The node reads a unit when a session resumes or a scratchpad is
492
+ restored. It writes the unit after the session changes. With `lease`, one ADE
493
+ at a time holds a unit.
494
+
495
+ - `history`: the run state of a session: the transcript, the event log and the
496
+ harness state. A resume on another machine reads it.
497
+ - `session` and `mission`: the scratchpads, as text files only. A transcript
498
+ never goes there.
499
+ - `temporary`: short-lived files.
500
+
501
+ **Config.** Only an explicit choice: `store: { history: [{ plugin: r2, bucket: atom }] }`.
502
+ `settings` is the choice without `plugin`: here `{ bucket: 'atom' }`. A store
503
+ whose `kinds` do not hold the kind refuses the start.
504
+
505
+ **Interface.**
506
+
507
+ ```ts
508
+ interface StateStore {
509
+ readonly kinds: readonly StateKind[];
510
+ list(ref: StoreRef): Promise<StoredEntry[] | null>;
511
+ read(ref: StoreRef, path: string): Promise<StoredFile | null>;
512
+ write(ref: StoreRef, files: readonly StoredFile[], removed: readonly string[]): Promise<void>;
513
+ lease?(ref: StoreRef, holder: string, ttlMs: number): Promise<StoreLease>;
514
+ release?(ref: StoreRef, holder: string): Promise<void>;
515
+ }
516
+ type StateKind = 'history' | 'session' | 'mission' | 'temporary';
517
+ interface StoreRef { kind: StateKind; id: string; unit: string; settings: Record<string, unknown> }
518
+ interface StoredEntry { path: string; hash: string; size: number }
519
+ interface StoredFile { path: string; contentBase64: string }
520
+ interface StoreLease { granted: boolean; holder: string; expiresAt: string }
521
+ ```
522
+
523
+ **Remote methods.** `store.list {ref}`, `store.read {ref, path}`,
524
+ `store.write {ref, files, removed}`, `store.lease {ref, holder, ttlMs}`,
525
+ `store.release {ref, holder}`.
526
+
527
+ **Example.**
528
+
529
+ ```ts
530
+ ports: {
531
+ store: {
532
+ kinds: ['history'],
533
+ list: async (ref) => /* [{ path, hash: SHA-256 hex, size }], or null when it holds no unit */,
534
+ read: async (ref, path) => /* { path, contentBase64 } or null */,
535
+ write: async (ref, files, removed) => { /* write files, delete the removed paths */ },
536
+ },
537
+ },
538
+ ```
539
+
540
+ - `unit` is the place of the unit, the same on every ADE: `sessions/<id>` for
541
+ history, `agents/<agent>/sessions/<id>` for a session scratchpad. Use it as
542
+ the key prefix.
543
+ - Paths use `/` between folders. Refuse a path that leaves the unit.
544
+ - Fixture: `{"store": <settings>}`. `orbital plugin new <name> --port store`
545
+ writes a store in a folder that passes every check.
546
+
547
+ ## `workspace`
548
+
549
+ **For.** Keep the working folder of a team or an agent off this machine. A
550
+ session's own (`ephemeral`) workspace is never saved.
551
+
552
+ **When.** `load(ref, dir)` when the node makes the workspace on a machine that
553
+ has none of it. `save(ref, dir, ignore)` after each turn.
554
+
555
+ **Config.** Only an explicit choice: `store: { workspace: [{ plugin: r2, bucket: atom }] }`.
556
+
557
+ **Interface.**
558
+
559
+ ```ts
560
+ interface WorkspaceStore {
561
+ load(ref: WorkspaceRef, dir: string): Promise<boolean>;
562
+ save(ref: WorkspaceRef, dir: string, ignore: readonly string[]): Promise<void>;
563
+ }
564
+ type WorkspaceOwner = 'team' | 'agent' | 'session';
565
+ interface WorkspaceRef { project: string; owner: WorkspaceOwner; name: string; settings: Record<string, unknown> }
566
+ ```
567
+
568
+ **Remote methods.** `workspace.load {ref, dir}`, `workspace.save {ref, dir, ignore}`.
569
+
570
+ **Example.**
571
+
572
+ ```ts
573
+ ports: {
574
+ workspace: {
575
+ load: async (ref, dir) => /* fill dir; answer false when the store holds none */,
576
+ save: async (ref, dir, ignore) => { /* store dir, except the paths in ignore, such as .git */ },
577
+ },
578
+ },
579
+ ```
580
+
581
+ - The plugin runs on the node's machine, so it reads and writes `dir` itself.
582
+ - `project` is `<folder>-<hash>`, the same on every machine with the same
583
+ remote. `name` is the team (`<team>.<mission>` for mission work), the agent
584
+ or the session id. Use `<project>/<owner>/<name>` as the key prefix.
585
+ - A save is the whole workspace: a file removed from the workspace goes from the
586
+ store too.
587
+ - Fixture: `{"workspace": <settings>}`.
588
+
589
+ ## `harnesses`
590
+
591
+ **For.** Run turns on another agent program, such as a CLI agent.
592
+
593
+ **When.** A session's harness is one of the ids. For each turn, the node calls
594
+ `command(spec)`, writes the spec to a file, sets `ORBITAL_HARNESS_TURN` to its
595
+ path, and runs the command in the sandbox.
596
+
597
+ **Config.** None. An agent or a team member names the harness id. The node uses
598
+ the first plugin that brings the id.
599
+
600
+ **Interface.**
601
+
602
+ ```ts
603
+ interface HarnessProvider {
604
+ readonly harnesses: ReadonlyArray<{ id: string; description: string; callsModel?: boolean }>;
605
+ command(spec: HarnessTurnSpec): Promise<HarnessCommand>;
606
+ }
607
+ interface HarnessCommand { command: string[]; env?: Record<string, string>; egress?: string[] }
608
+ interface HarnessTurnSpec {
609
+ harness: string; sessionId: string; prompt: string; systemPrompt: string; model: string | null;
610
+ skillsDir: string; workspaceDir: string; sessionDir: string; resume: boolean; env: Record<string, string>;
611
+ credentials: Array<{ provider: string; authType: string; payload: string }>;
612
+ tools: { command: string[]; env: Record<string, string> };
613
+ scratchpad: string | null; memory: string | null; missionScratchpad: string | null;
614
+ }
615
+ ```
616
+
617
+ **Remote methods.** `harness.command {spec}`.
618
+
619
+ **Example.**
620
+
621
+ ```ts
622
+ ports: {
623
+ harnesses: {
624
+ harnesses: [{ id: 'my-agent', description: 'My agent CLI.', callsModel: false }],
625
+ command: async () => ({
626
+ command: [process.execPath, fileURLToPath(new URL('turn.js', import.meta.url))],
627
+ env: {},
628
+ egress: ['api.openai.com'],
629
+ }),
630
+ },
631
+ },
632
+ ```
633
+
634
+ An id is `a-z`, `0-9` and `-`, at most 32. `callsModel` is true by default:
635
+ the node then needs a provider key for the agent's model and gives it in
636
+ `credentials`. Set `callsModel: false` for a program that uses its own login or
637
+ calls no model.
638
+
639
+ The harnesses a node runs are its runners (`pi`, `claude`) and the ids of its
640
+ harness plugins. A node refuses to start when a charter member or an agent names
641
+ a harness outside them, and names the ones it runs. A member runs on the
642
+ harness its charter names, also when its lead dispatches it.
643
+
644
+ The turn program writes one JSON object a line on stdout. Each `message` shows
645
+ live in `orbital up`, in the pane of the Claude Code mod and in the transcript
646
+ of the session; build its parts with `textPart`, `thinkingPart`,
647
+ `toolCallPart` and `toolResultPart` from `@kaiba-cloud/orbital-sdk`:
648
+
649
+ ```ts
650
+ const say = (role: 'ROLE_AGENT' | 'ROLE_USER', parts: Part[]) => line({ type: 'message', message: { messageId: randomUUID(), role, parts } });
651
+ say('ROLE_AGENT', [thinkingPart('I look at the workspace first.')]);
652
+ say('ROLE_AGENT', [toolCallPart({ id: 'c1', name: 'ls', input: { path: spec.workspaceDir } })]);
653
+ say('ROLE_USER', [toolResultPart({ callId: 'c1', name: 'ls', text: listing, isError: false })]);
654
+ say('ROLE_AGENT', [textPart(answer)]);
655
+ line({ type: 'usage', models: [{ provider: 'example', model: spec.harness, inputTokens: 12, outputTokens: 33 }] });
656
+ line({ type: 'result', text: answer });
657
+ ```
658
+
659
+ A turn with no `result` failed. `{"type":"error","message":"…"}` says why. A
660
+ line that is not JSON is the program's own log. `usage` reaches the telemetry
661
+ of the node as the tokens of the turn. `spec.resume` is true when the session
662
+ has turns already. A stop or a mission pause ends the process with SIGTERM.
663
+ `orbital plugin new <name> --port harness` writes a harness that does all of
664
+ this; recipe 5 runs it in a team.
665
+
666
+ The spec gives three folders, or null when the run has none: `scratchpad` (the
667
+ session's working documents), `memory` (the agent's memory, which outlives the
668
+ session) and `missionScratchpad` (the shared folder of the mission). Mapping
669
+ them is optional. The environment also has `$ORBITAL_SCRATCHPAD`,
670
+ `$ORBITAL_MEMORY` and `$ORBITAL_MISSION`. The sandbox lets the turn write them.
671
+
672
+ ## `panels`
673
+
674
+ **For.** Rows that a surface shows: `orbital panel`, `orbital up` and the TUI.
675
+ The key of the record is the panel id.
676
+
677
+ **When.** `read()` each time a surface shows the panel.
678
+
679
+ Two panel ids have a meaning to the node:
680
+
681
+ - `missions`: rows of missions of other nodes. The node merges them into its
682
+ mission list and uses them for claims. See the missions recipe in
683
+ [recipes.md](recipes.md).
684
+ - `peers`: rows with `node`, `address` and `trust`. `orbital up` prints a line
685
+ for each new peer.
686
+
687
+ **Config.** None. Every enabled plugin's panels show.
688
+
689
+ **Interface.**
690
+
691
+ ```ts
692
+ interface Panel {
693
+ readonly title: string;
694
+ readonly kind: PanelKind;
695
+ read(): Promise<PanelData>;
696
+ }
697
+ type PanelKind = 'list' | 'tree' | 'transcript' | 'form';
698
+ interface PanelData { columns: string[]; rows: Array<Record<string, string | number | boolean | null>>; note?: string }
699
+ ```
700
+
701
+ **Remote methods.** `panel.read {id}`.
702
+
703
+ **Example.**
704
+
705
+ ```ts
706
+ ports: {
707
+ panels: {
708
+ queue: {
709
+ title: 'discord queue',
710
+ kind: 'list',
711
+ read: async () => ({ columns: ['channel', 'waiting'], rows: [{ channel: '#bugs', waiting: 2 }], note: 'Counts refresh each read.' }),
712
+ },
713
+ },
714
+ },
715
+ ```
716
+
717
+ Each row key must be a column. Each value is a string, a number, a boolean or
718
+ null. `orbital panel <plugin>/<id>` shows it.
719
+
720
+ ## `actions`
721
+
722
+ **For.** Run the action types of the ontology. The action gateway of the node
723
+ lists them to agents as tools, and sends each call to this port.
724
+
725
+ **When.**
726
+
727
+ - `catalog(session)`: the agent tools server of a session lists its tools.
728
+ - `evaluate(request, session)`: a call of a tool with `readOnlyHint: true`. It
729
+ reads and changes nothing.
730
+ - `submit(request, session)`: a call of any other tool, after the
731
+ `action.submit` policy action. Answer `applied` with the edits, or `refused`
732
+ with findings.
733
+
734
+ The node checks the input against `inputSchema` before it calls the port.
735
+
736
+ **Config.** None. The node uses the first plugin that provides actions.
737
+
738
+ **Interface.**
739
+
740
+ ```ts
741
+ interface ActionsPort {
742
+ catalog(session: SessionInfo): Promise<ActionTool[]>;
743
+ submit(request: ActionRequest, session: SessionInfo): Promise<ActionResult>;
744
+ evaluate(request: ActionRequest, session: SessionInfo): Promise<FunctionResult>;
745
+ }
746
+ interface ActionTool { name: string; title: string; description: string; inputSchema: JsonObject; outputSchema: JsonObject; annotations: ActionToolAnnotations }
747
+ interface ActionToolAnnotations { title: string; readOnlyHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint: boolean }
748
+ interface ActionRequest { name: string; input: JsonObject; actor: ActionActor | null }
749
+ interface ActionActor { agent: string; skills: readonly { slug: string; name: string }[]; relations: readonly { type: string; uid: string }[] }
750
+ interface ActionResult { verdict: 'applied' | 'refused'; edits: AppliedEdit[]; findings: ActionFinding[] }
751
+ interface AppliedEdit { edit: string; op: string; uid: string; path: string }
752
+ interface ActionFinding { rule: string; finding: string; path: string; message: string }
753
+ interface FunctionResult { result: Json }
754
+ ```
755
+
756
+ **Remote methods.** `actions.catalog {session}`, `actions.submit {request, session}`,
757
+ `actions.evaluate {request, session}`.
758
+
759
+ **Example.**
760
+
761
+ ```ts
762
+ ports: {
763
+ actions: {
764
+ catalog: async () => [],
765
+ submit: async (request) => ({
766
+ verdict: 'refused',
767
+ edits: [],
768
+ findings: [{ rule: 'action', finding: 'violation', path: '', message: `No action type ${request.name}.` }],
769
+ }),
770
+ evaluate: async (request) => { throw new Error(`No function ${request.name}.`); },
771
+ },
772
+ },
773
+ ```
774
+
775
+ The checks: each tool has a name, an object `inputSchema` and a boolean
776
+ `readOnlyHint`, and `submit()` of an unknown type answers `refused` with a
777
+ finding and no edit. `orbital plugin new <name> --port actions` writes this
778
+ port.
779
+
780
+ ## `workflows`
781
+
782
+ **For.** Run workflow files on an engine, such as BPMN or Serverless Workflow.
783
+
784
+ **When.** The node binds a workflow file to an engine by its suffix. The port
785
+ starts a run, sends it a signal, reads its state and cancels it. The node uses
786
+ the first plugin whose `engines` holds the engine.
787
+
788
+ **Config.** `workflowBindings:` in `config.yaml` binds a file suffix to an
789
+ engine name: `- { engine: <name>, suffix: .bpmn }`. The defaults bind
790
+ `.sw.yaml`, `.sw.json`, `.bpmn` and `.arazzo.yaml`.
791
+
792
+ **Interface.**
793
+
794
+ ```ts
795
+ interface WorkflowsPort {
796
+ readonly engines: readonly string[];
797
+ start(binding: WorkflowStart, input: JsonObject): Promise<WorkflowRun>;
798
+ signal(runId: string, name: string, payload: Json): Promise<void>;
799
+ status(runId: string): Promise<WorkflowRun | null>;
800
+ cancel(runId: string): Promise<WorkflowRun | null>;
801
+ }
802
+ interface WorkflowStart { engine: string; path: string; name: string }
803
+ type WorkflowRunState = 'running' | 'completed' | 'failed' | 'cancelled';
804
+ interface WorkflowRun { runId: string; engine: string; state: WorkflowRunState; output: Json }
805
+ ```
806
+
807
+ **Remote methods.** `workflows.start {binding, input}`,
808
+ `workflows.signal {runId, name, payload}`, `workflows.status {runId}`,
809
+ `workflows.cancel {runId}`.
810
+
811
+ **Example.**
812
+
813
+ ```ts
814
+ const runs = new Map<string, WorkflowRun>();
815
+
816
+ ports: {
817
+ workflows: {
818
+ engines: ['flow'],
819
+ start: async (binding, input) => {
820
+ const run: WorkflowRun = { runId: `flow-${runs.size + 1}`, engine: binding.engine, state: 'completed', output: input };
821
+ runs.set(run.runId, run);
822
+ return run;
823
+ },
824
+ signal: async () => undefined,
825
+ status: async (runId) => runs.get(runId) ?? null,
826
+ cancel: async (runId) => runs.get(runId) ?? null,
827
+ },
828
+ },
829
+ ```
830
+
831
+ The check: `engines` is a list of names, and `status()` and `cancel()` of an
832
+ unknown run answer null.
833
+
834
+ ## `reasoner`
835
+
836
+ **For.** Reason over an OWL ontology in Turtle: extract a module, classify,
837
+ align two ontologies, and check consistency.
838
+
839
+ **When.** A caller of the node's port registry asks the first plugin that
840
+ provides a reasoner. Each answer is three-valued: `true`, `false` or
841
+ `'unknown'`. `unchecked` lists what the reasoner did not check. A method the
842
+ plugin cannot run answers `'unknown'` with an `unsupported` message.
843
+
844
+ **Config.** None. The node uses the first plugin that provides a reasoner.
845
+
846
+ **Interface.**
847
+
848
+ ```ts
849
+ interface ReasonerPort {
850
+ extractModule(request: ExtractModuleRequest): Promise<ExtractModuleAnswer>;
851
+ classify(request: ClassifyRequest): Promise<ClassifyAnswer>;
852
+ align(request: AlignRequest): Promise<AlignAnswer>;
853
+ checkConsistency(request: ConsistencyRequest): Promise<ConsistencyAnswer>;
854
+ }
855
+ interface ReasonerAnswer { answer: true | false | 'unknown'; unchecked: string[]; unsupported?: string }
856
+ interface ExtractModuleRequest { ontology: string; signature: string[]; method: 'star' | 'bot' | 'top' }
857
+ interface ExtractModuleAnswer extends ReasonerAnswer { module: string }
858
+ interface ClassifyRequest { ontology: string }
859
+ interface ClassifyAnswer extends ReasonerAnswer { subclasses: [string, string][] }
860
+ interface AlignRequest { source: string; target: string }
861
+ interface AlignAnswer extends ReasonerAnswer { candidates: Array<{ subject: string; predicate: AlignmentPredicate; object: string; confidence: number }> }
862
+ interface ConsistencyRequest { ontology: string }
863
+ interface ConsistencyAnswer extends ReasonerAnswer { unsatisfiable: string[] }
864
+ ```
865
+
866
+ **Remote methods.** `reasoner.extractModule {request}`, `reasoner.classify {request}`,
867
+ `reasoner.align {request}`, `reasoner.checkConsistency {request}`.
868
+
869
+ **Example.**
870
+
871
+ ```ts
872
+ const unsupported = { answer: 'unknown' as const, unchecked: [], unsupported: 'This reasoner only checks consistency.' };
873
+
874
+ ports: {
875
+ reasoner: {
876
+ extractModule: async () => ({ ...unsupported, module: '' }),
877
+ classify: async () => ({ ...unsupported, subclasses: [] }),
878
+ align: async () => ({ ...unsupported, candidates: [] }),
879
+ checkConsistency: async ({ ontology }) => ({ answer: true, unchecked: [], unsatisfiable: [] }),
880
+ },
881
+ },
882
+ ```
883
+
884
+ The check calls each method on a small ontology. See
885
+ `plugins/reasoner-open-ontologies` in the source repository.
886
+
887
+ ## `missions`
888
+
889
+ **For.** Carry the work on a mission between nodes. One node runs the lead of
890
+ the mission. A person or an agent on another node gives the mission, answers
891
+ its questions, talks to its lead, pauses it or resumes it. This is the port a
892
+ LAN or cross-machine plugin implements.
893
+
894
+ **When.** The node calls the port only for a mission that it does not hold
895
+ itself. It asks each plugin with the port, in plugin order. The first answer
896
+ that is not null (or true) wins.
897
+
898
+ - `join(request)`: an attached session on this node joins a mission as its
899
+ giver (`missionJoin`). `request.giver` is `{ node, sessionId }` of this node.
900
+ - `say(request)`: a person or a session talks to the lead of a mission
901
+ (`mission say`, `missionSay`). The far node gives the words to the lead: in
902
+ its running turn, as its next turn, or as the answer to its question. When
903
+ no plugin answers, the node tries `reply` next.
904
+ - `reply(request)`: a person or a session answers the question of a waiting
905
+ mission (`mission reply`, `missionReply`).
906
+ - `pause(request)`, `resume(request)`: `mission pause`, `mission resume`.
907
+ - `ask(question)`: the lead of a mission on this node waits with a question,
908
+ and the giver is a session of another node. Answer true when you carried the
909
+ question to that node. When no plugin answers true, the node warns.
910
+
911
+ Answer null (or false) when you do not know the mission or the node. Else
912
+ answer what the far node gave back: a `MissionRecord`, or a `Said` for `say`.
913
+
914
+ **The far side.** The plugin on the far node takes the request on its own
915
+ transport, then calls the host API of its node:
916
+
917
+ | Near node calls the port | The plugin on the far node calls |
918
+ |---|---|
919
+ | `join(request)` | `host.joinMission(request)` |
920
+ | `say(request)` | `host.sayMission(request)` |
921
+ | `reply(request)` | `host.replyMission(request)` |
922
+ | `pause(request)` | `host.pauseMission(request)` |
923
+ | `resume(request)` | `host.resumeMission(request)` |
924
+ | `ask(question)` | `host.askGiver(question)` (true when delivered) |
925
+
926
+ The far node does not relay a request again. To find which node holds a
927
+ mission, read `host.missions()` on each node: it gives the missions of that
928
+ node only. To show the missions of other nodes in the mission list, and for
929
+ claims, also give a `missions` panel (see the recipe).
930
+
931
+ **Config.** None. The node uses every enabled plugin with the port.
932
+
933
+ **Interface.**
934
+
935
+ ```ts
936
+ interface MissionRelayPort {
937
+ join(request: RelayJoin): Promise<MissionRecord | null>;
938
+ reply(request: RelayReply): Promise<MissionRecord | null>;
939
+ pause(request: RelayControl): Promise<MissionRecord | null>;
940
+ resume(request: RelayControl): Promise<MissionRecord | null>;
941
+ ask(question: MissionQuestion): Promise<boolean>;
942
+ say(request: RelayMessage): Promise<Said | null>;
943
+ }
944
+ interface RemoteGiver { node: string; sessionId: string }
945
+ interface RelayJoin { missionId: string; giver: RemoteGiver }
946
+ interface RelayReply { missionId: string; text: string; by: string }
947
+ interface RelayControl { missionId: string; by: string }
948
+ interface RelayMessage { missionId: string; text: string; by: string }
949
+ interface Said { sessionId: string; missionId: string | null; delivery: 'reply' | 'steer' | 'turn' }
950
+ interface MissionQuestion { missionId: string; node: string; lead: string | null; leadSessionId: string; question: string; giver: RemoteGiver }
951
+ interface MissionRecord {
952
+ missionId: string; team: string | null; node: string; file: string | null;
953
+ state: 'open' | 'active' | 'waiting' | 'paused' | 'done' | 'failed';
954
+ declared: boolean; givenBy: string | null; brief: string | null; sessionId: string | null; taskId: string | null;
955
+ source: string | null; result: string | null; question: string | null; giver: string | null; lead: string | null;
956
+ lastActivity: string | null; claim: string | null; claimedAt: string | null; stale: boolean; updatedAt: string | null;
957
+ history: Array<{ state: MissionState; at: string; by: string; note: string | null }>;
958
+ }
959
+ ```
960
+
961
+ A mission id is lowercase letters, digits and hyphens, at most 63.
962
+
963
+ **Remote methods.** `missions.join {request}`, `missions.reply {request}`,
964
+ `missions.pause {request}`, `missions.resume {request}`, `missions.ask {question}`,
965
+ `missions.say {request}`.
966
+
967
+ **Example.** `peer` is your transport to the same plugin on the other nodes.
968
+
969
+ ```ts
970
+ ports: {
971
+ missions: {
972
+ join: async (request) => peer.ask('join', request),
973
+ reply: async (request) => peer.ask('reply', request),
974
+ pause: async (request) => peer.ask('pause', request),
975
+ resume: async (request) => peer.ask('resume', request),
976
+ ask: async (question) => (await peer.send(question.giver.node, 'ask', question)) === true,
977
+ say: async (request) => peer.ask('say', request),
978
+ },
979
+ },
980
+ ```
981
+
982
+ `peer.ask` sends to each known node and answers the first answer, or null.
983
+ The full plugin, with the far side, is the first recipe in
984
+ [recipes.md](recipes.md). No conformance check exists for this port yet, so
985
+ test it with two nodes.
986
+
987
+ ## `commands`
988
+
989
+ `commands` sits beside `ports`, not in it. A person runs a command with
990
+ `orbital plugin run <plugin> <command> -- <args>`. The node prints the answer.
991
+
992
+ ```ts
993
+ commands: {
994
+ whoami: {
995
+ summary: 'Name the account this plugin posts as.',
996
+ run: async (args) => `bot in ${args[0] ?? 'the default guild'}`,
997
+ },
998
+ },
999
+ ```
1000
+
1001
+ **Remote method.** `command.run {name, args}`, answered with `{ output }`.
1002
+
1003
+ ## `service`
1004
+
1005
+ `service` sits beside `ports`, not in it. It is background work while the node
1006
+ serves: a socket that listens, a poll, a timer.
1007
+
1008
+ ```ts
1009
+ service: {
1010
+ ends: false,
1011
+ start: async ({ host, signal }) => {
1012
+ const timer = setInterval(() => void poll(host), 5000);
1013
+ signal.addEventListener('abort', () => clearInterval(timer));
1014
+ return { stop: async () => clearInterval(timer) };
1015
+ },
1016
+ },
1017
+ ```
1018
+
1019
+ - The node starts the service after every plugin is ready and the node serves,
1020
+ so `host` is ready.
1021
+ - With `ends: true`, return `done`, a promise that resolves when the work ends,
1022
+ such as a queue served once. The node stops when every `done` resolves.
1023
+ - After a restart of the plugin, the node starts the service again.
1024
+
1025
+ **Remote methods.** `service.start`, `service.stop`; the plugin sends
1026
+ `service.done {ok, error?}` when `done` settles.