@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/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 deadline = Date.now() + timeoutSec * 1000;
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
- printJson(record.result);
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
- printJson(again.result);
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
- printJson({
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
- printJson({ status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null });
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
- rly result <id> result/status now (includes live autosaved draft while open)
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> serve a saved board again, prefilled with its saved answers
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':