@hanzlaa/rcode 4.1.2 → 4.3.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.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. package/server/orchestrator.js +329 -5
@@ -8,9 +8,13 @@
8
8
  * local terminal.
9
9
  *
10
10
  * HTTP (control plane):
11
- * POST /api/run { storyId, cmd? } → spawn a PTY session
11
+ * POST /api/run { storyId, cmd?, runner?, model? } → spawn a PTY session
12
12
  * POST /api/stop { storyId } → SIGTERM the PTY
13
- * GET /api/sessions → list all sessions
13
+ * GET /api/sessions → list all sessions (status is
14
+ * 'blocked' instead of 'running' when the PTY is idle on a question —
15
+ * see looksBlocked(); each entry also carries lastOutputAt)
16
+ * GET /api/runners → detected agent CLIs + their models
17
+ * GET /api/history → completed run history (newest-first)
14
18
  * WebSocket (data plane):
15
19
  * /ws/<storyId>?token=... → live terminal I/O
16
20
  *
@@ -26,7 +30,9 @@
26
30
 
27
31
  const http = require('http');
28
32
  const path = require('path');
33
+ const os = require('os');
29
34
  const crypto = require('crypto');
35
+ const fs = require('fs');
30
36
  const { execFile } = require('child_process');
31
37
 
32
38
  // @lydell/node-pty ships prebuilt binaries and never invokes node-gyp, so a
@@ -78,6 +84,139 @@ const COMMAND_ALLOWLIST = new Set([
78
84
  '/rcode-stats',
79
85
  ]);
80
86
 
87
+ // ── Runner registry ──────────────────────────────────────────────────────────
88
+ // Each entry describes one agent CLI the dashboard can launch. `args` builds
89
+ // the full argv array (never a shell string — user input is never shell-
90
+ // interpolated). `models` is the closed set accepted by POST /api/run; an
91
+ // empty/omitted model means "let the CLI use its own default", and an empty
92
+ // models[] hides the model dropdown in the UI entirely.
93
+ //
94
+ // Every args builder below is grounded in the CLI's real `--help` output
95
+ // (verified against the installed versions: codex-cli 0.139.0, grok 0.2.22,
96
+ // copilot 1.0.60). Each launches the CLI's INTERACTIVE entry — we spawn
97
+ // inside a PTY and the user keeps typing after the initial prompt — so
98
+ // headless one-shot flags (`copilot -p`, `gemini -p`, `grok --single`) are
99
+ // deliberately avoided: they exit after one response.
100
+ //
101
+ // `promptViaStdin: true` marks a CLI with no interactive initial-prompt flag
102
+ // (grok): the prompt is written to the PTY as keystrokes once the TUI is up.
103
+ //
104
+ // `beta: true` renders a "Beta" pill in the picker — claude is the first-class
105
+ // default; every other runner is beta. `untested: true` forces a runner
106
+ // unavailable (reason 'untested flags') even when its binary is on PATH:
107
+ // nobody has live-verified its argv, so the picker disables it with a tooltip
108
+ // instead of letting users hit a crash.
109
+ //
110
+ // The default runner is claude with no model flag — identical argv to the
111
+ // pre-registry behavior, so /api/run calls without {runner, model} are
112
+ // backward compatible.
113
+ const RUNNERS = [
114
+ {
115
+ // claude --help: `claude [prompt]` starts interactive; `--model` takes an
116
+ // alias ('fable', 'opus', 'sonnet') or a full model id like fable-5.
117
+ id: 'claude', label: 'Claude Code', bin: CLAUDE_BIN, modelFlag: '--model',
118
+ models: ['fable-5', 'opus', 'sonnet', 'haiku'],
119
+ args: (model, prompt) => model
120
+ ? [prompt, '--dangerously-skip-permissions', '--model', model]
121
+ : [prompt, '--dangerously-skip-permissions'],
122
+ },
123
+ {
124
+ // codex --help: `codex [OPTIONS] [PROMPT]` — positional prompt starts the
125
+ // interactive TUI (`codex exec` is the NON-interactive path; not used).
126
+ // `-m, --model <MODEL>`. Model list: gpt-5.5 is the current model on this
127
+ // install (~/.codex/config.toml model migrations end at gpt-5.5); older
128
+ // ids are auto-migrated server-side, so only the verified-current one is
129
+ // offered. Live-verified: in an untrusted directory codex first shows its
130
+ // own interactive "Do you trust the contents of this directory?" dialog —
131
+ // that is codex UX, not a launch failure; answer it in the terminal.
132
+ // A wrong model id does NOT abort the TUI (verified with a bogus id).
133
+ id: 'codex', label: 'Codex CLI', bin: 'codex', modelFlag: '--model', beta: true,
134
+ models: ['gpt-5.5'],
135
+ args: (model, prompt) => model ? ['--model', model, prompt] : [prompt],
136
+ },
137
+ {
138
+ // copilot --help: `-i, --interactive <prompt>` = "Start interactive mode
139
+ // and automatically execute this prompt" (NOT `-p`, which is headless and
140
+ // exits after completion). `--model <model>` documents only 'auto' as a
141
+ // guaranteed value ("use 'auto' to let Copilot pick automatically").
142
+ id: 'copilot', label: 'GitHub Copilot CLI', bin: 'copilot', modelFlag: '--model', beta: true,
143
+ models: ['auto'],
144
+ args: (model, prompt) => model ? ['--model', model, '-i', prompt] : ['-i', prompt],
145
+ },
146
+ {
147
+ // gemini --help: `-i, --prompt-interactive <prompt>` = "Execute the
148
+ // provided prompt and continue in interactive mode"; `-m, --model`.
149
+ // gemini-2.5-pro / gemini-2.5-flash are the documented stable ids.
150
+ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', modelFlag: '--model', beta: true,
151
+ models: ['gemini-2.5-pro', 'gemini-2.5-flash'],
152
+ args: (model, prompt) => model ? ['--model', model, '-i', prompt] : ['-i', prompt],
153
+ },
154
+ {
155
+ // grok --help: the TUI has NO interactive initial-prompt flag — `-p` /
156
+ // `--prompt-file` are single-turn headless and exit after the response.
157
+ // So: launch the bare TUI (plus `-m, --model`) and type the prompt into
158
+ // the PTY after boot (promptViaStdin). Model ids come from the CLI's own
159
+ // ~/.grok/models_cache.json: grok-build, grok-composer-2.5-fast.
160
+ id: 'grok', label: 'Grok CLI', bin: 'grok', modelFlag: '--model', beta: true,
161
+ models: ['grok-build', 'grok-composer-2.5-fast'],
162
+ promptViaStdin: true,
163
+ args: (model) => model ? ['--model', model] : [],
164
+ },
165
+ {
166
+ // cursor-agent --help: `cursor-agent [options] [prompt...]` — positional
167
+ // prompt, interactive by default; `--model <model>` with documented
168
+ // examples "gpt-5, sonnet-4, sonnet-4-thinking".
169
+ id: 'cursor', label: 'Cursor Agent', bin: 'cursor-agent', modelFlag: '--model', beta: true,
170
+ models: ['gpt-5', 'sonnet-4', 'sonnet-4-thinking'],
171
+ args: (model, prompt) => model ? ['--model', model, prompt] : [prompt],
172
+ },
173
+ {
174
+ // Not installed on any tested machine — its flags have never been
175
+ // live-verified, so `untested` keeps it disabled (picker tooltip:
176
+ // 'untested flags') even if an `antigravity` binary appears on PATH.
177
+ // Remove `untested` only after grounding the argv in its real --help
178
+ // and a successful spawn test.
179
+ id: 'antigravity', label: 'Antigravity', bin: 'antigravity', modelFlag: null, beta: true,
180
+ untested: true,
181
+ models: [],
182
+ args: (model, prompt) => [prompt],
183
+ },
184
+ ];
185
+
186
+ // How long to wait before typing the initial prompt into a promptViaStdin
187
+ // runner's PTY — the TUI needs to finish mounting or the keystrokes land on
188
+ // a splash screen. PTYs buffer input, so erring high is safe.
189
+ const STDIN_PROMPT_DELAY_MS = 2000;
190
+
191
+ // True when `bin` resolves to an executable — either an explicit path (e.g.
192
+ // CLAUDE_BIN=/opt/claude/bin/claude) or a name found on PATH.
193
+ async function binAvailable(bin) {
194
+ if (!bin) return false;
195
+ const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : [''];
196
+ async function executable(p) {
197
+ for (const ext of exts) {
198
+ try { await fs.promises.access(p + ext, fs.constants.X_OK); return true; } catch { /* keep looking */ }
199
+ }
200
+ return false;
201
+ }
202
+ if (bin.includes('/') || bin.includes(path.sep)) return executable(bin);
203
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
204
+ if (dir && await executable(path.join(dir, bin))) return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ // Availability is detected once at boot and cached on each registry entry,
210
+ // along with a human-readable reason when a runner is unusable. Route
211
+ // handlers await this so an early request never reads a stale flag.
212
+ const runnersReady = Promise.all(
213
+ RUNNERS.map(async r => {
214
+ if (r.untested) { r.available = false; r.reason = 'untested flags'; return; }
215
+ r.available = await binAvailable(r.bin);
216
+ r.reason = r.available ? '' : 'not installed';
217
+ })
218
+ );
219
+
81
220
  // Cap kept-in-memory scrollback per session so a long run can't grow unbounded.
82
221
  const SCROLLBACK_MAX = 256 * 1024;
83
222
 
@@ -85,6 +224,62 @@ const SCROLLBACK_MAX = 256 * 1024;
85
224
  // Session: { proc, status, startTime, cmd, cols, rows, scrollback, wsClients:Set }
86
225
  const sessions = new Map();
87
226
 
227
+ const HISTORY_FILE = path.join(os.homedir(), '.rcode', 'orch-history.json');
228
+ const HISTORY_MAX = 200; // cap persisted runs so the file cannot grow unbounded
229
+ const REJECTIONS_PATH = path.join(os.homedir(), '.rcode', 'rejections.json');
230
+
231
+ function loadHistory() {
232
+ try {
233
+ const raw = fs.readFileSync(HISTORY_FILE, 'utf8');
234
+ const parsed = JSON.parse(raw);
235
+ return Array.isArray(parsed) ? parsed : [];
236
+ } catch {
237
+ return [];
238
+ }
239
+ }
240
+
241
+ let history = loadHistory();
242
+
243
+ function persistRun(storyId, s, status) {
244
+ const endTime = new Date().toISOString();
245
+ const durationMs = Date.parse(endTime) - (Date.parse(s.startTime) || Date.parse(endTime));
246
+ const entry = { storyId, cmd: s.cmd, status, startTime: s.startTime, endTime, durationMs };
247
+ history.push(entry);
248
+ if (history.length > HISTORY_MAX) history = history.slice(-HISTORY_MAX);
249
+ try {
250
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
251
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
252
+ } catch (err) {
253
+ console.error('[orchestrator] failed to persist run history:', err.message);
254
+ }
255
+ }
256
+
257
+ // ── Rejection persistence ─────────────────────────────────────────────────────
258
+
259
+ function readRejections() {
260
+ try {
261
+ const raw = fs.readFileSync(REJECTIONS_PATH, 'utf8');
262
+ const parsed = JSON.parse(raw);
263
+ return Array.isArray(parsed) ? parsed : [];
264
+ } catch {
265
+ return [];
266
+ }
267
+ }
268
+
269
+ function appendRejection(entry) {
270
+ try {
271
+ const list = readRejections();
272
+ list.push(entry);
273
+ const dir = path.dirname(REJECTIONS_PATH);
274
+ fs.mkdirSync(dir, { recursive: true });
275
+ fs.writeFileSync(REJECTIONS_PATH, JSON.stringify(list, null, 2));
276
+ return true;
277
+ } catch (err) {
278
+ console.error('[orchestrator] failed to persist rejection:', err.message);
279
+ return false;
280
+ }
281
+ }
282
+
88
283
  // ── helpers ──────────────────────────────────────────────────────────────────
89
284
 
90
285
  function json(res, code, body) {
@@ -164,8 +359,71 @@ function gitModified() {
164
359
  // almost certainly waiting for the user (a question, or end of a turn).
165
360
  const IDLE_THRESHOLD_MS = 20000;
166
361
 
362
+ // ── Blocked-session detection ─────────────────────────────────────────────────
363
+ // A session is classified "blocked" when its PTY has been silent for at least
364
+ // BLOCKED_IDLE_MS AND the scrollback tail looks like a question / permission
365
+ // prompt / idle input box. Deliberately conservative: both conditions must
366
+ // hold, and the patterns below only match clear ask-the-user shapes.
367
+ const BLOCKED_IDLE_MS = 10000;
368
+ const BLOCKED_TAIL_CHARS = 2000;
369
+
370
+ // Strip ANSI escape sequences (OSC, CSI, other ESC) and carriage returns so
371
+ // pattern matching sees plain text, not control bytes.
372
+ function stripAnsi(str) {
373
+ return String(str)
374
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
375
+ .replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
376
+ .replace(/\x1b[@-_]/g, '')
377
+ .replace(/\r/g, '');
378
+ }
379
+
380
+ // Heuristic: does the recent scrollback tail look like the CLI is asking the
381
+ // user something? Checks only the last few visible lines (box-drawing borders
382
+ // removed) for question/permission shapes:
383
+ // - "Do you want …" - "[y/n]" / "(y/n)"
384
+ // - "Enter to select/confirm" - "❯" idle/selection prompt
385
+ // - a "1. … / 2. …" option list - a line ending with "?"
386
+ function looksBlocked(scrollback) {
387
+ const tail = stripAnsi(String(scrollback || '').slice(-BLOCKED_TAIL_CHARS));
388
+ const lines = tail.split('\n')
389
+ .map(l => l.replace(/[│┃┆┇┊┋]/g, ' ').replace(/[─━╭╮╰╯└┘┌┐├┤╴╶]+/g, ' ').trim())
390
+ .filter(Boolean);
391
+ const recent = lines.slice(-12);
392
+ if (recent.length === 0) return false;
393
+ const text = recent.join('\n');
394
+ if (/\bdo you want\b/i.test(text)) return true;
395
+ if (/\[y\/n\]|\(y\/n\)/i.test(text)) return true;
396
+ if (/enter to (select|confirm|continue)|press enter/i.test(text)) return true;
397
+ if (/❯/.test(recent.slice(-6).join('\n'))) return true;
398
+ const hasOpt1 = recent.some(l => /^❯?\s*1[.)]\s+\S/.test(l));
399
+ const hasOpt2 = recent.some(l => /^❯?\s*2[.)]\s+\S/.test(l));
400
+ if (hasOpt1 && hasOpt2) return true;
401
+ if (recent.slice(-3).some(l => /\?\s*$/.test(l))) return true;
402
+ return false;
403
+ }
404
+
405
+ // Classify a session for /api/sessions: a live PTY whose output has gone
406
+ // idle on a question shape reports 'blocked'; otherwise the lifecycle status
407
+ // (running / done / exited / stopped / error) passes through unchanged.
408
+ function classifyStatus(s, idleMs) {
409
+ if (s.status === 'running' && idleMs > BLOCKED_IDLE_MS && looksBlocked(s.scrollback)) {
410
+ return 'blocked';
411
+ }
412
+ return s.status;
413
+ }
414
+
167
415
  // ── route handlers ────────────────────────────────────────────────────────────
168
416
 
417
+ async function handleRunners(res) {
418
+ await runnersReady;
419
+ json(res, 200, {
420
+ runners: RUNNERS.map(r => ({
421
+ id: r.id, label: r.label, available: !!r.available, models: r.models,
422
+ beta: !!r.beta, reason: r.reason || '',
423
+ })),
424
+ });
425
+ }
426
+
169
427
  async function handleSessions(res) {
170
428
  const current = await gitModified();
171
429
  const now = Date.now();
@@ -177,10 +435,13 @@ async function handleSessions(res) {
177
435
  const idleMs = now - (s.lastDataAt || now);
178
436
  out.push({
179
437
  storyId: id,
180
- status: s.status,
438
+ status: classifyStatus(s, idleMs),
181
439
  pid: s.proc ? s.proc.pid : null,
182
440
  cmd: s.cmd,
441
+ runner: s.runner || 'claude',
442
+ model: s.model || '',
183
443
  startTime: s.startTime,
444
+ lastOutputAt: s.lastDataAt ? new Date(s.lastDataAt).toISOString() : s.startTime,
184
445
  clients: s.wsClients.size,
185
446
  filesChanged: changed,
186
447
  idleSeconds: Math.floor(idleMs / 1000),
@@ -190,6 +451,11 @@ async function handleSessions(res) {
190
451
  json(res, 200, { sessions: out });
191
452
  }
192
453
 
454
+ function handleHistory(res) {
455
+ const out = [...history].sort((a, b) => String(b.endTime || '').localeCompare(String(a.endTime || '')));
456
+ json(res, 200, { history: out });
457
+ }
458
+
193
459
  async function handleRun(req, res) {
194
460
  const body = await parseBody(req);
195
461
  const storyId = String(body.storyId || '').trim();
@@ -212,6 +478,26 @@ async function handleRun(req, res) {
212
478
  }
213
479
  }
214
480
 
481
+ // Runner + model selection — STRICT validation against the registry.
482
+ // Omitted runner → claude with no model flag (pre-registry behavior).
483
+ // An explicitly requested runner must exist AND be installed; a model must
484
+ // be in that runner's closed list. Everything is spawned as an argv array,
485
+ // so none of these values ever reach a shell.
486
+ await runnersReady;
487
+ const runnerId = (body.runner === undefined || body.runner === null || body.runner === '')
488
+ ? 'claude' : String(body.runner);
489
+ const runner = RUNNERS.find(r => r.id === runnerId);
490
+ if (!runner) { json(res, 400, { error: 'unknown runner: ' + runnerId }); return; }
491
+ if (body.runner !== undefined && body.runner !== null && body.runner !== '' && !runner.available) {
492
+ json(res, 400, { error: 'runner unavailable (' + (runner.reason || 'not installed') + '): ' + runnerId });
493
+ return;
494
+ }
495
+ const model = (body.model === undefined || body.model === null) ? '' : String(body.model);
496
+ if (model && !runner.models.includes(model)) {
497
+ json(res, 400, { error: 'invalid model for ' + runnerId + ': ' + model });
498
+ return;
499
+ }
500
+
215
501
  if (!pty) {
216
502
  json(res, 503, { error: 'interactive terminal unavailable on this platform — run: pnpm add @lydell/node-pty' });
217
503
  return;
@@ -233,7 +519,7 @@ async function handleRun(req, res) {
233
519
 
234
520
  let proc;
235
521
  try {
236
- proc = pty.spawn(CLAUDE_BIN, [cmd, '--dangerously-skip-permissions'], {
522
+ proc = pty.spawn(runner.bin, runner.args(model, cmd), {
237
523
  name: 'xterm-color',
238
524
  cols, rows,
239
525
  cwd: PROJECT_ROOT,
@@ -246,6 +532,7 @@ async function handleRun(req, res) {
246
532
 
247
533
  const s = {
248
534
  proc, status: 'running', cmd, cols, rows,
535
+ runner: runner.id, model,
249
536
  startTime: new Date().toISOString(),
250
537
  lastDataAt: Date.now(),
251
538
  scrollback: '',
@@ -269,8 +556,20 @@ async function handleRun(req, res) {
269
556
  proc.onExit(({ exitCode, signal }) => {
270
557
  const status = signal ? 'stopped' : (exitCode === 0 ? 'done' : 'exited');
271
558
  setStatus(s, status);
559
+ persistRun(storyId, s, status);
272
560
  });
273
561
 
562
+ // CLIs with no interactive initial-prompt flag (see registry) get the
563
+ // prompt typed into the PTY once their TUI has had time to mount. The
564
+ // timer is unref'd so it never holds the process open, and the write is
565
+ // skipped if the session already ended.
566
+ if (runner.promptViaStdin && cmd) {
567
+ const t = setTimeout(() => {
568
+ if (s.status === 'running') { try { proc.write(cmd + '\r'); } catch { /* pty gone */ } }
569
+ }, STDIN_PROMPT_DELAY_MS);
570
+ if (t.unref) t.unref();
571
+ }
572
+
274
573
  json(res, 200, { storyId, pid: proc.pid, status: 'running' });
275
574
  }
276
575
 
@@ -302,6 +601,27 @@ async function handleCleanSessions(req, res) {
302
601
  json(res, 200, { removed });
303
602
  }
304
603
 
604
+ async function handleReject(req, res) {
605
+ const body = await parseBody(req);
606
+ const storyId = String(body.storyId || '').trim();
607
+ if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
608
+ const text = String(body.reason || '').trim();
609
+ if (!text) { json(res, 400, { error: 'reason required' }); return; }
610
+ if (text.length > 2000) { json(res, 400, { error: 'reason too long' }); return; }
611
+ const entry = {
612
+ storyId,
613
+ phase: body.phase || null,
614
+ reason: text,
615
+ ts: new Date().toISOString(),
616
+ };
617
+ if (!appendRejection(entry)) { json(res, 500, { error: 'could not persist rejection' }); return; }
618
+ json(res, 200, { ok: true, entry });
619
+ }
620
+
621
+ function handleRejections(res) {
622
+ json(res, 200, { rejections: readRejections() });
623
+ }
624
+
305
625
  // ── WebSocket data plane ───────────────────────────────────────────────────────
306
626
 
307
627
  function attachWebSocket(ws, storyId) {
@@ -361,10 +681,14 @@ const server = http.createServer(async (req, res) => {
361
681
  const pathOnly = url.indexOf('?') === -1 ? url : url.slice(0, url.indexOf('?'));
362
682
 
363
683
  if (method === 'GET' && pathOnly === '/api/status') { json(res, 200, { ok: true, sessions: sessions.size }); return; }
684
+ if (method === 'GET' && pathOnly === '/api/runners') { await handleRunners(res); return; }
364
685
  if (method === 'GET' && pathOnly === '/api/sessions') { await handleSessions(res); return; }
686
+ if (method === 'GET' && pathOnly === '/api/history') { handleHistory(res); return; }
365
687
  if (method === 'POST' && pathOnly === '/api/run') { await handleRun(req, res); return; }
366
688
  if (method === 'POST' && pathOnly === '/api/stop') { await handleStop(req, res); return; }
367
689
  if (method === 'POST' && pathOnly === '/api/clean-sessions') { await handleCleanSessions(req, res); return; }
690
+ if (method === 'POST' && pathOnly === '/api/reject') { await handleReject(req, res); return; }
691
+ if (method === 'GET' && pathOnly === '/api/rejections') { handleRejections(res); return; }
368
692
 
369
693
  res.writeHead(404); res.end('Not found');
370
694
  });
@@ -406,6 +730,6 @@ server.listen(PORT, '127.0.0.1', () => {
406
730
  console.log(' Token: ' + AUTH_TOKEN.slice(0, 8) + '... (redacted)');
407
731
  console.log(' PTY: ' + (pty ? 'node-pty ready' : 'node-pty MISSING'));
408
732
  console.log(' WS: ' + (WebSocketServer ? 'ready' : 'ws MISSING'));
409
- console.log(' POST /api/run GET /api/sessions WS /ws/<id>');
733
+ console.log(' POST /api/run GET /api/sessions GET /api/runners WS /ws/<id>');
410
734
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
411
735
  });