@hanzlaa/rcode 3.5.0 → 3.6.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 (33) hide show
  1. package/package.json +7 -1
  2. package/server/dashboard.js +105 -3
  3. package/server/lib/html/client/agents-data.js +27 -0
  4. package/server/lib/html/client/app.js +15 -0
  5. package/server/lib/html/client/components/App.js +211 -0
  6. package/server/lib/html/client/components/OrchPanel.js +293 -0
  7. package/server/lib/html/client/components/Sidebar.js +73 -0
  8. package/server/lib/html/client/components/Topbar.js +53 -0
  9. package/server/lib/html/client/components/XtermPanel.js +220 -0
  10. package/server/lib/html/client/components/shared.js +330 -0
  11. package/server/lib/html/client/icons-client.js +85 -0
  12. package/server/lib/html/client/orchestrator.js +279 -0
  13. package/server/lib/html/client/preact.js +34 -0
  14. package/server/lib/html/client/store.js +91 -0
  15. package/server/lib/html/client/util.js +186 -0
  16. package/server/lib/html/client/views/AgentsView.js +83 -0
  17. package/server/lib/html/client/views/DecisionsView.js +102 -0
  18. package/server/lib/html/client/views/FilesView.js +223 -0
  19. package/server/lib/html/client/views/KanbanView.js +236 -0
  20. package/server/lib/html/client/views/MemoryView.js +157 -0
  21. package/server/lib/html/client/views/MilestonesView.js +136 -0
  22. package/server/lib/html/client/views/OrchestrationView.js +167 -0
  23. package/server/lib/html/client/views/OverviewView.js +221 -0
  24. package/server/lib/html/client/views/PhasesView.js +184 -0
  25. package/server/lib/html/client/views/RoadmapView.js +238 -0
  26. package/server/lib/html/client/views/SprintsView.js +178 -0
  27. package/server/lib/html/client/views/TasksView.js +148 -0
  28. package/server/lib/html/client.js +41 -1775
  29. package/server/lib/html/css.js +264 -44
  30. package/server/lib/html/icons.js +68 -0
  31. package/server/lib/html/shell.js +9 -296
  32. package/server/lib/scanner.js +76 -0
  33. package/server/orchestrator.js +237 -313
@@ -1,44 +1,82 @@
1
1
  /**
2
2
  * Rihal Local Orchestrator — port 7718
3
3
  *
4
- * Spawns `claude -p` sessions from kanban card clicks.
5
- * Streams stdout/stderr back to the browser via SSE.
6
- * Pure Node stdlib no external dependencies.
4
+ * Spawns interactive `claude` sessions inside a real pseudo-terminal
5
+ * (node-pty) and bridges each one to the browser over a WebSocket.
6
+ * The browser renders the raw terminal with xterm.js, so the session
7
+ * is fully interactive — the user types, Claude responds, just like a
8
+ * local terminal.
7
9
  *
8
- * Endpoints:
9
- * POST /api/run { storyId, cmd? } → spawn claude session
10
- * POST /api/stop { storyId } → SIGTERM the process
11
- * GET /api/status → all session states
12
- * GET /api/stream/:storyId → SSE log stream
10
+ * HTTP (control plane):
11
+ * POST /api/run { storyId, cmd? } → spawn a PTY session
12
+ * POST /api/stop { storyId } → SIGTERM the PTY
13
+ * GET /api/sessionslist all sessions
14
+ * WebSocket (data plane):
15
+ * /ws/<storyId>?token=... → live terminal I/O
16
+ *
17
+ * Wire protocol (JSON each frame):
18
+ * server→client { t:'o', d } terminal output
19
+ * { t:'s', s } status change (running|done|exited|stopped|error)
20
+ * { t:'hist', d } scrollback replay on connect
21
+ * client→server { t:'i', d } keystroke input
22
+ * { t:'r', cols, rows } resize
13
23
  */
14
24
 
15
25
  'use strict';
16
26
 
17
- const { spawn } = require('child_process');
18
- const http = require('http');
19
- const path = require('path');
20
- const fs = require('fs');
21
- const os = require('os');
22
- const crypto = require('crypto');
27
+ const http = require('http');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+ const { execFile } = require('child_process');
31
+
32
+ // @lydell/node-pty ships prebuilt binaries and never invokes node-gyp, so a
33
+ // plain `npm install` works on any common platform with no build toolchain.
34
+ // It is still an optionalDependency: on an unsupported platform the require
35
+ // throws, the orchestrator stays up, and /api/run reports a clear error
36
+ // instead of crashing — `npx rcode` keeps working everywhere.
37
+ let pty = null;
38
+ try { pty = require('@lydell/node-pty'); } catch { /* handled in handleRun */ }
39
+
40
+ let WebSocketServer = null;
41
+ try { ({ WebSocketServer } = require('ws')); } catch { /* handled at boot */ }
23
42
 
24
43
  const PORT = parseInt(process.env.ORCH_PORT || '7718', 10);
25
44
  const PROJECT_ROOT = path.resolve(__dirname, '..');
26
45
  const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
27
- const SESSIONS_DIR = path.join(os.homedir(), '.rihal', 'sessions');
28
46
 
29
- // Per-session auth token. Use ORCH_TOKEN if set, else generate one and print
30
- // it on boot. The dashboard process / user must pass this token on EVERY call
31
- // (Authorization: Bearer header, or ?token= query param for the SSE endpoint).
47
+ // Per-session auth token see authed(). The dashboard passes ORCH_TOKEN in
48
+ // via env; standalone runs generate one and print it on boot.
32
49
  const AUTH_TOKEN = process.env.ORCH_TOKEN || crypto.randomBytes(24).toString('hex');
33
50
 
34
- // storyId must be a safe path segment — no separators, no traversal.
51
+ // storyId must be a safe single path segment — no separators, no traversal.
35
52
  const STORY_ID_RE = /^[A-Za-z0-9._-]+$/;
36
53
 
37
- // Ensure sessions directory exists
38
- try { fs.mkdirSync(SESSIONS_DIR, { recursive: true }); } catch {}
54
+ // Command allowlist the SECURITY BOUNDARY for the dashboard command runner.
55
+ // Only commands listed here may be launched via the UI command picker.
56
+ // Slash-commands that launch dev work (rihal-dev-story, rihal-execute, etc.)
57
+ // are NOT listed here; they are composed by the UI itself via storyId, not
58
+ // by the command runner. This list covers read-mostly and informational rihal
59
+ // slash-commands that are safe to run from the browser without further context.
60
+ const COMMAND_ALLOWLIST = new Set([
61
+ '/rihal-init',
62
+ '/rihal-status',
63
+ '/rihal-progress',
64
+ '/rihal-help',
65
+ '/rihal-health',
66
+ '/rihal-next',
67
+ '/rihal-show',
68
+ '/rihal-list-plans',
69
+ '/rihal-sprint-status',
70
+ '/rihal-config',
71
+ '/rihal-diff',
72
+ '/rihal-stats',
73
+ ]);
74
+
75
+ // Cap kept-in-memory scrollback per session so a long run can't grow unbounded.
76
+ const SCROLLBACK_MAX = 256 * 1024;
39
77
 
40
78
  // Map<storyId, Session>
41
- // Session: { pid, proc, status, logs[], fileOps[], toolBuf{}, sseClients: Set, startTime }
79
+ // Session: { proc, status, startTime, cmd, cols, rows, scrollback, wsClients:Set }
42
80
  const sessions = new Map();
43
81
 
44
82
  // ── helpers ──────────────────────────────────────────────────────────────────
@@ -48,9 +86,9 @@ function json(res, code, body) {
48
86
  res.end(JSON.stringify(body));
49
87
  }
50
88
 
51
- // Constant-time token check. Accepts the token via `Authorization: Bearer`
52
- // header, or a `?token=` query param (the EventSource SSE client cannot set
53
- // headers). Returns true only on an exact match.
89
+ // Constant-time token check. Token arrives as `Authorization: Bearer <t>`
90
+ // (HTTP) or `?token=<t>` (WebSocket upgrade the browser cannot set
91
+ // headers on a WebSocket handshake).
54
92
  function authed(req) {
55
93
  let presented = null;
56
94
  const auth = req.headers && req.headers.authorization;
@@ -59,8 +97,7 @@ function authed(req) {
59
97
  } else {
60
98
  const qIdx = (req.url || '').indexOf('?');
61
99
  if (qIdx !== -1) {
62
- const params = new URLSearchParams((req.url || '').slice(qIdx + 1));
63
- presented = params.get('token');
100
+ presented = new URLSearchParams((req.url || '').slice(qIdx + 1)).get('token');
64
101
  }
65
102
  }
66
103
  if (typeof presented !== 'string') return false;
@@ -70,9 +107,6 @@ function authed(req) {
70
107
  return crypto.timingSafeEqual(a, b);
71
108
  }
72
109
 
73
- // Validate a storyId before it touches the filesystem. Charset blocks path
74
- // separators; the explicit `..` check blocks traversal even though `.` is
75
- // allowed in the charset.
76
110
  function validStoryId(id) {
77
111
  return typeof id === 'string'
78
112
  && id.length > 0
@@ -89,315 +123,193 @@ function parseBody(req) {
89
123
  });
90
124
  }
91
125
 
92
- // Push one log line to session buffer + all connected SSE clients
93
- function broadcast(storyId, line) {
94
- const s = sessions.get(storyId);
95
- if (!s || !line) return;
96
- s.logs.push(line);
97
- const payload = 'data: ' + JSON.stringify({ line }) + '\n\n';
98
- for (const client of s.sseClients) {
99
- try { client.write(payload); } catch { s.sseClients.delete(client); }
126
+ // Send one wire frame to every WebSocket client attached to a session.
127
+ function wsSend(s, obj) {
128
+ const payload = JSON.stringify(obj);
129
+ for (const ws of s.wsClients) {
130
+ try { ws.send(payload); } catch { s.wsClients.delete(ws); }
100
131
  }
101
132
  }
102
133
 
103
- // Push a raw text chunk to SSE (not buffered — for streaming characters)
104
- function broadcastChunk(storyId, chunk) {
105
- const s = sessions.get(storyId);
106
- if (!s || !chunk) return;
107
- const payload = 'data: ' + JSON.stringify({ chunk }) + '\n\n';
108
- for (const client of s.sseClients) {
109
- try { client.write(payload); } catch { s.sseClients.delete(client); }
110
- }
111
- }
112
-
113
- // Push a file operation event to SSE clients + buffer it
114
- function broadcastFileOp(storyId, fileOp) {
115
- const s = sessions.get(storyId);
116
- if (!s || !fileOp) return;
117
- s.fileOps.push(fileOp);
118
- const payload = 'data: ' + JSON.stringify({ fileOp }) + '\n\n';
119
- for (const client of s.sseClients) {
120
- try { client.write(payload); } catch { s.sseClients.delete(client); }
121
- }
122
- }
123
-
124
- // Push a status event to all SSE clients for a session
125
- function broadcastStatus(storyId, status) {
126
- const s = sessions.get(storyId);
127
- if (!s) return;
134
+ function setStatus(s, status) {
128
135
  s.status = status;
129
- const payload = 'data: ' + JSON.stringify({ status }) + '\n\n';
130
- for (const client of s.sseClients) {
131
- try { client.write(payload); } catch { s.sseClients.delete(client); }
132
- }
136
+ wsSend(s, { t: 's', s: status });
133
137
  }
134
138
 
135
- // Parse one stream-json line { text?, fileOp? }
136
- // toolBuf = accumulated partial JSON per content block index
137
- function parseStreamLine(raw, toolBuf) {
138
- if (!raw) return {};
139
- try {
140
- const p = JSON.parse(raw);
141
-
142
- // Streaming text delta send as 'chunk' so browser appends in-place
143
- if (p.type === 'content_block_delta' && p.delta?.type === 'text_delta') {
144
- return { chunk: p.delta.text || null };
145
- }
146
-
147
- // Tool use start — record tool name, init buffer
148
- if (p.type === 'content_block_start' && p.content_block?.type === 'tool_use') {
149
- const name = p.content_block.name || 'tool';
150
- toolBuf[p.index] = { name, json: '' };
151
- return { text: '⚙ ' + name };
152
- }
153
-
154
- // Tool input JSON accumulation
155
- if (p.type === 'content_block_delta' && p.delta?.type === 'input_json_delta') {
156
- if (toolBuf[p.index]) toolBuf[p.index].json += (p.delta.partial_json || '');
157
- return {};
158
- }
159
-
160
- // Tool use complete — try to extract file path
161
- if (p.type === 'content_block_stop' && toolBuf[p.index]) {
162
- const { name, json: partial } = toolBuf[p.index];
163
- delete toolBuf[p.index];
164
- let fileOp = null;
165
- try {
166
- const inp = JSON.parse(partial);
167
- const filePath = inp.path || inp.file_path || inp.file || inp.filename || null;
168
- const isWrite = /write|edit|create|str_replace/i.test(name);
169
- const isRead = /read|view|cat/i.test(name);
170
- const isBash = /bash|exec|run|shell/i.test(name);
171
- if (filePath) {
172
- fileOp = { tool: name, path: filePath, op: isWrite ? 'write' : isRead ? 'read' : 'access' };
173
- } else if (isBash && inp.command) {
174
- fileOp = { tool: 'bash', path: null, cmd: String(inp.command).slice(0, 80), op: 'bash' };
139
+ // Set of working-tree files with uncommitted changes. A session's
140
+ // "files changed" is the current dirty set minus the set captured when it
141
+ // started an estimate of what that session touched.
142
+ function gitModified() {
143
+ return new Promise(resolve => {
144
+ execFile('git', ['-C', PROJECT_ROOT, 'status', '--porcelain'],
145
+ { timeout: 5000 }, (err, stdout) => {
146
+ if (err) { resolve(new Set()); return; }
147
+ const set = new Set();
148
+ for (const line of String(stdout).split('\n')) {
149
+ const f = line.slice(3).trim();
150
+ if (f) set.add(f);
175
151
  }
176
- } catch {}
177
- return { fileOp };
178
- }
179
-
180
- // Result summary
181
- if (p.type === 'result') return { text: '✓ ' + (p.subtype || 'done') };
182
-
183
- // Legacy format
184
- if (p.type === 'assistant' && Array.isArray(p.message?.content)) {
185
- const text = p.message.content.filter(c => c.type === 'text').map(c => c.text).join('');
186
- return { text: text || null };
187
- }
188
-
189
- return {};
190
- } catch {
191
- const t = raw.trim();
192
- return { text: t.startsWith('{') ? null : (t || null) };
193
- }
194
- }
195
-
196
- // Persist completed session to ~/.rihal/sessions/{storyId}-{date}.json
197
- function persistSession(storyId, exitStatus) {
198
- const s = sessions.get(storyId);
199
- if (!s) return;
200
- try {
201
- const date = new Date().toISOString().slice(0, 10);
202
- const file = path.join(SESSIONS_DIR, storyId + '-' + date + '.json');
203
- // Defense-in-depth: refuse to write outside SESSIONS_DIR.
204
- if (!path.resolve(file).startsWith(SESSIONS_DIR + path.sep)) return;
205
- fs.writeFileSync(file, JSON.stringify({
206
- storyId, status: exitStatus,
207
- startTime: s.startTime, endTime: new Date().toISOString(),
208
- logs: s.logs, fileOps: s.fileOps,
209
- }), 'utf8');
210
- } catch {}
152
+ resolve(set);
153
+ });
154
+ });
211
155
  }
212
156
 
213
- // Load most recent persisted session for a storyId (if any)
214
- function loadLastSession(storyId) {
215
- try {
216
- const files = fs.readdirSync(SESSIONS_DIR)
217
- .filter(f => f.startsWith(storyId + '-') && f.endsWith('.json'))
218
- .sort()
219
- .reverse();
220
- if (!files.length) return null;
221
- const file = path.join(SESSIONS_DIR, files[0]);
222
- // Defense-in-depth: refuse to read outside SESSIONS_DIR.
223
- if (!path.resolve(file).startsWith(SESSIONS_DIR + path.sep)) return null;
224
- return JSON.parse(fs.readFileSync(file, 'utf8'));
225
- } catch { return null; }
226
- }
227
-
228
- // Clean sessions older than N days
229
- function cleanSessions(olderThanDays) {
230
- const cutoff = Date.now() - olderThanDays * 86400000;
231
- let removed = 0;
232
- try {
233
- for (const f of fs.readdirSync(SESSIONS_DIR)) {
234
- if (!f.endsWith('.json')) continue;
235
- const full = path.join(SESSIONS_DIR, f);
236
- const stat = fs.statSync(full);
237
- if (stat.mtimeMs < cutoff) { fs.unlinkSync(full); removed++; }
238
- }
239
- } catch {}
240
- return removed;
241
- }
157
+ // A running session that has produced no terminal output for this long is
158
+ // almost certainly waiting for the user (a question, or end of a turn).
159
+ const IDLE_THRESHOLD_MS = 20000;
242
160
 
243
161
  // ── route handlers ────────────────────────────────────────────────────────────
244
162
 
245
- function handleStatus(res) {
246
- const out = {};
163
+ async function handleSessions(res) {
164
+ const current = await gitModified();
165
+ const now = Date.now();
166
+ const out = [];
247
167
  for (const [id, s] of sessions) {
248
- out[id] = { pid: s.pid, status: s.status, lines: s.logs.length, fileOps: s.fileOps };
168
+ const start = s.filesAtStart || new Set();
169
+ let changed = 0;
170
+ for (const f of current) if (!start.has(f)) changed++;
171
+ const idleMs = now - (s.lastDataAt || now);
172
+ out.push({
173
+ storyId: id,
174
+ status: s.status,
175
+ pid: s.proc ? s.proc.pid : null,
176
+ cmd: s.cmd,
177
+ startTime: s.startTime,
178
+ clients: s.wsClients.size,
179
+ filesChanged: changed,
180
+ idleSeconds: Math.floor(idleMs / 1000),
181
+ waiting: s.status === 'running' && idleMs > IDLE_THRESHOLD_MS,
182
+ });
249
183
  }
250
- json(res, 200, out);
184
+ json(res, 200, { sessions: out });
251
185
  }
252
186
 
253
- function handleStream(req, res, storyId) {
254
- if (!validStoryId(storyId)) { res.writeHead(400); res.end('invalid storyId'); return; }
255
- res.writeHead(200, {
256
- 'Content-Type': 'text/event-stream',
257
- 'Cache-Control': 'no-cache',
258
- 'Connection': 'keep-alive',
259
- 'X-Accel-Buffering': 'no', // disable nginx/proxy buffering
260
- });
261
- // Disable Nagle — flush every write immediately to the browser
262
- if (res.socket) res.socket.setNoDelay(true);
187
+ async function handleRun(req, res) {
188
+ const body = await parseBody(req);
189
+ const storyId = String(body.storyId || '').trim();
190
+ if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
263
191
 
264
- const s = sessions.get(storyId);
265
- if (!s) {
266
- // Try to replay last persisted session
267
- const last = loadLastSession(storyId);
268
- if (last) {
269
- for (const line of (last.logs || [])) {
270
- res.write('data: ' + JSON.stringify({ line }) + '\n\n');
271
- }
272
- for (const fileOp of (last.fileOps || [])) {
273
- res.write('data: ' + JSON.stringify({ fileOp }) + '\n\n');
274
- }
275
- res.write('data: ' + JSON.stringify({ status: last.status || 'done' }) + '\n\n');
276
- } else {
277
- res.write('data: ' + JSON.stringify({ error: 'no session for ' + storyId }) + '\n\n');
192
+ // Gate the allowlist on command-runner sessions only.
193
+ // Command-runner sessions always use a storyId with the "cmd-" prefix
194
+ // (e.g. "cmd-rihal-init"). Existing dev-run sessions use storyIds such as
195
+ // "phase-33", "sprint-33.1", or a raw task id — never "cmd-*" — and MUST NOT
196
+ // be gated here, even though they also supply body.cmd explicitly.
197
+ // This prefix check is the authoritative discriminant between the two call paths.
198
+ // NOTE: The gate fires for ANY cmd- storyId — a missing or empty body.cmd is
199
+ // also rejected. Previously the truthiness check on body.cmd allowed falsy values
200
+ // to bypass the allowlist and fall through to the /rihal-dev-story fallback.
201
+ if (storyId.startsWith('cmd-')) {
202
+ const reqCmd = typeof body.cmd === 'string' ? body.cmd.trim() : '';
203
+ if (!reqCmd || !COMMAND_ALLOWLIST.has(reqCmd)) {
204
+ json(res, 403, { error: 'command not in allowlist', cmd: reqCmd });
205
+ return;
278
206
  }
279
- res.end();
280
- return;
281
207
  }
282
208
 
283
- // Replay buffered logs + file ops so late-connecting clients see history
284
- for (const line of s.logs) {
285
- res.write('data: ' + JSON.stringify({ line }) + '\n\n');
286
- }
287
- for (const fileOp of s.fileOps) {
288
- res.write('data: ' + JSON.stringify({ fileOp }) + '\n\n');
209
+ if (!pty) {
210
+ json(res, 503, { error: 'interactive terminal unavailable on this platform — run: pnpm add @lydell/node-pty' });
211
+ return;
289
212
  }
290
- res.write('data: ' + JSON.stringify({ status: s.status }) + '\n\n');
291
-
292
- s.sseClients.add(res);
293
- req.on('close', () => s.sseClients.delete(res));
294
- }
295
-
296
- async function handleRun(req, res) {
297
- const body = await parseBody(req);
298
- const storyId = String(body.storyId || '').trim();
299
- if (!storyId) { json(res, 400, { error: 'missing storyId' }); return; }
300
- if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
301
213
 
302
214
  const existing = sessions.get(storyId);
303
- if (existing?.status === 'running') {
304
- json(res, 409, { error: 'already running', pid: existing.pid });
215
+ if (existing && existing.status === 'running') {
216
+ json(res, 409, { error: 'already running', pid: existing.proc && existing.proc.pid });
305
217
  return;
306
218
  }
219
+ // Replacing a finished session — drop any sockets still attached.
220
+ if (existing) { for (const ws of existing.wsClients) { try { ws.close(); } catch {} } }
307
221
 
308
- // Default command: invoke the rihal-dev-story skill for the given story ID
309
- const cmd = String(body.cmd || `/rihal-dev-story ${storyId}`);
222
+ // Initial prompt. `claude [prompt]` starts an interactive session that
223
+ // processes the prompt, then waits for further input — exactly the
224
+ // run-then-communicate flow we want.
225
+ const cmd = String(body.cmd || `/rihal-dev-story ${storyId}`);
226
+ const cols = 120, rows = 30;
227
+
228
+ let proc;
229
+ try {
230
+ proc = pty.spawn(CLAUDE_BIN, [cmd, '--dangerously-skip-permissions'], {
231
+ name: 'xterm-color',
232
+ cols, rows,
233
+ cwd: PROJECT_ROOT,
234
+ env: process.env,
235
+ });
236
+ } catch (err) {
237
+ json(res, 500, { error: 'spawn failed: ' + err.message });
238
+ return;
239
+ }
310
240
 
311
241
  const s = {
312
- pid: null, proc: null, status: 'starting',
313
- logs: ['▶ Starting: claude -p "' + cmd + '"'],
314
- fileOps: [],
315
- toolBuf: {},
316
- sseClients: new Set(),
317
- startTime: new Date().toISOString(),
242
+ proc, status: 'running', cmd, cols, rows,
243
+ startTime: new Date().toISOString(),
244
+ lastDataAt: Date.now(),
245
+ scrollback: '',
246
+ wsClients: new Set(),
247
+ filesAtStart: new Set(),
318
248
  };
319
249
  sessions.set(storyId, s);
320
-
321
- const proc = spawn(CLAUDE_BIN, [
322
- '-p', cmd,
323
- '--output-format', 'stream-json',
324
- '--verbose',
325
- '--dangerously-skip-permissions',
326
- ], {
327
- cwd: PROJECT_ROOT,
328
- env: { ...process.env },
329
- stdio: ['pipe', 'pipe', 'pipe'],
330
- });
331
-
332
- s.proc = proc;
333
- s.pid = proc.pid;
334
- s.status = 'running';
335
-
336
- proc.stdout.on('data', chunk => {
337
- for (const raw of chunk.toString().split('\n')) {
338
- const line = raw.trim();
339
- if (!line) continue;
340
- const { text, chunk, fileOp } = parseStreamLine(line, s.toolBuf);
341
- if (chunk) broadcastChunk(storyId, chunk); // streaming text — in-place append
342
- if (text) broadcast(storyId, text); // event/status line — new row
343
- if (fileOp) broadcastFileOp(storyId, fileOp);
250
+ // Snapshot the dirty working tree so /api/sessions can report how many
251
+ // files this session has changed since it began.
252
+ gitModified().then(set => { s.filesAtStart = set; });
253
+
254
+ proc.onData(d => {
255
+ s.lastDataAt = Date.now();
256
+ s.scrollback += d;
257
+ if (s.scrollback.length > SCROLLBACK_MAX) {
258
+ s.scrollback = s.scrollback.slice(-SCROLLBACK_MAX);
344
259
  }
260
+ wsSend(s, { t: 'o', d });
345
261
  });
346
262
 
347
- proc.stderr.on('data', chunk => {
348
- const msg = chunk.toString().trim();
349
- if (msg) broadcast(storyId, '⚠ ' + msg);
350
- });
351
-
352
- proc.on('error', err => {
353
- broadcast(storyId, '✗ spawn error: ' + err.message);
354
- broadcastStatus(storyId, 'error');
355
- });
356
-
357
- proc.on('exit', code => {
358
- const final = code === 0 ? 'done' : (code === null ? 'stopped' : 'error');
359
- broadcast(storyId, final === 'done' ? '✅ Completed' : '✗ Exited with code ' + code);
360
- broadcastStatus(storyId, final);
361
- persistSession(storyId, final);
263
+ proc.onExit(({ exitCode, signal }) => {
264
+ const status = signal ? 'stopped' : (exitCode === 0 ? 'done' : 'exited');
265
+ setStatus(s, status);
362
266
  });
363
267
 
364
268
  json(res, 200, { storyId, pid: proc.pid, status: 'running' });
365
269
  }
366
270
 
367
- async function handleMessage(req, res) {
271
+ async function handleStop(req, res) {
368
272
  const body = await parseBody(req);
369
273
  const storyId = String(body.storyId || '').trim();
370
- const data = String(body.data || '');
371
274
  if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
372
275
  const s = sessions.get(storyId);
373
- if (!s || s.status !== 'running') { json(res, 404, { error: 'no active session' }); return; }
374
- try {
375
- s.proc.stdin.write(data);
376
- broadcast(storyId, '[input] ' + data.trim());
377
- json(res, 200, { ok: true });
378
- } catch (err) {
379
- json(res, 500, { error: err.message });
380
- }
276
+ if (!s) { json(res, 404, { error: 'no session' }); return; }
277
+ try { s.proc.kill(); } catch {}
278
+ setStatus(s, 'stopped');
279
+ json(res, 200, { storyId, status: 'stopped' });
381
280
  }
382
281
 
383
- async function handleCleanSessions(req, res) {
384
- const body = await parseBody(req);
385
- const days = parseInt(body.olderThanDays || 7, 10);
386
- const removed = cleanSessions(days);
387
- json(res, 200, { removed, sessionsDir: SESSIONS_DIR });
388
- }
282
+ // ── WebSocket data plane ───────────────────────────────────────────────────────
389
283
 
390
- async function handleStop(req, res) {
391
- const body = await parseBody(req);
392
- const storyId = String(body.storyId || '').trim();
393
- if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
394
- const s = sessions.get(storyId);
395
- if (!s) { json(res, 404, { error: 'no session' }); return; }
284
+ function attachWebSocket(ws, storyId) {
285
+ const s = sessions.get(storyId);
286
+ if (!s) {
287
+ ws.send(JSON.stringify({ t: 's', s: 'error' }));
288
+ ws.close();
289
+ return;
290
+ }
396
291
 
397
- try { s.proc?.kill('SIGTERM'); } catch {}
398
- broadcast(storyId, '■ Stopped by user');
399
- broadcastStatus(storyId, 'stopped');
400
- json(res, 200, { storyId, status: 'stopped' });
292
+ s.wsClients.add(ws);
293
+ // Replay history so a late-joining client sees the session so far.
294
+ if (s.scrollback) ws.send(JSON.stringify({ t: 'hist', d: s.scrollback }));
295
+ ws.send(JSON.stringify({ t: 's', s: s.status }));
296
+
297
+ ws.on('message', raw => {
298
+ let msg;
299
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
300
+ if (msg.t === 'i' && typeof msg.d === 'string' && s.status === 'running') {
301
+ try { s.proc.write(msg.d); } catch {}
302
+ } else if (msg.t === 'r' && s.status === 'running') {
303
+ const cols = parseInt(msg.cols, 10), rows = parseInt(msg.rows, 10);
304
+ if (cols > 0 && rows > 0) {
305
+ s.cols = cols; s.rows = rows;
306
+ try { s.proc.resize(cols, rows); } catch {}
307
+ }
308
+ }
309
+ });
310
+
311
+ ws.on('close', () => s.wsClients.delete(ws));
312
+ ws.on('error', () => s.wsClients.delete(ws));
401
313
  }
402
314
 
403
315
  // ── server ────────────────────────────────────────────────────────────────────
@@ -405,34 +317,46 @@ async function handleStop(req, res) {
405
317
  const server = http.createServer(async (req, res) => {
406
318
  const method = req.method || '';
407
319
  const url = req.url || '';
408
- // Every route requires the token — /api/status leaks session detail too.
320
+
321
+ // CORS — the dashboard is served from a different port (7717), so every
322
+ // browser call here is cross-origin. The loopback bind + token are what
323
+ // gate access; a wildcard origin is safe with no cookies involved.
324
+ res.setHeader('Access-Control-Allow-Origin', '*');
325
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
326
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
327
+ if (method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
328
+
409
329
  if (!authed(req)) { json(res, 401, { error: 'unauthorized' }); return; }
410
330
 
411
- if (method === 'GET' && url === '/api/status') { handleStatus(res); return; }
412
- if (method === 'GET' && url.startsWith('/api/stream/')) {
413
- const pathOnly = url.indexOf('?') === -1 ? url : url.slice(0, url.indexOf('?'));
414
- const storyId = decodeURIComponent(pathOnly.slice('/api/stream/'.length));
415
- handleStream(req, res, storyId);
416
- return;
417
- }
418
- if (method === 'POST' && url === '/api/run') { await handleRun(req, res); return; }
419
- if (method === 'POST' && url === '/api/stop') { await handleStop(req, res); return; }
420
- if (method === 'POST' && url === '/api/message') { await handleMessage(req, res); return; }
421
- if (method === 'POST' && url === '/api/clean-sessions') { await handleCleanSessions(req, res); return; }
331
+ const pathOnly = url.indexOf('?') === -1 ? url : url.slice(0, url.indexOf('?'));
332
+
333
+ if (method === 'GET' && pathOnly === '/api/sessions') { await handleSessions(res); return; }
334
+ if (method === 'POST' && pathOnly === '/api/run') { await handleRun(req, res); return; }
335
+ if (method === 'POST' && pathOnly === '/api/stop') { await handleStop(req, res); return; }
422
336
 
423
337
  res.writeHead(404); res.end('Not found');
424
338
  });
425
339
 
340
+ // WebSocket upgrade — authenticate, validate the storyId, then hand off.
341
+ if (WebSocketServer) {
342
+ const wss = new WebSocketServer({ noServer: true });
343
+ server.on('upgrade', (req, socket, head) => {
344
+ const url = req.url || '';
345
+ const pathOnly = url.indexOf('?') === -1 ? url : url.slice(0, url.indexOf('?'));
346
+ if (!pathOnly.startsWith('/ws/') || !authed(req)) { socket.destroy(); return; }
347
+ const storyId = decodeURIComponent(pathOnly.slice('/ws/'.length));
348
+ if (!validStoryId(storyId)) { socket.destroy(); return; }
349
+ wss.handleUpgrade(req, socket, head, ws => attachWebSocket(ws, storyId));
350
+ });
351
+ }
352
+
426
353
  server.listen(PORT, '127.0.0.1', () => {
427
354
  console.log('\n🤖 Rihal Orchestrator');
428
355
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
429
- console.log(' Port: ' + PORT);
430
- console.log(' Bind: 127.0.0.1 (loopback only)');
431
- // The dashboard process / user must pass this token on every API call.
432
- console.log(' Token: ' + AUTH_TOKEN);
433
- console.log(' POST /api/run { storyId }');
434
- console.log(' POST /api/stop { storyId }');
435
- console.log(' GET /api/status');
436
- console.log(' GET /api/stream/:storyId (SSE)');
356
+ console.log(' Port: ' + PORT + ' (127.0.0.1, loopback only)');
357
+ console.log(' Token: ' + AUTH_TOKEN);
358
+ console.log(' PTY: ' + (pty ? 'node-pty ready' : 'node-pty MISSING'));
359
+ console.log(' WS: ' + (WebSocketServer ? 'ready' : 'ws MISSING'));
360
+ console.log(' POST /api/run GET /api/sessions WS /ws/<id>');
437
361
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
438
362
  });