@khanglvm/relay 0.13.0 → 0.13.2

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.0",
3
+ "version": "0.13.2",
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",
@@ -417,6 +417,18 @@ the matching `answers` value (e.g. `answers.approach = "a"` but
417
417
  `notes.approach = "actually B"` → the user means B). Reconcile them before
418
418
  generating output. The same channels appear under `draft` on timeout/cancel.
419
419
 
420
+ **Don't let your shell truncate the result.** A board with several annotations
421
+ prints a large JSON blob, and most agent shell tools cap stdout — so you silently
422
+ get only the first few annotations and miss the rest. Two rules:
423
+
424
+ - **Never pipe `rly wait`/`rly result` through `head`/`tail`/`sed`** (or any
425
+ output cap). That's exactly how annotations get dropped.
426
+ - The full result is **always written to a file**, surfaced as the FIRST field
427
+ of the output: `"resultFile": "~/.relay/boards/<id>.result.json"`. If the
428
+ output looks cut off (or to be safe on any board with annotations), **read
429
+ that file with your file tool** instead of trusting stdout — it's the complete,
430
+ untruncated payload.
431
+
420
432
  ### Reply to annotations (agent → user conversation)
421
433
 
422
434
  ```sh
@@ -472,9 +484,31 @@ one yesno "Does this match your mental model?" + a textarea for notes.
472
484
  `rly history` (saved boards) · `rly spec <id>` (print spec to modify) ·
473
485
  `rly reuse <id>` (re-run blank) · `rly reopen <id>` (re-open with saved
474
486
  answers prefilled) · `rly reopen <id> --replies file.json` (add agent replies) ·
487
+ `rly rescue <id>` (re-serve a dropped board on its ORIGINAL port) ·
475
488
  `rly list` / `rly open` / `rly stop <id>` · `rly rm <id>`.
476
489
  Multiple boards can run concurrently.
477
490
 
491
+ ### Continue a board — reconnect, NEVER recreate it
492
+
493
+ When the user refers to a board that already exists — a URL/port ("the board on
494
+ `127.0.0.1:59926`"), "the board from yesterday", "reopen it", "it disconnected"
495
+ — do **NOT** run `rly ask`/`rly show`. A fresh board lands on a **new port**,
496
+ **strands the user's open tab** on the dead one, and **loses their comments**.
497
+ Find the real board and reconnect it:
498
+
499
+ 1. **Identify it** — `rly list` (running) and `rly history` (saved) print each
500
+ board's id, title, and url/port. Match by what the user said (port, title).
501
+ 2. **Tab still open but "connection lost"** (server died / machine slept) →
502
+ `rly rescue <id>`. Re-serves on the SAME port so that tab reconnects on its
503
+ own and re-flushes any comments it buffered — no new tab, no lost input.
504
+ 3. **Want a fresh tab** with prior answers prefilled → `rly reopen <id>` (also
505
+ reuses the board's last port, so an old tab still reconnects).
506
+ 4. `rly reuse <id>` is the ONLY "make a new board from this one" path — use it
507
+ solely for a deliberately blank re-run, never to "continue" or "reconnect".
508
+
509
+ Rule of thumb: **an existing board is reconnected (`rescue`/`reopen`), never
510
+ re-asked.** Only call `rly ask`/`rly show` for a genuinely new question.
511
+
478
512
  ### Live mutation — `rly update`
479
513
 
480
514
  Push a new spec to a running board. The page reloads and prefills answers from the
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  loadRunning,
16
16
  removeRunning,
17
17
  isAlive,
18
+ saveResultFile,
18
19
  HOME,
19
20
  } from './store.js';
20
21
  import { runBoard } from './server.js';
@@ -76,6 +77,17 @@ function printJson(obj) {
76
77
  process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
77
78
  }
78
79
 
80
+ // Print a terminal result. A board with many annotations produces a large JSON
81
+ // payload, and an agent's shell harness commonly truncates long stdout — so the
82
+ // agent silently misses annotations past the cut. We write the FULL result to a
83
+ // sidecar file and surface its path FIRST (`resultFile`), so even a truncated
84
+ // stdout shows where the complete payload is; the agent reads that file with its
85
+ // file tool. Falls back to a plain print if the sidecar can't be written.
86
+ function printResult(result) {
87
+ const resultFile = result && result.boardId ? saveResultFile(result.boardId, result) : null;
88
+ printJson(resultFile ? { resultFile, ...result } : result);
89
+ }
90
+
79
91
  // Push-wake for `rly wait --notify-cmd`: run the agent's local shell command
80
92
  // once a TERMINAL result lands. Result JSON goes to the command's stdin;
81
93
  // RLY_BOARD_ID / RLY_STATUS / RLY_URL are exported. Same shape as the server's
@@ -225,7 +237,7 @@ async function runOrDetach(record, args) {
225
237
 
226
238
  const { done } = await runBoard({ id: record.id, port, open, timeoutSec });
227
239
  const result = await done;
228
- printJson(result);
240
+ printResult(result);
229
241
  return exitCodeFor(result.status);
230
242
  }
231
243
 
@@ -349,7 +361,10 @@ async function cmdReopen(args) {
349
361
  printJson({ status: 'open', boardId: record.id, url: running.url, note: 'already running — browser re-opened' });
350
362
  return 0;
351
363
  }
352
- return runOrDetach(record, args);
364
+ // Reuse the board's last port (unless the user forced one) so a tab still open
365
+ // from a previous run reconnects on its own instead of being stranded on a
366
+ // dead port. runBoard falls back to a random port if it's been taken.
367
+ return runOrDetach(record, { ...args, port: args.port ?? record.lastPort });
353
368
  }
354
369
 
355
370
  // Rescue a board whose browser tab is still open but disconnected (its server
@@ -487,7 +502,7 @@ async function cmdWait(args) {
487
502
  // Push-wake: run the agent's --notify-cmd after a TERMINAL result, then print.
488
503
  const finishResult = (result) => {
489
504
  if (notifyCmd) runNotifyCmd(notifyCmd, result);
490
- printJson(result);
505
+ printResult(result);
491
506
  return exitCodeFor(result.status);
492
507
  };
493
508
 
@@ -503,7 +518,7 @@ async function cmdWait(args) {
503
518
  if (again?.result?.finishedAt) {
504
519
  return finishResult(again.result);
505
520
  }
506
- printJson({
521
+ printResult({
507
522
  status: 'lost',
508
523
  boardId: id,
509
524
  draft: again?.draft ?? null,
@@ -549,7 +564,7 @@ async function cmdWaitLoop(id, deadline, opts) {
549
564
  const { whileActive, idleGrace, notifyCmd } = opts;
550
565
  const finishResult = (result) => {
551
566
  if (notifyCmd) runNotifyCmd(notifyCmd, result);
552
- printJson(result);
567
+ printResult(result);
553
568
  return exitCodeFor(result.status);
554
569
  };
555
570
  while (Date.now() < deadline) {
@@ -564,7 +579,7 @@ async function cmdWaitLoop(id, deadline, opts) {
564
579
  if (again?.result?.finishedAt) {
565
580
  return finishResult(again.result);
566
581
  }
567
- printJson({
582
+ printResult({
568
583
  status: 'lost',
569
584
  boardId: id,
570
585
  draft: again?.draft ?? null,
@@ -600,7 +615,7 @@ async function cmdWaitLoop(id, deadline, opts) {
600
615
  async function cmdResult(args) {
601
616
  const record = mustLoad(args._[0]);
602
617
  if (record.result && record.result.finishedAt) {
603
- printJson(record.result);
618
+ printResult(record.result);
604
619
  return exitCodeFor(record.result.status);
605
620
  }
606
621
  const running = loadRunning(record.id);
@@ -610,10 +625,10 @@ async function cmdResult(args) {
610
625
  const out = { status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null };
611
626
  const presence = await fetchPresence(running.url);
612
627
  if (presence) out.presence = presence;
613
- printJson(out);
628
+ printResult(out); // draft can hold many annotations — sidecar it too
614
629
  return 0;
615
630
  }
616
- printJson({ status: 'lost', boardId: record.id, draft: record.draft ?? null });
631
+ printResult({ status: 'lost', boardId: record.id, draft: record.draft ?? null });
617
632
  return 5;
618
633
  }
619
634
 
@@ -1377,6 +1392,8 @@ USAGE
1377
1392
  --while-active [--idle-grace 180]: keep waiting past the deadline
1378
1393
  while the user is still viewing/focused & recently active
1379
1394
  --notify-cmd "<cmd>": run <cmd> on a terminal result (JSON on stdin)
1395
+ NB: also writes the FULL result to "resultFile" (first field) —
1396
+ read that file if your shell truncates stdout; never pipe to head/tail
1380
1397
  rly result <id> result/status now (includes live autosaved draft + presence while open)
1381
1398
  rly list [--json] running boards
1382
1399
  rly open [id] re-open the browser tab of a running board
package/src/server.js CHANGED
@@ -693,9 +693,25 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
693
693
  }
694
694
  });
695
695
 
696
+ // Bind the requested port (reopen/rescue reuse the board's last port so a
697
+ // still-open tab reconnects). If that port was grabbed by another process in
698
+ // the meantime, fall back to a random free one rather than failing to boot.
696
699
  await new Promise((resolve, reject) => {
697
- server.once('error', reject);
698
- server.listen(port, '127.0.0.1', resolve);
700
+ const bind = (p, allowFallback) => {
701
+ const onErr = (e) => {
702
+ if (allowFallback && e && e.code === 'EADDRINUSE' && p !== 0) {
703
+ bind(0, false); // desired port busy → random free one
704
+ } else {
705
+ reject(e);
706
+ }
707
+ };
708
+ server.once('error', onErr);
709
+ server.listen(p, '127.0.0.1', () => {
710
+ server.removeListener('error', onErr);
711
+ resolve();
712
+ });
713
+ };
714
+ bind(port, true);
699
715
  });
700
716
  const actualPort = server.address().port;
701
717
  const url = `http://127.0.0.1:${actualPort}/`;
package/src/store.js CHANGED
@@ -53,9 +53,25 @@ export function loadBoard(id) {
53
53
  }
54
54
  }
55
55
 
56
+ // Write the full result to its own file (just the result, not the whole board
57
+ // record with its bulky spec). An agent's shell may truncate `rly wait`/`result`
58
+ // stdout for a board with many annotations — this file is the complete payload
59
+ // it can read with a file tool instead. Returns the path (null on failure).
60
+ export function saveResultFile(id, result) {
61
+ try {
62
+ ensureDirs();
63
+ const p = path.join(BOARDS_DIR, `${id}.result.json`);
64
+ fs.writeFileSync(p, JSON.stringify(result, null, 2));
65
+ return p;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
56
71
  export function deleteBoard(id) {
57
72
  try {
58
73
  fs.unlinkSync(boardPath(id));
74
+ try { fs.unlinkSync(path.join(BOARDS_DIR, `${id}.result.json`)); } catch { /* no sidecar */ }
59
75
  return true;
60
76
  } catch {
61
77
  return false;
@@ -66,7 +82,7 @@ export function listBoards(limit = 20) {
66
82
  ensureDirs();
67
83
  const records = [];
68
84
  for (const f of fs.readdirSync(BOARDS_DIR)) {
69
- if (!f.endsWith('.json')) continue;
85
+ if (!f.endsWith('.json') || f.endsWith('.result.json')) continue; // skip result sidecars
70
86
  try {
71
87
  records.push(JSON.parse(fs.readFileSync(path.join(BOARDS_DIR, f), 'utf8')));
72
88
  } catch {