@hanzlaa/rcode 3.4.33 → 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/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 +14 -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 +154 -5
- 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 +42 -1064
- package/server/lib/html/css.js +2266 -466
- package/server/lib/html/icons.js +68 -0
- package/server/lib/html/shell.js +16 -210
- package/server/lib/scanner.js +109 -0
- package/server/orchestrator.js +362 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rihal Local Orchestrator — port 7718
|
|
3
|
+
*
|
|
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.
|
|
9
|
+
*
|
|
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
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
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 */ }
|
|
42
|
+
|
|
43
|
+
const PORT = parseInt(process.env.ORCH_PORT || '7718', 10);
|
|
44
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
45
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
|
|
46
|
+
|
|
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.
|
|
49
|
+
const AUTH_TOKEN = process.env.ORCH_TOKEN || crypto.randomBytes(24).toString('hex');
|
|
50
|
+
|
|
51
|
+
// storyId must be a safe single path segment — no separators, no traversal.
|
|
52
|
+
const STORY_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
53
|
+
|
|
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;
|
|
77
|
+
|
|
78
|
+
// Map<storyId, Session>
|
|
79
|
+
// Session: { proc, status, startTime, cmd, cols, rows, scrollback, wsClients:Set }
|
|
80
|
+
const sessions = new Map();
|
|
81
|
+
|
|
82
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function json(res, code, body) {
|
|
85
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify(body));
|
|
87
|
+
}
|
|
88
|
+
|
|
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).
|
|
92
|
+
function authed(req) {
|
|
93
|
+
let presented = null;
|
|
94
|
+
const auth = req.headers && req.headers.authorization;
|
|
95
|
+
if (auth && auth.startsWith('Bearer ')) {
|
|
96
|
+
presented = auth.slice('Bearer '.length);
|
|
97
|
+
} else {
|
|
98
|
+
const qIdx = (req.url || '').indexOf('?');
|
|
99
|
+
if (qIdx !== -1) {
|
|
100
|
+
presented = new URLSearchParams((req.url || '').slice(qIdx + 1)).get('token');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (typeof presented !== 'string') return false;
|
|
104
|
+
const a = Buffer.from(presented);
|
|
105
|
+
const b = Buffer.from(AUTH_TOKEN);
|
|
106
|
+
if (a.length !== b.length) return false;
|
|
107
|
+
return crypto.timingSafeEqual(a, b);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validStoryId(id) {
|
|
111
|
+
return typeof id === 'string'
|
|
112
|
+
&& id.length > 0
|
|
113
|
+
&& id.length <= 128
|
|
114
|
+
&& !id.includes('..')
|
|
115
|
+
&& STORY_ID_RE.test(id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseBody(req) {
|
|
119
|
+
return new Promise(resolve => {
|
|
120
|
+
let buf = '';
|
|
121
|
+
req.on('data', c => buf += c);
|
|
122
|
+
req.on('end', () => { try { resolve(JSON.parse(buf)); } catch { resolve({}); } });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
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); }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setStatus(s, status) {
|
|
135
|
+
s.status = status;
|
|
136
|
+
wsSend(s, { t: 's', s: status });
|
|
137
|
+
}
|
|
138
|
+
|
|
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);
|
|
151
|
+
}
|
|
152
|
+
resolve(set);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
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;
|
|
160
|
+
|
|
161
|
+
// ── route handlers ────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
async function handleSessions(res) {
|
|
164
|
+
const current = await gitModified();
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const [id, s] of sessions) {
|
|
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
|
+
});
|
|
183
|
+
}
|
|
184
|
+
json(res, 200, { sessions: out });
|
|
185
|
+
}
|
|
186
|
+
|
|
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; }
|
|
191
|
+
|
|
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;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!pty) {
|
|
210
|
+
json(res, 503, { error: 'interactive terminal unavailable on this platform — run: pnpm add @lydell/node-pty' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const existing = sessions.get(storyId);
|
|
215
|
+
if (existing && existing.status === 'running') {
|
|
216
|
+
json(res, 409, { error: 'already running', pid: existing.proc && existing.proc.pid });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Replacing a finished session — drop any sockets still attached.
|
|
220
|
+
if (existing) { for (const ws of existing.wsClients) { try { ws.close(); } catch {} } }
|
|
221
|
+
|
|
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
|
+
}
|
|
240
|
+
|
|
241
|
+
const s = {
|
|
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(),
|
|
248
|
+
};
|
|
249
|
+
sessions.set(storyId, s);
|
|
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);
|
|
259
|
+
}
|
|
260
|
+
wsSend(s, { t: 'o', d });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
264
|
+
const status = signal ? 'stopped' : (exitCode === 0 ? 'done' : 'exited');
|
|
265
|
+
setStatus(s, status);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
json(res, 200, { storyId, pid: proc.pid, status: 'running' });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function handleStop(req, res) {
|
|
272
|
+
const body = await parseBody(req);
|
|
273
|
+
const storyId = String(body.storyId || '').trim();
|
|
274
|
+
if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
|
|
275
|
+
const s = sessions.get(storyId);
|
|
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' });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── WebSocket data plane ───────────────────────────────────────────────────────
|
|
283
|
+
|
|
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
|
+
}
|
|
291
|
+
|
|
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));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── server ────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
const server = http.createServer(async (req, res) => {
|
|
318
|
+
const method = req.method || '';
|
|
319
|
+
const url = req.url || '';
|
|
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
|
+
|
|
329
|
+
if (!authed(req)) { json(res, 401, { error: 'unauthorized' }); return; }
|
|
330
|
+
|
|
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; }
|
|
336
|
+
|
|
337
|
+
res.writeHead(404); res.end('Not found');
|
|
338
|
+
});
|
|
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
|
+
|
|
353
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
354
|
+
console.log('\n🤖 Rihal Orchestrator');
|
|
355
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
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>');
|
|
361
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
362
|
+
});
|