@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.6",
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) {