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.
- package/README.md +575 -153
- package/api-reference.md +3291 -0
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +139 -0
- package/dist/audience.d.ts +2 -0
- package/dist/audience.js +2 -0
- package/dist/bindings.d.ts +3 -0
- package/dist/bindings.js +3 -0
- package/dist/chart-font.d.ts +19 -0
- package/dist/chart-font.js +15 -0
- package/dist/chart.d.ts +83 -0
- package/dist/chart.js +247 -0
- package/dist/computer.d.ts +191 -0
- package/dist/computer.js +226 -0
- package/dist/db/client-core.d.ts +38 -0
- package/dist/db/client-core.js +68 -1
- package/dist/db/compile-rules.d.ts +60 -2
- package/dist/db/compile-rules.js +178 -30
- package/dist/db/custom-roles.d.ts +156 -0
- package/dist/db/custom-roles.js +280 -0
- package/dist/db/memory-client.d.ts +0 -15
- package/dist/db/memory-client.js +13 -23
- package/dist/db/schema-gen.js +2 -8
- package/dist/editor-sync.d.ts +130 -0
- package/dist/editor-sync.js +413 -0
- package/dist/files.d.ts +70 -0
- package/dist/files.js +48 -0
- package/dist/helpers.d.ts +10 -0
- package/dist/helpers.js +10 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +25 -0
- package/dist/labelme.d.ts +84 -0
- package/dist/labelme.js +118 -0
- package/dist/manifest.d.ts +400 -106
- package/dist/manifest.js +59 -5
- package/dist/ops.d.ts +115 -0
- package/dist/ops.js +120 -0
- package/dist/react.d.ts +280 -4
- package/dist/react.js +100 -3
- package/dist/server.d.ts +268 -10
- package/dist/server.js +120 -4
- package/dist/testing/db.d.ts +6 -0
- package/dist/testing/db.js +3 -8
- package/dist/testing/files.d.ts +22 -0
- package/dist/testing/files.js +175 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/types.d.ts +47 -0
- package/docs/02-manifest.md +2 -0
- package/docs/03-events-and-state.md +8 -0
- package/docs/04-tools.md +58 -26
- package/docs/05-ui.md +35 -0
- package/docs/06-server.md +121 -7
- package/docs/07-background-tasks.md +7 -9
- package/docs/09-files.md +126 -17
- package/docs/10-testing.md +6 -0
- package/docs/11-shipping.md +10 -2
- package/docs/13-people-and-access.md +180 -0
- package/docs/14-database.md +6 -0
- package/docs/15-realtime.md +3 -0
- package/docs/17-editing-and-merging.md +155 -0
- package/llms-full.txt +1296 -214
- package/llms.txt +3 -3
- package/package.json +9 -4
- package/schemas/plugin.schema.json +134 -15
- package/scripts/build-api-reference.mjs +104 -0
- 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;
|
package/dist/computer.js
ADDED
|
@@ -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
|
+
}
|