ralphflow 0.5.0 → 0.5.2

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  BUILT_IN_TEMPLATES,
3
+ cloneBuiltInTemplate,
3
4
  copyTemplate,
4
5
  createCustomTemplate,
5
6
  deleteCustomTemplate,
@@ -14,14 +15,14 @@ import {
14
15
  resolveFlowDir,
15
16
  resolveTemplatePathWithCustom,
16
17
  validateTemplateName
17
- } from "./chunk-TCCMQDVT.js";
18
+ } from "./chunk-DOC64TD6.js";
18
19
 
19
20
  // src/dashboard/server.ts
20
21
  import { Hono as Hono2 } from "hono";
21
22
  import { cors } from "hono/cors";
22
23
  import { serve } from "@hono/node-server";
23
24
  import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
24
- import { join as join4, dirname } from "path";
25
+ import { join as join4, dirname, extname } from "path";
25
26
  import { fileURLToPath } from "url";
26
27
  import { WebSocketServer as WebSocketServer3 } from "ws";
27
28
  import chalk from "chalk";
@@ -34,6 +35,7 @@ import { randomUUID } from "crypto";
34
35
  import { WebSocket } from "ws";
35
36
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
36
37
  var notifications = [];
38
+ var decisions = [];
37
39
  function broadcastWs(wss, event) {
38
40
  if (!wss) return;
39
41
  const data = JSON.stringify(event);
@@ -68,7 +70,9 @@ function createApiRoutes(cwd, port = 4242, wss) {
68
70
  order: loop.order,
69
71
  stages: loop.stages,
70
72
  multiAgent: !!(loop.multi_agent && typeof loop.multi_agent === "object" && loop.multi_agent.enabled),
71
- model: loop.model || null
73
+ model: loop.model || null,
74
+ feeds: loop.feeds || [],
75
+ fed_by: loop.fed_by || []
72
76
  }))
73
77
  };
74
78
  });
@@ -133,6 +137,11 @@ function createApiRoutes(cwd, port = 4242, wss) {
133
137
  notifications.splice(i, 1);
134
138
  }
135
139
  }
140
+ for (let i = decisions.length - 1; i >= 0; i--) {
141
+ if (decisions[i].app === appName) {
142
+ decisions.splice(i, 1);
143
+ }
144
+ }
136
145
  return c.json({ ok: true, appName });
137
146
  });
138
147
  api.post("/api/apps/:app/archive", (c) => {
@@ -209,6 +218,11 @@ function createApiRoutes(cwd, port = 4242, wss) {
209
218
  notifications.splice(i, 1);
210
219
  }
211
220
  }
221
+ for (let i = decisions.length - 1; i >= 0; i--) {
222
+ if (decisions[i].app === appName) {
223
+ decisions.splice(i, 1);
224
+ }
225
+ }
212
226
  const archivePath = `.ralph-flow/.archives/${appName}/${archiveTimestamp}`;
213
227
  return c.json({ ok: true, archivePath, timestamp: archiveTimestamp });
214
228
  } catch (err) {
@@ -461,6 +475,138 @@ function createApiRoutes(cwd, port = 4242, wss) {
461
475
  }
462
476
  return c.json({ ok: true, templateName: name });
463
477
  });
478
+ api.post("/api/templates/:name/clone", async (c) => {
479
+ const sourceName = c.req.param("name");
480
+ if (sourceName.includes("..") || sourceName.includes("/") || sourceName.includes("\\")) {
481
+ return c.json({ error: 'Invalid name: must not contain "..", "/", or "\\"' }, 400);
482
+ }
483
+ if (!BUILT_IN_TEMPLATES.includes(sourceName)) {
484
+ return c.json({ error: `"${sourceName}" is not a built-in template. Only built-in templates can be cloned.` }, 400);
485
+ }
486
+ let body;
487
+ try {
488
+ body = await c.req.json();
489
+ } catch {
490
+ return c.json({ error: "Invalid JSON body" }, 400);
491
+ }
492
+ const { newName } = body;
493
+ if (!newName || newName.trim().length === 0) {
494
+ return c.json({ error: "newName is required" }, 400);
495
+ }
496
+ const validation = validateTemplateName(newName.trim());
497
+ if (!validation.valid) {
498
+ return c.json({ error: validation.error }, 400);
499
+ }
500
+ const customDir = join(cwd, ".ralph-flow", ".templates", newName.trim());
501
+ if (existsSync(customDir)) {
502
+ return c.json({ error: `Template "${newName.trim()}" already exists` }, 409);
503
+ }
504
+ try {
505
+ cloneBuiltInTemplate(cwd, sourceName, newName.trim());
506
+ } catch (err) {
507
+ const msg = err instanceof Error ? err.message : String(err);
508
+ if (msg.includes("already exists")) {
509
+ return c.json({ error: msg }, 409);
510
+ }
511
+ return c.json({ error: `Clone failed: ${msg}` }, 500);
512
+ }
513
+ return c.json({
514
+ ok: true,
515
+ source: sourceName,
516
+ templateName: newName.trim(),
517
+ message: `Template "${sourceName}" cloned as "${newName.trim()}"`
518
+ }, 201);
519
+ });
520
+ api.get("/api/templates/:name/config", (c) => {
521
+ const name = c.req.param("name");
522
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
523
+ return c.json({ error: "Invalid name" }, 400);
524
+ }
525
+ let templateDir;
526
+ try {
527
+ templateDir = resolveTemplatePathWithCustom(name, cwd);
528
+ } catch {
529
+ return c.json({ error: `Template "${name}" not found` }, 404);
530
+ }
531
+ try {
532
+ const config = loadConfig(templateDir);
533
+ return c.json(config);
534
+ } catch (err) {
535
+ const msg = err instanceof Error ? err.message : String(err);
536
+ return c.json({ error: msg }, 500);
537
+ }
538
+ });
539
+ api.get("/api/templates/:name/loops/:loopKey/prompt", (c) => {
540
+ const name = c.req.param("name");
541
+ const loopKey = c.req.param("loopKey");
542
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
543
+ return c.json({ error: "Invalid name" }, 400);
544
+ }
545
+ if (loopKey.includes("..") || loopKey.includes("/") || loopKey.includes("\\")) {
546
+ return c.json({ error: "Invalid loop key" }, 400);
547
+ }
548
+ let templateDir;
549
+ try {
550
+ templateDir = resolveTemplatePathWithCustom(name, cwd);
551
+ } catch {
552
+ return c.json({ error: `Template "${name}" not found` }, 404);
553
+ }
554
+ let config;
555
+ try {
556
+ config = loadConfig(templateDir);
557
+ } catch (err) {
558
+ const msg = err instanceof Error ? err.message : String(err);
559
+ return c.json({ error: msg }, 500);
560
+ }
561
+ const loopConfig = config.loops[loopKey];
562
+ if (!loopConfig) {
563
+ return c.json({ error: `Loop "${loopKey}" not found in template` }, 404);
564
+ }
565
+ const promptPath = resolve(templateDir, "loops", loopConfig.prompt);
566
+ if (!promptPath.startsWith(resolve(templateDir))) {
567
+ return c.json({ error: "Invalid path" }, 403);
568
+ }
569
+ if (!existsSync(promptPath)) {
570
+ return c.json({ error: "prompt.md not found", content: "" }, 404);
571
+ }
572
+ const content = readFileSync(promptPath, "utf-8");
573
+ return c.json({ path: loopConfig.prompt, content });
574
+ });
575
+ api.put("/api/templates/:name/loops/:loopKey/prompt", async (c) => {
576
+ const name = c.req.param("name");
577
+ const loopKey = c.req.param("loopKey");
578
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
579
+ return c.json({ error: "Invalid name" }, 400);
580
+ }
581
+ if (loopKey.includes("..") || loopKey.includes("/") || loopKey.includes("\\")) {
582
+ return c.json({ error: "Invalid loop key" }, 400);
583
+ }
584
+ if (BUILT_IN_TEMPLATES.includes(name)) {
585
+ return c.json({ error: "Cannot modify built-in template prompts" }, 403);
586
+ }
587
+ const customDir = join(cwd, ".ralph-flow", ".templates", name);
588
+ if (!existsSync(customDir) || !existsSync(join(customDir, "ralphflow.yaml"))) {
589
+ return c.json({ error: `Custom template "${name}" not found` }, 404);
590
+ }
591
+ let config;
592
+ try {
593
+ config = loadConfig(customDir);
594
+ } catch (err) {
595
+ const msg = err instanceof Error ? err.message : String(err);
596
+ return c.json({ error: msg }, 500);
597
+ }
598
+ const loopConfig = config.loops[loopKey];
599
+ if (!loopConfig) {
600
+ return c.json({ error: `Loop "${loopKey}" not found in template` }, 404);
601
+ }
602
+ const promptPath = resolve(customDir, "loops", loopConfig.prompt);
603
+ if (!promptPath.startsWith(resolve(customDir))) {
604
+ return c.json({ error: "Invalid path" }, 403);
605
+ }
606
+ const body = await c.req.json();
607
+ writeFileSync(promptPath, body.content, "utf-8");
608
+ return c.json({ ok: true });
609
+ });
464
610
  api.post("/api/notification", async (c) => {
465
611
  const app = c.req.query("app") || "unknown";
466
612
  const loop = c.req.query("loop") || "unknown";
@@ -493,6 +639,45 @@ function createApiRoutes(cwd, port = 4242, wss) {
493
639
  broadcastWs(wss, { type: "notification:dismissed", id });
494
640
  return c.json({ ok: true });
495
641
  });
642
+ api.post("/api/decision", async (c) => {
643
+ const app = c.req.query("app") || "unknown";
644
+ const loop = c.req.query("loop") || "unknown";
645
+ let body;
646
+ try {
647
+ body = await c.req.json();
648
+ } catch {
649
+ return c.json({ error: "Invalid JSON body" }, 400);
650
+ }
651
+ if (!body.item || !body.decision) {
652
+ return c.json({ error: "item and decision are required" }, 400);
653
+ }
654
+ const decision = {
655
+ id: randomUUID(),
656
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
657
+ app,
658
+ loop,
659
+ item: body.item,
660
+ agent: body.agent || "unknown",
661
+ decision: body.decision,
662
+ reasoning: body.reasoning || ""
663
+ };
664
+ decisions.push(decision);
665
+ broadcastWs(wss, { type: "decision:reported", decision });
666
+ return c.json(decision, 200);
667
+ });
668
+ api.get("/api/decisions", (c) => {
669
+ return c.json(decisions);
670
+ });
671
+ api.delete("/api/decision/:id", (c) => {
672
+ const id = c.req.param("id");
673
+ const idx = decisions.findIndex((d) => d.id === id);
674
+ if (idx === -1) {
675
+ return c.json({ error: "Decision not found" }, 404);
676
+ }
677
+ decisions.splice(idx, 1);
678
+ broadcastWs(wss, { type: "decision:dismissed", id });
679
+ return c.json({ ok: true });
680
+ });
496
681
  return api;
497
682
  }
498
683
  function validatePath(resolvedPath, cwd) {
@@ -728,19 +913,24 @@ function removeNotificationHook(cwd) {
728
913
 
729
914
  // src/dashboard/server.ts
730
915
  var __dirname = dirname(fileURLToPath(import.meta.url));
731
- function resolveUiPath() {
916
+ var CONTENT_TYPES = {
917
+ ".css": "text/css",
918
+ ".js": "text/javascript",
919
+ ".html": "text/html"
920
+ };
921
+ function resolveUiDir() {
732
922
  const candidates = [
733
- join4(__dirname, "..", "dashboard", "ui", "index.html"),
923
+ join4(__dirname, "..", "dashboard", "ui"),
734
924
  // dev: src/dashboard/ -> src/dashboard/ui/
735
- join4(__dirname, "..", "src", "dashboard", "ui", "index.html")
925
+ join4(__dirname, "..", "src", "dashboard", "ui")
736
926
  // bundled: dist/ -> src/dashboard/ui/
737
927
  ];
738
928
  for (const candidate of candidates) {
739
- if (existsSync3(candidate)) return candidate;
929
+ if (existsSync3(join4(candidate, "index.html"))) return candidate;
740
930
  }
741
931
  throw new Error(
742
932
  `Dashboard UI not found. Searched:
743
- ${candidates.join("\n")}`
933
+ ${candidates.map((c) => join4(c, "index.html")).join("\n")}`
744
934
  );
745
935
  }
746
936
  async function startDashboard(options) {
@@ -753,11 +943,21 @@ async function startDashboard(options) {
753
943
  }));
754
944
  const apiRoutes = createApiRoutes(cwd, port, wss);
755
945
  app.route("/", apiRoutes);
946
+ const uiDir = resolveUiDir();
756
947
  app.get("/", (c) => {
757
- const htmlPath = resolveUiPath();
758
- const html = readFileSync3(htmlPath, "utf-8");
948
+ const html = readFileSync3(join4(uiDir, "index.html"), "utf-8");
759
949
  return c.html(html);
760
950
  });
951
+ app.get("/:file{.+\\.(css|js)$}", (c) => {
952
+ const file = c.req.param("file");
953
+ const filePath = join4(uiDir, file);
954
+ if (!filePath.startsWith(uiDir) || !existsSync3(filePath)) {
955
+ return c.notFound();
956
+ }
957
+ const content = readFileSync3(filePath, "utf-8");
958
+ const contentType = CONTENT_TYPES[extname(filePath)] || "text/plain";
959
+ return c.text(content, 200, { "Content-Type": contentType });
960
+ });
761
961
  const server = serve({
762
962
  fetch: app.fetch,
763
963
  port,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralphflow",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Multi-agent AI workflow orchestration framework for Claude Code. Define pipelines as loops, coordinate parallel agents, and ship structured work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,9 @@
22
22
  "dev": "tsup --watch",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "lint": "eslint src/",
25
+ "docs:dev": "vitepress dev docs",
26
+ "docs:build": "vitepress build docs",
27
+ "docs:preview": "vitepress preview docs",
25
28
  "prepublishOnly": "npm run build"
26
29
  },
27
30
  "keywords": [
@@ -67,6 +70,7 @@
67
70
  "@types/node": "^22.0.0",
68
71
  "@types/ws": "^8.5.0",
69
72
  "tsup": "^8.3.0",
70
- "typescript": "^5.6.0"
73
+ "typescript": "^5.6.0",
74
+ "vitepress": "^1.6.4"
71
75
  }
72
76
  }
@@ -0,0 +1,203 @@
1
+ // Application entry point — WebSocket, data fetching, init, action wiring.
2
+
3
+ import { state, dom, actions } from './state.js';
4
+ import { fetchJson } from './utils.js';
5
+ import { renderSidebar, selectApp, selectLoop } from './sidebar.js';
6
+ import { renderContent, loadTracker, openDeleteAppModal, openArchiveAppModal, openCreateAppModal } from './loop-detail.js';
7
+ import {
8
+ fetchNotifications,
9
+ dismissNotification,
10
+ fetchDecisions,
11
+ dismissDecision,
12
+ maybeRequestNotifPermission,
13
+ showBrowserNotification,
14
+ showBrowserDecisionNotification,
15
+ playNotificationChime,
16
+ onFirstInteraction,
17
+ } from './notifications.js';
18
+ import { switchAppTab, loadArchives } from './archives.js';
19
+ import {
20
+ renderTemplatesPage,
21
+ renderTemplateBuilder,
22
+ captureBuilderInputs,
23
+ updateYamlPreview,
24
+ updateMinimapIO,
25
+ fetchTemplates,
26
+ } from './templates.js';
27
+
28
+ // -----------------------------------------------------------------------
29
+ // Wire cross-module actions registry
30
+ // -----------------------------------------------------------------------
31
+ actions.renderSidebar = renderSidebar;
32
+ actions.renderContent = renderContent;
33
+ actions.selectLoop = selectLoop;
34
+ actions.fetchApps = fetchApps;
35
+ actions.fetchAppStatus = fetchAppStatus;
36
+ actions.openCreateAppModal = openCreateAppModal;
37
+ actions.openDeleteAppModal = openDeleteAppModal;
38
+ actions.openArchiveAppModal = openArchiveAppModal;
39
+ actions.renderTemplatesPage = renderTemplatesPage;
40
+ actions.renderTemplateBuilder = renderTemplateBuilder;
41
+ actions.captureBuilderInputs = captureBuilderInputs;
42
+ actions.updateYamlPreview = updateYamlPreview;
43
+ actions.updateMinimapIO = updateMinimapIO;
44
+ actions.fetchTemplates = fetchTemplates;
45
+ actions.switchAppTab = switchAppTab;
46
+ actions.loadArchives = loadArchives;
47
+ actions.dismissNotification = dismissNotification;
48
+ actions.dismissDecision = dismissDecision;
49
+
50
+ // -----------------------------------------------------------------------
51
+ // Host display
52
+ // -----------------------------------------------------------------------
53
+ dom.hostDisplay.textContent = location.host;
54
+
55
+ fetch('/api/context')
56
+ .then(r => r.json())
57
+ .then(ctx => {
58
+ dom.hostDisplay.textContent = ctx.projectName + ' :' + ctx.port;
59
+ })
60
+ .catch(() => { /* keep location.host as fallback */ });
61
+
62
+ // -----------------------------------------------------------------------
63
+ // WebSocket
64
+ // -----------------------------------------------------------------------
65
+ function connectWs() {
66
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
67
+ state.ws = new WebSocket(`${proto}//${location.host}/ws`);
68
+
69
+ state.ws.onopen = () => {
70
+ dom.statusDot.className = 'status-dot connected';
71
+ dom.statusText.textContent = 'Connected';
72
+ state.reconnectDelay = 1000;
73
+ };
74
+
75
+ state.ws.onclose = () => {
76
+ dom.statusDot.className = 'status-dot disconnected';
77
+ dom.statusText.textContent = 'Disconnected';
78
+ setTimeout(connectWs, state.reconnectDelay);
79
+ state.reconnectDelay = Math.min(state.reconnectDelay * 2, 30000);
80
+ };
81
+
82
+ state.ws.onerror = () => {
83
+ state.ws.close();
84
+ };
85
+
86
+ state.ws.onmessage = (e) => {
87
+ const event = JSON.parse(e.data);
88
+ state.eventCounter++;
89
+ dom.eventCountEl.textContent = state.eventCounter;
90
+ dom.lastUpdate.textContent = new Date().toLocaleTimeString();
91
+ handleWsEvent(event);
92
+ };
93
+ }
94
+
95
+ function handleWsEvent(event) {
96
+ if (event.type === 'status:full') {
97
+ state.apps = event.apps;
98
+ renderSidebar();
99
+ if (state.selectedApp) {
100
+ const updated = state.apps.find(a => a.appName === state.selectedApp.appName);
101
+ if (updated) {
102
+ state.selectedApp = updated;
103
+ renderContent();
104
+ }
105
+ }
106
+ } else if (event.type === 'tracker:updated') {
107
+ if (state.selectedApp && state.selectedApp.appName === event.app) {
108
+ const loopEntry = state.selectedApp.loops.find(l => l.key === event.loop);
109
+ if (loopEntry) {
110
+ loopEntry.status = event.status;
111
+ }
112
+ renderContent();
113
+ if (state.selectedLoop === event.loop) {
114
+ loadTracker(event.app, event.loop);
115
+ }
116
+ }
117
+ } else if (event.type === 'file:changed') {
118
+ if (state.selectedApp && state.selectedApp.appName === event.app) {
119
+ fetchAppStatus(event.app);
120
+ }
121
+ } else if (event.type === 'notification:attention') {
122
+ const n = event.notification;
123
+ state.notificationsList.unshift(n);
124
+ renderSidebar();
125
+ renderContent();
126
+ maybeRequestNotifPermission();
127
+ showBrowserNotification(n);
128
+ playNotificationChime();
129
+ } else if (event.type === 'notification:dismissed') {
130
+ state.notificationsList = state.notificationsList.filter(n => n.id !== event.id);
131
+ renderSidebar();
132
+ renderContent();
133
+ } else if (event.type === 'decision:reported') {
134
+ const d = event.decision;
135
+ state.decisionsList.unshift(d);
136
+ renderSidebar();
137
+ renderContent();
138
+ maybeRequestNotifPermission();
139
+ showBrowserDecisionNotification(d);
140
+ playNotificationChime();
141
+ } else if (event.type === 'decision:dismissed') {
142
+ state.decisionsList = state.decisionsList.filter(d => d.id !== event.id);
143
+ renderSidebar();
144
+ renderContent();
145
+ }
146
+ }
147
+
148
+ // -----------------------------------------------------------------------
149
+ // API
150
+ // -----------------------------------------------------------------------
151
+ async function fetchApps() {
152
+ state.apps = await fetchJson('/api/apps');
153
+ renderSidebar();
154
+ if (state.apps.length > 0 && !state.selectedApp) {
155
+ selectApp(state.apps[0]);
156
+ }
157
+ }
158
+
159
+ async function fetchAppStatus(appName) {
160
+ const statuses = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/status`);
161
+ if (state.selectedApp && state.selectedApp.appName === appName) {
162
+ statuses.forEach(s => {
163
+ const loop = state.selectedApp.loops.find(l => l.key === s.key);
164
+ if (loop) loop.status = s;
165
+ });
166
+ renderContent();
167
+ }
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Templates nav click handler
172
+ // -----------------------------------------------------------------------
173
+ document.getElementById('templatesNav').addEventListener('click', () => {
174
+ state.currentPage = 'templates';
175
+ state.selectedApp = null;
176
+ state.selectedLoop = null;
177
+ state.showTemplateBuilder = false;
178
+ state.templateBuilderState = null;
179
+ state.editingTemplateName = null;
180
+ state.viewingTemplateName = null;
181
+ state.viewingTemplateConfig = null;
182
+ state.viewingTemplatePrompts = {};
183
+ state.showTemplateWizard = false;
184
+ state.wizardStep = 0;
185
+ state.wizardData = null;
186
+ document.title = 'Templates - RalphFlow Dashboard';
187
+ renderSidebar();
188
+ renderContent();
189
+ });
190
+
191
+ // -----------------------------------------------------------------------
192
+ // Audio context init on first interaction
193
+ // -----------------------------------------------------------------------
194
+ document.addEventListener('click', onFirstInteraction);
195
+ document.addEventListener('keydown', onFirstInteraction);
196
+
197
+ // -----------------------------------------------------------------------
198
+ // Boot
199
+ // -----------------------------------------------------------------------
200
+ fetchApps();
201
+ fetchNotifications();
202
+ fetchDecisions();
203
+ connectWs();