@khanglvm/relay 0.13.5 → 0.13.6

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
@@ -88,7 +88,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
88
88
  | | |
89
89
  |---|---|
90
90
  | `rly help` | every command at a glance |
91
- | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s) — library-free; great for plans, READMEs, reports |
91
+ | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
92
92
  | `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
93
93
  | `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
94
94
  | `rly mcp` | run relay as an MCP App server so boards render **inline** in the chat — **stdio** for local desktop hosts (Claude Desktop, Codex), or `rly mcp --http` (Streamable HTTP) for web/mobile/remote; `rly mcp config` / `rly mcp install --target claude\|codex` to register it |
@@ -164,6 +164,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
164
164
  - **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
165
165
  video file streamed from the server with HTTP Range (seekable), never
166
166
  embedded in the payload.
167
+ - **`pdf` block** — render local `.pdf` files or PDF URLs inline. Local PDFs
168
+ stream from the board server and are never embedded in the page payload.
167
169
  - **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
168
170
  board whose connection drops blocks further input instead of losing it, and
169
171
  `rly rescue <id>` re-serves on the same port so an open tab reconnects.
package/docs/AGENT.md CHANGED
@@ -151,6 +151,7 @@ Read a markdown file (no questions; library-free renderer; submit reads "Done"):
151
151
  rly view PLAN.md # one file
152
152
  rly view README.md CHANGELOG.md # several, each under a filename heading
153
153
  rly view data.csv # .csv/.tsv/.json → a sortable, filterable table
154
+ rly view report.pdf # .pdf → inline PDF viewer
154
155
  ```
155
156
 
156
157
  Show a git diff in one step (sugar — runs git diff, opens a diff board):
@@ -405,6 +406,10 @@ Rules of thumb:
405
406
  { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
406
407
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
407
408
 
409
+ // PDF — an http(s) PDF URL or a local .pdf file rendered inline. Local PDFs
410
+ // stream from the server and are never embedded in the page payload.
411
+ { "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
412
+
408
413
  // HTML — sandboxed iframe; default height 360
409
414
  { "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
410
415
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
@@ -469,6 +474,7 @@ the question-types section above.
469
474
  | `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
470
475
  | `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
471
476
  | `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
477
+ | `pdf` | quotes, reports, exports, forms — local `.pdf` files or PDF URLs rendered inline; local files stream from the board server |
472
478
  | `image` | screenshots, mockup exports, photos — local files embed and work offline |
473
479
  | `palette` | color palettes / themes — swatch cards with hover-hex + click-to-copy; pair with a `color` question to let the user pick |
474
480
  | `kpi` | big-number metric cards (`items:[{label,value,delta?,dir?,sub?}]`) with up/down/flat-tinted deltas — at-a-glance numbers without a chart |
@@ -646,6 +652,25 @@ cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
646
652
  a `wait` that obtains a terminal result. Write a file your harness watches,
647
653
  hit a webhook — whatever wakes you.
648
654
 
655
+ ### Codex browser-board pattern
656
+
657
+ Codex does not have a normal, user-facing "wake this agent turn from a browser
658
+ submit" command. If the Codex turn stops waiting, a later board submit can leave
659
+ the user needing to prompt manually. So when an agent is running in Codex and
660
+ uses the browser board path, keep the waiter in the foreground until the user
661
+ submits:
662
+
663
+ ```sh
664
+ rly ask --file spec.json --detach
665
+ rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
666
+ ```
667
+
668
+ If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
669
+ If it is still open and the user may continue, run `rly wait` again. Do not use
670
+ `--on-result` as the primary Codex return path; it can write files or hit
671
+ webhooks, but normal Codex CLI sessions do not expose a portable inbound API
672
+ that wakes the current agent turn.
673
+
649
674
  ## Editable diagrams — let the user redraw your mermaid
650
675
 
651
676
  Add `"editable": true` to any mermaid block. The user gets an "Edit diagram"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
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",
@@ -86,6 +86,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
86
86
  | `image` | screenshots / mockups / renders — zoom+pan; `pins:true` → click-to-drop point comments |
87
87
  | `compare` | a before/after pair — draggable divider |
88
88
  | `video` | a demo / screen recording / walkthrough |
89
+ | `pdf` | a quote / report / exported document that should render inline |
89
90
  | `palette` | color schemes — swatch cards, hover-hex, click-to-copy |
90
91
  | `typography` | type choices — specimens at given size/weight/font |
91
92
  | `html` | anything bespoke — custom widgets, pixel-perfect mockups |
@@ -145,6 +146,22 @@ Push-wake instead of polling: add --on-result '<shell cmd>' to ask/show/reopen
145
146
  (or --notify-cmd on wait) - the command runs the moment the board finishes,
146
147
  with the full result JSON on stdin and RLY_BOARD_ID/RLY_STATUS/RLY_URL in env.
147
148
 
149
+ **Codex browser-board pattern.** Codex does not have a normal, user-facing
150
+ "wake this agent turn from a browser submit" command. If the Codex turn stops
151
+ waiting, a later board submit can leave the user needing to prompt manually.
152
+ So in Codex, keep the waiter in the foreground until the user submits:
153
+
154
+ ```sh
155
+ rly ask --file spec.json --detach
156
+ rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
157
+ ```
158
+
159
+ If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
160
+ If it is still open and the user may continue, run `rly wait` again. Do not use
161
+ `--on-result` as the primary Codex return path; it can write files or hit
162
+ webhooks, but normal Codex CLI sessions do not expose a portable inbound API
163
+ that wakes the current agent turn.
164
+
148
165
  Blocking mode (`rly ask --file spec.json --timeout 1800`, no --detach) is fine
149
166
  ONLY when your shell tool has no execution time limit.
150
167
 
@@ -270,6 +287,8 @@ single/multi question.
270
287
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
271
288
  // ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
272
289
  // file (mp4/webm/ogv/mov/mkv/m4v) plays inline (local files stream, not embedded).
290
+ { "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
291
+ // ^ local .pdf files and http(s) PDF URLs render inline; local files stream, not embedded.
273
292
  { "type": "html", "html": "<p>hi</p>", "height": 360 }
274
293
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
275
294
  { "type": "image", "src": "screenshot.png" } // local file, URL, or data URI
package/src/cli.js CHANGED
@@ -279,13 +279,12 @@ async function cmdDiff(rest) {
279
279
  return runOrDetach(record, args);
280
280
  }
281
281
 
282
- // `rly view <file.md> [more.md …]` — quick read-only board that renders one or
283
- // more markdown files (README, plan, report) with the built-in no-library
284
- // renderer. Each file becomes a markdown block; with 2+ files a small filename
285
- // heading separates them. Sugar over `rly show` with markdown mdFile blocks.
282
+ // `rly view <file.md> [more.md …]` — quick read-only board that renders files
283
+ // with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
284
+ // filename heading separates them. Sugar over `rly show`.
286
285
  async function cmdView(args) {
287
286
  const files = args._;
288
- if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json> [more …] [--title T] [--detach]');
287
+ if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json|.pdf> [more …] [--title T] [--detach]');
289
288
  const multi = files.length > 1;
290
289
  const DATA_EXT = new Set(['csv', 'tsv', 'json']);
291
290
  const blocks = [];
@@ -293,6 +292,7 @@ async function cmdView(args) {
293
292
  if (multi) blocks.push({ type: 'markdown', md: `## ${path.basename(f)}` });
294
293
  const ext = path.extname(f).slice(1).toLowerCase();
295
294
  if (DATA_EXT.has(ext)) blocks.push({ type: 'table', rowsFile: f, sortable: true, filterable: true, exportable: true });
295
+ else if (ext === 'pdf') blocks.push({ type: 'pdf', src: f, title: path.basename(f) });
296
296
  else blocks.push({ type: 'markdown', mdFile: f });
297
297
  }
298
298
  const raw = { blocks };
@@ -879,8 +879,8 @@ what you're about to output would look better than monospace text — or is a fi
879
879
  the user should view — put it in relay instead of printing it.**
880
880
 
881
881
  - **Markdown file / doc / README / plan / report** → \`rly view file.md\` (renders it
882
- in the browser, no library; \`.csv\`/\`.json\` → a sortable table). Never \`cat\` a
883
- doc into the terminal for the user to read.
882
+ in the browser, no library; \`.csv\`/\`.json\` → a sortable table; \`.pdf\` → an
883
+ inline PDF viewer). Never \`cat\` a doc into the terminal for the user to read.
884
884
  - **Image / screenshot / mockup / diagram / chart / table** → a relay board with an
885
885
  \`image\` block (or \`mermaid\`/\`graphviz\`/\`chart\`/\`table\`/\`kpi\`/\`compare\`). The
886
886
  terminal can't show pixels; relay can — with click-to-zoom and comment-in-place.
@@ -889,6 +889,15 @@ the user should view — put it in relay instead of printing it.**
889
889
  \`AskUserQuestion\` / ask-user tool. Build a spec and run
890
890
  \`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`. **Holds in plan
891
891
  mode too**; reserve \`ExitPlanMode\` for the final plan-approval gate only.
892
+ - **Codex-specific browser-board pattern:** Codex does not have a normal,
893
+ user-facing "wake this agent turn from a browser submit" command. In Codex,
894
+ keep the waiter in the foreground until the user submits:
895
+ \`rly ask --file spec.json --detach\` then
896
+ \`rly wait <boardId> --timeout 1800 --while-active --idle-grace 300\`.
897
+ If wait exits with \`wait-timeout\`, immediately run \`rly result <boardId>\`;
898
+ if it is still open and the user may continue, run \`rly wait\` again. Do not
899
+ use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
900
+ do not expose a portable inbound API that wakes the current agent turn.
892
901
  - **A plan, structure, architecture, data, or prototype** → a relay board with
893
902
  diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
894
903
  - **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
@@ -899,7 +908,7 @@ the user should view — put it in relay instead of printing it.**
899
908
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
900
909
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
901
910
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
902
- \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`palette\`,
911
+ \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
903
912
  \`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
904
913
  \`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
905
914
  (per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
@@ -1385,7 +1394,8 @@ USAGE
1385
1394
  rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
1386
1395
  rly show --html-file viz.html visualization-only board (submit button = acknowledge)
1387
1396
  rly view <file.md> [more.md …] quick read-only board rendering markdown file(s) (no lib)
1388
- (.csv/.tsv/.json render as a filterable, sortable table)
1397
+ (.csv/.tsv/.json render as a filterable, sortable table;
1398
+ .pdf streams in an inline PDF viewer)
1389
1399
  rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
1390
1400
  --title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
1391
1401
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
package/src/server.js CHANGED
@@ -40,7 +40,9 @@ function readUi(name) {
40
40
 
41
41
  // Strips block bodies for the client payload: html blocks ship only metadata
42
42
  // (their bodies are served via /html/b/<id>), embedded images ship only
43
- // metadata (bytes served via /img/b/<id>), everything else ships as-is.
43
+ // metadata (bytes served via /img/b/<id>), streamed local media ships only
44
+ // metadata (bytes served via /video/b/<id> or /pdf/b/<id>), everything else
45
+ // ships as-is.
44
46
  function clientBlock(b) {
45
47
  // Cross-block fields preserved when we ship metadata-only (ref = reference-link
46
48
  // target name; pins = image coordinate comments). The default `return b` path
@@ -58,6 +60,9 @@ function clientBlock(b) {
58
60
  if (b && b.type === 'video' && typeof b.file === 'string') {
59
61
  return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
60
62
  }
63
+ if (b && b.type === 'pdf' && typeof b.file === 'string') {
64
+ return { id: b.id, type: 'pdf', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
65
+ }
61
66
  return b;
62
67
  }
63
68
 
@@ -619,6 +624,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
619
624
  const block = findBlock(record.spec, blockId, 'video');
620
625
  if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
621
626
  streamFile(req, res, block.file, block.mime || 'application/octet-stream');
627
+ } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/pdf/b/')) {
628
+ // Local PDF bytes, Range-streamed for the browser's built-in PDF viewer.
629
+ const blockId = decodeURIComponent(pathname.slice('/pdf/b/'.length));
630
+ const block = findBlock(record.spec, blockId, 'pdf');
631
+ if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local pdf block "${blockId}"` });
632
+ streamFile(req, res, block.file, block.mime || 'application/pdf');
622
633
  } else if (req.method === 'GET' && pathname === '/html/board') {
623
634
  // Legacy alias → the board's first html block.
624
635
  const block = firstBoardHtml(record.spec);
package/src/spec.js CHANGED
@@ -39,7 +39,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
39
39
 
40
40
  // Block heights clamp to the same window; defaults vary per block type.
41
41
  const BLOCK_HEIGHT = { min: 100, max: 2400 };
42
- export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
42
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
43
43
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
44
44
 
45
45
  // code/diff blocks may load their text from a local file (like htmlFile). Caps
@@ -55,6 +55,9 @@ const VIDEO_MIMES = {
55
55
  };
56
56
  const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
57
57
 
58
+ const PDF_MIME = 'application/pdf';
59
+ const PDF_MAX_BYTES = 512 * 1024 * 1024;
60
+
58
61
  // image blocks: local files are embedded as data URIs at spec time (the page
59
62
  // then loads them via /img/b/<id>), so boards stay self-contained offline.
60
63
  const IMAGE_MIMES = {
@@ -343,6 +346,37 @@ function normalizeBlock(rawBlock, id, cwd, where) {
343
346
  return block;
344
347
  }
345
348
 
349
+ if (type === 'pdf') {
350
+ const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
351
+ if (!src) throw new CliError(`${where}: pdf block needs a "src" (http(s) PDF URL or local .pdf file path).`);
352
+ const block = { id, type: 'pdf' };
353
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
354
+ if (rawBlock.alt !== undefined && block.title === undefined) block.title = asStr(rawBlock.alt);
355
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
356
+ if (/^https?:/i.test(src)) {
357
+ block.src = src;
358
+ block.mime = PDF_MIME;
359
+ if (!block.title) block.title = path.basename(src.split(/[?#]/)[0]) || 'PDF';
360
+ return block;
361
+ }
362
+ const p = path.resolve(cwd, src);
363
+ const ext = path.extname(p).slice(1).toLowerCase();
364
+ if (ext !== 'pdf') throw new CliError(`${where}: unsupported pdf extension ".${ext}" — use a local .pdf file or an http(s) PDF URL.`);
365
+ let stat;
366
+ try {
367
+ stat = fs.statSync(p);
368
+ } catch {
369
+ throw new CliError(`${where}: cannot read pdf "${src}" (resolved: ${p})`);
370
+ }
371
+ if (stat.size > PDF_MAX_BYTES) {
372
+ throw new CliError(`${where}: pdf "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${PDF_MAX_BYTES / 1024 / 1024}MB.`);
373
+ }
374
+ block.file = p;
375
+ block.mime = PDF_MIME;
376
+ if (!block.title) block.title = path.basename(p);
377
+ return block;
378
+ }
379
+
346
380
  if (type === 'chart') {
347
381
  const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
348
382
  const hasShorthand =
package/src/ui/blocks.css CHANGED
@@ -278,6 +278,14 @@
278
278
  .blk-video-embed { aspect-ratio: 16 / 9; height: auto; }
279
279
  .blk-videocap { margin-top: 8px; font-size: 0.84rem; color: var(--muted); text-align: center; }
280
280
 
281
+ /* ---------- PDF (local stream / direct URL) ---------- */
282
+ .blk-pdfwrap { margin: 0; }
283
+ .blk-pdf {
284
+ display: block; width: 100%; height: min(900px, 78vh); min-height: 420px;
285
+ border: 0; border-radius: 10px; background: var(--card);
286
+ }
287
+ .blk-pdfwrap.blk-full .blk-pdf { height: calc(100vh - 92px); min-height: 0; }
288
+
281
289
  /* ---------- table ---------- */
282
290
  .blk-table {
283
291
  width: 100%;
package/src/ui/blocks.js CHANGED
@@ -692,6 +692,27 @@
692
692
  return wrap;
693
693
  }
694
694
 
695
+ // ---------- PDF (local stream or direct URL) ----------
696
+ function renderPdf(block, ctx, blockId) {
697
+ const wrap = el('div', { class: 'blk-pdfwrap' });
698
+ const src = block.hasFile ? '/pdf/b/' + encodeURIComponent(blockId) : block.src;
699
+ const title = block.title || 'PDF';
700
+ const frame = el('iframe', {
701
+ class: 'blk-pdf',
702
+ src,
703
+ title,
704
+ loading: 'lazy',
705
+ });
706
+ if (block.height != null) frame.style.height = clampHeight(block.height, 900) + 'px';
707
+ frame.addEventListener('error', () => {
708
+ wrap.replaceChildren(el('div', { class: 'blk-error' }, 'PDF failed to load'));
709
+ });
710
+ wrap.append(frame);
711
+ if (block.title) wrap.append(el('div', { class: 'blk-videocap' }, block.title));
712
+ attachViewer(wrap, { zoomEl: null, label: 'pdf', comment: wholeBlockComment(ctx, blockId, 'pdf') });
713
+ return wrap;
714
+ }
715
+
695
716
  // ---------- table ----------
696
717
  function normalizeColumns(columns) {
697
718
  return (columns || []).map((c, idx) => {
@@ -2199,6 +2220,10 @@
2199
2220
  inner = renderVideo(block, ctx, blockId);
2200
2221
  wrapper.append(inner);
2201
2222
  break;
2223
+ case 'pdf':
2224
+ inner = renderPdf(block, ctx, blockId);
2225
+ wrapper.append(inner);
2226
+ break;
2202
2227
  case 'chart':
2203
2228
  inner = renderChart(block, ctx, blockId);
2204
2229
  wrapper.append(inner);