@khanglvm/relay 0.9.1 → 0.10.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
@@ -35,7 +35,8 @@ That's it. Next time your agent needs a decision or wants to show you a plan,
35
35
  it opens a board like the ones above and waits for your Submit.
36
36
 
37
37
  Keep relay current with **`rly upgrade`** — it installs the latest CLI and
38
- refreshes the skill in one step, leaving any boards you have open untouched.
38
+ refreshes the skill (via `npx skills`, falling back to the bundled copy) in one
39
+ step, leaving any boards you have open untouched.
39
40
 
40
41
  ## What it improves
41
42
 
@@ -45,6 +46,7 @@ refreshes the skill in one step, leaving any boards you have open untouched.
45
46
  | "Option B is the one with caching (see my last message)" | Each answer option carries its own image / chart / diagram — pick by looking |
46
47
  | ASCII architecture art | Mermaid, Graphviz, PlantUML — zoomable, full-screen, even user-editable |
47
48
  | Numbers buried in prose | Charts and sortable tables; screenshots and HTML prototypes in a sandbox |
49
+ | "Here's the diff — paste it in your editor" | Side-by-side **diff** blocks, syntax-highlighted **code**, **video** walkthroughs, and **file paths you click to open** in the default app |
48
50
  | "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
49
51
  | Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
50
52
 
@@ -71,6 +73,34 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
71
73
 
72
74
  ## Changelog
73
75
 
76
+ ### 0.10.0 — open files, richer code, diffs & video
77
+ - **Clickable local file-links.** Write a path in any markdown (`~/clip.mp4`,
78
+ `./src/app.ts`, `/abs/report.pdf`, a `file://` URL, a backtick-wrapped path,
79
+ or `[label](path)`) and it renders as a link that opens the file in the OS
80
+ default app — guarded by a same-origin check + an allowlist of paths the
81
+ board actually references. `RLY_OPEN_CMD` overrides the opener.
82
+ - **`code` blocks leveled up** — syntax highlighting for ~20 languages (js, ts,
83
+ py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json,
84
+ sh, css, html…), a line-number gutter, a filename/lang header, and `codeFile`
85
+ to load source straight from a local file.
86
+ - **`diff` block** — render a unified git diff as a colored, line-numbered
87
+ comparison with a live **Unified ⇄ Split (side-by-side)** toggle. No git
88
+ required; the agent supplies the diff text (`diff`/`diffFile`, `view`).
89
+ - **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
90
+ video file streamed from the server with HTTP Range (seekable), never
91
+ embedded in the payload.
92
+ - **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
93
+ board whose connection drops blocks further input instead of losing it, and
94
+ `rly rescue <id>` re-serves on the same port so an open tab reconnects.
95
+ - Still **zero runtime dependencies**, offline, and cross-platform.
96
+
97
+ ### 0.9.1
98
+ - The board **intro renders as markdown** (bold/italic/code/links/lists).
99
+
100
+ ### 0.9.0 — interactive visual annotations
101
+ - Drag/zoom/full-screen viewer, per-element **and** whole-block comments, chart
102
+ data-point comment badges, and inline-SVG PlantUML rendering.
103
+
74
104
  ### 0.8.1
75
105
  - `rly install` adds **OpenCode** (`~/.config/opencode/AGENTS.md`) and **Droid /
76
106
  Factory** (`~/.factory/AGENTS.md`) targets.
package/docs/AGENT.md CHANGED
@@ -249,8 +249,28 @@ Rules of thumb:
249
249
  }
250
250
  // columns may also be plain string array; rows may be parallel arrays [[val,val],...]
251
251
 
252
- // Code — styled pre/code block
253
- { "type": "code", "lang": "js", "code": "const x = 1 + 2;" }
252
+ // Code — syntax-highlighted + line-numbered. Inline "code" or load a local
253
+ // file with "codeFile" (lang then defaults from the extension). "filename"
254
+ // shows a header label. Highlighted langs: js ts py go rust java c cpp csharp
255
+ // ruby php swift kotlin sql yaml json sh css html (+ aliases) — others plain.
256
+ { "type": "code", "lang": "js", "code": "const x = 1 + 2;", "filename": "demo.js" }
257
+ { "type": "code", "codeFile": "src/server.js" }
258
+
259
+ // Diff — a unified diff (git diff / `diff -u` output) rendered as a colored,
260
+ // line-numbered comparison: +added / −removed / context, file & hunk headers.
261
+ // No git needed — just write/paste the diff text. "lang" tints each code line;
262
+ // "diffFile" loads it from a local file. "view":"split" starts side-by-side
263
+ // (old vs new); the viewer has a live Unified⇄Split toggle either way.
264
+ {
265
+ "type": "diff", "lang": "js", "filename": "src/auth.js", "view": "split",
266
+ "diff": "@@ -1,3 +1,3 @@\n function login(u) {\n- return check(u)\n+ return check(u.trim())\n }"
267
+ }
268
+
269
+ // Video — a YouTube/Vimeo URL embeds a player; an http(s) media URL or a local
270
+ // video file (mp4/webm/ogv/mov/mkv/m4v) plays inline. Local files STREAM from
271
+ // the server (Range-enabled, seekable) and are never embedded in the payload.
272
+ { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
273
+ { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
254
274
 
255
275
  // HTML — sandboxed iframe; default height 360
256
276
  { "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
@@ -262,6 +282,16 @@ Rules of thumb:
262
282
  { "type": "image", "src": "https://example.com/mock.png" }
263
283
  ```
264
284
 
285
+ ### Local file links — clickable, open in the default app
286
+
287
+ Inside any **markdown** (the intro or a `markdown` block) just write a local
288
+ file path — `~/clip.mp4`, `./src/app.ts`, `/abs/report.pdf`, a `file://` URL,
289
+ or a backtick-wrapped path — and it renders as a click-to-open link. Clicking it
290
+ asks relay to open that file in the user's OS default app (video player, editor,
291
+ viewer, …); a `[label](~/path)` link works too. Only paths you actually wrote on
292
+ the board can be opened (same-origin + allowlist guarded), so prefer surfacing a
293
+ real path over telling the user to paste it into a terminal.
294
+
265
295
  ### When to use which block
266
296
 
267
297
  | Block | Best for |
@@ -272,7 +302,9 @@ Rules of thumb:
272
302
  | `chart` | numbers, trends, comparisons, metrics |
273
303
  | `table` | structured comparisons, option matrices, data grids — **use this for any tabular data**: it's sortable and every cell is commentable, unlike a markdown pipe table |
274
304
  | `markdown` | prose context, background, instructions, section headings (renders GFM pipe tables too, but reach for a `table` block for real data) |
275
- | `code` | code snippets, config examples, command output |
305
+ | `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
306
+ | `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
307
+ | `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
276
308
  | `image` | screenshots, mockup exports, photos — local files embed and work offline |
277
309
  | `html` | anything else — pixel-perfect mockups, custom widgets, embeds |
278
310
 
@@ -512,7 +544,14 @@ call rather than calling it repeatedly in a loop.
512
544
  comments and reopen the board as a conversation thread.
513
545
  - Use `rly update <id>` to push spec changes to a running board — the page
514
546
  reloads and answers survive via draft autosave. Batch updates; do not spam.
547
+ - When the user asks to *see* code changes — "show me the diff", "show me git
548
+ diff", "review these changes" — capture `git diff` (or `git show <sha>`) and
549
+ render it in a `diff` block instead of printing it to the terminal; for a
550
+ brand-new file use a `code` block. Point them at a file to inspect with a
551
+ clickable local path in markdown.
515
552
  - For sensitive PlantUML diagrams, set `"server": "https://your-server"` to avoid
516
553
  sending source to the public plantuml.com server.
517
554
  - Bundled universal skill (Claude Code, Codex, any SKILL.md-aware agent):
518
555
  `rly skill install` — or `npx skills add khanglvm/relay --skill relay --all`.
556
+ `rly upgrade` refreshes the CLI **and** the skill (via npx skills, falling back
557
+ to the bundled copy) in one step.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.9.1",
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.",
3
+ "version": "0.10.1",
4
+ "description": "Browser-based 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, wait for submit, read answers as JSON.",
5
5
  "keywords": [
6
6
  "ai-agents",
7
7
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: relay
3
- description: "The tool for collecting user requirements, decisions, and answers (choice, yes-no, text, scale questions) and for presenting prototypes, plans, structures, or reports with rich visuals - mermaid/graphviz/plantuml diagrams, charts, sortable tables, custom HTML - plus inline comments on any element. Opens a browser board, waits for Submit, returns JSON answers, comments, and edited diagrams. Use PROACTIVELY instead of (a) native ask-user tools for 2+ answers or options needing explanation, (b) ASCII trees/tables/diagrams in the terminal or prose descriptions of structures/designs/plans, (c) hand-rolled HTML demos. Triggers: collect requirements, ask the user, get decisions/feedback, present a prototype, plan approval, design review, show me the structure, repo/folder structure, file tree, codebase map, architecture overview, dependency graph, visualize, diagram, chart, data table, compare alternatives, survey, which do you prefer, let the user edit the diagram. Skip for a single trivial yes/no confirmation."
3
+ description: "The tool for collecting user requirements, decisions, and answers (choice, yes-no, text, scale) and for presenting prototypes, plans, structures, code changes, or reports with rich visuals - mermaid/graphviz/plantuml diagrams, charts, tables, code, diffs, video, custom HTML, clickable file-links - plus inline comments on any element. Opens a browser board, waits for Submit, returns JSON answers, comments, and edited diagrams. Use PROACTIVELY instead of (a) native ask-user tools for 2+ answers or options needing explanation, (b) ASCII trees/tables/diagrams in the terminal or prose for structures/designs/plans, (c) hand-rolled HTML demos. Triggers: collect requirements, ask the user, get decisions/feedback, present a prototype, plan/design review, show me the structure/file tree, architecture or dependency graph, visualize, diagram, chart, table, compare alternatives, survey, edit the diagram, show me the diff / git diff, video walkthrough, open a file. Skip for a single trivial yes/no confirmation."
4
4
  ---
5
5
 
6
6
  # relay (`rly`)
@@ -28,6 +28,9 @@ Schema).** The essentials are below.
28
28
  | Present a prototype / demo an idea | **rly show** — never hand-roll an HTML file + server |
29
29
  | Gather requirements / plan approval / feedback round | **rly** |
30
30
  | Architecture or flow that benefits from a diagram | **rly** (mermaid block) |
31
+ | "Show me the diff" / git diff / code changes / before-after | **rly** (`diff` block — run `git diff`, render it; never dump it in the terminal) |
32
+ | A demo, screen recording or walkthrough | **rly** (`video` block) |
33
+ | Point the user at a file to open (log, capture, report) | **rly** (a clickable local file-link in markdown) |
31
34
  | Something you can decide yourself from context | neither — just decide |
32
35
 
33
36
  Once the user has answered one board in a session, prefer boards for later
@@ -119,7 +122,17 @@ single/multi question.
119
122
  { "type": "table", "columns": ["A","B"], "rows": [["x","y"]], "sortable": true }
120
123
  // ^ use a `table` block for tabular data — sortable + per-cell comments.
121
124
  // (markdown blocks render GFM pipe tables too, but those are display-only.)
122
- { "type": "code", "lang": "js", "code": "const x = 1;" }
125
+ { "type": "code", "lang": "js", "code": "const x = 1;", "filename": "demo.js" }
126
+ { "type": "code", "codeFile": "src/server.js" } // load text from a local file
127
+ { "type": "diff", "lang": "js", "filename": "src/auth.js", "view": "split",
128
+ "diff": "@@ -1,3 +1,3 @@\n ctx\n-old line\n+new line\n ctx" }
129
+ // ^ a unified / `git diff` text rendered as a colored, line-numbered comparison
130
+ // (no git needed — just paste the diff). "view":"split" = side-by-side; the
131
+ // viewer also has a live Unified⇄Split toggle. "diffFile" loads it from a file.
132
+ { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
133
+ { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
134
+ // ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
135
+ // file (mp4/webm/ogv/mov/mkv/m4v) plays inline (local files stream, not embedded).
123
136
  { "type": "html", "html": "<p>hi</p>", "height": 360 }
124
137
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
125
138
  { "type": "image", "src": "screenshot.png" } // local file, URL, or data URI
@@ -154,6 +167,15 @@ stays dependency-free. PlantUML uses the public plantuml.com server by default;
154
167
  pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
155
168
  `"htmlHeight"` on root or questions are still accepted and normalised automatically.
156
169
 
170
+ ### Local file links — clickable, open in the default app
171
+
172
+ Write a local path in any markdown (the `intro` or a `markdown` block) — `~/clip.mp4`,
173
+ `./src/app.ts`, `/abs/report.pdf`, a `file://` URL, or a backtick-wrapped path — and it
174
+ renders as a click-to-open link that opens the file in the user's OS default app
175
+ (editor, video player, viewer …); `[label](~/path)` works too. Only paths you actually
176
+ wrote on the board can be opened (same-origin + allowlist guarded). Surface a real
177
+ clickable path instead of telling the user to paste it into a terminal.
178
+
157
179
  ## Annotations
158
180
 
159
181
  Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
@@ -210,6 +232,14 @@ urgency, `textarea` for constraints.
210
232
  `html`/`image` block rendering that variant (see Visual options above); `scale`
211
233
  for confidence; `textarea` for what's missing from both.
212
234
 
235
+ **Show a git diff / code changes** — when the user says "show me the diff" /
236
+ "show me git diff" / "review these changes": capture `git diff` (or `git diff
237
+ <ref>`, `git show <sha>`) and present it in a `diff` block — set `"view":
238
+ "split"` for side-by-side — instead of dumping it in the terminal. Pair it with
239
+ a `yesno` "Apply these changes?" and a `textarea` for feedback; users can select
240
+ diff text to comment on a specific line. For a brand-new file prefer a `code` block; for a
241
+ recorded walkthrough of the change add a `video` block.
242
+
213
243
  **Metrics review** — board-level `chart` block (bar or line) showing the key
214
244
  numbers, followed by a `table` block for the raw data; at least one question
215
245
  asking what to act on. In the intro, tell the user they can click chart points
package/src/cli.js CHANGED
@@ -295,6 +295,42 @@ async function cmdReopen(args) {
295
295
  return runOrDetach(record, args);
296
296
  }
297
297
 
298
+ // Rescue a board whose browser tab is still open but disconnected (its server
299
+ // died / the machine slept). Re-serves the SAME board on the SAME port it last
300
+ // used, so the open tab's relative /api/* fetches reconnect on their own — the
301
+ // page's recovery loop lifts its "connection lost" block and re-flushes the
302
+ // draft (incl. anything the user mirrored to localStorage during the outage)
303
+ // with zero action from the user. Defaults to NOT opening a new browser tab
304
+ // (the point is the existing one); pass --open to also open a fresh tab.
305
+ async function cmdRescue(args) {
306
+ const record = mustLoad(args._[0]);
307
+ const running = loadRunning(record.id);
308
+ if (running && isAlive(running.pid)) {
309
+ if (args.open) openUrl(running.url);
310
+ printJson({
311
+ status: 'open',
312
+ boardId: record.id,
313
+ url: running.url,
314
+ port: running.port,
315
+ note: 'already running — the open tab should be connected; reload it if not',
316
+ });
317
+ return 0;
318
+ }
319
+ if (!record.lastPort) {
320
+ throw new CliError(
321
+ `board "${record.id}" has no known port to reuse (never served in this version). Use \`rly reopen ${record.id}\` instead.`,
322
+ 5
323
+ );
324
+ }
325
+ // Force the original port so the disconnected tab can reconnect; default to
326
+ // not opening a second tab. seedAgentReplies parity with reopen if provided.
327
+ if (args.replies !== undefined) {
328
+ const replies = parseJson(readFileOrThrow(args.replies), args.replies);
329
+ seedAgentReplies(record, replies);
330
+ }
331
+ return runOrDetach(record, { ...args, port: record.lastPort, open: args.open === true });
332
+ }
333
+
298
334
  async function cmdReuse(args) {
299
335
  const src = mustLoad(args._[0]);
300
336
  if (args.dump) {
@@ -737,6 +773,25 @@ function removeLegacySkills() {
737
773
  return removed;
738
774
  }
739
775
 
776
+ // Stamps the current CLI version into every installed skill dir's .rly-version.
777
+ // `rly skill install` writes this itself, but an `npx skills add` refresh does
778
+ // not — so after any refresh we re-stamp the dirs that now have a SKILL.md,
779
+ // keeping skillFreshnessWarning() from nagging about a just-refreshed skill.
780
+ function stampSkillVersion() {
781
+ const stamped = [];
782
+ for (const dir of Object.values(KNOWN_SKILL_DIRS())) {
783
+ try {
784
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) {
785
+ fs.writeFileSync(path.join(dir, '.rly-version'), VERSION);
786
+ stamped.push(dir);
787
+ }
788
+ } catch {
789
+ // best effort — a marker we couldn't write just re-triggers the nudge
790
+ }
791
+ }
792
+ return stamped;
793
+ }
794
+
740
795
  // Short instruction block for an agent's main instruction file (CLAUDE.md,
741
796
  // AGENTS.md, …). A skill is only a tier-1 hint that agents can ignore; these
742
797
  // rules, appended to the instructions the agent always reads, enforce usage.
@@ -747,8 +802,12 @@ const SKILL_RULES = `## relay — ask & show in the browser, not the terminal
747
802
  beyond one trivial yes/no)? Do NOT ask in plain terminal text. Build a board
748
803
  spec and run: \`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`.
749
804
  - Presenting a plan, structure, architecture, data, or a prototype? Show a
750
- relay board with mermaid/graphviz/chart/table/image/html blocks — never
751
- ASCII diagrams or walls of prose.
805
+ relay board with mermaid/graphviz/chart/table/code/diff/video/image/html
806
+ blocks — never ASCII diagrams or walls of prose.
807
+ - "Show me the diff / git diff / these changes"? Run \`git diff\` (or \`git show\`)
808
+ and render the output in a \`diff\` block — never paste a raw diff in the
809
+ terminal. Point the user at a file with a clickable local path in a markdown
810
+ block, and embed a screen recording with a \`video\` block.
752
811
  - When answer choices are visual (designs, layouts, variants), give each
753
812
  option its own visual (\`options[].blocks\`) so the user picks by looking.
754
813
  - Read the result JSON from stdout; treat \`comment\` and \`annotations\` as
@@ -995,8 +1054,9 @@ function cmdAgent() {
995
1054
  return 0;
996
1055
  }
997
1056
 
998
- // `rly upgrade` — install the latest CLI globally AND refresh the bundled skill
999
- // in one shot. (`update` is taken by the live-mutate command, so this is
1057
+ // `rly upgrade` — install the latest CLI globally AND refresh the skill (via the
1058
+ // npx-skills package manager, falling back to the bundled copy) in one shot.
1059
+ // (`update` is taken by the live-mutate command, so this is
1000
1060
  // `upgrade` / `self-update`.) Running boards are surfaced and handled: a global
1001
1061
  // reinstall overwrites relay's files, but live detached servers snapshot their
1002
1062
  // UI at first request and serve from memory, so they keep working on their own
@@ -1015,7 +1075,7 @@ async function cmdUpgrade(args) {
1015
1075
  if (args.dryRun === true) {
1016
1076
  printJson({
1017
1077
  dryRun: true,
1018
- wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'rly skill install'].filter(Boolean),
1078
+ wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'npx skills add khanglvm/relay --skill relay --all'].filter(Boolean),
1019
1079
  runningBoards: running.map((r) => r.id),
1020
1080
  runningHandling: running.length
1021
1081
  ? doStop
@@ -1072,17 +1132,30 @@ async function cmdUpgrade(args) {
1072
1132
  }
1073
1133
 
1074
1134
  if (wantSkill) {
1075
- // Spawn the freshly installed binary (on PATH) so the NEW bundled skill is
1076
- // what lands this process still holds the previous bundle in memory.
1077
- process.stderr.write('\nRefreshing the bundled skill (rly skill install)\n');
1078
- const r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
1135
+ // Refresh the skill through the npx-skills package manager the same channel
1136
+ // the skill is normally managed by (skills-lock.json, ~/.agents, ~/.claude, …)
1137
+ // — so the global install stays in sync and pulls the freshest skill for
1138
+ // khanglvm/relay. `npx -y` so the one-off download needs no prompt.
1139
+ process.stderr.write('\nRefreshing the relay skill (npx skills add khanglvm/relay --skill relay --all)\n');
1140
+ let r = spawnSync('npx', ['-y', 'skills', 'add', 'khanglvm/relay', '--skill', 'relay', '--all'], { stdio: 'inherit', shell: true });
1141
+ let how = 'npx-skills';
1142
+ if (r.error || r.status !== 0) {
1143
+ // Fallback: install the skill bundled with the CLI we just upgraded. Works
1144
+ // offline / when the skills CLI is unreachable, and matches this version.
1145
+ process.stderr.write('\nnpx skills unavailable — falling back to the bundled skill (rly skill install)\n');
1146
+ r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
1147
+ how = 'bundled';
1148
+ }
1079
1149
  if (r.error || r.status !== 0) {
1080
1150
  process.stderr.write(
1081
1151
  `Skill refresh did not complete${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`} — ` +
1082
- 'run `rly skill install` yourself (or `npx skills add khanglvm/relay --skill relay --all`).\n'
1152
+ 'run `npx skills add khanglvm/relay --skill relay --all` (or `rly skill install`) yourself.\n'
1083
1153
  );
1084
1154
  } else {
1085
- did.skill = 'installed';
1155
+ // Keep the freshness marker accurate however the skill landed, so the next
1156
+ // `rly` run doesn't nag that the just-refreshed skill is stale.
1157
+ stampSkillVersion();
1158
+ did.skill = how;
1086
1159
  }
1087
1160
  }
1088
1161
 
@@ -1141,6 +1214,9 @@ USAGE
1141
1214
  rly open [id] re-open the browser tab of a running board
1142
1215
  rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
1143
1216
  (--replies [{annotationId,text}] = agent answers to element comments)
1217
+ rly rescue <id> [--open] re-serve a board on its ORIGINAL port so a still-open but
1218
+ disconnected browser tab auto-reconnects & re-saves (no new tab
1219
+ unless --open). Use when a tab shows "connection lost".
1144
1220
  rly reuse <id> [--dump] re-run a past board as a new board (--dump prints its spec)
1145
1221
  rly update <id> --file spec.json live-mutate a RUNNING board (or --title/--intro/-q); page reloads
1146
1222
  rly stop <id> | --all stop running board(s) (status: cancelled, draft preserved)
@@ -1196,6 +1272,8 @@ export async function main(argv) {
1196
1272
  return await cmdAsk(parseArgs(rest), 'show');
1197
1273
  case 'reopen':
1198
1274
  return await cmdReopen(parseArgs(rest));
1275
+ case 'rescue':
1276
+ return await cmdRescue(parseArgs(rest));
1199
1277
  case 'reuse':
1200
1278
  return await cmdReuse(parseArgs(rest));
1201
1279
  case 'update':
package/src/open.js CHANGED
@@ -1,7 +1,19 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
+ // Opens a URL OR a local file/folder path in the OS default handler (browser
4
+ // for http(s), the registered app for a file). On success the file opens in
5
+ // whatever the user set as default (video player, editor, image viewer, …).
6
+ //
7
+ // RLY_OPEN_CMD overrides the platform opener with a custom command — the target
8
+ // is passed as its sole argument ("$1"). Power users can point it at a chooser;
9
+ // the test suite points it at a no-op so opening a file launches nothing.
3
10
  export function openUrl(url) {
4
11
  try {
12
+ const custom = process.env.RLY_OPEN_CMD;
13
+ if (custom && custom.trim()) {
14
+ spawn('/bin/sh', ['-c', `${custom} "$1"`, 'sh', url], { stdio: 'ignore', detached: true }).unref();
15
+ return true;
16
+ }
5
17
  const p = process.platform;
6
18
  const [cmd, args] =
7
19
  p === 'darwin' ? ['open', [url]]
package/src/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import crypto from 'node:crypto';
5
6
  import { spawn } from 'node:child_process';
@@ -47,6 +48,11 @@ function clientBlock(b) {
47
48
  if (b && b.type === 'image' && typeof b.src === 'string' && b.src.startsWith('data:')) {
48
49
  return { id: b.id, type: 'image', alt: b.alt, height: b.height, hasData: true };
49
50
  }
51
+ // Local video: the absolute file path stays server-side; the client gets a
52
+ // flag + mime and loads the bytes (Range-streamed) from /video/b/<id>.
53
+ if (b && b.type === 'video' && typeof b.file === 'string') {
54
+ return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true };
55
+ }
50
56
  return b;
51
57
  }
52
58
 
@@ -113,6 +119,10 @@ function buildPage(record, rev) {
113
119
  notes: record.draft.notes || {},
114
120
  annotations: record.draft.annotations || [],
115
121
  blockEdits: record.draft.blockEdits || {},
122
+ // The server draft's save time, so the client can pick the NEWER of this
123
+ // vs. its localStorage mirror (a tab that kept typing while the server
124
+ // was unreachable holds fresher input than the last server save).
125
+ updatedAt: record.draft.updatedAt || null,
116
126
  }
117
127
  : null;
118
128
  const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
@@ -211,6 +221,103 @@ function firstQuestionHtml(q) {
211
221
  return (q.blocks || []).find((b) => b && b.type === 'html');
212
222
  }
213
223
 
224
+ // ---------- local-file links (POST /api/open) ----------
225
+ // The markdown renderer turns file paths an agent writes (~/x, ./x, /abs/x,
226
+ // file://…) into click-to-open links. Clicking POSTs the raw path here; the
227
+ // server resolves it against the board's authoring cwd and opens it in the OS
228
+ // default app — BUT only if the path is one the board actually references
229
+ // (allowlist below). That keeps a cross-site/blind POST from opening arbitrary
230
+ // files: the only openable paths are ones the agent already put on the board.
231
+ //
232
+ // FILE_PATH_RE / looksLikeLocalPath MUST stay in sync with the same logic in
233
+ // ui/blocks.js, so the set the server allows matches the set the page links.
234
+ const FILE_PATH_RE =
235
+ /(?<![\w@:./])(?:file:\/\/\/?[^\s)<>"'`*]+|~\/[^\s)<>"'`*]+|\.{1,2}\/[^\s)<>"'`*]+|\/[^\s)<>"'`*]+|[A-Za-z]:[\\/][^\s)<>"'`*]+)/g;
236
+
237
+ function looksLikeLocalPath(s) {
238
+ if (typeof s !== 'string') return false;
239
+ const t = s.trim();
240
+ if (!t || /\s/.test(t)) return false;
241
+ if (/^file:\/\//i.test(t)) return true;
242
+ if (/^[A-Za-z]:[\\/]/.test(t)) return true; // windows drive
243
+ if (t === '~' || /^~\//.test(t)) return true;
244
+ if (/^\.\.?\//.test(t)) return true; // ./ or ../
245
+ if (t.startsWith('/')) {
246
+ // a lone "/" or a one-segment "/word" is more likely punctuation/URL — only
247
+ // treat as a file when it has ≥2 segments or a file extension.
248
+ return /\/[^/]+\/[^/]/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
249
+ }
250
+ return false;
251
+ }
252
+
253
+ // Expands ~ / file:// and resolves a (possibly relative) path to an absolute,
254
+ // normalized one against the board's authoring cwd. null on a malformed URL.
255
+ function resolveLocalPath(raw, baseCwd) {
256
+ let p = String(raw || '').trim();
257
+ if (!p) return null;
258
+ if (/^file:\/\//i.test(p)) {
259
+ try {
260
+ p = fileURLToPath(p);
261
+ } catch {
262
+ return null;
263
+ }
264
+ } else if (p === '~' || p.startsWith('~/')) {
265
+ p = path.join(os.homedir(), p.slice(1));
266
+ }
267
+ if (!path.isAbsolute(p)) p = path.resolve(baseCwd || process.cwd(), p);
268
+ return path.normalize(p);
269
+ }
270
+
271
+ // Every markdown source the page runs through its inline renderer (intro + any
272
+ // markdown block, board / question / option scoped). These are the only places
273
+ // file paths become clickable, so they define the open allowlist.
274
+ function collectMarkdownSources(spec) {
275
+ const out = [];
276
+ if (typeof spec.intro === 'string') out.push(spec.intro);
277
+ const addBlocks = (blocks) => {
278
+ for (const b of Array.isArray(blocks) ? blocks : []) {
279
+ if (b && b.type === 'markdown' && typeof b.md === 'string') out.push(b.md);
280
+ }
281
+ };
282
+ addBlocks(spec.blocks);
283
+ for (const q of spec.questions || []) {
284
+ addBlocks(q.blocks);
285
+ for (const o of Array.isArray(q.options) ? q.options : []) {
286
+ if (o) addBlocks(o.blocks);
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+
292
+ // The set of absolute paths the board references and is therefore allowed to
293
+ // open. Rebuilt per request (specs are small) so it tracks live `rly update`s.
294
+ function buildOpenAllowlist(spec, baseCwd) {
295
+ const set = new Set();
296
+ const text = collectMarkdownSources(spec).join('\n');
297
+ const re = new RegExp(FILE_PATH_RE.source, 'g');
298
+ let m;
299
+ while ((m = re.exec(text))) {
300
+ if (!looksLikeLocalPath(m[0])) continue;
301
+ const abs = resolveLocalPath(m[0], baseCwd);
302
+ if (abs) set.add(abs);
303
+ }
304
+ return set;
305
+ }
306
+
307
+ // True when an Origin header (if present) belongs to this board's own server.
308
+ // Same-origin fetches send no Origin or our own; a foreign Origin is a
309
+ // cross-site POST and must not be allowed to open a local file.
310
+ function sameOrigin(req, port) {
311
+ const origin = req.headers.origin;
312
+ if (!origin) return true;
313
+ try {
314
+ const h = new URL(origin).host;
315
+ return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
214
321
  function sendJson(res, code, obj) {
215
322
  if (res.headersSent) return;
216
323
  res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
@@ -243,6 +350,57 @@ function sendFromDir(res, dir, name, contentType) {
243
350
  return true;
244
351
  }
245
352
 
353
+ // Streams a file with HTTP Range support so a <video>/<audio> element can seek
354
+ // and the browser can request byte ranges instead of the whole clip. Honors a
355
+ // single "bytes=start-end" range; falls back to the full body otherwise. Safe
356
+ // for a HEAD probe (sends headers, no body).
357
+ function streamFile(req, res, filePath, contentType) {
358
+ let stat;
359
+ try {
360
+ stat = fs.statSync(filePath);
361
+ } catch {
362
+ return sendJson(res, 404, { error: 'file not found' });
363
+ }
364
+ const total = stat.size;
365
+ const range = req.headers.range;
366
+ const baseHeaders = {
367
+ 'content-type': contentType,
368
+ 'accept-ranges': 'bytes',
369
+ 'cache-control': 'no-store',
370
+ };
371
+ let start = 0;
372
+ let end = total - 1;
373
+ let status = 200;
374
+ if (range) {
375
+ const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
376
+ if (m) {
377
+ if (m[1] === '' && m[2] === '') {
378
+ // "bytes=-" — unsatisfiable
379
+ } else if (m[1] === '') {
380
+ start = Math.max(0, total - Number(m[2])); // suffix range
381
+ } else {
382
+ start = Number(m[1]);
383
+ if (m[2] !== '') end = Math.min(end, Number(m[2]));
384
+ }
385
+ }
386
+ if (start > end || start >= total) {
387
+ res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
388
+ return res.end();
389
+ }
390
+ status = 206;
391
+ baseHeaders['content-range'] = `bytes ${start}-${end}/${total}`;
392
+ }
393
+ baseHeaders['content-length'] = String(end - start + 1);
394
+ res.writeHead(status, baseHeaders);
395
+ if (req.method === 'HEAD') return res.end();
396
+ const stream = fs.createReadStream(filePath, { start, end });
397
+ stream.on('error', () => {
398
+ if (!res.headersSent) sendJson(res, 500, { error: 'stream error' });
399
+ else res.destroy();
400
+ });
401
+ stream.pipe(res);
402
+ }
403
+
246
404
  // Loaded into every custom-HTML iframe so users can hover any element to leave a
247
405
  // comment (relayKit.annotate.auto). Idempotent with an author-added /kit.js, and
248
406
  // a no-op when the author opts out via data-relay-annotate="off".
@@ -450,6 +608,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
450
608
  if (!m) return sendJson(res, 404, { error: `no embedded image block "${blockId}"` });
451
609
  res.writeHead(200, { 'content-type': m[1], 'cache-control': 'no-store' });
452
610
  res.end(Buffer.from(m[2], 'base64'));
611
+ } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/video/b/')) {
612
+ // Local video bytes, Range-streamed so the <video> element can seek.
613
+ const blockId = decodeURIComponent(pathname.slice('/video/b/'.length));
614
+ const block = findBlock(record.spec, blockId, 'video');
615
+ if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
616
+ streamFile(req, res, block.file, block.mime || 'application/octet-stream');
453
617
  } else if (req.method === 'GET' && pathname === '/html/board') {
454
618
  // Legacy alias → the board's first html block.
455
619
  const block = firstBoardHtml(record.spec);
@@ -464,6 +628,28 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
464
628
  const body = JSON.parse((await readBody(req)) || '{}');
465
629
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
466
630
  sendJson(res, 200, { ok: true });
631
+ } else if (req.method === 'POST' && pathname === '/api/open') {
632
+ // Open a board-referenced local file in the OS default app. Guarded by
633
+ // a same-origin check + an allowlist of paths the board actually links.
634
+ if (!sameOrigin(req, actualPort)) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
635
+ const body = JSON.parse((await readBody(req)) || '{}');
636
+ const raw = typeof body.path === 'string' ? body.path : '';
637
+ if (!raw.trim()) return sendJson(res, 400, { error: 'missing "path"' });
638
+ const baseCwd = record.cwd || process.cwd();
639
+ const target = resolveLocalPath(raw, baseCwd);
640
+ if (!target) return sendJson(res, 400, { error: 'invalid path' });
641
+ if (!buildOpenAllowlist(record.spec, baseCwd).has(target)) {
642
+ return sendJson(res, 403, { error: 'this path is not referenced on the board' });
643
+ }
644
+ let stat = null;
645
+ try {
646
+ stat = fs.statSync(target);
647
+ } catch {
648
+ stat = null;
649
+ }
650
+ if (!stat) return sendJson(res, 404, { error: 'file not found', path: target });
651
+ if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
652
+ sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
467
653
  } else if (req.method === 'POST' && pathname === '/api/draft') {
468
654
  const body = JSON.parse((await readBody(req)) || '{}');
469
655
  record.draft = {
@@ -505,6 +691,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
505
691
  });
506
692
  const actualPort = server.address().port;
507
693
  const url = `http://127.0.0.1:${actualPort}/`;
694
+ // Remember the port this board last bound, so `rly rescue <id>` can re-serve
695
+ // on the SAME port — letting a still-open (but disconnected) browser tab
696
+ // reconnect to its relative /api/* URLs without the user touching anything.
697
+ if (record.lastPort !== actualPort) {
698
+ record.lastPort = actualPort;
699
+ saveBoard(record);
700
+ }
508
701
  saveRunning({
509
702
  id: record.id,
510
703
  pid: process.pid,