esoul-sdk 0.7.0 → 0.16.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 (67) hide show
  1. package/README.md +575 -153
  2. package/api-reference.md +3291 -0
  3. package/dist/assets.d.ts +72 -0
  4. package/dist/assets.js +139 -0
  5. package/dist/audience.d.ts +2 -0
  6. package/dist/audience.js +2 -0
  7. package/dist/bindings.d.ts +3 -0
  8. package/dist/bindings.js +3 -0
  9. package/dist/chart-font.d.ts +19 -0
  10. package/dist/chart-font.js +15 -0
  11. package/dist/chart.d.ts +83 -0
  12. package/dist/chart.js +247 -0
  13. package/dist/computer.d.ts +191 -0
  14. package/dist/computer.js +226 -0
  15. package/dist/db/client-core.d.ts +38 -0
  16. package/dist/db/client-core.js +68 -1
  17. package/dist/db/compile-rules.d.ts +60 -2
  18. package/dist/db/compile-rules.js +178 -30
  19. package/dist/db/custom-roles.d.ts +156 -0
  20. package/dist/db/custom-roles.js +280 -0
  21. package/dist/db/memory-client.d.ts +0 -15
  22. package/dist/db/memory-client.js +13 -23
  23. package/dist/db/schema-gen.js +2 -8
  24. package/dist/editor-sync.d.ts +130 -0
  25. package/dist/editor-sync.js +413 -0
  26. package/dist/files.d.ts +70 -0
  27. package/dist/files.js +48 -0
  28. package/dist/helpers.d.ts +10 -0
  29. package/dist/helpers.js +10 -0
  30. package/dist/index.d.ts +24 -0
  31. package/dist/index.js +25 -0
  32. package/dist/labelme.d.ts +84 -0
  33. package/dist/labelme.js +118 -0
  34. package/dist/manifest.d.ts +400 -106
  35. package/dist/manifest.js +59 -5
  36. package/dist/ops.d.ts +115 -0
  37. package/dist/ops.js +120 -0
  38. package/dist/react.d.ts +280 -4
  39. package/dist/react.js +100 -3
  40. package/dist/server.d.ts +268 -10
  41. package/dist/server.js +120 -4
  42. package/dist/testing/db.d.ts +6 -0
  43. package/dist/testing/db.js +3 -8
  44. package/dist/testing/files.d.ts +22 -0
  45. package/dist/testing/files.js +175 -0
  46. package/dist/testing/index.d.ts +2 -0
  47. package/dist/testing/index.js +1 -0
  48. package/dist/types.d.ts +47 -0
  49. package/docs/02-manifest.md +2 -0
  50. package/docs/03-events-and-state.md +8 -0
  51. package/docs/04-tools.md +58 -26
  52. package/docs/05-ui.md +35 -0
  53. package/docs/06-server.md +121 -7
  54. package/docs/07-background-tasks.md +7 -9
  55. package/docs/09-files.md +126 -17
  56. package/docs/10-testing.md +6 -0
  57. package/docs/11-shipping.md +10 -2
  58. package/docs/13-people-and-access.md +180 -0
  59. package/docs/14-database.md +6 -0
  60. package/docs/15-realtime.md +3 -0
  61. package/docs/17-editing-and-merging.md +155 -0
  62. package/llms-full.txt +1296 -214
  63. package/llms.txt +3 -3
  64. package/package.json +9 -4
  65. package/schemas/plugin.schema.json +134 -15
  66. package/scripts/build-api-reference.mjs +104 -0
  67. package/scripts/build-llms.mjs +1 -2
package/dist/chart.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * A CHART FROM DATA — one SVG for the app's screen and for the chat.
3
+ *
4
+ * A research app keeps having to show a series with something marked on it: a
5
+ * sensor around a fault, a loss curve with the epoch it diverged, a latency trace
6
+ * with the deploy. `chartSvg(spec)` draws stacked time panels sharing one x axis,
7
+ * with markers across every panel, as a self-contained SVG string:
8
+ *
9
+ * const svg = chartSvg({
10
+ * title: "C143 · temp_probe_fault", xStart, xEnd,
11
+ * panels: [{ label: "Water temperature", unit: "°C", points }],
12
+ * markers: [{ x: faultAt, label: "probe fault", tone: "page" }],
13
+ * });
14
+ * <div dangerouslySetInnerHTML={{ __html: svg }} /> // in the app
15
+ * const img = await renderChartImage(ctx, { name: "c143", svg }); // for a tool's answer
16
+ *
17
+ * Every word is drawn as glyph outlines (chart-font.ts), not `<text>`: a serverless
18
+ * rasteriser has no fonts, and a chart whose labels vanish is a chart that lies by
19
+ * omission. Pure — no DOM, no dependencies — so it runs in a browser, a server
20
+ * op and a unit test alike.
21
+ */
22
+ import { GLYPHS } from "./chart-font.js";
23
+ const TONE = {
24
+ page: "#e11d48",
25
+ escalate: "#db2777",
26
+ warn: "#d97706",
27
+ note: "#64748b",
28
+ info: "#0284c7",
29
+ ok: "#059669",
30
+ };
31
+ const PALETTE = {
32
+ light: { bg: "#faf6f0", ink: "#3d2f1e", soft: "#8a7a66", grid: "#e8dfd2", line: "#9a3412", band: "#16a34a" },
33
+ dark: { bg: "#1c1917", ink: "#f5f0e8", soft: "#a8a29e", grid: "#3a3430", line: "#fb923c", band: "#4ade80" },
34
+ };
35
+ /** The width of a string at a font size, in pixels. */
36
+ export function textWidth(text, size) {
37
+ let w = 0;
38
+ for (const ch of text)
39
+ w += (GLYPHS[ch] ?? GLYPHS["?"] ?? [600, ""])[0];
40
+ return (w * size) / 1000;
41
+ }
42
+ /** A string as filled glyph outlines. `y` is the baseline. */
43
+ export function textPath(text, x, y, size, opts = { fill: "#000" }) {
44
+ let s = text;
45
+ if (opts.maxWidth !== undefined && textWidth(s, size) > opts.maxWidth) {
46
+ while (s.length > 1 && textWidth(`${s}…`, size) > opts.maxWidth)
47
+ s = s.slice(0, -1);
48
+ s = `${s}…`;
49
+ }
50
+ const w = textWidth(s, size);
51
+ const x0 = opts.anchor === "middle" ? x - w / 2 : opts.anchor === "end" ? x - w : x;
52
+ const k = size / 1000;
53
+ let adv = 0;
54
+ let d = "";
55
+ for (const ch of s) {
56
+ const g = GLYPHS[ch] ?? GLYPHS["?"];
57
+ if (!g)
58
+ continue;
59
+ if (g[1])
60
+ d += `<path transform="translate(${round(adv)} 0)" d="${g[1]}"/>`;
61
+ adv += g[0];
62
+ }
63
+ return d ? `<g transform="translate(${round(x0)} ${round(y)}) scale(${round(k, 5)})" fill="${opts.fill}">${d}</g>` : "";
64
+ }
65
+ const round = (n, p = 1) => {
66
+ const f = 10 ** p;
67
+ return Math.round(n * f) / f;
68
+ };
69
+ /** A number the way a person reads a measurement: few digits, no noise. */
70
+ export function formatValue(v) {
71
+ if (!Number.isFinite(v))
72
+ return "—";
73
+ const a = Math.abs(v);
74
+ if (a >= 1000)
75
+ return v.toFixed(0);
76
+ if (a >= 100)
77
+ return v.toFixed(1).replace(/\.0$/, "");
78
+ if (a >= 1)
79
+ return v.toFixed(2).replace(/0$/, "").replace(/\.0$/, "");
80
+ return v.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") || "0";
81
+ }
82
+ /**
83
+ * A chart as one SVG string from a spec (panels of series, markers, tones): the same picture for the
84
+ * app's screen (`dangerouslySetInnerHTML`) and, rendered with `renderChartImage` on the server, for
85
+ * a tool's answer. Text is drawn as glyph outlines, so it needs no fonts where it is rasterised.
86
+ */
87
+ export function chartSvg(spec) {
88
+ const theme = PALETTE[spec.theme ?? "light"];
89
+ const W = Math.max(320, spec.width ?? 960);
90
+ const PH = Math.max(40, spec.panelHeight ?? 96);
91
+ const left = 16;
92
+ const right = 16;
93
+ const axis = 58; // room for the y labels
94
+ const plotX0 = left + axis;
95
+ const plotX1 = W - right;
96
+ const head = spec.title ? (spec.subtitle ? 58 : 40) : 12;
97
+ const panelGap = 30; // label row above each panel
98
+ const foot = spec.xTicks?.length ? 30 : 12;
99
+ const H = head + spec.panels.length * (PH + panelGap) + foot;
100
+ const span = spec.xEnd - spec.xStart || 1;
101
+ const px = (x) => plotX0 + ((x - spec.xStart) / span) * (plotX1 - plotX0);
102
+ const out = [];
103
+ out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
104
+ out.push(`<rect width="${W}" height="${H}" fill="${theme.bg}"/>`);
105
+ if (spec.title)
106
+ out.push(textPath(spec.title, left, 26, 17, { fill: theme.ink, maxWidth: W - left - right }));
107
+ if (spec.subtitle)
108
+ out.push(textPath(spec.subtitle, left, 46, 12, { fill: theme.soft, maxWidth: W - left - right }));
109
+ const inWindow = (x) => x >= spec.xStart && x <= spec.xEnd;
110
+ const markers = (spec.markers ?? []).filter((m) => inWindow(m.x));
111
+ spec.panels.forEach((p, i) => {
112
+ const top = head + i * (PH + panelGap) + panelGap;
113
+ const bottom = top + PH;
114
+ const all = p.points.filter((q) => Number.isFinite(q[0]) && Number.isFinite(q[1]));
115
+ const step = p.kind === "step";
116
+ const bad = (v) => !step && !!p.impossible && ((p.impossible.below !== undefined && v <= p.impossible.below) || (p.impossible.above !== undefined && v >= p.impossible.above));
117
+ const pts = all.filter((q) => !bad(q[1]));
118
+ const rail = all.filter((q) => bad(q[1]));
119
+ // Label row: the channel on the left, its latest value on the right.
120
+ out.push(textPath(`${p.label}${p.unit ? ` · ${p.unit}` : ""}`, left, top - 9, 12, { fill: theme.ink, maxWidth: W / 2 }));
121
+ if (all.length) {
122
+ const last = all[all.length - 1][1];
123
+ const lastText = step ? (last >= 0.5 ? (p.levels?.[1] ?? "on") : (p.levels?.[0] ?? "off")) : `${formatValue(last)}${p.unit ? ` ${p.unit}` : ""}`;
124
+ out.push(textPath(`now ${lastText}`, plotX1, top - 9, 11, { fill: theme.soft, anchor: "end" }));
125
+ }
126
+ out.push(`<rect x="${plotX0}" y="${top}" width="${plotX1 - plotX0}" height="${PH}" fill="none" stroke="${theme.grid}"/>`);
127
+ if (rail.length) {
128
+ // The impossible readings, on the panel's bottom edge, one tick each.
129
+ const c = TONE.page;
130
+ out.push(`<rect x="${plotX0}" y="${bottom - 3}" width="${plotX1 - plotX0}" height="3" fill="${c}" fill-opacity="0.12"/>`);
131
+ let ticks = "";
132
+ for (const q of rail)
133
+ ticks += `M${round(px(q[0]))} ${bottom - 10}V${bottom}`;
134
+ out.push(`<path d="${ticks}" stroke="${c}" stroke-width="1.4"/>`);
135
+ const lo = Math.min(...rail.map((q) => q[1]));
136
+ out.push(textPath(`${rail.length} impossible reading${rail.length === 1 ? "" : "s"} (down to ${formatValue(lo)})${p.impossible?.label ? ` · ${p.impossible.label}` : ""}`, plotX0 + 6, bottom - 14, 10, { fill: c }));
137
+ }
138
+ if (!pts.length) {
139
+ if (!rail.length)
140
+ out.push(textPath("no readings in this window", (plotX0 + plotX1) / 2, top + PH / 2 + 4, 12, { fill: theme.soft, anchor: "middle" }));
141
+ for (const m of markers)
142
+ out.push(`<line x1="${round(px(m.x))}" x2="${round(px(m.x))}" y1="${top}" y2="${bottom}" stroke="${TONE[m.tone ?? "page"]}" stroke-width="1.4" stroke-opacity="0.85"/>`);
143
+ return;
144
+ }
145
+ let lo = step ? 0 : Math.min(...pts.map((q) => q[1]));
146
+ let hi = step ? 1 : Math.max(...pts.map((q) => q[1]));
147
+ if (p.band && !step) {
148
+ lo = Math.min(lo, p.band.min);
149
+ hi = Math.max(hi, p.band.max);
150
+ }
151
+ if (hi - lo < 1e-9) {
152
+ lo -= 1;
153
+ hi += 1;
154
+ }
155
+ const pad = step ? 0.12 : 0.08;
156
+ const y = (v) => bottom - ((v - lo) / (hi - lo)) * PH * (1 - 2 * pad) - PH * pad;
157
+ if (p.band && !step) {
158
+ const y0 = y(p.band.max);
159
+ const y1 = y(p.band.min);
160
+ out.push(`<rect x="${plotX0}" y="${round(y0)}" width="${plotX1 - plotX0}" height="${round(Math.max(1, y1 - y0))}" fill="${theme.band}" fill-opacity="0.08"/>`);
161
+ if (p.band.label)
162
+ out.push(textPath(p.band.label, plotX0 + 6, round(y0) + 12, 10, { fill: theme.band }));
163
+ }
164
+ // y labels: the extremes of what is drawn — for a probe fault, -999.9 IS the story.
165
+ const yl = step ? [p.levels?.[1] ?? "on", p.levels?.[0] ?? "off"] : [formatValue(hi), formatValue(lo)];
166
+ out.push(textPath(yl[0], plotX0 - 8, y(hi) + 4, 10, { fill: theme.soft, anchor: "end" }));
167
+ out.push(textPath(yl[1], plotX0 - 8, y(lo) + 4, 10, { fill: theme.soft, anchor: "end" }));
168
+ // A line is a claim that the value passed between two readings. Where the
169
+ // readings are far apart (a device silent, a sentinel lifted out), there is no
170
+ // such claim to make, so the line breaks.
171
+ // Per panel: a sensor that reports every hour is not "gone" for 40 minutes.
172
+ const deltas = pts.slice(1).map((q, j) => q[0] - pts[j][0]).sort((a, b) => a - b);
173
+ const typical = deltas.length ? deltas[Math.floor(deltas.length / 2)] : 0;
174
+ const gap = spec.gap ?? Math.max(span * 0.05, typical * 4);
175
+ let d = "";
176
+ pts.forEach((q, j) => {
177
+ const X = round(px(q[0]));
178
+ const Y = round(y(q[1]));
179
+ const broken = j > 0 && q[0] - pts[j - 1][0] > gap;
180
+ if (j === 0 || (broken && !step))
181
+ d += `M${X} ${Y}`;
182
+ else if (step)
183
+ d += `H${X}V${Y}`;
184
+ else
185
+ d += `L${X} ${Y}`;
186
+ });
187
+ if (step && pts.length)
188
+ d += `H${round(px(Math.min(spec.xEnd, spec.now ?? spec.xEnd)))}`;
189
+ out.push(`<path d="${d}" fill="none" stroke="${theme.line}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>`);
190
+ // Markers cross every panel, so a fault lines up with every channel at once.
191
+ for (const m of markers) {
192
+ out.push(`<line x1="${round(px(m.x))}" x2="${round(px(m.x))}" y1="${top}" y2="${bottom}" stroke="${TONE[m.tone ?? "page"]}" stroke-width="1.4" stroke-opacity="0.85"/>`);
193
+ }
194
+ });
195
+ // Marker flags above the first panel, spaced so labels do not sit on each other.
196
+ const firstTop = head + panelGap;
197
+ let lastLabelEnd = -Infinity;
198
+ for (const m of [...markers].sort((a, b) => a.x - b.x)) {
199
+ const X = px(m.x);
200
+ const c = TONE[m.tone ?? "page"];
201
+ out.push(`<path d="M${round(X - 5)} ${firstTop - 24}h10l-5 7z" fill="${c}"/>`);
202
+ if (m.label && X - 4 > lastLabelEnd) {
203
+ const lw = Math.min(180, textWidth(m.label, 10));
204
+ out.push(textPath(m.label, X + 7, firstTop - 17, 10, { fill: c, maxWidth: 180 }));
205
+ lastLabelEnd = X + 7 + lw + 8;
206
+ }
207
+ }
208
+ if (spec.now !== undefined && inWindow(spec.now)) {
209
+ const X = round(px(spec.now));
210
+ out.push(`<line x1="${X}" x2="${X}" y1="${head + panelGap}" y2="${H - foot}" stroke="${theme.ink}" stroke-dasharray="3 3" stroke-opacity="0.45"/>`);
211
+ }
212
+ for (const t of spec.xTicks ?? []) {
213
+ if (!inWindow(t.x))
214
+ continue;
215
+ out.push(textPath(t.label, px(t.x), H - 10, 10, { fill: theme.soft, anchor: "middle" }));
216
+ }
217
+ out.push("</svg>");
218
+ return out.join("");
219
+ }
220
+ /**
221
+ * Naive local timestamps ("2026-03-31T20:58:20" or with a space) as a number for an
222
+ * x axis — minutes since the epoch, WITHOUT a timezone. Stream times are wall-clock
223
+ * strings from a device; `new Date(s)` would shift them by the viewer's offset.
224
+ */
225
+ export function naiveMinutes(ts) {
226
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(ts ?? "");
227
+ if (!m)
228
+ return NaN;
229
+ return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0)) / 60_000;
230
+ }
231
+ /** A naive-minutes value back to "MM-DD HH:MM" for a tick. */
232
+ export function naiveLabel(minutes, withDate = true) {
233
+ const d = new Date(minutes * 60_000);
234
+ const p = (n) => String(n).padStart(2, "0");
235
+ const hm = `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
236
+ return withDate ? `${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${hm}` : hm;
237
+ }
238
+ /** Evenly spaced ticks across a naive-minutes window, labelled for the span. */
239
+ export function naiveTicks(start, end, count = 6) {
240
+ const out = [];
241
+ const n = Math.max(2, count);
242
+ for (let i = 0; i < n; i++) {
243
+ const x = start + ((end - start) * i) / (n - 1);
244
+ out.push({ x, label: naiveLabel(x, end - start > 36 * 60 || i === 0) });
245
+ }
246
+ return out;
247
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * A MACHINE, FROM SERVER CODE — `computer(ctx, machineNodeId)`.
3
+ *
4
+ * A `my_computer` app is a paired personal machine: run a shell command on it,
5
+ * give Claude Code a task on it, read the result. Its tools answer JSON in a
6
+ * string, every command is approval-gated unless the owner set the app to
7
+ * auto, a wait is at most 55 seconds and a command at most 900, and a task that
8
+ * takes longer than one wait comes back "running" with a commandId to ask for
9
+ * again. An app orchestrating a machine — a research runner starting a server,
10
+ * a replay, a Claude Code session — had to know all of that and parse the
11
+ * strings itself. This holds it once.
12
+ *
13
+ * const box = computer(ctx, machineNodeId); // ctx: an op's or a task's
14
+ * const st = await box.status(); // { paired, online, autonomy, pendingApproval }
15
+ * const r = await box.run("cd ~/proj && make", { cwd: "~", timeoutSeconds: 600 });
16
+ * if (r.status === "pending_approval") … // the owner must approve in the app
17
+ * const done = await box.runToEnd("python -m stream.replay …", { deadlineMs: 300_000 });
18
+ * const said = await box.claude("Read README.md and summarise the run steps", { permissionMode: "no_writes" });
19
+ *
20
+ * Reaches the machine through `callWorkspaceTool`, so it is gated by the
21
+ * manifest's `workspaceTools` (`my_computer:run_on_computer`, `my_computer:get_computer_result`,
22
+ * `my_computer:claude_task`, `my_computer:computer_status`) and works wherever
23
+ * that works: installed, and in a Forge box through the board's tab.
24
+ *
25
+ * Pure: the same file on the npm package and on the platform; each side binds
26
+ * it to its own `callWorkspaceTool` (`makeComputer`).
27
+ */
28
+ import type { CallWorkspaceToolArgs } from "./server.js";
29
+ export type CommandStatus = "pending_approval" | "approved" | "running" | "done" | "denied" | "failed" | "unknown";
30
+ /** What a command came back as. Flat: the repo an app compiles in may not narrow a union. */
31
+ export interface CommandOutcome {
32
+ ok: boolean;
33
+ status: CommandStatus;
34
+ commandId?: string;
35
+ command?: string;
36
+ exitCode?: number;
37
+ timedOut?: boolean;
38
+ durationMs?: number;
39
+ stdout?: string;
40
+ stderr?: string;
41
+ /** The machine's or the platform's own words when something refused or failed. */
42
+ error?: string;
43
+ /** What the tool said in words (approval waits carry the sentence to tell the user). */
44
+ message?: string;
45
+ /** The tool's answer as it came, for anything this shape does not carry. */
46
+ raw: string;
47
+ /**
48
+ * The machine's output was CUT: the My Computer app keeps at most
49
+ * `OUTPUT_LIMIT` characters of a command's stdout. A caller parsing the
50
+ * output must treat a truncated answer as unknown, never as the whole.
51
+ */
52
+ truncated?: boolean;
53
+ }
54
+ export interface ClaudeOutcome extends CommandOutcome {
55
+ answer?: string;
56
+ sessionId?: string;
57
+ costUsd?: number;
58
+ isError?: boolean;
59
+ }
60
+ export interface ComputerStatus {
61
+ ok: boolean;
62
+ paired: boolean;
63
+ online: boolean;
64
+ hostname?: string;
65
+ os?: string;
66
+ autonomy?: "approve" | "auto";
67
+ pendingApproval?: number;
68
+ message?: string;
69
+ error?: string;
70
+ raw: string;
71
+ }
72
+ export interface RunOptions {
73
+ cwd?: string;
74
+ /** Kill the command after this many seconds (default 120, max 900). */
75
+ timeoutSeconds?: number;
76
+ /** How long one wait may last before the tool answers (default 30, max 55). */
77
+ waitSeconds?: number;
78
+ }
79
+ export interface ToEndOptions extends RunOptions {
80
+ /** Stop asking after this long (default 15 min — a command cannot outlive 900 s anyway). */
81
+ deadlineMs?: number;
82
+ /** Keep waiting while the command is pending the owner's approval (default: return at once). */
83
+ waitForApproval?: boolean;
84
+ /** Called on every non-terminal answer — say "still waiting for approval" somewhere. */
85
+ onWait?: (o: CommandOutcome) => void | Promise<void>;
86
+ }
87
+ export interface ClaudeOptions extends ToEndOptions {
88
+ sessionId?: string;
89
+ permissionMode?: "full" | "no_writes";
90
+ }
91
+ export interface Computer {
92
+ readonly machineNodeId: string;
93
+ status(): Promise<ComputerStatus>;
94
+ run(command: string, opts?: RunOptions): Promise<CommandOutcome>;
95
+ result(commandId: string, waitSeconds?: number): Promise<CommandOutcome>;
96
+ /** `run`, then `result` until it is terminal or the deadline passes. */
97
+ runToEnd(command: string, opts?: ToEndOptions): Promise<CommandOutcome>;
98
+ claude(prompt: string, opts?: ClaudeOptions): Promise<ClaudeOutcome>;
99
+ /** `claude`, then `result` until Claude has answered. */
100
+ claudeToEnd(prompt: string, opts?: ClaudeOptions): Promise<ClaudeOutcome>;
101
+ /** The contents of one file, as the machine's `cat` returns it. */
102
+ readFile(path: string, opts?: {
103
+ maxBytes?: number;
104
+ } & ToEndOptions): Promise<{
105
+ ok: boolean;
106
+ text: string;
107
+ error?: string;
108
+ }>;
109
+ /**
110
+ * A JSON answer from a service on the MACHINE's own network — `http://localhost:8600/health`,
111
+ * a dashboard, a database's REST door — which the platform cannot reach and the machine can.
112
+ * `curl` runs there; the body comes back parsed. This is how a research app shows what a
113
+ * server on the analyst's laptop is doing.
114
+ */
115
+ fetchJson(url: string, opts?: {
116
+ method?: "GET" | "POST";
117
+ body?: unknown;
118
+ timeoutSeconds?: number;
119
+ } & ToEndOptions): Promise<{
120
+ ok: boolean;
121
+ status?: number;
122
+ json?: unknown;
123
+ text: string;
124
+ error?: string;
125
+ }>;
126
+ /**
127
+ * COMPUTE ON THE MACHINE, BRING BACK A LITTLE JSON. Runs a command whose stdout
128
+ * is one JSON document and parses it. The machine keeps at most `OUTPUT_LIMIT`
129
+ * characters of a command's output, so this is the shape for anything big —
130
+ * a database query, a series to plot, a log to summarise: do the heavy part
131
+ * THERE and print only what the app needs. A cut answer is an error that says so.
132
+ */
133
+ runJson<T = unknown>(command: string, opts?: ToEndOptions): Promise<JsonOutcome<T>>;
134
+ /** `runJson` for a Python script, fed on stdin to the interpreter you name (default `python3`). */
135
+ python<T = unknown>(script: string, opts?: {
136
+ python?: string;
137
+ } & ToEndOptions): Promise<JsonOutcome<T>>;
138
+ }
139
+ /** Flat, like every outcome here. `ok` with `json`; otherwise `error` in words. */
140
+ export interface JsonOutcome<T = unknown> {
141
+ ok: boolean;
142
+ json?: T;
143
+ status: CommandStatus;
144
+ commandId?: string;
145
+ exitCode?: number;
146
+ /** The command's stderr tail, which is where a script's traceback is. */
147
+ stderr?: string;
148
+ error?: string;
149
+ message?: string;
150
+ truncated?: boolean;
151
+ }
152
+ export type CallWorkspaceToolFn = (a: CallWorkspaceToolArgs) => Promise<{
153
+ ok: boolean;
154
+ text: string;
155
+ truncated?: boolean;
156
+ }>;
157
+ /** The longest one call waits on a machine command before answering "still running" (a tool call's budget). */
158
+ export declare const MAX_WAIT_SECONDS = 55;
159
+ /** The longest a machine command may run before the machine stops it. */
160
+ export declare const MAX_TIMEOUT_SECONDS = 900;
161
+ /** How much of a command's stdout the My Computer app keeps. Print less than this. */
162
+ export declare const OUTPUT_LIMIT = 8000;
163
+ /** The tool answers `JSON.stringify(json)`; a refusal from the platform is plain words. */
164
+ export declare function parseToolJson(text: string): Record<string, unknown> | null;
165
+ /** One command answer, from whatever the tool said. */
166
+ export declare function parseCommandOutcome(r: {
167
+ ok: boolean;
168
+ text: string;
169
+ truncated?: boolean;
170
+ }): CommandOutcome;
171
+ /** What an app tells a person when the machine waits for them. */
172
+ export declare const APPROVAL_WAIT = "Waiting for the owner to approve this command in the My Computer app (or set that app to Auto).";
173
+ /** A Claude Code task's tool answer as fields: the command's outcome plus `answer`, `sessionId`, `costUsd`, `isError`. */
174
+ export declare function parseClaudeOutcome(r: {
175
+ ok: boolean;
176
+ text: string;
177
+ truncated?: boolean;
178
+ }): ClaudeOutcome;
179
+ /** The machine's status answer as fields: paired, online, hostname, os, autonomy, pending approvals. */
180
+ export declare function parseStatus(r: {
181
+ ok: boolean;
182
+ text: string;
183
+ }): ComputerStatus;
184
+ /** Whether a command has finished for good (`done`, `failed`, `denied`) — anything else is worth waiting on. */
185
+ export declare const isTerminal: (s: CommandStatus) => boolean;
186
+ export declare function __forgetAutonomy(): void;
187
+ /** Bind the client to a side's `callWorkspaceTool`. Authors use `computer(...)`, never this. */
188
+ export declare function makeComputer(call: CallWorkspaceToolFn): (ctx: {
189
+ pluginId: string;
190
+ nodeId: string;
191
+ }, machineNodeId: string) => Computer;
@@ -0,0 +1,226 @@
1
+ const TERMINAL = ["done", "failed", "denied"];
2
+ /** The longest one call waits on a machine command before answering "still running" (a tool call's budget). */
3
+ export const MAX_WAIT_SECONDS = 55;
4
+ /** The longest a machine command may run before the machine stops it. */
5
+ export const MAX_TIMEOUT_SECONDS = 900;
6
+ /** How much of a command's stdout the My Computer app keeps. Print less than this. */
7
+ export const OUTPUT_LIMIT = 8_000;
8
+ const TRUNCATION_MARK = "\n…[truncated]";
9
+ const asRecord = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : null);
10
+ const str = (x) => (typeof x === "string" ? x : undefined);
11
+ const num = (x) => (typeof x === "number" && Number.isFinite(x) ? x : undefined);
12
+ const bool = (x) => (typeof x === "boolean" ? x : undefined);
13
+ /** The tool answers `JSON.stringify(json)`; a refusal from the platform is plain words. */
14
+ export function parseToolJson(text) {
15
+ const t = (text ?? "").trim();
16
+ if (!t.startsWith("{"))
17
+ return null;
18
+ try {
19
+ return asRecord(JSON.parse(t));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function statusOf(j) {
26
+ const s = str(j?.status);
27
+ return s === "pending_approval" || s === "approved" || s === "running" || s === "done" || s === "denied" || s === "failed" ? s : "unknown";
28
+ }
29
+ /** One command answer, from whatever the tool said. */
30
+ export function parseCommandOutcome(r) {
31
+ // A tool answer cut in transit is half a JSON document: say so, instead of "no answer".
32
+ if (r.truncated)
33
+ return { ok: false, status: "unknown", truncated: true, error: `the machine's answer was cut in transit (${r.text.length} characters arrived) — ask for less`, raw: r.text };
34
+ const j = parseToolJson(r.text);
35
+ if (!j)
36
+ return { ok: false, status: "unknown", error: r.text || "no answer", raw: r.text };
37
+ const okFlag = j.ok !== false && r.ok;
38
+ const status = statusOf(j);
39
+ const stdoutRaw = str(j.stdout);
40
+ const truncated = !!stdoutRaw && stdoutRaw.endsWith(TRUNCATION_MARK);
41
+ return {
42
+ ok: okFlag,
43
+ status,
44
+ commandId: str(j.commandId),
45
+ command: str(j.command),
46
+ exitCode: num(j.exitCode),
47
+ timedOut: bool(j.timedOut),
48
+ durationMs: num(j.durationMs),
49
+ stdout: truncated ? stdoutRaw.slice(0, -TRUNCATION_MARK.length) : stdoutRaw,
50
+ ...(truncated ? { truncated: true } : {}),
51
+ stderr: str(j.stderr),
52
+ error: str(j.error),
53
+ // The My Computer route words its waits for a CHAT agent ("park on it with
54
+ // wait_for_workspace_event…"). An app shows this to a person, so a wait for
55
+ // approval is said plainly; the agent's hint stays in `raw` (2026-09-14, seen
56
+ // on an app screen).
57
+ message: status === "pending_approval" ? APPROVAL_WAIT : str(j.message),
58
+ raw: r.text,
59
+ };
60
+ }
61
+ /** What an app tells a person when the machine waits for them. */
62
+ export const APPROVAL_WAIT = "Waiting for the owner to approve this command in the My Computer app (or set that app to Auto).";
63
+ /** A Claude Code task's tool answer as fields: the command's outcome plus `answer`, `sessionId`, `costUsd`, `isError`. */
64
+ export function parseClaudeOutcome(r) {
65
+ const base = parseCommandOutcome(r);
66
+ const j = parseToolJson(r.text);
67
+ return { ...base, answer: str(j?.answer), sessionId: str(j?.sessionId), costUsd: num(j?.costUsd), isError: bool(j?.isError) };
68
+ }
69
+ /** The machine's status answer as fields: paired, online, hostname, os, autonomy, pending approvals. */
70
+ export function parseStatus(r) {
71
+ const j = parseToolJson(r.text);
72
+ if (!j)
73
+ return { ok: false, paired: false, online: false, error: r.text || "no answer", raw: r.text };
74
+ const autonomy = str(j.autonomy);
75
+ return {
76
+ ok: j.ok !== false && r.ok,
77
+ paired: j.paired === true,
78
+ online: j.online === true,
79
+ hostname: str(j.hostname),
80
+ os: str(j.os),
81
+ autonomy: autonomy === "auto" || autonomy === "approve" ? autonomy : undefined,
82
+ pendingApproval: num(j.pendingApproval),
83
+ message: str(j.message),
84
+ error: str(j.error),
85
+ raw: r.text,
86
+ };
87
+ }
88
+ /** Whether a command has finished for good (`done`, `failed`, `denied`) — anything else is worth waiting on. */
89
+ export const isTerminal = (s) => TERMINAL.includes(s);
90
+ const clampWait = (n) => Math.max(1, Math.min(MAX_WAIT_SECONDS, Math.floor(n ?? 30)));
91
+ /**
92
+ * What each machine last said about approvals, per process. A command on a machine
93
+ * set to `approve` cannot finish inside a wait — the tool sits on the whole wait and
94
+ * then says "pending_approval" — so once a machine has said `approve`, a first ask
95
+ * waits two seconds, not thirty, and the caller hears "waiting for the owner" at
96
+ * once. `status()` and every answer that names the autonomy refresh it.
97
+ */
98
+ const AUTONOMY_SEEN = new Map();
99
+ export function __forgetAutonomy() {
100
+ AUTONOMY_SEEN.clear();
101
+ }
102
+ const firstWait = (machineNodeId, asked, fallback) => clampWait(asked ?? (AUTONOMY_SEEN.get(machineNodeId) === "approve" ? 2 : fallback));
103
+ const clampTimeout = (n) => (n === undefined ? undefined : Math.max(1, Math.min(MAX_TIMEOUT_SECONDS, Math.floor(n))));
104
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
105
+ /** Bind the client to a side's `callWorkspaceTool`. Authors use `computer(...)`, never this. */
106
+ export function makeComputer(call) {
107
+ return function computer(ctx, machineNodeId) {
108
+ const invoke = (tool, args) => call({ pluginId: ctx.pluginId, nodeId: ctx.nodeId, tool, args, appType: "my_computer", targetNodeId: machineNodeId });
109
+ const result = async (commandId, waitSeconds) => parseCommandOutcome(await invoke("get_computer_result", { commandId, ...(waitSeconds !== undefined ? { waitSeconds: clampWait(waitSeconds) } : {}) }));
110
+ const untilEnd = async (first, opts, parse) => {
111
+ const deadline = Date.now() + (opts?.deadlineMs ?? 15 * 60_000);
112
+ let last = first;
113
+ while (!isTerminal(last.status)) {
114
+ if (!last.ok || !last.commandId)
115
+ return last; // refused, or nothing to ask about
116
+ if (last.status === "pending_approval" && !opts?.waitForApproval)
117
+ return last;
118
+ await opts?.onWait?.(last);
119
+ if (Date.now() >= deadline)
120
+ return last;
121
+ // An approval wait polls gently; a running command waits inside the tool.
122
+ if (last.status === "pending_approval")
123
+ await sleep(3_000);
124
+ last = parse(await invoke("get_computer_result", { commandId: last.commandId, waitSeconds: clampWait(opts?.waitSeconds ?? 50) }));
125
+ }
126
+ return last;
127
+ };
128
+ // An answer that names the gate teaches the memory too — a pending approval is a machine on `approve`.
129
+ const noting = (o) => {
130
+ if (o.status === "pending_approval")
131
+ AUTONOMY_SEEN.set(machineNodeId, "approve");
132
+ return o;
133
+ };
134
+ const run = async (command, opts) => noting(parseCommandOutcome(await invoke("run_on_computer", {
135
+ command,
136
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
137
+ ...(opts?.timeoutSeconds !== undefined ? { timeoutSeconds: clampTimeout(opts.timeoutSeconds) } : {}),
138
+ waitSeconds: firstWait(machineNodeId, opts?.waitSeconds, 30),
139
+ })));
140
+ const claude = async (prompt, opts) => noting(parseClaudeOutcome(await invoke("claude_task", {
141
+ prompt,
142
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
143
+ ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}),
144
+ ...(opts?.permissionMode ? { permissionMode: opts.permissionMode } : {}),
145
+ ...(opts?.timeoutSeconds !== undefined ? { timeoutSeconds: clampTimeout(opts.timeoutSeconds) } : {}),
146
+ waitSeconds: firstWait(machineNodeId, opts?.waitSeconds, 20),
147
+ })));
148
+ const runJson = async (command, opts) => {
149
+ const r = await run(command, { timeoutSeconds: opts?.timeoutSeconds ?? 120, waitSeconds: opts?.waitSeconds, ...(opts?.cwd ? { cwd: opts.cwd } : {}) }).then((first) => untilEnd(first, opts, parseCommandOutcome));
150
+ const base = { status: r.status, commandId: r.commandId, exitCode: r.exitCode, stderr: r.stderr?.slice(-2000), message: r.message };
151
+ if (r.status === "pending_approval")
152
+ return { ok: false, ...base, error: `pending_approval: ${r.message ?? "the machine waits for the owner's approval"}` };
153
+ // A cut answer first: cut in transit it has no status to read, and "status unknown" would hide why.
154
+ if (r.truncated)
155
+ return { ok: false, ...base, truncated: true, error: r.error ?? `the output was longer than the machine keeps (${OUTPUT_LIMIT} characters) and was cut — print less` };
156
+ if (r.status !== "done")
157
+ return { ok: false, ...base, error: r.error ?? r.message ?? `status ${r.status}` };
158
+ if (r.exitCode !== 0)
159
+ return { ok: false, ...base, error: `exited ${r.exitCode}${r.stderr ? `: ${r.stderr.trim().split("\n").slice(-3).join(" | ")}` : ""}` };
160
+ const text = (r.stdout ?? "").trim();
161
+ try {
162
+ return { ok: true, ...base, json: JSON.parse(text) };
163
+ }
164
+ catch {
165
+ return { ok: false, ...base, error: `the output is not JSON: ${text.slice(0, 200)}` };
166
+ }
167
+ };
168
+ return {
169
+ machineNodeId,
170
+ status: async () => {
171
+ const st = parseStatus(await invoke("computer_status", {}));
172
+ if (st.autonomy)
173
+ AUTONOMY_SEEN.set(machineNodeId, st.autonomy);
174
+ return st;
175
+ },
176
+ run,
177
+ result,
178
+ runToEnd: (command, opts) => run(command, opts).then((first) => untilEnd(first, opts, parseCommandOutcome)),
179
+ claude,
180
+ claudeToEnd: (prompt, opts) => claude(prompt, opts).then((first) => untilEnd(first, opts, parseClaudeOutcome)),
181
+ fetchJson: async (url, opts) => {
182
+ const safeUrl = url.replace(/'/g, "'\\''");
183
+ const method = opts?.method ?? "GET";
184
+ const body = opts?.body === undefined ? "" : ` -H 'content-type: application/json' --data-binary '${JSON.stringify(opts.body).replace(/'/g, "'\\''")}'`;
185
+ // The status code rides on the last line, so a refusal is a status, not a guess.
186
+ const cmd = `curl -sS --max-time ${Math.max(1, Math.min(120, opts?.timeoutSeconds ?? 20))} -X ${method}${body} -w '\n__HTTP__%{http_code}' '${safeUrl}'`;
187
+ const r = await run(cmd, { timeoutSeconds: 150, waitSeconds: opts?.waitSeconds ?? 20 }).then((first) => untilEnd(first, opts, parseCommandOutcome));
188
+ if (r.truncated)
189
+ return { ok: false, text: r.stdout ?? "", error: r.error ?? `the answer from ${url} was longer than the machine keeps (${OUTPUT_LIMIT} characters) and was cut — ask for less, or compact it on the machine with runJson/python` };
190
+ if (r.status !== "done")
191
+ return { ok: false, text: r.stdout ?? "", error: r.status === "pending_approval" ? `pending_approval: ${r.message ?? "the machine waits for the owner's approval"}` : r.error ?? r.message ?? `status ${r.status}` };
192
+ const out = r.stdout ?? "";
193
+ const m = /\n?__HTTP__(\d{3})\s*$/.exec(out);
194
+ const status = m ? Number(m[1]) : undefined;
195
+ const text = m ? out.slice(0, m.index) : out;
196
+ if (r.exitCode !== 0 && !m)
197
+ return { ok: false, text, error: r.stderr || `curl exited ${r.exitCode}` };
198
+ let json;
199
+ try {
200
+ json = text.trim() ? JSON.parse(text) : undefined;
201
+ }
202
+ catch {
203
+ json = undefined;
204
+ }
205
+ return { ok: status !== undefined && status >= 200 && status < 300, status, json, text, ...(status !== undefined && status >= 300 ? { error: `HTTP ${status}` } : {}) };
206
+ },
207
+ runJson: (command, opts) => runJson(command, opts),
208
+ python: async (script, opts) => {
209
+ const py = (opts?.python ?? "python3").replace(/'/g, "'\\''");
210
+ // A quoted heredoc: the script reaches the interpreter byte for byte, no shell expansion.
211
+ const tag = "ESOUL_PY_EOF";
212
+ if (script.split("\n").some((l) => l.trim() === tag))
213
+ return { ok: false, status: "unknown", error: `the script may not contain a line that is exactly ${tag}` };
214
+ return runJson(`'${py}' - <<'${tag}'\n${script}\n${tag}`, opts);
215
+ },
216
+ readFile: async (path, opts) => {
217
+ const max = Math.max(1, Math.min(2_000_000, opts?.maxBytes ?? 200_000));
218
+ const safe = path.replace(/'/g, "'\\''");
219
+ const r = await run(`head -c ${max} '${safe}'`, { timeoutSeconds: 60, waitSeconds: opts?.waitSeconds ?? 20, ...(opts?.cwd ? { cwd: opts.cwd } : {}) }).then((first) => untilEnd(first, opts, parseCommandOutcome));
220
+ if (r.status === "done" && r.exitCode === 0)
221
+ return { ok: true, text: r.stdout ?? "" };
222
+ return { ok: false, text: r.stdout ?? "", error: r.error ?? r.stderr ?? r.message ?? `status ${r.status}` };
223
+ },
224
+ };
225
+ };
226
+ }