ralphflow 0.4.0 → 0.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.
@@ -0,0 +1,821 @@
1
+ import {
2
+ BUILT_IN_TEMPLATES,
3
+ copyTemplate,
4
+ createCustomTemplate,
5
+ deleteCustomTemplate,
6
+ deleteFlowState,
7
+ getAllLoopStates,
8
+ getAvailableTemplates,
9
+ getDb,
10
+ listCustomTemplates,
11
+ listFlows,
12
+ loadConfig,
13
+ parseTracker,
14
+ resolveFlowDir,
15
+ resolveTemplatePathWithCustom,
16
+ validateTemplateName
17
+ } from "./chunk-TCCMQDVT.js";
18
+
19
+ // src/dashboard/server.ts
20
+ import { Hono as Hono2 } from "hono";
21
+ import { cors } from "hono/cors";
22
+ import { serve } from "@hono/node-server";
23
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
24
+ import { join as join4, dirname } from "path";
25
+ import { fileURLToPath } from "url";
26
+ import { WebSocketServer as WebSocketServer3 } from "ws";
27
+ import chalk from "chalk";
28
+
29
+ // src/dashboard/api.ts
30
+ import { Hono } from "hono";
31
+ import { readFileSync, writeFileSync, readdirSync, existsSync, rmSync, cpSync, mkdirSync, statSync } from "fs";
32
+ import { join, resolve, basename, relative } from "path";
33
+ import { randomUUID } from "crypto";
34
+ import { WebSocket } from "ws";
35
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
36
+ var notifications = [];
37
+ function broadcastWs(wss, event) {
38
+ if (!wss) return;
39
+ const data = JSON.stringify(event);
40
+ for (const client of wss.clients) {
41
+ if (client.readyState === WebSocket.OPEN) {
42
+ client.send(data);
43
+ }
44
+ }
45
+ }
46
+ function createApiRoutes(cwd, port = 4242, wss) {
47
+ const api = new Hono();
48
+ api.get("/api/context", (c) => {
49
+ return c.json({
50
+ cwd,
51
+ projectName: basename(cwd),
52
+ port
53
+ });
54
+ });
55
+ api.get("/api/apps", (c) => {
56
+ const flows = listFlows(cwd);
57
+ const apps = flows.map((appName) => {
58
+ const flowDir = resolveFlowDir(cwd, appName);
59
+ const config = loadConfig(flowDir);
60
+ const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
61
+ return {
62
+ appName,
63
+ appType: config.name,
64
+ description: config.description || "",
65
+ loops: sortedLoops.map(([key, loop]) => ({
66
+ key,
67
+ name: loop.name,
68
+ order: loop.order,
69
+ stages: loop.stages,
70
+ multiAgent: !!(loop.multi_agent && typeof loop.multi_agent === "object" && loop.multi_agent.enabled),
71
+ model: loop.model || null
72
+ }))
73
+ };
74
+ });
75
+ return c.json(apps);
76
+ });
77
+ api.post("/api/apps", async (c) => {
78
+ const body = await c.req.json();
79
+ const { template, name } = body;
80
+ const allTemplateNames = [
81
+ ...BUILT_IN_TEMPLATES,
82
+ ...listCustomTemplates(cwd)
83
+ ];
84
+ if (!template || !allTemplateNames.includes(template)) {
85
+ return c.json({ error: `Invalid template. Available: ${allTemplateNames.join(", ")}` }, 400);
86
+ }
87
+ if (!name || name.trim().length === 0) {
88
+ return c.json({ error: "Name is required" }, 400);
89
+ }
90
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
91
+ return c.json({ error: 'Invalid name: must not contain "..", "/", or "\\"' }, 400);
92
+ }
93
+ const appName = name.trim();
94
+ const flowDir = join(cwd, ".ralph-flow", appName);
95
+ if (existsSync(flowDir)) {
96
+ return c.json({ error: `App "${appName}" already exists` }, 409);
97
+ }
98
+ const claudeMdPath = join(cwd, "CLAUDE.md");
99
+ const warning = existsSync(claudeMdPath) ? null : "No CLAUDE.md found in project root. Consider creating one for better Claude context.";
100
+ try {
101
+ copyTemplate(template, flowDir, cwd);
102
+ } catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ return c.json({ error: `Failed to create app: ${msg}` }, 500);
105
+ }
106
+ return c.json({
107
+ ok: true,
108
+ appName,
109
+ warning,
110
+ commands: [
111
+ `npx ralphflow run story --flow ${appName}`,
112
+ `npx ralphflow e2e --flow ${appName}`
113
+ ]
114
+ }, 201);
115
+ });
116
+ api.delete("/api/apps/:app", (c) => {
117
+ const appName = c.req.param("app");
118
+ if (appName.includes("..") || appName.includes("/") || appName.includes("\\")) {
119
+ return c.json({ error: 'Invalid name: must not contain "..", "/", or "\\"' }, 400);
120
+ }
121
+ const flowDir = join(cwd, ".ralph-flow", appName);
122
+ if (!existsSync(flowDir)) {
123
+ return c.json({ error: `App "${appName}" not found` }, 404);
124
+ }
125
+ rmSync(flowDir, { recursive: true, force: true });
126
+ try {
127
+ const db = getDb(cwd);
128
+ deleteFlowState(db, appName);
129
+ } catch {
130
+ }
131
+ for (let i = notifications.length - 1; i >= 0; i--) {
132
+ if (notifications[i].app === appName) {
133
+ notifications.splice(i, 1);
134
+ }
135
+ }
136
+ return c.json({ ok: true, appName });
137
+ });
138
+ api.post("/api/apps/:app/archive", (c) => {
139
+ const appName = c.req.param("app");
140
+ if (appName.includes("..") || appName.includes("/") || appName.includes("\\")) {
141
+ return c.json({ error: 'Invalid name: must not contain "..", "/", or "\\"' }, 400);
142
+ }
143
+ const flowDir = join(cwd, ".ralph-flow", appName);
144
+ if (!existsSync(flowDir)) {
145
+ return c.json({ error: `App "${appName}" not found` }, 404);
146
+ }
147
+ let config;
148
+ try {
149
+ config = loadConfig(flowDir);
150
+ } catch (err) {
151
+ const msg = err instanceof Error ? err.message : String(err);
152
+ return c.json({ error: `Failed to load config: ${msg}` }, 500);
153
+ }
154
+ const now = /* @__PURE__ */ new Date();
155
+ const timestamp = now.toISOString().slice(0, 16).replace("T", "_").replace(":", "-");
156
+ const archivesBase = join(cwd, ".ralph-flow", ".archives", appName);
157
+ let archiveDir = join(archivesBase, timestamp);
158
+ let archiveTimestamp = timestamp;
159
+ if (existsSync(archiveDir)) {
160
+ let seq = 2;
161
+ while (existsSync(`${archiveDir}-${seq}`)) seq++;
162
+ archiveDir = `${archiveDir}-${seq}`;
163
+ archiveTimestamp = `${timestamp}-${seq}`;
164
+ }
165
+ try {
166
+ mkdirSync(archiveDir, { recursive: true });
167
+ cpSync(flowDir, archiveDir, { recursive: true });
168
+ let templateDir;
169
+ try {
170
+ templateDir = resolveTemplatePathWithCustom(config.name, cwd);
171
+ } catch {
172
+ }
173
+ for (const loop of Object.values(config.loops)) {
174
+ if (templateDir) {
175
+ const templateTracker = join(templateDir, "loops", loop.tracker);
176
+ const appTracker = join(flowDir, loop.tracker);
177
+ if (existsSync(templateTracker)) {
178
+ writeFileSync(appTracker, readFileSync(templateTracker, "utf-8"));
179
+ }
180
+ }
181
+ if (loop.data_files && templateDir) {
182
+ for (const dataFile of loop.data_files) {
183
+ const templateData = join(templateDir, "loops", dataFile);
184
+ const appData = join(flowDir, dataFile);
185
+ if (existsSync(templateData)) {
186
+ writeFileSync(appData, readFileSync(templateData, "utf-8"));
187
+ }
188
+ }
189
+ }
190
+ const loopDir = resolve(flowDir, loop.tracker, "..");
191
+ const agentsPath = join(loopDir, ".agents");
192
+ if (existsSync(agentsPath)) {
193
+ rmSync(agentsPath, { recursive: true, force: true });
194
+ }
195
+ if (loop.lock) {
196
+ const lockPath = join(flowDir, loop.lock.file);
197
+ if (existsSync(lockPath)) {
198
+ rmSync(lockPath);
199
+ }
200
+ }
201
+ }
202
+ try {
203
+ const db = getDb(cwd);
204
+ deleteFlowState(db, appName);
205
+ } catch {
206
+ }
207
+ for (let i = notifications.length - 1; i >= 0; i--) {
208
+ if (notifications[i].app === appName) {
209
+ notifications.splice(i, 1);
210
+ }
211
+ }
212
+ const archivePath = `.ralph-flow/.archives/${appName}/${archiveTimestamp}`;
213
+ return c.json({ ok: true, archivePath, timestamp: archiveTimestamp });
214
+ } catch (err) {
215
+ const msg = err instanceof Error ? err.message : String(err);
216
+ return c.json({ error: `Archive failed: ${msg}` }, 500);
217
+ }
218
+ });
219
+ api.get("/api/apps/:app/archives", (c) => {
220
+ const appName = c.req.param("app");
221
+ if (appName.includes("..") || appName.includes("/") || appName.includes("\\")) {
222
+ return c.json({ error: "Invalid name" }, 400);
223
+ }
224
+ const archivesDir = join(cwd, ".ralph-flow", ".archives", appName);
225
+ if (!existsSync(archivesDir)) {
226
+ return c.json([]);
227
+ }
228
+ const entries = readdirSync(archivesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
229
+ const archiveDir = join(archivesDir, d.name);
230
+ const summary = parseArchiveSummary(archiveDir);
231
+ const fileCount = countFiles(archiveDir);
232
+ return {
233
+ timestamp: d.name,
234
+ summary,
235
+ fileCount
236
+ };
237
+ }).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
238
+ return c.json(entries);
239
+ });
240
+ api.get("/api/apps/:app/archives/:timestamp/files", (c) => {
241
+ const appName = c.req.param("app");
242
+ const timestamp = c.req.param("timestamp");
243
+ if (appName.includes("..") || appName.includes("/") || appName.includes("\\")) {
244
+ return c.json({ error: "Invalid name" }, 400);
245
+ }
246
+ if (timestamp.includes("..") || timestamp.includes("/") || timestamp.includes("\\")) {
247
+ return c.json({ error: "Invalid timestamp" }, 400);
248
+ }
249
+ const archiveDir = resolve(cwd, ".ralph-flow", ".archives", appName, timestamp);
250
+ if (!validatePath(archiveDir, cwd)) {
251
+ return c.json({ error: "Invalid path" }, 403);
252
+ }
253
+ if (!existsSync(archiveDir)) {
254
+ return c.json({ error: "Archive not found" }, 404);
255
+ }
256
+ const files = listFilesRecursive(archiveDir, archiveDir);
257
+ return c.json(files);
258
+ });
259
+ api.get("/api/apps/:app/archives/:timestamp/files/*", (c) => {
260
+ const appName = c.req.param("app");
261
+ const timestamp = c.req.param("timestamp");
262
+ const filePath = c.req.path.replace(`/api/apps/${encodeURIComponent(appName)}/archives/${encodeURIComponent(timestamp)}/files/`, "");
263
+ if (appName.includes("..") || appName.includes("/") || appName.includes("\\")) {
264
+ return c.json({ error: "Invalid name" }, 400);
265
+ }
266
+ if (timestamp.includes("..") || timestamp.includes("/") || timestamp.includes("\\")) {
267
+ return c.json({ error: "Invalid timestamp" }, 400);
268
+ }
269
+ if (!filePath || filePath.includes("..")) {
270
+ return c.json({ error: "Invalid file path" }, 400);
271
+ }
272
+ const fullPath = resolve(cwd, ".ralph-flow", ".archives", appName, timestamp, filePath);
273
+ if (!validatePath(fullPath, cwd)) {
274
+ return c.json({ error: "Invalid path" }, 403);
275
+ }
276
+ if (!existsSync(fullPath) || statSync(fullPath).isDirectory()) {
277
+ return c.json({ error: "File not found" }, 404);
278
+ }
279
+ const content = readFileSync(fullPath, "utf-8");
280
+ return c.json({ path: filePath, content });
281
+ });
282
+ api.get("/api/apps/:app/status", (c) => {
283
+ const appName = c.req.param("app");
284
+ const flowDir = resolveFlowDir(cwd, appName);
285
+ const config = loadConfig(flowDir);
286
+ const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
287
+ const statuses = sortedLoops.map(([key, loop]) => ({
288
+ key,
289
+ ...parseTracker(loop.tracker, flowDir, loop.name)
290
+ }));
291
+ return c.json(statuses);
292
+ });
293
+ api.get("/api/apps/:app/config", (c) => {
294
+ const appName = c.req.param("app");
295
+ const flowDir = resolveFlowDir(cwd, appName);
296
+ const config = loadConfig(flowDir);
297
+ const configPath = join(flowDir, "ralphflow.yaml");
298
+ const rawYaml = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
299
+ return c.json({ ...config, _rawYaml: rawYaml });
300
+ });
301
+ api.put("/api/apps/:app/config/model", async (c) => {
302
+ const appName = c.req.param("app");
303
+ const flowDir = resolveFlowDir(cwd, appName);
304
+ const configPath = join(flowDir, "ralphflow.yaml");
305
+ if (!existsSync(configPath)) {
306
+ return c.json({ error: "ralphflow.yaml not found" }, 404);
307
+ }
308
+ const body = await c.req.json();
309
+ const { loop: loopKey, model } = body;
310
+ if (!loopKey) {
311
+ return c.json({ error: "loop is required" }, 400);
312
+ }
313
+ const rawYaml = readFileSync(configPath, "utf-8");
314
+ const config = parseYaml(rawYaml);
315
+ if (!config.loops || !config.loops[loopKey]) {
316
+ return c.json({ error: `Loop "${loopKey}" not found in config` }, 404);
317
+ }
318
+ if (model === null || model === "") {
319
+ delete config.loops[loopKey].model;
320
+ } else {
321
+ config.loops[loopKey].model = model;
322
+ }
323
+ writeFileSync(configPath, stringifyYaml(config, { lineWidth: 0 }), "utf-8");
324
+ return c.json({ ok: true, loop: loopKey, model: model || null });
325
+ });
326
+ api.get("/api/apps/:app/db", (c) => {
327
+ const appName = c.req.param("app");
328
+ const db = getDb(cwd);
329
+ const rows = getAllLoopStates(db, appName);
330
+ return c.json(rows);
331
+ });
332
+ api.get("/api/apps/:app/loops/:loop/prompt", (c) => {
333
+ const { app: appName, loop: loopKey } = c.req.param();
334
+ const flowDir = resolveFlowDir(cwd, appName);
335
+ const config = loadConfig(flowDir);
336
+ const loopConfig = config.loops[loopKey];
337
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
338
+ const promptPath = resolve(flowDir, loopConfig.prompt);
339
+ if (!validatePath(promptPath, cwd)) {
340
+ return c.json({ error: "Invalid path" }, 403);
341
+ }
342
+ if (!existsSync(promptPath)) {
343
+ return c.json({ error: "prompt.md not found" }, 404);
344
+ }
345
+ const content = readFileSync(promptPath, "utf-8");
346
+ return c.json({ path: loopConfig.prompt, content });
347
+ });
348
+ api.put("/api/apps/:app/loops/:loop/prompt", async (c) => {
349
+ const { app: appName, loop: loopKey } = c.req.param();
350
+ const flowDir = resolveFlowDir(cwd, appName);
351
+ const config = loadConfig(flowDir);
352
+ const loopConfig = config.loops[loopKey];
353
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
354
+ const promptPath = resolve(flowDir, loopConfig.prompt);
355
+ if (!validatePath(promptPath, cwd)) {
356
+ return c.json({ error: "Invalid path" }, 403);
357
+ }
358
+ const body = await c.req.json();
359
+ writeFileSync(promptPath, body.content, "utf-8");
360
+ return c.json({ ok: true });
361
+ });
362
+ api.get("/api/apps/:app/loops/:loop/tracker", (c) => {
363
+ const { app: appName, loop: loopKey } = c.req.param();
364
+ const flowDir = resolveFlowDir(cwd, appName);
365
+ const config = loadConfig(flowDir);
366
+ const loopConfig = config.loops[loopKey];
367
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
368
+ const trackerPath = resolve(flowDir, loopConfig.tracker);
369
+ if (!validatePath(trackerPath, cwd)) {
370
+ return c.json({ error: "Invalid path" }, 403);
371
+ }
372
+ if (!existsSync(trackerPath)) {
373
+ return c.json({ error: "tracker.md not found", content: "" }, 404);
374
+ }
375
+ const content = readFileSync(trackerPath, "utf-8");
376
+ return c.json({ path: loopConfig.tracker, content });
377
+ });
378
+ api.get("/api/apps/:app/loops/:loop/files", (c) => {
379
+ const { app: appName, loop: loopKey } = c.req.param();
380
+ const flowDir = resolveFlowDir(cwd, appName);
381
+ const config = loadConfig(flowDir);
382
+ const loopConfig = config.loops[loopKey];
383
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
384
+ const loopDir = resolve(flowDir, loopConfig.prompt, "..");
385
+ if (!validatePath(loopDir, cwd)) {
386
+ return c.json({ error: "Invalid path" }, 403);
387
+ }
388
+ if (!existsSync(loopDir)) {
389
+ return c.json({ files: [] });
390
+ }
391
+ const files = readdirSync(loopDir, { withFileTypes: true }).map((d) => ({
392
+ name: d.name,
393
+ isDirectory: d.isDirectory()
394
+ }));
395
+ return c.json({ files });
396
+ });
397
+ api.get("/api/templates", (c) => {
398
+ return c.json(getAvailableTemplates(cwd));
399
+ });
400
+ api.post("/api/templates", async (c) => {
401
+ let definition;
402
+ try {
403
+ definition = await c.req.json();
404
+ } catch {
405
+ return c.json({ error: "Invalid JSON body" }, 400);
406
+ }
407
+ if (!definition.name) {
408
+ return c.json({ error: "Template name is required" }, 400);
409
+ }
410
+ const validation = validateTemplateName(definition.name);
411
+ if (!validation.valid) {
412
+ return c.json({ error: validation.error }, 400);
413
+ }
414
+ if (!definition.loops || !Array.isArray(definition.loops) || definition.loops.length === 0) {
415
+ return c.json({ error: "At least one loop is required" }, 400);
416
+ }
417
+ for (let i = 0; i < definition.loops.length; i++) {
418
+ const loop = definition.loops[i];
419
+ if (!loop.name) {
420
+ return c.json({ error: `Loop ${i + 1}: name is required` }, 400);
421
+ }
422
+ if (!loop.stages || !Array.isArray(loop.stages) || loop.stages.length === 0) {
423
+ return c.json({ error: `Loop "${loop.name}": at least one stage is required` }, 400);
424
+ }
425
+ if (!loop.completion) {
426
+ return c.json({ error: `Loop "${loop.name}": completion string is required` }, 400);
427
+ }
428
+ }
429
+ const customDir = join(cwd, ".ralph-flow", ".templates", definition.name);
430
+ if (existsSync(customDir)) {
431
+ return c.json({ error: `Template "${definition.name}" already exists` }, 409);
432
+ }
433
+ try {
434
+ createCustomTemplate(cwd, definition);
435
+ } catch (err) {
436
+ const msg = err instanceof Error ? err.message : String(err);
437
+ return c.json({ error: `Failed to create template: ${msg}` }, 500);
438
+ }
439
+ return c.json({
440
+ ok: true,
441
+ templateName: definition.name,
442
+ message: `Template "${definition.name}" created successfully`
443
+ }, 201);
444
+ });
445
+ api.delete("/api/templates/:name", (c) => {
446
+ const name = c.req.param("name");
447
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
448
+ return c.json({ error: 'Invalid name: must not contain "..", "/", or "\\"' }, 400);
449
+ }
450
+ if (BUILT_IN_TEMPLATES.includes(name)) {
451
+ return c.json({ error: "Cannot delete built-in templates" }, 403);
452
+ }
453
+ try {
454
+ deleteCustomTemplate(cwd, name);
455
+ } catch (err) {
456
+ const msg = err instanceof Error ? err.message : String(err);
457
+ if (msg.includes("not found")) {
458
+ return c.json({ error: msg }, 404);
459
+ }
460
+ return c.json({ error: msg }, 500);
461
+ }
462
+ return c.json({ ok: true, templateName: name });
463
+ });
464
+ api.post("/api/notification", async (c) => {
465
+ const app = c.req.query("app") || "unknown";
466
+ const loop = c.req.query("loop") || "unknown";
467
+ let payload = {};
468
+ try {
469
+ payload = await c.req.json();
470
+ } catch {
471
+ }
472
+ const notification = {
473
+ id: randomUUID(),
474
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
475
+ app,
476
+ loop,
477
+ payload
478
+ };
479
+ notifications.push(notification);
480
+ broadcastWs(wss, { type: "notification:attention", notification });
481
+ return c.json(notification, 200);
482
+ });
483
+ api.get("/api/notifications", (c) => {
484
+ return c.json(notifications);
485
+ });
486
+ api.delete("/api/notification/:id", (c) => {
487
+ const id = c.req.param("id");
488
+ const idx = notifications.findIndex((n) => n.id === id);
489
+ if (idx === -1) {
490
+ return c.json({ error: "Notification not found" }, 404);
491
+ }
492
+ notifications.splice(idx, 1);
493
+ broadcastWs(wss, { type: "notification:dismissed", id });
494
+ return c.json({ ok: true });
495
+ });
496
+ return api;
497
+ }
498
+ function validatePath(resolvedPath, cwd) {
499
+ const ralphFlowDir = resolve(cwd, ".ralph-flow");
500
+ return resolvedPath.startsWith(ralphFlowDir) && !resolvedPath.includes("..");
501
+ }
502
+ function parseArchiveSummary(archiveDir) {
503
+ let storyCount = 0;
504
+ let taskCount = 0;
505
+ const mdFiles = listFilesRecursive(archiveDir, archiveDir).filter((f) => f.path.endsWith(".md"));
506
+ for (const file of mdFiles) {
507
+ try {
508
+ const content = readFileSync(join(archiveDir, file.path), "utf-8");
509
+ const storyMatches = content.match(/^## STORY-\d+:/gm);
510
+ if (storyMatches) storyCount += storyMatches.length;
511
+ const taskMatches = content.match(/^## TASK-\d+:/gm);
512
+ if (taskMatches) taskCount += taskMatches.length;
513
+ } catch {
514
+ }
515
+ }
516
+ return { storyCount, taskCount };
517
+ }
518
+ function countFiles(dir) {
519
+ let count = 0;
520
+ const entries = readdirSync(dir, { withFileTypes: true });
521
+ for (const entry of entries) {
522
+ if (entry.isDirectory()) {
523
+ count += countFiles(join(dir, entry.name));
524
+ } else {
525
+ count++;
526
+ }
527
+ }
528
+ return count;
529
+ }
530
+ function listFilesRecursive(dir, baseDir) {
531
+ const results = [];
532
+ const entries = readdirSync(dir, { withFileTypes: true });
533
+ for (const entry of entries) {
534
+ const fullPath = join(dir, entry.name);
535
+ const relPath = relative(baseDir, fullPath);
536
+ if (entry.isDirectory()) {
537
+ results.push(...listFilesRecursive(fullPath, baseDir));
538
+ } else {
539
+ results.push({ path: relPath, isDirectory: false });
540
+ }
541
+ }
542
+ return results;
543
+ }
544
+
545
+ // src/dashboard/watcher.ts
546
+ import { watch } from "chokidar";
547
+ import { join as join2, relative as relative2, sep } from "path";
548
+ import { WebSocket as WebSocket2 } from "ws";
549
+ function setupWatcher(cwd, wss) {
550
+ const ralphFlowDir = join2(cwd, ".ralph-flow");
551
+ const debounceTimers = /* @__PURE__ */ new Map();
552
+ const watcher = watch(ralphFlowDir, {
553
+ ignoreInitial: true
554
+ });
555
+ watcher.on("change", (filePath) => {
556
+ if (!filePath.endsWith(".md") && !filePath.endsWith(".yaml")) return;
557
+ const existing = debounceTimers.get(filePath);
558
+ if (existing) clearTimeout(existing);
559
+ debounceTimers.set(filePath, setTimeout(() => {
560
+ debounceTimers.delete(filePath);
561
+ handleFileChange(filePath, cwd, wss);
562
+ }, 300));
563
+ });
564
+ let cachedDbState = "";
565
+ const dbPollInterval = setInterval(() => {
566
+ try {
567
+ const db = getDb(cwd);
568
+ const flows = listFlows(cwd);
569
+ const allStates = {};
570
+ for (const flow of flows) {
571
+ allStates[flow] = getAllLoopStates(db, flow);
572
+ }
573
+ const stateStr = JSON.stringify(allStates);
574
+ if (stateStr !== cachedDbState) {
575
+ cachedDbState = stateStr;
576
+ const fullStatus = buildFullStatus(cwd);
577
+ broadcast(wss, { type: "status:full", apps: fullStatus });
578
+ }
579
+ } catch {
580
+ }
581
+ }, 2e3);
582
+ wss.on("connection", (ws) => {
583
+ const fullStatus = buildFullStatus(cwd);
584
+ ws.send(JSON.stringify({ type: "status:full", apps: fullStatus }));
585
+ });
586
+ return {
587
+ close() {
588
+ watcher.close();
589
+ clearInterval(dbPollInterval);
590
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
591
+ debounceTimers.clear();
592
+ }
593
+ };
594
+ }
595
+ function handleFileChange(filePath, cwd, wss) {
596
+ const ralphFlowDir = join2(cwd, ".ralph-flow");
597
+ const rel = relative2(ralphFlowDir, filePath);
598
+ const parts = rel.split(sep);
599
+ if (parts.length < 2) return;
600
+ const appName = parts[0];
601
+ if (filePath.endsWith("tracker.md")) {
602
+ try {
603
+ const flowDir = resolveFlowDir(cwd, appName);
604
+ const config = loadConfig(flowDir);
605
+ for (const [key, loop] of Object.entries(config.loops)) {
606
+ const trackerFullPath = join2(flowDir, loop.tracker);
607
+ if (trackerFullPath === filePath) {
608
+ const status = parseTracker(loop.tracker, flowDir, loop.name);
609
+ broadcast(wss, {
610
+ type: "tracker:updated",
611
+ app: appName,
612
+ loop: key,
613
+ status: { key, ...status }
614
+ });
615
+ return;
616
+ }
617
+ }
618
+ } catch {
619
+ }
620
+ }
621
+ broadcast(wss, {
622
+ type: "file:changed",
623
+ app: appName,
624
+ path: rel
625
+ });
626
+ }
627
+ function buildFullStatus(cwd) {
628
+ const flows = listFlows(cwd);
629
+ return flows.map((appName) => {
630
+ try {
631
+ const flowDir = resolveFlowDir(cwd, appName);
632
+ const config = loadConfig(flowDir);
633
+ const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
634
+ return {
635
+ appName,
636
+ appType: config.name,
637
+ description: config.description || "",
638
+ loops: sortedLoops.map(([key, loop]) => ({
639
+ key,
640
+ name: loop.name,
641
+ order: loop.order,
642
+ stages: loop.stages,
643
+ status: parseTracker(loop.tracker, flowDir, loop.name)
644
+ }))
645
+ };
646
+ } catch {
647
+ return {
648
+ appName,
649
+ appType: "unknown",
650
+ description: "",
651
+ loops: []
652
+ };
653
+ }
654
+ });
655
+ }
656
+ function broadcast(wss, event) {
657
+ const data = JSON.stringify(event);
658
+ for (const client of wss.clients) {
659
+ if (client.readyState === WebSocket2.OPEN) {
660
+ client.send(data);
661
+ }
662
+ }
663
+ }
664
+
665
+ // src/dashboard/hooks.ts
666
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
667
+ import { join as join3 } from "path";
668
+ var RALPHFLOW_MARKER = "# ralphflow-managed";
669
+ function settingsPath(cwd) {
670
+ return join3(cwd, ".claude", "settings.local.json");
671
+ }
672
+ function readSettings(cwd) {
673
+ const path = settingsPath(cwd);
674
+ if (!existsSync2(path)) return {};
675
+ try {
676
+ const raw = readFileSync2(path, "utf-8").trim();
677
+ if (!raw) return {};
678
+ return JSON.parse(raw);
679
+ } catch {
680
+ return {};
681
+ }
682
+ }
683
+ function writeSettings(cwd, settings) {
684
+ const dir = join3(cwd, ".claude");
685
+ mkdirSync2(dir, { recursive: true });
686
+ writeFileSync2(settingsPath(cwd), JSON.stringify(settings, null, 2) + "\n", "utf-8");
687
+ }
688
+ function buildHookCommand(port) {
689
+ return `cat | curl --connect-timeout 2 --max-time 5 -s -X POST "http://127.0.0.1:${port}/api/notification?app=$RALPHFLOW_APP&loop=$RALPHFLOW_LOOP" -H 'Content-Type: application/json' -d @- ${RALPHFLOW_MARKER}`;
690
+ }
691
+ function isRalphFlowHook(entry) {
692
+ return entry.command?.includes(RALPHFLOW_MARKER) ?? false;
693
+ }
694
+ function installNotificationHook(cwd, port) {
695
+ const settings = readSettings(cwd);
696
+ if (!settings.hooks) {
697
+ settings.hooks = {};
698
+ }
699
+ const notificationHooks = settings.hooks.Notification ?? [];
700
+ const cleaned = notificationHooks.map((matcher) => ({
701
+ ...matcher,
702
+ hooks: matcher.hooks.filter((h) => !isRalphFlowHook(h))
703
+ })).filter((matcher) => matcher.hooks.length > 0);
704
+ cleaned.push({
705
+ matcher: "",
706
+ hooks: [{ type: "command", command: buildHookCommand(port) }]
707
+ });
708
+ settings.hooks.Notification = cleaned;
709
+ writeSettings(cwd, settings);
710
+ }
711
+ function removeNotificationHook(cwd) {
712
+ const path = settingsPath(cwd);
713
+ if (!existsSync2(path)) return;
714
+ const settings = readSettings(cwd);
715
+ if (!settings.hooks?.Notification) return;
716
+ settings.hooks.Notification = settings.hooks.Notification.map((matcher) => ({
717
+ ...matcher,
718
+ hooks: matcher.hooks.filter((h) => !isRalphFlowHook(h))
719
+ })).filter((matcher) => matcher.hooks.length > 0);
720
+ if (settings.hooks.Notification.length === 0) {
721
+ delete settings.hooks.Notification;
722
+ }
723
+ if (Object.keys(settings.hooks).length === 0) {
724
+ delete settings.hooks;
725
+ }
726
+ writeSettings(cwd, settings);
727
+ }
728
+
729
+ // src/dashboard/server.ts
730
+ var __dirname = dirname(fileURLToPath(import.meta.url));
731
+ function resolveUiPath() {
732
+ const candidates = [
733
+ join4(__dirname, "..", "dashboard", "ui", "index.html"),
734
+ // dev: src/dashboard/ -> src/dashboard/ui/
735
+ join4(__dirname, "..", "src", "dashboard", "ui", "index.html")
736
+ // bundled: dist/ -> src/dashboard/ui/
737
+ ];
738
+ for (const candidate of candidates) {
739
+ if (existsSync3(candidate)) return candidate;
740
+ }
741
+ throw new Error(
742
+ `Dashboard UI not found. Searched:
743
+ ${candidates.join("\n")}`
744
+ );
745
+ }
746
+ async function startDashboard(options) {
747
+ const { cwd, port = 4242 } = options;
748
+ const app = new Hono2();
749
+ const wss = new WebSocketServer3({ noServer: true });
750
+ app.use("*", cors({
751
+ origin: (origin) => origin || "*",
752
+ allowMethods: ["GET", "PUT", "POST", "DELETE"]
753
+ }));
754
+ const apiRoutes = createApiRoutes(cwd, port, wss);
755
+ app.route("/", apiRoutes);
756
+ app.get("/", (c) => {
757
+ const htmlPath = resolveUiPath();
758
+ const html = readFileSync3(htmlPath, "utf-8");
759
+ return c.html(html);
760
+ });
761
+ const server = serve({
762
+ fetch: app.fetch,
763
+ port,
764
+ hostname: "127.0.0.1"
765
+ });
766
+ server.on("upgrade", (request, socket, head) => {
767
+ const url = new URL(request.url || "/", `http://${request.headers.host}`);
768
+ if (url.pathname === "/ws") {
769
+ wss.handleUpgrade(request, socket, head, (ws) => {
770
+ wss.emit("connection", ws, request);
771
+ });
772
+ } else {
773
+ socket.destroy();
774
+ }
775
+ });
776
+ const watcherHandle = setupWatcher(cwd, wss);
777
+ try {
778
+ installNotificationHook(cwd, port);
779
+ console.log(chalk.dim(` Configured Claude hook \u2192 .claude/settings.local.json`));
780
+ } catch (err) {
781
+ const msg = err instanceof Error ? err.message : String(err);
782
+ console.log(chalk.yellow(` \u26A0 Could not configure Claude hook: ${msg}`));
783
+ }
784
+ console.log();
785
+ console.log(chalk.bold(` Dashboard ${chalk.dim("\u2192")} http://localhost:${port}`));
786
+ console.log(chalk.dim(` Watching ${cwd}/.ralph-flow/`));
787
+ console.log();
788
+ let closed = false;
789
+ const close = () => {
790
+ if (closed) return;
791
+ closed = true;
792
+ watcherHandle.close();
793
+ wss.close();
794
+ server.close();
795
+ try {
796
+ removeNotificationHook(cwd);
797
+ console.log(chalk.dim(" Removed Claude hook"));
798
+ } catch (err) {
799
+ const msg = err instanceof Error ? err.message : String(err);
800
+ console.log(chalk.yellow(` \u26A0 Could not remove Claude hook: ${msg}`));
801
+ }
802
+ };
803
+ const onSignal = () => {
804
+ close();
805
+ process.exit();
806
+ };
807
+ process.on("SIGINT", onSignal);
808
+ process.on("SIGTERM", onSignal);
809
+ process.on("exit", () => {
810
+ if (!closed) {
811
+ try {
812
+ removeNotificationHook(cwd);
813
+ } catch {
814
+ }
815
+ }
816
+ });
817
+ return { close };
818
+ }
819
+ export {
820
+ startDashboard
821
+ };