@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.
- package/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/server/dashboard.js +26 -7
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- package/server/orchestrator.js +329 -5
package/server/orchestrator.js
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* local terminal.
|
|
9
9
|
*
|
|
10
10
|
* HTTP (control plane):
|
|
11
|
-
* POST /api/run { storyId, cmd? }
|
|
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
|
|
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(
|
|
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
|
});
|