@hanzlaa/rcode 3.4.33 → 3.5.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/AGENTS.md +6 -6
- package/CONTRIBUTING.md +2 -0
- package/LICENSE +21 -0
- package/README.md +66 -403
- package/cli/doctor.js +87 -1
- package/cli/install.js +122 -31
- package/cli/lib/schemas.cjs +318 -0
- package/cli/postinstall.js +19 -3
- package/dist/rcode.js +316 -23
- package/package.json +8 -4
- package/rihal/agents/rihal-cross-platform-auditor.md +1 -1
- package/rihal/agents/rihal-dep-auditor.md +1 -1
- package/rihal/agents/rihal-docs-auditor.md +3 -145
- package/rihal/agents/rihal-i18n-auditor.md +1 -1
- package/rihal/agents/rihal-nyquist-auditor.md +4 -156
- package/rihal/agents/rihal-observability-auditor.md +1 -1
- package/rihal/bin/rihal-hooks.cjs +394 -4
- package/rihal/bin/rihal-tools.cjs +891 -24
- package/rihal/commands/create-prd.md +18 -0
- package/rihal/commands/execute-milestone.md +18 -0
- package/rihal/commands/plan-milestone.md +18 -0
- package/rihal/commands/scaffold-milestone.md +18 -0
- package/rihal/commands/scaffold-skill.md +18 -0
- package/rihal/references/REFERENCES_INDEX.md +49 -7
- package/rihal/references/agent-contracts.md +10 -0
- package/rihal/references/design-tokens.md +98 -0
- package/rihal/references/docs-auditor-playbook.md +148 -0
- package/rihal/references/git-preflight.md +117 -0
- package/rihal/references/iterative-retrieval.md +85 -0
- package/rihal/references/nyquist-auditor-playbook.md +157 -0
- package/rihal/references/workstream-flag.md +2 -2
- package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +9 -0
- package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +9 -0
- package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +4 -0
- package/rihal/skills/actions/4-implementation/rihal-code-review/steps/step-02-review.md +2 -2
- package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +4 -0
- package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +4 -0
- package/rihal/skills/agents/haitham-frontend/SKILL.md +2 -0
- package/rihal/templates/settings-hooks.json +39 -0
- package/rihal/workflows/check-todos.md +4 -0
- package/rihal/workflows/code-review-fix.md +4 -3
- package/rihal/workflows/code-review.md +1 -1
- package/rihal/workflows/debug.md +1 -1
- package/rihal/workflows/dev-story.md +4 -0
- package/rihal/workflows/diff.md +2 -2
- package/rihal/workflows/do.md +16 -8
- package/rihal/workflows/docs-update.md +2 -2
- package/rihal/workflows/enable-hooks.md +6 -1
- package/rihal/workflows/execute-milestone.md +139 -0
- package/rihal/workflows/execute-regression-gates.md +1 -1
- package/rihal/workflows/execute-sprint.md +54 -2
- package/rihal/workflows/execute-verify-phase-goal.md +31 -4
- package/rihal/workflows/execute-waves.md +33 -5
- package/rihal/workflows/execute.md +40 -6
- package/rihal/workflows/help.md +1 -1
- package/rihal/workflows/import.md +1 -1
- package/rihal/workflows/lens-audit.md +39 -23
- package/rihal/workflows/list-workspaces.md +1 -1
- package/rihal/workflows/map-codebase.md +4 -4
- package/rihal/workflows/new-milestone.md +18 -1
- package/rihal/workflows/new-project-research.md +53 -1
- package/rihal/workflows/new-workspace.md +1 -1
- package/rihal/workflows/plan-milestone.md +105 -0
- package/rihal/workflows/plan-research-validation.md +1 -1
- package/rihal/workflows/plan-spawn-planner.md +1 -1
- package/rihal/workflows/plan.md +31 -3
- package/rihal/workflows/plant-seed.md +6 -0
- package/rihal/workflows/quick.md +11 -5
- package/rihal/workflows/research-phase.md +24 -0
- package/rihal/workflows/scaffold-milestone.md +60 -0
- package/rihal/workflows/scaffold-skill.md +137 -0
- package/rihal/workflows/scan.md +1 -1
- package/rihal/workflows/session-report.md +43 -3
- package/rihal/workflows/verify-work.md +3 -3
- package/server/dashboard.js +52 -5
- package/server/lib/html/client.js +723 -11
- package/server/lib/html/css.js +2046 -466
- package/server/lib/html/shell.js +227 -134
- package/server/lib/scanner.js +33 -0
- package/server/orchestrator.js +438 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rihal Local Orchestrator — port 7718
|
|
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.
|
|
7
|
+
*
|
|
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
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
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');
|
|
23
|
+
|
|
24
|
+
const PORT = parseInt(process.env.ORCH_PORT || '7718', 10);
|
|
25
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
26
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
|
|
27
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.rihal', 'sessions');
|
|
28
|
+
|
|
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).
|
|
32
|
+
const AUTH_TOKEN = process.env.ORCH_TOKEN || crypto.randomBytes(24).toString('hex');
|
|
33
|
+
|
|
34
|
+
// storyId must be a safe path segment — no separators, no traversal.
|
|
35
|
+
const STORY_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
36
|
+
|
|
37
|
+
// Ensure sessions directory exists
|
|
38
|
+
try { fs.mkdirSync(SESSIONS_DIR, { recursive: true }); } catch {}
|
|
39
|
+
|
|
40
|
+
// Map<storyId, Session>
|
|
41
|
+
// Session: { pid, proc, status, logs[], fileOps[], toolBuf{}, sseClients: Set, startTime }
|
|
42
|
+
const sessions = new Map();
|
|
43
|
+
|
|
44
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function json(res, code, body) {
|
|
47
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
48
|
+
res.end(JSON.stringify(body));
|
|
49
|
+
}
|
|
50
|
+
|
|
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.
|
|
54
|
+
function authed(req) {
|
|
55
|
+
let presented = null;
|
|
56
|
+
const auth = req.headers && req.headers.authorization;
|
|
57
|
+
if (auth && auth.startsWith('Bearer ')) {
|
|
58
|
+
presented = auth.slice('Bearer '.length);
|
|
59
|
+
} else {
|
|
60
|
+
const qIdx = (req.url || '').indexOf('?');
|
|
61
|
+
if (qIdx !== -1) {
|
|
62
|
+
const params = new URLSearchParams((req.url || '').slice(qIdx + 1));
|
|
63
|
+
presented = params.get('token');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof presented !== 'string') return false;
|
|
67
|
+
const a = Buffer.from(presented);
|
|
68
|
+
const b = Buffer.from(AUTH_TOKEN);
|
|
69
|
+
if (a.length !== b.length) return false;
|
|
70
|
+
return crypto.timingSafeEqual(a, b);
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
function validStoryId(id) {
|
|
77
|
+
return typeof id === 'string'
|
|
78
|
+
&& id.length > 0
|
|
79
|
+
&& id.length <= 128
|
|
80
|
+
&& !id.includes('..')
|
|
81
|
+
&& STORY_ID_RE.test(id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseBody(req) {
|
|
85
|
+
return new Promise(resolve => {
|
|
86
|
+
let buf = '';
|
|
87
|
+
req.on('data', c => buf += c);
|
|
88
|
+
req.on('end', () => { try { resolve(JSON.parse(buf)); } catch { resolve({}); } });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
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); }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
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;
|
|
128
|
+
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
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
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' };
|
|
175
|
+
}
|
|
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 {}
|
|
211
|
+
}
|
|
212
|
+
|
|
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
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── route handlers ────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function handleStatus(res) {
|
|
246
|
+
const out = {};
|
|
247
|
+
for (const [id, s] of sessions) {
|
|
248
|
+
out[id] = { pid: s.pid, status: s.status, lines: s.logs.length, fileOps: s.fileOps };
|
|
249
|
+
}
|
|
250
|
+
json(res, 200, out);
|
|
251
|
+
}
|
|
252
|
+
|
|
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);
|
|
263
|
+
|
|
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');
|
|
278
|
+
}
|
|
279
|
+
res.end();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
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');
|
|
289
|
+
}
|
|
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
|
+
|
|
302
|
+
const existing = sessions.get(storyId);
|
|
303
|
+
if (existing?.status === 'running') {
|
|
304
|
+
json(res, 409, { error: 'already running', pid: existing.pid });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Default command: invoke the rihal-dev-story skill for the given story ID
|
|
309
|
+
const cmd = String(body.cmd || `/rihal-dev-story ${storyId}`);
|
|
310
|
+
|
|
311
|
+
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(),
|
|
318
|
+
};
|
|
319
|
+
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);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
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);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
json(res, 200, { storyId, pid: proc.pid, status: 'running' });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function handleMessage(req, res) {
|
|
368
|
+
const body = await parseBody(req);
|
|
369
|
+
const storyId = String(body.storyId || '').trim();
|
|
370
|
+
const data = String(body.data || '');
|
|
371
|
+
if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
|
|
372
|
+
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
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
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
|
+
}
|
|
389
|
+
|
|
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; }
|
|
396
|
+
|
|
397
|
+
try { s.proc?.kill('SIGTERM'); } catch {}
|
|
398
|
+
broadcast(storyId, '■ Stopped by user');
|
|
399
|
+
broadcastStatus(storyId, 'stopped');
|
|
400
|
+
json(res, 200, { storyId, status: 'stopped' });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── server ────────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
const server = http.createServer(async (req, res) => {
|
|
406
|
+
const method = req.method || '';
|
|
407
|
+
const url = req.url || '';
|
|
408
|
+
// Every route requires the token — /api/status leaks session detail too.
|
|
409
|
+
if (!authed(req)) { json(res, 401, { error: 'unauthorized' }); return; }
|
|
410
|
+
|
|
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; }
|
|
422
|
+
|
|
423
|
+
res.writeHead(404); res.end('Not found');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
427
|
+
console.log('\n🤖 Rihal Orchestrator');
|
|
428
|
+
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)');
|
|
437
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
438
|
+
});
|