@khanglvm/relay 0.3.0 → 0.4.1

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 CHANGED
@@ -356,6 +356,17 @@ Exit codes: `0` submitted/acknowledged · `2` timeout · `3` cancelled ·
356
356
 
357
357
  Storage: `~/.relay` (override with `RLY_HOME`). Boards bind to `127.0.0.1` only.
358
358
 
359
+ ## Presence, push-wake & diagram co-editing (v0.4)
360
+
361
+ While a board is open the page reports user activity; `rly result <id>`
362
+ includes `presence` ({visible, focused, secondsSinceActivity}) and
363
+ `rly wait <id> --while-active --idle-grace 180` keeps waiting while the user
364
+ is demonstrably engaged instead of dying on a fixed timer. For pushes,
365
+ `--on-result '<cmd>'` (on ask/show/reopen/reuse) and `rly wait --notify-cmd`
366
+ execute your command with the result JSON on stdin the moment the board
367
+ finishes. Mermaid blocks with `"editable": true` let the user edit the diagram
368
+ source with live preview — their version returns as `result.blockEdits`.
369
+
359
370
  ## Agent skill (Claude Code, Codex, …)
360
371
 
361
372
  A universal [SKILL.md](skills/relay/SKILL.md) is bundled:
package/docs/AGENT.md CHANGED
@@ -321,6 +321,53 @@ annotation IDs cause a `CliError` (exit 4) listing valid IDs.
321
321
  The UI shows agent and user replies as a thread under each comment — agent replies
322
322
  use an accent chip, user replies use a muted chip.
323
323
 
324
+ ## Presence — is the user still there?
325
+
326
+ While a board is open, the page reports activity (visibility, focus, idle
327
+ time). Use it instead of guessing timeouts:
328
+
329
+ ```sh
330
+ rly result b-xxxxx # open board → includes "presence":
331
+ # {open, seen, visible, focused, secondsSinceActivity, secondsSincePing}
332
+ rly wait b-xxxxx --timeout 550 --while-active --idle-grace 180
333
+ ```
334
+
335
+ `--while-active` keeps extending the wait as long as the user is demonstrably
336
+ active (page visible/focused and interaction within `--idle-grace` seconds,
337
+ default 180); once they go idle it returns the normal `wait-timeout` JSON,
338
+ with `presence` attached so you can decide what to do next. Prefer this over
339
+ raising `--timeout`.
340
+
341
+ ## Push-wake — get notified instead of polling
342
+
343
+ ```sh
344
+ rly ask --file spec.json --detach --on-result 'curl -s -X POST localhost:9999/wake -d @-'
345
+ rly wait b-xxxxx --notify-cmd 'touch /tmp/board-done'
346
+ ```
347
+
348
+ `--on-result` (on ask/show/reopen/reuse) runs your shell command the moment
349
+ the board reaches a terminal status — submitted, acknowledged, timeout, or
350
+ cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
351
+ `RLY_STATUS`, `RLY_URL` in the environment. `--notify-cmd` does the same from
352
+ a `wait` that obtains a terminal result. Write a file your harness watches,
353
+ hit a webhook — whatever wakes you.
354
+
355
+ ## Editable diagrams — let the user redraw your mermaid
356
+
357
+ Add `"editable": true` to any mermaid block. The user gets an "Edit diagram"
358
+ button with live-preview source editing (syntax errors shown inline without
359
+ destroying the last good render; Reset restores your original). Their edited
360
+ source returns in the result:
361
+
362
+ ```json
363
+ "blockEdits": { "b2": "graph TD; A-->B; B-->C[their new step]" }
364
+ ```
365
+
366
+ Diff it against your original to see exactly what the user changed. Recipe:
367
+ propose an architecture as an editable mermaid block + a `yesno` "Does this
368
+ match your mental model?" + a `textarea` for anything the diagram can't say.
369
+ Edits autosave with the draft, so they survive reloads and timeouts too.
370
+
324
371
  ## Managing boards
325
372
 
326
373
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, sandboxed HTML) and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: relay
3
- description: Ask the user interactive questions in a browser board (single/multi choice, yes-no, free text, scale) and/or present rich content blocks (markdown, mermaid diagrams, graphviz, plantuml, uml, charts, interactive tables, code, sandboxed HTML), then wait for Submit and read JSON answers plus element-level annotations. PROACTIVELY use whenever you would otherwise (a) call a native ask-user/question tool with 2+ questions or options that need explanation, (b) describe a UI/design/plan/architecture in prose that a visual would show better draft diagrams (mermaid, graphviz, plantuml), charts, and interactive tables as native blocks, or (c) hand-roll an HTML file or local server to demo an idea - relay replaces all three. Triggers - clarify requirements before ambiguous work, choose between approaches, plan approval, design/UX feedback, mockup or prototype review, compare alternatives, survey, metrics review, "ask the user", "show the user", "which do you prefer", "get feedback", diagram, chart, data table, architecture overview, metrics, dependency graph, sequence diagram, class diagram, uml, graphviz, plantuml. Skip only for a single trivial yes/no confirmation.
3
+ description: "Show the user anything visual in an interactive browser board - repo/file structures, architecture diagrams (mermaid, graphviz, plantuml, uml), charts, sortable tables, code, prototypes - and/or ask structured questions (choice, yes-no, text, scale), then wait for Submit and read JSON answers plus element comments and user-edited diagrams. PROACTIVELY use instead of (a) drawing an ASCII tree/table/diagram in the terminal or describing a structure/design/plan in prose, (b) a native ask-user tool for 2+ questions or options needing explanation, (c) hand-rolling an HTML demo + server. Triggers: show me the structure, repo/folder structure, file tree, directory layout, codebase map, architecture overview, dependency graph, sequence/class diagram, uml, visualize, diagram, chart, data table, metrics review, prototype review, plan approval, design feedback, compare alternatives, edit the diagram, clarify requirements, survey, ask the user, show the user, get feedback. Skip for a single trivial yes/no confirmation."
4
4
  ---
5
5
 
6
6
  # relay (`rly`)
@@ -45,6 +45,19 @@ rly wait b-xxxxx --timeout 550 # blocks until submit, prints result JSON
45
45
  rly result b-xxxxx # non-blocking peek (includes live draft)
46
46
  ```
47
47
 
48
+ For long waits prefer presence-aware waiting over a huge --timeout:
49
+
50
+ ```sh
51
+ rly wait b-xxxxx --timeout 550 --while-active --idle-grace 180
52
+ # keeps extending while the user is demonstrably viewing/typing on the board;
53
+ # returns wait-timeout promptly once they are idle/gone (presence included)
54
+ rly result b-xxxxx # while open also shows presence {visible, focused, secondsSinceActivity}
55
+ ```
56
+
57
+ Push-wake instead of polling: add --on-result '<shell cmd>' to ask/show/reopen
58
+ (or --notify-cmd on wait) - the command runs the moment the board finishes,
59
+ with the full result JSON on stdin and RLY_BOARD_ID/RLY_STATUS/RLY_URL in env.
60
+
48
61
  Blocking mode (`rly ask --file spec.json --timeout 1800`, no --detach) is fine
49
62
  ONLY when your shell tool has no execution time limit.
50
63
 
@@ -168,6 +181,14 @@ numbers, followed by a `table` block for the raw data; at least one question
168
181
  asking what to act on. In the intro, tell the user they can click chart points
169
182
  and table cells to comment on specific values.
170
183
 
184
+ ## Diagram co-editing (user edits your diagram)
185
+
186
+ Add "editable": true to a mermaid block: the user gets an Edit button with
187
+ live-preview source editing. Their version comes back as
188
+ result.blockEdits["<blockId>"] - diff it against your original to see what
189
+ they changed. Recipe: propose an architecture as an editable mermaid block +
190
+ one yesno "Does this match your mental model?" + a textarea for notes.
191
+
171
192
  ## Reuse & management
172
193
 
173
194
  `rly history` (saved boards) · `rly spec <id>` (print spec to modify) ·
@@ -0,0 +1,15 @@
1
+ {
2
+ "title": "Architecture review — redraw it if I got it wrong",
3
+ "intro": "Below is my proposed architecture. If it doesn't match your mental model, click 'Edit diagram' and change it directly — I'll read your version.",
4
+ "blocks": [
5
+ {
6
+ "type": "mermaid",
7
+ "editable": true,
8
+ "code": "graph TD; client[Web Client] --> api[API Gateway]; api --> auth[Auth Service]; api --> core[Core Service]; core --> db[(Postgres)]; core --> queue[[Job Queue]]"
9
+ }
10
+ ],
11
+ "questions": [
12
+ { "id": "match", "type": "yesno", "label": "Does this match your mental model (after your edits, if any)?", "required": true },
13
+ { "id": "notes", "type": "textarea", "label": "Anything the diagram can't express?", "placeholder": "constraints, scaling concerns, naming…" }
14
+ ]
15
+ }
package/src/cli.js CHANGED
@@ -28,6 +28,7 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'),
28
28
  const VALUED_FLAGS = new Set([
29
29
  'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
30
30
  'submit-label', 'height', 'limit', 'target', 'id', 'replies',
31
+ 'on-result', 'notify-cmd', 'idle-grace',
31
32
  ]);
32
33
 
33
34
  function camel(key) {
@@ -71,6 +72,44 @@ function printJson(obj) {
71
72
  process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
72
73
  }
73
74
 
75
+ // Push-wake for `rly wait --notify-cmd`: run the agent's local shell command
76
+ // once a TERMINAL result lands. Result JSON goes to the command's stdin;
77
+ // RLY_BOARD_ID / RLY_STATUS / RLY_URL are exported. Same shape as the server's
78
+ // --on-result hook. Failures are swallowed; a 30s kill timer caps a hung cmd.
79
+ function runNotifyCmd(cmd, result) {
80
+ if (typeof cmd !== 'string' || !cmd.trim()) return;
81
+ try {
82
+ const child = spawn('/bin/sh', ['-c', cmd], {
83
+ env: {
84
+ ...process.env,
85
+ RLY_BOARD_ID: result.boardId || '',
86
+ RLY_STATUS: result.status || '',
87
+ RLY_URL: result.url || '',
88
+ },
89
+ stdio: ['pipe', 'ignore', 'ignore'],
90
+ });
91
+ child.on('error', () => {});
92
+ try {
93
+ child.stdin.write(JSON.stringify(result));
94
+ child.stdin.end();
95
+ } catch {
96
+ // best effort
97
+ }
98
+ const killTimer = setTimeout(() => {
99
+ try {
100
+ child.kill('SIGKILL');
101
+ } catch {
102
+ // already gone
103
+ }
104
+ }, 30000);
105
+ killTimer.unref();
106
+ child.on('close', () => clearTimeout(killTimer));
107
+ child.unref();
108
+ } catch {
109
+ // swallow — push-wake is best effort
110
+ }
111
+ }
112
+
74
113
  function exitCodeFor(status) {
75
114
  return { submitted: 0, acknowledged: 0, open: 0, timeout: 2, cancelled: 3 }[status] ?? 1;
76
115
  }
@@ -146,6 +185,15 @@ async function runOrDetach(record, args) {
146
185
  const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
147
186
  const open = args.open !== false;
148
187
 
188
+ // Push-wake: persist the agent's --on-result command on the record so BOTH
189
+ // the inline runBoard path and the detached __serve path pick it up (the
190
+ // detached server reads record.onResult from disk). Runtime concern only —
191
+ // not part of the spec.
192
+ if (typeof args.onResult === 'string' && args.onResult.trim()) {
193
+ record.onResult = args.onResult;
194
+ saveBoard(record);
195
+ }
196
+
149
197
  if (args.detach) {
150
198
  const child = spawn(
151
199
  process.execPath,
@@ -313,25 +361,52 @@ async function cmdUpdate(args) {
313
361
  return 0;
314
362
  }
315
363
 
364
+ // Best-effort fetch of /api/presence for a running board. Returns the parsed
365
+ // presence object, or null on any failure / timeout (500ms cap via
366
+ // AbortController). Never throws.
367
+ async function fetchPresence(url) {
368
+ if (!url) return null;
369
+ const controller = new AbortController();
370
+ const timer = setTimeout(() => controller.abort(), 500);
371
+ try {
372
+ const res = await fetch(new URL('/api/presence', url), { signal: controller.signal });
373
+ if (!res.ok) return null;
374
+ return await res.json();
375
+ } catch {
376
+ return null;
377
+ } finally {
378
+ clearTimeout(timer);
379
+ }
380
+ }
381
+
316
382
  async function cmdWait(args) {
317
383
  const id = args._[0];
318
- if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>]');
384
+ if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>] [--while-active] [--idle-grace <sec>] [--notify-cmd <cmd>]');
319
385
  const timeoutSec = args.timeout !== undefined ? Math.max(1, Number.parseInt(args.timeout, 10) || 1) : 3600;
320
- const deadline = Date.now() + timeoutSec * 1000;
386
+ const whileActive = args.whileActive === true;
387
+ const idleGrace = args.idleGrace !== undefined ? Math.max(0, Number.parseInt(args.idleGrace, 10) || 0) : 180;
388
+ const notifyCmd = typeof args.notifyCmd === 'string' && args.notifyCmd.trim() ? args.notifyCmd : null;
389
+ let deadline = Date.now() + timeoutSec * 1000;
321
390
  mustLoad(id);
391
+
392
+ // Push-wake: run the agent's --notify-cmd after a TERMINAL result, then print.
393
+ const finishResult = (result) => {
394
+ if (notifyCmd) runNotifyCmd(notifyCmd, result);
395
+ printJson(result);
396
+ return exitCodeFor(result.status);
397
+ };
398
+
322
399
  while (Date.now() < deadline) {
323
400
  const record = mustLoad(id);
324
401
  if (record.result && record.result.finishedAt) {
325
- printJson(record.result);
326
- return exitCodeFor(record.result.status);
402
+ return finishResult(record.result);
327
403
  }
328
404
  const running = loadRunning(id);
329
405
  if (!running || !isAlive(running.pid)) {
330
406
  await sleep(700); // the result write may be racing the process exit
331
407
  const again = loadBoard(id);
332
408
  if (again?.result?.finishedAt) {
333
- printJson(again.result);
334
- return exitCodeFor(again.result.status);
409
+ return finishResult(again.result);
335
410
  }
336
411
  printJson({
337
412
  status: 'lost',
@@ -341,17 +416,93 @@ async function cmdWait(args) {
341
416
  });
342
417
  return 5;
343
418
  }
419
+ if (Date.now() >= deadline) break;
344
420
  await sleep(400);
345
421
  }
346
- printJson({
422
+
423
+ // Deadline hit while the board is still OPEN. Fetch presence (cheaply).
424
+ const running = loadRunning(id);
425
+ const presence = running && isAlive(running.pid) ? await fetchPresence(running.url) : null;
426
+
427
+ // --while-active: if the user is present + recently active, EXTEND and keep
428
+ // waiting (repeat indefinitely while they stay active).
429
+ if (
430
+ whileActive &&
431
+ presence &&
432
+ presence.seen &&
433
+ (presence.visible || presence.focused) &&
434
+ presence.secondsSinceActivity < idleGrace
435
+ ) {
436
+ deadline = Date.now() + Math.min(idleGrace, 120) * 1000;
437
+ return cmdWaitLoop(id, deadline, { whileActive, idleGrace, notifyCmd });
438
+ }
439
+
440
+ const out = {
347
441
  status: 'wait-timeout',
348
442
  boardId: id,
349
443
  hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
350
- });
444
+ };
445
+ if (presence) out.presence = presence;
446
+ printJson(out);
351
447
  return 2;
352
448
  }
353
449
 
354
- function cmdResult(args) {
450
+ // Continuation loop for `rly wait --while-active` after a deadline extension.
451
+ // Identical waiting logic to cmdWait's main loop, then re-evaluates presence;
452
+ // extends again while the user stays active, otherwise emits wait-timeout.
453
+ async function cmdWaitLoop(id, deadline, opts) {
454
+ const { whileActive, idleGrace, notifyCmd } = opts;
455
+ const finishResult = (result) => {
456
+ if (notifyCmd) runNotifyCmd(notifyCmd, result);
457
+ printJson(result);
458
+ return exitCodeFor(result.status);
459
+ };
460
+ while (Date.now() < deadline) {
461
+ const record = mustLoad(id);
462
+ if (record.result && record.result.finishedAt) {
463
+ return finishResult(record.result);
464
+ }
465
+ const running = loadRunning(id);
466
+ if (!running || !isAlive(running.pid)) {
467
+ await sleep(700);
468
+ const again = loadBoard(id);
469
+ if (again?.result?.finishedAt) {
470
+ return finishResult(again.result);
471
+ }
472
+ printJson({
473
+ status: 'lost',
474
+ boardId: id,
475
+ draft: again?.draft ?? null,
476
+ error: 'board server exited without writing a result',
477
+ });
478
+ return 5;
479
+ }
480
+ if (Date.now() >= deadline) break;
481
+ await sleep(400);
482
+ }
483
+ const running = loadRunning(id);
484
+ const presence = running && isAlive(running.pid) ? await fetchPresence(running.url) : null;
485
+ if (
486
+ whileActive &&
487
+ presence &&
488
+ presence.seen &&
489
+ (presence.visible || presence.focused) &&
490
+ presence.secondsSinceActivity < idleGrace
491
+ ) {
492
+ const next = Date.now() + Math.min(idleGrace, 120) * 1000;
493
+ return cmdWaitLoop(id, next, opts);
494
+ }
495
+ const out = {
496
+ status: 'wait-timeout',
497
+ boardId: id,
498
+ hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
499
+ };
500
+ if (presence) out.presence = presence;
501
+ printJson(out);
502
+ return 2;
503
+ }
504
+
505
+ async function cmdResult(args) {
355
506
  const record = mustLoad(args._[0]);
356
507
  if (record.result && record.result.finishedAt) {
357
508
  printJson(record.result);
@@ -359,8 +510,12 @@ function cmdResult(args) {
359
510
  }
360
511
  const running = loadRunning(record.id);
361
512
  if (running && isAlive(running.pid)) {
362
- // While open, expose the real-time autosaved draft so agents can peek.
363
- printJson({ status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null });
513
+ // While open, expose the real-time autosaved draft so agents can peek, plus
514
+ // best-effort presence (whether the user is still viewing/focused/active).
515
+ const out = { status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null };
516
+ const presence = await fetchPresence(running.url);
517
+ if (presence) out.presence = presence;
518
+ printJson(out);
364
519
  return 0;
365
520
  }
366
521
  printJson({ status: 'lost', boardId: record.id, draft: record.draft ?? null });
@@ -497,6 +652,9 @@ const SKILL_SRC = path.join(PKG_ROOT, 'skills', 'relay');
497
652
  const KNOWN_SKILL_DIRS = () => ({
498
653
  claude: path.join(os.homedir(), '.claude', 'skills', 'relay'),
499
654
  codex: path.join(os.homedir(), '.codex', 'skills', 'relay'),
655
+ // The cross-agent skills dir (npx-skills ecosystem). Some Codex/agent
656
+ // setups load skills from here INSTEAD of ~/.codex/skills.
657
+ agents: path.join(os.homedir(), '.agents', 'skills', 'relay'),
500
658
  });
501
659
 
502
660
  // Pre-rename skill dirs (quest-board). `skill install` removes these so a
@@ -547,19 +705,15 @@ function skillFreshnessWarning() {
547
705
  }
548
706
 
549
707
  function skillTargets(target) {
550
- const home = os.homedir();
551
- const known = {
552
- claude: path.join(home, '.claude', 'skills', 'relay'),
553
- codex: path.join(home, '.codex', 'skills', 'relay'),
554
- };
708
+ const known = KNOWN_SKILL_DIRS();
555
709
  if (!target || target === true || target === 'auto') {
556
710
  const found = Object.values(known).filter((p) => fs.existsSync(path.dirname(path.dirname(p))));
557
711
  if (!found.length) {
558
- throw new CliError('no agent dirs found (~/.claude or ~/.codex). Use --target claude|codex|both|<dir>.');
712
+ throw new CliError('no agent dirs found (~/.claude, ~/.codex, or ~/.agents). Use --target claude|codex|agents|all|<dir>.');
559
713
  }
560
714
  return found;
561
715
  }
562
- if (target === 'both') return Object.values(known);
716
+ if (target === 'both' || target === 'all') return Object.values(known);
563
717
  if (known[target]) return [known[target]];
564
718
  return [path.join(path.resolve(target), 'relay')];
565
719
  }
@@ -643,9 +797,13 @@ USAGE
643
797
  rly ask -q "Deploy?::yesno" -q "!Env::single::dev,staging,prod"
644
798
  quick inline questions ("!" = required, label::type::options)
645
799
  rly ask ... --detach no blocking: prints {boardId,url} now; collect via \`rly wait <id>\`
800
+ rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
646
801
  rly show --html-file viz.html visualization-only board (submit button = acknowledge)
647
802
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
648
- rly result <id> result/status now (includes live autosaved draft while open)
803
+ --while-active [--idle-grace 180]: keep waiting past the deadline
804
+ while the user is still viewing/focused & recently active
805
+ --notify-cmd "<cmd>": run <cmd> on a terminal result (JSON on stdin)
806
+ rly result <id> result/status now (includes live autosaved draft + presence while open)
649
807
  rly list [--json] running boards
650
808
  rly open [id] re-open the browser tab of a running board
651
809
  rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
@@ -706,7 +864,7 @@ export async function main(argv) {
706
864
  case 'wait':
707
865
  return await cmdWait(parseArgs(rest));
708
866
  case 'result':
709
- return cmdResult(parseArgs(rest));
867
+ return await cmdResult(parseArgs(rest));
710
868
  case 'list':
711
869
  return cmdList(parseArgs(rest));
712
870
  case 'open':
package/src/server.js CHANGED
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
7
8
  import { openUrl } from './open.js';
@@ -80,6 +81,7 @@ function buildPage(record, rev) {
80
81
  comment: record.draft.comment || '',
81
82
  notes: record.draft.notes || {},
82
83
  annotations: record.draft.annotations || [],
84
+ blockEdits: record.draft.blockEdits || {},
83
85
  }
84
86
  : null;
85
87
  const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
@@ -130,6 +132,24 @@ function sanitizeAnnotations(value) {
130
132
  return out;
131
133
  }
132
134
 
135
+ // Validates + sanitizes an incoming blockEdits map (from draft/submit).
136
+ // Keeps only string-keyed entries with string values <= 20000 chars; caps at
137
+ // 50 entries; drops invalid entries. Returns {} when nothing valid is present.
138
+ function sanitizeBlockEdits(value) {
139
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
140
+ const out = {};
141
+ let n = 0;
142
+ for (const key of Object.keys(value)) {
143
+ if (n >= 50) break;
144
+ if (typeof key !== 'string') continue;
145
+ const v = value[key];
146
+ if (typeof v !== 'string' || v.length > 20000) continue;
147
+ out[key] = v;
148
+ n++;
149
+ }
150
+ return out;
151
+ }
152
+
133
153
  // Resolves an html block body by id from the board or any question scope.
134
154
  function findHtmlBlock(spec, blockId) {
135
155
  const scan = (blocks) => (Array.isArray(blocks) ? blocks.find((b) => b && b.id === blockId && b.type === 'html') : undefined);
@@ -214,6 +234,47 @@ function readBody(req, limit = 5 * 1024 * 1024) {
214
234
  });
215
235
  }
216
236
 
237
+ // Push-wake: run the agent's own local shell command after a board finishes.
238
+ // The full result JSON is written to the command's stdin; RLY_BOARD_ID /
239
+ // RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
240
+ // only a stderr note when not quiet. A 30s kill timer prevents a hung command
241
+ // from keeping the process alive.
242
+ function runOnResult(cmd, result, { quiet = false } = {}) {
243
+ if (typeof cmd !== 'string' || !cmd.trim()) return;
244
+ try {
245
+ const child = spawn('/bin/sh', ['-c', cmd], {
246
+ env: {
247
+ ...process.env,
248
+ RLY_BOARD_ID: result.boardId || '',
249
+ RLY_STATUS: result.status || '',
250
+ RLY_URL: result.url || '',
251
+ },
252
+ stdio: ['pipe', 'ignore', 'ignore'],
253
+ });
254
+ child.on('error', () => {
255
+ if (!quiet) process.stderr.write(`[relay] --on-result command failed to spawn\n`);
256
+ });
257
+ try {
258
+ child.stdin.write(JSON.stringify(result));
259
+ child.stdin.end();
260
+ } catch {
261
+ // best effort — stdin may already be gone
262
+ }
263
+ const killTimer = setTimeout(() => {
264
+ try {
265
+ child.kill('SIGKILL');
266
+ } catch {
267
+ // already gone
268
+ }
269
+ }, 30000);
270
+ killTimer.unref();
271
+ child.on('close', () => clearTimeout(killTimer));
272
+ child.unref();
273
+ } catch {
274
+ if (!quiet) process.stderr.write(`[relay] --on-result command failed to run\n`);
275
+ }
276
+ }
277
+
217
278
  // Serves one board on 127.0.0.1 and resolves `done` when it finishes
218
279
  // (submitted / acknowledged / timeout / cancelled). The result is also
219
280
  // persisted into the board record so `rly wait` / `rly result` can read it
@@ -233,6 +294,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
233
294
  comment: record.result.comment || '',
234
295
  notes: record.result.notes || {},
235
296
  annotations: record.result.annotations || [],
297
+ blockEdits: record.result.blockEdits || {},
236
298
  updatedAt: new Date().toISOString(),
237
299
  };
238
300
  }
@@ -248,6 +310,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
248
310
  let rev = 1;
249
311
  let status = 'open';
250
312
  let finished = false;
313
+ // Latest client presence ping (null until the first ping arrives).
314
+ let presence = null;
251
315
  let resolveDone;
252
316
  const done = new Promise((r) => {
253
317
  resolveDone = r;
@@ -264,6 +328,31 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
264
328
  sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
265
329
  } else if (req.method === 'GET' && pathname === '/api/status') {
266
330
  sendJson(res, 200, { status, rev });
331
+ } else if (req.method === 'POST' && pathname === '/api/ping') {
332
+ const body = JSON.parse((await readBody(req)) || '{}');
333
+ // Validate body shape: visible/focused booleans, idleMs finite >= 0.
334
+ if (
335
+ typeof body.visible === 'boolean' &&
336
+ typeof body.focused === 'boolean' &&
337
+ Number.isFinite(body.idleMs) &&
338
+ body.idleMs >= 0
339
+ ) {
340
+ presence = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
341
+ }
342
+ sendJson(res, 200, { ok: true });
343
+ } else if (req.method === 'GET' && pathname === '/api/presence') {
344
+ if (!presence) {
345
+ sendJson(res, 200, { open: true, seen: false });
346
+ } else {
347
+ sendJson(res, 200, {
348
+ open: true,
349
+ seen: true,
350
+ visible: presence.visible,
351
+ focused: presence.focused,
352
+ secondsSinceActivity: Math.round((Date.now() - presence.atMs + presence.idleMs) / 1000),
353
+ secondsSincePing: Math.round((Date.now() - presence.atMs) / 1000),
354
+ });
355
+ }
267
356
  } else if (req.method === 'POST' && pathname === '/api/update') {
268
357
  if (req.headers['x-relay-token'] !== token) return sendJson(res, 403, { error: 'forbidden' });
269
358
  const body = JSON.parse((await readBody(req)) || '{}');
@@ -311,6 +400,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
311
400
  comment: typeof body.comment === 'string' ? body.comment : '',
312
401
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
313
402
  annotations: sanitizeAnnotations(body.annotations),
403
+ blockEdits: sanitizeBlockEdits(body.blockEdits),
314
404
  updatedAt: new Date().toISOString(),
315
405
  };
316
406
  saveBoard(record);
@@ -328,6 +418,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
328
418
  comment: typeof body.comment === 'string' ? body.comment : '',
329
419
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
330
420
  annotations: sanitizeAnnotations(body.annotations),
421
+ blockEdits: sanitizeBlockEdits(body.blockEdits),
331
422
  });
332
423
  } else {
333
424
  sendJson(res, 404, { error: 'not found' });
@@ -363,6 +454,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
363
454
  if (finished) return;
364
455
  finished = true;
365
456
  status = partial.status;
457
+ // blockEdits: from this submit, else fall back to the autosaved draft
458
+ // (timeout/cancel). null when there are none.
459
+ const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
460
+ const blockEdits = editsRaw && Object.keys(editsRaw).length ? editsRaw : null;
366
461
  const result = {
367
462
  status: partial.status,
368
463
  boardId: record.id,
@@ -373,6 +468,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
373
468
  comment: partial.comment ?? '',
374
469
  notes: partial.notes ?? null,
375
470
  annotations: partial.annotations ?? (record.draft?.annotations || []),
471
+ blockEdits,
376
472
  createdAt: record.createdAt,
377
473
  finishedAt: new Date().toISOString(),
378
474
  durationMs: Date.now() - startedAt,
@@ -384,6 +480,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
384
480
  }
385
481
  record.result = result;
386
482
  saveBoard(record);
483
+ // Push-wake: run the agent's local command for EVERY terminal status.
484
+ runOnResult(record.onResult, result, { quiet });
387
485
  removeRunning(record.id);
388
486
  if (timer) clearTimeout(timer);
389
487
  process.removeListener('SIGINT', onSignal);
package/src/spec.js CHANGED
@@ -89,6 +89,7 @@ function normalizeBlock(rawBlock, id, cwd, where) {
89
89
  const code = asStr(rawBlock.code);
90
90
  if (!code.trim()) throw new CliError(`${where}: mermaid block needs a non-empty "code" string.`);
91
91
  const block = { id, type: 'mermaid', code };
92
+ if (rawBlock.editable === true) block.editable = true;
92
93
  if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
93
94
  return block;
94
95
  }
@@ -367,6 +368,7 @@ const BLOCK_SCHEMA = {
367
368
  type: { type: 'string', enum: BLOCK_TYPES },
368
369
  md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
369
370
  code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
371
+ editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
370
372
  dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
371
373
  server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
372
374
  lang: { type: 'string', description: 'code block: language hint for display.' },
@@ -398,7 +400,7 @@ export const SPEC_SCHEMA = {
398
400
  title: 'relay board spec',
399
401
  type: 'object',
400
402
  description:
401
- 'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", and "annotations" (element-level comments the user attached to any block — see the annotation shape below).',
403
+ 'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", "annotations" (element-level comments the user attached to any block — see the annotation shape below), and "blockEdits" (a map blockId→edited source for any editable mermaid block the user changed, null when none).',
402
404
  properties: {
403
405
  title: { type: 'string', description: 'Board title (default "Relay")' },
404
406
  intro: { type: 'string', description: 'Intro text shown under the title. Newlines preserved.' },
@@ -455,6 +457,12 @@ export const SPEC_SCHEMA = {
455
457
  description:
456
458
  'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
457
459
  },
460
+ blockEdits: {
461
+ type: 'object',
462
+ readOnly: true,
463
+ description:
464
+ 'Returned in the result (not part of the input spec). A map blockId→edited mermaid source for any editable mermaid block the user changed. null when the user made no edits. Read result.blockEdits[<blockId>] for the user\'s edited diagram source.',
465
+ },
458
466
  },
459
467
  anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
460
468
  };
@@ -200,6 +200,7 @@
200
200
  function refreshBadges() {
201
201
  for (const b of badges) b.remove();
202
202
  badges = [];
203
+ if (torndown) return;
203
204
  registered = registered.filter((r) => r.el.isConnected);
204
205
  for (const entry of registered) {
205
206
  const count = matching(entry.info).length;
@@ -500,7 +501,32 @@
500
501
  renderSummaries();
501
502
  }
502
503
 
504
+ // Remove every floating element (pin, badges, popover, selection button)
505
+ // and stop reacting — called when the board reaches its submitted screen so
506
+ // nothing leaks over it (they live on <body> with elevated z-index).
507
+ let torndown = false;
508
+ function teardown() {
509
+ torndown = true;
510
+ try {
511
+ closePopover();
512
+ } catch {
513
+ // popover may not be open
514
+ }
515
+ hidePin();
516
+ hideSelBtn();
517
+ for (const b of badges) b.remove();
518
+ badges = [];
519
+ registered = [];
520
+ if (dom) {
521
+ dom.pin.remove();
522
+ dom.selBtn.remove();
523
+ dom.pop.remove();
524
+ dom = null;
525
+ }
526
+ }
527
+
503
528
  function register(targetEl, info) {
529
+ if (torndown) return;
504
530
  ensureDom();
505
531
  targetEl.classList.add('ann-target');
506
532
  const entry = { el: targetEl, info };
@@ -511,14 +537,17 @@
511
537
  }
512
538
 
513
539
  function enableTextSelection(rootEl, baseInfo) {
540
+ if (torndown) return;
514
541
  ensureDom();
515
542
  rootEl.addEventListener('mouseup', () => {
543
+ if (torndown) return;
516
544
  // defer: the selection settles after mouseup
517
545
  setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
518
546
  });
519
547
  }
520
548
 
521
549
  function openExternal(info, anchorEl) {
550
+ if (torndown) return;
522
551
  openPopover(info, anchorEl);
523
552
  }
524
553
 
@@ -532,5 +561,5 @@
532
561
  renderSummaryInto(target);
533
562
  }
534
563
 
535
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary };
564
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, teardown };
536
565
  })();
package/src/ui/app.js CHANGED
@@ -82,6 +82,10 @@
82
82
  notes: {},
83
83
  comment: '',
84
84
  annotations: (boot.prefill && boot.prefill.annotations) || [],
85
+ // Editable-mermaid edits: blockId -> edited source. Seeded from the live
86
+ // draft so a reload/reopen restores the user's edited diagram. Mutated via
87
+ // the blocks ctx.onBlockEdit callback below; returned in payload().
88
+ blockEdits: (boot.prefill && boot.prefill.blockEdits) || {},
85
89
  };
86
90
  let submitted = false;
87
91
 
@@ -147,7 +151,13 @@
147
151
  const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
148
152
  if (n) notes[q.id] = n;
149
153
  }
150
- return { answers, comment: (state.comment || '').trim(), notes, annotations: state.annotations };
154
+ return {
155
+ answers,
156
+ comment: (state.comment || '').trim(),
157
+ notes,
158
+ annotations: state.annotations,
159
+ blockEdits: state.blockEdits,
160
+ };
151
161
  }
152
162
 
153
163
  // ---------- real-time autosave ----------
@@ -174,6 +184,44 @@
174
184
  }
175
185
  }
176
186
 
187
+ // ---------- presence / awareness ----------
188
+ // Track the last time the user interacted with the page so the agent (via
189
+ // /api/presence) can tell whether someone is still actively viewing the board
190
+ // and keep waiting instead of timing out. Every existing 3s heartbeat tick
191
+ // also POSTs /api/ping {visible, focused, idleMs}; pinging stops after submit.
192
+ let lastInteractionAt = Date.now();
193
+ let lastMoveAt = 0;
194
+ function noteInteraction() {
195
+ lastInteractionAt = Date.now();
196
+ }
197
+ window.addEventListener('pointerdown', noteInteraction, { passive: true });
198
+ window.addEventListener('keydown', noteInteraction, { passive: true });
199
+ window.addEventListener('scroll', noteInteraction, { passive: true });
200
+ window.addEventListener('touchstart', noteInteraction, { passive: true });
201
+ // pointermove fires continuously — throttle to at most once per second.
202
+ window.addEventListener('pointermove', () => {
203
+ const now = Date.now();
204
+ if (now - lastMoveAt >= 1000) {
205
+ lastMoveAt = now;
206
+ lastInteractionAt = now;
207
+ }
208
+ }, { passive: true });
209
+
210
+ function pingPresence() {
211
+ if (submitted) return;
212
+ fetch('/api/ping', {
213
+ method: 'POST',
214
+ headers: { 'content-type': 'application/json' },
215
+ body: JSON.stringify({
216
+ visible: !document.hidden,
217
+ focused: document.hasFocus(),
218
+ idleMs: Date.now() - lastInteractionAt,
219
+ }),
220
+ }).catch(() => {
221
+ // presence is best-effort — a failed ping must never surface to the user
222
+ });
223
+ }
224
+
177
225
  // ---------- annotations ----------
178
226
  // RelayAnnotate owns the live annotation list; mirror it into state on every
179
227
  // change so payload()/autosave/submit carry it exactly like answers.
@@ -188,13 +236,25 @@
188
236
  });
189
237
  }
190
238
 
191
- // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract.
239
+ // Editable-mermaid: record (or clear) the user's edit for a block, then
240
+ // autosave. A null/empty code clears the entry (block matches the original
241
+ // again — e.g. after Reset), so payload()/draft carry only real divergences.
242
+ function onBlockEdit(blockId, codeOrNull) {
243
+ if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
244
+ else state.blockEdits[blockId] = codeOrNull;
245
+ scheduleSave();
246
+ }
247
+
248
+ // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
249
+ // the editable-mermaid plumbing (edits map + onBlockEdit callback).
192
250
  function blockCtx(questionId) {
193
251
  return {
194
252
  theme: effectiveTheme,
195
253
  htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
196
254
  questionId: questionId == null ? null : questionId,
197
255
  annotate: Annotate,
256
+ edits: state.blockEdits,
257
+ onBlockEdit,
198
258
  };
199
259
  }
200
260
 
@@ -517,6 +577,15 @@
517
577
 
518
578
  function showDone(closing) {
519
579
  stopHeartbeat();
580
+ // Annotation pins/badges/popover float on <body> with elevated z-index —
581
+ // remove them so they don't leak over the submitted screen.
582
+ if (window.RelayAnnotate && typeof RelayAnnotate.teardown === 'function') {
583
+ try {
584
+ RelayAnnotate.teardown();
585
+ } catch {
586
+ // best effort
587
+ }
588
+ }
520
589
  app.replaceChildren(
521
590
  el('div', { class: 'done' },
522
591
  el('div', { class: 'mark' }, '✓'),
@@ -608,6 +677,8 @@
608
677
  let misses = 0;
609
678
  let reloading = false;
610
679
  let hb = setInterval(async () => {
680
+ // Piggyback presence on the heartbeat (best-effort; no-ops after submit).
681
+ pingPresence();
611
682
  try {
612
683
  const r = await fetch('/api/status', { cache: 'no-store' });
613
684
  if (!r.ok) throw new Error('bad status');
package/src/ui/blocks.css CHANGED
@@ -169,6 +169,69 @@
169
169
  .blk-mermaid .node,
170
170
  .blk-mermaid .edgeLabel { cursor: default; }
171
171
 
172
+ /* ---------- editable mermaid (diagram editor) ---------- */
173
+ .blk-mermaid-edit { margin-top: 8px; }
174
+ .blk-edit-btn {
175
+ appearance: none;
176
+ background: transparent;
177
+ border: 1px solid var(--border);
178
+ border-radius: 7px;
179
+ color: var(--muted);
180
+ font-family: var(--sans);
181
+ font-size: 0.78rem;
182
+ padding: 4px 10px;
183
+ cursor: pointer;
184
+ transition: color 150ms var(--ease), border-color 150ms var(--ease);
185
+ }
186
+ .blk-edit-btn:hover,
187
+ .blk-edit-btn.is-open { color: var(--accent); border-color: var(--accent); }
188
+ .blk-editor { margin-top: 8px; }
189
+ .blk-editor[hidden] { display: none; }
190
+ .blk-editor-ta {
191
+ display: block;
192
+ width: 100%;
193
+ box-sizing: border-box;
194
+ min-height: 120px;
195
+ resize: vertical;
196
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
197
+ font-size: 13px;
198
+ line-height: 1.5;
199
+ color: var(--fg);
200
+ background: var(--bg-sunken);
201
+ border: 1px solid var(--border);
202
+ border-radius: 8px;
203
+ padding: 10px 12px;
204
+ }
205
+ .blk-editor-ta:focus {
206
+ outline: none;
207
+ border-color: var(--accent);
208
+ }
209
+ .blk-editor-row {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 12px;
213
+ margin-top: 8px;
214
+ }
215
+ .blk-editor-reset {
216
+ appearance: none;
217
+ background: transparent;
218
+ border: 1px solid var(--border);
219
+ border-radius: 7px;
220
+ color: var(--muted);
221
+ font-family: var(--sans);
222
+ font-size: 0.78rem;
223
+ padding: 4px 10px;
224
+ cursor: pointer;
225
+ transition: color 150ms var(--ease), border-color 150ms var(--ease);
226
+ }
227
+ .blk-editor-reset:hover { color: var(--accent); border-color: var(--accent); }
228
+ .blk-editor-status {
229
+ font-family: var(--sans);
230
+ font-size: 0.78rem;
231
+ color: var(--muted);
232
+ }
233
+ .blk-editor-status.has-error { color: var(--muted); }
234
+
172
235
  /* ---------- graphviz (offline, vendored Viz.js) ---------- */
173
236
  .blk-graphviz {
174
237
  overflow: auto;
package/src/ui/blocks.js CHANGED
@@ -554,16 +554,133 @@
554
554
  const mermaidRegistry = [];
555
555
  const chartRegistry = [];
556
556
 
557
+ // Effective code = the user's edit if present, else the authored block.code.
558
+ // Used by the initial render, the editor's live preview, and the theme
559
+ // re-render registry path so an edit survives a theme toggle.
560
+ function effectiveMermaidCode(entry) {
561
+ const { block, ctx, blockId } = entry;
562
+ const edited = ctx.edits ? ctx.edits[blockId] : undefined;
563
+ if (edited !== undefined && edited !== null) return edited;
564
+ // The live editor keeps entry.code as the source of truth; if the host's
565
+ // ctx.edits hasn't been updated yet but the user has diverged from the
566
+ // original, preserve that divergence (don't snap back to block.code).
567
+ if (entry.code !== undefined && entry.code !== (block.code || '')) return entry.code;
568
+ return block.code || '';
569
+ }
570
+
557
571
  function renderMermaid(block, ctx, blockId) {
558
572
  const container = el('div', { class: 'blk-mermaid' });
559
573
  const entry = { container, block, ctx, blockId };
574
+ entry.code = effectiveMermaidCode(entry);
560
575
  mermaidRegistry.push(entry);
576
+
577
+ if (!block.editable) {
578
+ drawMermaid(entry);
579
+ return container;
580
+ }
581
+
582
+ // Editable: wrap the diagram + an editor below it. The wrapper is what the
583
+ // dispatcher appends; the registry still tracks `container` (the diagram).
584
+ const wrap = el('div', { class: 'blk-mermaid-wrap' });
585
+ wrap.append(container);
586
+ wrap.append(buildMermaidEditor(entry));
561
587
  drawMermaid(entry);
562
- return container;
588
+ return wrap;
563
589
  }
564
590
 
565
- function drawMermaid(entry) {
566
- const { container, block, ctx, blockId } = entry;
591
+ // Editor: toggle button + collapsible panel (textarea + Reset + status line).
592
+ // Live re-render is debounced 600ms; errors show inline WITHOUT destroying the
593
+ // last good diagram. Each accepted change calls ctx.onBlockEdit(blockId, code)
594
+ // (null when the value matches the original block.code again).
595
+ function buildMermaidEditor(entry) {
596
+ const { block, ctx, blockId, container } = entry;
597
+ const original = block.code || '';
598
+
599
+ const toggleBtn = el('button', { type: 'button', class: 'blk-edit-btn' }, 'Edit diagram');
600
+ const panel = el('div', { class: 'blk-editor', hidden: '' });
601
+
602
+ const ta = el('textarea', { class: 'blk-editor-ta', spellcheck: 'false' });
603
+ ta.value = entry.code;
604
+
605
+ const status = el('div', { class: 'blk-editor-status' }, '');
606
+ const resetBtn = el('button', { type: 'button', class: 'blk-editor-reset' }, 'Reset');
607
+ const row = el('div', { class: 'blk-editor-row' }, resetBtn, status);
608
+ panel.append(ta, row);
609
+
610
+ let open = false;
611
+ toggleBtn.addEventListener('click', () => {
612
+ open = !open;
613
+ panel.hidden = !open;
614
+ toggleBtn.classList.toggle('is-open', open);
615
+ if (open) ta.focus();
616
+ });
617
+
618
+ let timer = null;
619
+ function reportEdit(code) {
620
+ // Only report a real divergence; equal-to-original clears the edit (null).
621
+ try {
622
+ if (code === original) ctx.onBlockEdit(blockId, null);
623
+ else ctx.onBlockEdit(blockId, code);
624
+ } catch {
625
+ // host callback failures must not break editing
626
+ }
627
+ }
628
+
629
+ // Try to render `code`; on success swap the diagram + re-register annotation
630
+ // targets (via drawMermaid) and report the edit; on failure keep the last
631
+ // good diagram and show the error inline.
632
+ function applyCode(code) {
633
+ previewMermaid(entry, code)
634
+ .then(() => {
635
+ status.textContent = '';
636
+ status.classList.remove('has-error');
637
+ entry.code = code;
638
+ reportEdit(code);
639
+ })
640
+ .catch((err) => {
641
+ status.textContent = 'diagram error: ' + (err && err.message ? err.message : String(err));
642
+ status.classList.add('has-error');
643
+ });
644
+ }
645
+
646
+ ta.addEventListener('input', () => {
647
+ if (timer) clearTimeout(timer);
648
+ const code = ta.value;
649
+ timer = setTimeout(() => applyCode(code), 600);
650
+ });
651
+
652
+ resetBtn.addEventListener('click', () => {
653
+ if (timer) { clearTimeout(timer); timer = null; }
654
+ ta.value = original;
655
+ applyCode(original);
656
+ });
657
+
658
+ return el('div', { class: 'blk-mermaid-edit' }, toggleBtn, panel);
659
+ }
660
+
661
+ // Render `code` for the editor preview WITHOUT mutating entry.code on failure.
662
+ // Resolves once the diagram is swapped into the container (annotation targets
663
+ // re-registered by drawMermaid); rejects with the render error so the caller
664
+ // can show it inline and keep the last good diagram.
665
+ function previewMermaid(entry, code) {
666
+ const prev = entry.code;
667
+ entry.code = code;
668
+ return new Promise((resolve, reject) => {
669
+ drawMermaid(entry, { onDone: resolve, onError: reject });
670
+ }).catch((err) => {
671
+ entry.code = prev;
672
+ throw err;
673
+ });
674
+ }
675
+
676
+ function drawMermaid(entry, hooks) {
677
+ const { container, ctx, blockId } = entry;
678
+ const onError = hooks && hooks.onError ? hooks.onError : null;
679
+ const onDone = hooks && hooks.onDone ? hooks.onDone : null;
680
+ const fail = (err) => {
681
+ if (onError) onError(err);
682
+ else showMermaidErr(container, err);
683
+ };
567
684
  loadMermaid().then((mermaid) => {
568
685
  try {
569
686
  mermaid.initialize({
@@ -575,7 +692,7 @@
575
692
  // ignore re-init issues
576
693
  }
577
694
  const id = 'rly-mmd-' + (++mermaidSeq);
578
- const code = block.code || '';
695
+ const code = entry.code !== undefined ? entry.code : effectiveMermaidCode(entry);
579
696
  const onSvg = (svg) => {
580
697
  container.innerHTML = svg;
581
698
  const svgEl = container.querySelector('svg');
@@ -593,24 +710,26 @@
593
710
  svgEl.style.maxWidth = '100%';
594
711
  }
595
712
  }
596
- if (!ctx.annotate) return;
597
- const nodes = container.querySelectorAll('.node, .edgeLabel');
598
- nodes.forEach((g) => {
599
- ctx.annotate.register(g, {
600
- blockId,
601
- questionId: ctx.questionId,
602
- target: {
603
- kind: 'mermaid-node',
604
- nodeId: g.id || '',
605
- text: (g.textContent || '').trim().slice(0, 120),
606
- },
713
+ if (ctx.annotate) {
714
+ const nodes = container.querySelectorAll('.node, .edgeLabel');
715
+ nodes.forEach((g) => {
716
+ ctx.annotate.register(g, {
717
+ blockId,
718
+ questionId: ctx.questionId,
719
+ target: {
720
+ kind: 'mermaid-node',
721
+ nodeId: g.id || '',
722
+ text: (g.textContent || '').trim().slice(0, 120),
723
+ },
724
+ });
607
725
  });
608
- });
726
+ }
727
+ if (onDone) onDone();
609
728
  };
610
729
  try {
611
730
  const ret = mermaid.render(id, code);
612
731
  if (ret && typeof ret.then === 'function') {
613
- ret.then((r) => onSvg(r.svg)).catch((err) => showMermaidErr(container, err));
732
+ ret.then((r) => onSvg(r.svg)).catch((err) => fail(err));
614
733
  } else if (ret && ret.svg) {
615
734
  onSvg(ret.svg);
616
735
  } else if (typeof ret === 'string') {
@@ -620,9 +739,9 @@
620
739
  mermaid.render(id, code, (svg) => onSvg(svg));
621
740
  }
622
741
  } catch (err) {
623
- showMermaidErr(container, err);
742
+ fail(err);
624
743
  }
625
- }).catch((err) => showMermaidErr(container, err));
744
+ }).catch((err) => fail(err));
626
745
  }
627
746
 
628
747
  // Live theme toggle: re-render mermaid diagrams with the new mermaid theme
@@ -630,6 +749,8 @@
630
749
  function onThemeChange() {
631
750
  for (const entry of mermaidRegistry) {
632
751
  try {
752
+ // re-render with the effective code so an edit survives the toggle
753
+ entry.code = effectiveMermaidCode(entry);
633
754
  drawMermaid(entry);
634
755
  } catch {
635
756
  // keep the previous svg on failure
@@ -844,6 +965,11 @@
844
965
  htmlSrc: ctx && ctx.htmlSrc ? ctx.htmlSrc : (id) => '/html/b/' + id,
845
966
  questionId: ctx && ctx.questionId !== undefined ? ctx.questionId : null,
846
967
  annotate: ctx && ctx.annotate ? ctx.annotate : null,
968
+ // editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
969
+ // reports an accepted change (or null to clear back to the original).
970
+ edits: ctx && ctx.edits ? ctx.edits : {},
971
+ onBlockEdit:
972
+ ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
847
973
  };
848
974
  for (const block of list) {
849
975
  if (!block || !block.type) continue;