@khanglvm/relay 0.5.4 → 0.7.0
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 +16 -0
- package/docs/AGENT.md +53 -17
- package/package.json +1 -1
- package/skills/relay/SKILL.md +10 -3
- package/src/cli.js +119 -2
- package/src/server.js +111 -14
- package/src/spec.js +7 -4
- package/src/ui/annotate.css +93 -1
- package/src/ui/annotate.js +259 -11
- package/src/ui/app.js +125 -39
- package/src/ui/blocks.css +25 -7
- package/src/ui/blocks.js +54 -1
- package/src/ui/kit.js +335 -27
- package/src/ui/style.css +8 -2
package/README.md
CHANGED
|
@@ -92,6 +92,22 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
92
92
|
npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Changelog
|
|
96
|
+
|
|
97
|
+
### 0.6.0 — comment on anything
|
|
98
|
+
- **Comment on any part of a custom-HTML mockup.** Hover any element — a heading,
|
|
99
|
+
a button, a card, the price — and a pin appears to leave an inline note. No
|
|
100
|
+
setup needed; the agent writes zero annotation code. Want to scope it? Mark
|
|
101
|
+
specific elements with `data-relay-annotate="label"`.
|
|
102
|
+
- **Radio questions can carry a note.** Pick an option *and* say why, in one
|
|
103
|
+
optional field — now shown by default (set `"note": false` to hide it).
|
|
104
|
+
- **Edge-to-edge fullscreen** for charts and HTML mockups, with the toolbar
|
|
105
|
+
pinned to the top while you scroll.
|
|
106
|
+
- The board **title and intro are commentable** too.
|
|
107
|
+
|
|
108
|
+
### 0.5.0
|
|
109
|
+
- Visual answer options, image blocks, viewer redesign, adoption rules.
|
|
110
|
+
|
|
95
111
|
## Migration from quest-board
|
|
96
112
|
|
|
97
113
|
relay was formerly `@khanglvm/quest-board` (CLI: `qbd`) — that package is
|
package/docs/AGENT.md
CHANGED
|
@@ -6,9 +6,10 @@ then **wait for them to click Submit** and read the answers as JSON from stdout.
|
|
|
6
6
|
No "type 'done' in the terminal", no hand-rolled HTML+server.
|
|
7
7
|
|
|
8
8
|
**Tell the user** at the start of your intro text that they can hover chart
|
|
9
|
-
points, diagram nodes,
|
|
10
|
-
markdown blocks to annotate — their comments
|
|
11
|
-
alongside their answers. Treat annotations as
|
|
9
|
+
points, diagram nodes, table cells, and any element of a custom-HTML block to
|
|
10
|
+
leave comments, and select text in markdown blocks to annotate — their comments
|
|
11
|
+
come back in `result.annotations` alongside their answers. Treat annotations as
|
|
12
|
+
first-class feedback.
|
|
12
13
|
|
|
13
14
|
Everything machine-relevant is on **stdout as JSON**; human-facing logs go to
|
|
14
15
|
stderr. Exit codes: `0` submitted/acknowledged · `2` timeout · `3` cancelled ·
|
|
@@ -137,7 +138,9 @@ boolean/bool/yn→yesno, input→text, longtext→textarea, rating/likert→scal
|
|
|
137
138
|
|
|
138
139
|
Unanswered questions are absent from `answers` and listed in `skipped`.
|
|
139
140
|
Questions with `"note": true` show a small optional free-text field; non-empty
|
|
140
|
-
notes come back in `notes` keyed by question id.
|
|
141
|
+
notes come back in `notes` keyed by question id. **`single` (radio) questions
|
|
142
|
+
show this note by default** so the user can qualify their pick — set
|
|
143
|
+
`"note": false` to hide it. On `timeout`/`cancelled`, a
|
|
141
144
|
`draft` field carries the autosaved partial answers and any annotations written
|
|
142
145
|
so far.
|
|
143
146
|
|
|
@@ -186,7 +189,9 @@ Rules of thumb:
|
|
|
186
189
|
### All block shapes
|
|
187
190
|
|
|
188
191
|
```jsonc
|
|
189
|
-
// Markdown — built-in mini renderer, no library
|
|
192
|
+
// Markdown — built-in mini renderer, no library. Headings, lists, code, quotes,
|
|
193
|
+
// links, and GFM pipe tables all render. For real tabular DATA use a `table`
|
|
194
|
+
// block instead (sortable + per-cell comments); markdown tables are display-only.
|
|
190
195
|
{ "type": "markdown", "md": "## Heading\nAny **CommonMark** prose." }
|
|
191
196
|
|
|
192
197
|
// Mermaid diagram — vendored, lazy-loaded; natural height, max 1200 px + scroll
|
|
@@ -226,7 +231,9 @@ Rules of thumb:
|
|
|
226
231
|
"height": 280
|
|
227
232
|
}
|
|
228
233
|
|
|
229
|
-
// Table — sortable, annotatable cells
|
|
234
|
+
// Table — sortable, with individually annotatable cells. Prefer this over a
|
|
235
|
+
// markdown pipe table whenever you're showing structured data (option matrices,
|
|
236
|
+
// comparisons, workstream/effort grids): users can sort it and comment per cell.
|
|
230
237
|
{
|
|
231
238
|
"type": "table",
|
|
232
239
|
"columns": [
|
|
@@ -263,8 +270,8 @@ Rules of thumb:
|
|
|
263
270
|
| `graphviz` | precise dependency graphs, call graphs, state machines when Mermaid's auto-layout falls short; individually annotatable nodes and edges |
|
|
264
271
|
| `plantuml` | UML diagrams (sequence, class, component) via server rendering; great for detailed interface contracts |
|
|
265
272
|
| `chart` | numbers, trends, comparisons, metrics |
|
|
266
|
-
| `table` | structured comparisons, option matrices, data grids |
|
|
267
|
-
| `markdown` | prose context, background, instructions, section headings |
|
|
273
|
+
| `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
|
+
| `markdown` | prose context, background, instructions, section headings (renders GFM pipe tables too, but reach for a `table` block for real data) |
|
|
268
275
|
| `code` | code snippets, config examples, command output |
|
|
269
276
|
| `image` | screenshots, mockup exports, photos — local files embed and work offline |
|
|
270
277
|
| `html` | anything else — pixel-perfect mockups, custom widgets, embeds |
|
|
@@ -295,23 +302,40 @@ Rules of thumb:
|
|
|
295
302
|
background/text match the user's current theme. **Full documents** are served
|
|
296
303
|
verbatim and receive a `?theme=light|dark` query param on theme toggle.
|
|
297
304
|
|
|
298
|
-
###
|
|
305
|
+
### Custom HTML is hover-commentable automatically
|
|
299
306
|
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
Every custom-HTML block is annotatable out of the box — relay injects a tiny
|
|
308
|
+
runtime that lets the user **hover any meaningful element** (headings,
|
|
309
|
+
paragraphs, list items, buttons, images, cards, table cells…) to get a comment
|
|
310
|
+
pin, exactly like the rest of the board. You don't have to do anything. Comments
|
|
311
|
+
come back in `result.annotations` with `target.kind = "html-element"`, a stable
|
|
312
|
+
`target.ref` (the element), and a `target.label` derived from the element.
|
|
313
|
+
|
|
314
|
+
Reach for the controls below only when you want to **scope or label** what's
|
|
315
|
+
annotatable — typically for an interactive prototype where blanket hover targets
|
|
316
|
+
would get in the way:
|
|
317
|
+
|
|
318
|
+
**Declarative signal (preferred)** — mark the elements you want commented. Any
|
|
319
|
+
signal present switches the auto-pick off, so only your marked elements are
|
|
320
|
+
annotatable:
|
|
321
|
+
|
|
322
|
+
```html
|
|
323
|
+
<button data-relay-annotate="Primary CTA" data-relay-detail="checkout flow">Buy now</button>
|
|
324
|
+
<section data-relay-annotate="Pricing table">…</section>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Imperative** — same effect from script (needs `/kit.js`, which relay also
|
|
328
|
+
auto-loads):
|
|
302
329
|
|
|
303
330
|
```html
|
|
304
331
|
<script src="/kit.js"></script>
|
|
305
332
|
<script>
|
|
306
333
|
relayKit.commentable(document.getElementById('chart'), 'Revenue chart', 'Q1 2026');
|
|
307
|
-
relayKit.commentable(document.getElementById('hero-cta'), 'CTA button');
|
|
308
334
|
</script>
|
|
309
335
|
```
|
|
310
336
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
Annotations come back in `result.annotations` with `target.kind = "html-element"`,
|
|
314
|
-
`target.label`, and (if provided) `target.detail`.
|
|
337
|
+
**Opt out** of element annotation for a block with `data-relay-annotate="off"`
|
|
338
|
+
on `<html>` or `<body>`.
|
|
315
339
|
|
|
316
340
|
## Annotations
|
|
317
341
|
|
|
@@ -349,7 +373,7 @@ intro. Annotations are autosaved with the draft and returned in the final result
|
|
|
349
373
|
| `graphviz-node` | `nodeId`, `text` | clicking a Graphviz node or edge |
|
|
350
374
|
| `table-cell` | `row` (0-based), `col` (column key), `value` | clicking a table cell |
|
|
351
375
|
| `text` | `quote`, `prefix` (≤30 chars before), `suffix` (≤30 after) | selecting text in a markdown block |
|
|
352
|
-
| `html-element` | `label`, `detail?` |
|
|
376
|
+
| `html-element` | `ref`, `label`, `detail?` | hovering any element in a custom-HTML block (auto), or a `data-relay-annotate` / `relayKit.commentable()` element |
|
|
353
377
|
| `image` | `label` | clicking a PlantUML diagram or an image block |
|
|
354
378
|
|
|
355
379
|
Read annotations as first-class feedback — they often carry the sharpest insight
|
|
@@ -390,6 +414,18 @@ default 180); once they go idle it returns the normal `wait-timeout` JSON,
|
|
|
390
414
|
with `presence` attached so you can decide what to do next. Prefer this over
|
|
391
415
|
raising `--timeout`.
|
|
392
416
|
|
|
417
|
+
### A `timeout` on a detached board is NOT the end
|
|
418
|
+
|
|
419
|
+
For a **detached** board, the `timeout` deadline is *soft*: it hands you a
|
|
420
|
+
`timeout` result (with the autosaved draft) so you regain control, but the
|
|
421
|
+
server stays up and the board stays fully usable — the user can keep
|
|
422
|
+
commenting and still hit Submit. The page tells them you stopped waiting and
|
|
423
|
+
to prompt you afterward. So if you got a `timeout` and the user might still be
|
|
424
|
+
working: re-check later with `rly result <id>` (its status flips to
|
|
425
|
+
`submitted` once they finish), or pass `--on-result` so a late submit
|
|
426
|
+
push-wakes you. A blocking `rly ask` (no `--detach`) still ends hard on
|
|
427
|
+
timeout, since there's no separate waiter to hand back to.
|
|
428
|
+
|
|
393
429
|
## Push-wake — get notified instead of polling
|
|
394
430
|
|
|
395
431
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -93,7 +93,8 @@ submit with unanswered questions (returned in `skipped`) unless
|
|
|
93
93
|
|
|
94
94
|
Set `"note": true` on a question to add a small optional free-text field under
|
|
95
95
|
it — use when the user may want to qualify their choice. Returned as
|
|
96
|
-
`result.notes[questionId]`.
|
|
96
|
+
`result.notes[questionId]`. `single` (radio) questions include this note by
|
|
97
|
+
default (so a pick can carry a comment); set `"note": false` to hide it.
|
|
97
98
|
|
|
98
99
|
Quick one-liners without a spec file:
|
|
99
100
|
|
|
@@ -116,6 +117,8 @@ single/multi question.
|
|
|
116
117
|
"labels": ["Jan","Feb"], "series": [{"label":"x","data":[1,2]}], "height": 320 }
|
|
117
118
|
{ "type": "chart", "config": { /* full Chart.js v4 config */ }, "height": 300 }
|
|
118
119
|
{ "type": "table", "columns": ["A","B"], "rows": [["x","y"]], "sortable": true }
|
|
120
|
+
// ^ use a `table` block for tabular data — sortable + per-cell comments.
|
|
121
|
+
// (markdown blocks render GFM pipe tables too, but those are display-only.)
|
|
119
122
|
{ "type": "code", "lang": "js", "code": "const x = 1;" }
|
|
120
123
|
{ "type": "html", "html": "<p>hi</p>", "height": 360 }
|
|
121
124
|
{ "type": "html", "htmlFile": "viz.html", "height": 400 }
|
|
@@ -153,8 +156,12 @@ pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
|
|
|
153
156
|
|
|
154
157
|
## Annotations
|
|
155
158
|
|
|
156
|
-
Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
|
|
157
|
-
select text in markdown to leave inline
|
|
159
|
+
Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
|
|
160
|
+
any element of a custom-HTML block, or select text in markdown to leave inline
|
|
161
|
+
comments. Custom HTML is hover-commentable automatically — to scope/label what's
|
|
162
|
+
annotatable, mark elements with `data-relay-annotate="Label"` (any signal turns
|
|
163
|
+
the auto-pick off); opt a block out with `data-relay-annotate="off"`. Always
|
|
164
|
+
mention annotation in the board intro.
|
|
158
165
|
|
|
159
166
|
`result.annotations` is an array of:
|
|
160
167
|
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { CliError, sleep, pollFor } from './util.js';
|
|
7
7
|
import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
@@ -23,7 +23,9 @@ import { openUrl } from './open.js';
|
|
|
23
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
25
25
|
const BIN = path.join(PKG_ROOT, 'bin', 'rly.js');
|
|
26
|
-
const
|
|
26
|
+
const PKG_JSON = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
27
|
+
const VERSION = PKG_JSON.version;
|
|
28
|
+
const PKG_NAME = PKG_JSON.name; // e.g. "@khanglvm/relay" — the global package to upgrade
|
|
27
29
|
|
|
28
30
|
const VALUED_FLAGS = new Set([
|
|
29
31
|
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
@@ -798,6 +800,113 @@ function cmdAgent() {
|
|
|
798
800
|
return 0;
|
|
799
801
|
}
|
|
800
802
|
|
|
803
|
+
// `rly upgrade` — install the latest CLI globally AND refresh the bundled skill
|
|
804
|
+
// in one shot. (`update` is taken by the live-mutate command, so this is
|
|
805
|
+
// `upgrade` / `self-update`.) Running boards are surfaced and handled: a global
|
|
806
|
+
// reinstall overwrites relay's files, but live detached servers snapshot their
|
|
807
|
+
// UI at first request and serve from memory, so they keep working on their own
|
|
808
|
+
// version. Flags: --stop (stop running boards first), --force (upgrade while
|
|
809
|
+
// they keep running), --cli-only / --skill-only (scope).
|
|
810
|
+
async function cmdUpgrade(args) {
|
|
811
|
+
const force = args.force === true;
|
|
812
|
+
const doStop = args.stop === true;
|
|
813
|
+
const wantCli = args.skillOnly !== true;
|
|
814
|
+
const wantSkill = args.cliOnly !== true;
|
|
815
|
+
|
|
816
|
+
const running = listRunning();
|
|
817
|
+
|
|
818
|
+
// --dry-run: report the plan (incl. how running boards would be handled)
|
|
819
|
+
// without installing anything or stopping anything.
|
|
820
|
+
if (args.dryRun === true) {
|
|
821
|
+
printJson({
|
|
822
|
+
dryRun: true,
|
|
823
|
+
wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'rly skill install'].filter(Boolean),
|
|
824
|
+
runningBoards: running.map((r) => r.id),
|
|
825
|
+
runningHandling: running.length
|
|
826
|
+
? doStop
|
|
827
|
+
? 'stop them first'
|
|
828
|
+
: force
|
|
829
|
+
? 'leave them running (snapshotted)'
|
|
830
|
+
: 'BLOCKED — pass --stop or --force'
|
|
831
|
+
: 'none running',
|
|
832
|
+
});
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (running.length) {
|
|
837
|
+
const ids = running.map((r) => r.id).join(', ');
|
|
838
|
+
if (doStop) {
|
|
839
|
+
process.stderr.write(`Stopping ${running.length} running board(s) before upgrading: ${ids}\n`);
|
|
840
|
+
for (const r of running) {
|
|
841
|
+
try { process.kill(r.pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
842
|
+
}
|
|
843
|
+
for (const r of running) await pollFor(() => (loadRunning(r.id) ? null : true), 5000);
|
|
844
|
+
} else if (!force) {
|
|
845
|
+
// Default: don't silently overwrite under live boards — explain + let the
|
|
846
|
+
// user choose. The boards themselves are safe; this is about clarity.
|
|
847
|
+
process.stderr.write(
|
|
848
|
+
`${running.length} board(s) still running: ${ids}\n` +
|
|
849
|
+
'They keep serving their current version safely (each snapshots its UI in memory),\n' +
|
|
850
|
+
'and stay readable via `rly result <id>` / `rly wait <id>`. Pick one:\n' +
|
|
851
|
+
' • rly stop --all then re-run `rly upgrade` (cleanest)\n' +
|
|
852
|
+
' • rly upgrade --stop stop them as part of this upgrade\n' +
|
|
853
|
+
' • rly upgrade --force upgrade now; leave them running on their snapshot\n'
|
|
854
|
+
);
|
|
855
|
+
printJson({ upgraded: false, reason: 'running-boards', running: running.map((r) => r.id) });
|
|
856
|
+
return 0;
|
|
857
|
+
} else {
|
|
858
|
+
process.stderr.write(`--force: leaving ${running.length} board(s) running on their snapshotted version: ${ids}\n`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const did = {};
|
|
863
|
+
|
|
864
|
+
if (wantCli) {
|
|
865
|
+
process.stderr.write(`\nUpgrading ${PKG_NAME} → latest (npm install -g ${PKG_NAME}@latest)\n`);
|
|
866
|
+
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
|
|
867
|
+
if (r.error || r.status !== 0) {
|
|
868
|
+
throw new CliError(
|
|
869
|
+
`npm install failed${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`}. ` +
|
|
870
|
+
`Update manually: npm install -g ${PKG_NAME}@latest`,
|
|
871
|
+
1
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
did.cli = `${PKG_NAME}@latest`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (wantSkill) {
|
|
878
|
+
// Spawn the freshly installed binary (on PATH) so the NEW bundled skill is
|
|
879
|
+
// what lands — this process still holds the previous bundle in memory.
|
|
880
|
+
process.stderr.write('\nRefreshing the bundled skill (rly skill install)\n');
|
|
881
|
+
const r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
|
|
882
|
+
if (r.error || r.status !== 0) {
|
|
883
|
+
process.stderr.write(
|
|
884
|
+
`Skill refresh did not complete${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`} — ` +
|
|
885
|
+
'run `rly skill install` yourself (or `npx skills add khanglvm/relay --skill relay --all`).\n'
|
|
886
|
+
);
|
|
887
|
+
} else {
|
|
888
|
+
did.skill = 'installed';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Report the now-current global version (best-effort).
|
|
893
|
+
let nowVersion = null;
|
|
894
|
+
try {
|
|
895
|
+
const v = spawnSync('rly', ['--version'], { encoding: 'utf8', shell: true });
|
|
896
|
+
if (v.status === 0 && v.stdout) nowVersion = String(v.stdout).trim();
|
|
897
|
+
} catch {
|
|
898
|
+
// best effort
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
printJson({
|
|
902
|
+
upgraded: true,
|
|
903
|
+
...did,
|
|
904
|
+
version: nowVersion,
|
|
905
|
+
note: 'most agents pick the new skill up immediately; if not, re-list skills or restart the session',
|
|
906
|
+
});
|
|
907
|
+
return 0;
|
|
908
|
+
}
|
|
909
|
+
|
|
801
910
|
async function cmdServeInternal(args) {
|
|
802
911
|
const id = args.id;
|
|
803
912
|
if (!id) throw new CliError('__serve: missing --id');
|
|
@@ -807,6 +916,9 @@ async function cmdServeInternal(args) {
|
|
|
807
916
|
open: args.open !== false,
|
|
808
917
|
timeoutSec: args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800,
|
|
809
918
|
quiet: true,
|
|
919
|
+
// Detached board: timeout hands back to the agent but keeps serving so the
|
|
920
|
+
// user can keep commenting and still submit (seamless past the deadline).
|
|
921
|
+
keepAliveOnTimeout: true,
|
|
810
922
|
});
|
|
811
923
|
await done;
|
|
812
924
|
return 0;
|
|
@@ -842,6 +954,8 @@ USAGE
|
|
|
842
954
|
rly agent FULL GUIDE for AI agents (spec format, blocks, sizing, patterns)
|
|
843
955
|
rly skill [install|rules|path] bundled universal agent skill (Claude Code, Codex, …)
|
|
844
956
|
\`rly skill rules >> CLAUDE.md\` adds always-read usage rules
|
|
957
|
+
rly upgrade install the latest CLI globally + refresh the skill in one step
|
|
958
|
+
--stop/--force handle running boards · --dry-run · --cli-only/--skill-only
|
|
845
959
|
|
|
846
960
|
COMMON FLAGS
|
|
847
961
|
--title <s> --intro <s> --html-file <f> --height <px> --submit-label <s>
|
|
@@ -904,6 +1018,9 @@ export async function main(argv) {
|
|
|
904
1018
|
return cmdRm(parseArgs(rest));
|
|
905
1019
|
case 'skill':
|
|
906
1020
|
return cmdSkill(rest);
|
|
1021
|
+
case 'upgrade':
|
|
1022
|
+
case 'self-update':
|
|
1023
|
+
return await cmdUpgrade(parseArgs(rest));
|
|
907
1024
|
case 'agent':
|
|
908
1025
|
return cmdAgent();
|
|
909
1026
|
case 'schema':
|
package/src/server.js
CHANGED
|
@@ -16,12 +16,25 @@ const escapeHtml = (s) =>
|
|
|
16
16
|
|
|
17
17
|
// Concurrently authored UI assets (blocks/annotate) may not exist yet at this
|
|
18
18
|
// phase's runtime — read-guard so the server still boots with empty fallbacks.
|
|
19
|
+
//
|
|
20
|
+
// Successful reads are cached for the lifetime of the process. A running board
|
|
21
|
+
// thus serves one consistent UI snapshot taken at first request: if the relay
|
|
22
|
+
// package is updated on disk (e.g. `rly upgrade`) while this server is live, it
|
|
23
|
+
// keeps serving its own version instead of mixing new assets with old in-memory
|
|
24
|
+
// server logic. Empty/failed reads are NOT cached, preserving the boot-time
|
|
25
|
+
// fallback for assets authored concurrently during a build.
|
|
26
|
+
const _uiCache = new Map();
|
|
19
27
|
function readUi(name) {
|
|
28
|
+
const hit = _uiCache.get(name);
|
|
29
|
+
if (hit) return hit;
|
|
30
|
+
let content = '';
|
|
20
31
|
try {
|
|
21
|
-
|
|
32
|
+
content = fs.readFileSync(path.join(UI_DIR, name), 'utf8');
|
|
22
33
|
} catch {
|
|
23
34
|
return '';
|
|
24
35
|
}
|
|
36
|
+
if (content) _uiCache.set(name, content);
|
|
37
|
+
return content;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
// Strips block bodies for the client payload: html blocks ship only metadata
|
|
@@ -230,16 +243,35 @@ function sendFromDir(res, dir, name, contentType) {
|
|
|
230
243
|
return true;
|
|
231
244
|
}
|
|
232
245
|
|
|
246
|
+
// Loaded into every custom-HTML iframe so users can hover any element to leave a
|
|
247
|
+
// comment (relayKit.annotate.auto). Idempotent with an author-added /kit.js, and
|
|
248
|
+
// a no-op when the author opts out via data-relay-annotate="off".
|
|
249
|
+
const ANNOTATE_BOOTSTRAP =
|
|
250
|
+
'<script>(function(){function go(){try{window.relayKit&&window.relayKit.annotate&&window.relayKit.annotate.auto();}catch(e){}}' +
|
|
251
|
+
'if(window.relayKit&&window.relayKit.annotate)return go();' +
|
|
252
|
+
"var s=document.createElement('script');s.src='/kit.js';s.onload=go;s.onerror=go;" +
|
|
253
|
+
'(document.head||document.documentElement).appendChild(s);})();<\/script>';
|
|
254
|
+
|
|
255
|
+
// Insert a snippet right before </body> (else </html>, else append).
|
|
256
|
+
function injectBeforeBodyEnd(html, snippet) {
|
|
257
|
+
const lower = html.toLowerCase();
|
|
258
|
+
let idx = lower.lastIndexOf('</body>');
|
|
259
|
+
if (idx === -1) idx = lower.lastIndexOf('</html>');
|
|
260
|
+
if (idx === -1) return html + snippet;
|
|
261
|
+
return html.slice(0, idx) + snippet + html.slice(idx);
|
|
262
|
+
}
|
|
263
|
+
|
|
233
264
|
// Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
|
|
234
265
|
// matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
|
|
235
266
|
// block in dark mode. Full documents are served verbatim — their authors can
|
|
236
|
-
// read the ?theme=light|dark query param themselves.
|
|
267
|
+
// read the ?theme=light|dark query param themselves. Either way the annotate
|
|
268
|
+
// bootstrap is injected so every element is hover-commentable.
|
|
237
269
|
function wrapFragment(content, theme) {
|
|
238
|
-
if (/<html[\s>]/i.test(content)) return content;
|
|
270
|
+
if (/<html[\s>]/i.test(content)) return injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP);
|
|
239
271
|
const dark = theme === 'dark';
|
|
240
272
|
const bg = dark ? '#282624' : '#ffffff';
|
|
241
273
|
const fg = dark ? '#edeae4' : '#1c1b19';
|
|
242
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}</body></html>`;
|
|
274
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${ANNOTATE_BOOTSTRAP}</body></html>`;
|
|
243
275
|
}
|
|
244
276
|
|
|
245
277
|
function readBody(req, limit = 5 * 1024 * 1024) {
|
|
@@ -305,7 +337,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
|
|
|
305
337
|
// (submitted / acknowledged / timeout / cancelled). The result is also
|
|
306
338
|
// persisted into the board record so `rly wait` / `rly result` can read it
|
|
307
339
|
// from another process.
|
|
308
|
-
export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false }) {
|
|
340
|
+
export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false, keepAliveOnTimeout = false }) {
|
|
309
341
|
const record = loadBoard(id);
|
|
310
342
|
if (!record) throw new Error(`board ${id} not found`);
|
|
311
343
|
const spec = record.spec;
|
|
@@ -336,6 +368,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
336
368
|
let rev = 1;
|
|
337
369
|
let status = 'open';
|
|
338
370
|
let finished = false;
|
|
371
|
+
// Soft timeout: the board's time is up and a `timeout` result was handed back
|
|
372
|
+
// to the waiting agent, but the server stays live so the user can keep
|
|
373
|
+
// working and still submit. Surfaced via /api/status so the page can show a
|
|
374
|
+
// calm "agent stopped waiting" note instead of disconnecting.
|
|
375
|
+
let softTimedOut = false;
|
|
339
376
|
// Latest client presence ping (null until the first ping arrives).
|
|
340
377
|
let presence = null;
|
|
341
378
|
let resolveDone;
|
|
@@ -353,7 +390,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
353
390
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
354
391
|
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
355
392
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
356
|
-
sendJson(res, 200, { status, rev });
|
|
393
|
+
sendJson(res, 200, { status, rev, softTimedOut });
|
|
357
394
|
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
358
395
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
359
396
|
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
@@ -479,15 +516,25 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
479
516
|
});
|
|
480
517
|
|
|
481
518
|
let timer = null;
|
|
482
|
-
|
|
519
|
+
let idleTimer = null;
|
|
520
|
+
// The detached server's timeout is SOFT (keepAliveOnTimeout): at the deadline
|
|
521
|
+
// we hand a `timeout` result back to the waiting agent (so `rly wait` returns
|
|
522
|
+
// with the autosaved draft) but keep the server listening, so the user can
|
|
523
|
+
// keep working and still submit. A late submit overwrites the result with
|
|
524
|
+
// `submitted` and re-fires the push-wake; the board only truly closes on
|
|
525
|
+
// submit, an explicit stop, or once the user has clearly left (idle
|
|
526
|
+
// watchdog). A BLOCKING `rly ask` has no separate waiter to hand back to, so
|
|
527
|
+
// its timeout stays hard (close + resolve, exit 2).
|
|
528
|
+
if (timeoutSec > 0) {
|
|
529
|
+
timer = setTimeout(keepAliveOnTimeout ? softTimeout : () => finish({ status: 'timeout' }), timeoutSec * 1000);
|
|
530
|
+
}
|
|
483
531
|
const onSignal = () => finish({ status: 'cancelled' });
|
|
484
532
|
process.on('SIGINT', onSignal);
|
|
485
533
|
process.on('SIGTERM', onSignal);
|
|
486
534
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
status = partial.status;
|
|
535
|
+
// Build + persist a result object onto the record (read cross-process by
|
|
536
|
+
// `rly wait` / `rly result`). Does NOT touch the server lifecycle.
|
|
537
|
+
function persistResult(partial) {
|
|
491
538
|
// blockEdits: from this submit, else fall back to the autosaved draft
|
|
492
539
|
// (timeout/cancel). null when there are none.
|
|
493
540
|
const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
|
|
@@ -514,13 +561,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
514
561
|
}
|
|
515
562
|
record.result = result;
|
|
516
563
|
saveBoard(record);
|
|
517
|
-
|
|
518
|
-
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Tear the HTTP server down after a short grace (so the success page renders
|
|
568
|
+
// and auto-closes first). The result is assumed already persisted.
|
|
569
|
+
function closeServer(result) {
|
|
519
570
|
removeRunning(record.id);
|
|
520
571
|
if (timer) clearTimeout(timer);
|
|
572
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
521
573
|
process.removeListener('SIGINT', onSignal);
|
|
522
574
|
process.removeListener('SIGTERM', onSignal);
|
|
523
|
-
// Grace period so the success page renders (and auto-closes) first.
|
|
524
575
|
setTimeout(() => {
|
|
525
576
|
try {
|
|
526
577
|
server.closeAllConnections?.();
|
|
@@ -532,6 +583,52 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
532
583
|
}, 600);
|
|
533
584
|
}
|
|
534
585
|
|
|
586
|
+
// Terminal finish (submit / acknowledge / explicit cancel): persist the
|
|
587
|
+
// result, push-wake the agent for EVERY terminal status, and close the
|
|
588
|
+
// server. A late submit after a soft timeout lands here and overwrites the
|
|
589
|
+
// earlier `timeout` result with `submitted` (re-firing the push-wake).
|
|
590
|
+
function finish(partial) {
|
|
591
|
+
if (finished) return;
|
|
592
|
+
finished = true;
|
|
593
|
+
status = partial.status;
|
|
594
|
+
const result = persistResult(partial);
|
|
595
|
+
runOnResult(record.onResult, result, { quiet });
|
|
596
|
+
closeServer(result);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// SOFT timeout — hand a `timeout` result to the agent but keep the board live
|
|
600
|
+
// and submittable. The agent's `rly wait` returns now; the user can carry on.
|
|
601
|
+
function softTimeout() {
|
|
602
|
+
if (finished || softTimedOut) return;
|
|
603
|
+
softTimedOut = true;
|
|
604
|
+
const result = persistResult({ status: 'timeout' });
|
|
605
|
+
runOnResult(record.onResult, result, { quiet });
|
|
606
|
+
startIdleWatchdog();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// After a soft timeout, close the board for real once the user has clearly
|
|
610
|
+
// left (no presence ping for IDLE_CLOSE_MS) or a hard absolute cap is hit, so
|
|
611
|
+
// an abandoned board doesn't keep a server alive forever. Re-persists the
|
|
612
|
+
// latest draft as the final `timeout` result; no double push-wake.
|
|
613
|
+
function startIdleWatchdog() {
|
|
614
|
+
const IDLE_CLOSE_MS = 15 * 60 * 1000;
|
|
615
|
+
const HARD_CAP_MS = 6 * 60 * 60 * 1000;
|
|
616
|
+
const softAt = Date.now();
|
|
617
|
+
idleTimer = setInterval(() => {
|
|
618
|
+
if (finished) {
|
|
619
|
+
clearInterval(idleTimer);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const lastSeen = presence ? presence.atMs : softAt;
|
|
623
|
+
if (Date.now() - lastSeen > IDLE_CLOSE_MS || Date.now() - softAt > HARD_CAP_MS) {
|
|
624
|
+
finished = true;
|
|
625
|
+
clearInterval(idleTimer);
|
|
626
|
+
closeServer(persistResult({ status: 'timeout' }));
|
|
627
|
+
}
|
|
628
|
+
}, 60 * 1000);
|
|
629
|
+
idleTimer.unref?.();
|
|
630
|
+
}
|
|
631
|
+
|
|
535
632
|
if (open) openUrl(url);
|
|
536
633
|
if (!quiet) {
|
|
537
634
|
process.stderr.write(
|
package/src/spec.js
CHANGED
|
@@ -332,7 +332,10 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
332
332
|
label,
|
|
333
333
|
description: asStr(rq.description),
|
|
334
334
|
required: rq.required === true,
|
|
335
|
-
|
|
335
|
+
// Radio (single) questions show the optional per-answer note by default so
|
|
336
|
+
// the user can qualify their pick; other types stay opt-in. An explicit
|
|
337
|
+
// note:false turns it off for a single question.
|
|
338
|
+
note: rq.note === undefined ? type === 'single' : rq.note === true,
|
|
336
339
|
blocks: buildBlocks(rq, cwd, where, `${id}-`),
|
|
337
340
|
placeholder: asStr(rq.placeholder),
|
|
338
341
|
};
|
|
@@ -408,7 +411,7 @@ const BLOCK_SCHEMA = {
|
|
|
408
411
|
required: ['type'],
|
|
409
412
|
properties: {
|
|
410
413
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
411
|
-
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
|
|
414
|
+
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library) — headings, lists, code, quotes, links, and GFM pipe tables. Text selections are commentable. For real tabular data prefer a "table" block (sortable + per-cell comments).' },
|
|
412
415
|
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
413
416
|
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>].' },
|
|
414
417
|
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
|
@@ -486,7 +489,7 @@ export const SPEC_SCHEMA = {
|
|
|
486
489
|
},
|
|
487
490
|
},
|
|
488
491
|
other: { type: 'boolean', default: false, description: 'single/multi: add a free-text "Other" option. Its text is returned verbatim as the value.' },
|
|
489
|
-
note: { type: 'boolean',
|
|
492
|
+
note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for "single" (radio) questions so users can comment on their pick, false for other types; set note:false to hide it on a single question.' },
|
|
490
493
|
placeholder: { type: 'string', description: 'For text/textarea.' },
|
|
491
494
|
default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
|
|
492
495
|
min: { type: 'integer', default: 1, description: 'scale only' },
|
|
@@ -504,7 +507,7 @@ export const SPEC_SCHEMA = {
|
|
|
504
507
|
type: 'array',
|
|
505
508
|
readOnly: true,
|
|
506
509
|
description:
|
|
507
|
-
'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}.',
|
|
510
|
+
'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"|"graphviz-node"|"table-cell"|"text"|"html-element"|"image", …}, text, createdAt}. For html-element, target carries a stable ref + label; users can hover any element of a custom-HTML block (automatic) or ones the author marked with data-relay-annotate.',
|
|
508
511
|
},
|
|
509
512
|
blockEdits: {
|
|
510
513
|
type: 'object',
|