@khanglvm/relay 0.2.0 → 0.4.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 +83 -5
- package/docs/AGENT.md +117 -4
- package/docs/assets/annotations.png +0 -0
- package/docs/assets/board-dark.png +0 -0
- package/docs/assets/board-light.png +0 -0
- package/docs/assets/demo.gif +0 -0
- package/docs/assets/mobile.png +0 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +79 -24
- package/skills/relay/examples/blocks-showcase.json +9 -0
- package/skills/relay/examples/diagram-coedit.json +15 -0
- package/src/cli.js +287 -15
- package/src/server.js +148 -11
- package/src/spec.js +37 -4
- package/src/ui/annotate.css +57 -5
- package/src/ui/annotate.js +130 -5
- package/src/ui/app.js +117 -2
- package/src/ui/blocks.css +97 -0
- package/src/ui/blocks.js +281 -19
- package/src/ui/style.css +9 -0
- package/vendor/VERSIONS.json +2 -1
- package/vendor/viz-standalone.js +9 -0
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
|
8
8
|
import {
|
|
9
9
|
createBoard,
|
|
10
10
|
loadBoard,
|
|
11
|
+
saveBoard,
|
|
11
12
|
deleteBoard,
|
|
12
13
|
listBoards,
|
|
13
14
|
listRunning,
|
|
@@ -26,7 +27,8 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'),
|
|
|
26
27
|
|
|
27
28
|
const VALUED_FLAGS = new Set([
|
|
28
29
|
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
29
|
-
'submit-label', 'height', 'limit', 'target', 'id',
|
|
30
|
+
'submit-label', 'height', 'limit', 'target', 'id', 'replies',
|
|
31
|
+
'on-result', 'notify-cmd', 'idle-grace',
|
|
30
32
|
]);
|
|
31
33
|
|
|
32
34
|
function camel(key) {
|
|
@@ -70,6 +72,44 @@ function printJson(obj) {
|
|
|
70
72
|
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
// Push-wake for `rly wait --notify-cmd`: run the agent's local shell command
|
|
76
|
+
// once a TERMINAL result lands. Result JSON goes to the command's stdin;
|
|
77
|
+
// RLY_BOARD_ID / RLY_STATUS / RLY_URL are exported. Same shape as the server's
|
|
78
|
+
// --on-result hook. Failures are swallowed; a 30s kill timer caps a hung cmd.
|
|
79
|
+
function runNotifyCmd(cmd, result) {
|
|
80
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return;
|
|
81
|
+
try {
|
|
82
|
+
const child = spawn('/bin/sh', ['-c', cmd], {
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
RLY_BOARD_ID: result.boardId || '',
|
|
86
|
+
RLY_STATUS: result.status || '',
|
|
87
|
+
RLY_URL: result.url || '',
|
|
88
|
+
},
|
|
89
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
90
|
+
});
|
|
91
|
+
child.on('error', () => {});
|
|
92
|
+
try {
|
|
93
|
+
child.stdin.write(JSON.stringify(result));
|
|
94
|
+
child.stdin.end();
|
|
95
|
+
} catch {
|
|
96
|
+
// best effort
|
|
97
|
+
}
|
|
98
|
+
const killTimer = setTimeout(() => {
|
|
99
|
+
try {
|
|
100
|
+
child.kill('SIGKILL');
|
|
101
|
+
} catch {
|
|
102
|
+
// already gone
|
|
103
|
+
}
|
|
104
|
+
}, 30000);
|
|
105
|
+
killTimer.unref();
|
|
106
|
+
child.on('close', () => clearTimeout(killTimer));
|
|
107
|
+
child.unref();
|
|
108
|
+
} catch {
|
|
109
|
+
// swallow — push-wake is best effort
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
function exitCodeFor(status) {
|
|
74
114
|
return { submitted: 0, acknowledged: 0, open: 0, timeout: 2, cancelled: 3 }[status] ?? 1;
|
|
75
115
|
}
|
|
@@ -145,6 +185,15 @@ async function runOrDetach(record, args) {
|
|
|
145
185
|
const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
|
|
146
186
|
const open = args.open !== false;
|
|
147
187
|
|
|
188
|
+
// Push-wake: persist the agent's --on-result command on the record so BOTH
|
|
189
|
+
// the inline runBoard path and the detached __serve path pick it up (the
|
|
190
|
+
// detached server reads record.onResult from disk). Runtime concern only —
|
|
191
|
+
// not part of the spec.
|
|
192
|
+
if (typeof args.onResult === 'string' && args.onResult.trim()) {
|
|
193
|
+
record.onResult = args.onResult;
|
|
194
|
+
saveBoard(record);
|
|
195
|
+
}
|
|
196
|
+
|
|
148
197
|
if (args.detach) {
|
|
149
198
|
const child = spawn(
|
|
150
199
|
process.execPath,
|
|
@@ -183,8 +232,58 @@ async function cmdAsk(args, mode) {
|
|
|
183
232
|
return runOrDetach(record, args);
|
|
184
233
|
}
|
|
185
234
|
|
|
235
|
+
// Seeds the draft from the last result (as runBoard would on reopen) and
|
|
236
|
+
// appends agent replies to the matching annotations, so an agent can ANSWER
|
|
237
|
+
// the user's element comments and re-open the board as a conversation.
|
|
238
|
+
// Persists the record with the result archived so runBoard doesn't re-seed.
|
|
239
|
+
function seedAgentReplies(record, replies) {
|
|
240
|
+
if (!Array.isArray(replies)) throw new CliError('--replies file must be a JSON array of {annotationId, text}.', 4);
|
|
241
|
+
// Mirror runBoard's reopen draft-seeding from the prior result.
|
|
242
|
+
if (record.result) {
|
|
243
|
+
if (record.result.answers) {
|
|
244
|
+
record.draft = {
|
|
245
|
+
answers: record.result.answers,
|
|
246
|
+
comment: record.result.comment || '',
|
|
247
|
+
notes: record.result.notes || {},
|
|
248
|
+
annotations: record.result.annotations || [],
|
|
249
|
+
updatedAt: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
record.pastResults = [...(record.pastResults || []), record.result].slice(-10);
|
|
253
|
+
record.result = null;
|
|
254
|
+
}
|
|
255
|
+
const annotations = (record.draft && Array.isArray(record.draft.annotations)) ? record.draft.annotations : [];
|
|
256
|
+
const validIds = annotations.map((a) => a && a.id).filter(Boolean);
|
|
257
|
+
const now = new Date().toISOString();
|
|
258
|
+
replies.forEach((r, i) => {
|
|
259
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
260
|
+
throw new CliError(`--replies[${i}]: must be an object {annotationId, text}.`, 4);
|
|
261
|
+
}
|
|
262
|
+
const annotationId = typeof r.annotationId === 'string' ? r.annotationId : '';
|
|
263
|
+
const text = typeof r.text === 'string' ? r.text : '';
|
|
264
|
+
if (!annotationId) throw new CliError(`--replies[${i}]: missing "annotationId".`, 4);
|
|
265
|
+
if (!text.trim()) throw new CliError(`--replies[${i}]: missing "text".`, 4);
|
|
266
|
+
const ann = annotations.find((a) => a && a.id === annotationId);
|
|
267
|
+
if (!ann) {
|
|
268
|
+
throw new CliError(
|
|
269
|
+
`--replies[${i}]: unknown annotationId "${annotationId}". Valid ids: ${validIds.length ? validIds.join(', ') : '(none)'}.`,
|
|
270
|
+
4
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (!Array.isArray(ann.replies)) ann.replies = [];
|
|
274
|
+
ann.replies.push({ author: 'agent', text, createdAt: now });
|
|
275
|
+
});
|
|
276
|
+
if (!record.draft) record.draft = { answers: {}, comment: '', notes: {}, annotations, updatedAt: now };
|
|
277
|
+
else record.draft.annotations = annotations;
|
|
278
|
+
saveBoard(record);
|
|
279
|
+
}
|
|
280
|
+
|
|
186
281
|
async function cmdReopen(args) {
|
|
187
282
|
const record = mustLoad(args._[0]);
|
|
283
|
+
if (args.replies !== undefined) {
|
|
284
|
+
const replies = parseJson(readFileOrThrow(args.replies), args.replies);
|
|
285
|
+
seedAgentReplies(record, replies);
|
|
286
|
+
}
|
|
188
287
|
const running = loadRunning(record.id);
|
|
189
288
|
if (running && isAlive(running.pid)) {
|
|
190
289
|
openUrl(running.url);
|
|
@@ -204,25 +303,110 @@ async function cmdReuse(args) {
|
|
|
204
303
|
return runOrDetach(record, args);
|
|
205
304
|
}
|
|
206
305
|
|
|
306
|
+
// Live-mutates a running board: rebuild the spec (full replace via --file, or
|
|
307
|
+
// patch the current spec via --title/--intro/-q), then POST it (already
|
|
308
|
+
// normalized) to the board's /api/update with the per-board mutation token.
|
|
309
|
+
async function cmdUpdate(args) {
|
|
310
|
+
const id = args._[0];
|
|
311
|
+
if (!id) throw new CliError('usage: rly update <board-id> (--file new-spec.json | --title T | --intro I | -q "...")');
|
|
312
|
+
const record = loadBoard(id);
|
|
313
|
+
if (!record) throw new CliError(`board "${id}" not found. See \`rly history\`.`, 5);
|
|
314
|
+
const running = loadRunning(id);
|
|
315
|
+
if (!running || !isAlive(running.pid)) {
|
|
316
|
+
throw new CliError(`board "${id}" is not running — \`rly reopen ${id}\` to serve it, then update.`, 5);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let spec;
|
|
320
|
+
if (args.file) {
|
|
321
|
+
const raw =
|
|
322
|
+
args.file === '-'
|
|
323
|
+
? parseJson(await readStdin(), 'stdin')
|
|
324
|
+
: parseJson(readFileOrThrow(args.file), args.file);
|
|
325
|
+
spec = normalizeSpec(raw);
|
|
326
|
+
} else if (args.title || args.intro || args.q.length) {
|
|
327
|
+
// Patch the CURRENT spec, then re-normalize so it's a clean normalized spec.
|
|
328
|
+
const raw = { ...record.spec };
|
|
329
|
+
if (args.title) raw.title = args.title;
|
|
330
|
+
if (args.intro) raw.intro = args.intro;
|
|
331
|
+
if (args.q.length) {
|
|
332
|
+
raw.questions = [...(raw.questions || []), ...args.q.map((s, i) => questionFromInline(s, i))];
|
|
333
|
+
}
|
|
334
|
+
spec = normalizeSpec(raw);
|
|
335
|
+
} else {
|
|
336
|
+
throw new CliError('update needs --file <spec.json>, --title, --intro, or -q "...".', 4);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let res;
|
|
340
|
+
try {
|
|
341
|
+
res = await fetch(new URL('/api/update', running.url), {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'content-type': 'application/json', 'x-relay-token': running.token || '' },
|
|
344
|
+
body: JSON.stringify({ spec }),
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
throw new CliError(`could not reach board "${id}" at ${running.url}: ${String((err && err.message) || err)}`, 5);
|
|
348
|
+
}
|
|
349
|
+
if (res.status === 403) throw new CliError(`board "${id}" rejected the update token (stale running file?).`, 5);
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
let detail = '';
|
|
352
|
+
try {
|
|
353
|
+
detail = (await res.json()).error || '';
|
|
354
|
+
} catch {
|
|
355
|
+
// non-JSON body
|
|
356
|
+
}
|
|
357
|
+
throw new CliError(`board "${id}" rejected the update${detail ? `: ${detail}` : ''}.`, 4);
|
|
358
|
+
}
|
|
359
|
+
const body = await res.json();
|
|
360
|
+
printJson({ status: 'updated', boardId: id, rev: body.rev, url: running.url });
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Best-effort fetch of /api/presence for a running board. Returns the parsed
|
|
365
|
+
// presence object, or null on any failure / timeout (500ms cap via
|
|
366
|
+
// AbortController). Never throws.
|
|
367
|
+
async function fetchPresence(url) {
|
|
368
|
+
if (!url) return null;
|
|
369
|
+
const controller = new AbortController();
|
|
370
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
371
|
+
try {
|
|
372
|
+
const res = await fetch(new URL('/api/presence', url), { signal: controller.signal });
|
|
373
|
+
if (!res.ok) return null;
|
|
374
|
+
return await res.json();
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
} finally {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
207
382
|
async function cmdWait(args) {
|
|
208
383
|
const id = args._[0];
|
|
209
|
-
if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>]');
|
|
384
|
+
if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>] [--while-active] [--idle-grace <sec>] [--notify-cmd <cmd>]');
|
|
210
385
|
const timeoutSec = args.timeout !== undefined ? Math.max(1, Number.parseInt(args.timeout, 10) || 1) : 3600;
|
|
211
|
-
const
|
|
386
|
+
const whileActive = args.whileActive === true;
|
|
387
|
+
const idleGrace = args.idleGrace !== undefined ? Math.max(0, Number.parseInt(args.idleGrace, 10) || 0) : 180;
|
|
388
|
+
const notifyCmd = typeof args.notifyCmd === 'string' && args.notifyCmd.trim() ? args.notifyCmd : null;
|
|
389
|
+
let deadline = Date.now() + timeoutSec * 1000;
|
|
212
390
|
mustLoad(id);
|
|
391
|
+
|
|
392
|
+
// Push-wake: run the agent's --notify-cmd after a TERMINAL result, then print.
|
|
393
|
+
const finishResult = (result) => {
|
|
394
|
+
if (notifyCmd) runNotifyCmd(notifyCmd, result);
|
|
395
|
+
printJson(result);
|
|
396
|
+
return exitCodeFor(result.status);
|
|
397
|
+
};
|
|
398
|
+
|
|
213
399
|
while (Date.now() < deadline) {
|
|
214
400
|
const record = mustLoad(id);
|
|
215
401
|
if (record.result && record.result.finishedAt) {
|
|
216
|
-
|
|
217
|
-
return exitCodeFor(record.result.status);
|
|
402
|
+
return finishResult(record.result);
|
|
218
403
|
}
|
|
219
404
|
const running = loadRunning(id);
|
|
220
405
|
if (!running || !isAlive(running.pid)) {
|
|
221
406
|
await sleep(700); // the result write may be racing the process exit
|
|
222
407
|
const again = loadBoard(id);
|
|
223
408
|
if (again?.result?.finishedAt) {
|
|
224
|
-
|
|
225
|
-
return exitCodeFor(again.result.status);
|
|
409
|
+
return finishResult(again.result);
|
|
226
410
|
}
|
|
227
411
|
printJson({
|
|
228
412
|
status: 'lost',
|
|
@@ -232,17 +416,93 @@ async function cmdWait(args) {
|
|
|
232
416
|
});
|
|
233
417
|
return 5;
|
|
234
418
|
}
|
|
419
|
+
if (Date.now() >= deadline) break;
|
|
235
420
|
await sleep(400);
|
|
236
421
|
}
|
|
237
|
-
|
|
422
|
+
|
|
423
|
+
// Deadline hit while the board is still OPEN. Fetch presence (cheaply).
|
|
424
|
+
const running = loadRunning(id);
|
|
425
|
+
const presence = running && isAlive(running.pid) ? await fetchPresence(running.url) : null;
|
|
426
|
+
|
|
427
|
+
// --while-active: if the user is present + recently active, EXTEND and keep
|
|
428
|
+
// waiting (repeat indefinitely while they stay active).
|
|
429
|
+
if (
|
|
430
|
+
whileActive &&
|
|
431
|
+
presence &&
|
|
432
|
+
presence.seen &&
|
|
433
|
+
(presence.visible || presence.focused) &&
|
|
434
|
+
presence.secondsSinceActivity < idleGrace
|
|
435
|
+
) {
|
|
436
|
+
deadline = Date.now() + Math.min(idleGrace, 120) * 1000;
|
|
437
|
+
return cmdWaitLoop(id, deadline, { whileActive, idleGrace, notifyCmd });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const out = {
|
|
238
441
|
status: 'wait-timeout',
|
|
239
442
|
boardId: id,
|
|
240
443
|
hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
|
|
241
|
-
}
|
|
444
|
+
};
|
|
445
|
+
if (presence) out.presence = presence;
|
|
446
|
+
printJson(out);
|
|
447
|
+
return 2;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Continuation loop for `rly wait --while-active` after a deadline extension.
|
|
451
|
+
// Identical waiting logic to cmdWait's main loop, then re-evaluates presence;
|
|
452
|
+
// extends again while the user stays active, otherwise emits wait-timeout.
|
|
453
|
+
async function cmdWaitLoop(id, deadline, opts) {
|
|
454
|
+
const { whileActive, idleGrace, notifyCmd } = opts;
|
|
455
|
+
const finishResult = (result) => {
|
|
456
|
+
if (notifyCmd) runNotifyCmd(notifyCmd, result);
|
|
457
|
+
printJson(result);
|
|
458
|
+
return exitCodeFor(result.status);
|
|
459
|
+
};
|
|
460
|
+
while (Date.now() < deadline) {
|
|
461
|
+
const record = mustLoad(id);
|
|
462
|
+
if (record.result && record.result.finishedAt) {
|
|
463
|
+
return finishResult(record.result);
|
|
464
|
+
}
|
|
465
|
+
const running = loadRunning(id);
|
|
466
|
+
if (!running || !isAlive(running.pid)) {
|
|
467
|
+
await sleep(700);
|
|
468
|
+
const again = loadBoard(id);
|
|
469
|
+
if (again?.result?.finishedAt) {
|
|
470
|
+
return finishResult(again.result);
|
|
471
|
+
}
|
|
472
|
+
printJson({
|
|
473
|
+
status: 'lost',
|
|
474
|
+
boardId: id,
|
|
475
|
+
draft: again?.draft ?? null,
|
|
476
|
+
error: 'board server exited without writing a result',
|
|
477
|
+
});
|
|
478
|
+
return 5;
|
|
479
|
+
}
|
|
480
|
+
if (Date.now() >= deadline) break;
|
|
481
|
+
await sleep(400);
|
|
482
|
+
}
|
|
483
|
+
const running = loadRunning(id);
|
|
484
|
+
const presence = running && isAlive(running.pid) ? await fetchPresence(running.url) : null;
|
|
485
|
+
if (
|
|
486
|
+
whileActive &&
|
|
487
|
+
presence &&
|
|
488
|
+
presence.seen &&
|
|
489
|
+
(presence.visible || presence.focused) &&
|
|
490
|
+
presence.secondsSinceActivity < idleGrace
|
|
491
|
+
) {
|
|
492
|
+
const next = Date.now() + Math.min(idleGrace, 120) * 1000;
|
|
493
|
+
return cmdWaitLoop(id, next, opts);
|
|
494
|
+
}
|
|
495
|
+
const out = {
|
|
496
|
+
status: 'wait-timeout',
|
|
497
|
+
boardId: id,
|
|
498
|
+
hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
|
|
499
|
+
};
|
|
500
|
+
if (presence) out.presence = presence;
|
|
501
|
+
printJson(out);
|
|
242
502
|
return 2;
|
|
243
503
|
}
|
|
244
504
|
|
|
245
|
-
function cmdResult(args) {
|
|
505
|
+
async function cmdResult(args) {
|
|
246
506
|
const record = mustLoad(args._[0]);
|
|
247
507
|
if (record.result && record.result.finishedAt) {
|
|
248
508
|
printJson(record.result);
|
|
@@ -250,8 +510,12 @@ function cmdResult(args) {
|
|
|
250
510
|
}
|
|
251
511
|
const running = loadRunning(record.id);
|
|
252
512
|
if (running && isAlive(running.pid)) {
|
|
253
|
-
// While open, expose the real-time autosaved draft so agents can peek
|
|
254
|
-
|
|
513
|
+
// While open, expose the real-time autosaved draft so agents can peek, plus
|
|
514
|
+
// best-effort presence (whether the user is still viewing/focused/active).
|
|
515
|
+
const out = { status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null };
|
|
516
|
+
const presence = await fetchPresence(running.url);
|
|
517
|
+
if (presence) out.presence = presence;
|
|
518
|
+
printJson(out);
|
|
255
519
|
return 0;
|
|
256
520
|
}
|
|
257
521
|
printJson({ status: 'lost', boardId: record.id, draft: record.draft ?? null });
|
|
@@ -534,13 +798,19 @@ USAGE
|
|
|
534
798
|
rly ask -q "Deploy?::yesno" -q "!Env::single::dev,staging,prod"
|
|
535
799
|
quick inline questions ("!" = required, label::type::options)
|
|
536
800
|
rly ask ... --detach no blocking: prints {boardId,url} now; collect via \`rly wait <id>\`
|
|
801
|
+
rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
|
|
537
802
|
rly show --html-file viz.html visualization-only board (submit button = acknowledge)
|
|
538
803
|
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
|
539
|
-
|
|
804
|
+
--while-active [--idle-grace 180]: keep waiting past the deadline
|
|
805
|
+
while the user is still viewing/focused & recently active
|
|
806
|
+
--notify-cmd "<cmd>": run <cmd> on a terminal result (JSON on stdin)
|
|
807
|
+
rly result <id> result/status now (includes live autosaved draft + presence while open)
|
|
540
808
|
rly list [--json] running boards
|
|
541
809
|
rly open [id] re-open the browser tab of a running board
|
|
542
|
-
rly reopen <id>
|
|
810
|
+
rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
|
|
811
|
+
(--replies [{annotationId,text}] = agent answers to element comments)
|
|
543
812
|
rly reuse <id> [--dump] re-run a past board as a new board (--dump prints its spec)
|
|
813
|
+
rly update <id> --file spec.json live-mutate a RUNNING board (or --title/--intro/-q); page reloads
|
|
544
814
|
rly stop <id> | --all stop running board(s) (status: cancelled, draft preserved)
|
|
545
815
|
rly history [--limit n] [--json] saved boards
|
|
546
816
|
rly spec <id> print a saved board's spec JSON (edit, then ask --file again)
|
|
@@ -590,10 +860,12 @@ export async function main(argv) {
|
|
|
590
860
|
return await cmdReopen(parseArgs(rest));
|
|
591
861
|
case 'reuse':
|
|
592
862
|
return await cmdReuse(parseArgs(rest));
|
|
863
|
+
case 'update':
|
|
864
|
+
return await cmdUpdate(parseArgs(rest));
|
|
593
865
|
case 'wait':
|
|
594
866
|
return await cmdWait(parseArgs(rest));
|
|
595
867
|
case 'result':
|
|
596
|
-
return cmdResult(parseArgs(rest));
|
|
868
|
+
return await cmdResult(parseArgs(rest));
|
|
597
869
|
case 'list':
|
|
598
870
|
return cmdList(parseArgs(rest));
|
|
599
871
|
case 'open':
|