@khanglvm/relay 0.13.6 → 0.13.7
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/package.json +1 -1
- package/src/cli.js +6 -1
- package/src/mcp.js +10 -8
- package/src/server.js +6 -0
- package/src/spec.js +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.7",
|
|
4
4
|
"description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/src/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { CliError, sleep, pollFor } from './util.js';
|
|
7
|
-
import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
7
|
+
import { assertSpecReady, normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
8
8
|
import {
|
|
9
9
|
createBoard,
|
|
10
10
|
loadBoard,
|
|
@@ -197,6 +197,7 @@ function readFileOrThrow(p) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
async function runOrDetach(record, args) {
|
|
200
|
+
await assertSpecReady(record.spec);
|
|
200
201
|
const timeoutSec = args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800;
|
|
201
202
|
const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
|
|
202
203
|
const open = args.open !== false;
|
|
@@ -244,6 +245,7 @@ async function runOrDetach(record, args) {
|
|
|
244
245
|
async function cmdAsk(args, mode) {
|
|
245
246
|
const raw = await resolveSpecInput(args, mode);
|
|
246
247
|
const spec = normalizeSpec(raw);
|
|
248
|
+
await assertSpecReady(spec);
|
|
247
249
|
const record = createBoard(spec);
|
|
248
250
|
return runOrDetach(record, args);
|
|
249
251
|
}
|
|
@@ -275,6 +277,7 @@ async function cmdDiff(rest) {
|
|
|
275
277
|
}
|
|
276
278
|
const title = args.title || ('git diff' + (gitArgs.length ? ' ' + gitArgs.join(' ') : ''));
|
|
277
279
|
const spec = normalizeSpec({ title, blocks: [{ type: 'diff', diff, view: args.split ? 'split' : 'unified' }] });
|
|
280
|
+
await assertSpecReady(spec);
|
|
278
281
|
const record = createBoard(spec);
|
|
279
282
|
return runOrDetach(record, args);
|
|
280
283
|
}
|
|
@@ -299,6 +302,7 @@ async function cmdView(args) {
|
|
|
299
302
|
raw.title = args.title || (multi ? `${files.length} files` : path.basename(files[0]));
|
|
300
303
|
raw.submitLabel = args.submitLabel || 'Done';
|
|
301
304
|
const spec = normalizeSpec(raw); // reads + validates each file, clear error if unreadable
|
|
305
|
+
await assertSpecReady(spec);
|
|
302
306
|
const record = createBoard(spec);
|
|
303
307
|
return runOrDetach(record, args);
|
|
304
308
|
}
|
|
@@ -445,6 +449,7 @@ async function cmdUpdate(args) {
|
|
|
445
449
|
} else {
|
|
446
450
|
throw new CliError('update needs --file <spec.json>, --title, --intro, or -q "...".', 4);
|
|
447
451
|
}
|
|
452
|
+
await assertSpecReady(spec);
|
|
448
453
|
|
|
449
454
|
let res;
|
|
450
455
|
try {
|
package/src/mcp.js
CHANGED
|
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
import http from 'node:http';
|
|
20
20
|
import crypto from 'node:crypto';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { normalizeSpec, SPEC_SCHEMA } from './spec.js';
|
|
22
|
+
import { assertSpecReady, normalizeSpec, SPEC_SCHEMA } from './spec.js';
|
|
23
23
|
import { CliError } from './util.js';
|
|
24
24
|
|
|
25
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -143,7 +143,7 @@ function resourceMeta() {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// ---------- request routing ----------
|
|
146
|
-
function buildResult(method, params, clientProtocol) {
|
|
146
|
+
async function buildResult(method, params, clientProtocol) {
|
|
147
147
|
switch (method) {
|
|
148
148
|
case 'initialize':
|
|
149
149
|
return {
|
|
@@ -202,7 +202,7 @@ function readResource(params) {
|
|
|
202
202
|
// ui/update-model-context as a best-effort context sync). Spec errors come back
|
|
203
203
|
// as an isError tool result (not a protocol error) so the model can see and fix
|
|
204
204
|
// them.
|
|
205
|
-
function callTool(params) {
|
|
205
|
+
async function callTool(params) {
|
|
206
206
|
const name = params && params.name;
|
|
207
207
|
if (name !== 'relay_ask' && name !== 'relay_show') {
|
|
208
208
|
return { content: [{ type: 'text', text: 'unknown tool: ' + name }], isError: true };
|
|
@@ -211,6 +211,7 @@ function callTool(params) {
|
|
|
211
211
|
let spec;
|
|
212
212
|
try {
|
|
213
213
|
spec = normalizeSpec(args);
|
|
214
|
+
await assertSpecReady(spec);
|
|
214
215
|
} catch (err) {
|
|
215
216
|
const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
|
|
216
217
|
return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
|
|
@@ -265,7 +266,7 @@ export function runMcp() {
|
|
|
265
266
|
catch { stdoutOpen = false; }
|
|
266
267
|
};
|
|
267
268
|
|
|
268
|
-
function handleLine(line) {
|
|
269
|
+
async function handleLine(line) {
|
|
269
270
|
let msg;
|
|
270
271
|
try { msg = JSON.parse(line); } catch {
|
|
271
272
|
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
@@ -278,7 +279,7 @@ export function runMcp() {
|
|
|
278
279
|
if (typeof m.method !== 'string') continue; // a response to us — we issue none
|
|
279
280
|
let result;
|
|
280
281
|
try {
|
|
281
|
-
result = buildResult(m.method, m.params || {});
|
|
282
|
+
result = await buildResult(m.method, m.params || {});
|
|
282
283
|
} catch (err) {
|
|
283
284
|
if (isRequest) {
|
|
284
285
|
send({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
|
|
@@ -290,6 +291,7 @@ export function runMcp() {
|
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
let buf = '';
|
|
294
|
+
let queue = Promise.resolve();
|
|
293
295
|
process.stdin.setEncoding('utf8');
|
|
294
296
|
process.stdin.on('data', (chunk) => {
|
|
295
297
|
buf += chunk;
|
|
@@ -298,7 +300,7 @@ export function runMcp() {
|
|
|
298
300
|
const line = buf.slice(0, idx);
|
|
299
301
|
buf = buf.slice(idx + 1);
|
|
300
302
|
const trimmed = line.trim();
|
|
301
|
-
if (trimmed) handleLine(trimmed);
|
|
303
|
+
if (trimmed) queue = queue.then(() => handleLine(trimmed)).catch(() => {});
|
|
302
304
|
}
|
|
303
305
|
});
|
|
304
306
|
|
|
@@ -406,7 +408,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
|
|
|
406
408
|
let body = '';
|
|
407
409
|
let aborted = false;
|
|
408
410
|
req.on('data', (c) => { body += c; if (body.length > MAX_BODY) { aborted = true; req.destroy(); } });
|
|
409
|
-
req.on('end', () => {
|
|
411
|
+
req.on('end', async () => {
|
|
410
412
|
if (aborted) { res.writeHead(413, base); return res.end(); }
|
|
411
413
|
let msg;
|
|
412
414
|
try { msg = JSON.parse(body); } catch {
|
|
@@ -421,7 +423,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
|
|
|
421
423
|
if (m.method === 'initialize') isInit = true;
|
|
422
424
|
const isRequest = m.id !== undefined && m.id !== null;
|
|
423
425
|
try {
|
|
424
|
-
const result = buildResult(m.method, m.params || {});
|
|
426
|
+
const result = await buildResult(m.method, m.params || {});
|
|
425
427
|
if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, result });
|
|
426
428
|
} catch (err) {
|
|
427
429
|
if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
|
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
|
|
9
9
|
import { openUrl } from './open.js';
|
|
10
|
+
import { assertSpecReady } from './spec.js';
|
|
10
11
|
|
|
11
12
|
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
|
|
12
13
|
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -592,6 +593,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
592
593
|
!Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
|
|
593
594
|
return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
|
|
594
595
|
}
|
|
596
|
+
try {
|
|
597
|
+
await assertSpecReady(next);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
return sendJson(res, 400, { error: err && err.message ? err.message : String(err) });
|
|
600
|
+
}
|
|
595
601
|
record.spec = next;
|
|
596
602
|
rev++;
|
|
597
603
|
saveBoard(record);
|
package/src/spec.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { CliError } from './util.js';
|
|
4
6
|
|
|
5
7
|
export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color', 'rank', 'checklist', 'allocate'];
|
|
@@ -65,6 +67,9 @@ const IMAGE_MIMES = {
|
|
|
65
67
|
webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', bmp: 'image/bmp',
|
|
66
68
|
};
|
|
67
69
|
const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
70
|
+
const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vendor');
|
|
71
|
+
|
|
72
|
+
let mermaidValidatorPromise = null;
|
|
68
73
|
|
|
69
74
|
const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
70
75
|
|
|
@@ -222,6 +227,99 @@ function parseVideoEmbed(src) {
|
|
|
222
227
|
return null;
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
function compactError(err) {
|
|
231
|
+
const raw = err && (err.str || err.message) ? (err.str || err.message) : String(err || 'unknown error');
|
|
232
|
+
const msg = String(raw).replace(/\s+/g, ' ').trim();
|
|
233
|
+
return msg.length > 500 ? msg.slice(0, 497) + '...' : msg;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function mermaidValidator() {
|
|
237
|
+
if (mermaidValidatorPromise) return mermaidValidatorPromise;
|
|
238
|
+
mermaidValidatorPromise = (async () => {
|
|
239
|
+
const sourcePath = path.join(VENDOR_DIR, 'mermaid.min.js');
|
|
240
|
+
let source;
|
|
241
|
+
try {
|
|
242
|
+
source = fs.readFileSync(sourcePath, 'utf8');
|
|
243
|
+
} catch {
|
|
244
|
+
throw new CliError('mermaid blocks need the vendored parser (vendor/mermaid.min.js is missing).');
|
|
245
|
+
}
|
|
246
|
+
const quietConsole = { log() {}, info() {}, warn() {}, error() {}, debug() {} };
|
|
247
|
+
const context = {
|
|
248
|
+
console: quietConsole,
|
|
249
|
+
setTimeout,
|
|
250
|
+
clearTimeout,
|
|
251
|
+
};
|
|
252
|
+
context.globalThis = context;
|
|
253
|
+
context.window = { addEventListener() {} };
|
|
254
|
+
vm.createContext(context);
|
|
255
|
+
try {
|
|
256
|
+
vm.runInContext(source, context, { filename: sourcePath });
|
|
257
|
+
} catch (err) {
|
|
258
|
+
throw new CliError(`could not load mermaid parser: ${compactError(err)}`);
|
|
259
|
+
}
|
|
260
|
+
if (!context.mermaid || typeof context.mermaid.parse !== 'function') {
|
|
261
|
+
throw new CliError('could not load mermaid parser: vendor/mermaid.min.js did not expose mermaid.parse.');
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
context.mermaid.initialize({
|
|
265
|
+
startOnLoad: false,
|
|
266
|
+
securityLevel: 'strict',
|
|
267
|
+
suppressErrorRendering: true,
|
|
268
|
+
});
|
|
269
|
+
} catch {
|
|
270
|
+
// Some Mermaid versions dislike repeated initialize calls; parse below is
|
|
271
|
+
// the real readiness gate.
|
|
272
|
+
}
|
|
273
|
+
return context.mermaid;
|
|
274
|
+
})();
|
|
275
|
+
return mermaidValidatorPromise;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function assertMermaidSyntax(code, where) {
|
|
279
|
+
const mermaid = await mermaidValidator();
|
|
280
|
+
try {
|
|
281
|
+
const parsed = mermaid.parse(code);
|
|
282
|
+
if (parsed && typeof parsed.then === 'function') await parsed;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new CliError(`${where}: invalid mermaid syntax — ${compactError(err)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function* allBlocks(spec) {
|
|
289
|
+
const boardBlocks = Array.isArray(spec.blocks) ? spec.blocks : [];
|
|
290
|
+
for (let i = 0; i < boardBlocks.length; i++) {
|
|
291
|
+
yield { block: boardBlocks[i], where: `board.blocks[${i}]${boardBlocks[i]?.id ? ` (${boardBlocks[i].id})` : ''}` };
|
|
292
|
+
}
|
|
293
|
+
const questions = Array.isArray(spec.questions) ? spec.questions : [];
|
|
294
|
+
for (let qi = 0; qi < questions.length; qi++) {
|
|
295
|
+
const q = questions[qi] || {};
|
|
296
|
+
const qBlocks = Array.isArray(q.blocks) ? q.blocks : [];
|
|
297
|
+
for (let bi = 0; bi < qBlocks.length; bi++) {
|
|
298
|
+
yield { block: qBlocks[bi], where: `questions[${qi}].blocks[${bi}]${qBlocks[bi]?.id ? ` (${qBlocks[bi].id})` : ''}` };
|
|
299
|
+
}
|
|
300
|
+
const opts = Array.isArray(q.options) ? q.options : [];
|
|
301
|
+
for (let oi = 0; oi < opts.length; oi++) {
|
|
302
|
+
const oBlocks = Array.isArray(opts[oi]?.blocks) ? opts[oi].blocks : [];
|
|
303
|
+
for (let bi = 0; bi < oBlocks.length; bi++) {
|
|
304
|
+
yield {
|
|
305
|
+
block: oBlocks[bi],
|
|
306
|
+
where: `questions[${qi}].options[${oi}].blocks[${bi}]${oBlocks[bi]?.id ? ` (${oBlocks[bi].id})` : ''}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function assertSpecReady(spec) {
|
|
314
|
+
if (!spec || typeof spec !== 'object') return spec;
|
|
315
|
+
if (spec.__relayReady === true) return spec;
|
|
316
|
+
for (const { block, where } of allBlocks(spec)) {
|
|
317
|
+
if (block && block.type === 'mermaid') await assertMermaidSyntax(block.code || '', where);
|
|
318
|
+
}
|
|
319
|
+
Object.defineProperty(spec, '__relayReady', { value: true, enumerable: false, configurable: true });
|
|
320
|
+
return spec;
|
|
321
|
+
}
|
|
322
|
+
|
|
225
323
|
// Normalizes one block object. `id` is the already-assigned block id.
|
|
226
324
|
// Returns the normalized block (with a guaranteed string `type` + `id`).
|
|
227
325
|
function normalizeBlock(rawBlock, id, cwd, where) {
|