@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.
- package/package.json +7 -1
- package/server/dashboard.js +105 -3
- package/server/lib/html/client/agents-data.js +27 -0
- package/server/lib/html/client/app.js +15 -0
- package/server/lib/html/client/components/App.js +211 -0
- package/server/lib/html/client/components/OrchPanel.js +293 -0
- package/server/lib/html/client/components/Sidebar.js +73 -0
- package/server/lib/html/client/components/Topbar.js +53 -0
- package/server/lib/html/client/components/XtermPanel.js +220 -0
- package/server/lib/html/client/components/shared.js +330 -0
- package/server/lib/html/client/icons-client.js +85 -0
- package/server/lib/html/client/orchestrator.js +279 -0
- package/server/lib/html/client/preact.js +34 -0
- package/server/lib/html/client/store.js +91 -0
- package/server/lib/html/client/util.js +186 -0
- package/server/lib/html/client/views/AgentsView.js +83 -0
- package/server/lib/html/client/views/DecisionsView.js +102 -0
- package/server/lib/html/client/views/FilesView.js +223 -0
- package/server/lib/html/client/views/KanbanView.js +236 -0
- package/server/lib/html/client/views/MemoryView.js +157 -0
- package/server/lib/html/client/views/MilestonesView.js +136 -0
- package/server/lib/html/client/views/OrchestrationView.js +167 -0
- package/server/lib/html/client/views/OverviewView.js +221 -0
- package/server/lib/html/client/views/PhasesView.js +184 -0
- package/server/lib/html/client/views/RoadmapView.js +238 -0
- package/server/lib/html/client/views/SprintsView.js +178 -0
- package/server/lib/html/client/views/TasksView.js +148 -0
- package/server/lib/html/client.js +41 -1775
- package/server/lib/html/css.js +264 -44
- package/server/lib/html/icons.js +68 -0
- package/server/lib/html/shell.js +9 -296
- package/server/lib/scanner.js +76 -0
- package/server/orchestrator.js +237 -313
package/server/orchestrator.js
CHANGED
|
@@ -1,44 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rihal Local Orchestrator — port 7718
|
|
3
3
|
*
|
|
4
|
-
* Spawns `claude
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
* POST /api/run
|
|
10
|
-
* POST /api/stop
|
|
11
|
-
* GET /api/
|
|
12
|
-
*
|
|
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/sessions → list 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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
30
|
-
//
|
|
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
|
-
//
|
|
38
|
-
|
|
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: {
|
|
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.
|
|
52
|
-
//
|
|
53
|
-
// headers
|
|
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
|
-
|
|
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
|
-
//
|
|
93
|
-
function
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
246
|
-
const
|
|
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
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
//
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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.
|
|
348
|
-
const
|
|
349
|
-
|
|
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
|
|
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
|
|
374
|
-
try {
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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:
|
|
430
|
-
console.log('
|
|
431
|
-
|
|
432
|
-
console.log('
|
|
433
|
-
console.log(' POST
|
|
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
|
});
|