@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 +1 -1
- package/skills/relay/SKILL.md +34 -0
- package/src/cli.js +26 -9
- package/src/server.js +18 -2
- package/src/store.js +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.13.
|
|
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",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
printResult(out); // draft can hold many annotations — sidecar it too
|
|
614
629
|
return 0;
|
|
615
630
|
}
|
|
616
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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 {
|