@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,581 @@
1
+ # Recipes
2
+
3
+ Each recipe is one `src/plugin.ts`. Start each one with `orbital plugin new`,
4
+ replace `src/plugin.ts`, then build, check and add it as SKILL.md says.
5
+
6
+ The `policy` and `command` templates add one check of their own to
7
+ `test/conformance.test.ts`, after the conformance check. When you replace
8
+ `src/plugin.ts`, replace that check with one for your plugin, or delete it.
9
+
10
+ ## 1. Cross-machine work: missions relay, directory and placements
11
+
12
+ The plugin `lan` joins the nodes of a local network. Each machine runs a node
13
+ with the same plugin. The plugins talk to each other over HTTP with a shared
14
+ key. Then:
15
+
16
+ - an agent on one node dispatches `coder@build-box`, and the session runs on
17
+ the node `build-box` (`sessions.create`, `placements`);
18
+ - the agents of each node list the teams and agents of the other nodes
19
+ (`directory`);
20
+ - a person on one node joins, talks to, answers, pauses and resumes a mission
21
+ whose lead runs on another node, and the lead's question reaches the giver on its own
22
+ node (`missions`);
23
+ - `orbital mission list` and the claims see the missions of every node, and
24
+ `orbital up` shows the peers (`panels`).
25
+
26
+ Start it:
27
+
28
+ ```bash
29
+ orbital plugin new lan --port panel
30
+ ```
31
+
32
+ Replace `src/plugin.ts`:
33
+
34
+ ```ts
35
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
36
+ import type { HostApi, MissionRecord, PluginContext, PluginImplementation, Said } from '@kaiba-cloud/orbital-sdk';
37
+
38
+ export const NAME = 'lan';
39
+
40
+ const TERMINAL = ['TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED'];
41
+ const REMOTE_AGENT = /^(.+)@([a-z0-9][a-z0-9-]{0,62})$/;
42
+
43
+ interface Peer { name: string; url: string }
44
+ interface Hello { node: string; agents: Array<{ name: string; description: string | null }>; missions: MissionRecord[] }
45
+ interface Seen { at: string; hello: Hello }
46
+
47
+ function send(response: ServerResponse, status: number, body: unknown): void {
48
+ response.writeHead(status, { 'content-type': 'application/json' });
49
+ response.end(JSON.stringify(body));
50
+ }
51
+
52
+ function read(request: IncomingMessage): Promise<string> {
53
+ return new Promise((resolve, reject) => {
54
+ const chunks: Buffer[] = [];
55
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
56
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
57
+ request.on('error', reject);
58
+ });
59
+ }
60
+
61
+ export function plugin(context: PluginContext): PluginImplementation {
62
+ const { config } = context.init;
63
+ const self = context.init.node.name;
64
+ const port = typeof config.port === 'number' ? config.port : 7420;
65
+ const peers: Peer[] = Object.entries((config.peers ?? {}) as Record<string, string>)
66
+ .filter(([name]) => name !== self)
67
+ .map(([name, url]) => ({ name, url: url.replace(/\/$/, '') }));
68
+ const seen = new Map<string, Seen>();
69
+
70
+ async function key(): Promise<string> {
71
+ const value = await context.secret('lan.key');
72
+ if (!value) throw new Error('No shared key: run `orbital plugin secret set lan lan.key` on each machine.');
73
+ return value;
74
+ }
75
+
76
+ async function call<T>(peer: Peer, path: string, body?: unknown): Promise<T | null> {
77
+ const response = await fetch(`${peer.url}${path}`, {
78
+ method: body === undefined ? 'GET' : 'POST',
79
+ headers: { authorization: `Bearer ${await key()}`, 'content-type': 'application/json' },
80
+ body: body === undefined ? undefined : JSON.stringify(body),
81
+ signal: AbortSignal.timeout(10_000),
82
+ });
83
+ if (response.status === 404) return null;
84
+ if (!response.ok) throw new Error(`${peer.name} ${path}: ${response.status} ${await response.text()}`);
85
+ return (await response.json()) as T;
86
+ }
87
+
88
+ async function firstAnswer<T>(path: string, body: unknown): Promise<T | null> {
89
+ for (const peer of peers) {
90
+ const answer = await call<T>(peer, path, body).catch(() => null);
91
+ if (answer) return answer;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ async function poll(): Promise<void> {
97
+ for (const peer of peers) {
98
+ const hello = await call<Hello>(peer, '/hello').catch(() => null);
99
+ if (hello) seen.set(peer.name, { at: new Date().toISOString(), hello });
100
+ }
101
+ }
102
+
103
+ async function a2a(host: HostApi, body: string, response: ServerResponse): Promise<void> {
104
+ const rpc = JSON.parse(body) as { id: number | string; method: string; params: { id?: string } };
105
+ if (rpc.method !== 'SubscribeToTask') return send(response, 200, await host.a2a(body));
106
+ response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
107
+ let last = '';
108
+ while (!response.destroyed) {
109
+ const answer = (await host.a2a(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, method: 'GetTask', params: { id: rpc.params.id } }))) as { result?: { status?: { state: string } }; error?: unknown };
110
+ if (!answer.result) {
111
+ response.write(`data: ${JSON.stringify(answer)}\n\n`);
112
+ break;
113
+ }
114
+ const frame = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result: { task: answer.result } });
115
+ if (frame !== last) response.write(`data: ${frame}\n\n`);
116
+ last = frame;
117
+ if (TERMINAL.includes(answer.result.status?.state ?? '')) break;
118
+ await new Promise((done) => setTimeout(done, 1000));
119
+ }
120
+ response.end();
121
+ }
122
+
123
+ async function serve(host: HostApi, request: IncomingMessage, response: ServerResponse): Promise<void> {
124
+ if (request.headers.authorization !== `Bearer ${await key()}`) return send(response, 401, { error: 'The key does not match.' });
125
+ const path = new URL(request.url ?? '/', 'http://lan').pathname;
126
+ if (request.method === 'GET' && path === '/hello') {
127
+ const catalog = await host.catalog();
128
+ const agents = [...catalog.teams, ...catalog.agents].map(({ name, description }) => ({ name, description }));
129
+ return send(response, 200, { node: self, agents, missions: await host.missions() } satisfies Hello);
130
+ }
131
+ const body = await read(request);
132
+ if (path === '/a2a') return a2a(host, body, response);
133
+ if (path === '/missions/ask') return send(response, 200, { delivered: await host.askGiver(JSON.parse(body)) });
134
+ const relay = /^\/missions\/(join|say|reply|pause|resume)$/.exec(path);
135
+ if (!relay) return send(response, 404, { error: `No route ${path}.` });
136
+ const asked = JSON.parse(body);
137
+ if (!(await host.missions()).some((one) => one.missionId === asked.missionId)) return send(response, 404, { error: `No mission ${asked.missionId} here.` });
138
+ switch (relay[1]) {
139
+ case 'join':
140
+ return send(response, 200, await host.joinMission(asked));
141
+ case 'say':
142
+ return send(response, 200, await host.sayMission(asked));
143
+ case 'reply':
144
+ return send(response, 200, await host.replyMission(asked));
145
+ case 'pause':
146
+ return send(response, 200, await host.pauseMission(asked));
147
+ default:
148
+ return send(response, 200, await host.resumeMission(asked));
149
+ }
150
+ }
151
+
152
+ return {
153
+ name: NAME,
154
+ version: '0.1.0',
155
+ permissions: { network: peers.map((peer) => new URL(peer.url).hostname), secrets: ['lan.key'], models: [] },
156
+ ports: {
157
+ directory: {
158
+ agents: async () => [...seen.values()].flatMap(({ hello }) => hello.agents.map((agent) => ({ name: `${agent.name}@${hello.node}`, id: null, description: agent.description }))),
159
+ lookup: async () => null,
160
+ agent: async () => null,
161
+ skill: async () => null,
162
+ },
163
+ sessions: {
164
+ create: async (start) => {
165
+ const match = REMOTE_AGENT.exec(start.agent);
166
+ if (!match || !peers.some((peer) => peer.name === match[2])) return null;
167
+ return { kind: 'placement', placement: `lan:${match[2]}` };
168
+ },
169
+ },
170
+ placements: {
171
+ lan: {
172
+ decides: true,
173
+ endpoint: async (placement) => {
174
+ const name = placement.slice('lan:'.length);
175
+ const peer = peers.find((one) => one.name === name);
176
+ if (!peer) throw new Error(`No peer ${name}: add it under config.peers of the lan plugin.`);
177
+ return { url: `${peer.url}/a2a`, headers: { authorization: `Bearer ${await key()}` } };
178
+ },
179
+ },
180
+ },
181
+ missions: {
182
+ join: async (request) => firstAnswer<MissionRecord>('/missions/join', request),
183
+ say: async (request) => firstAnswer<Said>('/missions/say', request),
184
+ reply: async (request) => firstAnswer<MissionRecord>('/missions/reply', request),
185
+ pause: async (request) => firstAnswer<MissionRecord>('/missions/pause', request),
186
+ resume: async (request) => firstAnswer<MissionRecord>('/missions/resume', request),
187
+ ask: async (question) => {
188
+ const peer = peers.find((one) => one.name === question.giver.node);
189
+ if (!peer) return false;
190
+ return (await call<{ delivered: boolean }>(peer, '/missions/ask', question).catch(() => null))?.delivered === true;
191
+ },
192
+ },
193
+ panels: {
194
+ missions: {
195
+ title: 'missions of the other nodes',
196
+ kind: 'list',
197
+ read: async () => ({
198
+ columns: ['mission', 'node', 'team', 'state', 'lead', 'session', 'claim', 'claimedAt', 'question', 'result', 'updated', 'seen'],
199
+ rows: [...seen.values()].flatMap(({ at, hello }) => hello.missions.map((one) => ({
200
+ mission: one.missionId, node: one.node, team: one.team, state: one.state, lead: one.lead, session: one.sessionId,
201
+ claim: one.claim, claimedAt: one.claimedAt, question: one.question, result: one.result, updated: one.updatedAt, seen: at,
202
+ }))),
203
+ }),
204
+ },
205
+ peers: {
206
+ title: 'peers on the network',
207
+ kind: 'list',
208
+ read: async () => ({
209
+ columns: ['node', 'address', 'trust'],
210
+ rows: peers.map((peer) => ({ node: peer.name, address: peer.url, trust: seen.has(peer.name) ? 'shared key' : 'not seen' })),
211
+ }),
212
+ },
213
+ },
214
+ },
215
+ service: {
216
+ ends: false,
217
+ start: async ({ host, signal }) => {
218
+ const server = createServer((request, response) => {
219
+ serve(host, request, response).catch((error: unknown) => send(response, 500, { error: error instanceof Error ? error.message : String(error) }));
220
+ });
221
+ await new Promise<void>((done) => server.listen(port, '0.0.0.0', done));
222
+ await poll();
223
+ const timer = setInterval(() => void poll(), 10_000);
224
+ signal.addEventListener('abort', () => clearInterval(timer));
225
+ context.log('info', `${NAME} listens on port ${port} for ${peers.length} peers`);
226
+ return { stop: async () => { clearInterval(timer); server.close(); } };
227
+ },
228
+ },
229
+ };
230
+ }
231
+ ```
232
+
233
+ How it works:
234
+
235
+ - **Two sides.** Each method of a port is the near side. The HTTP route of the
236
+ same plugin on the other node is the far side, and it calls the host API of
237
+ its node. See the table in [ports.md](ports.md#missions).
238
+ - **Missions.** `join`, `say`, `reply`, `pause` and `resume` ask each peer. A peer
239
+ answers 404 when `host.missions()` does not hold the mission, and the next
240
+ peer is asked. `ask` goes to the peer that `question.giver.node` names.
241
+ - **Placements.** The node of the far agent never shows its own secret. The
242
+ plugin serves A2A at `/a2a` and passes each call to `host.a2a`. The host API
243
+ does not stream, so the plugin answers `SubscribeToTask` as a stream of
244
+ `GetTask` results until the task ends.
245
+ - **Directory.** The list shows `<agent>@<node>`. `lookup` and `agent` answer
246
+ null: such a session never runs here, because `sessions.create` sends it to
247
+ the placement. The far node takes the `@<node>` part off.
248
+ - **Panels.** The `missions` panel feeds the mission list and the claims of the
249
+ node. `seen` marks a row stale when the peer stops answering. The `peers`
250
+ panel feeds `orbital up`.
251
+
252
+ Keep `fixtures.json` as `{}`: `npm test` gives the plugin no config and no
253
+ secret, so the placement check skips. To run the placement check through the
254
+ real protocol, set the key and the `peers` config first, then give a second
255
+ fixtures file:
256
+
257
+ ```bash
258
+ echo '{ "placements": { "lan": "lan:build-box" } }' > lan-check.json
259
+ orbital plugin check lan --fixtures lan-check.json
260
+ ```
261
+
262
+ `plugin check` reads the config of the `config.yaml` entry and the secrets that
263
+ `orbital plugin secret set` keeps.
264
+
265
+ `config.yaml` on the machine `laptop`, with its node started as
266
+ `orbital node start --name laptop`:
267
+
268
+ ```yaml
269
+ plugins:
270
+ lan:
271
+ source: ./plugins/lan
272
+ config:
273
+ port: 7420
274
+ peers:
275
+ build-box: http://192.168.1.20:7420
276
+ ```
277
+
278
+ The machine `build-box` names `laptop` the same way. Keep the same key on
279
+ both:
280
+
281
+ ```bash
282
+ orbital plugin secret set lan lan.key
283
+ ```
284
+
285
+ ## 2. A telemetry dashboard sink
286
+
287
+ The plugin holds the graph of the node and posts it to a dashboard after each
288
+ change. It uses the snapshot once, then the events.
289
+
290
+ ```bash
291
+ orbital plugin new board --port telemetry
292
+ ```
293
+
294
+ The template already keeps the graph. Add the post at the end of `event`, and
295
+ declare the host:
296
+
297
+ ```ts
298
+ permissions: { network: ['board.example.com'], secrets: ['board.token'], models: [] },
299
+ ```
300
+
301
+ ```ts
302
+ async function post(body: unknown): Promise<void> {
303
+ const token = await context.secret('board.token');
304
+ if (!token) throw new Error('No token: run `orbital plugin secret set board board.token`.');
305
+ const response = await fetch('https://board.example.com/api/graph', {
306
+ method: 'POST',
307
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
308
+ body: JSON.stringify(body),
309
+ signal: AbortSignal.timeout(5000),
310
+ });
311
+ if (!response.ok) throw new Error(`The board answered ${response.status}.`);
312
+ }
313
+ ```
314
+
315
+ ```ts
316
+ event: async (event) => {
317
+ if (!graph) {
318
+ early.push(event);
319
+ return;
320
+ }
321
+ if (event.seq > graph.sequence + 1) {
322
+ graph = await context.host().snapshot();
323
+ await post(graph);
324
+ return;
325
+ }
326
+ apply(event);
327
+ await post(graph);
328
+ },
329
+ ```
330
+
331
+ - A `seq` more than one above the graph's `sequence` means that the node
332
+ dropped events for this sink. Take a new snapshot.
333
+ - The node never waits for the sink. A slow post only costs this sink its
334
+ oldest calls.
335
+ - To send each event instead of the whole graph, post `event` and let the
336
+ dashboard apply it.
337
+ - Watch the same data with `orbital telemetry watch` and
338
+ `orbital telemetry snapshot` while you test.
339
+
340
+ `config.yaml`:
341
+
342
+ ```yaml
343
+ plugins:
344
+ board:
345
+ source: ./plugins/board
346
+ telemetry: board
347
+ ```
348
+
349
+ ## 3. A Slack channel
350
+
351
+ The plugin posts messages to Slack and gives the node each message a person
352
+ writes in a channel that a binding names.
353
+
354
+ ```bash
355
+ orbital plugin new slack-lite --port channel
356
+ ```
357
+
358
+ ```ts
359
+ import type { ChannelBinding, ChannelEnvelope, PluginContext, PluginImplementation } from '@kaiba-cloud/orbital-sdk';
360
+
361
+ export const NAME = 'slack-lite';
362
+
363
+ export function plugin(context: PluginContext): PluginImplementation {
364
+ const sent = new Map<string, { ref: string; thread: string | null }>();
365
+ let bindings: ChannelBinding[] = [];
366
+
367
+ async function slack<T>(method: string, body: Record<string, unknown>): Promise<T> {
368
+ const token = await context.secret('slack.botToken');
369
+ if (!token) throw new Error('No token: run `orbital plugin secret set slack-lite slack.botToken`.');
370
+ const response = await fetch(`https://slack.com/api/${method}`, {
371
+ method: 'POST',
372
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' },
373
+ body: JSON.stringify(body),
374
+ });
375
+ const answer = (await response.json()) as T & { ok: boolean; error?: string };
376
+ if (!answer.ok) throw new Error(`Slack ${method}: ${answer.error ?? response.status}`);
377
+ return answer;
378
+ }
379
+
380
+ const text = (envelope: ChannelEnvelope) => envelope.message.parts.flatMap((part) => (part.text ? [part.text] : [])).join('\n');
381
+
382
+ return {
383
+ name: NAME,
384
+ version: '0.1.0',
385
+ permissions: { network: ['slack.com'], secrets: ['slack.botToken'], models: [] },
386
+ ports: {
387
+ channels: {
388
+ [NAME]: {
389
+ capabilities: { live: true, human: true, threads: true, durable: true, offline: false },
390
+ send: async (envelope, binding) => {
391
+ const known = sent.get(envelope.message.messageId);
392
+ if (known) return known;
393
+ const posted = await slack<{ ts: string }>('chat.postMessage', {
394
+ channel: String(binding.config.channel),
395
+ text: text(envelope),
396
+ ...(envelope.thread && { thread_ts: envelope.thread }),
397
+ });
398
+ const answer = { ref: posted.ts, thread: envelope.thread ?? posted.ts };
399
+ sent.set(envelope.message.messageId, answer);
400
+ return answer;
401
+ },
402
+ listen: async (given) => {
403
+ bindings = [...given];
404
+ },
405
+ },
406
+ },
407
+ },
408
+ service: {
409
+ ends: false,
410
+ start: async ({ host, signal }) => {
411
+ const oldest = new Map<string, string>();
412
+ const poll = async () => {
413
+ for (const binding of bindings) {
414
+ const channel = String(binding.config.channel);
415
+ const history = await slack<{ messages: Array<{ ts: string; user?: string; text: string; thread_ts?: string; bot_id?: string }> }>('conversations.history', { channel, oldest: oldest.get(channel) ?? String(Date.now() / 1000) });
416
+ for (const message of history.messages.reverse()) {
417
+ oldest.set(channel, message.ts);
418
+ if (message.bot_id || !message.user) continue;
419
+ const answer = await host.deliver({
420
+ channel: NAME,
421
+ bindingId: binding.id,
422
+ envelope: {
423
+ message: { messageId: `slack-${channel}-${message.ts}`, role: 'ROLE_USER', parts: [{ text: message.text }] },
424
+ from: { kind: 'person', name: message.user },
425
+ to: binding.team ? { kind: 'team', name: binding.team } : { kind: 'node', name: binding.ade },
426
+ thread: message.thread_ts ?? message.ts,
427
+ },
428
+ });
429
+ if (!answer.accepted && !answer.duplicate) context.log('warn', `not taken: ${answer.reason}`);
430
+ }
431
+ }
432
+ };
433
+ const timer = setInterval(() => void poll().catch((error: unknown) => context.log('error', String(error))), 5000);
434
+ signal.addEventListener('abort', () => clearInterval(timer));
435
+ return { stop: async () => clearInterval(timer) };
436
+ },
437
+ },
438
+ };
439
+ }
440
+ ```
441
+
442
+ - The message id `slack-<channel>-<ts>` is stable, so the node drops a second
443
+ delivery of the same message.
444
+ - An answer goes to the thread of its question: `thread` carries the `ts` of
445
+ the first message.
446
+ - This recipe polls. For pushes, use Socket Mode with the `ws` package, as
447
+ `plugins/channel-slack` in the source repository does. `ws` is a CommonJS
448
+ package: the template's build banner gives it a `require`.
449
+
450
+ A team uses it in `teams/<team>/channels.yaml`:
451
+
452
+ ```yaml
453
+ - type: slack-lite
454
+ channel: C0123456789
455
+ ```
456
+
457
+ Fixture for the send check, with a real test channel:
458
+
459
+ ```json
460
+ { "channels": { "slack-lite": { "id": "node:0", "channel": "slack-lite", "config": { "channel": "C0123456789" }, "team": null, "ade": "conformance" } } }
461
+ ```
462
+
463
+ ## 4. A policy engine
464
+
465
+ The plugin reads rules from its folder in the definition, such as
466
+ `.atom/guard/rules.json`, and asks a model only when no rule matches.
467
+
468
+ ```bash
469
+ orbital plugin new guard --port policy
470
+ ```
471
+
472
+ ```ts
473
+ import { existsSync, readFileSync } from 'node:fs';
474
+ import { join } from 'node:path';
475
+ import type { PluginContext, PluginImplementation, PolicyAction, PolicyDecision } from '@kaiba-cloud/orbital-sdk';
476
+
477
+ export const NAME = 'guard';
478
+
479
+ interface Rule { tool: string; contains: string; verdict: 'deny' | 'ask'; reason: string }
480
+
481
+ export function plugin(context: PluginContext): PluginImplementation {
482
+ const file = context.init.folder ? join(context.init.folder, 'rules.json') : null;
483
+ const rules: Rule[] = file && existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as Rule[]) : [{ tool: 'shell', contains: 'git push', verdict: 'deny', reason: 'Pushes go through review.' }];
484
+ const judge = typeof context.init.config.judge === 'string' ? context.init.config.judge : null;
485
+
486
+ async function byModel(action: PolicyAction): Promise<PolicyDecision> {
487
+ if (!judge) return { verdict: 'allow' };
488
+ try {
489
+ const { text } = await context.host().completeModel({
490
+ model: judge,
491
+ system: 'You guard a coding agent. Answer DENY: <reason>, or OK.',
492
+ messages: [{ role: 'user', content: JSON.stringify(action) }],
493
+ maxTokens: 200,
494
+ });
495
+ return text.startsWith('DENY') ? { verdict: 'deny', reason: text.slice(5).trim() || 'The judge refused it.', policy: NAME } : { verdict: 'allow' };
496
+ } catch (error) {
497
+ return { verdict: 'ask', question: `The judge failed (${error instanceof Error ? error.message : String(error)}). Allow ${action.kind}?`, policy: NAME };
498
+ }
499
+ }
500
+
501
+ return {
502
+ name: NAME,
503
+ version: '0.1.0',
504
+ permissions: { network: [], secrets: [], models: judge ? [`${judge.slice(0, judge.indexOf(':'))}:*`] : [] },
505
+ ports: {
506
+ policy: {
507
+ deterministic: judge === null,
508
+ decide: async (action) => {
509
+ if (action.kind !== 'tool.call') return { verdict: 'allow' };
510
+ const rule = rules.find((one) => one.tool === action.tool && action.target.includes(one.contains));
511
+ if (rule?.verdict === 'deny') return { verdict: 'deny', reason: rule.reason, policy: NAME };
512
+ if (rule?.verdict === 'ask') return { verdict: 'ask', question: rule.reason, policy: NAME };
513
+ return byModel(action);
514
+ },
515
+ },
516
+ },
517
+ };
518
+ }
519
+ ```
520
+
521
+ - With `judge` set, the engine is not deterministic, so the node reads its
522
+ `allow` as no answer. Only `deny` and `ask` count.
523
+ - When the model fails, answer `ask`, never `allow`.
524
+ - `permissions.models` must match the judge model, or `completeModel` fails.
525
+ - A rule file is optional. Without it, the plugin uses its default rule.
526
+
527
+ `config.yaml`:
528
+
529
+ ```yaml
530
+ plugins:
531
+ guard:
532
+ source: ./plugins/guard
533
+ config:
534
+ judge: anthropic:claude-haiku-4-5
535
+ policy: [orbital, guard]
536
+ ```
537
+
538
+ ## 5. A team member on your own harness
539
+
540
+ A harness plugin runs a team member on another agent program, the same way
541
+ `claude` and `pi` run. Checked end to end with a lead on `claude` that
542
+ dispatches a member on the harness `scripted`.
543
+
544
+ 1. Write the plugin, and add your program to `src/turn.ts`:
545
+
546
+ ```bash
547
+ orbital plugin new scripted --port harness --dir plugins/scripted
548
+ cd plugins/scripted && npm install && npm test
549
+ ```
550
+
551
+ The template writes thinking, one tool call with its output, the answer, the
552
+ tokens and the result, and says `callsModel: false`.
553
+
554
+ 2. Name the plugin in `config.yaml`. A relative source is relative to the
555
+ definition folder:
556
+
557
+ ```yaml
558
+ plugins:
559
+ scripted:
560
+ source: ../plugins/scripted
561
+ ```
562
+
563
+ 3. Give a member the harness in its charter:
564
+
565
+ ```yaml
566
+ lead: lead
567
+ members:
568
+ - name: scout
569
+ role: Looks around the workspace.
570
+ harness: scripted
571
+ ```
572
+
573
+ 4. Start the node, and give the team a mission whose lead dispatches `scout`.
574
+
575
+ What you see:
576
+
577
+ - `orbital up` lists `scout` under the team with `· scripted` on each session, and enter opens its transcript: the thinking, `ls(…)` with its output, and the answer, live while the turn runs.
578
+ - `orbital telemetry snapshot` holds the tokens of the model `example:scripted` for the mission, and the events hold a `job.dispatched` and a `job.ended` for each dispatch.
579
+ - A second message to the same job starts a turn with `spec.resume` true.
580
+ - `orbital mission pause` stops a running turn of the member (SIGTERM), and `orbital mission resume` lets the lead go on.
581
+
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.