@khanglvm/relay 0.6.0 → 0.8.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/docs/AGENT.md +20 -4
- package/package.json +1 -1
- package/skills/relay/SKILL.md +2 -0
- package/src/cli.js +314 -3
- package/src/server.js +89 -11
- package/src/spec.js +1 -1
- package/src/ui/annotate.css +93 -1
- package/src/ui/annotate.js +259 -11
- package/src/ui/app.js +60 -16
- package/src/ui/blocks.css +4 -0
- package/src/ui/blocks.js +54 -1
- package/src/ui/style.css +8 -2
package/docs/AGENT.md
CHANGED
|
@@ -189,7 +189,9 @@ Rules of thumb:
|
|
|
189
189
|
### All block shapes
|
|
190
190
|
|
|
191
191
|
```jsonc
|
|
192
|
-
// 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.
|
|
193
195
|
{ "type": "markdown", "md": "## Heading\nAny **CommonMark** prose." }
|
|
194
196
|
|
|
195
197
|
// Mermaid diagram — vendored, lazy-loaded; natural height, max 1200 px + scroll
|
|
@@ -229,7 +231,9 @@ Rules of thumb:
|
|
|
229
231
|
"height": 280
|
|
230
232
|
}
|
|
231
233
|
|
|
232
|
-
// 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.
|
|
233
237
|
{
|
|
234
238
|
"type": "table",
|
|
235
239
|
"columns": [
|
|
@@ -266,8 +270,8 @@ Rules of thumb:
|
|
|
266
270
|
| `graphviz` | precise dependency graphs, call graphs, state machines when Mermaid's auto-layout falls short; individually annotatable nodes and edges |
|
|
267
271
|
| `plantuml` | UML diagrams (sequence, class, component) via server rendering; great for detailed interface contracts |
|
|
268
272
|
| `chart` | numbers, trends, comparisons, metrics |
|
|
269
|
-
| `table` | structured comparisons, option matrices, data grids |
|
|
270
|
-
| `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) |
|
|
271
275
|
| `code` | code snippets, config examples, command output |
|
|
272
276
|
| `image` | screenshots, mockup exports, photos — local files embed and work offline |
|
|
273
277
|
| `html` | anything else — pixel-perfect mockups, custom widgets, embeds |
|
|
@@ -410,6 +414,18 @@ default 180); once they go idle it returns the normal `wait-timeout` JSON,
|
|
|
410
414
|
with `presence` attached so you can decide what to do next. Prefer this over
|
|
411
415
|
raising `--timeout`.
|
|
412
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
|
+
|
|
413
429
|
## Push-wake — get notified instead of polling
|
|
414
430
|
|
|
415
431
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
@@ -117,6 +117,8 @@ single/multi question.
|
|
|
117
117
|
"labels": ["Jan","Feb"], "series": [{"label":"x","data":[1,2]}], "height": 320 }
|
|
118
118
|
{ "type": "chart", "config": { /* full Chart.js v4 config */ }, "height": 300 }
|
|
119
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.)
|
|
120
122
|
{ "type": "code", "lang": "js", "code": "const x = 1;" }
|
|
121
123
|
{ "type": "html", "html": "<p>hi</p>", "height": 360 }
|
|
122
124
|
{ "type": "html", "htmlFile": "viz.html", "height": 400 }
|
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,12 +23,14 @@ 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',
|
|
30
32
|
'submit-label', 'height', 'limit', 'target', 'id', 'replies',
|
|
31
|
-
'on-result', 'notify-cmd', 'idle-grace',
|
|
33
|
+
'on-result', 'notify-cmd', 'idle-grace', 'scope',
|
|
32
34
|
]);
|
|
33
35
|
|
|
34
36
|
function camel(key) {
|
|
@@ -769,6 +771,11 @@ function cmdSkill(rest) {
|
|
|
769
771
|
const installed = [];
|
|
770
772
|
for (const t of targets) {
|
|
771
773
|
fs.mkdirSync(path.dirname(t), { recursive: true });
|
|
774
|
+
// Clear whatever is already there first. cpSync refuses to overwrite a
|
|
775
|
+
// non-directory (a symlink or file at the target — e.g. a skill dir the
|
|
776
|
+
// user symlinked elsewhere) with a directory, so a plain re-install would
|
|
777
|
+
// crash. rmSync on a symlink removes the link itself, not its target.
|
|
778
|
+
fs.rmSync(t, { recursive: true, force: true });
|
|
772
779
|
fs.cpSync(SKILL_SRC, t, { recursive: true });
|
|
773
780
|
fs.writeFileSync(path.join(t, '.rly-version'), VERSION);
|
|
774
781
|
installed.push(t);
|
|
@@ -793,11 +800,302 @@ Full guide: \`rly agent\`.`);
|
|
|
793
800
|
return 0;
|
|
794
801
|
}
|
|
795
802
|
|
|
803
|
+
// ===========================================================================
|
|
804
|
+
// `rly install` — inject relay's always-read rules into ANY agent's
|
|
805
|
+
// instruction file, cross-platform. `rly skill install` (above) handles the
|
|
806
|
+
// full SKILL.md for skill-aware agents; this covers the long tail (Cursor,
|
|
807
|
+
// Copilot, Kiro, Windsurf, Cline, Gemini, generic AGENTS.md, …) that read a
|
|
808
|
+
// rules/instructions/steering/context markdown file instead.
|
|
809
|
+
// ===========================================================================
|
|
810
|
+
|
|
811
|
+
const RELAY_BEGIN = '<!-- relay:begin (managed by `rly install` — your edits outside these markers are kept) -->';
|
|
812
|
+
const RELAY_END = '<!-- relay:end -->';
|
|
813
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
814
|
+
|
|
815
|
+
// Resolve OS-specific base dirs from an injectable env/home/platform so the
|
|
816
|
+
// path logic is unit-testable for win32 / linux / darwin without running there.
|
|
817
|
+
function platformDirs({ platform, home, env }) {
|
|
818
|
+
const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
819
|
+
const localApp = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
820
|
+
// Copilot-in-JetBrains global instructions dir.
|
|
821
|
+
const jetbrainsCopilot = platform === 'win32'
|
|
822
|
+
? path.join(localApp, 'github-copilot', 'intellij')
|
|
823
|
+
: path.join(xdg, 'github-copilot', 'intellij');
|
|
824
|
+
return { jetbrainsCopilot, documents: path.join(home, 'Documents') };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// The relay instruction registry. Each agent declares how to install relay's
|
|
828
|
+
// rules at `global` (user, machine-wide) and/or `project` (cwd) scope, in what
|
|
829
|
+
// `style`, and a `detect` dir whose existence means "you use this agent"
|
|
830
|
+
// (drives `--all`). Styles:
|
|
831
|
+
// 'shared' — upsert a marked block into a possibly-shared file (CLAUDE.md,
|
|
832
|
+
// AGENTS.md, copilot-instructions.md, …); your other content is
|
|
833
|
+
// preserved, only relay's block is rewritten.
|
|
834
|
+
// 'dedicated' — relay owns the whole file (.kiro/steering, .windsurf/rules,
|
|
835
|
+
// .clinerules, …); safe to overwrite.
|
|
836
|
+
// 'mdc' — dedicated, with Cursor `.mdc` YAML frontmatter.
|
|
837
|
+
export function agentRegistry({ platform = process.platform, home = os.homedir(), cwd = process.cwd(), env = process.env } = {}) {
|
|
838
|
+
const d = platformDirs({ platform, home, env });
|
|
839
|
+
const j = path.join;
|
|
840
|
+
return [
|
|
841
|
+
{ id: 'claude', label: 'Claude Code',
|
|
842
|
+
detect: j(home, '.claude'),
|
|
843
|
+
global: { file: j(home, '.claude', 'CLAUDE.md'), style: 'shared' },
|
|
844
|
+
project: { file: j(cwd, 'CLAUDE.md'), style: 'shared' },
|
|
845
|
+
note: 'full skill: `rly skill install`' },
|
|
846
|
+
{ id: 'codex', label: 'OpenAI Codex',
|
|
847
|
+
detect: j(home, '.codex'),
|
|
848
|
+
global: { file: j(home, '.codex', 'AGENTS.md'), style: 'shared' },
|
|
849
|
+
project: { file: j(cwd, 'AGENTS.md'), style: 'shared' } },
|
|
850
|
+
{ id: 'agents', label: 'AGENTS.md standard (Amp, Jules, Cline, …)',
|
|
851
|
+
detect: j(home, '.agents'),
|
|
852
|
+
global: { file: j(home, '.agents', 'AGENTS.md'), style: 'shared' },
|
|
853
|
+
project: { file: j(cwd, 'AGENTS.md'), style: 'shared' } },
|
|
854
|
+
{ id: 'cursor', label: 'Cursor',
|
|
855
|
+
detect: j(home, '.cursor'),
|
|
856
|
+
global: null, // user rules are set in Cursor Settings UI (not file-based)
|
|
857
|
+
project: { file: j(cwd, '.cursor', 'rules', 'relay.mdc'), style: 'mdc' },
|
|
858
|
+
note: 'global "User Rules" are set in Settings UI, not a file' },
|
|
859
|
+
{ id: 'copilot', label: 'GitHub Copilot (VS Code / Visual Studio / JetBrains)',
|
|
860
|
+
detect: path.dirname(d.jetbrainsCopilot), // …/github-copilot
|
|
861
|
+
global: { file: j(d.jetbrainsCopilot, 'global-copilot-instructions.md'), style: 'shared' },
|
|
862
|
+
project: { file: j(cwd, '.github', 'copilot-instructions.md'), style: 'shared' },
|
|
863
|
+
note: 'global file applies in JetBrains IDEs; project file applies everywhere' },
|
|
864
|
+
{ id: 'kiro', label: 'Kiro',
|
|
865
|
+
detect: j(home, '.kiro'),
|
|
866
|
+
global: { file: j(home, '.kiro', 'steering', 'relay.md'), style: 'dedicated' },
|
|
867
|
+
project: { file: j(cwd, '.kiro', 'steering', 'relay.md'), style: 'dedicated' } },
|
|
868
|
+
{ id: 'windsurf', label: 'Windsurf',
|
|
869
|
+
detect: j(home, '.codeium'),
|
|
870
|
+
global: { file: j(home, '.codeium', 'windsurf', 'memories', 'global_rules.md'), style: 'shared' },
|
|
871
|
+
project: { file: j(cwd, '.windsurf', 'rules', 'relay.md'), style: 'dedicated' } },
|
|
872
|
+
{ id: 'cline', label: 'Cline',
|
|
873
|
+
detect: j(d.documents, 'Cline'),
|
|
874
|
+
global: { file: j(d.documents, 'Cline', 'Rules', 'relay.md'), style: 'dedicated' },
|
|
875
|
+
project: { file: j(cwd, '.clinerules', 'relay.md'), style: 'dedicated' } },
|
|
876
|
+
{ id: 'gemini', label: 'Gemini CLI',
|
|
877
|
+
detect: j(home, '.gemini'),
|
|
878
|
+
global: { file: j(home, '.gemini', 'GEMINI.md'), style: 'shared' },
|
|
879
|
+
project: { file: j(cwd, 'GEMINI.md'), style: 'shared' } },
|
|
880
|
+
];
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Render the relay rules in the style the target file expects.
|
|
884
|
+
function renderInstruction(style) {
|
|
885
|
+
if (style === 'mdc') {
|
|
886
|
+
return `---\ndescription: relay — collect decisions & show rich visuals in the browser, not the terminal\nalwaysApply: true\n---\n\n${SKILL_RULES}\n`;
|
|
887
|
+
}
|
|
888
|
+
return `${SKILL_RULES}\n`; // dedicated file — relay owns it
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Upsert relay's marked block into a (possibly shared / pre-existing) file,
|
|
892
|
+
// leaving everything outside the markers untouched. Returns 'added'|'updated'.
|
|
893
|
+
function upsertBlock(file, body) {
|
|
894
|
+
let existing = '';
|
|
895
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
|
|
896
|
+
const wrapped = `${RELAY_BEGIN}\n${body}\n${RELAY_END}`;
|
|
897
|
+
const re = new RegExp(escapeRegExp(RELAY_BEGIN) + '[\\s\\S]*?' + escapeRegExp(RELAY_END));
|
|
898
|
+
const had = re.test(existing);
|
|
899
|
+
const next = had
|
|
900
|
+
? existing.replace(re, wrapped)
|
|
901
|
+
: (existing.trim() ? existing.replace(/\s*$/, '') + '\n\n' + wrapped + '\n' : wrapped + '\n');
|
|
902
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
903
|
+
fs.writeFileSync(file, next);
|
|
904
|
+
return had ? 'updated' : 'added';
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function writeInstruction(target) {
|
|
908
|
+
if (target.style === 'shared') return upsertBlock(target.file, SKILL_RULES);
|
|
909
|
+
const existed = fs.existsSync(target.file);
|
|
910
|
+
fs.mkdirSync(path.dirname(target.file), { recursive: true });
|
|
911
|
+
fs.writeFileSync(target.file, renderInstruction(target.style));
|
|
912
|
+
return existed ? 'updated' : 'added';
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function cmdInstall(args) {
|
|
916
|
+
const reg = agentRegistry();
|
|
917
|
+
const byId = Object.fromEntries(reg.map((a) => [a.id, a]));
|
|
918
|
+
const scope = args.scope === 'project' ? 'project' : args.scope === 'global' ? 'global' : null;
|
|
919
|
+
|
|
920
|
+
// Pick the target for an agent: explicit --scope wins; else prefer global
|
|
921
|
+
// (machine-wide), falling back to project when the agent has no global file.
|
|
922
|
+
const pick = (a) => {
|
|
923
|
+
if (scope === 'project') return a.project ? { scope: 'project', t: a.project } : null;
|
|
924
|
+
if (scope === 'global') return a.global ? { scope: 'global', t: a.global } : null;
|
|
925
|
+
if (a.global) return { scope: 'global', t: a.global };
|
|
926
|
+
if (a.project) return { scope: 'project', t: a.project };
|
|
927
|
+
return null;
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const wantAll = args.all === true || String(args.target || '').toLowerCase() === 'all';
|
|
931
|
+
|
|
932
|
+
// No target → show the matrix for THIS platform.
|
|
933
|
+
if (!wantAll && (args.list === true || !args.target)) {
|
|
934
|
+
printJson({
|
|
935
|
+
platform: process.platform,
|
|
936
|
+
agents: reg.map((a) => ({
|
|
937
|
+
agent: a.id, label: a.label,
|
|
938
|
+
global: a.global ? a.global.file : null,
|
|
939
|
+
project: a.project ? a.project.file : null,
|
|
940
|
+
note: a.note,
|
|
941
|
+
})),
|
|
942
|
+
usage: 'rly install --target <agent>[,<agent>] [--scope global|project] [--print] | rly install --all',
|
|
943
|
+
});
|
|
944
|
+
return 0;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Resolve the set of {agent, scope, target} to act on.
|
|
948
|
+
let chosen;
|
|
949
|
+
if (wantAll) {
|
|
950
|
+
chosen = reg
|
|
951
|
+
.filter((a) => { try { return fs.existsSync(a.detect); } catch { return false; } })
|
|
952
|
+
.map((a) => ({ a, ...(pick(a) || {}) }))
|
|
953
|
+
.filter((x) => x.t);
|
|
954
|
+
if (!chosen.length) {
|
|
955
|
+
throw new CliError('no known agents detected on this machine (no ~/.claude, ~/.codex, ~/.cursor, ~/.kiro, …). Use --target <agent>.', 4);
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
const ids = String(args.target).split(',').map((s) => s.trim()).filter(Boolean);
|
|
959
|
+
chosen = [];
|
|
960
|
+
for (const id of ids) {
|
|
961
|
+
const a = byId[id];
|
|
962
|
+
if (!a) throw new CliError(`unknown agent "${id}". Run \`rly install --list\` to see supported agents.`, 4);
|
|
963
|
+
const p = pick(a);
|
|
964
|
+
if (!p) throw new CliError(`${a.label} has no ${scope || 'installable'} instruction file${a.note ? ` — ${a.note}` : ''}. Try \`--scope project\`.`, 4);
|
|
965
|
+
chosen.push({ a, ...p });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// --print: emit content + resolved path for manual copy/paste; no writes.
|
|
970
|
+
if (args.print === true) {
|
|
971
|
+
for (const { a, t } of chosen) {
|
|
972
|
+
const content = t.style === 'shared' ? `${RELAY_BEGIN}\n${SKILL_RULES}\n${RELAY_END}` : renderInstruction(t.style);
|
|
973
|
+
process.stdout.write(`# ${a.label}\n# → ${t.file}\n\n${content}\n\n`);
|
|
974
|
+
}
|
|
975
|
+
return 0;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const installed = chosen.map(({ a, scope: sc, t }) => ({
|
|
979
|
+
agent: a.id, scope: sc, file: t.file, action: writeInstruction(t),
|
|
980
|
+
}));
|
|
981
|
+
printJson({ installed, note: 'reload/re-open your agent (or re-list its rules) if it does not pick this up immediately' });
|
|
982
|
+
return 0;
|
|
983
|
+
}
|
|
984
|
+
|
|
796
985
|
function cmdAgent() {
|
|
797
986
|
console.log(fs.readFileSync(path.join(PKG_ROOT, 'docs', 'AGENT.md'), 'utf8'));
|
|
798
987
|
return 0;
|
|
799
988
|
}
|
|
800
989
|
|
|
990
|
+
// `rly upgrade` — install the latest CLI globally AND refresh the bundled skill
|
|
991
|
+
// in one shot. (`update` is taken by the live-mutate command, so this is
|
|
992
|
+
// `upgrade` / `self-update`.) Running boards are surfaced and handled: a global
|
|
993
|
+
// reinstall overwrites relay's files, but live detached servers snapshot their
|
|
994
|
+
// UI at first request and serve from memory, so they keep working on their own
|
|
995
|
+
// version. Flags: --stop (stop running boards first), --force (upgrade while
|
|
996
|
+
// they keep running), --cli-only / --skill-only (scope).
|
|
997
|
+
async function cmdUpgrade(args) {
|
|
998
|
+
const force = args.force === true;
|
|
999
|
+
const doStop = args.stop === true;
|
|
1000
|
+
const wantCli = args.skillOnly !== true;
|
|
1001
|
+
const wantSkill = args.cliOnly !== true;
|
|
1002
|
+
|
|
1003
|
+
const running = listRunning();
|
|
1004
|
+
|
|
1005
|
+
// --dry-run: report the plan (incl. how running boards would be handled)
|
|
1006
|
+
// without installing anything or stopping anything.
|
|
1007
|
+
if (args.dryRun === true) {
|
|
1008
|
+
printJson({
|
|
1009
|
+
dryRun: true,
|
|
1010
|
+
wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'rly skill install'].filter(Boolean),
|
|
1011
|
+
runningBoards: running.map((r) => r.id),
|
|
1012
|
+
runningHandling: running.length
|
|
1013
|
+
? doStop
|
|
1014
|
+
? 'stop them first'
|
|
1015
|
+
: force
|
|
1016
|
+
? 'leave them running (snapshotted)'
|
|
1017
|
+
: 'BLOCKED — pass --stop or --force'
|
|
1018
|
+
: 'none running',
|
|
1019
|
+
});
|
|
1020
|
+
return 0;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (running.length) {
|
|
1024
|
+
const ids = running.map((r) => r.id).join(', ');
|
|
1025
|
+
if (doStop) {
|
|
1026
|
+
process.stderr.write(`Stopping ${running.length} running board(s) before upgrading: ${ids}\n`);
|
|
1027
|
+
for (const r of running) {
|
|
1028
|
+
try { process.kill(r.pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
1029
|
+
}
|
|
1030
|
+
for (const r of running) await pollFor(() => (loadRunning(r.id) ? null : true), 5000);
|
|
1031
|
+
} else if (!force) {
|
|
1032
|
+
// Default: don't silently overwrite under live boards — explain + let the
|
|
1033
|
+
// user choose. The boards themselves are safe; this is about clarity.
|
|
1034
|
+
process.stderr.write(
|
|
1035
|
+
`${running.length} board(s) still running: ${ids}\n` +
|
|
1036
|
+
'They keep serving their current version safely (each snapshots its UI in memory),\n' +
|
|
1037
|
+
'and stay readable via `rly result <id>` / `rly wait <id>`. Pick one:\n' +
|
|
1038
|
+
' • rly stop --all then re-run `rly upgrade` (cleanest)\n' +
|
|
1039
|
+
' • rly upgrade --stop stop them as part of this upgrade\n' +
|
|
1040
|
+
' • rly upgrade --force upgrade now; leave them running on their snapshot\n'
|
|
1041
|
+
);
|
|
1042
|
+
printJson({ upgraded: false, reason: 'running-boards', running: running.map((r) => r.id) });
|
|
1043
|
+
return 0;
|
|
1044
|
+
} else {
|
|
1045
|
+
process.stderr.write(`--force: leaving ${running.length} board(s) running on their snapshotted version: ${ids}\n`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const did = {};
|
|
1050
|
+
|
|
1051
|
+
if (wantCli) {
|
|
1052
|
+
process.stderr.write(`\nUpgrading ${PKG_NAME} → latest (npm install -g ${PKG_NAME}@latest)\n`);
|
|
1053
|
+
// shell:true so Windows resolves `npm` → `npm.cmd` (same reason the skill /
|
|
1054
|
+
// version spawns below use it); the package name has no shell metacharacters.
|
|
1055
|
+
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit', shell: true });
|
|
1056
|
+
if (r.error || r.status !== 0) {
|
|
1057
|
+
throw new CliError(
|
|
1058
|
+
`npm install failed${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`}. ` +
|
|
1059
|
+
`Update manually: npm install -g ${PKG_NAME}@latest`,
|
|
1060
|
+
1
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
did.cli = `${PKG_NAME}@latest`;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (wantSkill) {
|
|
1067
|
+
// Spawn the freshly installed binary (on PATH) so the NEW bundled skill is
|
|
1068
|
+
// what lands — this process still holds the previous bundle in memory.
|
|
1069
|
+
process.stderr.write('\nRefreshing the bundled skill (rly skill install)\n');
|
|
1070
|
+
const r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
|
|
1071
|
+
if (r.error || r.status !== 0) {
|
|
1072
|
+
process.stderr.write(
|
|
1073
|
+
`Skill refresh did not complete${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`} — ` +
|
|
1074
|
+
'run `rly skill install` yourself (or `npx skills add khanglvm/relay --skill relay --all`).\n'
|
|
1075
|
+
);
|
|
1076
|
+
} else {
|
|
1077
|
+
did.skill = 'installed';
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Report the now-current global version (best-effort).
|
|
1082
|
+
let nowVersion = null;
|
|
1083
|
+
try {
|
|
1084
|
+
const v = spawnSync('rly', ['--version'], { encoding: 'utf8', shell: true });
|
|
1085
|
+
if (v.status === 0 && v.stdout) nowVersion = String(v.stdout).trim();
|
|
1086
|
+
} catch {
|
|
1087
|
+
// best effort
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
printJson({
|
|
1091
|
+
upgraded: true,
|
|
1092
|
+
...did,
|
|
1093
|
+
version: nowVersion,
|
|
1094
|
+
note: 'most agents pick the new skill up immediately; if not, re-list skills or restart the session',
|
|
1095
|
+
});
|
|
1096
|
+
return 0;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
801
1099
|
async function cmdServeInternal(args) {
|
|
802
1100
|
const id = args.id;
|
|
803
1101
|
if (!id) throw new CliError('__serve: missing --id');
|
|
@@ -807,6 +1105,9 @@ async function cmdServeInternal(args) {
|
|
|
807
1105
|
open: args.open !== false,
|
|
808
1106
|
timeoutSec: args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800,
|
|
809
1107
|
quiet: true,
|
|
1108
|
+
// Detached board: timeout hands back to the agent but keeps serving so the
|
|
1109
|
+
// user can keep commenting and still submit (seamless past the deadline).
|
|
1110
|
+
keepAliveOnTimeout: true,
|
|
810
1111
|
});
|
|
811
1112
|
await done;
|
|
812
1113
|
return 0;
|
|
@@ -842,6 +1143,11 @@ USAGE
|
|
|
842
1143
|
rly agent FULL GUIDE for AI agents (spec format, blocks, sizing, patterns)
|
|
843
1144
|
rly skill [install|rules|path] bundled universal agent skill (Claude Code, Codex, …)
|
|
844
1145
|
\`rly skill rules >> CLAUDE.md\` adds always-read usage rules
|
|
1146
|
+
rly install --target <agent> inject relay's rules into an agent's instruction file
|
|
1147
|
+
agents: claude codex cursor copilot kiro windsurf cline gemini agents
|
|
1148
|
+
--scope global|project · --print (copy/paste) · --all · --list (no flags)
|
|
1149
|
+
rly upgrade install the latest CLI globally + refresh the skill in one step
|
|
1150
|
+
--stop/--force handle running boards · --dry-run · --cli-only/--skill-only
|
|
845
1151
|
|
|
846
1152
|
COMMON FLAGS
|
|
847
1153
|
--title <s> --intro <s> --html-file <f> --height <px> --submit-label <s>
|
|
@@ -904,6 +1210,11 @@ export async function main(argv) {
|
|
|
904
1210
|
return cmdRm(parseArgs(rest));
|
|
905
1211
|
case 'skill':
|
|
906
1212
|
return cmdSkill(rest);
|
|
1213
|
+
case 'install':
|
|
1214
|
+
return cmdInstall(parseArgs(rest));
|
|
1215
|
+
case 'upgrade':
|
|
1216
|
+
case 'self-update':
|
|
1217
|
+
return await cmdUpgrade(parseArgs(rest));
|
|
907
1218
|
case 'agent':
|
|
908
1219
|
return cmdAgent();
|
|
909
1220
|
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
|
|
@@ -324,7 +337,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
|
|
|
324
337
|
// (submitted / acknowledged / timeout / cancelled). The result is also
|
|
325
338
|
// persisted into the board record so `rly wait` / `rly result` can read it
|
|
326
339
|
// from another process.
|
|
327
|
-
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 }) {
|
|
328
341
|
const record = loadBoard(id);
|
|
329
342
|
if (!record) throw new Error(`board ${id} not found`);
|
|
330
343
|
const spec = record.spec;
|
|
@@ -355,6 +368,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
355
368
|
let rev = 1;
|
|
356
369
|
let status = 'open';
|
|
357
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;
|
|
358
376
|
// Latest client presence ping (null until the first ping arrives).
|
|
359
377
|
let presence = null;
|
|
360
378
|
let resolveDone;
|
|
@@ -372,7 +390,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
372
390
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
373
391
|
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
374
392
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
375
|
-
sendJson(res, 200, { status, rev });
|
|
393
|
+
sendJson(res, 200, { status, rev, softTimedOut });
|
|
376
394
|
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
377
395
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
378
396
|
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
@@ -498,15 +516,25 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
498
516
|
});
|
|
499
517
|
|
|
500
518
|
let timer = null;
|
|
501
|
-
|
|
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
|
+
}
|
|
502
531
|
const onSignal = () => finish({ status: 'cancelled' });
|
|
503
532
|
process.on('SIGINT', onSignal);
|
|
504
533
|
process.on('SIGTERM', onSignal);
|
|
505
534
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
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) {
|
|
510
538
|
// blockEdits: from this submit, else fall back to the autosaved draft
|
|
511
539
|
// (timeout/cancel). null when there are none.
|
|
512
540
|
const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
|
|
@@ -533,13 +561,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
533
561
|
}
|
|
534
562
|
record.result = result;
|
|
535
563
|
saveBoard(record);
|
|
536
|
-
|
|
537
|
-
|
|
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) {
|
|
538
570
|
removeRunning(record.id);
|
|
539
571
|
if (timer) clearTimeout(timer);
|
|
572
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
540
573
|
process.removeListener('SIGINT', onSignal);
|
|
541
574
|
process.removeListener('SIGTERM', onSignal);
|
|
542
|
-
// Grace period so the success page renders (and auto-closes) first.
|
|
543
575
|
setTimeout(() => {
|
|
544
576
|
try {
|
|
545
577
|
server.closeAllConnections?.();
|
|
@@ -551,6 +583,52 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
551
583
|
}, 600);
|
|
552
584
|
}
|
|
553
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
|
+
|
|
554
632
|
if (open) openUrl(url);
|
|
555
633
|
if (!quiet) {
|
|
556
634
|
process.stderr.write(
|
package/src/spec.js
CHANGED
|
@@ -411,7 +411,7 @@ const BLOCK_SCHEMA = {
|
|
|
411
411
|
required: ['type'],
|
|
412
412
|
properties: {
|
|
413
413
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
414
|
-
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).' },
|
|
415
415
|
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
416
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>].' },
|
|
417
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.' },
|