@jbarket/loomai 0.8.0 → 0.9.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The page vocabulary (docs/extensions.md, "The page").
3
+ *
4
+ * loom's page is built from these shapes, both loom's own pages and the
5
+ * installed pieces'. A piece answers `ui --json` (a manifest), `ui page --json`
6
+ * (a page) and `ui act --json` (an action's result) in this vocabulary. Pieces
7
+ * send data, never markup: loom renders every block itself and escapes every
8
+ * string, so a piece can't put HTML or script on loom's page. Anything that
9
+ * doesn't validate is dropped whole (a piece that sends garbage just isn't
10
+ * shown), never half-rendered.
11
+ */
12
+ export type Tone = 'ok' | 'warn' | 'bad' | 'muted';
13
+ /** Where a link goes: a page of the same source, one of loom's (`loom:` prefix), or outside. */
14
+ export interface Target {
15
+ page?: string;
16
+ href?: string;
17
+ }
18
+ export type Cell = string | {
19
+ text: string;
20
+ page?: string;
21
+ };
22
+ export interface Field {
23
+ name: string;
24
+ label: string;
25
+ kind: 'text' | 'textarea' | 'choice' | 'secret';
26
+ hint?: string;
27
+ value?: string;
28
+ required?: boolean;
29
+ options?: Array<{
30
+ value: string;
31
+ label: string;
32
+ detail?: string;
33
+ }>;
34
+ }
35
+ export type Block = {
36
+ type: 'stats';
37
+ items: Array<{
38
+ label: string;
39
+ value: string;
40
+ detail?: string;
41
+ tone?: Tone;
42
+ page?: string;
43
+ }>;
44
+ } | {
45
+ type: 'list';
46
+ items: Array<{
47
+ text: string;
48
+ meta?: string;
49
+ badge?: string;
50
+ tone?: Tone;
51
+ } & Target>;
52
+ } | {
53
+ type: 'table';
54
+ columns: string[];
55
+ rows: Cell[][];
56
+ } | {
57
+ type: 'kv';
58
+ items: Array<{
59
+ label: string;
60
+ value: string;
61
+ page?: string;
62
+ }>;
63
+ } | {
64
+ type: 'text';
65
+ text: string;
66
+ tone?: Tone;
67
+ } | {
68
+ type: 'callout';
69
+ text: string;
70
+ title?: string;
71
+ tone?: Tone;
72
+ } | ({
73
+ type: 'link';
74
+ text: string;
75
+ } & Target) | {
76
+ type: 'filters';
77
+ param: string;
78
+ options: Array<{
79
+ value: string;
80
+ label: string;
81
+ }>;
82
+ } | {
83
+ type: 'details';
84
+ summary: string;
85
+ meta?: string;
86
+ blocks: Block[];
87
+ } | {
88
+ type: 'form';
89
+ action: string;
90
+ submit: string;
91
+ fields: Field[];
92
+ title?: string;
93
+ note?: string;
94
+ hidden?: Record<string, string>;
95
+ } | {
96
+ type: 'buttons';
97
+ items: Array<{
98
+ action: string;
99
+ label: string;
100
+ tone?: 'primary' | 'danger';
101
+ fields?: Record<string, string>;
102
+ }>;
103
+ };
104
+ export interface Section {
105
+ id: string;
106
+ title?: string;
107
+ side?: boolean;
108
+ tone?: Tone;
109
+ blocks: Block[];
110
+ }
111
+ export interface Page {
112
+ title: string;
113
+ intro?: string;
114
+ sections: Section[];
115
+ }
116
+ export interface Tile {
117
+ label: string;
118
+ value: string;
119
+ detail?: string;
120
+ tone?: Tone;
121
+ page?: string;
122
+ }
123
+ export interface Ask {
124
+ text: string;
125
+ meta?: string;
126
+ page?: string;
127
+ label?: string;
128
+ }
129
+ export interface Manifest {
130
+ tab?: {
131
+ label: string;
132
+ };
133
+ tile?: Tile;
134
+ asks: Ask[];
135
+ /** A first-version answer: these sections become the piece's page. */
136
+ legacy?: Section[];
137
+ }
138
+ export interface ActResult {
139
+ ok: boolean;
140
+ message: string;
141
+ page?: string;
142
+ }
143
+ export declare const LIMITS: {
144
+ readonly sections: 10;
145
+ readonly blocks: 20;
146
+ readonly items: 200;
147
+ readonly columns: 12;
148
+ readonly string: 2000;
149
+ readonly asks: 10;
150
+ readonly fields: 30;
151
+ readonly hidden: 20;
152
+ readonly options: 50;
153
+ readonly query: 10;
154
+ readonly queryValue: 200;
155
+ readonly path: 200;
156
+ };
157
+ export declare const ID: RegExp;
158
+ export declare const QUERY_KEY: RegExp;
159
+ /** A page path: `""` (the main page) or lowercase segments, never `..`, never absolute. */
160
+ export declare function validPath(p: string): boolean;
161
+ /**
162
+ * A page link: `[loom:]<path>[?<query>]`. Split into the source (loom's own or
163
+ * this piece's), the path and the query, or null when it isn't valid.
164
+ */
165
+ export declare function splitPage(s: string): {
166
+ core: boolean;
167
+ path: string;
168
+ query: Record<string, string>;
169
+ } | null;
170
+ /** Validate a piece's `ui --json` answer (v2, or a first-version `{sections}`). */
171
+ export declare function parseManifest(value: unknown): Manifest | string;
172
+ /** Validate a piece's `ui page --json` answer. */
173
+ export declare function parsePage(value: unknown): Page | string;
174
+ /** Validate a piece's `ui act --json` answer. */
175
+ export declare function parseActResult(value: unknown): ActResult | string;
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The page vocabulary (docs/extensions.md, "The page").
3
+ *
4
+ * loom's page is built from these shapes, both loom's own pages and the
5
+ * installed pieces'. A piece answers `ui --json` (a manifest), `ui page --json`
6
+ * (a page) and `ui act --json` (an action's result) in this vocabulary. Pieces
7
+ * send data, never markup: loom renders every block itself and escapes every
8
+ * string, so a piece can't put HTML or script on loom's page. Anything that
9
+ * doesn't validate is dropped whole (a piece that sends garbage just isn't
10
+ * shown), never half-rendered.
11
+ */
12
+ export const LIMITS = {
13
+ sections: 10,
14
+ blocks: 20,
15
+ items: 200,
16
+ columns: 12,
17
+ string: 2000,
18
+ asks: 10,
19
+ fields: 30,
20
+ hidden: 20,
21
+ options: 50,
22
+ query: 10,
23
+ queryValue: 200,
24
+ path: 200,
25
+ };
26
+ const TONES = new Set(['ok', 'warn', 'bad', 'muted']);
27
+ export const ID = /^[a-z][a-z0-9-]{0,39}$/;
28
+ const NAME = /^[a-z][a-z0-9_-]{0,39}$/;
29
+ export const QUERY_KEY = /^[a-z][a-z0-9_-]{0,31}$/;
30
+ const PATH_CHARS = /^[a-z0-9._~/-]*$/;
31
+ class Invalid extends Error {
32
+ }
33
+ /** loom's own pages a link may name with `loom:`. */
34
+ const CORE_PAGES = /^(|mind|people|people\/[a-z0-9][a-z0-9-]*|memory\/.+)$/;
35
+ /** A page path: `""` (the main page) or lowercase segments, never `..`, never absolute. */
36
+ export function validPath(p) {
37
+ if (p.length > LIMITS.path || !PATH_CHARS.test(p))
38
+ return false;
39
+ if (p === '')
40
+ return true;
41
+ if (p.startsWith('/') || p.endsWith('/'))
42
+ return false;
43
+ return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
44
+ }
45
+ function str(v, what) {
46
+ if (typeof v === 'number' && Number.isFinite(v))
47
+ return String(v);
48
+ if (typeof v !== 'string')
49
+ throw new Invalid(`${what} must be a string`);
50
+ if (v.length > LIMITS.string)
51
+ throw new Invalid(`${what} is longer than ${LIMITS.string}`);
52
+ return v;
53
+ }
54
+ function optStr(v, what) {
55
+ return v === undefined || v === null ? undefined : str(v, what);
56
+ }
57
+ function bool(v) {
58
+ return v === undefined ? undefined : v === true;
59
+ }
60
+ function tone(v) {
61
+ if (v === undefined || v === null)
62
+ return undefined;
63
+ if (typeof v !== 'string' || !TONES.has(v))
64
+ throw new Invalid(`tone must be one of ${[...TONES].join(', ')}`);
65
+ return v;
66
+ }
67
+ function arr(v, what, max) {
68
+ if (!Array.isArray(v))
69
+ throw new Invalid(`${what} must be an array`);
70
+ if (v.length > max)
71
+ throw new Invalid(`${what} has more than ${max} entries`);
72
+ return v;
73
+ }
74
+ function obj(v, what) {
75
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
76
+ throw new Invalid(`${what} must be an object`);
77
+ return v;
78
+ }
79
+ function name(v, what) {
80
+ const s = str(v, what);
81
+ if (!NAME.test(s))
82
+ throw new Invalid(`${what} ${JSON.stringify(s)} must be lowercase letters, digits, _ and -`);
83
+ return s;
84
+ }
85
+ /**
86
+ * A page link: `[loom:]<path>[?<query>]`. Split into the source (loom's own or
87
+ * this piece's), the path and the query, or null when it isn't valid.
88
+ */
89
+ export function splitPage(s) {
90
+ const core = s.startsWith('loom:');
91
+ const rest = core ? s.slice(5) : s;
92
+ const q = rest.indexOf('?');
93
+ const path = q === -1 ? rest : rest.slice(0, q);
94
+ if (!validPath(path))
95
+ return null;
96
+ // Into loom, only loom's own pages: not another piece's, and not login,
97
+ // logout, actions or the JSON endpoints.
98
+ if (core && !CORE_PAGES.test(path))
99
+ return null;
100
+ const query = {};
101
+ if (q !== -1) {
102
+ const params = new URLSearchParams(rest.slice(q + 1));
103
+ let n = 0;
104
+ for (const [k, v] of params) {
105
+ if (!QUERY_KEY.test(k) || v.length > LIMITS.queryValue || ++n > LIMITS.query)
106
+ return null;
107
+ query[k] = v;
108
+ }
109
+ }
110
+ return { core, path, query };
111
+ }
112
+ /** A link to a page: this source's path, or `loom:<path>` for one of loom's own, either with an optional query. */
113
+ function page(v, what) {
114
+ if (v === undefined || v === null)
115
+ return undefined;
116
+ const s = str(v, what);
117
+ if (!splitPage(s))
118
+ throw new Invalid(`${what} ${JSON.stringify(s)} is not a valid page link`);
119
+ return s;
120
+ }
121
+ /** Only http(s): no javascript:, data:, or relative tricks. */
122
+ function href(v, what) {
123
+ if (v === undefined || v === null)
124
+ return undefined;
125
+ const s = str(v, what);
126
+ let url;
127
+ try {
128
+ url = new URL(s);
129
+ }
130
+ catch {
131
+ throw new Invalid(`${what} must be an absolute URL`);
132
+ }
133
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
134
+ throw new Invalid(`${what} must be http(s)`);
135
+ return url.href;
136
+ }
137
+ function target(o, what) {
138
+ const p = page(o.page, `${what}.page`);
139
+ const h = href(o.href, `${what}.href`);
140
+ if (p !== undefined && h !== undefined)
141
+ throw new Invalid(`${what} has both page and href`);
142
+ return { ...(p !== undefined ? { page: p } : {}), ...(h !== undefined ? { href: h } : {}) };
143
+ }
144
+ function strMap(v, what, max) {
145
+ if (v === undefined || v === null)
146
+ return undefined;
147
+ const o = obj(v, what);
148
+ const keys = Object.keys(o);
149
+ if (keys.length > max)
150
+ throw new Invalid(`${what} has more than ${max} entries`);
151
+ const out = {};
152
+ for (const k of keys)
153
+ out[name(k, `${what} key`)] = str(o[k], `${what}.${k}`);
154
+ return out;
155
+ }
156
+ function field(v) {
157
+ const o = obj(v, 'field');
158
+ const kind = o.kind;
159
+ if (kind !== 'text' && kind !== 'textarea' && kind !== 'choice' && kind !== 'secret') {
160
+ throw new Invalid('field.kind must be text, textarea, choice or secret');
161
+ }
162
+ const f = { name: name(o.name, 'field.name'), label: str(o.label, 'field.label'), kind };
163
+ const hint = optStr(o.hint, 'field.hint');
164
+ if (hint !== undefined)
165
+ f.hint = hint;
166
+ // A secret field never carries a value: the page must not be able to show one.
167
+ if (kind !== 'secret') {
168
+ const value = optStr(o.value, 'field.value');
169
+ if (value !== undefined)
170
+ f.value = value;
171
+ }
172
+ if (bool(o.required))
173
+ f.required = true;
174
+ if (kind === 'choice') {
175
+ f.options = arr(o.options, 'field.options', LIMITS.options).map((x) => {
176
+ const opt = obj(x, 'option');
177
+ return { value: str(opt.value, 'option.value'), label: str(opt.label, 'option.label'), detail: optStr(opt.detail, 'option.detail') };
178
+ });
179
+ }
180
+ return f;
181
+ }
182
+ function block(v, depth = 0) {
183
+ const b = obj(v, 'block');
184
+ switch (b.type) {
185
+ case 'stats':
186
+ return {
187
+ type: 'stats',
188
+ items: arr(b.items, 'stats.items', LIMITS.items).map((i) => {
189
+ const o = obj(i, 'stat');
190
+ return {
191
+ label: str(o.label, 'stat.label'), value: str(o.value, 'stat.value'),
192
+ detail: optStr(o.detail, 'stat.detail'), tone: tone(o.tone), page: page(o.page, 'stat.page'),
193
+ };
194
+ }),
195
+ };
196
+ case 'list':
197
+ return {
198
+ type: 'list',
199
+ items: arr(b.items, 'list.items', LIMITS.items).map((i) => {
200
+ const o = obj(i, 'list item');
201
+ return {
202
+ text: str(o.text, 'list.text'), meta: optStr(o.meta, 'list.meta'), badge: optStr(o.badge, 'list.badge'),
203
+ tone: tone(o.tone), ...target(o, 'list item'),
204
+ };
205
+ }),
206
+ };
207
+ case 'table': {
208
+ const columns = arr(b.columns, 'table.columns', LIMITS.columns).map((c) => str(c, 'table column'));
209
+ const rows = arr(b.rows, 'table.rows', LIMITS.items).map((r) => arr(r, 'table row', columns.length).map((c) => {
210
+ if (typeof c === 'string' || typeof c === 'number')
211
+ return str(c, 'table cell');
212
+ const o = obj(c, 'table cell');
213
+ const p = page(o.page, 'table cell.page');
214
+ return p !== undefined ? { text: str(o.text, 'table cell.text'), page: p } : str(o.text, 'table cell.text');
215
+ }));
216
+ return { type: 'table', columns, rows };
217
+ }
218
+ case 'kv':
219
+ return {
220
+ type: 'kv',
221
+ items: arr(b.items, 'kv.items', LIMITS.items).map((i) => {
222
+ const o = obj(i, 'kv item');
223
+ return { label: str(o.label, 'kv.label'), value: str(o.value, 'kv.value'), page: page(o.page, 'kv.page') };
224
+ }),
225
+ };
226
+ case 'text':
227
+ return { type: 'text', text: str(b.text, 'text'), tone: tone(b.tone) };
228
+ case 'callout':
229
+ return { type: 'callout', text: str(b.text, 'callout.text'), title: optStr(b.title, 'callout.title'), tone: tone(b.tone) };
230
+ case 'link': {
231
+ const t = target(b, 'link');
232
+ if (t.page === undefined && t.href === undefined)
233
+ throw new Invalid('link needs page or href');
234
+ return { type: 'link', text: str(b.text, 'link.text'), ...t };
235
+ }
236
+ case 'filters': {
237
+ const param = str(b.param, 'filters.param');
238
+ if (!QUERY_KEY.test(param))
239
+ throw new Invalid('filters.param must be a query key');
240
+ return {
241
+ type: 'filters',
242
+ param,
243
+ options: arr(b.options, 'filters.options', LIMITS.options).map((x) => {
244
+ const o = obj(x, 'filter option');
245
+ const value = str(o.value, 'filter.value');
246
+ if (value.length > LIMITS.queryValue)
247
+ throw new Invalid('filter.value is too long');
248
+ return { value, label: str(o.label, 'filter.label') };
249
+ }),
250
+ };
251
+ }
252
+ case 'details':
253
+ if (depth > 0)
254
+ throw new Invalid('details can\'t contain details');
255
+ return {
256
+ type: 'details',
257
+ summary: str(b.summary, 'details.summary'),
258
+ meta: optStr(b.meta, 'details.meta'),
259
+ blocks: arr(b.blocks, 'details.blocks', LIMITS.blocks).map((x) => block(x, depth + 1)),
260
+ };
261
+ case 'form':
262
+ return {
263
+ type: 'form',
264
+ action: name(b.action, 'form.action'),
265
+ submit: str(b.submit, 'form.submit'),
266
+ fields: arr(b.fields, 'form.fields', LIMITS.fields).map(field),
267
+ title: optStr(b.title, 'form.title'),
268
+ note: optStr(b.note, 'form.note'),
269
+ hidden: strMap(b.hidden, 'form.hidden', LIMITS.hidden),
270
+ };
271
+ case 'buttons':
272
+ return {
273
+ type: 'buttons',
274
+ items: arr(b.items, 'buttons.items', LIMITS.fields).map((x) => {
275
+ const o = obj(x, 'button');
276
+ const t = o.tone;
277
+ if (t !== undefined && t !== 'primary' && t !== 'danger')
278
+ throw new Invalid('button.tone must be primary or danger');
279
+ return {
280
+ action: name(o.action, 'button.action'),
281
+ label: str(o.label, 'button.label'),
282
+ tone: t,
283
+ fields: strMap(o.fields, 'button.fields', LIMITS.hidden),
284
+ };
285
+ }),
286
+ };
287
+ default:
288
+ throw new Invalid(`unknown block type ${JSON.stringify(b.type)}`);
289
+ }
290
+ }
291
+ function section(v) {
292
+ const o = obj(v, 'section');
293
+ const id = str(o.id, 'section.id');
294
+ if (!ID.test(id))
295
+ throw new Invalid(`section.id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
296
+ return {
297
+ id,
298
+ title: optStr(o.title, 'section.title'),
299
+ side: bool(o.side),
300
+ tone: tone(o.tone),
301
+ blocks: arr(o.blocks, 'section.blocks', LIMITS.blocks).map((x) => block(x)),
302
+ };
303
+ }
304
+ function sections(v) {
305
+ return arr(v, 'sections', LIMITS.sections).map(section);
306
+ }
307
+ function attempt(f) {
308
+ try {
309
+ return f();
310
+ }
311
+ catch (err) {
312
+ if (err instanceof Invalid)
313
+ return err.message;
314
+ throw err;
315
+ }
316
+ }
317
+ /** Validate a piece's `ui --json` answer (v2, or a first-version `{sections}`). */
318
+ export function parseManifest(value) {
319
+ return attempt(() => {
320
+ const top = obj(value, 'answer');
321
+ if (top.v === undefined && top.sections !== undefined) {
322
+ return { asks: [], legacy: sections(top.sections) };
323
+ }
324
+ if (top.v !== 2)
325
+ throw new Invalid('v must be 2');
326
+ const m = { asks: [] };
327
+ if (top.tab !== undefined)
328
+ m.tab = { label: str(obj(top.tab, 'tab').label, 'tab.label') };
329
+ if (top.tile !== undefined) {
330
+ const t = obj(top.tile, 'tile');
331
+ m.tile = {
332
+ label: str(t.label, 'tile.label'), value: str(t.value, 'tile.value'),
333
+ detail: optStr(t.detail, 'tile.detail'), tone: tone(t.tone), page: page(t.page, 'tile.page'),
334
+ };
335
+ }
336
+ if (top.asks !== undefined) {
337
+ m.asks = arr(top.asks, 'asks', LIMITS.asks).map((x) => {
338
+ const a = obj(x, 'ask');
339
+ return { text: str(a.text, 'ask.text'), meta: optStr(a.meta, 'ask.meta'), page: page(a.page, 'ask.page'), label: optStr(a.label, 'ask.label') };
340
+ });
341
+ }
342
+ return m;
343
+ });
344
+ }
345
+ /** Validate a piece's `ui page --json` answer. */
346
+ export function parsePage(value) {
347
+ return attempt(() => {
348
+ const top = obj(value, 'page');
349
+ return { title: str(top.title, 'title'), intro: optStr(top.intro, 'intro'), sections: sections(top.sections) };
350
+ });
351
+ }
352
+ /** Validate a piece's `ui act --json` answer. */
353
+ export function parseActResult(value) {
354
+ return attempt(() => {
355
+ const top = obj(value, 'result');
356
+ if (typeof top.ok !== 'boolean')
357
+ throw new Invalid('ok must be true or false');
358
+ return { ok: top.ok, message: str(top.message, 'message'), page: page(top.page, 'page') };
359
+ });
360
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,20 +0,0 @@
1
- import { type Section } from './sections.js';
2
- export declare const PIECE_TIMEOUT_MS = 3000;
3
- export declare const PIECE_MAX_BYTES: number;
4
- /** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
5
- export declare const CACHE_MS = 45000;
6
- export interface PieceSections {
7
- piece: string;
8
- sections: Section[];
9
- }
10
- /** Why a piece contributed nothing, for the page footer (never shown as an error). */
11
- export interface PieceSkip {
12
- piece: string;
13
- reason: string;
14
- }
15
- export declare function clearPieceCache(): void;
16
- export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
17
- export declare function pieceSections(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
18
- contributed: PieceSections[];
19
- skipped: PieceSkip[];
20
- }>;
@@ -1,112 +0,0 @@
1
- /**
2
- * Sections from installed pieces: each `loom-<piece>` on PATH is asked
3
- * `ui --json`. A piece that answers with valid sections shows up on loom's
4
- * page with no configuration; one that doesn't (no `ui` verb, a timeout, bad
5
- * JSON) simply isn't there. Composes without requiring (docs/extensions.md).
6
- *
7
- * Bounded on every axis: one run per piece per cache window however many
8
- * pages load at once (the in-flight promise is what's cached), 3s and 512KB
9
- * per answer, and on a timeout the piece's whole process group is killed, not
10
- * just its first process. A piece gets the daemon's env minus loom's own
11
- * bearer tokens.
12
- */
13
- import { spawn } from 'node:child_process';
14
- import { listPieces } from '../cli/pieces.js';
15
- import { parseSections } from './sections.js';
16
- export const PIECE_TIMEOUT_MS = 3_000;
17
- export const PIECE_MAX_BYTES = 512 * 1024;
18
- /** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
19
- export const CACHE_MS = 45_000;
20
- /** After the piece exits, how long to wait for its stdout to close (a background helper may hold it). */
21
- const DRAIN_MS = 200;
22
- /** Env loom never hands to a piece. */
23
- const WITHHELD = ['LOOM_BEARER_TOKEN', 'LOOM_KNOWLEDGE_BEARER_TOKENS'];
24
- const cache = new Map();
25
- export function clearPieceCache() { cache.clear(); }
26
- export function pieceEnv(env) {
27
- const out = { ...env };
28
- for (const k of WITHHELD)
29
- delete out[k];
30
- return out;
31
- }
32
- function killGroup(pid) {
33
- if (pid === undefined)
34
- return;
35
- try {
36
- process.kill(-pid, 'SIGKILL');
37
- }
38
- catch { /* already gone */ }
39
- }
40
- function ask(path, env) {
41
- return new Promise((resolve) => {
42
- // Its own process group, so a timeout takes the piece's children with it.
43
- const child = spawn(path, ['ui', '--json'], { env: pieceEnv(env), stdio: ['ignore', 'pipe', 'ignore'], detached: true });
44
- let out = '';
45
- let bytes = 0;
46
- let settled = false;
47
- let drain;
48
- const finish = (r) => {
49
- if (settled)
50
- return;
51
- settled = true;
52
- clearTimeout(timer);
53
- if (drain)
54
- clearTimeout(drain);
55
- child.stdout?.destroy();
56
- resolve(r);
57
- };
58
- const timer = setTimeout(() => { killGroup(child.pid); finish({ ok: false, reason: 'timed out' }); }, PIECE_TIMEOUT_MS);
59
- child.stdout?.on('data', (d) => {
60
- bytes += d.length;
61
- if (bytes > PIECE_MAX_BYTES) {
62
- killGroup(child.pid);
63
- finish({ ok: false, reason: 'answer too large' });
64
- return;
65
- }
66
- out += d.toString('utf-8');
67
- });
68
- child.on('error', (e) => finish({ ok: false, reason: e.message }));
69
- // Resolve on the piece's own exit (plus a short drain), not on 'close':
70
- // 'close' waits for every holder of the pipe, and a helper the piece left
71
- // running would hold it open past the timeout.
72
- child.on('exit', (code) => {
73
- const done = () => finish(code === 0 ? { ok: true, stdout: out } : { ok: false, reason: 'no ui' });
74
- if (child.stdout?.readableEnded)
75
- return done();
76
- child.stdout?.once('end', done);
77
- drain = setTimeout(done, DRAIN_MS);
78
- });
79
- });
80
- }
81
- async function run(name, path, env) {
82
- const r = await ask(path, env);
83
- if (!r.ok)
84
- return { piece: name, reason: r.reason };
85
- let parsed;
86
- try {
87
- parsed = JSON.parse(r.stdout);
88
- }
89
- catch {
90
- return { piece: name, reason: 'invalid: not JSON' };
91
- }
92
- const sections = parseSections(parsed);
93
- return typeof sections === 'string' ? { piece: name, reason: `invalid: ${sections}` } : { piece: name, sections };
94
- }
95
- function sectionsFor(name, path, env, nowMs) {
96
- const hit = cache.get(path);
97
- if (hit && nowMs - hit.at < CACHE_MS)
98
- return hit.result;
99
- // Cache the promise itself: concurrent page loads share one run.
100
- const result = run(name, path, env);
101
- cache.set(path, { at: nowMs, result });
102
- return result;
103
- }
104
- export async function pieceSections(env, nowMs = Date.now()) {
105
- // "loom" is the core group's name on the page; a piece can't take it.
106
- const pieces = listPieces(env).filter((p) => p.name !== 'loom');
107
- const results = await Promise.all(pieces.map((p) => sectionsFor(p.name, p.path, env, nowMs)));
108
- return {
109
- contributed: results.filter((r) => 'sections' in r && r.sections.length > 0),
110
- skipped: results.filter((r) => 'reason' in r),
111
- };
112
- }