@field/cascade 0.8.0 → 0.11.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.
Files changed (195) hide show
  1. package/AGENTS.md +36 -3
  2. package/CHANGELOG.md +401 -0
  3. package/DESIGN.md +14 -0
  4. package/README.md +41 -26
  5. package/dist/assets/CodeEditor-4O7FQ__R.js +623 -0
  6. package/dist/assets/CodePanel-BU-autmq.js +2 -0
  7. package/dist/assets/{CustomNodeDialog-BQbEhIwL.js → CustomNodeDialog-BRpDfOV5.js} +1 -1
  8. package/dist/assets/OpenGraphDialog-BwbRlC0X.js +1 -0
  9. package/dist/assets/ProjectSettings-DLOXLxcA.js +2 -0
  10. package/dist/assets/SettingsDialog-Bz8JxISH.js +1 -0
  11. package/dist/assets/{VersionHistoryDialog-D6VBzSQ0.js → VersionHistoryDialog-D5Z0nJ5-.js} +2 -2
  12. package/dist/assets/{core-BInLG4gw.js → core-Yqi0Ni61.js} +8 -8
  13. package/dist/assets/{cssMode-DfR2qmYG.js → cssMode-CxUzc4vp.js} +1 -1
  14. package/dist/assets/definitions-GreEMXLV.js +1 -0
  15. package/dist/assets/{editor.api-Ck8_vgQV.js → editor.api-BNFb25CX.js} +1 -1
  16. package/dist/assets/{freemarker2-fVn10Wn3.js → freemarker2-0ZDnWxeM.js} +1 -1
  17. package/dist/assets/geo-u0n_jOsD.js +1 -0
  18. package/dist/assets/{handlebars-BCSchLT2.js → handlebars-Cy18MoaD.js} +1 -1
  19. package/dist/assets/{html-CVpSv_k9.js → html-B-2Dx7nq.js} +1 -1
  20. package/dist/assets/{htmlMode-Da4ESTv0.js → htmlMode-DUAc9B_d.js} +1 -1
  21. package/dist/assets/{image-BLn--oSF.js → image-cAa-Nu9O.js} +3 -3
  22. package/dist/assets/index-5ZtDAuy2.js +220 -0
  23. package/dist/assets/index-7vae6YFC.css +1 -0
  24. package/dist/assets/index-client-DqYmKstU.js +1 -0
  25. package/dist/assets/{javascript-BpX0Gvcf.js → javascript-DeQsDqP5.js} +1 -1
  26. package/dist/assets/{jsonMode-CsLqaou7.js → jsonMode-BiFwJVSk.js} +1 -1
  27. package/dist/assets/legacy-CTEeXeqF.js +2 -0
  28. package/dist/assets/{liquid-efUz51Wf.js → liquid-BLoHH68a.js} +1 -1
  29. package/dist/assets/{lspLanguageFeatures-DCi5-K0n.js → lspLanguageFeatures-CNFgm1Kv.js} +1 -1
  30. package/dist/assets/math-DioIQOPJ.js +1 -0
  31. package/dist/assets/{mdx-DP_sjGVy.js → mdx-CFXQVNfs.js} +1 -1
  32. package/dist/assets/pop-DC_TQG9t.js +1 -0
  33. package/dist/assets/{python-aAuP3LUi.js → python-Cf7S7p2E.js} +1 -1
  34. package/dist/assets/{razor-7M6sMmC5.js → razor-CMHBcG-g.js} +1 -1
  35. package/dist/assets/studioScenes-8sq9FfFg.js +1 -0
  36. package/dist/assets/{tsMode-Bf4CnoYK.js → tsMode-BEih0GDj.js} +1 -1
  37. package/dist/assets/{typescript-ddhYBGyT.js → typescript-COEKYD_a.js} +1 -1
  38. package/dist/assets/{workers-X5la8DDT.js → workers-BNhAhMNn.js} +1 -1
  39. package/dist/assets/{xml-BzbXa3Ps.js → xml-CPiMpnPl.js} +1 -1
  40. package/dist/assets/{yaml-CXs-VzxL.js → yaml-Bgp1ITLR.js} +1 -1
  41. package/dist/cli/Graph-ZJJTRQTM.js +2975 -0
  42. package/dist/cli/chunk-2KXB47FP.js +115 -0
  43. package/dist/cli/chunk-35D343DC.js +1447 -0
  44. package/dist/cli/chunk-36P5K3SD.js +631 -0
  45. package/dist/cli/chunk-3JTJHF7V.js +267 -0
  46. package/dist/cli/chunk-4UDFYF5G.js +132 -0
  47. package/dist/cli/chunk-53NMBJAO.js +35 -0
  48. package/dist/cli/chunk-A4OAMOZH.js +310 -0
  49. package/dist/cli/chunk-BIJO5OFS.js +71 -0
  50. package/dist/cli/chunk-BP7UKF36.js +376 -0
  51. package/dist/cli/chunk-CKS2FQWR.js +89 -0
  52. package/dist/cli/chunk-CO22XHNG.js +593 -0
  53. package/dist/cli/chunk-D6BUOHUG.js +634 -0
  54. package/dist/cli/chunk-DEQSEFFH.js +153 -0
  55. package/dist/cli/chunk-DUIUFVU2.js +241 -0
  56. package/dist/cli/chunk-EUIAC3U3.js +3006 -0
  57. package/dist/cli/chunk-FCDXJ6EK.js +2208 -0
  58. package/dist/cli/chunk-GFS6W3C5.js +308 -0
  59. package/dist/cli/chunk-HFHWYL55.js +139 -0
  60. package/dist/cli/chunk-HSNOBIVT.js +263 -0
  61. package/dist/cli/chunk-ICH7N5PN.js +26 -0
  62. package/dist/cli/chunk-J7VK7MNF.js +418 -0
  63. package/dist/cli/chunk-KH4MUBKF.js +1551 -0
  64. package/dist/cli/chunk-MIIEFUGG.js +623 -0
  65. package/dist/cli/chunk-OWWBYLHC.js +136 -0
  66. package/dist/cli/chunk-POMMNKZZ.js +672 -0
  67. package/dist/cli/chunk-QBBKRNGY.js +90 -0
  68. package/dist/cli/chunk-QQPA2ZHH.js +273 -0
  69. package/dist/cli/chunk-RLCAJBVR.js +224 -0
  70. package/dist/cli/chunk-RQZ4SX5R.js +1125 -0
  71. package/dist/cli/chunk-RUU3YWXU.js +99 -0
  72. package/dist/cli/chunk-RXDIMXN5.js +98 -0
  73. package/dist/cli/chunk-SGJAET2L.js +197 -0
  74. package/dist/cli/chunk-VVGSLVSF.js +151 -0
  75. package/dist/cli/chunk-WD62JIP6.js +118 -0
  76. package/dist/cli/chunk-WIH4TEIS.js +104 -0
  77. package/dist/cli/chunk-WKURDVYP.js +1119 -0
  78. package/dist/cli/chunk-WPUDGEFY.js +20 -0
  79. package/dist/cli/chunk-XWDSKG7N.js +1301 -0
  80. package/dist/cli/chunk-YAT5Y25U.js +121 -0
  81. package/dist/cli/chunk-ZL4JTG5Y.js +37 -0
  82. package/dist/cli/cliCommands-UQSFGEHF.js +3464 -0
  83. package/dist/cli/commands-Q23M26QM.js +1292 -0
  84. package/dist/cli/definitions-HF2FTHYU.js +32 -0
  85. package/dist/cli/doctorCommand-B7BPQMN6.js +76 -0
  86. package/dist/cli/edit-JHCPTQFT.js +28 -0
  87. package/dist/cli/editorCommands-UIF5YLUZ.js +33 -0
  88. package/dist/cli/encode-HAUZTYPT.js +17 -0
  89. package/dist/cli/frames-LSAUBZ7P.js +16 -0
  90. package/dist/cli/geo-4RNK3QVN.js +43 -0
  91. package/dist/cli/graphCommands-6BMAKGQE.js +184 -0
  92. package/dist/cli/graphFile-VPSQ6OF7.js +286 -0
  93. package/dist/cli/headlessCanvas-5XLDZT7M.js +15 -0
  94. package/dist/cli/helpCommand-3VRE3KVO.js +155 -0
  95. package/dist/cli/index.js +106 -14207
  96. package/dist/cli/math-ZRMLKU2D.js +16 -0
  97. package/dist/cli/mcp-ZLEEMYGF.js +164 -0
  98. package/dist/cli/parity-KINARCP2.js +328 -0
  99. package/dist/cli/pop-USDFXB7H.js +39 -0
  100. package/dist/cli/project-K3QIPGBB.js +12 -0
  101. package/dist/cli/projectDefinition-DMTZN2NE.js +27 -0
  102. package/dist/cli/projectRuntime-Z5PJOH47.js +48 -0
  103. package/dist/cli/registerStandardNodes-S2VLMF6J.js +3081 -0
  104. package/dist/cli/render-YURAPPUI.js +436 -0
  105. package/dist/cli/runCommand-PI764LEG.js +198 -0
  106. package/dist/cli/runner-RA6UKEPD.js +37 -0
  107. package/dist/cli/runtime/image.ts +277 -0
  108. package/dist/cli/runtime/stage.ts +74 -11
  109. package/dist/cli/stageRunner-KUNIMQKJ.js +10 -0
  110. package/dist/cli/verbs-RCE3K4VQ.js +19 -0
  111. package/dist/cli/webBuild-GT4PZT5D.js +37 -0
  112. package/dist/contracts/bundle.d.ts +75 -0
  113. package/dist/contracts/bundle.js +112 -0
  114. package/dist/contracts/definition.d.ts +67 -4
  115. package/dist/contracts/document.d.ts +29 -0
  116. package/dist/contracts/documentFormat.d.ts +18 -0
  117. package/dist/contracts/documentFormat.js +195 -0
  118. package/dist/contracts/editor.d.ts +189 -0
  119. package/dist/contracts/editor.js +2 -0
  120. package/dist/contracts/index.d.ts +3 -0
  121. package/dist/contracts/index.js +3 -0
  122. package/dist/contracts/validate.js +59 -3
  123. package/dist/contracts/values.d.ts +3 -3
  124. package/dist/contracts/values.js +1 -0
  125. package/dist/image.d.ts +141 -0
  126. package/dist/image.js +127 -0
  127. package/dist/index.html +4 -4
  128. package/dist/player/app.js +17 -5
  129. package/dist/player/editor.js +1919 -0
  130. package/dist/player/editorEngine.js +477 -0
  131. package/dist/runtime/builtins/core/bundle.d.ts +70 -0
  132. package/dist/runtime/builtins/core/bundle.js +81 -0
  133. package/dist/runtime/builtins/core/index.d.ts +3 -2
  134. package/dist/runtime/builtins/core/index.js +6 -2
  135. package/dist/runtime/builtins/core/routing.d.ts +27 -0
  136. package/dist/runtime/builtins/core/routing.js +28 -0
  137. package/dist/runtime/builtins/headless.d.ts +19 -0
  138. package/dist/runtime/builtins/headless.js +25 -0
  139. package/dist/runtime/builtins/image/contactSheet.d.ts +226 -0
  140. package/dist/runtime/builtins/image/contactSheet.js +199 -0
  141. package/dist/runtime/builtins/image/index.d.ts +9 -0
  142. package/dist/runtime/builtins/image/index.js +9 -0
  143. package/dist/runtime/edit/document.d.ts +94 -0
  144. package/dist/runtime/edit/document.js +194 -0
  145. package/dist/runtime/edit/graphEditor.d.ts +223 -0
  146. package/dist/runtime/edit/graphEditor.js +893 -0
  147. package/dist/runtime/edit/index.d.ts +12 -0
  148. package/dist/runtime/edit/index.js +6 -0
  149. package/dist/runtime/edit/layout.d.ts +50 -0
  150. package/dist/runtime/edit/layout.js +126 -0
  151. package/dist/runtime/edit/operations.d.ts +65 -0
  152. package/dist/runtime/edit/operations.js +57 -0
  153. package/dist/runtime/edit/parameters.d.ts +75 -0
  154. package/dist/runtime/edit/parameters.js +284 -0
  155. package/dist/runtime/edit/read.d.ts +35 -0
  156. package/dist/runtime/edit/read.js +65 -0
  157. package/dist/runtime/editor/index.d.ts +104 -0
  158. package/dist/runtime/editor/index.js +439 -0
  159. package/dist/runtime/expressions/bundlePaths.d.ts +37 -0
  160. package/dist/runtime/expressions/bundlePaths.js +55 -0
  161. package/dist/runtime/expressions/index.d.ts +2 -0
  162. package/dist/runtime/expressions/index.js +1 -0
  163. package/dist/runtime/index.d.ts +2 -0
  164. package/dist/runtime/index.js +2 -0
  165. package/dist/runtime/params/animator.d.ts +5 -0
  166. package/dist/runtime/params/animator.js +16 -0
  167. package/dist/runtime/runtime.js +238 -12
  168. package/dist/runtime/seed.d.ts +29 -0
  169. package/dist/runtime/seed.js +52 -0
  170. package/dist/runtime/types.d.ts +12 -0
  171. package/dist/stage.js +43 -9
  172. package/dist/studio/panel.d.ts +2 -0
  173. package/doc/CLI_REFERENCE.md +171 -0
  174. package/doc/EDITOR_EXPORT.md +235 -0
  175. package/doc/HEADLESS_GPU.md +27 -0
  176. package/doc/MCP.md +69 -0
  177. package/doc/NODE_REFERENCE.md +208 -0
  178. package/doc/PROJECT_AUTHORING.md +258 -1
  179. package/package.json +29 -8
  180. package/dist/assets/CodeEditor-BEk-kIBd.js +0 -623
  181. package/dist/assets/CodePanel-CeSdjXkg.js +0 -2
  182. package/dist/assets/ExportDialog-CjTbbTZJ.js +0 -358
  183. package/dist/assets/ExportDialog-DOq0flMV.css +0 -1
  184. package/dist/assets/OpenGraphDialog-DCwH9itM.js +0 -1
  185. package/dist/assets/ProjectSettings-Dg7M5xt9.js +0 -2
  186. package/dist/assets/SettingsDialog-e7GgsjB9.js +0 -1
  187. package/dist/assets/definitions-DZpOjWKR.js +0 -1
  188. package/dist/assets/geo-D2VPjAdt.js +0 -1
  189. package/dist/assets/index-CMiBdEuT.js +0 -212
  190. package/dist/assets/index-DMIuml9a.css +0 -1
  191. package/dist/assets/index-client-BI2H82FJ.js +0 -1
  192. package/dist/assets/legacy-ZA7textG.js +0 -2
  193. package/dist/assets/math-B6hpxPJO.js +0 -1
  194. package/dist/assets/pop-CYUIjT6x.js +0 -1
  195. package/dist/assets/projectSettingsApi-C1WvIwfP.js +0 -1
@@ -0,0 +1,3464 @@
1
+ import {
2
+ formatCompactJSON
3
+ } from "./chunk-OWWBYLHC.js";
4
+ import {
5
+ PythonWorker,
6
+ StageCancelledError,
7
+ callStage,
8
+ compileEmbedded,
9
+ compileProjectModule,
10
+ compileProjectPanel,
11
+ formatStageTrace,
12
+ pythonWorkerStatuses,
13
+ shutdownAllPythonWorkers,
14
+ stageSources
15
+ } from "./chunk-MIIEFUGG.js";
16
+ import {
17
+ writeFileAtomic,
18
+ writeFileAtomicSync
19
+ } from "./chunk-WPUDGEFY.js";
20
+ import {
21
+ readStageConfig,
22
+ runProjectStage
23
+ } from "./chunk-VVGSLVSF.js";
24
+ import "./chunk-WD62JIP6.js";
25
+ import {
26
+ CredentialStore,
27
+ PathSafetyError,
28
+ ProjectRoot,
29
+ ShellProcessError,
30
+ ShellRequestError,
31
+ readProjectManifest,
32
+ resolveWithinRoot,
33
+ secretValue,
34
+ writeProjectManifest
35
+ } from "./chunk-RQZ4SX5R.js";
36
+ import "./chunk-A4OAMOZH.js";
37
+ import "./chunk-J7VK7MNF.js";
38
+ import "./chunk-BIJO5OFS.js";
39
+
40
+ // server/src/cliCommands.ts
41
+ import fs13 from "node:fs";
42
+ import path13 from "node:path";
43
+ import { execFile as execFile3 } from "node:child_process";
44
+
45
+ // server/src/config.ts
46
+ import fs from "fs";
47
+ import os from "os";
48
+ import path from "path";
49
+ var CONFIG_DIR = path.join(os.homedir(), ".cascade");
50
+ var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
51
+ var DEFAULT_PROJECTS_ROOT = path.join(os.homedir(), "Documents", "Cascade");
52
+ function loadConfig() {
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
55
+ if (typeof raw?.projectsRoot === "string" && raw.projectsRoot.trim()) {
56
+ const expanded = raw.projectsRoot.replace(/^~(?=\/|$)/, os.homedir());
57
+ return { projectsRoot: path.resolve(expanded) };
58
+ }
59
+ } catch {
60
+ }
61
+ return { projectsRoot: DEFAULT_PROJECTS_ROOT };
62
+ }
63
+ function saveConfig(config) {
64
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
65
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
66
+ }
67
+ function configPath() {
68
+ return CONFIG_FILE;
69
+ }
70
+
71
+ // server/src/index.ts
72
+ import express2 from "express";
73
+ import { createServer } from "http";
74
+ import path8 from "path";
75
+ import fs9 from "fs";
76
+ import { fileURLToPath } from "url";
77
+
78
+ // server/src/routes/graph.ts
79
+ import { Router } from "express";
80
+
81
+ // server/src/git.ts
82
+ import { execFile } from "child_process";
83
+ import { promisify } from "util";
84
+ import fssync from "fs";
85
+ import path2 from "path";
86
+ var execFileAsync = promisify(execFile);
87
+ async function git(cwd, args) {
88
+ return execFileAsync("git", args, { cwd });
89
+ }
90
+ async function ensureGitRepo(root) {
91
+ if (fssync.existsSync(path2.join(root, ".git"))) return;
92
+ await git(root, ["init"]);
93
+ }
94
+ async function autoCommit(root, message2, paths) {
95
+ await ensureGitRepo(root);
96
+ const scope = paths && paths.length ? ["--", ...paths] : [];
97
+ await git(root, scope.length ? ["add", ...scope] : ["add", "-A"]);
98
+ const { stdout } = await git(root, ["status", "--porcelain", ...scope]);
99
+ if (!stdout.trim()) {
100
+ return { committed: false };
101
+ }
102
+ await git(root, ["commit", "-m", message2, ...scope]);
103
+ const { stdout: sha } = await git(root, ["rev-parse", "HEAD"]);
104
+ return { committed: true, sha: sha.trim() };
105
+ }
106
+ var LOG_SEP = "";
107
+ async function listFileVersions(root, file, limit = 50) {
108
+ if (!fssync.existsSync(path2.join(root, ".git"))) return [];
109
+ try {
110
+ const { stdout } = await git(root, [
111
+ "log",
112
+ `--max-count=${limit}`,
113
+ `--format=%H${LOG_SEP}%aI${LOG_SEP}%s`,
114
+ "--",
115
+ file
116
+ ]);
117
+ return stdout.split("\n").filter(Boolean).map((line) => {
118
+ const [sha, date, ...rest] = line.split(LOG_SEP);
119
+ return { sha, shortSha: sha.slice(0, 7), date, message: rest.join(LOG_SEP) };
120
+ });
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+ async function isFileDirty(root, file) {
126
+ if (!fssync.existsSync(path2.join(root, ".git"))) return false;
127
+ try {
128
+ const { stdout } = await git(root, ["status", "--porcelain", "--", file]);
129
+ return Boolean(stdout.trim());
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+ async function readFileAtVersion(root, file, sha) {
135
+ if (!/^[0-9a-fA-F]{4,40}$/.test(sha)) {
136
+ throw new Error(`Not a commit hash: ${sha}`);
137
+ }
138
+ try {
139
+ const { stdout } = await git(root, ["show", `${sha}:${file}`]);
140
+ return stdout;
141
+ } catch (err) {
142
+ const notFound = new Error(`No version ${sha} of ${file}`);
143
+ notFound.code = "ENOENT";
144
+ throw notFound;
145
+ }
146
+ }
147
+
148
+ // server/src/routes/graph.ts
149
+ import fs2 from "node:fs/promises";
150
+ import path3 from "node:path";
151
+ function handleError(res, err) {
152
+ if (err instanceof PathSafetyError) {
153
+ res.status(400).json({ error: err.message });
154
+ return;
155
+ }
156
+ const code = err?.code;
157
+ if (code === "ENOENT") {
158
+ res.status(404).json({ error: "not found" });
159
+ return;
160
+ }
161
+ console.error("[graph route] error:", err);
162
+ res.status(500).json({ error: String(err) });
163
+ }
164
+ function createGraphRouter(project) {
165
+ const router = Router();
166
+ router.get("/", async (_req, res) => {
167
+ try {
168
+ res.json({
169
+ root: project.root,
170
+ isGitRepo: project.isGitRepo,
171
+ graphs: await project.listGraphFiles(),
172
+ default: await project.resolveDefaultGraph()
173
+ });
174
+ } catch (err) {
175
+ handleError(res, err);
176
+ }
177
+ });
178
+ router.get("/:filename", async (req, res) => {
179
+ try {
180
+ const content = await project.readGraph(req.params.filename);
181
+ res.type("application/json").send(content);
182
+ } catch (err) {
183
+ handleError(res, err);
184
+ }
185
+ });
186
+ router.put("/:filename", async (req, res) => {
187
+ try {
188
+ const graphData = req.body;
189
+ graphData.metadata = graphData.metadata || {};
190
+ graphData.metadata.modified = (/* @__PURE__ */ new Date()).toISOString();
191
+ const compact = formatCompactJSON(JSON.stringify(graphData, null, 2), 2);
192
+ await project.writeGraph(req.params.filename, compact);
193
+ const { committed, sha } = await autoCommit(
194
+ project.root,
195
+ saveCommitMessage(req.params.filename),
196
+ [req.params.filename]
197
+ );
198
+ res.json({ ok: true, committed, sha });
199
+ } catch (err) {
200
+ handleError(res, err);
201
+ }
202
+ });
203
+ router.get("/:filename/workspace", async (req, res) => {
204
+ try {
205
+ const target = workspacePath(project, req.params.filename);
206
+ await fs2.access(project.resolve(req.params.filename));
207
+ let content;
208
+ try {
209
+ content = await fs2.readFile(target, "utf-8");
210
+ } catch (err) {
211
+ if (err?.code === "ENOENT") {
212
+ res.json(null);
213
+ return;
214
+ }
215
+ throw err;
216
+ }
217
+ res.type("application/json").send(content);
218
+ } catch (err) {
219
+ handleError(res, err);
220
+ }
221
+ });
222
+ router.put("/:filename/workspace", async (req, res) => {
223
+ try {
224
+ const body = req.body;
225
+ if (!body || typeof body !== "object" || Array.isArray(body) || !body.layout || typeof body.layout !== "object") {
226
+ res.status(400).json({ error: "a workspace is an object with a layout" });
227
+ return;
228
+ }
229
+ const slot = req.query.slot === "failed" ? "failed" : null;
230
+ const target = workspacePath(project, req.params.filename, slot);
231
+ await writeFileAtomic(target, JSON.stringify(body, null, 1));
232
+ res.json({ ok: true, file: path3.basename(target) });
233
+ } catch (err) {
234
+ handleError(res, err);
235
+ }
236
+ });
237
+ router.get("/:filename/versions", async (req, res) => {
238
+ try {
239
+ const filename = req.params.filename;
240
+ if (!filename.endsWith(".cascade")) {
241
+ res.status(400).json({ error: `not a .cascade file: ${filename}` });
242
+ return;
243
+ }
244
+ project.resolve(filename);
245
+ res.json({
246
+ isGitRepo: project.isGitRepo,
247
+ dirty: await isFileDirty(project.root, filename),
248
+ versions: await listFileVersions(project.root, filename)
249
+ });
250
+ } catch (err) {
251
+ handleError(res, err);
252
+ }
253
+ });
254
+ router.get("/:filename/versions/:sha", async (req, res) => {
255
+ try {
256
+ const filename = req.params.filename;
257
+ if (!filename.endsWith(".cascade")) {
258
+ res.status(400).json({ error: `not a .cascade file: ${filename}` });
259
+ return;
260
+ }
261
+ project.resolve(filename);
262
+ if (!/^[0-9a-fA-F]{4,40}$/.test(req.params.sha)) {
263
+ res.status(400).json({ error: `not a commit hash: ${req.params.sha}` });
264
+ return;
265
+ }
266
+ const content = await readFileAtVersion(project.root, filename, req.params.sha);
267
+ res.type("application/json").send(content);
268
+ } catch (err) {
269
+ handleError(res, err);
270
+ }
271
+ });
272
+ return router;
273
+ }
274
+ function workspacePath(project, filename, slot = null) {
275
+ if (!filename.endsWith(".cascade") || filename !== path3.basename(filename)) {
276
+ throw new PathSafetyError(`not a .cascade file: ${filename}`);
277
+ }
278
+ const stem = filename.slice(0, -".cascade".length);
279
+ return project.resolve(`${stem}.workspace${slot ? `.${slot}` : ""}.json`);
280
+ }
281
+ function saveCommitMessage(filename, at = /* @__PURE__ */ new Date()) {
282
+ const time = at.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
283
+ return `Saved ${filename} ${time}`;
284
+ }
285
+
286
+ // server/src/routes/assets.ts
287
+ import { Router as Router2 } from "express";
288
+ import multer from "multer";
289
+ import path4 from "path";
290
+ import fs3 from "fs/promises";
291
+ function handleError2(res, err) {
292
+ if (err instanceof PathSafetyError) {
293
+ res.status(400).json({ error: err.message });
294
+ return;
295
+ }
296
+ res.status(500).json({ error: String(err) });
297
+ }
298
+ function createAssetsRouter(project) {
299
+ const router = Router2();
300
+ const assetsRoot = project.resolve("assets");
301
+ const storage = multer.diskStorage({
302
+ destination: async (_req, file, cb) => {
303
+ let assetType = "data";
304
+ if (file.mimetype.startsWith("image/")) assetType = "images";
305
+ else if (file.mimetype.startsWith("audio/")) assetType = "audio";
306
+ const dir = path4.join(assetsRoot, assetType);
307
+ await fs3.mkdir(dir, { recursive: true });
308
+ cb(null, dir);
309
+ },
310
+ filename: (_req, file, cb) => cb(null, file.originalname)
311
+ });
312
+ const upload = multer({ storage });
313
+ router.post("/", upload.single("file"), (req, res) => {
314
+ if (!req.file) {
315
+ res.status(400).json({ error: "no file uploaded" });
316
+ return;
317
+ }
318
+ const relativePath = path4.relative(assetsRoot, req.file.path);
319
+ res.json({
320
+ id: req.file.filename,
321
+ path: `./assets/${relativePath}`,
322
+ type: req.file.mimetype.startsWith("image/") ? "image" : req.file.mimetype.startsWith("audio/") ? "audio" : "binary",
323
+ size: req.file.size,
324
+ filename: req.file.filename
325
+ });
326
+ });
327
+ router.get("/{*assetPath}", async (req, res) => {
328
+ try {
329
+ const rel = req.params.assetPath?.join("/") ?? "";
330
+ const full = resolveWithinRoot(assetsRoot, rel);
331
+ res.sendFile(full);
332
+ } catch (err) {
333
+ if (err instanceof PathSafetyError) {
334
+ res.status(400).json({ error: err.message });
335
+ } else {
336
+ res.status(404).json({ error: "asset not found" });
337
+ }
338
+ }
339
+ });
340
+ router.delete("/{*assetPath}", async (req, res) => {
341
+ try {
342
+ const rel = req.params.assetPath?.join("/") ?? "";
343
+ const full = resolveWithinRoot(assetsRoot, rel);
344
+ await fs3.unlink(full);
345
+ res.json({ ok: true });
346
+ } catch (err) {
347
+ handleError2(res, err);
348
+ }
349
+ });
350
+ return router;
351
+ }
352
+
353
+ // server/src/routes/nodes.ts
354
+ import { Router as Router3 } from "express";
355
+
356
+ // server/src/nodeWatch.ts
357
+ import fs5 from "node:fs";
358
+ import path5 from "node:path";
359
+
360
+ // server/src/watchSeam.ts
361
+ import fs4 from "node:fs";
362
+ var systemClock = {
363
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
364
+ clearTimeout: (handle) => clearTimeout(handle)
365
+ };
366
+ var systemWatch = (directory, options, listener) => fs4.watch(directory, options, (event, filename) => listener(event, filename === null ? null : String(filename)));
367
+ function createDebouncer(clock, ms, fire) {
368
+ const pending = /* @__PURE__ */ new Map();
369
+ return {
370
+ push(key2) {
371
+ const existing = pending.get(key2);
372
+ if (existing !== void 0) clock.clearTimeout(existing);
373
+ pending.set(key2, clock.setTimeout(() => {
374
+ pending.delete(key2);
375
+ fire(key2);
376
+ }, ms));
377
+ },
378
+ cancel() {
379
+ for (const handle of pending.values()) clock.clearTimeout(handle);
380
+ pending.clear();
381
+ }
382
+ };
383
+ }
384
+
385
+ // server/src/nodeWatch.ts
386
+ var ALL_MODULES = "*";
387
+ var NODE_WATCH_DEBOUNCE_MS = 120;
388
+ function moduleNameFor(relative) {
389
+ const segments = relative.split(path5.sep).filter(Boolean);
390
+ if (segments.length < 2) return null;
391
+ const name = segments[0];
392
+ if (!name || name.startsWith(".")) return null;
393
+ return name;
394
+ }
395
+ function createNodeWatcher(project, seams = {}) {
396
+ const listeners = /* @__PURE__ */ new Set();
397
+ const watchers = [];
398
+ const watchDirectory = seams.watch ?? systemWatch;
399
+ const debounce = createDebouncer(seams.clock ?? systemClock, NODE_WATCH_DEBOUNCE_MS, (moduleName) => {
400
+ for (const listener of listeners) listener(moduleName);
401
+ });
402
+ const emit = (moduleName) => debounce.push(moduleName);
403
+ function watch(directory, scope) {
404
+ if (!fs5.existsSync(directory)) return;
405
+ try {
406
+ const watcher = watchDirectory(directory, { recursive: true }, (_event, filename) => {
407
+ if (!filename) return;
408
+ if (scope === "shared") {
409
+ emit(ALL_MODULES);
410
+ return;
411
+ }
412
+ const moduleName = moduleNameFor(String(filename));
413
+ if (moduleName) emit(moduleName);
414
+ });
415
+ watcher.on?.("error", (error) => console.warn("[node watch] stopped watching", directory, error));
416
+ watchers.push(watcher);
417
+ } catch (error) {
418
+ console.warn("[node watch] could not watch", directory, error);
419
+ }
420
+ }
421
+ watch(project.resolve("nodes"), "project");
422
+ try {
423
+ const sharedNodes = fs5.realpathSync(path5.join(project.root, "shared"));
424
+ watch(path5.join(sharedNodes, "nodes"), "shared");
425
+ } catch {
426
+ }
427
+ return {
428
+ subscribe(listener) {
429
+ listeners.add(listener);
430
+ return () => listeners.delete(listener);
431
+ },
432
+ close() {
433
+ debounce.cancel();
434
+ for (const watcher of watchers) watcher.close();
435
+ watchers.length = 0;
436
+ listeners.clear();
437
+ }
438
+ };
439
+ }
440
+
441
+ // server/src/routes/nodes.ts
442
+ function handleError3(res, err) {
443
+ if (err instanceof PathSafetyError) {
444
+ res.status(400).json({ error: err.message });
445
+ return;
446
+ }
447
+ const code = err?.code;
448
+ if (code === "ENOENT") {
449
+ res.status(404).json({ error: "not found" });
450
+ return;
451
+ }
452
+ console.error("[nodes route] error:", err);
453
+ res.status(500).json({ error: String(err) });
454
+ }
455
+ var MAX_BATCH_MODULES = 500;
456
+ function createNodesRouter(project) {
457
+ const router = Router3();
458
+ let watcher = null;
459
+ const factsCache = /* @__PURE__ */ new Map();
460
+ function forget(moduleName) {
461
+ if (moduleName === ALL_MODULES) factsCache.clear();
462
+ else factsCache.delete(moduleName);
463
+ }
464
+ function ensureWatcher() {
465
+ if (!watcher) {
466
+ watcher = createNodeWatcher(project);
467
+ watcher.subscribe(forget);
468
+ }
469
+ return watcher;
470
+ }
471
+ function moduleFacts(name) {
472
+ let cached = factsCache.get(name);
473
+ if (!cached) {
474
+ cached = Promise.all([project.moduleRunsOn(name), project.moduleIcon(name)]).then(([runsOn, icon]) => ({ runsOn, icon }));
475
+ factsCache.set(name, cached);
476
+ cached.catch(() => {
477
+ if (factsCache.get(name) === cached) factsCache.delete(name);
478
+ });
479
+ }
480
+ return cached;
481
+ }
482
+ router.get("/events", (req, res) => {
483
+ res.set({
484
+ "Content-Type": "text/event-stream",
485
+ "Cache-Control": "no-cache, no-transform",
486
+ Connection: "keep-alive",
487
+ // Without this a proxy can hold the stream in a buffer and deliver
488
+ // every event at once when the request finally ends.
489
+ "X-Accel-Buffering": "no"
490
+ });
491
+ res.flushHeaders?.();
492
+ res.write("retry: 2000\n\n");
493
+ const unsubscribe = ensureWatcher().subscribe((moduleName) => {
494
+ res.write(`event: module-changed
495
+ data: ${JSON.stringify({ module: moduleName })}
496
+
497
+ `);
498
+ });
499
+ const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 3e4);
500
+ req.on("close", () => {
501
+ clearInterval(keepAlive);
502
+ unsubscribe();
503
+ });
504
+ });
505
+ router.get("/", async (_req, res) => {
506
+ try {
507
+ ensureWatcher();
508
+ const modules = await project.listNodeModules();
509
+ const runsOn = {};
510
+ const icons = {};
511
+ await Promise.all(modules.map(async (name) => {
512
+ const facts = await moduleFacts(name);
513
+ runsOn[name] = facts.runsOn;
514
+ if (facts.icon) icons[name] = facts.icon;
515
+ }));
516
+ res.json({ modules, runsOn, icons });
517
+ } catch (err) {
518
+ handleError3(res, err);
519
+ }
520
+ });
521
+ router.get("/compiled", async (req, res) => {
522
+ const raw = req.query.module;
523
+ const names = [...new Set((Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).map(String).filter(Boolean))];
524
+ if (names.length === 0) {
525
+ res.status(400).json({ error: "name at least one module: /api/nodes/compiled?module=<name>" });
526
+ return;
527
+ }
528
+ if (names.length > MAX_BATCH_MODULES) {
529
+ res.status(400).json({ error: `at most ${MAX_BATCH_MODULES} modules per request` });
530
+ return;
531
+ }
532
+ try {
533
+ const modules = {};
534
+ await Promise.all(names.map(async (name) => {
535
+ const result = await compileProjectModule(project, name);
536
+ modules[name] = result.ok ? { code: result.code } : { error: result.errors.join("\n") };
537
+ }));
538
+ res.set("Cache-Control", "no-store").json({ modules });
539
+ } catch (err) {
540
+ handleError3(res, err);
541
+ }
542
+ });
543
+ router.get("/:moduleName/runs-on", async (req, res) => {
544
+ try {
545
+ res.json({ module: req.params.moduleName, runsOn: await project.moduleRunsOn(req.params.moduleName) });
546
+ } catch (err) {
547
+ handleError3(res, err);
548
+ }
549
+ });
550
+ router.get("/:moduleName/path", (req, res) => {
551
+ try {
552
+ res.json({ path: project.resolve(`nodes/${req.params.moduleName}/index.ts`) });
553
+ } catch (err) {
554
+ handleError3(res, err);
555
+ }
556
+ });
557
+ router.get("/:moduleName/compiled", async (req, res) => {
558
+ try {
559
+ const result = await compileProjectModule(project, req.params.moduleName);
560
+ if (!result.ok) {
561
+ res.status(422).json({ error: result.errors.join("\n") });
562
+ return;
563
+ }
564
+ res.type("application/javascript").send(result.code);
565
+ } catch (err) {
566
+ handleError3(res, err);
567
+ }
568
+ });
569
+ router.get("/:moduleName/{*file}", async (req, res) => {
570
+ try {
571
+ const rel = req.params.file?.join("/") || "index.ts";
572
+ const content = await project.readNodeModuleFile(req.params.moduleName, rel);
573
+ res.type("text/plain").send(content);
574
+ } catch (err) {
575
+ handleError3(res, err);
576
+ }
577
+ });
578
+ router.put("/:moduleName/{*file}", async (req, res) => {
579
+ try {
580
+ const rel = req.params.file?.join("/") || "index.ts";
581
+ const content = req.body?.content;
582
+ if (typeof content !== "string") {
583
+ res.status(400).json({ error: "body must be { content: string }" });
584
+ return;
585
+ }
586
+ await project.writeNodeModuleFile(req.params.moduleName, rel, content);
587
+ forget(req.params.moduleName);
588
+ const { committed } = await autoCommit(project.root, `Update node ${req.params.moduleName}/${rel}`, [`nodes/${req.params.moduleName}`]);
589
+ res.json({ ok: true, committed });
590
+ } catch (err) {
591
+ handleError3(res, err);
592
+ }
593
+ });
594
+ router.post("/", async (req, res) => {
595
+ try {
596
+ const name = String(req.body?.name ?? "");
597
+ if (!name) {
598
+ res.status(400).json({ error: "body must include { name: string }" });
599
+ return;
600
+ }
601
+ await project.ensureNodeModuleScaffold(name);
602
+ forget(name);
603
+ const { committed } = await autoCommit(project.root, `Scaffold node ${name}`, [`nodes/${name}`]);
604
+ res.json({ ok: true, name, committed });
605
+ } catch (err) {
606
+ handleError3(res, err);
607
+ }
608
+ });
609
+ router.post("/compile-embedded", async (req, res) => {
610
+ try {
611
+ const code = req.body?.code;
612
+ if (typeof code !== "string") {
613
+ res.status(400).json({ error: "body must be { code: string }" });
614
+ return;
615
+ }
616
+ const result = await compileEmbedded(project, code);
617
+ if (!result.ok) {
618
+ res.status(422).json({ error: result.errors.join("\n") });
619
+ return;
620
+ }
621
+ res.type("application/javascript").send(result.code);
622
+ } catch (err) {
623
+ handleError3(res, err);
624
+ }
625
+ });
626
+ return router;
627
+ }
628
+
629
+ // server/src/routes/panels.ts
630
+ import { Router as Router4 } from "express";
631
+ function handleError4(res, err) {
632
+ if (err instanceof PathSafetyError) {
633
+ res.status(400).json({ error: err.message });
634
+ return;
635
+ }
636
+ if (err?.code === "ENOENT") {
637
+ res.status(404).json({ error: "not found" });
638
+ return;
639
+ }
640
+ console.error("[panels route] error:", err);
641
+ res.status(500).json({ error: String(err) });
642
+ }
643
+ function createPanelsRouter(project) {
644
+ const router = Router4();
645
+ router.get("/", async (_req, res) => {
646
+ try {
647
+ const names = await project.listPanels();
648
+ res.json({ panels: await Promise.all(names.map((name) => project.panelMeta(name))) });
649
+ } catch (err) {
650
+ handleError4(res, err);
651
+ }
652
+ });
653
+ router.get("/:name/compiled", async (req, res) => {
654
+ try {
655
+ await project.resolvePanelEntry(req.params.name);
656
+ const result = await compileProjectPanel(project, req.params.name);
657
+ if (!result.ok) {
658
+ res.status(422).json({ error: result.errors.join("\n") });
659
+ return;
660
+ }
661
+ res.type("application/javascript").send(result.code);
662
+ } catch (err) {
663
+ handleError4(res, err);
664
+ }
665
+ });
666
+ return router;
667
+ }
668
+
669
+ // server/src/routes/exec.ts
670
+ import { execFile as execFile2 } from "node:child_process";
671
+ import { Router as Router5, json } from "express";
672
+
673
+ // server/src/security.ts
674
+ import { randomBytes, timingSafeEqual } from "node:crypto";
675
+ import { isIP } from "node:net";
676
+ function isLoopbackHost(host) {
677
+ const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
678
+ return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
679
+ }
680
+ function allowsSensitiveCapabilities(options) {
681
+ return options.sensitiveCapabilities;
682
+ }
683
+ function createProjectRequestBoundary(options) {
684
+ const origins = new Set(options.trustedOrigins.map((origin) => validateOrigin(origin, "Project")));
685
+ const authorities = allowedAuthorities(options, origins);
686
+ return {
687
+ guard(req, res, next) {
688
+ const host = req.get("Host");
689
+ const origin = req.get("Origin");
690
+ if (!host || !authorities.has(host.toLowerCase()) || origin === "null" || origin && !origins.has(origin)) {
691
+ res.status(403).json({ ok: false, error: "Project access denied" });
692
+ return;
693
+ }
694
+ if (origin) setCorsOrigin(res, origin);
695
+ next();
696
+ },
697
+ preflight(req, res) {
698
+ const origin = req.get("Origin");
699
+ if (origin) setCorsOrigin(res, origin);
700
+ res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS");
701
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
702
+ res.status(204).end();
703
+ }
704
+ };
705
+ }
706
+ function createBrowserCapabilityBoundary(options, label, header) {
707
+ const origins = new Set(options.trustedOrigins.map((origin) => validateOrigin(origin, label)));
708
+ const authorities = allowedAuthorities(options, origins);
709
+ const capability = randomBytes(32).toString("base64url");
710
+ const expectedBytes = Buffer.from(capability);
711
+ return {
712
+ guardOrigin(req, res, next) {
713
+ const origin = req.get("Origin");
714
+ const host = req.get("Host");
715
+ if (!host || !authorities.has(host.toLowerCase()) || origin === "null" || origin && !origins.has(origin)) {
716
+ res.status(403).json({ ok: false, error: `${label} access denied` });
717
+ return;
718
+ }
719
+ if (origin) setCorsOrigin(res, origin);
720
+ next();
721
+ },
722
+ preflight(_req, res) {
723
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
724
+ res.setHeader("Access-Control-Allow-Headers", `Content-Type, ${header}`);
725
+ res.status(204).end();
726
+ },
727
+ issueCapability(_req, res) {
728
+ res.setHeader("Cache-Control", "no-store, private");
729
+ res.setHeader("Pragma", "no-cache");
730
+ res.json({ capability });
731
+ },
732
+ requireCapability(req, res, next) {
733
+ const supplied = req.get(header);
734
+ const suppliedBytes = supplied ? Buffer.from(supplied) : Buffer.alloc(0);
735
+ if (suppliedBytes.length !== expectedBytes.length || !timingSafeEqual(suppliedBytes, expectedBytes)) {
736
+ res.status(403).json({ ok: false, error: `${label} capability required` });
737
+ return;
738
+ }
739
+ next();
740
+ }
741
+ };
742
+ }
743
+ function allowedAuthorities(options, origins) {
744
+ const authorities = new Set([...origins].map((origin) => new URL(origin).host.toLowerCase()));
745
+ if (isLoopbackHost(options.host)) {
746
+ authorities.add(`127.0.0.1:${options.port}`);
747
+ authorities.add(`localhost:${options.port}`);
748
+ } else if (!isWildcardHost(options.host)) {
749
+ authorities.add(authority(options.host, options.port));
750
+ }
751
+ for (const host of options.trustedHosts) {
752
+ const value = trustedAuthority(host, options.port);
753
+ authorities.add(value);
754
+ const bare = value.replace(/:(?:80|443)$/, "");
755
+ if (bare !== value) authorities.add(bare);
756
+ }
757
+ return authorities;
758
+ }
759
+ function isWildcardHost(host) {
760
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
761
+ return normalized === "0.0.0.0" || normalized === "::" || normalized === "*";
762
+ }
763
+ function trustedAuthority(value, defaultPort) {
764
+ const trimmed = value.trim().toLowerCase();
765
+ const bracketed = /^\[([^\]]+)\]:(\d+)$/.exec(trimmed);
766
+ if (bracketed) return authority(`[${bracketed[1]}]`, checkedPort(bracketed[2]));
767
+ const hostAndPort = /^([^:]+):(\d+)$/.exec(trimmed);
768
+ if (hostAndPort) return authority(hostAndPort[1], checkedPort(hostAndPort[2]));
769
+ return authority(trimmed, defaultPort);
770
+ }
771
+ function checkedPort(raw) {
772
+ const port = Number(raw);
773
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
774
+ throw new Error(`Invalid trusted host port: ${raw}`);
775
+ }
776
+ return port;
777
+ }
778
+ function authority(host, port) {
779
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
780
+ if (!normalized || /[\s/?#@]/.test(normalized)) throw new Error(`Invalid trusted host: ${host}`);
781
+ if (normalized.includes(":") && isIP(normalized) !== 6) throw new Error(`Invalid trusted host: ${host}`);
782
+ return `${normalized.includes(":") ? `[${normalized}]` : normalized}:${port}`;
783
+ }
784
+ function setCorsOrigin(res, origin) {
785
+ res.setHeader("Access-Control-Allow-Origin", origin);
786
+ res.setHeader("Vary", "Origin");
787
+ }
788
+ function validateOrigin(value, label) {
789
+ const url = new URL(value);
790
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
791
+ throw new Error(`Invalid trusted ${label.toLowerCase()} origin: ${value}`);
792
+ }
793
+ return url.origin;
794
+ }
795
+
796
+ // server/src/routes/exec.ts
797
+ var EXEC_TIMEOUT_MS = 12e4;
798
+ var MAX_BUFFER = 64 * 1024 * 1024;
799
+ var BODY_LIMIT = "64mb";
800
+ function createExecRouter(project, security) {
801
+ const router = Router5();
802
+ if (!allowsSensitiveCapabilities(security)) return router.use((_req, res) => res.status(404).end());
803
+ const boundary = createBrowserCapabilityBoundary(security, "Exec", "X-Cascade-Exec-Capability");
804
+ let workerSlot = null;
805
+ router.use(boundary.guardOrigin);
806
+ router.options("{*path}", boundary.preflight);
807
+ router.get("/capability", boundary.issueCapability);
808
+ router.post("/", boundary.requireCapability, json({ limit: BODY_LIMIT }), async (req, res) => {
809
+ try {
810
+ const request = parseRequest(req.body);
811
+ const credentialEnv = new CredentialStore().environment(readProjectManifest(project.root).credentials);
812
+ if (request.kind === "stage") {
813
+ const config = readStageConfig(project);
814
+ const env = { ...process.env, ...credentialEnv };
815
+ const onTrace = (trace) => console.log(`[stage] ${formatStageTrace(trace)}`);
816
+ if (config.worker) {
817
+ const sources = stageSources(project, config);
818
+ const key2 = `${config.python}\0${config.worker}\0${sources.roots.join("\0")}\0${JSON.stringify(credentialEnv)}`;
819
+ if (workerSlot?.key !== key2) {
820
+ workerSlot?.worker.shutdown();
821
+ workerSlot = { key: key2, worker: new PythonWorker(project, config.worker, config.python, env, { sources }) };
822
+ }
823
+ const worker = workerSlot.worker;
824
+ const workerAbort = new AbortController();
825
+ res.once("close", () => {
826
+ if (!res.writableEnded) workerAbort.abort();
827
+ });
828
+ try {
829
+ const { result, trace } = await callStage(project, config, request.stage, request.args, (envelope) => worker.callWithMeta(request.stage, request.args, { signal: workerAbort.signal, cascade: envelope }), { env, onTrace, caller: request.caller });
830
+ res.json({ ok: true, stdout: JSON.stringify(result), stderr: "", trace });
831
+ } catch (error) {
832
+ if (res.destroyed || res.writableEnded) return;
833
+ const message2 = error instanceof Error ? error.message : String(error);
834
+ const cancelled = error instanceof StageCancelledError;
835
+ res.status(cancelled ? 499 : 500).json({ ok: false, error: message2, stdout: "", stderr: message2, ...cancelled ? { cancelled: true } : {} });
836
+ }
837
+ return;
838
+ }
839
+ const controller = new AbortController();
840
+ res.once("close", () => {
841
+ if (!res.writableEnded) controller.abort();
842
+ });
843
+ try {
844
+ const { result, trace } = await callStage(project, config, request.stage, request.args, async (envelope) => ({
845
+ result: await runProjectStage(project, request.stage, request.args, {
846
+ env,
847
+ timeout: EXEC_TIMEOUT_MS,
848
+ signal: controller.signal,
849
+ cascade: envelope,
850
+ codeHash: envelope.code
851
+ })
852
+ }), { env, onTrace, caller: request.caller });
853
+ res.json({ ok: true, stdout: JSON.stringify(result), stderr: "", trace });
854
+ } catch (error) {
855
+ const message2 = error instanceof Error ? error.message : String(error);
856
+ res.status(500).json({ ok: false, error: message2, stdout: "", stderr: message2 });
857
+ }
858
+ return;
859
+ }
860
+ await runPython(res, project, request.python, request.entrypoint, request.args, credentialEnv);
861
+ } catch (error) {
862
+ res.status(400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
863
+ }
864
+ });
865
+ router.use((error, _req, res, next) => {
866
+ if (error?.type === "entity.too.large" || error?.type === "entity.parse.failed") {
867
+ res.status(error.status ?? 400).json({ ok: false, error: error.message, type: error.type });
868
+ } else next(error);
869
+ });
870
+ return router;
871
+ }
872
+ function parseRequest(body) {
873
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("body must be an object");
874
+ const value = body;
875
+ if (typeof value.stage === "string") {
876
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.stage)) throw new Error("stage contains unsupported characters");
877
+ if (value.args !== void 0 && (!value.args || typeof value.args !== "object" || Array.isArray(value.args))) {
878
+ throw new Error("stage args must be an object");
879
+ }
880
+ const caller = value.caller === "cook" ? "cook" : "action";
881
+ return { kind: "stage", stage: value.stage, args: value.args ?? {}, caller };
882
+ }
883
+ if (typeof value.entrypoint !== "string" || !value.entrypoint) {
884
+ throw new Error("body must include either { stage: string } or { entrypoint: string }");
885
+ }
886
+ if (value.args !== void 0 && !Array.isArray(value.args)) throw new Error("entrypoint args must be an array");
887
+ const python = value.python === "python" ? "python" : "python3";
888
+ return { kind: "entrypoint", entrypoint: value.entrypoint, args: (value.args ?? []).map(String), python };
889
+ }
890
+ function runPython(res, project, python, entrypoint, args, credentialEnv) {
891
+ const scriptPath = project.resolve(entrypoint);
892
+ return new Promise((resolve) => {
893
+ const child = execFile2(
894
+ python,
895
+ [scriptPath, ...args],
896
+ { cwd: project.root, timeout: EXEC_TIMEOUT_MS, maxBuffer: MAX_BUFFER, env: { ...process.env, ...credentialEnv } },
897
+ (error, stdout, stderr) => {
898
+ if (error) {
899
+ res.status(500).json({
900
+ ok: false,
901
+ error: error.message,
902
+ stdout,
903
+ stderr,
904
+ timedOut: error.signal === "SIGTERM"
905
+ });
906
+ } else {
907
+ res.json({ ok: true, stdout, stderr });
908
+ }
909
+ resolve();
910
+ }
911
+ );
912
+ res.once("close", () => {
913
+ if (!res.writableEnded) child.kill();
914
+ });
915
+ });
916
+ }
917
+
918
+ // server/src/routes/shell.ts
919
+ import { Router as Router6, json as json2 } from "express";
920
+ function createShellRouter(shell, options) {
921
+ const router = Router6();
922
+ if (!allowsSensitiveCapabilities(options)) return router.use((_req, res) => res.status(404).end());
923
+ const boundary = createBrowserCapabilityBoundary(options, "Shell", "X-Cascade-Shell-Capability");
924
+ router.use(boundary.guardOrigin);
925
+ router.options("{*path}", boundary.preflight);
926
+ router.get("/capability", boundary.issueCapability);
927
+ router.post("/", boundary.requireCapability, json2({ limit: "20mb" }), async (req, res) => {
928
+ const controller = new AbortController();
929
+ const cancel = () => controller.abort();
930
+ req.once("aborted", cancel);
931
+ res.once("close", () => {
932
+ if (!res.writableEnded) cancel();
933
+ });
934
+ try {
935
+ const body = parseBody(req.body);
936
+ const result = await shell.run(body.command, body.args, { ...body.options, signal: controller.signal });
937
+ if (result.code !== 0) {
938
+ res.status(500).json(errorPayload(new ShellProcessError("nonzero", result, result.stderr || `Command exited with code ${result.code}`)));
939
+ } else {
940
+ res.json({ ok: true, ...result });
941
+ }
942
+ } catch (error) {
943
+ if (error instanceof ShellRequestError) {
944
+ res.status(error.code === "COMMAND_NOT_ALLOWED" ? 403 : 400).json({ ok: false, error: error.message, code: error.code });
945
+ } else if (error instanceof ShellProcessError) {
946
+ res.status(error.kind === "cancelled" ? 499 : 500).json(errorPayload(error));
947
+ } else {
948
+ res.status(500).json({ ok: false, error: "Shell execution failed" });
949
+ }
950
+ }
951
+ });
952
+ router.use((error, _req, res, next) => {
953
+ if (error?.type === "entity.too.large" || error?.type === "entity.parse.failed") {
954
+ res.status(error.status ?? 400).json({ ok: false, error: error.message, type: error.type });
955
+ } else next(error);
956
+ });
957
+ return router;
958
+ }
959
+ function parseBody(body) {
960
+ if (!body || typeof body !== "object" || typeof body.command !== "string") throw new ShellRequestError("INVALID_REQUEST", "body must include { command: string }");
961
+ if (body.args !== void 0 && !Array.isArray(body.args)) throw new ShellRequestError("INVALID_REQUEST", "args must be an array");
962
+ return { command: body.command, args: body.args ?? [], options: { cwd: body.cwd, timeout: body.timeout, stdin: body.stdin, env: body.env } };
963
+ }
964
+ function errorPayload(error) {
965
+ return { ok: false, error: error.message, kind: error.kind, ...error.result };
966
+ }
967
+
968
+ // server/src/routes/agent.ts
969
+ import { Router as Router7, json as json3 } from "express";
970
+
971
+ // server/src/agent/session.ts
972
+ import { spawn } from "node:child_process";
973
+
974
+ // server/src/agent/eventBuffer.ts
975
+ var MAX_BUFFERED_EVENTS = 2e3;
976
+ var MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
977
+ function weigh(event) {
978
+ const text = event?.text;
979
+ return (typeof text === "string" ? text.length : 0) + 64;
980
+ }
981
+ var AgentEventBuffer = class {
982
+ constructor(maxEvents = MAX_BUFFERED_EVENTS, maxBytes = MAX_BUFFERED_BYTES) {
983
+ this.maxEvents = maxEvents;
984
+ this.maxBytes = maxBytes;
985
+ this.entries = [];
986
+ this.bytes = 0;
987
+ this.next = 0;
988
+ this.lost = 0;
989
+ }
990
+ /** The sequence number the next pushed event will carry. */
991
+ get nextSeq() {
992
+ return this.next;
993
+ }
994
+ /** The sequence number of the oldest event still held. */
995
+ get firstSeq() {
996
+ return this.entries[0]?.seq ?? this.next;
997
+ }
998
+ get size() {
999
+ return this.entries.length;
1000
+ }
1001
+ /** How many events have been evicted over the life of this buffer. */
1002
+ get droppedTotal() {
1003
+ return this.lost;
1004
+ }
1005
+ push(event) {
1006
+ const seq = this.next++;
1007
+ this.entries.push({ seq, event });
1008
+ this.bytes += weigh(event);
1009
+ while (this.entries.length > 1 && (this.entries.length > this.maxEvents || this.bytes > this.maxBytes)) {
1010
+ const evicted = this.entries.shift();
1011
+ if (!evicted) break;
1012
+ this.bytes -= weigh(evicted.event);
1013
+ this.lost += 1;
1014
+ }
1015
+ return seq;
1016
+ }
1017
+ replay(since = 0) {
1018
+ const from = Math.max(0, Math.floor(Number.isFinite(since) ? since : 0));
1019
+ const start2 = this.firstSeq;
1020
+ return {
1021
+ events: this.entries.filter((entry) => entry.seq >= from).map((entry) => entry.event),
1022
+ dropped: Math.max(0, Math.min(start2, this.next) - from),
1023
+ nextSeq: this.next
1024
+ };
1025
+ }
1026
+ };
1027
+
1028
+ // server/src/agent/allowlist.ts
1029
+ import fs6 from "node:fs";
1030
+ import os2 from "node:os";
1031
+ import path6 from "node:path";
1032
+ var ALIAS = /^[A-Za-z0-9._-]+$/;
1033
+ var AgentRequestError = class extends Error {
1034
+ constructor(code, message2) {
1035
+ super(message2);
1036
+ this.code = code;
1037
+ this.name = "AgentRequestError";
1038
+ }
1039
+ };
1040
+ function allowlistHint(root, alias) {
1041
+ return `Add this under "commands" in ${root}/cascade.json: "${alias}": "<path>"`;
1042
+ }
1043
+ function readManifest(root) {
1044
+ try {
1045
+ return JSON.parse(fs6.readFileSync(path6.join(root, "cascade.json"), "utf8"));
1046
+ } catch {
1047
+ return {};
1048
+ }
1049
+ }
1050
+ function readCommands(root) {
1051
+ const raw = readManifest(root);
1052
+ return raw?.commands && typeof raw.commands === "object" && !Array.isArray(raw.commands) ? raw.commands : {};
1053
+ }
1054
+ var DEFAULT_AGENT_ARGS = {
1055
+ // A headless Claude cannot prompt, so without this it runs and then declines
1056
+ // to edit anything — the console would launch successfully and do nothing,
1057
+ // which is the worst of both.
1058
+ claude: ["--permission-mode", "acceptEdits"]
1059
+ };
1060
+ function agentExtraArgs(root, alias) {
1061
+ const raw = readManifest(root);
1062
+ const entry = raw?.agent?.[alias];
1063
+ const declared = entry && typeof entry === "object" && !Array.isArray(entry) && "args" in entry ? entry.args : void 0;
1064
+ if (Array.isArray(declared)) {
1065
+ return declared.filter((value) => typeof value === "string" && !value.includes("\0"));
1066
+ }
1067
+ return [...DEFAULT_AGENT_ARGS[alias] ?? []];
1068
+ }
1069
+ function executableFile(candidate) {
1070
+ try {
1071
+ fs6.accessSync(candidate, fs6.constants.X_OK);
1072
+ return fs6.statSync(candidate).isFile() ? fs6.realpathSync(candidate) : null;
1073
+ } catch {
1074
+ return null;
1075
+ }
1076
+ }
1077
+ function resolveExecutable(target) {
1078
+ if (typeof target !== "string" || !target || target.includes("\0")) return null;
1079
+ const expanded = target.replace(/^~(?=\/|$)/, os2.homedir());
1080
+ if (path6.isAbsolute(expanded)) return executableFile(expanded);
1081
+ if (!/[\\/]/.test(expanded)) {
1082
+ for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
1083
+ const found = executableFile(path6.join(dir, expanded));
1084
+ if (found) return found;
1085
+ }
1086
+ return null;
1087
+ }
1088
+ return null;
1089
+ }
1090
+ function agentAvailability(root, alias) {
1091
+ if (!ALIAS.test(alias)) {
1092
+ return { alias, configured: false, resolved: false, hint: "Agent name must be an allowlist alias" };
1093
+ }
1094
+ const commands = readCommands(root);
1095
+ if (!(alias in commands)) {
1096
+ return { alias, configured: false, resolved: false, hint: allowlistHint(root, alias) };
1097
+ }
1098
+ const executable = resolveExecutable(commands[alias]);
1099
+ return {
1100
+ alias,
1101
+ configured: true,
1102
+ resolved: Boolean(executable),
1103
+ hint: executable ? null : `Configured command "${alias}" is not available \u2014 check the path in ${root}/cascade.json`
1104
+ };
1105
+ }
1106
+ function requireExecutable(root, alias) {
1107
+ if (!ALIAS.test(alias)) throw new AgentRequestError("INVALID_REQUEST", "agent must be an allowlist alias");
1108
+ const commands = readCommands(root);
1109
+ if (!(alias in commands)) {
1110
+ throw new AgentRequestError("COMMAND_NOT_ALLOWED", `Command "${alias}" is not allowed. ${allowlistHint(root, alias)}`);
1111
+ }
1112
+ const executable = resolveExecutable(commands[alias]);
1113
+ if (!executable) throw new AgentRequestError("MISSING_EXECUTABLE", `Configured command "${alias}" is not available`);
1114
+ return executable;
1115
+ }
1116
+
1117
+ // server/src/agent/session.ts
1118
+ var DEFAULT_TIMEOUT_MS = 9e5;
1119
+ var MAX_PROMPT_BYTES = 64 * 1024;
1120
+ function childEnvironment() {
1121
+ const env = { ...process.env };
1122
+ for (const key2 of Object.keys(env)) {
1123
+ if (key2.startsWith("TELEGRAM_") || key2 === "CLAUDE_CHANNELS") delete env[key2];
1124
+ }
1125
+ return env;
1126
+ }
1127
+ function claudeArgs(prompt, sessionId) {
1128
+ const args = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
1129
+ if (sessionId) args.push("--resume", sessionId);
1130
+ return args;
1131
+ }
1132
+ function argsFor(agent, prompt, sessionId, extra = []) {
1133
+ if (agent === "codex") {
1134
+ const base2 = sessionId ? ["exec", "resume", "--json"] : ["exec", "--json"];
1135
+ const flags = codexExtraArgs(extra);
1136
+ return sessionId ? [...base2, ...flags, sessionId, prompt] : [...base2, ...flags, prompt];
1137
+ }
1138
+ const base = claudeArgs(prompt, sessionId);
1139
+ return [...base, ...extra];
1140
+ }
1141
+ function codexExtraArgs(extra) {
1142
+ const result = [];
1143
+ for (let index = 0; index < extra.length; index += 1) {
1144
+ const arg = extra[index];
1145
+ if (arg === "--sandbox" && extra[index + 1]) {
1146
+ result.push("-c", `sandbox_mode=${JSON.stringify(extra[index + 1])}`);
1147
+ index += 1;
1148
+ } else if (arg.startsWith("--sandbox=")) {
1149
+ result.push("-c", `sandbox_mode=${JSON.stringify(arg.slice("--sandbox=".length))}`);
1150
+ } else {
1151
+ result.push(arg);
1152
+ }
1153
+ }
1154
+ return result;
1155
+ }
1156
+ function sessionIdFrom(line) {
1157
+ const trimmed = line.trim();
1158
+ if (!trimmed.startsWith("{")) return null;
1159
+ try {
1160
+ const value = JSON.parse(trimmed);
1161
+ const id = value?.session_id ?? value?.sessionId ?? (value?.type === "thread.started" ? value?.thread_id : null);
1162
+ return typeof id === "string" && id ? id : null;
1163
+ } catch {
1164
+ return null;
1165
+ }
1166
+ }
1167
+ var LiveRun = class {
1168
+ constructor(agent, sketch) {
1169
+ this.agent = agent;
1170
+ this.sketch = sketch;
1171
+ this.buffer = new AgentEventBuffer();
1172
+ this.listeners = /* @__PURE__ */ new Set();
1173
+ this.startedAt = Date.now();
1174
+ this.child = null;
1175
+ this.running = true;
1176
+ this.endedAt = null;
1177
+ }
1178
+ emit(event) {
1179
+ this.buffer.push(event);
1180
+ for (const listener of [...this.listeners]) {
1181
+ try {
1182
+ listener(event);
1183
+ } catch {
1184
+ this.listeners.delete(listener);
1185
+ }
1186
+ }
1187
+ }
1188
+ /** SIGTERM the whole process group — the agent spawns children of its own,
1189
+ * and killing only the parent leaves those behind. */
1190
+ stop() {
1191
+ const child = this.child;
1192
+ if (!child?.pid) return;
1193
+ try {
1194
+ if (process.platform === "win32") child.kill("SIGTERM");
1195
+ else process.kill(-child.pid, "SIGTERM");
1196
+ } catch {
1197
+ child.kill("SIGTERM");
1198
+ }
1199
+ }
1200
+ };
1201
+ var liveRuns = /* @__PURE__ */ new Set();
1202
+ var reaperInstalled = false;
1203
+ function reapAll() {
1204
+ for (const run of [...liveRuns]) run.stop();
1205
+ }
1206
+ function ensureReaper() {
1207
+ if (reaperInstalled) return;
1208
+ reaperInstalled = true;
1209
+ process.on("exit", reapAll);
1210
+ for (const signal of ["SIGINT", "SIGTERM"]) {
1211
+ if (process.listenerCount(signal) > 0) continue;
1212
+ process.on(signal, () => {
1213
+ reapAll();
1214
+ process.removeAllListeners(signal);
1215
+ process.kill(process.pid, signal);
1216
+ });
1217
+ }
1218
+ }
1219
+ var AgentSessions = class {
1220
+ constructor(project) {
1221
+ this.project = project;
1222
+ this.states = /* @__PURE__ */ new Map();
1223
+ this.changeListeners = /* @__PURE__ */ new Set();
1224
+ }
1225
+ /**
1226
+ * Called whenever a run starts, ends or is forgotten. This is what lets the
1227
+ * panel hold one quiet stream instead of asking for the status every second:
1228
+ * the only thing it was polling for was a run it did not start appearing.
1229
+ */
1230
+ onChange(listener) {
1231
+ this.changeListeners.add(listener);
1232
+ return () => this.changeListeners.delete(listener);
1233
+ }
1234
+ notifyChange() {
1235
+ for (const listener of [...this.changeListeners]) {
1236
+ try {
1237
+ listener();
1238
+ } catch {
1239
+ this.changeListeners.delete(listener);
1240
+ }
1241
+ }
1242
+ }
1243
+ availability(agent) {
1244
+ return agentAvailability(this.project.root, agent);
1245
+ }
1246
+ /** What the panel shows before anything is typed: which agents this sketch
1247
+ * may launch, for the ones it may not exactly what to add, and — the reason
1248
+ * a reloaded panel does not strand anything — whether a run is still live. */
1249
+ status(agents = ["claude", "codex"], sketch) {
1250
+ const sessions = [];
1251
+ for (const [id, state] of this.states) {
1252
+ const run = state.run;
1253
+ if (!run) continue;
1254
+ if (sketch && run.sketch !== sketch) continue;
1255
+ void id;
1256
+ sessions.push({
1257
+ agent: run.agent,
1258
+ sketch: run.sketch,
1259
+ running: run.running,
1260
+ sessionId: state.sessionId,
1261
+ startedAt: run.startedAt,
1262
+ endedAt: run.endedAt,
1263
+ buffered: run.buffer.size,
1264
+ dropped: run.buffer.droppedTotal,
1265
+ nextSeq: run.buffer.nextSeq
1266
+ });
1267
+ }
1268
+ return { root: this.project.root, agents: agents.map((agent) => this.availability(agent)), sessions };
1269
+ }
1270
+ sessionId(agent, sketch) {
1271
+ return this.states.get(key(agent, sketch))?.sessionId ?? null;
1272
+ }
1273
+ isRunning(agent, sketch) {
1274
+ return this.states.get(key(agent, sketch))?.run?.running === true;
1275
+ }
1276
+ /** Drop the conversation for one sketch without touching anything on disk.
1277
+ * Refuses while a run is live, because forgetting the session id mid-run
1278
+ * would silently start the next prompt as a fresh conversation. */
1279
+ reset(agent, sketch) {
1280
+ const state = this.states.get(key(agent, sketch));
1281
+ if (!state) return;
1282
+ if (state.run?.running) throw new AgentRequestError("BUSY", `The ${agent} session for ${sketch} is still working`);
1283
+ state.sessionId = null;
1284
+ state.run = null;
1285
+ this.notifyChange();
1286
+ }
1287
+ /** Stop a live run. This is the only thing that kills an agent now: a reader
1288
+ * going away no longer does, which is the whole point of the change. */
1289
+ cancel(agent, sketch) {
1290
+ const run = this.states.get(key(agent, sketch))?.run;
1291
+ if (!run?.running) return false;
1292
+ run.stop();
1293
+ return true;
1294
+ }
1295
+ /**
1296
+ * Join whatever is happening for this sketch — running, finished, or nothing.
1297
+ *
1298
+ * Never refuses with BUSY. BUSY exists to stop two prompts colliding on one
1299
+ * conversation; a second *reader* is the opposite of a collision, so attach is
1300
+ * always allowed and any number of panels may hold one open.
1301
+ *
1302
+ * Resolves when the run ends or when the caller detaches, whichever comes
1303
+ * first. Detaching does not touch the child.
1304
+ */
1305
+ attach(request) {
1306
+ const { agent, sketch, sink } = request;
1307
+ const state = this.states.get(key(agent, sketch));
1308
+ const run = state?.run ?? null;
1309
+ const since = Math.max(0, Math.floor(request.since ?? 0));
1310
+ if (!run) {
1311
+ sink({ type: "attached", agent, sketch, running: false, sessionId: state?.sessionId ?? null, dropped: 0, replayed: 0, since });
1312
+ return Promise.resolve();
1313
+ }
1314
+ const replay = run.buffer.replay(since);
1315
+ sink({
1316
+ type: "attached",
1317
+ agent,
1318
+ sketch,
1319
+ running: run.running,
1320
+ sessionId: state?.sessionId ?? null,
1321
+ dropped: replay.dropped,
1322
+ replayed: replay.events.length,
1323
+ since
1324
+ });
1325
+ for (const event of replay.events) sink(event);
1326
+ if (!run.running) return Promise.resolve();
1327
+ return new Promise((resolve) => {
1328
+ const detach = () => {
1329
+ run.listeners.delete(listener);
1330
+ request.signal?.removeEventListener("abort", detach);
1331
+ resolve();
1332
+ };
1333
+ const listener = (event) => {
1334
+ sink(event);
1335
+ if (event.type === "exit" || event.type === "error") detach();
1336
+ };
1337
+ run.listeners.add(listener);
1338
+ request.signal?.addEventListener("abort", detach, { once: true });
1339
+ if (request.signal?.aborted) detach();
1340
+ });
1341
+ }
1342
+ async prompt(request) {
1343
+ const prompt = request.prompt;
1344
+ if (typeof prompt !== "string" || !prompt.trim()) throw new AgentRequestError("INVALID_REQUEST", "prompt must be a non-empty string");
1345
+ if (Buffer.byteLength(prompt) > MAX_PROMPT_BYTES) throw new AgentRequestError("INVALID_REQUEST", "prompt exceeds 64 KiB");
1346
+ if (typeof request.sketch !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}\.cascade$/.test(request.sketch)) {
1347
+ throw new AgentRequestError("INVALID_REQUEST", "sketch must be a .cascade filename in this project");
1348
+ }
1349
+ const executable = requireExecutable(this.project.root, request.agent);
1350
+ const id = key(request.agent, request.sketch);
1351
+ const state = this.states.get(id) ?? { sessionId: null, run: null };
1352
+ this.states.set(id, state);
1353
+ if (state.run?.running) throw new AgentRequestError("BUSY", `The ${request.agent} session for ${request.sketch} is still working`);
1354
+ const run = new LiveRun(request.agent, request.sketch);
1355
+ state.run = run;
1356
+ liveRuns.add(run);
1357
+ ensureReaper();
1358
+ const reading = this.attach({
1359
+ agent: request.agent,
1360
+ sketch: request.sketch,
1361
+ sink: request.sink,
1362
+ signal: request.signal,
1363
+ since: 0
1364
+ });
1365
+ this.launch(executable, request, state, run);
1366
+ this.notifyChange();
1367
+ await reading;
1368
+ }
1369
+ launch(executable, request, state, run) {
1370
+ const resumed = Boolean(state.sessionId);
1371
+ const args = argsFor(request.agent, request.prompt, state.sessionId, agentExtraArgs(this.project.root, request.agent));
1372
+ run.emit({ type: "started", agent: request.agent, sketch: request.sketch, resumed });
1373
+ const child = spawn(executable, args, {
1374
+ cwd: this.project.root,
1375
+ env: childEnvironment(),
1376
+ shell: false,
1377
+ detached: process.platform !== "win32",
1378
+ stdio: ["pipe", "pipe", "pipe"]
1379
+ });
1380
+ run.child = child;
1381
+ let timedOut = false;
1382
+ let settled = false;
1383
+ let carry = "";
1384
+ const timer = setTimeout(() => {
1385
+ timedOut = true;
1386
+ run.stop();
1387
+ }, request.timeout ?? DEFAULT_TIMEOUT_MS);
1388
+ const finish = (event) => {
1389
+ if (settled) return;
1390
+ settled = true;
1391
+ clearTimeout(timer);
1392
+ run.running = false;
1393
+ run.endedAt = Date.now();
1394
+ liveRuns.delete(run);
1395
+ run.emit(event);
1396
+ run.listeners.clear();
1397
+ this.notifyChange();
1398
+ };
1399
+ const takeLine = (line) => {
1400
+ if (!line.trim()) return;
1401
+ const found = sessionIdFrom(line);
1402
+ if (found && found !== state.sessionId) {
1403
+ state.sessionId = found;
1404
+ run.emit({ type: "session", sessionId: found });
1405
+ }
1406
+ run.emit({ type: "stdout", text: line });
1407
+ };
1408
+ child.stdout.setEncoding("utf8");
1409
+ child.stdout.on("data", (chunk) => {
1410
+ carry += chunk;
1411
+ const lines = carry.split("\n");
1412
+ carry = lines.pop() ?? "";
1413
+ for (const line of lines) takeLine(line);
1414
+ });
1415
+ child.stderr.setEncoding("utf8");
1416
+ child.stderr.on("data", (chunk) => run.emit({ type: "stderr", text: String(chunk) }));
1417
+ child.once("error", (error) => {
1418
+ finish({ type: "error", message: `Unable to spawn ${request.agent}: ${error.message}`, code: "SPAWN_FAILED" });
1419
+ });
1420
+ child.once("close", (code) => {
1421
+ if (carry.trim()) takeLine(carry);
1422
+ carry = "";
1423
+ finish({ type: "exit", code, timedOut, cancelled: !timedOut && code !== 0 && wasSignalled(child) });
1424
+ });
1425
+ child.stdin.end();
1426
+ }
1427
+ };
1428
+ function wasSignalled(child) {
1429
+ return child.signalCode !== null;
1430
+ }
1431
+ function key(agent, sketch) {
1432
+ return `${agent} ${sketch}`;
1433
+ }
1434
+
1435
+ // server/src/routes/agent.ts
1436
+ var BODY_LIMIT2 = "1mb";
1437
+ var WATCH_KEEPALIVE_MS = 3e4;
1438
+ function createAgentRouter(project, security) {
1439
+ const router = Router7();
1440
+ if (!allowsSensitiveCapabilities(security)) return router.use((_req, res) => res.status(404).end());
1441
+ const boundary = createBrowserCapabilityBoundary(security, "Agent", "X-Cascade-Agent-Capability");
1442
+ const sessions = new AgentSessions(project);
1443
+ router.use(boundary.guardOrigin);
1444
+ router.options("{*path}", boundary.preflight);
1445
+ router.get("/capability", boundary.issueCapability);
1446
+ router.get("/status", boundary.requireCapability, (req, res) => {
1447
+ const sketch = typeof req.query?.sketch === "string" ? req.query.sketch : void 0;
1448
+ res.json({ ok: true, ...sessions.status(void 0, sketch) });
1449
+ });
1450
+ router.get("/watch", boundary.requireCapability, (req, res) => {
1451
+ const sketch = typeof req.query?.sketch === "string" ? req.query.sketch : void 0;
1452
+ res.status(200).set({
1453
+ "Content-Type": "application/x-ndjson; charset=utf-8",
1454
+ "Cache-Control": "no-store, no-transform",
1455
+ "X-Accel-Buffering": "no"
1456
+ });
1457
+ res.flushHeaders?.();
1458
+ const send = () => {
1459
+ if (res.writableEnded) return;
1460
+ res.write(`${JSON.stringify({ type: "status", sessions: sessions.status([], sketch).sessions })}
1461
+ `);
1462
+ };
1463
+ send();
1464
+ const unsubscribe = sessions.onChange(send);
1465
+ const keepAlive = setInterval(() => {
1466
+ if (!res.writableEnded) res.write("\n");
1467
+ }, WATCH_KEEPALIVE_MS);
1468
+ res.once("close", () => {
1469
+ clearInterval(keepAlive);
1470
+ unsubscribe();
1471
+ });
1472
+ });
1473
+ router.post("/reset", boundary.requireCapability, json3({ limit: BODY_LIMIT2 }), (req, res) => {
1474
+ const agent = typeof req.body?.agent === "string" ? req.body.agent : "claude";
1475
+ const sketch = typeof req.body?.sketch === "string" ? req.body.sketch : "";
1476
+ try {
1477
+ sessions.reset(agent, sketch);
1478
+ res.json({ ok: true });
1479
+ } catch (error) {
1480
+ if (error instanceof AgentRequestError) res.status(409).json({ ok: false, error: error.message, code: error.code });
1481
+ else throw error;
1482
+ }
1483
+ });
1484
+ router.post("/cancel", boundary.requireCapability, json3({ limit: BODY_LIMIT2 }), (req, res) => {
1485
+ const agent = typeof req.body?.agent === "string" ? req.body.agent : "claude";
1486
+ const sketch = typeof req.body?.sketch === "string" ? req.body.sketch : "";
1487
+ res.json({ ok: true, cancelled: sessions.cancel(agent, sketch) });
1488
+ });
1489
+ router.post("/attach", boundary.requireCapability, json3({ limit: BODY_LIMIT2 }), async (req, res) => {
1490
+ const agent = typeof req.body?.agent === "string" ? req.body.agent : "claude";
1491
+ const sketch = typeof req.body?.sketch === "string" ? req.body.sketch : "";
1492
+ const since = Number.isFinite(req.body?.since) ? Number(req.body.since) : 0;
1493
+ const controller = new AbortController();
1494
+ res.once("close", () => {
1495
+ if (!res.writableEnded) controller.abort();
1496
+ });
1497
+ const write = streamWriter(res);
1498
+ await sessions.attach({ agent, sketch, since, sink: write, signal: controller.signal });
1499
+ if (!res.writableEnded) res.end();
1500
+ });
1501
+ router.post("/prompt", boundary.requireCapability, json3({ limit: BODY_LIMIT2 }), async (req, res) => {
1502
+ const agent = typeof req.body?.agent === "string" ? req.body.agent : "claude";
1503
+ const sketch = typeof req.body?.sketch === "string" ? req.body.sketch : "";
1504
+ const prompt = typeof req.body?.prompt === "string" ? req.body.prompt : "";
1505
+ const controller = new AbortController();
1506
+ res.once("close", () => {
1507
+ if (!res.writableEnded) controller.abort();
1508
+ });
1509
+ const write = streamWriter(res);
1510
+ let streaming = false;
1511
+ const send = (event) => {
1512
+ streaming = true;
1513
+ write(event);
1514
+ };
1515
+ try {
1516
+ await sessions.prompt({ agent, sketch, prompt, sink: send, signal: controller.signal });
1517
+ if (!res.writableEnded) res.end();
1518
+ } catch (error) {
1519
+ const payload = error instanceof AgentRequestError ? { ok: false, error: error.message, code: error.code } : { ok: false, error: error instanceof Error ? error.message : "Agent launch failed" };
1520
+ if (streaming) {
1521
+ send({ type: "error", message: payload.error, code: "code" in payload ? payload.code : void 0 });
1522
+ if (!res.writableEnded) res.end();
1523
+ return;
1524
+ }
1525
+ const status = error instanceof AgentRequestError ? { COMMAND_NOT_ALLOWED: 403, MISSING_EXECUTABLE: 409, BUSY: 409, INVALID_REQUEST: 400 }[error.code] : 500;
1526
+ res.status(status).json({ ...payload, hint: hintFor(error, project) });
1527
+ }
1528
+ });
1529
+ router.use((error, _req, res, next) => {
1530
+ if (error?.type === "entity.too.large" || error?.type === "entity.parse.failed") {
1531
+ res.status(error.status ?? 400).json({ ok: false, error: error.message, type: error.type });
1532
+ } else next(error);
1533
+ });
1534
+ return router;
1535
+ }
1536
+ function streamWriter(res) {
1537
+ let open = false;
1538
+ return (event) => {
1539
+ if (!open) {
1540
+ res.status(200).set({
1541
+ "Content-Type": "application/x-ndjson; charset=utf-8",
1542
+ "Cache-Control": "no-store, no-transform",
1543
+ "X-Accel-Buffering": "no"
1544
+ });
1545
+ res.flushHeaders?.();
1546
+ open = true;
1547
+ }
1548
+ if (!res.writableEnded) res.write(`${JSON.stringify(event)}
1549
+ `);
1550
+ };
1551
+ }
1552
+ function hintFor(error, project) {
1553
+ if (!(error instanceof AgentRequestError) || error.code !== "COMMAND_NOT_ALLOWED") return null;
1554
+ return `${project.root}/cascade.json`;
1555
+ }
1556
+
1557
+ // server/src/routes/graphWatch.ts
1558
+ import { Router as Router8 } from "express";
1559
+
1560
+ // server/src/graphWatch.ts
1561
+ var GRAPH_WATCH_DEBOUNCE_MS = 150;
1562
+ function graphFileFor(relative) {
1563
+ const segments = String(relative).split(/[\\/]/).filter(Boolean);
1564
+ if (segments.length !== 1) return null;
1565
+ const name = segments[0];
1566
+ if (!name || name.startsWith(".")) return null;
1567
+ if (!name.endsWith(".cascade")) return null;
1568
+ return name;
1569
+ }
1570
+ function createGraphWatcher(project, seams = {}) {
1571
+ const listeners = /* @__PURE__ */ new Set();
1572
+ let watcher = null;
1573
+ const debounce = createDebouncer(seams.clock ?? systemClock, GRAPH_WATCH_DEBOUNCE_MS, (filename) => {
1574
+ for (const listener of listeners) listener(filename);
1575
+ });
1576
+ try {
1577
+ watcher = (seams.watch ?? systemWatch)(project.root, { recursive: false }, (_event, filename) => {
1578
+ if (!filename) return;
1579
+ const graph = graphFileFor(String(filename));
1580
+ if (graph) debounce.push(graph);
1581
+ });
1582
+ watcher.on?.("error", (error) => console.warn("[graph watch] stopped watching", project.root, error));
1583
+ } catch (error) {
1584
+ console.warn("[graph watch] could not watch", project.root, error);
1585
+ }
1586
+ return {
1587
+ subscribe(listener) {
1588
+ listeners.add(listener);
1589
+ return () => listeners.delete(listener);
1590
+ },
1591
+ close() {
1592
+ debounce.cancel();
1593
+ watcher?.close();
1594
+ watcher = null;
1595
+ listeners.clear();
1596
+ }
1597
+ };
1598
+ }
1599
+
1600
+ // server/src/routes/graphWatch.ts
1601
+ function createGraphWatchRouter(project) {
1602
+ const router = Router8();
1603
+ let watcher = null;
1604
+ router.get("/", (req, res) => {
1605
+ res.set({
1606
+ "Content-Type": "text/event-stream",
1607
+ "Cache-Control": "no-cache, no-transform",
1608
+ Connection: "keep-alive",
1609
+ // Without this a proxy can hold the stream in a buffer and deliver every
1610
+ // event at once when the request finally ends.
1611
+ "X-Accel-Buffering": "no"
1612
+ });
1613
+ res.flushHeaders?.();
1614
+ res.write("retry: 2000\n\n");
1615
+ watcher ??= createGraphWatcher(project);
1616
+ const unsubscribe = watcher.subscribe((filename) => {
1617
+ res.write(`event: graph-changed
1618
+ data: ${JSON.stringify({ file: filename })}
1619
+
1620
+ `);
1621
+ });
1622
+ const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 3e4);
1623
+ req.on("close", () => {
1624
+ clearInterval(keepAlive);
1625
+ unsubscribe();
1626
+ });
1627
+ });
1628
+ return router;
1629
+ }
1630
+
1631
+ // server/src/routes/projectSettings.ts
1632
+ import { Router as Router9, json as json4 } from "express";
1633
+ function createProjectSettingsRouter(project, security, credentials = new CredentialStore()) {
1634
+ const router = Router9();
1635
+ if (!allowsSensitiveCapabilities(security)) return router.use((_req, res) => res.status(404).end());
1636
+ const boundary = createBrowserCapabilityBoundary(security, "Project settings", "X-Cascade-Project-Capability");
1637
+ router.use(boundary.guardOrigin);
1638
+ router.options("{*path}", boundary.preflight);
1639
+ router.get("/capability", boundary.issueCapability);
1640
+ router.get("/", boundary.requireCapability, (_req, res) => {
1641
+ try {
1642
+ res.json(publicProjectState(project, credentials));
1643
+ } catch (error) {
1644
+ res.status(500).json({ ok: false, error: message(error) });
1645
+ }
1646
+ });
1647
+ router.put("/", boundary.requireCapability, json4({ limit: "1mb" }), (req, res) => {
1648
+ try {
1649
+ writeProjectManifest(project.root, req.body);
1650
+ res.json(publicProjectState(project, credentials));
1651
+ } catch (error) {
1652
+ res.status(400).json({ ok: false, error: message(error) });
1653
+ }
1654
+ });
1655
+ return router;
1656
+ }
1657
+ function publicProjectState(project, credentials) {
1658
+ const manifest = readProjectManifest(project.root);
1659
+ const requiredCredentials = manifest.credentials.map((name) => ({ name, set: credentials.has(name) }));
1660
+ return { ok: true, manifest, requiredCredentials, missingCredentials: requiredCredentials.filter((item) => !item.set).map((item) => item.name) };
1661
+ }
1662
+ function message(error) {
1663
+ return error instanceof Error ? error.message : String(error);
1664
+ }
1665
+
1666
+ // server/src/routes/net.ts
1667
+ import { Router as Router10, json as json5 } from "express";
1668
+ var ALLOWED_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
1669
+ var BLOCKED_HEADER = /^(?:authorization|cookie|host|origin|referer|proxy-|sec-)/i;
1670
+ function createNetRouter(project, security, credentials = new CredentialStore()) {
1671
+ const router = Router10();
1672
+ if (!allowsSensitiveCapabilities(security)) return router.use((_req, res) => res.status(404).end());
1673
+ const boundary = createBrowserCapabilityBoundary(security, "Authorized fetch", "X-Cascade-Net-Capability");
1674
+ router.use(boundary.guardOrigin);
1675
+ router.options("{*path}", boundary.preflight);
1676
+ router.get("/capability", boundary.issueCapability);
1677
+ router.post("/", boundary.requireCapability, json5({ limit: "16mb" }), async (req, res) => {
1678
+ try {
1679
+ res.json(await authorizedProxyRequest(project, credentials, req.body));
1680
+ } catch (error) {
1681
+ const status = typeof error?.status === "number" ? error.status : 400;
1682
+ res.status(status).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
1683
+ }
1684
+ });
1685
+ return router;
1686
+ }
1687
+ async function authorizedProxyRequest(project, credentials, input, fetcher = fetch) {
1688
+ const request = parseRequest2(input);
1689
+ const manifest = readProjectManifest(project.root);
1690
+ if (!manifest.credentials.includes(request.credential)) throw statusError(403, `Credential ${JSON.stringify(request.credential)} is not declared in cascade.json`);
1691
+ const definition = credentials.get(request.credential);
1692
+ if (!definition) throw statusError(424, `Credential ${JSON.stringify(request.credential)} is not set`);
1693
+ const secret = secretValue(definition);
1694
+ if (!secret || !definition.header || definition.hosts.length === 0) throw statusError(400, `Credential ${JSON.stringify(request.credential)} needs field/header/hosts metadata for browser proxy use`);
1695
+ const url = new URL(request.url);
1696
+ if (url.protocol !== "https:" || url.username || url.password || !definition.hosts.includes(url.hostname.toLowerCase())) {
1697
+ throw statusError(403, `Credential ${JSON.stringify(request.credential)} is not authorized for ${url.hostname}`);
1698
+ }
1699
+ const headers = new Headers(request.headers);
1700
+ for (const name of [...headers.keys()]) if (BLOCKED_HEADER.test(name)) headers.delete(name);
1701
+ headers.set(definition.header, definition.scheme ? `${definition.scheme} ${secret}` : secret);
1702
+ const response = await fetcher(url, { method: request.method, headers, body: request.body, redirect: "manual" });
1703
+ const body = await response.text();
1704
+ return {
1705
+ ok: true,
1706
+ status: response.status,
1707
+ statusText: response.statusText,
1708
+ headers: { "content-type": response.headers.get("content-type") ?? "text/plain" },
1709
+ body
1710
+ };
1711
+ }
1712
+ function parseRequest2(input) {
1713
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("body must be an object");
1714
+ const value = input;
1715
+ if (typeof value.credential !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.credential)) throw new Error("credential is invalid");
1716
+ if (typeof value.url !== "string") throw new Error("url is required");
1717
+ const method = typeof value.method === "string" ? value.method.toUpperCase() : "GET";
1718
+ if (!ALLOWED_METHODS.has(method)) throw new Error("method is not supported");
1719
+ if (value.body !== void 0 && typeof value.body !== "string") throw new Error("body must be a string");
1720
+ const headers = value.headers === void 0 ? {} : value.headers;
1721
+ if (!headers || typeof headers !== "object" || Array.isArray(headers) || Object.values(headers).some((item) => typeof item !== "string")) throw new Error("headers must be an object of strings");
1722
+ return { credential: value.credential, url: value.url, method, headers, ...typeof value.body === "string" ? { body: value.body } : {} };
1723
+ }
1724
+ function statusError(status, message2) {
1725
+ return Object.assign(new Error(message2), { status });
1726
+ }
1727
+
1728
+ // server/src/routes/media.ts
1729
+ import express, { Router as Router11 } from "express";
1730
+ import crypto from "crypto";
1731
+ import fs7 from "fs";
1732
+ import path7 from "path";
1733
+ var sharpModule = null;
1734
+ var sharpError = null;
1735
+ async function loadSharp() {
1736
+ if (sharpModule) return sharpModule;
1737
+ if (sharpError) throw new Error(sharpError);
1738
+ try {
1739
+ sharpModule = (await import("sharp")).default;
1740
+ return sharpModule;
1741
+ } catch (err) {
1742
+ sharpError = `Image transformation needs the "sharp" package, which failed to load: ${err instanceof Error ? err.message : String(err)}`;
1743
+ throw new Error(sharpError);
1744
+ }
1745
+ }
1746
+ var RASTER = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff", ".avif", ".gif"]);
1747
+ function readNpy(buffer) {
1748
+ if (buffer.length < 10 || buffer.toString("latin1", 0, 6) !== "\x93NUMPY") {
1749
+ throw new Error("not a .npy file");
1750
+ }
1751
+ const major = buffer[6];
1752
+ const headerLength = major >= 2 ? buffer.readUInt32LE(8) : buffer.readUInt16LE(8);
1753
+ const headerStart = major >= 2 ? 12 : 10;
1754
+ const header = buffer.toString("latin1", headerStart, headerStart + headerLength);
1755
+ const descr = header.match(/'descr':\s*'([^']+)'/)?.[1];
1756
+ const fortran = /'fortran_order':\s*True/.test(header);
1757
+ const shape = (header.match(/'shape':\s*\(([^)]*)\)/)?.[1] ?? "").split(",").map((part) => part.trim()).filter(Boolean).map(Number);
1758
+ if (!descr) throw new Error(".npy header has no dtype");
1759
+ if (fortran) throw new Error(".npy is Fortran-ordered; not supported");
1760
+ if (shape.length < 2) throw new Error(`.npy is ${shape.length}-dimensional; expected an image-shaped array`);
1761
+ const height = shape[0];
1762
+ const width = shape[1];
1763
+ const stride = shape.length > 2 ? shape.slice(2).reduce((a, b) => a * b, 1) : 1;
1764
+ const count = height * width * stride;
1765
+ const body = buffer.subarray(headerStart + headerLength);
1766
+ const readers = {
1767
+ "<f4": (o) => body.readFloatLE(o),
1768
+ "<f8": (o) => body.readDoubleLE(o),
1769
+ "|u1": (o) => body.readUInt8(o),
1770
+ "<u1": (o) => body.readUInt8(o),
1771
+ "|i1": (o) => body.readInt8(o),
1772
+ "<i2": (o) => body.readInt16LE(o),
1773
+ "<u2": (o) => body.readUInt16LE(o),
1774
+ "<i4": (o) => body.readInt32LE(o),
1775
+ "<u4": (o) => body.readUInt32LE(o),
1776
+ "|b1": (o) => body.readUInt8(o)
1777
+ };
1778
+ const sizes = {
1779
+ "<f4": 4,
1780
+ "<f8": 8,
1781
+ "|u1": 1,
1782
+ "<u1": 1,
1783
+ "|i1": 1,
1784
+ "<i2": 2,
1785
+ "<u2": 2,
1786
+ "<i4": 4,
1787
+ "<u4": 4,
1788
+ "|b1": 1
1789
+ };
1790
+ const read = readers[descr];
1791
+ const itemSize = sizes[descr];
1792
+ if (!read || !itemSize) throw new Error(`.npy dtype ${descr} not supported`);
1793
+ const data = new Float64Array(height * width);
1794
+ for (let i = 0; i < height * width; i++) {
1795
+ data[i] = read(i * stride * itemSize);
1796
+ }
1797
+ if (count * itemSize > body.length) throw new Error(".npy body is shorter than its shape");
1798
+ return { data, width, height };
1799
+ }
1800
+ function fieldToGrey(array) {
1801
+ let min = Infinity;
1802
+ let max = -Infinity;
1803
+ for (const value of array.data) {
1804
+ if (!Number.isFinite(value)) continue;
1805
+ if (value < min) min = value;
1806
+ if (value > max) max = value;
1807
+ }
1808
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
1809
+ min = 0;
1810
+ max = 1;
1811
+ }
1812
+ const span = max - min || 1;
1813
+ const pixels = Buffer.allocUnsafe(array.data.length);
1814
+ for (let i = 0; i < array.data.length; i++) {
1815
+ const value = array.data[i];
1816
+ pixels[i] = Number.isFinite(value) ? Math.round((value - min) / span * 255) : 0;
1817
+ }
1818
+ return { pixels, min, max };
1819
+ }
1820
+ var CACHE_DIR = ".cascade-cache";
1821
+ function contentAddressed(rel, body) {
1822
+ const hash = crypto.createHash("sha1").update(body).digest("hex").slice(0, 12);
1823
+ const dir = path7.posix.dirname(rel);
1824
+ const name = path7.posix.basename(rel);
1825
+ const [stem, ...rest] = name.split(".");
1826
+ const tail = rest.length ? `.${rest.join(".")}` : "";
1827
+ return path7.posix.join(dir, `${stem}.${hash}${tail}`);
1828
+ }
1829
+ var MIME = {
1830
+ ".png": "image/png",
1831
+ ".jpg": "image/jpeg",
1832
+ ".jpeg": "image/jpeg",
1833
+ ".webp": "image/webp",
1834
+ ".gif": "image/gif",
1835
+ ".tif": "image/tiff",
1836
+ ".tiff": "image/tiff",
1837
+ ".avif": "image/avif",
1838
+ ".svg": "image/svg+xml",
1839
+ ".json": "application/json",
1840
+ ".txt": "text/plain",
1841
+ ".npy": "application/octet-stream"
1842
+ };
1843
+ function clampInt(value, min, max) {
1844
+ const n = Number(value);
1845
+ if (!Number.isFinite(n)) return null;
1846
+ return Math.min(max, Math.max(min, Math.round(n)));
1847
+ }
1848
+ function createMediaRouter(project) {
1849
+ const router = Router11();
1850
+ router.put("/{*mediaPath}", express.raw({ type: "*/*", limit: "512mb" }), async (req, res) => {
1851
+ const rel = req.params.mediaPath?.join("/") ?? "";
1852
+ if (!rel.startsWith(CACHE_DIR + "/")) {
1853
+ res.status(400).json({ error: `writes are confined to ${CACHE_DIR}/ \u2014 got ${rel}` });
1854
+ return;
1855
+ }
1856
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
1857
+ res.status(400).json({ error: "empty body" });
1858
+ return;
1859
+ }
1860
+ try {
1861
+ const target = req.query.exact === "1" ? rel : contentAddressed(rel, req.body);
1862
+ const full = project.resolve(target);
1863
+ await fs7.promises.mkdir(path7.dirname(full), { recursive: true });
1864
+ await fs7.promises.writeFile(full, req.body);
1865
+ res.json({ ok: true, path: target, bytes: req.body.length });
1866
+ } catch (err) {
1867
+ res.status(err instanceof PathSafetyError ? 400 : 500).json({ error: String(err) });
1868
+ }
1869
+ });
1870
+ router.get("/{*mediaPath}", async (req, res) => {
1871
+ let full;
1872
+ const rel = req.params.mediaPath?.join("/") ?? "";
1873
+ try {
1874
+ full = project.resolveMedia(rel);
1875
+ } catch (err) {
1876
+ res.status(err instanceof PathSafetyError ? 400 : 500).json({ error: String(err) });
1877
+ return;
1878
+ }
1879
+ let stat;
1880
+ try {
1881
+ stat = await fs7.promises.stat(full);
1882
+ if (!stat.isFile()) throw new Error("not a file");
1883
+ } catch {
1884
+ res.status(404).json({ error: `no such file: ${rel}` });
1885
+ return;
1886
+ }
1887
+ const ext = path7.extname(full).toLowerCase();
1888
+ const width = clampInt(req.query.w, 1, 2e4);
1889
+ const quality = clampInt(req.query.q, 1, 100) ?? 82;
1890
+ const requested = String(req.query.fmt ?? "").toLowerCase();
1891
+ const raw = req.query.raw === "1" || req.query.raw === "true";
1892
+ const etag = `W/"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}-${width ?? "full"}-${requested || "auto"}-${quality}-${raw ? "raw" : "enc"}"`;
1893
+ res.setHeader("ETag", etag);
1894
+ res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
1895
+ if (req.headers["if-none-match"] === etag) {
1896
+ res.status(304).end();
1897
+ return;
1898
+ }
1899
+ if (!raw && ext === ".npy") {
1900
+ try {
1901
+ const array = readNpy(await fs7.promises.readFile(full));
1902
+ const { pixels, min, max } = fieldToGrey(array);
1903
+ res.setHeader("X-Field-Shape", `${array.height}x${array.width}`);
1904
+ res.setHeader("X-Field-Range", `${min}:${max}`);
1905
+ const sharp = await loadSharp();
1906
+ let pipeline = sharp(pixels, {
1907
+ raw: { width: array.width, height: array.height, channels: 1 }
1908
+ });
1909
+ if (width) pipeline = pipeline.resize({ width, withoutEnlargement: true });
1910
+ if (requested === "png") {
1911
+ res.type("image/png");
1912
+ pipeline = pipeline.png({ compressionLevel: 9 });
1913
+ } else {
1914
+ res.type("image/webp");
1915
+ pipeline = pipeline.webp({ quality, effort: 4 });
1916
+ }
1917
+ pipeline.on("error", (err) => {
1918
+ console.error("[media] field encode failed:", rel, err);
1919
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1920
+ else res.end();
1921
+ });
1922
+ pipeline.pipe(res);
1923
+ } catch (err) {
1924
+ console.error("[media] field render failed:", rel, err);
1925
+ res.status(422).json({ error: String(err instanceof Error ? err.message : err) });
1926
+ }
1927
+ return;
1928
+ }
1929
+ if (raw || !RASTER.has(ext)) {
1930
+ res.type(MIME[ext] ?? "application/octet-stream");
1931
+ res.setHeader("Content-Length", String(stat.size));
1932
+ fs7.createReadStream(full).pipe(res);
1933
+ return;
1934
+ }
1935
+ let format = requested;
1936
+ if (!format) format = ext === ".gif" ? "gif" : "webp";
1937
+ if (format === "gif") {
1938
+ res.type("image/gif");
1939
+ res.setHeader("Content-Length", String(stat.size));
1940
+ fs7.createReadStream(full).pipe(res);
1941
+ return;
1942
+ }
1943
+ try {
1944
+ const sharp = await loadSharp();
1945
+ let pipeline = sharp(full, { failOn: "none" }).rotate();
1946
+ if (width) {
1947
+ pipeline = pipeline.resize({ width, withoutEnlargement: true });
1948
+ }
1949
+ switch (format) {
1950
+ case "png":
1951
+ pipeline = pipeline.png({ compressionLevel: 9 });
1952
+ res.type("image/png");
1953
+ break;
1954
+ case "jpeg":
1955
+ case "jpg":
1956
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
1957
+ res.type("image/jpeg");
1958
+ break;
1959
+ case "avif":
1960
+ pipeline = pipeline.avif({ quality });
1961
+ res.type("image/avif");
1962
+ break;
1963
+ default:
1964
+ pipeline = pipeline.webp({ quality, effort: 4 });
1965
+ res.type("image/webp");
1966
+ }
1967
+ pipeline.on("error", (err) => {
1968
+ console.error("[media] encode failed:", rel, err);
1969
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1970
+ else res.end();
1971
+ });
1972
+ pipeline.pipe(res);
1973
+ } catch (err) {
1974
+ console.error("[media] error:", rel, err);
1975
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1976
+ }
1977
+ });
1978
+ return router;
1979
+ }
1980
+
1981
+ // server/src/build.ts
1982
+ import fs8 from "node:fs";
1983
+ import { createHash } from "node:crypto";
1984
+ function buildId(distDir) {
1985
+ try {
1986
+ const contents = fs8.readFileSync(`${distDir}/index.html`);
1987
+ return createHash("sha256").update(contents).digest("hex").slice(0, 16);
1988
+ } catch {
1989
+ return null;
1990
+ }
1991
+ }
1992
+
1993
+ // server/src/hostBinding.ts
1994
+ import net from "node:net";
1995
+ var HOST_UNAVAILABLE = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_NONAME", "EAI_FAIL", "EADDRNOTAVAIL"]);
1996
+ var DEFAULT_FALLBACK_HOSTS = Object.freeze(["127.0.0.1"]);
1997
+ var DEFAULT_HOST_RETRY_MS = 1e4;
1998
+ function isHostUnavailable(error) {
1999
+ return HOST_UNAVAILABLE.has(error?.code ?? "");
2000
+ }
2001
+ function bindWithFallback(server, options) {
2002
+ const { port, preferredHost } = options;
2003
+ const retryMs = options.retryMs ?? DEFAULT_HOST_RETRY_MS;
2004
+ const log = options.log ?? ((message2) => console.warn(message2));
2005
+ const resolveHost = options.resolveHost ?? ((host) => host);
2006
+ const fallbacks = (options.fallbackHosts ?? DEFAULT_FALLBACK_HOSTS).filter((host) => host && host !== preferredHost);
2007
+ let state = { host: null, port, preferredHost, fallback: false, reason: null, since: null };
2008
+ let timer = null;
2009
+ let stopped = false;
2010
+ const update = (next) => {
2011
+ state = { ...state, ...next };
2012
+ options.onChange?.(state);
2013
+ };
2014
+ const bound = (host, reason) => {
2015
+ update({ host, fallback: host !== preferredHost, reason, since: (/* @__PURE__ */ new Date()).toISOString() });
2016
+ if (state.fallback) schedule();
2017
+ };
2018
+ async function bindFirstAvailable() {
2019
+ let reason = null;
2020
+ for (const host of [preferredHost, ...fallbacks]) {
2021
+ try {
2022
+ await listen(server, port, resolveHost(host));
2023
+ if (host !== preferredHost) log(`[cascade] bound ${host}:${port} instead (fallback); will retry ${preferredHost} every ${Math.round(retryMs / 1e3)}s`);
2024
+ bound(host, reason);
2025
+ return state;
2026
+ } catch (error) {
2027
+ if (!isHostUnavailable(error)) throw error;
2028
+ const message2 = describe(error, host, port);
2029
+ reason ??= message2;
2030
+ log(`[cascade] host ${host} is unavailable (${message2})`);
2031
+ }
2032
+ }
2033
+ throw new Error(`no bindable host: tried ${[preferredHost, ...fallbacks].join(", ")} (${reason})`);
2034
+ }
2035
+ function schedule() {
2036
+ if (stopped || timer) return;
2037
+ timer = setTimeout(() => {
2038
+ timer = null;
2039
+ void retryPreferred();
2040
+ }, retryMs);
2041
+ timer.unref?.();
2042
+ }
2043
+ async function retryPreferred() {
2044
+ if (stopped || !state.fallback) return;
2045
+ if (state.host !== null && !server.listening) {
2046
+ stopped = true;
2047
+ return;
2048
+ }
2049
+ try {
2050
+ await probe(resolveHost(preferredHost), port);
2051
+ } catch {
2052
+ schedule();
2053
+ return;
2054
+ }
2055
+ const previous = state.host ?? fallbacks[0] ?? preferredHost;
2056
+ if (server.listening) server.close();
2057
+ server.closeIdleConnections?.();
2058
+ try {
2059
+ await listen(server, port, resolveHost(preferredHost));
2060
+ log(`[cascade] ${preferredHost} is back \u2014 moved from ${previous}:${port} to ${preferredHost}:${port}`);
2061
+ bound(preferredHost, null);
2062
+ } catch (error) {
2063
+ log(`[cascade] rebinding ${preferredHost} failed (${describe(error, preferredHost, port)}); staying on ${previous}`);
2064
+ try {
2065
+ await listen(server, port, resolveHost(previous));
2066
+ bound(previous, state.reason);
2067
+ } catch (fallbackError) {
2068
+ log(`[cascade] could not rebind ${previous}:${port} either (${describe(fallbackError, previous, port)}); Studio is not listening \u2014 retrying`);
2069
+ update({ host: null, since: null });
2070
+ schedule();
2071
+ }
2072
+ }
2073
+ }
2074
+ const ready = bindFirstAvailable();
2075
+ ready.catch(() => {
2076
+ });
2077
+ return {
2078
+ ready,
2079
+ state: () => state,
2080
+ stop() {
2081
+ stopped = true;
2082
+ if (timer) clearTimeout(timer);
2083
+ timer = null;
2084
+ }
2085
+ };
2086
+ }
2087
+ function listen(server, port, host) {
2088
+ return new Promise((resolve, reject) => {
2089
+ const onError = (error) => {
2090
+ server.off("listening", onListening);
2091
+ reject(error);
2092
+ };
2093
+ const onListening = () => {
2094
+ server.off("error", onError);
2095
+ resolve();
2096
+ };
2097
+ server.once("error", onError);
2098
+ server.once("listening", onListening);
2099
+ server.listen(port, host);
2100
+ });
2101
+ }
2102
+ function probe(host, port) {
2103
+ return new Promise((resolve, reject) => {
2104
+ const socket = net.createServer();
2105
+ socket.once("error", reject);
2106
+ socket.listen(port, host, () => socket.close(() => resolve()));
2107
+ });
2108
+ }
2109
+ function describe(error, host, port) {
2110
+ const code = error.code;
2111
+ if (code === "EADDRNOTAVAIL") return `EADDRNOTAVAIL ${host}:${port}`;
2112
+ if (code && code.startsWith("E")) return `getaddrinfo ${code} ${host}`;
2113
+ return error instanceof Error ? error.message : String(error);
2114
+ }
2115
+
2116
+ // server/src/index.ts
2117
+ var __filename = fileURLToPath(import.meta.url);
2118
+ var __dirname = path8.dirname(__filename);
2119
+ var CASCADE_DIST = path8.resolve(__dirname, "..", "..", "dist");
2120
+ var PROJECT_JSON_LIMIT = "32mb";
2121
+ function startServer(project, opts = {}) {
2122
+ const app = express2();
2123
+ const PORT = opts.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : 3030);
2124
+ const HOST = opts.host ?? "127.0.0.1";
2125
+ const trustedHosts = Object.freeze([...opts.trustedHosts ?? []]);
2126
+ const fallbackHosts = opts.fallbackHosts ?? DEFAULT_FALLBACK_HOSTS;
2127
+ const loopbackReachable = isLoopbackHost(HOST) || fallbackHosts.some(isLoopbackHost);
2128
+ const defaultOrigins = [
2129
+ ...loopbackReachable ? [`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`] : [],
2130
+ // Both schemes for a trusted host, because the server cannot know whether
2131
+ // something is terminating TLS in front of it.
2132
+ //
2133
+ // This was `http://` only, and behind a TLS proxy a browser sends
2134
+ // `Origin: https://name:port` — so the Host check passed and the **Origin
2135
+ // check refused it**, which 403s every POST and PUT while GETs without an
2136
+ // Origin header sail through. That is a Studio that loads, renders and
2137
+ // then silently cannot save. Measured before fixing: `httpsOrigin: 403`,
2138
+ // `httpOrigin: 200` on the same server.
2139
+ //
2140
+ // Not a widening: the Host allowlist already decides which authorities are
2141
+ // acceptable, and this only says the same authority may arrive over either
2142
+ // scheme.
2143
+ ...trustedHosts.flatMap((host) => {
2144
+ const value = trustedAuthority(host, PORT);
2145
+ const bare = value.replace(/:(?:80|443)$/, "");
2146
+ return [.../* @__PURE__ */ new Set([value, bare])].flatMap((authority3) => [
2147
+ `http://${authority3}`,
2148
+ `https://${authority3}`
2149
+ ]);
2150
+ })
2151
+ ];
2152
+ const security = Object.freeze({
2153
+ host: HOST,
2154
+ port: PORT,
2155
+ trustedHosts,
2156
+ sensitiveCapabilities: isLoopbackHost(HOST) || trustedHosts.length > 0,
2157
+ trustedOrigins: Object.freeze([...opts.trustedOrigins ?? opts.trustedShellOrigins ?? defaultOrigins])
2158
+ });
2159
+ app.use("/api/shell", createShellRouter(project.shell, security));
2160
+ app.use("/api/exec", createExecRouter(project, security));
2161
+ app.use("/api/net", createNetRouter(project, security));
2162
+ app.use("/api/agent", createAgentRouter(project, security));
2163
+ app.use("/api/project", createProjectSettingsRouter(project, security));
2164
+ const projectBoundary = createProjectRequestBoundary(security);
2165
+ app.use("/api", projectBoundary.guard);
2166
+ app.options("/api/{*splat}", projectBoundary.preflight);
2167
+ app.use(express2.json({ limit: PROJECT_JSON_LIMIT }));
2168
+ app.use(express2.urlencoded({ extended: true, limit: PROJECT_JSON_LIMIT }));
2169
+ app.use((err, _req, res, next) => {
2170
+ if (err && (err.type === "entity.too.large" || err.type === "entity.parse.failed")) {
2171
+ res.status(err.status ?? 400).json({ ok: false, error: err.message, type: err.type });
2172
+ return;
2173
+ }
2174
+ next(err);
2175
+ });
2176
+ app.use("/api/graph", createGraphRouter(project));
2177
+ app.use("/api/assets", createAssetsRouter(project));
2178
+ app.use("/api/nodes", createNodesRouter(project));
2179
+ app.use("/api/panels", createPanelsRouter(project));
2180
+ app.use("/api/media", createMediaRouter(project));
2181
+ app.use("/api/graph-events", createGraphWatchRouter(project));
2182
+ app.get("/health", (_req, res) => {
2183
+ res.json({ status: "ok", version: "2.0", projectRoot: project.root, isGitRepo: project.isGitRepo });
2184
+ });
2185
+ app.get("/api/build", (_req, res) => {
2186
+ res.json({ id: buildId(CASCADE_DIST) });
2187
+ });
2188
+ if (fs9.existsSync(CASCADE_DIST)) {
2189
+ app.use(express2.static(CASCADE_DIST, {
2190
+ setHeaders(res, filePath) {
2191
+ res.setHeader(
2192
+ "Cache-Control",
2193
+ filePath.endsWith("index.html") ? "no-store" : "public, max-age=31536000, immutable"
2194
+ );
2195
+ }
2196
+ }));
2197
+ app.get("/{*splat}", (req, res, next) => {
2198
+ if (req.path.startsWith("/api") || req.path === "/health") return next();
2199
+ res.setHeader("Cache-Control", "no-store");
2200
+ res.sendFile(path8.join(CASCADE_DIST, "index.html"));
2201
+ });
2202
+ } else {
2203
+ console.warn(`[cascade-server] no build found at ${CASCADE_DIST} \u2014 run "npm run build" in the Cascade repo first. API-only for now.`);
2204
+ }
2205
+ const server = createServer(app);
2206
+ const binding = bindWithFallback(server, {
2207
+ port: PORT,
2208
+ preferredHost: HOST,
2209
+ fallbackHosts,
2210
+ ...opts.hostRetryMs === void 0 ? {} : { retryMs: opts.hostRetryMs }
2211
+ });
2212
+ binding.ready.then((state) => {
2213
+ console.log(`\u{1F680} Cascade running on http://${state.host}:${PORT}${state.fallback ? ` (fallback; preferred ${HOST} unavailable)` : ""}`);
2214
+ console.log(`\u{1F4C1} Project: ${project.root}${project.isGitRepo ? " (git)" : " (not a git repo yet)"}`);
2215
+ if (trustedHosts.length > 0) console.log(`\u{1F510} Trusted Studio URL: http://${trustedAuthority(trustedHosts[0], PORT)} (all reachable VPN/interface peers are trusted)`);
2216
+ }, (error) => {
2217
+ server.emit("error", error);
2218
+ });
2219
+ return Object.assign(server, { binding });
2220
+ }
2221
+
2222
+ // server/src/projectTemplate.ts
2223
+ import fs10 from "node:fs";
2224
+ import path9 from "node:path";
2225
+ function cascadeVersionRange() {
2226
+ try {
2227
+ const pkg = JSON.parse(
2228
+ fs10.readFileSync(new URL("../../package.json", import.meta.url), "utf8")
2229
+ );
2230
+ const match = /^(\d+)\.(\d+)\./.exec(pkg.version ?? "");
2231
+ return match ? `^${match[1]}.${match[2]}.0` : "*";
2232
+ } catch {
2233
+ return "*";
2234
+ }
2235
+ }
2236
+ var PROJECT_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
2237
+ var NODE_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/;
2238
+ function createProject(projectsRoot, name) {
2239
+ if (!PROJECT_NAME.test(name)) {
2240
+ throw new Error("Project names use lowercase letters, numbers, and hyphens (maximum 64 characters).");
2241
+ }
2242
+ const root = path9.resolve(projectsRoot);
2243
+ const directory = path9.resolve(root, name);
2244
+ if (path9.dirname(directory) !== root) throw new Error("Project path escapes the configured projects directory.");
2245
+ if (fs10.existsSync(directory)) throw new Error(`Already exists: ${directory}`);
2246
+ fs10.mkdirSync(path9.join(directory, "nodes"), { recursive: true });
2247
+ fs10.mkdirSync(path9.join(directory, "assets"));
2248
+ writeJson(path9.join(directory, "cascade.json"), { name });
2249
+ writeJson(path9.join(directory, "index.cascade"), {
2250
+ name,
2251
+ version: "0.2",
2252
+ viewport: { x: 0, y: 0, zoom: 1 },
2253
+ nodes: [],
2254
+ connections: [],
2255
+ annotations: []
2256
+ });
2257
+ writeJson(path9.join(directory, "package.json"), {
2258
+ name,
2259
+ private: true,
2260
+ type: "module",
2261
+ scripts: {
2262
+ check: "tsc --noEmit",
2263
+ validate: "cascade validate index.cascade",
2264
+ "check:graph": "cascade check index.cascade",
2265
+ inspect: "cascade inspect index.cascade",
2266
+ run: "cascade run index.cascade"
2267
+ },
2268
+ // Aliased, not renamed: the package publishes as `@field/cascade` because
2269
+ // the bare name belongs to somebody else on npm — and a scaffolded project
2270
+ // asking for `cascade` would silently install that stranger's package.
2271
+ // The alias keeps every `cascade/contracts` import in a project working.
2272
+ devDependencies: {
2273
+ cascade: `npm:@field/cascade@${cascadeVersionRange()}`,
2274
+ typescript: "^6.0.0"
2275
+ }
2276
+ });
2277
+ writeJson(path9.join(directory, "tsconfig.json"), {
2278
+ compilerOptions: {
2279
+ target: "ES2022",
2280
+ module: "ESNext",
2281
+ moduleResolution: "Bundler",
2282
+ strict: true,
2283
+ noEmit: true
2284
+ },
2285
+ include: ["nodes/**/*.ts"]
2286
+ });
2287
+ fs10.writeFileSync(path9.join(directory, ".gitignore"), ".cascade-cache/\n.cascade/cache/\n.cascade/runs/\nrenders/\nnode_modules/\n");
2288
+ fs10.writeFileSync(path9.join(directory, "AGENTS.md"), `# Cascade project
2289
+
2290
+ Read \`node_modules/cascade/AGENTS.md\` for the framework itself. What follows is what an agent working in *this* project needs.
2291
+
2292
+ ## Read this first
2293
+
2294
+ \`node_modules/cascade/doc/NODE_REFERENCE.md\` \u2014 every built-in node with its inputs, props and outputs, generated from the definitions themselves. Read it before creating a wrapper for an existing node. Time-based parameter changes usually need an expression or channel.
2295
+
2296
+ ## Common commands
2297
+
2298
+ - Run \`npm install\` in this directory first. Scripts use the project's installed Cascade version; use \`npx --no-install cascade\` for direct local CLI commands.
2299
+ - \`npm run check\` \u2014 type-check custom nodes.
2300
+ - \`npm run check:graph\` \u2014 statically check the graph and node definitions.
2301
+ - \`npm run validate\` \u2014 validate \`index.cascade\`.
2302
+ - \`npm run run\` \u2014 execute headlessly without Studio.
2303
+ - \`cascade .\` \u2014 launch local Studio on loopback.
2304
+ - \`cascade . --host KURO --port 3030\` \u2014 launch for \`http://KURO:3030\` on a trusted VPN/LAN.
2305
+
2306
+ Do not start another Cascade process on an occupied port. Remote access requires
2307
+ both the explicit bind address and the exact trusted browser hostname.
2308
+
2309
+ ## The \`.cascade\` document format
2310
+
2311
+ Hand-editing is supported. Preserve metadata, source fields, IDs, and valid connection endpoints, then run the static checks. This example uses the node below; save it at \`nodes/Multiply/index.ts\` before running the graph.
2312
+
2313
+ \`\`\`json
2314
+ {
2315
+ "version": "0.2",
2316
+ "metadata": { "name": "My Sketch" },
2317
+ "nodes": [
2318
+ {
2319
+ "id": "multiply",
2320
+ "module": "project.Multiply",
2321
+ "position": [240, 200],
2322
+ "source": "project",
2323
+ "props": { "factor": { "value": 2, "expression": "2 + sin($T)" } }
2324
+ }
2325
+ ],
2326
+ "connections": [],
2327
+ "annotations": []
2328
+ }
2329
+ \`\`\`
2330
+
2331
+ Five things that are easy to get wrong:
2332
+
2333
+ - **Use \`module\` for the module identity and \`"source": "project"\` for project modules.** Unresolved modules need investigation even when validation only warns.
2334
+ - **\`position\` is \`[x, y]\`**, an array, not \`{x, y}\`.
2335
+ - **Definition-v1 stored parameter values belong under \`props\`.** A leftover \`params\` array is not read by the deterministic runtime; \`runtime/stray-params\` warns about stranded values.
2336
+ - **A connection is a pair of triples**: \`[[fromNode, portIndex, portName], [toNode, portIndex, portName]]\`. Direction comes from *position in the pair* \u2014 first is the source. The middle number is the **port index**, not a direction.
2337
+ - **A prop is a bare value or \`{ value, expression?, channel? }\`.** Channels take precedence over expressions, then stored values. Save the binding, not just its value at the playhead.
2338
+
2339
+ ## Design the graph, not one big node
2340
+
2341
+ A piece is several nodes, each doing one job: load the data, lay it out, colour it, draw it, export it. Do not write one 300-line node that does all of it. The graph is the record Marcus inspects, so each node boundary should be a place worth looking at, with the parameters that matter on it.
2342
+
2343
+ Clean graph design is clean code design:
2344
+
2345
+ - **One responsibility per node**, named for what it does (\`layout_rings\`, not \`main\`).
2346
+ - **Data flows through typed ports**, never through module-level state or files one node writes and another reads behind the graph's back.
2347
+ - **What varies is a parameter**; seeds, counts and colours are on the node, not constants in the code.
2348
+ - **Shared helpers go in \`lib/\` or the kit**; the node is the unit you compose and reuse.
2349
+
2350
+ Split a node when it does two of load, generate, style, render and export, or when its \`execute\` grows past about 150 lines.
2351
+
2352
+ ## Before writing a node, check whether you need one
2353
+
2354
+ **Parameters can hold expressions**, and this is usually the answer to anything time-based. The variables are Houdini's:
2355
+
2356
+ | | |
2357
+ |---|---|
2358
+ | \`$F\` | frame, integer |
2359
+ | \`$FF\` | frame, fractional |
2360
+ | \`$T\` | seconds \u2014 **zero on the first frame** |
2361
+ | \`$FPS\` | rate |
2362
+ | \`ch("node/param")\`, \`ch("./param")\` | read another parameter |
2363
+
2364
+ So "oscillate the angle" is \`sin($T) * 40\` in the \`angle\` prop. **There is no built-in oscillator node and you do not need one.** A node earns its place when it produces geometry or pixels, or when several parameters share a computation.
2365
+
2366
+ \`fit\`, \`fit01\`, \`clamp\`, \`lerp\`, \`smooth\`, \`noise\` and \`random\` are all available inside an expression.
2367
+
2368
+ The maths library is exposed bare, so write \`sin(x)\` rather than \`Math.sin(x)\`. Angles are in **radians**, unlike Houdini; \`sind\`, \`cosd\`, \`tand\`, \`radians()\` and \`degrees()\` are there for a formula carried across, and \`PI\`, \`TAU\` and \`E\` are in scope.
2369
+
2370
+ ## Writing a node
2371
+
2372
+ Project nodes live at \`nodes/<name>/index.ts\`, one folder per module, referenced as \`project.<name>\`. Write them in the **definition-v1** style, which is what \`cascade node <Name>\` scaffolds:
2373
+
2374
+ \`\`\`ts
2375
+ import type { NodeDefinition, NodeExecutionContext } from 'cascade/contracts';
2376
+
2377
+ export const definition = {
2378
+ apiVersion: 1,
2379
+ label: 'Multiply',
2380
+ icon: 'Circle', // a Lucide icon name
2381
+ runsOn: 'portable', // 'portable' | 'browser' | 'server'
2382
+ inputs: {
2383
+ value: { kind: 'data', type: 'float', default: 3 },
2384
+ },
2385
+ outputs: {
2386
+ result: { kind: 'data', type: 'float' },
2387
+ },
2388
+ props: {
2389
+ factor: { type: 'float', default: 2, min: 0, max: 10, step: 0.1 },
2390
+ },
2391
+ } as const satisfies NodeDefinition;
2392
+
2393
+ export function execute(context: NodeExecutionContext<typeof definition>) {
2394
+ context.outputs.result.set(context.inputs.value * context.props.factor);
2395
+ }
2396
+ \`\`\`
2397
+
2398
+ The declaration is the point: ports, types, props and the execution locus are read from the literal without running anything, which is what \`cascade check\` inspects and what the Definition panel shows. \`execute\` then does computation only \u2014 it never declares a port.
2399
+
2400
+ ### Use the vector types
2401
+
2402
+ Anything with an x and a y is **one** \`vec2\`, not two floats. Same for \`vec3\` and \`vec4\`, with \`vec2i\`, \`vec3i\` and \`vec4i\` for integer counts and pixel sizes. Positions, offsets, scales, and resolutions use vector ports; colours use the core \`color\` type.
2403
+
2404
+ \`\`\`ts
2405
+ // Wrong
2406
+ props: { offset_x: { type: 'float', default: 0 }, offset_y: { type: 'float', default: 0 } }
2407
+
2408
+ // Right
2409
+ props: { offset: { type: 'vec2', default: [0, 0] } }
2410
+ \`\`\`
2411
+
2412
+ Not a style preference. Two floats that are really one vector cannot be connected to a \`vec2\` output, get two rows in the Inspector instead of one control, need two keyframes to animate one movement, and let a graph carry an x without its y. The type system knows what a \`vec2\` is; it cannot know that \`offset_x\` and \`offset_y\` belong together.
2413
+
2414
+ Split only when the components differ in kind. Vector props support \`min\`, \`max\`, and \`step\`, with one range across their components.
2415
+
2416
+ Studio and a compatible CLI host cook this style. **Studio** builds ports from the compiled definition and calls \`execute\` with a real \`NodeExecutionContext\`; **\`cascade run\`** uses the deterministic runtime when the node's execution locus and capabilities are supported. A browser-only declaration still prevents a Node-host run.
2417
+
2418
+ The **dynamic** style \u2014 \`execute(node, graph)\` declaring ports with \`node.in\`, \`node.param\`, and \`node.out\` \u2014 remains supported by Studio and the CLI compatibility engine. It cannot provide the same static checks. Studio may mix both styles; the CLI rejects mixed graphs. Convert deliberately with saved-value and output comparisons.
2419
+
2420
+ Definition-v1 props support default \`expression\` values. An explicitly saved value overrides that default, even when equal to the numeric default. Both fully definition-v1 and supported dynamic graphs can render sequences with \`cascade run index.cascade --frames 1-100\`, subject to host capabilities. \`cascade render index.cascade --node <id>\` renders the same sequence across worker processes and encodes a half-size preview mp4; it needs each frame written to its own file (the frame in the name, or \`saveImage\` with \`cachePath\`, which is content-addressed), and refuses a node that writes one fixed path.
2421
+
2422
+ For a standalone browser page or embed, use \`cascade build index.cascade --out web-player\` with a new output directory. Add \`--asset assets/file.svg\` for each asset addressed from code; typed literal asset/image references in the document are included automatically. Serve the result over HTTP(S). This player supports definition-v1 browser/portable nodes with assets or GPU capabilities, not server operations or dynamic modules. See \`node_modules/cascade/doc/WEB_PLAYER.md\` for iframe and programmatic controls.
2423
+
2424
+ Either way the module is a real ES module and the loader needs the \`execute\` export. Top-level side effects do not belong in it.
2425
+
2426
+ ## Rendering
2427
+
2428
+ Prefer Canvas 2D, WebGL, and WebGPU for interactive rendering. Use Python or another server stage for libraries and tools that require it. Transport, serialization, and cold starts can affect parameter-drag latency; persistent workers can avoid repeated process/model startup.
2429
+
2430
+ Definition-v1 image nodes can use \`saveImage(canvas, cachePath(context.nodeId, '.png'))\` from \`cascade/io\`. The instance ID provides a cache namespace, not graph access. Final artefacts may instead use an explicit filename. Optional Skia supplies Canvas2D APIs; optional Dawn separately supplies WebGPU. Neither overrides a browser-only declaration or supplies the DOM.
2431
+
2432
+ Use \`browser\` only for code that genuinely needs the page, such as the DOM or WebGL. Prefer \`new OffscreenCanvas(width, height)\` to \`document.createElement('canvas')\` when both hosts can run the same node.
2433
+
2434
+ WebGPU nodes declare \`capabilities: ['gpu']\` and use \`context.capabilities.gpu\` for the shared device and per-node cache. Portable GPU nodes run in Studio or the optional Dawn CLI host. Import usage constants and explicit RGBA8 \`readTexture\` from \`cascade/gpu\`, then encode through the image/IO host. Graph texture exchange remains future work. For agent image feedback use \`cascade run index.cascade --frames 1 --json --timeout 60000\`; see \`node_modules/cascade/doc/HEADLESS_GPU.md\` for limitations.
2435
+
2436
+ ## Finding out what exists
2437
+
2438
+ - \`node_modules/cascade/doc/PROJECT_AUTHORING.md\` \u2014 project layout and the node contract.
2439
+ - \`node_modules/cascade/ARCHITECTURE.md\` \u2014 what each package owns.
2440
+ - \`GET /api/nodes\` on the running server \u2014 the project's own modules, with their \`runsOn\` and icons.
2441
+ - \`node_modules/cascade/dist/runtime/builtins/\` \u2014 the built-in node definitions, as the shipped type declarations. (\`packages/\` is not in the published \`files\` list, so an installed copy has no source tree.)
2442
+ - The **Definition panel** in Studio shows, for any selected node, where it is defined and what it declares.
2443
+
2444
+ ## Verification and shared development
2445
+
2446
+ Cascade's build refreshes \`dist/\`, which running Studio servers may serve. Coordinate a core rebuild with anyone using that checkout; routine sketch edits do not require rebuilding Cascade.
2447
+
2448
+ Use \`npm run check:graph\` for static checks and \`npm run run\` for a compatible headless graph. A browser-only node may pass static checks with an environment warning and still be refused by the CLI; verify it in Studio instead. Compare actual outputs after edits and distinguish recorded measurements from tests run in the current session.
2449
+ `);
2450
+ return directory;
2451
+ }
2452
+ function createNode(projectRoot, name) {
2453
+ if (!NODE_NAME.test(name)) {
2454
+ throw new Error("Node names start with an uppercase letter and contain only letters or numbers.");
2455
+ }
2456
+ const directory = path9.resolve(projectRoot, "nodes", name);
2457
+ if (fs10.existsSync(directory)) throw new Error(`Already exists: ${directory}`);
2458
+ fs10.mkdirSync(directory, { recursive: true });
2459
+ fs10.writeFileSync(path9.join(directory, "index.ts"), nodeSource(name));
2460
+ return directory;
2461
+ }
2462
+ function writeJson(file, value) {
2463
+ fs10.writeFileSync(file, `${JSON.stringify(value, null, 2)}
2464
+ `);
2465
+ }
2466
+ function nodeSource(name) {
2467
+ return `import type { NodeDefinition, NodeExecutionContext } from 'cascade/contracts';
2468
+
2469
+ export const definition = {
2470
+ apiVersion: 1,
2471
+ label: '${name}',
2472
+ runsOn: 'portable',
2473
+ inputs: {
2474
+ value: { kind: 'data', type: 'float', default: 0 }
2475
+ },
2476
+ outputs: {
2477
+ result: { kind: 'data', type: 'float' }
2478
+ }
2479
+ } as const satisfies NodeDefinition;
2480
+
2481
+ export function execute(context: NodeExecutionContext<typeof definition>) {
2482
+ context.outputs.result.set(context.inputs.value);
2483
+ }
2484
+ `;
2485
+ }
2486
+
2487
+ // server/src/serve/command.ts
2488
+ import fs12 from "node:fs";
2489
+ import path12 from "node:path";
2490
+ import { spawn as spawn3 } from "node:child_process";
2491
+
2492
+ // server/src/serve/files.ts
2493
+ import fs11 from "node:fs";
2494
+ import os3 from "node:os";
2495
+ import path10 from "node:path";
2496
+ import { createHash as createHash2 } from "node:crypto";
2497
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2498
+ function serveRoot() {
2499
+ return process.env.CASCADE_SERVE_DIR ?? path10.join(os3.homedir(), ".cascade", "serve");
2500
+ }
2501
+ function serviceId(projectRoot) {
2502
+ const name = path10.basename(projectRoot).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2503
+ const hash = createHash2("sha256").update(path10.resolve(projectRoot)).digest("hex").slice(0, 8);
2504
+ return `${name}-${hash}`;
2505
+ }
2506
+ function servicePaths(projectRoot) {
2507
+ return pathsForId(serviceId(projectRoot));
2508
+ }
2509
+ function pathsForId(id) {
2510
+ const dir = path10.join(serveRoot(), id);
2511
+ return {
2512
+ id,
2513
+ dir,
2514
+ lock: path10.join(dir, "serve.lock"),
2515
+ record: path10.join(dir, "serve.json"),
2516
+ server: path10.join(dir, "server.json"),
2517
+ options: path10.join(dir, "options.json"),
2518
+ log: path10.join(dir, "studio.log")
2519
+ };
2520
+ }
2521
+ function listServiceIds() {
2522
+ try {
2523
+ return fs11.readdirSync(serveRoot(), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
2524
+ } catch {
2525
+ return [];
2526
+ }
2527
+ }
2528
+ function readJson(file) {
2529
+ try {
2530
+ return JSON.parse(fs11.readFileSync(file, "utf8"));
2531
+ } catch {
2532
+ return null;
2533
+ }
2534
+ }
2535
+ function writeJsonAtomic(file, value) {
2536
+ fs11.mkdirSync(path10.dirname(file), { recursive: true });
2537
+ writeFileAtomicSync(file, JSON.stringify(value, null, 2) + "\n");
2538
+ }
2539
+ function removeFile(file) {
2540
+ try {
2541
+ fs11.unlinkSync(file);
2542
+ } catch {
2543
+ }
2544
+ }
2545
+ function isAlive(pid) {
2546
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
2547
+ try {
2548
+ process.kill(pid, 0);
2549
+ return true;
2550
+ } catch (error) {
2551
+ return error.code === "EPERM";
2552
+ }
2553
+ }
2554
+ var AlreadyRunningError = class extends Error {
2555
+ constructor(pid) {
2556
+ super(`already running (pid ${pid})`);
2557
+ this.pid = pid;
2558
+ }
2559
+ };
2560
+ function acquireLock(lockFile, pid = process.pid) {
2561
+ fs11.mkdirSync(path10.dirname(lockFile), { recursive: true });
2562
+ for (let attempt = 0; attempt < 2; attempt++) {
2563
+ try {
2564
+ const fd = fs11.openSync(lockFile, "wx");
2565
+ fs11.writeSync(fd, `${pid}
2566
+ `);
2567
+ fs11.closeSync(fd);
2568
+ return;
2569
+ } catch (error) {
2570
+ if (error.code !== "EEXIST") throw error;
2571
+ const holder = readLockPid(lockFile);
2572
+ if (holder !== null && holder !== pid && isAlive(holder)) throw new AlreadyRunningError(holder);
2573
+ removeFile(lockFile);
2574
+ }
2575
+ }
2576
+ throw new Error(`could not take the lock at ${lockFile}`);
2577
+ }
2578
+ function readLockPid(lockFile) {
2579
+ try {
2580
+ const pid = Number.parseInt(fs11.readFileSync(lockFile, "utf8").trim(), 10);
2581
+ return Number.isInteger(pid) ? pid : null;
2582
+ } catch {
2583
+ return null;
2584
+ }
2585
+ }
2586
+ function releaseLock(lockFile, pid = process.pid) {
2587
+ if (readLockPid(lockFile) === pid) removeFile(lockFile);
2588
+ }
2589
+ function tailLines(file, count) {
2590
+ let fd;
2591
+ try {
2592
+ fd = fs11.openSync(file, "r");
2593
+ } catch {
2594
+ return [];
2595
+ }
2596
+ try {
2597
+ const size = fs11.fstatSync(fd).size;
2598
+ const length = Math.min(size, Math.max(4096, count * 400));
2599
+ const buffer = Buffer.alloc(length);
2600
+ fs11.readSync(fd, buffer, 0, length, size - length);
2601
+ const lines = buffer.toString("utf8").split("\n");
2602
+ if (lines[lines.length - 1] === "") lines.pop();
2603
+ return lines.slice(-count);
2604
+ } finally {
2605
+ fs11.closeSync(fd);
2606
+ }
2607
+ }
2608
+ function rotateLog(file, maxBytes = 10 * 1024 * 1024) {
2609
+ try {
2610
+ if (fs11.statSync(file).size > maxBytes) fs11.renameSync(file, `${file}.1`);
2611
+ } catch {
2612
+ }
2613
+ }
2614
+ function cascadeVersion() {
2615
+ let directory = path10.dirname(fileURLToPath2(import.meta.url));
2616
+ for (let depth = 0; depth < 6; depth++) {
2617
+ const manifest = readJson(path10.join(directory, "package.json"));
2618
+ if (manifest?.name === "@field/cascade" && manifest.version) return manifest.version;
2619
+ directory = path10.dirname(directory);
2620
+ }
2621
+ return "unknown";
2622
+ }
2623
+
2624
+ // server/src/serve/launchd.ts
2625
+ import os4 from "node:os";
2626
+ import path11 from "node:path";
2627
+ import { execFileSync } from "node:child_process";
2628
+ function launchdLabel(id) {
2629
+ return `io.field.cascade.${id}`;
2630
+ }
2631
+ function launchAgentsDir() {
2632
+ return process.env.CASCADE_LAUNCH_AGENTS_DIR ?? path11.join(os4.homedir(), "Library", "LaunchAgents");
2633
+ }
2634
+ function plistPath(label) {
2635
+ return path11.join(launchAgentsDir(), `${label}.plist`);
2636
+ }
2637
+ function renderPlist(job) {
2638
+ const string = (value) => `<string>${escapeXml(value)}</string>`;
2639
+ const env = Object.entries(job.environment).map(([key2, value]) => ` <key>${escapeXml(key2)}</key>
2640
+ ${string(value)}`).join("\n");
2641
+ return `<?xml version="1.0" encoding="UTF-8"?>
2642
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2643
+ <plist version="1.0">
2644
+ <dict>
2645
+ <key>Label</key>
2646
+ ${string(job.label)}
2647
+ <key>ProgramArguments</key>
2648
+ <array>
2649
+ ${job.programArguments.map((argument) => ` ${string(argument)}`).join("\n")}
2650
+ </array>
2651
+ <key>WorkingDirectory</key>
2652
+ ${string(job.workingDirectory)}
2653
+ <key>EnvironmentVariables</key>
2654
+ <dict>
2655
+ ${env}
2656
+ </dict>
2657
+ <key>RunAtLoad</key>
2658
+ <true/>
2659
+ <key>KeepAlive</key>
2660
+ <dict>
2661
+ <key>SuccessfulExit</key>
2662
+ <false/>
2663
+ </dict>
2664
+ <key>StandardOutPath</key>
2665
+ ${string(job.logFile)}
2666
+ <key>StandardErrorPath</key>
2667
+ ${string(job.logFile)}
2668
+ </dict>
2669
+ </plist>
2670
+ `;
2671
+ }
2672
+ function escapeXml(value) {
2673
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2674
+ }
2675
+ function guiDomain() {
2676
+ return `gui/${process.getuid?.() ?? os4.userInfo().uid}`;
2677
+ }
2678
+ function launchctl(...args) {
2679
+ try {
2680
+ const output = execFileSync("launchctl", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
2681
+ return { ok: true, output };
2682
+ } catch (error) {
2683
+ const failure = error;
2684
+ return { ok: false, output: (failure.stderr || failure.message).trim() };
2685
+ }
2686
+ }
2687
+
2688
+ // server/src/serve/supervisor.ts
2689
+ import { spawn as spawn2 } from "node:child_process";
2690
+ var MAX_BACKOFF_MS = 3e4;
2691
+ function runSupervisor(config) {
2692
+ const { paths } = config;
2693
+ const maxCrashes = config.maxCrashes ?? 5;
2694
+ const crashWindowMs = config.crashWindowMs ?? 6e4;
2695
+ const stopGraceMs = config.stopGraceMs ?? 8e3;
2696
+ const log = config.log ?? ((message2) => console.log(`${(/* @__PURE__ */ new Date()).toISOString()} [serve] ${message2}`));
2697
+ acquireLock(paths.lock);
2698
+ removeFile(paths.server);
2699
+ let record = {
2700
+ pid: process.pid,
2701
+ project: config.project,
2702
+ id: paths.id,
2703
+ options: config.options,
2704
+ manager: config.manager,
2705
+ version: config.version,
2706
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2707
+ logFile: paths.log,
2708
+ serverPid: null,
2709
+ serverStartedAt: null,
2710
+ restarts: 0,
2711
+ lastExit: null
2712
+ };
2713
+ const save = (next) => {
2714
+ record = { ...record, ...next };
2715
+ writeJsonAtomic(paths.record, record);
2716
+ };
2717
+ save({});
2718
+ writeJsonAtomic(paths.options, config.options);
2719
+ return new Promise((resolve) => {
2720
+ let child = null;
2721
+ let stopping = false;
2722
+ let restartTimer = null;
2723
+ const crashes = [];
2724
+ const startChild = () => {
2725
+ const proc = spawn2(config.server.command, [...config.server.args], {
2726
+ cwd: config.project,
2727
+ env: { ...process.env, CASCADE_SERVE_STATE: paths.server },
2728
+ stdio: ["ignore", "inherit", "inherit"],
2729
+ detached: true
2730
+ // its own process group, so stop can take its workers with it
2731
+ });
2732
+ child = proc;
2733
+ log(`started Studio (pid ${proc.pid}) for ${config.project}`);
2734
+ save({ serverPid: proc.pid ?? null, serverStartedAt: (/* @__PURE__ */ new Date()).toISOString() });
2735
+ proc.once("error", (error) => log(`could not start Studio: ${error.message}`));
2736
+ proc.once("exit", (code, signal) => onChildExit(proc, code, signal));
2737
+ };
2738
+ const onChildExit = (proc, code, signal) => {
2739
+ if (child !== proc) return;
2740
+ child = null;
2741
+ killGroup(proc.pid, "SIGKILL");
2742
+ killWorkers();
2743
+ save({ serverPid: null, lastExit: { code, signal, at: (/* @__PURE__ */ new Date()).toISOString() } });
2744
+ if (stopping) return finish(0);
2745
+ const now = Date.now();
2746
+ crashes.push(now);
2747
+ while (crashes.length && now - crashes[0] > crashWindowMs) crashes.shift();
2748
+ if (crashes.length >= maxCrashes) {
2749
+ log(`Studio exited ${crashes.length} times in ${Math.round(crashWindowMs / 1e3)}s (last: ${signal ?? `code ${code}`}); giving up \u2014 see this log`);
2750
+ return finish(1);
2751
+ }
2752
+ const delay = Math.min(1e3 * 2 ** (crashes.length - 1), MAX_BACKOFF_MS);
2753
+ log(`Studio exited (${signal ?? `code ${code}`}); restarting in ${delay / 1e3}s`);
2754
+ save({ restarts: record.restarts + 1 });
2755
+ restartTimer = setTimeout(() => {
2756
+ restartTimer = null;
2757
+ if (!stopping) startChild();
2758
+ }, delay);
2759
+ };
2760
+ const stop2 = (signal) => {
2761
+ if (stopping) return;
2762
+ stopping = true;
2763
+ log(`${signal}: stopping Studio`);
2764
+ if (restartTimer) {
2765
+ clearTimeout(restartTimer);
2766
+ restartTimer = null;
2767
+ }
2768
+ const proc = child;
2769
+ if (!proc) return finish(0);
2770
+ killGroup(proc.pid, "SIGTERM");
2771
+ const force = setTimeout(() => {
2772
+ log(`Studio did not stop within ${stopGraceMs / 1e3}s; killing its process group`);
2773
+ killGroup(proc.pid, "SIGKILL");
2774
+ }, stopGraceMs);
2775
+ proc.once("exit", () => clearTimeout(force));
2776
+ };
2777
+ const killWorkers = () => {
2778
+ const state = readJson(paths.server);
2779
+ for (const worker of state?.workers ?? []) {
2780
+ if (worker.pid && isAlive(worker.pid)) {
2781
+ try {
2782
+ process.kill(worker.pid, "SIGKILL");
2783
+ } catch {
2784
+ }
2785
+ }
2786
+ }
2787
+ removeFile(paths.server);
2788
+ };
2789
+ const onTerm = () => stop2("SIGTERM");
2790
+ const onInt = () => stop2("SIGINT");
2791
+ const onHup = () => {
2792
+ };
2793
+ process.on("SIGTERM", onTerm);
2794
+ process.on("SIGINT", onInt);
2795
+ process.on("SIGHUP", onHup);
2796
+ let finished = false;
2797
+ const finish = (code) => {
2798
+ if (finished) return;
2799
+ finished = true;
2800
+ process.off("SIGTERM", onTerm);
2801
+ process.off("SIGINT", onInt);
2802
+ process.off("SIGHUP", onHup);
2803
+ removeFile(paths.record);
2804
+ removeFile(paths.server);
2805
+ releaseLock(paths.lock);
2806
+ log(code === 0 ? "stopped" : "supervisor exiting after repeated crashes");
2807
+ resolve(code);
2808
+ };
2809
+ startChild();
2810
+ });
2811
+ }
2812
+ function killGroup(pid, signal) {
2813
+ if (!pid) return;
2814
+ try {
2815
+ process.kill(-pid, signal);
2816
+ } catch {
2817
+ try {
2818
+ process.kill(pid, signal);
2819
+ } catch {
2820
+ }
2821
+ }
2822
+ }
2823
+
2824
+ // server/src/serve/command.ts
2825
+ var SERVE_USAGE = `Usage:
2826
+ cascade serve start [project] [--port N] [--host H] [--fallback-host H]... [--trusted-host H]...
2827
+ cascade serve stop [project]
2828
+ cascade serve restart [project] [same options as start]
2829
+ cascade serve status [project] [--all] [--json]
2830
+ cascade serve logs [project] [-n LINES] [-f]
2831
+ cascade serve install [project] [options] [--no-load] [--print] (macOS launchd)
2832
+ cascade serve uninstall [project]
2833
+
2834
+ Options given to start/restart are remembered: a later start without them reuses them.
2835
+ State and logs live in ~/.cascade/serve/<project>-<hash>/ (override with CASCADE_SERVE_DIR).`;
2836
+ var DEFAULT_PORT = 3030;
2837
+ function parseServeArgs(args) {
2838
+ const [action = "status", ...rest] = args;
2839
+ const positional = [];
2840
+ const fallbackHosts = [];
2841
+ const trustedHosts = [];
2842
+ let port;
2843
+ let host;
2844
+ let json6 = false, all = false, follow = false, load = true, print = false;
2845
+ let lines = 50;
2846
+ let waitMs = 3e4;
2847
+ for (let index = 0; index < rest.length; index++) {
2848
+ const argument = rest[index];
2849
+ const [name, inline] = argument.startsWith("--") ? argument.split("=", 2) : [argument, void 0];
2850
+ const value = () => {
2851
+ const next = inline ?? rest[++index];
2852
+ if (next === void 0 || next.startsWith("-")) throw new Error(`${name} requires a value`);
2853
+ return next;
2854
+ };
2855
+ switch (name) {
2856
+ case "--json":
2857
+ json6 = true;
2858
+ break;
2859
+ case "--all":
2860
+ all = true;
2861
+ break;
2862
+ case "-f":
2863
+ case "--follow":
2864
+ follow = true;
2865
+ break;
2866
+ case "--no-load":
2867
+ load = false;
2868
+ break;
2869
+ case "--print":
2870
+ print = true;
2871
+ break;
2872
+ case "--no-open":
2873
+ break;
2874
+ // serve never opens a browser; accepted for muscle memory
2875
+ case "-n":
2876
+ case "--lines":
2877
+ lines = positiveInteger(value(), name);
2878
+ break;
2879
+ case "--wait":
2880
+ waitMs = positiveInteger(value(), name) * 1e3;
2881
+ break;
2882
+ case "--port":
2883
+ port = positiveInteger(value(), name);
2884
+ break;
2885
+ case "--host":
2886
+ host = value();
2887
+ break;
2888
+ case "--fallback-host":
2889
+ fallbackHosts.push(value());
2890
+ break;
2891
+ case "--trusted-host":
2892
+ trustedHosts.push(value());
2893
+ break;
2894
+ default:
2895
+ if (argument.startsWith("-")) throw new Error(`unknown serve option: ${argument}`);
2896
+ positional.push(argument);
2897
+ }
2898
+ }
2899
+ if (positional.length > 1) throw new Error(`serve ${action} takes at most one project, got: ${positional.join(" ")}`);
2900
+ if (port !== void 0 && port > 65535) throw new Error("--port must be between 1 and 65535");
2901
+ const overrides = {
2902
+ ...port === void 0 ? {} : { port },
2903
+ ...host === void 0 ? {} : { host },
2904
+ ...fallbackHosts.length ? { fallbackHosts } : {},
2905
+ ...trustedHosts.length ? { trustedHosts } : {}
2906
+ };
2907
+ return { action, project: positional[0], overrides, json: json6, all, follow, lines, load, print, waitMs };
2908
+ }
2909
+ function positiveInteger(raw, name) {
2910
+ const value = Number(raw);
2911
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
2912
+ return value;
2913
+ }
2914
+ function studioArgs(project, options) {
2915
+ return [
2916
+ project,
2917
+ "--no-open",
2918
+ "--port",
2919
+ String(options.port),
2920
+ ...options.host ? ["--host", options.host] : [],
2921
+ ...options.fallbackHosts.flatMap((host) => ["--fallback-host", host]),
2922
+ ...options.trustedHosts.flatMap((host) => ["--trusted-host", host])
2923
+ ];
2924
+ }
2925
+ function optionFlags(options) {
2926
+ return studioArgs("", options).slice(2);
2927
+ }
2928
+ function cliInvocation() {
2929
+ const script = process.argv[1];
2930
+ return { command: process.execPath, args: [...process.execArgv, script ? fs12.realpathSync(script) : script] };
2931
+ }
2932
+ function resolveOptions(paths, overrides) {
2933
+ const saved = readJson(paths.options) ?? {};
2934
+ const options = {
2935
+ port: overrides.port ?? saved.port ?? DEFAULT_PORT,
2936
+ fallbackHosts: overrides.fallbackHosts ?? saved.fallbackHosts ?? [],
2937
+ trustedHosts: overrides.trustedHosts ?? saved.trustedHosts ?? [],
2938
+ ...overrides.host ?? saved.host ? { host: overrides.host ?? saved.host } : {}
2939
+ };
2940
+ parseStudioArgs(studioArgs(".", options).slice(1));
2941
+ return options;
2942
+ }
2943
+ async function runServeCommand(args, resolveProject) {
2944
+ const parsed = parseServeArgs(args);
2945
+ const projectFor = () => resolveProject(parsed.project ?? ".");
2946
+ switch (parsed.action) {
2947
+ case "run": {
2948
+ const project = projectFor();
2949
+ const paths = servicePaths(project);
2950
+ const options = resolveOptions(paths, parsed.overrides);
2951
+ const cli = cliInvocation();
2952
+ try {
2953
+ return await runSupervisor({
2954
+ project,
2955
+ paths,
2956
+ options,
2957
+ server: { command: cli.command, args: [...cli.args, ...studioArgs(project, options)] },
2958
+ manager: process.env.CASCADE_SERVE_MANAGER === "launchd" ? "launchd" : "cascade",
2959
+ version: cascadeVersion()
2960
+ });
2961
+ } catch (error) {
2962
+ if (error instanceof AlreadyRunningError) {
2963
+ console.error(`[cascade] ${project} is ${error.message}`);
2964
+ return 1;
2965
+ }
2966
+ throw error;
2967
+ }
2968
+ }
2969
+ case "start":
2970
+ return start(projectFor(), parsed);
2971
+ case "stop":
2972
+ return stop(projectFor());
2973
+ case "restart": {
2974
+ const project = projectFor();
2975
+ const paths = servicePaths(project);
2976
+ if (fs12.existsSync(plistPath(launchdLabel(paths.id))) && Object.keys(parsed.overrides).length === 0) {
2977
+ return restartLaunchd(project, paths, parsed.waitMs, parsed.json);
2978
+ }
2979
+ const stopped = await stop(project);
2980
+ if (stopped !== 0) return stopped;
2981
+ return start(project, parsed);
2982
+ }
2983
+ case "status": {
2984
+ if (parsed.all) return printStatuses(listServiceIds(), parsed);
2985
+ if (parsed.project) return printStatuses([servicePaths(projectFor()).id], parsed);
2986
+ let here = null;
2987
+ try {
2988
+ here = servicePaths(projectFor());
2989
+ } catch {
2990
+ }
2991
+ if (here && (fs12.existsSync(here.record) || fs12.existsSync(here.lock))) return printStatuses([here.id], parsed);
2992
+ return printStatuses(listServiceIds(), { ...parsed, all: true });
2993
+ }
2994
+ case "logs":
2995
+ return logs(projectFor(), parsed);
2996
+ case "install":
2997
+ return install(projectFor(), parsed);
2998
+ case "uninstall":
2999
+ return uninstall(projectFor());
3000
+ case "help":
3001
+ case "--help":
3002
+ case "-h":
3003
+ console.log(SERVE_USAGE);
3004
+ return 0;
3005
+ default:
3006
+ console.error(`unknown serve command: ${parsed.action}
3007
+
3008
+ ${SERVE_USAGE}`);
3009
+ return 1;
3010
+ }
3011
+ }
3012
+ async function start(project, parsed) {
3013
+ const paths = servicePaths(project);
3014
+ if (fs12.existsSync(plistPath(launchdLabel(paths.id)))) return startLaunchd(project, paths, parsed.waitMs, parsed.json);
3015
+ const running = supervisorPid(paths);
3016
+ if (running) {
3017
+ console.log(`[cascade] already running (pid ${running}); use \`cascade serve restart\` to restart`);
3018
+ printStatus(statusOf(paths), parsed.json);
3019
+ return 0;
3020
+ }
3021
+ const options = resolveOptions(paths, parsed.overrides);
3022
+ fs12.mkdirSync(paths.dir, { recursive: true });
3023
+ rotateLog(paths.log);
3024
+ const logFd = fs12.openSync(paths.log, "a");
3025
+ const cli = cliInvocation();
3026
+ const supervisor = spawn3(cli.command, [...cli.args, "serve", "run", project, ...optionFlags(options)], {
3027
+ cwd: project,
3028
+ detached: true,
3029
+ stdio: ["ignore", logFd, logFd],
3030
+ env: { ...process.env, CASCADE_SERVE_MANAGER: "cascade" }
3031
+ });
3032
+ supervisor.unref();
3033
+ fs12.closeSync(logFd);
3034
+ const state = await waitForListening(paths, supervisor.pid ?? 0, parsed.waitMs);
3035
+ if (!state) {
3036
+ console.error(`[cascade] Studio did not start within ${parsed.waitMs / 1e3}s. Last log lines (${paths.log}):`);
3037
+ for (const line of tailLines(paths.log, 20)) console.error(` ${line}`);
3038
+ if (supervisor.pid && isAlive(supervisor.pid)) await stop(project, true);
3039
+ return 1;
3040
+ }
3041
+ printStatus(statusOf(paths), parsed.json);
3042
+ return 0;
3043
+ }
3044
+ async function stop(project, quiet = false) {
3045
+ const paths = servicePaths(project);
3046
+ const label = launchdLabel(paths.id);
3047
+ if (fs12.existsSync(plistPath(label))) {
3048
+ const result = launchctl("bootout", `${guiDomain()}/${label}`);
3049
+ if (!result.ok && !/No such process|Could not find|not find service/i.test(result.output)) {
3050
+ console.error(`[cascade] launchctl bootout failed: ${result.output}`);
3051
+ }
3052
+ }
3053
+ const record = readJson(paths.record);
3054
+ const pid = supervisorPid(paths);
3055
+ if (!pid) {
3056
+ cleanup(paths, record);
3057
+ if (!quiet) console.log(`[cascade] not running (${project})`);
3058
+ return 0;
3059
+ }
3060
+ try {
3061
+ process.kill(pid, "SIGTERM");
3062
+ } catch {
3063
+ }
3064
+ const exited = await waitFor(() => !isAlive(pid), 15e3);
3065
+ if (!exited) {
3066
+ console.warn(`[cascade] supervisor ${pid} did not stop in 15s; killing it and its Studio`);
3067
+ try {
3068
+ process.kill(pid, "SIGKILL");
3069
+ } catch {
3070
+ }
3071
+ }
3072
+ cleanup(paths, record);
3073
+ if (!quiet) console.log(`[cascade] stopped ${project} (was pid ${pid})`);
3074
+ return 0;
3075
+ }
3076
+ function cleanup(paths, record) {
3077
+ const state = readJson(paths.server);
3078
+ const serverPid = state?.pid ?? record?.serverPid ?? null;
3079
+ if (serverPid && isAlive(serverPid)) {
3080
+ try {
3081
+ process.kill(-serverPid, "SIGKILL");
3082
+ } catch {
3083
+ try {
3084
+ process.kill(serverPid, "SIGKILL");
3085
+ } catch {
3086
+ }
3087
+ }
3088
+ }
3089
+ for (const worker of state?.workers ?? []) {
3090
+ if (worker.pid && isAlive(worker.pid)) {
3091
+ try {
3092
+ process.kill(worker.pid, "SIGKILL");
3093
+ } catch {
3094
+ }
3095
+ }
3096
+ }
3097
+ const lockHolder = readLockPid(paths.lock);
3098
+ if (lockHolder !== null && !isAlive(lockHolder)) releaseLock(paths.lock, lockHolder);
3099
+ if (!supervisorPid(paths)) {
3100
+ removeFile(paths.record);
3101
+ removeFile(paths.server);
3102
+ }
3103
+ }
3104
+ function supervisorPid(paths) {
3105
+ for (const pid of [readLockPid(paths.lock), readJson(paths.record)?.pid]) {
3106
+ if (pid && isAlive(pid)) return pid;
3107
+ }
3108
+ return null;
3109
+ }
3110
+ async function waitForListening(paths, supervisor, timeoutMs, notServerPid) {
3111
+ let state = null;
3112
+ await waitFor(() => {
3113
+ state = readJson(paths.server);
3114
+ const listening = !!state?.binding.host && isAlive(state.pid) && state.pid !== notServerPid;
3115
+ return listening || supervisor > 0 && !isAlive(supervisor) && !readLockPid(paths.lock);
3116
+ }, timeoutMs);
3117
+ const final = state;
3118
+ return final && final.binding.host && isAlive(final.pid) && final.pid !== notServerPid ? final : null;
3119
+ }
3120
+ async function waitFor(condition, timeoutMs, intervalMs = 200) {
3121
+ const deadline = Date.now() + timeoutMs;
3122
+ while (Date.now() < deadline) {
3123
+ if (condition()) return true;
3124
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
3125
+ }
3126
+ return condition();
3127
+ }
3128
+ function statusOf(paths) {
3129
+ const record = readJson(paths.record);
3130
+ const pid = supervisorPid(paths);
3131
+ const state = pid ? readJson(paths.server) : null;
3132
+ const serverAlive = !!state && isAlive(state.pid);
3133
+ const now = Date.now();
3134
+ const seconds = (iso) => iso ? Math.round((now - Date.parse(iso)) / 1e3) : null;
3135
+ const binding = serverAlive ? state.binding : null;
3136
+ const workers = serverAlive ? state.workers : [];
3137
+ const workerState = workers.some((worker) => worker.state === "busy") ? "busy" : workers.some((worker) => worker.state === "idle") ? "idle" : workers.length ? "stopped" : "none";
3138
+ return {
3139
+ project: record?.project ?? state?.project ?? null,
3140
+ id: paths.id,
3141
+ running: !!pid && serverAlive && !!binding?.host,
3142
+ manager: pid ? record?.manager ?? null : null,
3143
+ launchdInstalled: fs12.existsSync(plistPath(launchdLabel(paths.id))),
3144
+ pid,
3145
+ serverPid: serverAlive ? state.pid : null,
3146
+ host: binding?.host ?? null,
3147
+ port: binding?.port ?? (pid ? record?.options.port ?? null : null),
3148
+ preferredHost: binding?.preferredHost ?? (pid ? record?.options.host ?? null : null),
3149
+ fallback: binding?.fallback ?? false,
3150
+ fallbackReason: binding?.fallback ? binding.reason : null,
3151
+ url: binding?.host ? `http://${authority(binding.host, binding.port)}` : null,
3152
+ uptimeSeconds: pid ? seconds(record?.startedAt) : null,
3153
+ serverUptimeSeconds: serverAlive ? seconds(state.startedAt) : null,
3154
+ version: serverAlive ? state.version : pid ? record?.version ?? null : null,
3155
+ workerState,
3156
+ workers,
3157
+ restarts: pid ? record?.restarts ?? 0 : 0,
3158
+ lastExit: record?.lastExit ?? null,
3159
+ heartbeatAgeSeconds: serverAlive ? seconds(state.heartbeatAt) : null,
3160
+ logFile: paths.log
3161
+ };
3162
+ }
3163
+ function printStatuses(ids, parsed) {
3164
+ const statuses = ids.map((id) => statusOf(pathsForId(id)));
3165
+ const shown = parsed.all ? statuses.filter((entry) => entry.running || entry.pid) : statuses;
3166
+ if (parsed.json) {
3167
+ console.log(JSON.stringify(parsed.all ? shown : shown[0] ?? null, null, 2));
3168
+ } else if (shown.length === 0) {
3169
+ console.log("[cascade] no Studio is being served");
3170
+ } else {
3171
+ for (const entry of shown) printStatus(entry, false);
3172
+ }
3173
+ return 0;
3174
+ }
3175
+ function printStatus(entry, json6) {
3176
+ if (json6) {
3177
+ console.log(JSON.stringify(entry, null, 2));
3178
+ return;
3179
+ }
3180
+ const lines = [`${entry.project ?? entry.id}`];
3181
+ if (!entry.pid) {
3182
+ lines.push(" stopped");
3183
+ } else {
3184
+ lines.push(` ${entry.running ? "running" : "starting"} ${entry.url ?? "(not listening yet)"}${entry.fallback ? ` FALLBACK \u2014 ${entry.preferredHost} unavailable: ${entry.fallbackReason}` : ""}`);
3185
+ lines.push(` pid ${entry.pid} (supervisor, ${entry.manager}), Studio ${entry.serverPid ?? "-"}, up ${duration(entry.uptimeSeconds)}, v${entry.version ?? "?"}`);
3186
+ lines.push(` python worker: ${entry.workerState}${entry.workers.length ? ` (${entry.workers.map((worker) => `${worker.pid ?? "-"} ${worker.state}`).join(", ")})` : ""}`);
3187
+ if (entry.restarts) lines.push(` restarts: ${entry.restarts}${entry.lastExit ? `, last exit ${entry.lastExit.signal ?? `code ${entry.lastExit.code}`} at ${entry.lastExit.at}` : ""}`);
3188
+ }
3189
+ lines.push(` log: ${entry.logFile}${entry.launchdInstalled ? " (launchd job installed)" : ""}`);
3190
+ console.log(lines.join("\n"));
3191
+ }
3192
+ function duration(seconds) {
3193
+ if (seconds === null) return "?";
3194
+ if (seconds < 60) return `${seconds}s`;
3195
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
3196
+ return `${Math.floor(seconds / 3600)}h${Math.floor(seconds % 3600 / 60)}m`;
3197
+ }
3198
+ async function logs(project, parsed) {
3199
+ const paths = servicePaths(project);
3200
+ if (!fs12.existsSync(paths.log)) {
3201
+ console.error(`[cascade] no log yet at ${paths.log}`);
3202
+ return 1;
3203
+ }
3204
+ if (!parsed.follow) {
3205
+ for (const line of tailLines(paths.log, parsed.lines)) console.log(line);
3206
+ return 0;
3207
+ }
3208
+ return new Promise((resolve) => {
3209
+ const tail = spawn3("tail", ["-n", String(parsed.lines), "-F", paths.log], { stdio: "inherit" });
3210
+ tail.once("exit", (code) => resolve(code ?? 0));
3211
+ });
3212
+ }
3213
+ async function install(project, parsed) {
3214
+ const paths = servicePaths(project);
3215
+ const options = resolveOptions(paths, parsed.overrides);
3216
+ const cli = cliInvocation();
3217
+ const label = launchdLabel(paths.id);
3218
+ const plist = renderPlist({
3219
+ label,
3220
+ programArguments: [cli.command, ...cli.args, "serve", "run", project, ...optionFlags(options)],
3221
+ workingDirectory: project,
3222
+ logFile: paths.log,
3223
+ environment: {
3224
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
3225
+ HOME: process.env.HOME ?? "",
3226
+ CASCADE_SERVE_MANAGER: "launchd",
3227
+ ...process.env.CASCADE_SERVE_DIR ? { CASCADE_SERVE_DIR: process.env.CASCADE_SERVE_DIR } : {}
3228
+ }
3229
+ });
3230
+ if (parsed.print) {
3231
+ process.stdout.write(plist);
3232
+ return 0;
3233
+ }
3234
+ if (process.platform !== "darwin") {
3235
+ console.error("[cascade] serve install writes a launchd job and is macOS only; use --print to see it");
3236
+ return 1;
3237
+ }
3238
+ const file = plistPath(label);
3239
+ const wasInstalled = fs12.existsSync(file);
3240
+ if (wasInstalled) launchctl("bootout", `${guiDomain()}/${label}`);
3241
+ if (supervisorPid(paths)) await stop(project, true);
3242
+ fs12.mkdirSync(paths.dir, { recursive: true });
3243
+ fs12.mkdirSync(path12.dirname(file), { recursive: true });
3244
+ fs12.writeFileSync(file, plist);
3245
+ fs12.writeFileSync(paths.options, JSON.stringify(options, null, 2) + "\n");
3246
+ console.log(`[cascade] wrote ${file}`);
3247
+ if (!parsed.load) {
3248
+ console.log(`[cascade] not loaded; run: launchctl bootstrap ${guiDomain()} ${JSON.stringify(file)}`);
3249
+ return 0;
3250
+ }
3251
+ return startLaunchd(project, paths, parsed.waitMs, parsed.json);
3252
+ }
3253
+ async function uninstall(project) {
3254
+ const paths = servicePaths(project);
3255
+ const label = launchdLabel(paths.id);
3256
+ const file = plistPath(label);
3257
+ if (!fs12.existsSync(file)) {
3258
+ console.log(`[cascade] no launchd job installed for ${project}`);
3259
+ return 0;
3260
+ }
3261
+ await stop(project, true);
3262
+ fs12.unlinkSync(file);
3263
+ console.log(`[cascade] removed ${file}; Studio is stopped`);
3264
+ return 0;
3265
+ }
3266
+ async function startLaunchd(project, paths, waitMs, json6) {
3267
+ const label = launchdLabel(paths.id);
3268
+ if (!supervisorPid(paths)) {
3269
+ const result = launchctl("bootstrap", guiDomain(), plistPath(label));
3270
+ if (!result.ok) {
3271
+ const kick = launchctl("kickstart", `${guiDomain()}/${label}`);
3272
+ if (!kick.ok) {
3273
+ console.error(`[cascade] launchctl could not start ${label}: ${result.output}; ${kick.output}`);
3274
+ return 1;
3275
+ }
3276
+ }
3277
+ }
3278
+ const state = await waitForListening(paths, 0, waitMs);
3279
+ if (!state) {
3280
+ console.error(`[cascade] launchd job ${label} did not report listening within ${waitMs / 1e3}s. Last log lines:`);
3281
+ for (const line of tailLines(paths.log, 20)) console.error(` ${line}`);
3282
+ return 1;
3283
+ }
3284
+ printStatus(statusOf(paths), json6);
3285
+ return 0;
3286
+ }
3287
+ async function restartLaunchd(project, paths, waitMs, json6) {
3288
+ const label = launchdLabel(paths.id);
3289
+ const previous = readJson(paths.server)?.pid;
3290
+ const result = launchctl("kickstart", "-k", `${guiDomain()}/${label}`);
3291
+ if (!result.ok) return startLaunchd(project, paths, waitMs, json6);
3292
+ const state = await waitForListening(paths, 0, waitMs, previous);
3293
+ if (!state) {
3294
+ console.error(`[cascade] ${label} did not come back within ${waitMs / 1e3}s; see ${paths.log}`);
3295
+ return 1;
3296
+ }
3297
+ printStatus(statusOf(paths), json6);
3298
+ return 0;
3299
+ }
3300
+
3301
+ // server/src/serve/state.ts
3302
+ function startStateReporter(file, snapshot, intervalMs = 2e3) {
3303
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3304
+ const update = () => {
3305
+ try {
3306
+ writeJsonAtomic(file, { pid: process.pid, startedAt, heartbeatAt: (/* @__PURE__ */ new Date()).toISOString(), ...snapshot() });
3307
+ } catch (error) {
3308
+ console.warn(`[cascade] could not write serve state to ${file}: ${error.message}`);
3309
+ }
3310
+ };
3311
+ update();
3312
+ const timer = setInterval(update, intervalMs);
3313
+ timer.unref();
3314
+ return {
3315
+ update,
3316
+ stop() {
3317
+ clearInterval(timer);
3318
+ removeFile(file);
3319
+ }
3320
+ };
3321
+ }
3322
+
3323
+ // server/src/cliCommands.ts
3324
+ function parseStudioArgs(args) {
3325
+ const positional = [];
3326
+ const trustedHosts = [];
3327
+ const fallbackHosts = [];
3328
+ let noOpen = false;
3329
+ let host;
3330
+ let port;
3331
+ for (let index = 0; index < args.length; index++) {
3332
+ const argument = args[index];
3333
+ if (argument === "--no-open") {
3334
+ noOpen = true;
3335
+ continue;
3336
+ }
3337
+ const [name, inlineValue] = argument.startsWith("--") ? argument.split("=", 2) : [argument, void 0];
3338
+ if (name === "--host" || name === "--port" || name === "--trusted-host" || name === "--fallback-host") {
3339
+ const value = inlineValue ?? args[++index];
3340
+ if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
3341
+ if (name === "--host") host = value;
3342
+ else if (name === "--fallback-host") {
3343
+ if (isWildcardHost(value)) throw new Error("--fallback-host must be a specific address, not a wildcard");
3344
+ fallbackHosts.push(value);
3345
+ } else if (name === "--trusted-host") {
3346
+ trustedAuthority(value, 1);
3347
+ trustedHosts.push(value);
3348
+ } else {
3349
+ port = Number(value);
3350
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3351
+ throw new Error("--port must be an integer between 1 and 65535");
3352
+ }
3353
+ }
3354
+ continue;
3355
+ }
3356
+ if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
3357
+ positional.push(argument);
3358
+ }
3359
+ if (trustedHosts.length > 0 && !host) {
3360
+ throw new Error("--trusted-host requires an explicit --host bind address, loopback or otherwise");
3361
+ }
3362
+ if (host && !isLoopbackHost(host)) {
3363
+ if (host === "0.0.0.0" || host === "::" || host === "[::]") {
3364
+ throw new Error("remote Studio must bind a specific hostname or VPN/interface address, not a wildcard address");
3365
+ }
3366
+ if (trustedHosts.length === 0) trustedHosts.push(host);
3367
+ }
3368
+ return {
3369
+ positional,
3370
+ noOpen,
3371
+ ...host === void 0 ? {} : { host },
3372
+ ...port === void 0 ? {} : { port },
3373
+ trustedHosts,
3374
+ ...fallbackHosts.length ? { fallbackHosts } : {}
3375
+ };
3376
+ }
3377
+ async function runStudioCli(args) {
3378
+ if (args[0] === "serve") {
3379
+ process.exitCode = await runServeCommand(args.slice(1), resolveProjectTarget);
3380
+ return;
3381
+ }
3382
+ const { noOpen, positional, host, port, trustedHosts, fallbackHosts } = parseStudioArgs(args);
3383
+ const config = loadConfig();
3384
+ if (positional[0] === "projects") {
3385
+ if (positional[1]) {
3386
+ saveConfig({ projectsRoot: path13.resolve(positional[1].replace(/^~(?=\/|$)/, process.env.HOME ?? "~")) });
3387
+ console.log(`[cascade] projects root set to ${loadConfig().projectsRoot} (${configPath()})`);
3388
+ } else console.log(loadConfig().projectsRoot);
3389
+ return;
3390
+ }
3391
+ if (positional[0] === "new") {
3392
+ if (!positional[1]) throw new Error("usage: cascade new <name>");
3393
+ const directory = createProject(config.projectsRoot, positional[1]);
3394
+ console.log(`[cascade] created ${directory}`);
3395
+ positional[0] = directory;
3396
+ }
3397
+ if (positional[0] === "node") {
3398
+ if (!positional[1]) throw new Error("usage: cascade node <Name> [project-directory]");
3399
+ const project2 = ProjectRoot.fromArg(positional[2] ?? ".");
3400
+ const directory = createNode(project2.root, positional[1]);
3401
+ console.log(`[cascade] created project.${positional[1]} at ${directory}`);
3402
+ return;
3403
+ }
3404
+ const project = ProjectRoot.fromArg(resolveProjectTarget(positional[0] ?? "."));
3405
+ const envFallbacks = (process.env.CASCADE_FALLBACK_HOSTS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
3406
+ const server = startServer(project, {
3407
+ host,
3408
+ port,
3409
+ trustedHosts,
3410
+ ...fallbackHosts || envFallbacks.length ? { fallbackHosts: fallbackHosts ?? envFallbacks } : {}
3411
+ });
3412
+ server.on("error", (error) => {
3413
+ if (isHostUnavailable(error)) return;
3414
+ console.error(`[cascade] Studio could not listen: ${error.message}`);
3415
+ process.exit(1);
3416
+ });
3417
+ const stateFile = process.env.CASCADE_SERVE_STATE;
3418
+ const reporter = stateFile ? startStateReporter(stateFile, () => ({
3419
+ project: project.root,
3420
+ version: cascadeVersion(),
3421
+ binding: server.binding.state(),
3422
+ workers: pythonWorkerStatuses()
3423
+ })) : null;
3424
+ let stopping = false;
3425
+ const shutdown = (signal) => {
3426
+ if (stopping) return;
3427
+ stopping = true;
3428
+ console.log(`[cascade] ${signal}: stopping Studio`);
3429
+ server.binding.stop();
3430
+ shutdownAllPythonWorkers();
3431
+ reporter?.stop();
3432
+ server.close(() => process.exit(0));
3433
+ server.closeAllConnections?.();
3434
+ setTimeout(() => process.exit(0), 3e3).unref();
3435
+ };
3436
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
3437
+ process.once("SIGINT", () => shutdown("SIGINT"));
3438
+ server.binding.ready.then(async (bound) => {
3439
+ reporter?.update();
3440
+ const defaultGraph = await project.resolveDefaultGraph();
3441
+ console.log(defaultGraph ? `\u{1F4C4} Opening ${defaultGraph}` : "\u{1F4C4} No default graph (name one explicitly, or add index.cascade) \u2014 starting empty");
3442
+ const port2 = bound.port;
3443
+ const browserHost = bound.fallback ? isLoopbackHost(bound.host) ? "localhost" : bound.host : trustedHosts[0] ?? (host && !isLoopbackHost(host) ? host : "localhost");
3444
+ const url = `http://${trustedAuthority(browserHost, port2)}`;
3445
+ if (!noOpen) {
3446
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3447
+ execFile3(opener, [url], (error) => {
3448
+ if (error) console.warn(`[cascade] couldn't auto-open a browser (${error.message}) \u2014 open ${url} yourself.`);
3449
+ });
3450
+ }
3451
+ }, () => {
3452
+ });
3453
+ }
3454
+ function resolveProjectTarget(target) {
3455
+ if (fs13.existsSync(path13.resolve(target))) return path13.resolve(target);
3456
+ const projectTarget = path13.resolve(loadConfig().projectsRoot, target);
3457
+ return fs13.existsSync(projectTarget) ? projectTarget : path13.resolve(target);
3458
+ }
3459
+ export {
3460
+ parseStudioArgs,
3461
+ resolveProjectTarget,
3462
+ runStudioCli
3463
+ };
3464
+ //# sourceMappingURL=cliCommands-UQSFGEHF.js.map