esoul-sdk 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/api-reference.md +1172 -10
  2. package/bin/esoul-device-exec.mjs +99 -0
  3. package/dist/device.d.ts +136 -0
  4. package/dist/device.js +15 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +2 -0
  7. package/dist/machine/capabilities.d.ts +81 -0
  8. package/dist/machine/capabilities.js +211 -0
  9. package/dist/machine/cli.d.ts +2 -0
  10. package/dist/machine/cli.js +232 -0
  11. package/dist/machine/client.d.ts +46 -0
  12. package/dist/machine/client.js +91 -0
  13. package/dist/machine/commands.d.ts +136 -0
  14. package/dist/machine/commands.js +229 -0
  15. package/dist/machine/digest.d.ts +14 -0
  16. package/dist/machine/digest.js +42 -0
  17. package/dist/machine/files.d.ts +188 -0
  18. package/dist/machine/files.js +511 -0
  19. package/dist/machine/grant.d.ts +49 -0
  20. package/dist/machine/grant.js +69 -0
  21. package/dist/machine/index.d.ts +19 -0
  22. package/dist/machine/index.js +19 -0
  23. package/dist/machine/protocol.d.ts +52 -0
  24. package/dist/machine/protocol.js +88 -0
  25. package/dist/machine/runner.d.ts +43 -0
  26. package/dist/machine/runner.js +255 -0
  27. package/dist/machine/service.d.ts +30 -0
  28. package/dist/machine/service.js +189 -0
  29. package/dist/machine/shell.d.ts +65 -0
  30. package/dist/machine/shell.js +474 -0
  31. package/dist/machine/state.d.ts +50 -0
  32. package/dist/machine/state.js +96 -0
  33. package/dist/machine/supervisor.d.ts +54 -0
  34. package/dist/machine/supervisor.js +259 -0
  35. package/dist/machine/wake.d.ts +31 -0
  36. package/dist/machine/wake.js +105 -0
  37. package/dist/manifest.d.ts +444 -0
  38. package/dist/manifest.js +51 -0
  39. package/dist/react.d.ts +104 -0
  40. package/dist/react.js +36 -0
  41. package/dist/server.d.ts +28 -0
  42. package/dist/server.js +20 -0
  43. package/docs/05-ui.md +37 -0
  44. package/docs/18-your-computer.md +235 -0
  45. package/llms-full.txt +276 -0
  46. package/llms.txt +1 -0
  47. package/package.json +10 -4
  48. package/schemas/plugin.schema.json +242 -0
  49. package/scripts/build-api-reference.mjs +1 -0
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // The process that OWNS one run. The runtime starts it detached, so it — and
3
+ // the command under it — outlive a runtime restart or upgrade; the runtime
4
+ // reattaches by reading the files it leaves in the run directory.
5
+ //
6
+ // meta.json (in) { argv, cwd, env, timeoutSeconds }
7
+ // out.log (out) stdout and stderr, interleaved, appended as they come
8
+ // pid.json (out) { wrapperPid, childPid, startedAt, bootId }
9
+ // exit.json (out) { exitCode, signal, reason, startedAt, endedAt }
10
+ // cancel (in) present = stop: SIGTERM the command's group, SIGKILL after 10 s
11
+ //
12
+ // Plain JavaScript with no imports beyond node: — it must run from any copy
13
+ // of the package, including the one the service runs, with nothing built.
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync, openSync, readFileSync, writeFileSync, renameSync, watch } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ const dir = process.argv[2];
19
+ if (!dir) {
20
+ console.error("usage: esoul-device-exec <run-dir>");
21
+ process.exit(2);
22
+ }
23
+ const meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8"));
24
+ const write = (name, value) => {
25
+ const p = join(dir, name);
26
+ writeFileSync(`${p}.tmp`, JSON.stringify(value));
27
+ renameSync(`${p}.tmp`, p);
28
+ };
29
+ const bootId = () => {
30
+ try {
31
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const out = openSync(join(dir, "out.log"), "a");
38
+ const startedAt = Date.now();
39
+ let child;
40
+ try {
41
+ child = spawn(meta.argv[0], meta.argv.slice(1), {
42
+ cwd: meta.cwd || undefined,
43
+ env: meta.env,
44
+ stdio: ["ignore", out, out],
45
+ detached: true, // its own process group, so a stop reaches grandchildren too
46
+ });
47
+ } catch (e) {
48
+ write("exit.json", { exitCode: null, signal: null, reason: `could not start: ${e.message}`, startedAt, endedAt: Date.now() });
49
+ process.exit(0);
50
+ }
51
+
52
+ let reason = null;
53
+ let finished = false;
54
+ child.on("error", (e) => {
55
+ if (finished) return;
56
+ finished = true;
57
+ write("exit.json", { exitCode: null, signal: null, reason: e.code === "ENOENT" ? `program not found: ${meta.argv[0]}` : `could not start: ${e.message}`, startedAt, endedAt: Date.now() });
58
+ process.exit(0);
59
+ });
60
+ child.on("spawn", () => write("pid.json", { wrapperPid: process.pid, childPid: child.pid, startedAt, bootId: bootId() }));
61
+
62
+ const stop = (why) => {
63
+ if (reason || finished) return;
64
+ reason = why;
65
+ try {
66
+ process.kill(-child.pid, "SIGTERM");
67
+ } catch {}
68
+ setTimeout(() => {
69
+ try {
70
+ process.kill(-child.pid, "SIGKILL");
71
+ } catch {}
72
+ }, 10_000).unref();
73
+ };
74
+
75
+ if (meta.timeoutSeconds > 0) setTimeout(() => stop(`timed out after ${meta.timeoutSeconds} s`), meta.timeoutSeconds * 1000).unref();
76
+ const cancelFile = join(dir, "cancel");
77
+ // The cancel file's text is the reason ("cancelled", or "cancelled: <why>").
78
+ const checkCancel = () => {
79
+ if (!existsSync(cancelFile)) return;
80
+ let why = "cancelled";
81
+ try {
82
+ why = readFileSync(cancelFile, "utf8").trim() || why;
83
+ } catch {}
84
+ stop(why);
85
+ };
86
+ checkCancel();
87
+ try {
88
+ watch(dir, () => checkCancel()).unref();
89
+ } catch {}
90
+ const poll = setInterval(checkCancel, 1000);
91
+ poll.unref();
92
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(`runtime sent ${sig}`));
93
+
94
+ child.on("exit", (code, signal) => {
95
+ if (finished) return;
96
+ finished = true;
97
+ write("exit.json", { exitCode: code, signal, reason, startedAt, endedAt: Date.now() });
98
+ process.exit(0);
99
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Types for `devices(ctx)` — parts of an app that run on the person's own
3
+ * computer (plugin-device-arm.md §10–§11). The functions live in server.ts
4
+ * (host-only); the hooks and <ConnectComputer/> in react.ts.
5
+ *
6
+ * The flow an app is built around:
7
+ * 1. its config page renders <ConnectComputer workspaceId nodeId /> — one
8
+ * command to paste, a code to check, an Approve button;
9
+ * 2. its server code runs a DECLARED command (plugin.json `device.commands`):
10
+ * const d = devices(ctx);
11
+ * const { commandId } = await d.run("train", { epochs: 3 });
12
+ * 3. its UI shows the run live with useDeviceRun({ workspaceId, nodeId, commandId }).
13
+ */
14
+ export type DeviceRunStatus = "queued" | "claimed" | "running" | "done" | "expired" | "cancelled" | "lost";
15
+ /** What an app's agents may reach on a computer through the shared command line. */
16
+ export type WorkspaceGrant = {
17
+ scope: "folder";
18
+ root: string;
19
+ } | {
20
+ scope: "computer";
21
+ };
22
+ /**
23
+ * One line of the shared command line, answered (esoul-sdk/machine shell.ts):
24
+ * `text` is what a terminal would print (what an agent reads), `data` the same
25
+ * answer structured, `opId` the journal entry to `undo`.
26
+ */
27
+ export interface ShellResult {
28
+ ok: boolean;
29
+ text: string;
30
+ data?: unknown;
31
+ exitCode?: number | null;
32
+ error?: string;
33
+ opId?: string;
34
+ cwd: string;
35
+ }
36
+ /** One computer connected to this app instance. */
37
+ export interface DeviceSummary {
38
+ linkId: string;
39
+ status: "active" | "suspended";
40
+ reason: string | null;
41
+ /** The declared commands this computer will run (the ones the owner approved). */
42
+ commands: string[];
43
+ /** Commands an app update added or changed since approval; they wait for the owner. */
44
+ pendingApproval: {
45
+ added: string[];
46
+ changed: string[];
47
+ };
48
+ machine: {
49
+ machineId: string;
50
+ hostname: string;
51
+ os: string;
52
+ arch: string | null;
53
+ runtimeVersion: string | null;
54
+ lastSeenAt: number | null;
55
+ online: boolean;
56
+ } | null;
57
+ /** What this app's agents may reach on that computer (null = declared commands only). */
58
+ workspace?: WorkspaceGrant | null;
59
+ }
60
+ export interface DeviceRunStarted {
61
+ commandId: string;
62
+ status: DeviceRunStatus;
63
+ /** Whether the computer answered in the last minute. A run for an offline computer waits (until `expiresAt`). */
64
+ machineOnline: boolean;
65
+ expiresAt: number;
66
+ }
67
+ export interface DeviceRunView {
68
+ commandId: string;
69
+ linkId: string;
70
+ machineId: string;
71
+ /** "commands" (a declared command) or "workspace" (the shared command line). */
72
+ capability?: string;
73
+ /** The named agent, for workspace jobs. */
74
+ agent?: string | null;
75
+ name: string;
76
+ params: Record<string, unknown>;
77
+ argv: string[];
78
+ status: DeviceRunStatus;
79
+ exitCode: number | null;
80
+ signal: string | null;
81
+ /** Why it ended when it did not simply exit: "machine_restarted", "cancelled: …", "timed out after …", a refusal. */
82
+ reason: string | null;
83
+ /** The first 1 MB of output (stdout and stderr interleaved). */
84
+ output?: string;
85
+ /** When `truncated` (or the command keeps only the tail), the last 64 KB. */
86
+ tail?: string;
87
+ /** The command's parsed answer, when it declared `result: "json"` and ended well. */
88
+ result?: unknown;
89
+ /** Why `result` is missing: not JSON, or larger than the stored head. */
90
+ resultError?: string | null;
91
+ /** How its output travelled: live, batched or final (the command's `output.mode`). */
92
+ outputMode?: "live" | "batched" | "final";
93
+ outputBytes: number;
94
+ outputSeq: number;
95
+ truncated: boolean;
96
+ createdAt: number;
97
+ startedAt: number | null;
98
+ endedAt: number | null;
99
+ expiresAt: number;
100
+ }
101
+ export interface DeviceRunOptions {
102
+ /** Which computer. Optional when exactly one active computer is connected. */
103
+ linkId?: string;
104
+ /** How long the run may wait for an offline computer before it expires. Default 600 s. */
105
+ expiresInSeconds?: number;
106
+ }
107
+ export interface Devices {
108
+ list(): Promise<DeviceSummary[]>;
109
+ run(name: string, params?: Record<string, unknown>, opts?: DeviceRunOptions): Promise<DeviceRunStarted>;
110
+ get(commandId: string): Promise<DeviceRunView>;
111
+ /** Wait for a run to end, up to `timeoutSeconds` (≤ 55 in an op; use a task for longer). Returns the run as it is then. */
112
+ wait(commandId: string, opts?: {
113
+ timeoutSeconds?: number;
114
+ }): Promise<DeviceRunView>;
115
+ cancel(commandId: string): Promise<{
116
+ status: string;
117
+ }>;
118
+ /**
119
+ * One line of the shared command line on the computer, as a NAMED agent —
120
+ * `ls`, `tree`, `cat -n f --lines 10:40`, `write f` / `edit f` / `patch f`
121
+ * (content on `stdin`), `run npm test`, `term new dev npm run dev`, `undo <op>`
122
+ * (`help` lists them). Needs a workspace grant (a folder or the computer).
123
+ * Waits up to `waitSeconds` (default 30, ≤ 55) and returns the answer.
124
+ */
125
+ sh(line: string, opts: {
126
+ agent: string;
127
+ stdin?: string;
128
+ linkId?: string;
129
+ waitSeconds?: number;
130
+ }): Promise<ShellResult & {
131
+ commandId: string;
132
+ status: DeviceRunStatus;
133
+ }>;
134
+ }
135
+ /** The statuses a run ends in: done, expired, cancelled, lost. */
136
+ export declare const DEVICE_RUN_TERMINAL: readonly DeviceRunStatus[];
package/dist/device.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Types for `devices(ctx)` — parts of an app that run on the person's own
3
+ * computer (plugin-device-arm.md §10–§11). The functions live in server.ts
4
+ * (host-only); the hooks and <ConnectComputer/> in react.ts.
5
+ *
6
+ * The flow an app is built around:
7
+ * 1. its config page renders <ConnectComputer workspaceId nodeId /> — one
8
+ * command to paste, a code to check, an Approve button;
9
+ * 2. its server code runs a DECLARED command (plugin.json `device.commands`):
10
+ * const d = devices(ctx);
11
+ * const { commandId } = await d.run("train", { epochs: 3 });
12
+ * 3. its UI shows the run live with useDeviceRun({ workspaceId, nodeId, commandId }).
13
+ */
14
+ /** The statuses a run ends in: done, expired, cancelled, lost. */
15
+ export const DEVICE_RUN_TERMINAL = ["done", "expired", "cancelled", "lost"];
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export * from "./roles.js";
14
14
  * `opTool` in app.tsx — a tool's parameters cannot drift from what its op takes.
15
15
  */
16
16
  export * from "./ops.js";
17
+ export { deviceBlockProblems, renderCommand, DeviceCommandError, COMMAND_NAME_RE, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS } from "./machine/commands.js";
18
+ export type { DeviceBlock, CommandSpec, ParamSpec, ConfigKeySpec, RenderedCommand } from "./machine/commands.js";
17
19
  /** A chart from data — one SVG for the app's screen and, rendered, for a tool's answer. */
18
20
  export * from "./chart.js";
19
21
  /**
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ export * from "./roles.js";
14
14
  * `opTool` in app.tsx — a tool's parameters cannot drift from what its op takes.
15
15
  */
16
16
  export * from "./ops.js";
17
+ // The device arm's command rules — check a plugin.json `device` block in your own tests (docs/18).
18
+ export { deviceBlockProblems, renderCommand, DeviceCommandError, COMMAND_NAME_RE, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS } from "./machine/commands.js";
17
19
  /** A chart from data — one SVG for the app's screen and, rendered, for a tool's answer. */
18
20
  export * from "./chart.js";
19
21
  /**
@@ -0,0 +1,81 @@
1
+ import type { DoorClient } from "./client.js";
2
+ import { type ResolvedOutput } from "./commands.js";
3
+ import { type WorkspaceGrant } from "./grant.js";
4
+ import { type ClaimedCommand } from "./runner.js";
5
+ import { type LocalLink } from "./state.js";
6
+ export interface Job extends ClaimedCommand {
7
+ capability: string;
8
+ agent: string | null;
9
+ }
10
+ export interface JobOutcome {
11
+ exitCode: number | null;
12
+ reason?: string | null;
13
+ signal?: string | null;
14
+ /** A structured answer (the workspace's ShellResult, …). */
15
+ result?: unknown;
16
+ }
17
+ export interface JobContext {
18
+ home: string;
19
+ link: LocalLink;
20
+ /** The computer's own ceiling for workspace grants. */
21
+ ceiling: WorkspaceGrant | null;
22
+ client: DoorClient;
23
+ signal: AbortSignal;
24
+ log: (s: string) => void;
25
+ }
26
+ export interface Capability {
27
+ name: string;
28
+ version: number;
29
+ /** Why this job may NOT run here, or null. Checked before anything starts. */
30
+ refuse(job: Job, ctx: JobContext): string | null;
31
+ /** Start the job; resolves when the capability has taken responsibility for reporting it. */
32
+ start(job: Job, ctx: JobContext): Promise<void>;
33
+ }
34
+ /**
35
+ * Ordered, batched, retried upload of one job's output (the run directory is
36
+ * the outbox for detached runs; inline jobs hold theirs here). `finish` drains
37
+ * the output, then reports the end with the result.
38
+ */
39
+ export declare class OutputUploader {
40
+ private readonly client;
41
+ private readonly commandId;
42
+ private readonly output;
43
+ private readonly onCancel;
44
+ private buf;
45
+ private seq;
46
+ private flushing;
47
+ private timer;
48
+ constructor(client: DoorClient, commandId: string, output: ResolvedOutput, onCancel: () => void);
49
+ write(chunk: string): void;
50
+ private flush;
51
+ finish(outcome: JobOutcome): Promise<void>;
52
+ }
53
+ declare function markDone(home: string, id: string): void;
54
+ /** Inline jobs a previous runtime started but never reported. */
55
+ export declare function strandedInlineJobs(home: string): string[];
56
+ export declare const markInlineReported: typeof markDone;
57
+ /** The app's declared commands; the detached runner owns and reports each run. */
58
+ export declare function commandsCapability(deps: {
59
+ startRun: (job: Job, redact: string[], env: Record<string, string>) => Promise<void>;
60
+ }): Capability;
61
+ export interface WorkspaceJobParams {
62
+ line: string;
63
+ stdin?: string;
64
+ }
65
+ /** The shared command line, under grant ∩ ceiling, one working directory per named agent. */
66
+ export declare function workspaceCapability(): Capability;
67
+ /** The capability table the runtime dispatches jobs through, by name. */
68
+ export declare function registry(caps: Capability[]): Map<string, Capability>;
69
+ /** What the hello announces: ["commands@1", "workspace@1"]. */
70
+ export declare function announce(reg: Map<string, Capability>): string[];
71
+ /** The capabilities this runtime is built with, as one registry (the supervisor dispatches through it). */
72
+ export declare function builtInCapabilities(startRun: (job: Job, redact: string[], env: Record<string, string>) => Promise<void>): Map<string, Capability>;
73
+ /**
74
+ * What this runtime can do, said at CONNECT as well as in every hello, so the
75
+ * platform knows it from the approval on: work asked for before the runtime's
76
+ * first hello waits in the queue instead of being refused.
77
+ */
78
+ export declare const RUNTIME_CAPABILITIES: string[];
79
+ /** Read the computer's own ceiling from its state. */
80
+ export declare function localCeiling(home: string): WorkspaceGrant | null;
81
+ export {};
@@ -0,0 +1,211 @@
1
+ /**
2
+ * CAPABILITIES — the unit the machine side grows by (plugin-device-arm.md §15).
3
+ *
4
+ * Everything the platform asks of a computer is a JOB for one capability:
5
+ *
6
+ * commands@1 a declared command from the app's plugin.json (argv, typed params)
7
+ * workspace@1 the shared command line in a folder or the whole computer
8
+ * (later) messages, programs, … — each one module registered here
9
+ *
10
+ * A capability says whether a job may run on this link (from the runtime's OWN
11
+ * copy of the grants and the computer's ceiling — never the platform's word
12
+ * alone), and runs it. Output streams through one ordered uploader; the end is
13
+ * one report with an optional structured result. The runtime announces the
14
+ * registry in its hello, so the platform never queues work an older runtime
15
+ * cannot do.
16
+ */
17
+ import { mkdirSync, readdirSync, writeFileSync, existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { renderCommand, resolveOutput, DeviceCommandError } from "./commands.js";
20
+ import { effectiveGrant, parseGrant } from "./grant.js";
21
+ import { commandEnv } from "./runner.js";
22
+ import { execute, openWorkspace, AGENT_NAME_RE } from "./shell.js";
23
+ import { loadConfig, readJson } from "./state.js";
24
+ /**
25
+ * Ordered, batched, retried upload of one job's output (the run directory is
26
+ * the outbox for detached runs; inline jobs hold theirs here). `finish` drains
27
+ * the output, then reports the end with the result.
28
+ */
29
+ export class OutputUploader {
30
+ client;
31
+ commandId;
32
+ output;
33
+ onCancel;
34
+ buf = "";
35
+ seq = 0;
36
+ flushing = null;
37
+ timer = null;
38
+ constructor(client, commandId, output, onCancel) {
39
+ this.client = client;
40
+ this.commandId = commandId;
41
+ this.output = output;
42
+ this.onCancel = onCancel;
43
+ }
44
+ write(chunk) {
45
+ if (this.output.mode === "final" || !chunk) {
46
+ this.buf += chunk;
47
+ return;
48
+ }
49
+ this.buf += chunk;
50
+ if (this.buf.length >= this.output.maxChunkBytes)
51
+ void this.flush();
52
+ else if (!this.timer)
53
+ this.timer = setTimeout(() => void this.flush(), this.output.mode === "batched" ? this.output.flushMs : 50);
54
+ }
55
+ async flush() {
56
+ if (this.timer) {
57
+ clearTimeout(this.timer);
58
+ this.timer = null;
59
+ }
60
+ while (this.flushing)
61
+ await this.flushing;
62
+ if (!this.buf)
63
+ return;
64
+ const run = async () => {
65
+ while (this.buf) {
66
+ const data = this.buf.slice(0, this.output.maxChunkBytes);
67
+ const seq = this.seq + 1;
68
+ for (let attempt = 0;; attempt++) {
69
+ try {
70
+ const r = await this.client.call("POST", `/api/device/m/commands/${this.commandId}/output`, { seq, data });
71
+ if (r.cancel)
72
+ this.onCancel();
73
+ break;
74
+ }
75
+ catch (e) {
76
+ const b = e.body;
77
+ if (e.status === 409 && typeof b?.expectedSeq === "number") {
78
+ this.seq = b.expectedSeq - 1;
79
+ if (b.expectedSeq > seq)
80
+ break; // the door already has it
81
+ continue;
82
+ }
83
+ if (attempt >= 8)
84
+ throw e;
85
+ await new Promise((r) => setTimeout(r, Math.min(500 * 2 ** attempt, 15_000)));
86
+ }
87
+ }
88
+ this.seq = seq;
89
+ this.buf = this.buf.slice(data.length);
90
+ }
91
+ };
92
+ this.flushing = run().finally(() => (this.flushing = null));
93
+ await this.flushing;
94
+ }
95
+ async finish(outcome) {
96
+ await this.flush();
97
+ await this.client.call("POST", `/api/device/m/commands/${this.commandId}/done`, { exitCode: outcome.exitCode, signal: outcome.signal ?? null, reason: outcome.reason ?? null, result: outcome.result ?? undefined });
98
+ }
99
+ }
100
+ /** Inline jobs leave a marker while they run, so a runtime that died mid-job reports it lost instead of leaving it "running". */
101
+ function markStart(home, id) {
102
+ const d = join(home, "runs", id);
103
+ mkdirSync(d, { recursive: true, mode: 0o700 });
104
+ writeFileSync(join(d, "inline.json"), JSON.stringify({ at: Date.now() }));
105
+ }
106
+ function markDone(home, id) {
107
+ writeFileSync(join(home, "runs", id, "reported"), String(Date.now()));
108
+ }
109
+ /** Inline jobs a previous runtime started but never reported. */
110
+ export function strandedInlineJobs(home) {
111
+ const d = join(home, "runs");
112
+ if (!existsSync(d))
113
+ return [];
114
+ return readdirSync(d).filter((id) => existsSync(join(d, id, "inline.json")) && !existsSync(join(d, id, "reported")));
115
+ }
116
+ export const markInlineReported = markDone;
117
+ // ─────────────────────────────── commands@1 ───────────────────────────────
118
+ /** The app's declared commands; the detached runner owns and reports each run. */
119
+ export function commandsCapability(deps) {
120
+ return {
121
+ name: "commands",
122
+ version: 1,
123
+ refuse(job, ctx) {
124
+ const block = ctx.link.commands;
125
+ if (!block)
126
+ return "this computer holds no approved commands for that app";
127
+ let argv;
128
+ try {
129
+ argv = renderCommand(block, job.name, job.params).argv;
130
+ }
131
+ catch (e) {
132
+ return e instanceof DeviceCommandError ? e.message : String(e);
133
+ }
134
+ if (JSON.stringify(argv) !== JSON.stringify(job.argv))
135
+ return "the platform's command differs from the approved one";
136
+ return null;
137
+ },
138
+ async start(job, ctx) {
139
+ const block = ctx.link.commands;
140
+ const spec = block.commands[job.name];
141
+ const env = commandEnv(process.env, spec.env ?? {}, loadConfig(ctx.home, ctx.link.linkId), spec.config ?? []);
142
+ await deps.startRun({ ...job, cwd: spec.cwd ?? null, timeoutSeconds: spec.timeoutSeconds ?? 3600, onRestart: spec.onRestart ?? "report", output: resolveOutput(spec.output) }, block.redact ?? [], env);
143
+ },
144
+ };
145
+ }
146
+ /** The shared command line, under grant ∩ ceiling, one working directory per named agent. */
147
+ export function workspaceCapability() {
148
+ return {
149
+ name: "workspace",
150
+ version: 1,
151
+ refuse(job, ctx) {
152
+ if (!job.agent || !AGENT_NAME_RE.test(job.agent))
153
+ return "a workspace job needs a named agent";
154
+ const eff = effectiveGrant(parseGrant(ctx.link.grants?.workspace), ctx.ceiling);
155
+ return eff.ok ? null : eff.why;
156
+ },
157
+ async start(job, ctx) {
158
+ const eff = effectiveGrant(parseGrant(ctx.link.grants?.workspace), ctx.ceiling);
159
+ if (!eff.ok)
160
+ throw new Error(eff.why);
161
+ const scope = eff.grant.scope === "computer" ? { kind: "computer" } : { kind: "folder", root: eff.grant.root };
162
+ const ws = openWorkspace(ctx.home, ctx.link.linkId, scope);
163
+ const params = job.params;
164
+ markStart(ctx.home, job.commandId);
165
+ const ac = new AbortController();
166
+ ctx.signal.addEventListener("abort", () => ac.abort());
167
+ const up = new OutputUploader(ctx.client, job.commandId, job.output ?? resolveOutput({ mode: "live" }), () => ac.abort());
168
+ await ctx.client.call("POST", `/api/device/m/commands/${job.commandId}/started`, { startedAt: Date.now() }).catch(() => { });
169
+ // Run without holding the claim loop: agents work in parallel.
170
+ void (async () => {
171
+ let outcome;
172
+ try {
173
+ const r = await execute({ ws, agent: job.agent, line: String(params.line ?? ""), stdin: params.stdin, onOutput: (c) => up.write(c), signal: ac.signal });
174
+ outcome = { exitCode: r.exitCode ?? (r.ok ? 0 : 1), reason: r.ok ? null : r.error ?? "failed", result: r };
175
+ }
176
+ catch (e) {
177
+ outcome = { exitCode: null, reason: `failed on the computer: ${e.message}` };
178
+ }
179
+ try {
180
+ await up.finish(outcome);
181
+ markDone(ctx.home, job.commandId);
182
+ }
183
+ catch (e) {
184
+ ctx.log(`workspace job ${job.commandId}: could not report (${e.message}); it will be reported lost on restart`);
185
+ }
186
+ })();
187
+ },
188
+ };
189
+ }
190
+ /** The capability table the runtime dispatches jobs through, by name. */
191
+ export function registry(caps) {
192
+ return new Map(caps.map((c) => [c.name, c]));
193
+ }
194
+ /** What the hello announces: ["commands@1", "workspace@1"]. */
195
+ export function announce(reg) {
196
+ return [...reg.values()].map((c) => `${c.name}@${c.version}`);
197
+ }
198
+ /** The capabilities this runtime is built with, as one registry (the supervisor dispatches through it). */
199
+ export function builtInCapabilities(startRun) {
200
+ return registry([commandsCapability({ startRun }), workspaceCapability()]);
201
+ }
202
+ /**
203
+ * What this runtime can do, said at CONNECT as well as in every hello, so the
204
+ * platform knows it from the approval on: work asked for before the runtime's
205
+ * first hello waits in the queue instead of being refused.
206
+ */
207
+ export const RUNTIME_CAPABILITIES = announce(builtInCapabilities(async () => { }));
208
+ /** Read the computer's own ceiling from its state. */
209
+ export function localCeiling(home) {
210
+ return parseGrant(readJson(join(home, "state.json"))?.ceiling ?? null);
211
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};