@fieldwangai/agentflow 0.1.28 → 0.1.30

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 (63) hide show
  1. package/agents/agentflow-node-executor-code.md +3 -2
  2. package/agents/agentflow-node-executor-planning.md +3 -2
  3. package/agents/agentflow-node-executor-requirement.md +3 -2
  4. package/agents/agentflow-node-executor-test.md +3 -2
  5. package/agents/agentflow-node-executor-ui.md +3 -2
  6. package/agents/agentflow-node-executor.md +3 -2
  7. package/agents/en/agentflow-node-executor.md +3 -2
  8. package/agents/zh/agentflow-node-executor.md +3 -2
  9. package/bin/lib/agent-runners.mjs +38 -13
  10. package/bin/lib/api-runner.mjs +6 -3
  11. package/bin/lib/auth.mjs +240 -0
  12. package/bin/lib/catalog-agents.mjs +2 -2
  13. package/bin/lib/catalog-flows.mjs +192 -16
  14. package/bin/lib/composer-agent.mjs +21 -1
  15. package/bin/lib/composer-skill-router.mjs +10 -78
  16. package/bin/lib/flow-import.mjs +2 -2
  17. package/bin/lib/flow-write.mjs +20 -20
  18. package/bin/lib/help.mjs +2 -2
  19. package/bin/lib/locales/en.json +25 -1
  20. package/bin/lib/locales/zh.json +25 -1
  21. package/bin/lib/main.mjs +6 -1
  22. package/bin/lib/node-exec-context.mjs +5 -5
  23. package/bin/lib/node-execute.mjs +14 -9
  24. package/bin/lib/paths.mjs +64 -13
  25. package/bin/lib/recent-runs.mjs +2 -2
  26. package/bin/lib/run-node-statuses-from-disk.mjs +3 -3
  27. package/bin/lib/runtime-context.mjs +225 -0
  28. package/bin/lib/scheduler.mjs +41 -38
  29. package/bin/lib/skill-registry.mjs +145 -0
  30. package/bin/lib/ui-server.mjs +902 -57
  31. package/bin/lib/workspace-tree.mjs +4 -3
  32. package/bin/lib/workspace.mjs +9 -11
  33. package/bin/pipeline/build-node-prompt.mjs +29 -4
  34. package/bin/pipeline/get-exec-id.mjs +2 -2
  35. package/bin/pipeline/get-resolved-values.mjs +1 -0
  36. package/bin/pipeline/pre-process-node.mjs +306 -6
  37. package/bin/pipeline/validate-flow.mjs +2 -0
  38. package/builtin/nodes/agent_subAgent.md +7 -1
  39. package/builtin/nodes/control_cd_workspace.md +43 -0
  40. package/builtin/nodes/control_load_skills.md +48 -0
  41. package/builtin/nodes/display_ascii.md +17 -0
  42. package/builtin/nodes/display_markdown.md +17 -0
  43. package/builtin/nodes/display_mermaid.md +17 -0
  44. package/builtin/nodes/tool_git_checkout.md +54 -0
  45. package/builtin/nodes/tool_nodejs.md +8 -1
  46. package/builtin/nodes/tool_print.md +4 -1
  47. package/builtin/web-ui/dist/assets/index-NdVOJLL9.js +196 -0
  48. package/builtin/web-ui/dist/assets/index-naVI6LZj.css +1 -0
  49. package/builtin/web-ui/dist/index.html +2 -2
  50. package/package.json +2 -1
  51. package/skills/agentflow-flow-add-instances/SKILL.md +257 -0
  52. package/skills/agentflow-flow-edit-node-fields/SKILL.md +79 -0
  53. package/skills/agentflow-flow-recipes/SKILL.md +24 -0
  54. package/skills/agentflow-flow-recipes/references/recipes.md +63 -0
  55. package/skills/agentflow-flow-sync-ui/SKILL.md +59 -0
  56. package/skills/agentflow-node-reference/SKILL.md +25 -0
  57. package/skills/agentflow-node-reference/references/builtin-nodes.md +210 -0
  58. package/skills/agentflow-placeholder-reference/SKILL.md +24 -0
  59. package/skills/agentflow-placeholder-reference/references/placeholders.md +20 -0
  60. package/skills/agentflow-runtime-reference/SKILL.md +25 -0
  61. package/skills/agentflow-runtime-reference/references/runtime.md +64 -0
  62. package/builtin/web-ui/dist/assets/index-BeUBxIj1.js +0 -190
  63. package/builtin/web-ui/dist/assets/index-BzhdjOzb.css +0 -1
@@ -8,10 +8,17 @@
8
8
  import fs from "fs";
9
9
  import http from "http";
10
10
  import path from "path";
11
- import { spawn } from "child_process";
11
+ import { execFile, spawn } from "child_process";
12
12
  import busboy from "busboy";
13
13
  import { log } from "./log.mjs";
14
- import { getFlowYamlAbs, listFlowsJson, listNodesJson, readFlowJson } from "./catalog-flows.mjs";
14
+ import {
15
+ getFlowYamlAbs,
16
+ listFlowsJson,
17
+ listNodesJson,
18
+ readFlowJson,
19
+ readNodeDetailJson,
20
+ readNodeFilePreview,
21
+ } from "./catalog-flows.mjs";
15
22
  import {
16
23
  FLOW_YAML_FILENAME,
17
24
  archiveFlowPipeline,
@@ -42,6 +49,7 @@ import {
42
49
  loadResourcesForIntents,
43
50
  loadResourcesForSkillKeys,
44
51
  listComposerSkills,
52
+ readComposerSkillDetail,
45
53
  buildSkillInjectionBlock,
46
54
  buildSkillCompactInjectionBlock,
47
55
  } from "./composer-skill-router.mjs";
@@ -65,6 +73,14 @@ import { runNodeScript } from "./pipeline-scripts.mjs";
65
73
  import { readFlowSchedule, writeFlowSchedule } from "./schedule-config.mjs";
66
74
  import { listScheduleStatuses } from "./scheduler.mjs";
67
75
  import { installFlowDependency, listMarketplacePackages, publishNodeFromInstance } from "./marketplace.mjs";
76
+ import {
77
+ authSetupRequired,
78
+ buildClearSessionCookie,
79
+ buildSessionCookie,
80
+ getAuthUserFromRequest,
81
+ loginOrCreateUser,
82
+ logoutRequest,
83
+ } from "./auth.mjs";
68
84
 
69
85
  const MIME = {
70
86
  ".html": "text/html; charset=utf-8",
@@ -123,6 +139,99 @@ function readModelListsFromDisk(workspaceRoot) {
123
139
  }
124
140
  }
125
141
 
142
+ const SKILLHUB_TIMEOUT_MS = 60_000;
143
+
144
+ function runSkillhub(args, opts = {}) {
145
+ return new Promise((resolve) => {
146
+ execFile("skillhub", args, {
147
+ cwd: opts.cwd || process.cwd(),
148
+ timeout: opts.timeoutMs || SKILLHUB_TIMEOUT_MS,
149
+ maxBuffer: opts.maxBuffer || 2 * 1024 * 1024,
150
+ env: {
151
+ ...process.env,
152
+ FORCE_COLOR: "0",
153
+ },
154
+ }, (error, stdout, stderr) => {
155
+ const out = String(stdout || "");
156
+ const err = String(stderr || "");
157
+ resolve({
158
+ ok: !error,
159
+ code: error && typeof error.code === "number" ? error.code : 0,
160
+ error: error ? (err.trim() || error.message || "skillhub failed") : "",
161
+ stdout: out,
162
+ stderr: err,
163
+ });
164
+ });
165
+ });
166
+ }
167
+
168
+ function parseJsonText(text, fallback = null) {
169
+ const s = String(text || "").trim();
170
+ if (!s) return fallback;
171
+ try {
172
+ return JSON.parse(s);
173
+ } catch {
174
+ const match = s.match(/(\{[\s\S]*\}|\[[\s\S]*\])\s*$/);
175
+ if (!match) return fallback;
176
+ try { return JSON.parse(match[1]); } catch { return fallback; }
177
+ }
178
+ }
179
+
180
+ function normalizeSkillhubSearchPayload(raw) {
181
+ const data = raw && typeof raw === "object" ? raw : {};
182
+ const items = Array.isArray(data.items) ? data.items : Array.isArray(data.results) ? data.results : [];
183
+ return {
184
+ total: Number(data.total) || items.length,
185
+ mode: typeof data.mode === "string" ? data.mode : "",
186
+ degraded: Boolean(data.degraded),
187
+ items: items.map((item) => {
188
+ const x = item && typeof item === "object" ? item : {};
189
+ const id = x.id ?? x.skillId ?? x.skill_id ?? "";
190
+ const slug = String(x.slug ?? x.name ?? x.displayName ?? x.display_name ?? id ?? "").trim();
191
+ return {
192
+ id: String(id || slug),
193
+ slug,
194
+ name: String(x.displayName ?? x.display_name ?? x.name ?? slug),
195
+ summary: String(x.summary ?? x.description ?? ""),
196
+ version: String(x.version ?? x.latestVersion ?? x.latest_version ?? ""),
197
+ tags: Array.isArray(x.tags) ? x.tags.map(String) : [],
198
+ };
199
+ }).filter((x) => x.slug || x.name),
200
+ };
201
+ }
202
+
203
+ function normalizeSkillhubListPayload(raw) {
204
+ const arr = Array.isArray(raw) ? raw : [];
205
+ return arr.map((x) => ({
206
+ name: String(x?.name ?? ""),
207
+ baseDir: String(x?.baseDir ?? ""),
208
+ path: String(x?.path ?? ""),
209
+ kind: String(x?.kind ?? ""),
210
+ agent: String(x?.agent ?? ""),
211
+ })).filter((x) => x.name);
212
+ }
213
+
214
+ function skillhubInstallArgs(payload, { uninstall = false } = {}) {
215
+ const slug = String(payload?.slug || payload?.name || "").trim();
216
+ if (!slug && !payload?.collection) return null;
217
+ const args = [uninstall ? "uninstall" : "install"];
218
+ if (payload?.collection) {
219
+ args.push("--collection", String(payload.collection).trim());
220
+ } else {
221
+ args.push(slug);
222
+ }
223
+ if (payload?.skillId) args.push("--skill-id", String(payload.skillId).trim());
224
+ const target = String(payload?.target || "project").trim();
225
+ const agent = String(payload?.agent || "codex").trim();
226
+ if (target === "global") {
227
+ args.push("--global", "--agent", agent);
228
+ } else if (payload?.dir) {
229
+ args.push("--dir", String(payload.dir).trim());
230
+ }
231
+ if (payload?.force) args.push("--force");
232
+ return args;
233
+ }
234
+
126
235
  function readBody(req) {
127
236
  return new Promise((resolve, reject) => {
128
237
  const chunks = [];
@@ -132,6 +241,231 @@ function readBody(req) {
132
241
  });
133
242
  }
134
243
 
244
+ const WORKSPACE_FILE_SKIP_DIRS = new Set([
245
+ ".git",
246
+ "node_modules",
247
+ ".next",
248
+ ".nuxt",
249
+ ".turbo",
250
+ "dist",
251
+ "build",
252
+ "coverage",
253
+ ]);
254
+
255
+ const WORKSPACE_TEXT_EXTS = new Set([
256
+ ".md",
257
+ ".markdown",
258
+ ".txt",
259
+ ".json",
260
+ ".yaml",
261
+ ".yml",
262
+ ".js",
263
+ ".jsx",
264
+ ".ts",
265
+ ".tsx",
266
+ ".css",
267
+ ".html",
268
+ ".mjs",
269
+ ".cjs",
270
+ ]);
271
+
272
+ function resolveWorkspaceFilePath(workspaceRoot, relPath) {
273
+ const root = path.resolve(workspaceRoot);
274
+ const rel = String(relPath || "").replace(/^[/\\]+/, "");
275
+ const abs = path.resolve(root, rel);
276
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
277
+ throw new Error("Path traversal not allowed");
278
+ }
279
+ return { root, rel: path.relative(root, abs).replace(/\\/g, "/"), abs };
280
+ }
281
+
282
+ function workspaceFileIcon(fileName, isDir = false) {
283
+ if (isDir) return "folder";
284
+ const ext = path.extname(fileName).toLowerCase();
285
+ if (ext === ".md" || ext === ".markdown") return "article";
286
+ if ([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"].includes(ext)) return "code";
287
+ if ([".yaml", ".yml", ".json"].includes(ext)) return "data_object";
288
+ if (ext === ".css") return "palette";
289
+ if (ext === ".html") return "web";
290
+ return "draft";
291
+ }
292
+
293
+ function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget = { count: 0 }) {
294
+ if (depth > maxDepth || budget.count > 500) return [];
295
+ let entries;
296
+ try {
297
+ entries = fs.readdirSync(dir, { withFileTypes: true });
298
+ } catch {
299
+ return [];
300
+ }
301
+ const out = [];
302
+ for (const entry of entries) {
303
+ if (budget.count > 500) break;
304
+ if (entry.name.startsWith(".") && entry.name !== ".agents" && entry.name !== ".codex") continue;
305
+ const abs = path.join(dir, entry.name);
306
+ const rel = path.relative(root, abs).replace(/\\/g, "/");
307
+ if (entry.isDirectory()) {
308
+ if (WORKSPACE_FILE_SKIP_DIRS.has(entry.name)) continue;
309
+ budget.count++;
310
+ out.push({
311
+ type: "directory",
312
+ name: entry.name,
313
+ path: rel,
314
+ icon: workspaceFileIcon(entry.name, true),
315
+ children: readWorkspaceFilesRecursive(abs, root, depth + 1, maxDepth, budget),
316
+ });
317
+ } else if (entry.isFile()) {
318
+ const ext = path.extname(entry.name).toLowerCase();
319
+ if (!WORKSPACE_TEXT_EXTS.has(ext)) continue;
320
+ let size = 0;
321
+ try { size = fs.statSync(abs).size; } catch {}
322
+ budget.count++;
323
+ out.push({ type: "file", name: entry.name, path: rel, icon: workspaceFileIcon(entry.name), size });
324
+ }
325
+ }
326
+ out.sort((a, b) => {
327
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
328
+ return a.name.localeCompare(b.name);
329
+ });
330
+ return out;
331
+ }
332
+
333
+ function readWorkspaceFiles(workspaceRoot) {
334
+ const root = path.resolve(workspaceRoot);
335
+ return { root, files: readWorkspaceFilesRecursive(root, root) };
336
+ }
337
+
338
+ const WORKSPACE_GRAPH_FILENAME = "workspace.graph.json";
339
+
340
+ function workspaceGraphPath(workspaceRoot) {
341
+ return path.join(path.resolve(workspaceRoot), WORKSPACE_GRAPH_FILENAME);
342
+ }
343
+
344
+ function emptyWorkspaceGraph() {
345
+ return { version: 1, instances: {}, edges: [], ui: { nodePositions: {} } };
346
+ }
347
+
348
+ function readWorkspaceGraph(workspaceRoot) {
349
+ const graphPath = workspaceGraphPath(workspaceRoot);
350
+ if (!fs.existsSync(graphPath)) return { path: graphPath, graph: emptyWorkspaceGraph() };
351
+ const raw = fs.readFileSync(graphPath, "utf-8");
352
+ if (!raw.trim()) return { path: graphPath, graph: emptyWorkspaceGraph() };
353
+ const parsed = JSON.parse(raw);
354
+ const graph = parsed && typeof parsed === "object" ? parsed : {};
355
+ return {
356
+ path: graphPath,
357
+ graph: {
358
+ version: Number(graph.version) || 1,
359
+ instances: graph.instances && typeof graph.instances === "object" && !Array.isArray(graph.instances) ? graph.instances : {},
360
+ edges: Array.isArray(graph.edges) ? graph.edges : [],
361
+ ui: graph.ui && typeof graph.ui === "object" ? graph.ui : { nodePositions: {} },
362
+ },
363
+ };
364
+ }
365
+
366
+ function normalizeWorkspaceGraphPayload(payload) {
367
+ const graph = payload?.graph && typeof payload.graph === "object" ? payload.graph : payload;
368
+ return {
369
+ version: 1,
370
+ instances: graph?.instances && typeof graph.instances === "object" && !Array.isArray(graph.instances) ? graph.instances : {},
371
+ edges: Array.isArray(graph?.edges) ? graph.edges : [],
372
+ ui: graph?.ui && typeof graph.ui === "object" ? graph.ui : { nodePositions: {} },
373
+ updatedAt: new Date().toISOString(),
374
+ };
375
+ }
376
+
377
+ function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
378
+ const flowId = params.flowId != null ? String(params.flowId).trim() : "";
379
+ if (!flowId) return { root: path.resolve(workspaceRoot), flowId: "", flowSource: "", archived: false };
380
+ const flowSource = params.flowSource != null && String(params.flowSource).trim()
381
+ ? String(params.flowSource).trim()
382
+ : "user";
383
+ const archived = params.archived === true || params.archived === "1" || params.flowArchived === true;
384
+ if (!isValidFlowSourceRead(flowSource)) {
385
+ return { root: "", error: "Invalid flowSource" };
386
+ }
387
+ const result = getPipelineFiles(workspaceRoot, flowId, flowSource, archived, opts);
388
+ if (result.error || !result.path) {
389
+ return { root: "", error: result.error || "Pipeline workspace not found" };
390
+ }
391
+ return { root: path.resolve(result.path), flowId, flowSource, archived };
392
+ }
393
+
394
+ function buildWorkspaceGeneratePrompt(payload) {
395
+ const userPrompt = String(payload?.prompt || "").trim();
396
+ const outputKind = String(payload?.outputKind || payload?.kind || "markdown").trim().toLowerCase();
397
+ const allowFlowYaml = payload?.allowFlowYaml === true || payload?.allowFlowYaml === "1";
398
+ const workspaceGraph = payload?.workspaceGraph && typeof payload.workspaceGraph === "object" ? payload.workspaceGraph : null;
399
+ const selectedNodeIds = Array.isArray(payload?.selectedNodeIds)
400
+ ? payload.selectedNodeIds.map((id) => String(id || "").trim()).filter(Boolean)
401
+ : [];
402
+ const skillsBlock = typeof payload?.skillsBlock === "string" ? payload.skillsBlock.trim() : "";
403
+ const contexts = Array.isArray(payload?.contexts) ? payload.contexts : [];
404
+ const contextBlocks = contexts
405
+ .map((ctx, idx) => {
406
+ const title = String(ctx?.title || ctx?.path || `context-${idx + 1}`).trim();
407
+ const kind = String(ctx?.kind || "text").trim();
408
+ const content = String(ctx?.content || "").trim();
409
+ if (!content) return "";
410
+ return `### ${title} (${kind})\n\n${content}`;
411
+ })
412
+ .filter(Boolean)
413
+ .join("\n\n---\n\n");
414
+ const kindInstruction =
415
+ outputKind === "mermaid"
416
+ ? [
417
+ "你是 workspace Mermaid 图节点的内容生成器。",
418
+ "请根据用户 prompt 和上游节点/文件上下文生成 Mermaid flowchart 源码。",
419
+ "只输出 Mermaid 源码,不要解释,不要包裹 Markdown 代码围栏。",
420
+ "优先使用 `flowchart TD` 或 `graph TD`,节点 ID 使用简单英文/数字/下划线,节点 label 使用清晰短文本。",
421
+ ].join("\n")
422
+ : outputKind === "ascii"
423
+ ? [
424
+ "你是 workspace ASCII 图节点的内容生成器。",
425
+ "请根据用户 prompt 和上游节点/文件上下文生成等宽字体下可读的 ASCII 图。",
426
+ "只输出 ASCII 图正文,不要解释,不要包裹 Markdown 代码围栏。",
427
+ "使用 +-|/\\<> 等字符表达结构,尽量保持对齐。",
428
+ ].join("\n")
429
+ : [
430
+ "你是 workspace Markdown 节点的内容生成器。",
431
+ "请根据用户 prompt 和上游节点/文件上下文,生成可直接保存到工作区的 Markdown 正文。",
432
+ "只输出最终 Markdown 内容,不要解释你如何执行,也不要包裹代码围栏,除非正文本身需要代码块。",
433
+ ].join("\n");
434
+ return [
435
+ "你正在 AgentFlow 的 Workspace 工作画布中执行任务。",
436
+ "Workspace 是当前 pipeline 的临时工作区,用于分析、试验、生成中间文件和展示结果。",
437
+ allowFlowYaml
438
+ ? "用户已允许你考虑正式 flow.yaml;如需修改仍必须明确说明影响。"
439
+ : "默认不要修改正式 flow.yaml;优先在 workspace 文件、workspace.graph.json 或回复内容中完成任务。",
440
+ workspaceGraph ? `\n## 当前 workspace graph\n\n${JSON.stringify(workspaceGraph, null, 2)}` : "",
441
+ selectedNodeIds.length > 0 ? `\n## 当前用户选中的 workspace 节点\n\n${selectedNodeIds.map((id) => `- ${id}`).join("\n")}` : "",
442
+ skillsBlock ? `\n## Workspace Skills\n\n${skillsBlock}` : "",
443
+ kindInstruction,
444
+ contextBlocks ? `\n## 上下文\n\n${contextBlocks}` : "",
445
+ `\n## 用户 prompt\n\n${userPrompt}`,
446
+ ].filter(Boolean).join("\n");
447
+ }
448
+
449
+ function isTransientAgentNetworkError(err) {
450
+ const text = [
451
+ err?.message,
452
+ err?.cursorStderrTail,
453
+ err?.stderr,
454
+ err?.stack,
455
+ ].filter(Boolean).join("\n");
456
+ return /Client network socket disconnected before secure TLS connection was established/i.test(text) ||
457
+ /secure TLS connection was established/i.test(text) ||
458
+ /\bECONNRESET\b/i.test(text) ||
459
+ /\bETIMEDOUT\b/i.test(text) ||
460
+ /\bEAI_AGAIN\b/i.test(text) ||
461
+ /network socket disconnected/i.test(text) ||
462
+ /socket hang up/i.test(text);
463
+ }
464
+
465
+ function sleepMs(ms) {
466
+ return new Promise((resolve) => setTimeout(resolve, ms));
467
+ }
468
+
135
469
  /** ZIP 本地头:PK\x03\x04 / \x05\x06 / \x07\x08 */
136
470
  function bufferLooksLikeZip(buf) {
137
471
  return (
@@ -211,12 +545,12 @@ const flowEditorSyncSubscribers = new Map();
211
545
  /** 每次 broadcastFlowEditorSync 时递增,供轮询端点 /api/flow-editor-sync-poll 使用 */
212
546
  const flowEditorSyncVersions = new Map();
213
547
 
214
- function flowEditorSyncKey(flowId, flowSource, flowArchived) {
215
- return `${String(flowId)}\t${String(flowSource)}\t${flowArchived ? "1" : "0"}`;
548
+ function flowEditorSyncKey(flowId, flowSource, flowArchived, userId = "") {
549
+ return `${String(userId || "")}\t${String(flowId)}\t${String(flowSource)}\t${flowArchived ? "1" : "0"}`;
216
550
  }
217
551
 
218
- function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false) {
219
- const key = flowEditorSyncKey(flowId, flowSource, flowArchived);
552
+ function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false, userId = "") {
553
+ const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userId);
220
554
 
221
555
  /* 递增轮询版本号 */
222
556
  flowEditorSyncVersions.set(key, (flowEditorSyncVersions.get(key) ?? 0) + 1);
@@ -340,6 +674,7 @@ function normalizeContextInstanceIds(raw) {
340
674
  * @param {object} opts
341
675
  * @param {string} opts.workspaceRoot
342
676
  * @param {number} opts.port
677
+ * @param {boolean} [opts.hideCommunityLinks]
343
678
  * @param {string} [opts.staticDir] 默认 PACKAGE_ROOT/builtin/web-ui/dist(npm run build 产出)
344
679
  * @returns {Promise<import('http').Server>}
345
680
  */
@@ -347,10 +682,12 @@ export function startUiServer({
347
682
  workspaceRoot,
348
683
  port,
349
684
  host = "127.0.0.1",
685
+ hideCommunityLinks = false,
350
686
  staticDir = path.join(PACKAGE_ROOT, "builtin", "web-ui", "dist"),
351
687
  }) {
352
688
  const root = path.resolve(workspaceRoot);
353
689
  const uiPort = port;
690
+ const uiConfig = { hideCommunityLinks: Boolean(hideCommunityLinks) };
354
691
 
355
692
  const server = http.createServer(async (req, res) => {
356
693
  const url = new URL(req.url || "/", "http://127.0.0.1");
@@ -363,10 +700,58 @@ export function startUiServer({
363
700
  return origEnd(...args);
364
701
  };
365
702
 
703
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
704
+ const user = getAuthUserFromRequest(req);
705
+ json(res, 200, { authenticated: Boolean(user), user: user || null, setupRequired: authSetupRequired() });
706
+ return;
707
+ }
708
+
709
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
710
+ let payload;
711
+ try {
712
+ payload = JSON.parse(await readBody(req));
713
+ } catch {
714
+ json(res, 400, { error: "Invalid JSON body" });
715
+ return;
716
+ }
717
+ const result = loginOrCreateUser(payload?.username, payload?.password);
718
+ if (!result.ok) {
719
+ json(res, 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
720
+ return;
721
+ }
722
+ const body = JSON.stringify({ authenticated: true, user: result.user, setupRequired: false, migration: result.migration || null });
723
+ res.writeHead(200, {
724
+ "Content-Type": "application/json; charset=utf-8",
725
+ "Content-Length": Buffer.byteLength(body),
726
+ "Set-Cookie": buildSessionCookie(result.token),
727
+ });
728
+ res.end(body);
729
+ return;
730
+ }
731
+
732
+ if (url.pathname === "/api/auth/logout" && req.method === "POST") {
733
+ logoutRequest(req);
734
+ const body = JSON.stringify({ ok: true });
735
+ res.writeHead(200, {
736
+ "Content-Type": "application/json; charset=utf-8",
737
+ "Content-Length": Buffer.byteLength(body),
738
+ "Set-Cookie": buildClearSessionCookie(),
739
+ });
740
+ res.end(body);
741
+ return;
742
+ }
743
+
744
+ const authUser = getAuthUserFromRequest(req);
745
+ const userCtx = authUser ? { userId: authUser.userId } : {};
746
+ if (url.pathname.startsWith("/api/") && !authUser) {
747
+ json(res, 401, { error: "Authentication required", setupRequired: authSetupRequired() });
748
+ return;
749
+ }
750
+
366
751
  if (url.pathname === "/api/flows") {
367
752
  if (req.method === "GET") {
368
753
  try {
369
- json(res, 200, listFlowsJson(root));
754
+ json(res, 200, listFlowsJson(root, userCtx));
370
755
  } catch (e) {
371
756
  json(res, 500, { error: (e && e.message) || String(e) });
372
757
  }
@@ -400,7 +785,7 @@ export function startUiServer({
400
785
  if (ts === "workspace" || ts === "user") {
401
786
  targetSpace = ts;
402
787
  }
403
- const existing = listFlowsJson(root);
788
+ const existing = listFlowsJson(root, userCtx);
404
789
  if (
405
790
  existing.some(
406
791
  (f) => f.id === flowId && (f.source ?? "user") === targetSpace && !f.archived,
@@ -410,7 +795,7 @@ export function startUiServer({
410
795
  return;
411
796
  }
412
797
  const flowYaml = buildEmptyUserFlowYaml({ description: desc });
413
- const result = writeFlowYaml(root, flowId, targetSpace, flowYaml);
798
+ const result = writeFlowYaml(root, flowId, targetSpace, flowYaml, userCtx);
414
799
  if (!result.success) {
415
800
  json(res, 400, result);
416
801
  return;
@@ -456,7 +841,7 @@ export function startUiServer({
456
841
  }
457
842
  const flowId = idCheck.flowId;
458
843
  const targetSpace = parsed.targetSpace === "workspace" ? "workspace" : "user";
459
- const existing = listFlowsJson(root);
844
+ const existing = listFlowsJson(root, userCtx);
460
845
  if (
461
846
  existing.some(
462
847
  (f) => f.id === flowId && (f.source ?? "user") === targetSpace && !f.archived,
@@ -487,7 +872,7 @@ export function startUiServer({
487
872
  filesMap = new Map([["flow.yaml", Buffer.from(text, "utf8")]]);
488
873
  }
489
874
 
490
- const w = writePipelineTree(root, flowId, targetSpace, filesMap);
875
+ const w = writePipelineTree(root, flowId, targetSpace, filesMap, userCtx);
491
876
  if (!w.success) {
492
877
  json(res, 400, { error: w.error });
493
878
  return;
@@ -507,7 +892,7 @@ export function startUiServer({
507
892
  return;
508
893
  }
509
894
  const { getNodeExecContext } = await import("./node-exec-context.mjs");
510
- json(res, 200, getNodeExecContext(root, flowId, instanceId, runId));
895
+ json(res, 200, getNodeExecContext(root, flowId, instanceId, runId, userCtx));
511
896
  } catch (e) {
512
897
  json(res, 500, { error: (e && e.message) || String(e) });
513
898
  }
@@ -516,7 +901,7 @@ export function startUiServer({
516
901
 
517
902
  if (req.method === "GET" && url.pathname === "/api/pipeline-recent-runs") {
518
903
  try {
519
- json(res, 200, { runs: listRecentRunsFromDisk(root) });
904
+ json(res, 200, { runs: listRecentRunsFromDisk(root, userCtx) });
520
905
  } catch (e) {
521
906
  json(res, 500, { error: (e && e.message) || String(e) });
522
907
  }
@@ -532,7 +917,7 @@ export function startUiServer({
532
917
  return;
533
918
  }
534
919
  const { getRunNodeStatusesFromDisk } = await import("./run-node-statuses-from-disk.mjs");
535
- json(res, 200, { statuses: getRunNodeStatusesFromDisk(root, flowId, runId) });
920
+ json(res, 200, { statuses: getRunNodeStatusesFromDisk(root, flowId, runId, userCtx) });
536
921
  } catch (e) {
537
922
  json(res, 500, { error: (e && e.message) || String(e) });
538
923
  }
@@ -554,7 +939,7 @@ export function startUiServer({
554
939
  const { getRunDir } = await import("./workspace.mjs");
555
940
  const { RUN_LOG_REL } = await import("./paths.mjs");
556
941
  const { default: fsMod } = await import("node:fs");
557
- const logPath = path.join(getRunDir(root, flowId, runId), RUN_LOG_REL);
942
+ const logPath = path.join(getRunDir(root, flowId, runId, userCtx), RUN_LOG_REL);
558
943
  if (!fsMod.existsSync(logPath)) {
559
944
  json(res, 200, { bytes: 0, text: "" });
560
945
  return;
@@ -596,6 +981,303 @@ export function startUiServer({
596
981
  return;
597
982
  }
598
983
 
984
+ if (req.method === "GET" && url.pathname === "/api/workspace/files") {
985
+ try {
986
+ const scoped = resolveWorkspaceScopeRoot(root, {
987
+ flowId: url.searchParams.get("flowId") || "",
988
+ flowSource: url.searchParams.get("flowSource") || "user",
989
+ archived: url.searchParams.get("archived") === "1",
990
+ }, userCtx);
991
+ if (scoped.error) {
992
+ json(res, 400, { error: scoped.error });
993
+ return;
994
+ }
995
+ json(res, 200, { ...readWorkspaceFiles(scoped.root), flowId: scoped.flowId, flowSource: scoped.flowSource, archived: scoped.archived });
996
+ } catch (e) {
997
+ json(res, 500, { error: (e && e.message) || String(e) });
998
+ }
999
+ return;
1000
+ }
1001
+
1002
+ if (req.method === "GET" && url.pathname === "/api/workspace/graph") {
1003
+ try {
1004
+ const scoped = resolveWorkspaceScopeRoot(root, {
1005
+ flowId: url.searchParams.get("flowId") || "",
1006
+ flowSource: url.searchParams.get("flowSource") || "user",
1007
+ archived: url.searchParams.get("archived") === "1",
1008
+ }, userCtx);
1009
+ if (scoped.error) {
1010
+ json(res, 400, { error: scoped.error });
1011
+ return;
1012
+ }
1013
+ const { path: graphPath, graph } = readWorkspaceGraph(scoped.root);
1014
+ json(res, 200, {
1015
+ ok: true,
1016
+ graph,
1017
+ path: graphPath,
1018
+ root: scoped.root,
1019
+ flowId: scoped.flowId,
1020
+ flowSource: scoped.flowSource,
1021
+ archived: scoped.archived,
1022
+ writable: !(scoped.archived || scoped.flowSource === "builtin"),
1023
+ });
1024
+ } catch (e) {
1025
+ json(res, 500, { error: (e && e.message) || String(e) });
1026
+ }
1027
+ return;
1028
+ }
1029
+
1030
+ if (req.method === "POST" && url.pathname === "/api/workspace/graph") {
1031
+ let payload;
1032
+ try {
1033
+ payload = JSON.parse(await readBody(req));
1034
+ } catch {
1035
+ json(res, 400, { error: "Invalid JSON body" });
1036
+ return;
1037
+ }
1038
+ try {
1039
+ const scoped = resolveWorkspaceScopeRoot(root, {
1040
+ flowId: payload.flowId || "",
1041
+ flowSource: payload.flowSource || "user",
1042
+ archived: payload.archived === true || payload.flowArchived === true,
1043
+ }, userCtx);
1044
+ if (scoped.error) {
1045
+ json(res, 400, { error: scoped.error });
1046
+ return;
1047
+ }
1048
+ if (scoped.archived || scoped.flowSource === "builtin") {
1049
+ json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
1050
+ return;
1051
+ }
1052
+ const graph = normalizeWorkspaceGraphPayload(payload.graph || payload);
1053
+ const graphPath = workspaceGraphPath(scoped.root);
1054
+ fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf-8");
1055
+ json(res, 200, { ok: true, path: graphPath, graph });
1056
+ } catch (e) {
1057
+ json(res, 500, { error: (e && e.message) || String(e) });
1058
+ }
1059
+ return;
1060
+ }
1061
+
1062
+ if (req.method === "GET" && url.pathname === "/api/workspace/file") {
1063
+ try {
1064
+ const scoped = resolveWorkspaceScopeRoot(root, {
1065
+ flowId: url.searchParams.get("flowId") || "",
1066
+ flowSource: url.searchParams.get("flowSource") || "user",
1067
+ archived: url.searchParams.get("archived") === "1",
1068
+ }, userCtx);
1069
+ if (scoped.error) {
1070
+ json(res, 400, { error: scoped.error });
1071
+ return;
1072
+ }
1073
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, url.searchParams.get("path") || "");
1074
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
1075
+ json(res, 404, { error: "File not found" });
1076
+ return;
1077
+ }
1078
+ const stat = fs.statSync(abs);
1079
+ if (stat.size > 2 * 1024 * 1024) {
1080
+ json(res, 413, { error: "File too large" });
1081
+ return;
1082
+ }
1083
+ json(res, 200, { path: rel, content: fs.readFileSync(abs, "utf-8"), size: stat.size });
1084
+ } catch (e) {
1085
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
1086
+ }
1087
+ return;
1088
+ }
1089
+
1090
+ if (req.method === "POST" && url.pathname === "/api/workspace/file") {
1091
+ let payload;
1092
+ try {
1093
+ payload = JSON.parse(await readBody(req));
1094
+ } catch {
1095
+ json(res, 400, { error: "Invalid JSON body" });
1096
+ return;
1097
+ }
1098
+ try {
1099
+ const scoped = resolveWorkspaceScopeRoot(root, {
1100
+ flowId: payload.flowId || "",
1101
+ flowSource: payload.flowSource || "user",
1102
+ archived: payload.archived === true || payload.flowArchived === true,
1103
+ }, userCtx);
1104
+ if (scoped.error) {
1105
+ json(res, 400, { error: scoped.error });
1106
+ return;
1107
+ }
1108
+ if (scoped.archived || scoped.flowSource === "builtin") {
1109
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
1110
+ return;
1111
+ }
1112
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, payload.path || "");
1113
+ if (!rel) {
1114
+ json(res, 400, { error: "Missing path" });
1115
+ return;
1116
+ }
1117
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1118
+ fs.writeFileSync(abs, String(payload.content ?? ""), "utf-8");
1119
+ json(res, 200, { ok: true, path: rel });
1120
+ } catch (e) {
1121
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
1122
+ }
1123
+ return;
1124
+ }
1125
+
1126
+ if (req.method === "POST" && url.pathname === "/api/workspace/folder") {
1127
+ let payload;
1128
+ try {
1129
+ payload = JSON.parse(await readBody(req));
1130
+ } catch {
1131
+ json(res, 400, { error: "Invalid JSON body" });
1132
+ return;
1133
+ }
1134
+ try {
1135
+ const scoped = resolveWorkspaceScopeRoot(root, {
1136
+ flowId: payload.flowId || "",
1137
+ flowSource: payload.flowSource || "user",
1138
+ archived: payload.archived === true || payload.flowArchived === true,
1139
+ }, userCtx);
1140
+ if (scoped.error) {
1141
+ json(res, 400, { error: scoped.error });
1142
+ return;
1143
+ }
1144
+ if (scoped.archived || scoped.flowSource === "builtin") {
1145
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
1146
+ return;
1147
+ }
1148
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, payload.path || "");
1149
+ if (!rel) {
1150
+ json(res, 400, { error: "Missing path" });
1151
+ return;
1152
+ }
1153
+ fs.mkdirSync(abs, { recursive: true });
1154
+ json(res, 200, { ok: true, path: rel });
1155
+ } catch (e) {
1156
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
1157
+ }
1158
+ return;
1159
+ }
1160
+
1161
+ if (req.method === "POST" && url.pathname === "/api/workspace/delete") {
1162
+ let payload;
1163
+ try {
1164
+ payload = JSON.parse(await readBody(req));
1165
+ } catch {
1166
+ json(res, 400, { error: "Invalid JSON body" });
1167
+ return;
1168
+ }
1169
+ try {
1170
+ const scoped = resolveWorkspaceScopeRoot(root, {
1171
+ flowId: payload.flowId || "",
1172
+ flowSource: payload.flowSource || "user",
1173
+ archived: payload.archived === true || payload.flowArchived === true,
1174
+ }, userCtx);
1175
+ if (scoped.error) {
1176
+ json(res, 400, { error: scoped.error });
1177
+ return;
1178
+ }
1179
+ if (scoped.archived || scoped.flowSource === "builtin") {
1180
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
1181
+ return;
1182
+ }
1183
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, payload.path || "");
1184
+ if (!rel) {
1185
+ json(res, 400, { error: "Missing path" });
1186
+ return;
1187
+ }
1188
+ if (!fs.existsSync(abs)) {
1189
+ json(res, 404, { error: "Path not found" });
1190
+ return;
1191
+ }
1192
+ fs.rmSync(abs, { recursive: true, force: true });
1193
+ json(res, 200, { ok: true, path: rel });
1194
+ } catch (e) {
1195
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
1196
+ }
1197
+ return;
1198
+ }
1199
+
1200
+ if (req.method === "POST" && url.pathname === "/api/workspace/generate") {
1201
+ let payload;
1202
+ try {
1203
+ payload = JSON.parse(await readBody(req));
1204
+ } catch {
1205
+ json(res, 400, { error: "Invalid JSON body" });
1206
+ return;
1207
+ }
1208
+ const prompt = String(payload?.prompt || "").trim();
1209
+ if (!prompt) {
1210
+ json(res, 400, { error: "Missing prompt" });
1211
+ return;
1212
+ }
1213
+ try {
1214
+ const scoped = resolveWorkspaceScopeRoot(root, {
1215
+ flowId: payload.flowId || "",
1216
+ flowSource: payload.flowSource || "user",
1217
+ archived: payload.archived === true || payload.flowArchived === true,
1218
+ }, userCtx);
1219
+ if (scoped.error) {
1220
+ json(res, 400, { error: scoped.error });
1221
+ return;
1222
+ }
1223
+ const selectedSkillKeys = Array.isArray(payload?.selectedSkills)
1224
+ ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean)
1225
+ : [];
1226
+ const selectedSkillResources = selectedSkillKeys.length > 0
1227
+ ? loadResourcesForSkillKeys(selectedSkillKeys, PACKAGE_ROOT, scoped.root)
1228
+ : { skills: [], references: [] };
1229
+ const skillsBlock = selectedSkillKeys.length > 0
1230
+ ? buildSkillCompactInjectionBlock(selectedSkillResources.skills, selectedSkillResources.references)
1231
+ : "";
1232
+ let content = "";
1233
+ const events = [];
1234
+ const maxAttempts = 3;
1235
+ const promptText = buildWorkspaceGeneratePrompt({ ...payload, skillsBlock });
1236
+ const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
1237
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1238
+ let attemptContent = "";
1239
+ try {
1240
+ if (attempt > 1) {
1241
+ events.push({
1242
+ type: "status",
1243
+ line: `Workspace agent retry ${attempt}/${maxAttempts} after transient network failure...`,
1244
+ });
1245
+ await sleepMs(Math.min(1500 * attempt, 5000));
1246
+ }
1247
+ const handle = startComposerAgent({
1248
+ uiWorkspaceRoot: scoped.root,
1249
+ cliWorkspace: scoped.root,
1250
+ prompt: promptText,
1251
+ modelKey,
1252
+ agentflowUserId: userCtx.userId || "",
1253
+ onStreamEvent: (ev) => {
1254
+ events.push(ev);
1255
+ if (ev?.type === "natural" && typeof ev.text === "string") {
1256
+ attemptContent += (attemptContent ? "\n" : "") + ev.text;
1257
+ }
1258
+ },
1259
+ });
1260
+ await handle.finished;
1261
+ content = attemptContent;
1262
+ break;
1263
+ } catch (e) {
1264
+ if (attempt < maxAttempts && isTransientAgentNetworkError(e)) {
1265
+ events.push({
1266
+ type: "status",
1267
+ line: `Workspace agent transient network error: ${String(e.message || e).slice(0, 220)}`,
1268
+ });
1269
+ continue;
1270
+ }
1271
+ throw e;
1272
+ }
1273
+ }
1274
+ json(res, 200, { ok: true, content: content.trim(), events });
1275
+ } catch (e) {
1276
+ json(res, 500, { error: (e && e.message) || String(e) });
1277
+ }
1278
+ return;
1279
+ }
1280
+
599
1281
  if (req.method === "GET" && url.pathname === "/api/pipeline-files") {
600
1282
  const flowId = url.searchParams.get("flowId");
601
1283
  const flowSource = url.searchParams.get("flowSource") || "user";
@@ -605,7 +1287,7 @@ export function startUiServer({
605
1287
  return;
606
1288
  }
607
1289
  try {
608
- const result = getPipelineFiles(root, flowId, flowSource, archived);
1290
+ const result = getPipelineFiles(root, flowId, flowSource, archived, userCtx);
609
1291
  json(res, 200, result);
610
1292
  } catch (e) {
611
1293
  json(res, 500, { error: (e && e.message) || String(e) });
@@ -623,7 +1305,7 @@ export function startUiServer({
623
1305
  return;
624
1306
  }
625
1307
  try {
626
- const result = getPipelineFiles(root, flowId, flowSource, archived);
1308
+ const result = getPipelineFiles(root, flowId, flowSource, archived, userCtx);
627
1309
  if (result.error) {
628
1310
  json(res, 404, { error: result.error });
629
1311
  return;
@@ -669,7 +1351,7 @@ export function startUiServer({
669
1351
  content = String(body);
670
1352
  }
671
1353
  try {
672
- const result = getPipelineFiles(root, flowId, flowSource, archived);
1354
+ const result = getPipelineFiles(root, flowId, flowSource, archived, userCtx);
673
1355
  if (result.error) {
674
1356
  json(res, 404, { error: result.error });
675
1357
  return;
@@ -702,7 +1384,7 @@ export function startUiServer({
702
1384
 
703
1385
  if (req.method === "GET" && url.pathname === "/api/ui-context") {
704
1386
  try {
705
- json(res, 200, { workspaceRoot: root });
1387
+ json(res, 200, { workspaceRoot: root, ...uiConfig });
706
1388
  } catch (e) {
707
1389
  json(res, 500, { error: (e && e.message) || String(e) });
708
1390
  }
@@ -833,15 +1515,108 @@ export function startUiServer({
833
1515
  return;
834
1516
  }
835
1517
 
1518
+ if (req.method === "GET" && url.pathname === "/api/skillhub/status") {
1519
+ const versionRes = await runSkillhub(["version"], { cwd: root, timeoutMs: 15_000 });
1520
+ const whoRes = await runSkillhub(["whoami"], { cwd: root, timeoutMs: 15_000 });
1521
+ json(res, 200, {
1522
+ available: versionRes.ok,
1523
+ version: versionRes.ok ? versionRes.stdout.trim() : "",
1524
+ loggedIn: whoRes.ok,
1525
+ user: whoRes.ok ? whoRes.stdout.trim() : "",
1526
+ error: versionRes.ok ? "" : versionRes.error,
1527
+ });
1528
+ return;
1529
+ }
1530
+
1531
+ if (req.method === "GET" && url.pathname === "/api/skillhub/list") {
1532
+ const target = url.searchParams.get("target") || "global";
1533
+ const agent = url.searchParams.get("agent") || "codex";
1534
+ const args = ["list", "--json"];
1535
+ if (target === "all") args.push("--all");
1536
+ else if (target === "global") args.push("--global", "--agent", agent);
1537
+ const result = await runSkillhub(args, { cwd: root });
1538
+ if (!result.ok) {
1539
+ json(res, 500, { error: result.error, stdout: result.stdout });
1540
+ return;
1541
+ }
1542
+ json(res, 200, { skills: normalizeSkillhubListPayload(parseJsonText(result.stdout, [])) });
1543
+ return;
1544
+ }
1545
+
1546
+ if (req.method === "GET" && url.pathname === "/api/skillhub/search") {
1547
+ const q = (url.searchParams.get("q") || "").trim();
1548
+ if (!q) {
1549
+ json(res, 200, { total: 0, items: [] });
1550
+ return;
1551
+ }
1552
+ const result = await runSkillhub(["search", "-q", q], { cwd: root });
1553
+ if (!result.ok) {
1554
+ json(res, 500, { error: result.error, stdout: result.stdout });
1555
+ return;
1556
+ }
1557
+ json(res, 200, normalizeSkillhubSearchPayload(parseJsonText(result.stdout, {})));
1558
+ return;
1559
+ }
1560
+
1561
+ if (req.method === "POST" && url.pathname === "/api/skillhub/install") {
1562
+ let payload;
1563
+ try {
1564
+ payload = JSON.parse(await readBody(req));
1565
+ } catch {
1566
+ json(res, 400, { error: "Invalid JSON body" });
1567
+ return;
1568
+ }
1569
+ const args = skillhubInstallArgs(payload);
1570
+ if (!args) {
1571
+ json(res, 400, { error: "Missing skill slug or collection" });
1572
+ return;
1573
+ }
1574
+ const result = await runSkillhub(args, { cwd: root, timeoutMs: 180_000, maxBuffer: 4 * 1024 * 1024 });
1575
+ if (!result.ok) {
1576
+ json(res, 500, { error: result.error, stdout: result.stdout });
1577
+ return;
1578
+ }
1579
+ json(res, 200, { ok: true, stdout: result.stdout });
1580
+ return;
1581
+ }
1582
+
1583
+ if (req.method === "POST" && url.pathname === "/api/skillhub/uninstall") {
1584
+ let payload;
1585
+ try {
1586
+ payload = JSON.parse(await readBody(req));
1587
+ } catch {
1588
+ json(res, 400, { error: "Invalid JSON body" });
1589
+ return;
1590
+ }
1591
+ const args = skillhubInstallArgs(payload, { uninstall: true });
1592
+ if (!args) {
1593
+ json(res, 400, { error: "Missing skill slug or collection" });
1594
+ return;
1595
+ }
1596
+ const result = await runSkillhub(args, { cwd: root, timeoutMs: 120_000, maxBuffer: 4 * 1024 * 1024 });
1597
+ if (!result.ok) {
1598
+ json(res, 500, { error: result.error, stdout: result.stdout });
1599
+ return;
1600
+ }
1601
+ json(res, 200, { ok: true, stdout: result.stdout });
1602
+ return;
1603
+ }
1604
+
1605
+ if (req.method === "POST" && url.pathname === "/api/skillhub/update") {
1606
+ const result = await runSkillhub(["update"], { cwd: root, timeoutMs: 180_000, maxBuffer: 4 * 1024 * 1024 });
1607
+ if (!result.ok) {
1608
+ json(res, 500, { error: result.error, stdout: result.stdout });
1609
+ return;
1610
+ }
1611
+ json(res, 200, { ok: true, stdout: result.stdout });
1612
+ return;
1613
+ }
1614
+
836
1615
  if (req.method === "GET" && url.pathname === "/api/nodes") {
837
1616
  const flowId = url.searchParams.get("flowId");
838
1617
  const flowSource = url.searchParams.get("flowSource") || "user";
839
1618
  const lang = url.searchParams.get("lang") || "en";
840
- if (!flowId) {
841
- json(res, 400, { error: "Missing flowId" });
842
- return;
843
- }
844
- if (!isValidFlowSourceRead(flowSource)) {
1619
+ if (flowId && !isValidFlowSourceRead(flowSource)) {
845
1620
  json(res, 400, { error: "Invalid flowSource" });
846
1621
  return;
847
1622
  }
@@ -849,7 +1624,60 @@ export function startUiServer({
849
1624
  try {
850
1625
  const { setLanguage } = await import("./i18n.mjs");
851
1626
  setLanguage(lang);
852
- json(res, 200, listNodesJson(root, flowId, flowSource, { archived: nodesArchived }));
1627
+ json(res, 200, listNodesJson(root, flowId || "", flowId ? flowSource : "", { archived: nodesArchived, ...userCtx }));
1628
+ } catch (e) {
1629
+ json(res, 500, { error: (e && e.message) || String(e) });
1630
+ }
1631
+ return;
1632
+ }
1633
+
1634
+ if (req.method === "GET" && url.pathname === "/api/nodes/detail") {
1635
+ const nodeId = url.searchParams.get("id") || "";
1636
+ const flowId = url.searchParams.get("flowId") || "";
1637
+ const flowSource = url.searchParams.get("flowSource") || "";
1638
+ if (!nodeId) {
1639
+ json(res, 400, { error: "Missing node id" });
1640
+ return;
1641
+ }
1642
+ if (flowId && !isValidFlowSourceRead(flowSource || "user")) {
1643
+ json(res, 400, { error: "Invalid flowSource" });
1644
+ return;
1645
+ }
1646
+ const archived = url.searchParams.get("archived") === "1";
1647
+ try {
1648
+ const detail = readNodeDetailJson(root, nodeId, flowId, flowId ? (flowSource || "user") : "", { archived, ...userCtx });
1649
+ if (detail.error) {
1650
+ json(res, 404, { error: detail.error });
1651
+ return;
1652
+ }
1653
+ json(res, 200, detail);
1654
+ } catch (e) {
1655
+ json(res, 500, { error: (e && e.message) || String(e) });
1656
+ }
1657
+ return;
1658
+ }
1659
+
1660
+ if (req.method === "GET" && url.pathname === "/api/nodes/file") {
1661
+ const nodeId = url.searchParams.get("id") || "";
1662
+ const relPath = url.searchParams.get("path") || "";
1663
+ const flowId = url.searchParams.get("flowId") || "";
1664
+ const flowSource = url.searchParams.get("flowSource") || "";
1665
+ if (!nodeId || !relPath) {
1666
+ json(res, 400, { error: "Missing node id or path" });
1667
+ return;
1668
+ }
1669
+ if (flowId && !isValidFlowSourceRead(flowSource || "user")) {
1670
+ json(res, 400, { error: "Invalid flowSource" });
1671
+ return;
1672
+ }
1673
+ const archived = url.searchParams.get("archived") === "1";
1674
+ try {
1675
+ const file = readNodeFilePreview(root, nodeId, relPath, flowId, flowId ? (flowSource || "user") : "", { archived, ...userCtx });
1676
+ if (file.error) {
1677
+ json(res, 404, { error: file.error });
1678
+ return;
1679
+ }
1680
+ json(res, 200, file);
853
1681
  } catch (e) {
854
1682
  json(res, 500, { error: (e && e.message) || String(e) });
855
1683
  }
@@ -890,7 +1718,7 @@ export function startUiServer({
890
1718
  return;
891
1719
  }
892
1720
  try {
893
- const resolved = resolveFlowDirForWrite(root, flowId, flowSource);
1721
+ const resolved = resolveFlowDirForWrite(root, flowId, flowSource, userCtx);
894
1722
  if (resolved.error || !resolved.flowDir) {
895
1723
  json(res, 400, { error: resolved.error || "Could not resolve flow directory" });
896
1724
  return;
@@ -916,7 +1744,7 @@ export function startUiServer({
916
1744
  const flowSource = payload?.flowSource || "user";
917
1745
  let flowDir = "";
918
1746
  if (flowId && isValidFlowSourceWrite(flowSource)) {
919
- const resolved = resolveFlowDirForWrite(root, flowId, flowSource);
1747
+ const resolved = resolveFlowDirForWrite(root, flowId, flowSource, userCtx);
920
1748
  if (!resolved.error && resolved.flowDir) flowDir = resolved.flowDir;
921
1749
  }
922
1750
  const result = publishNodeFromInstance(root, payload || {}, { flowDir });
@@ -939,7 +1767,7 @@ export function startUiServer({
939
1767
  return;
940
1768
  }
941
1769
  const flowArchived = url.searchParams.get("archived") === "1";
942
- const result = readFlowJson(root, flowId, flowSource, { archived: flowArchived });
1770
+ const result = readFlowJson(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
943
1771
  if (result.error) {
944
1772
  json(res, 404, result);
945
1773
  return;
@@ -965,7 +1793,7 @@ export function startUiServer({
965
1793
  json(res, 400, { error: "Missing runUuid, instanceId, or content" });
966
1794
  return;
967
1795
  }
968
- const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid));
1796
+ const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid, userCtx));
969
1797
  const outputPath = path.join(runDir, `output/${instanceId}/node_${instanceId}_content.md`);
970
1798
  try {
971
1799
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
@@ -1000,7 +1828,7 @@ ${content}
1000
1828
 
1001
1829
  const opencodeCmd = process.env.OPENCODE_CMD || "opencode";
1002
1830
  const tmpPromptFile = path.join(
1003
- getRunDir(root, payload.flowId || "unknown", runUuid),
1831
+ getRunDir(root, payload.flowId || "unknown", runUuid, userCtx),
1004
1832
  "intermediate",
1005
1833
  `${instanceId}_ai_edit_prompt.txt`,
1006
1834
  );
@@ -1045,7 +1873,7 @@ ${content}
1045
1873
  json(res, 400, { error: "Missing runUuid or instanceId" });
1046
1874
  return;
1047
1875
  }
1048
- const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid));
1876
+ const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid, userCtx));
1049
1877
  const resultPath = path.join(runDir, `intermediate/${instanceId}/${instanceId}.result.md`);
1050
1878
  try {
1051
1879
  fs.mkdirSync(path.dirname(resultPath), { recursive: true });
@@ -1075,7 +1903,7 @@ finishedAt: "${new Date().toISOString()}"
1075
1903
  json(res, 400, { error: "Missing runUuid, instanceId, or branch" });
1076
1904
  return;
1077
1905
  }
1078
- const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid));
1906
+ const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid, userCtx));
1079
1907
  const resultPath = path.join(runDir, `intermediate/${instanceId}/${instanceId}.result.md`);
1080
1908
  try {
1081
1909
  fs.mkdirSync(path.dirname(resultPath), { recursive: true });
@@ -1123,7 +1951,7 @@ finishedAt: "${new Date().toISOString()}"
1123
1951
  return;
1124
1952
  }
1125
1953
  const flowArchived = Boolean(payload.flowArchived);
1126
- const result = writeFlowYaml(root, flowId, flowSource, flowYaml, { archived: flowArchived });
1954
+ const result = writeFlowYaml(root, flowId, flowSource, flowYaml, { archived: flowArchived, ...userCtx });
1127
1955
  if (!result.success) {
1128
1956
  json(res, 400, result);
1129
1957
  return;
@@ -1151,7 +1979,7 @@ finishedAt: "${new Date().toISOString()}"
1151
1979
  return;
1152
1980
  }
1153
1981
  const flowArchived = Boolean(payload.flowArchived);
1154
- broadcastFlowEditorSync(flowId, flowSource, flowArchived);
1982
+ broadcastFlowEditorSync(flowId, flowSource, flowArchived, userCtx.userId);
1155
1983
  json(res, 200, { ok: true });
1156
1984
  return;
1157
1985
  }
@@ -1168,7 +1996,7 @@ finishedAt: "${new Date().toISOString()}"
1168
1996
  return;
1169
1997
  }
1170
1998
  const flowArchived = url.searchParams.get("archived") === "1";
1171
- const key = flowEditorSyncKey(flowId, flowSource, flowArchived);
1999
+ const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userCtx.userId);
1172
2000
  let set = flowEditorSyncSubscribers.get(key);
1173
2001
  if (!set) {
1174
2002
  set = new Set();
@@ -1202,7 +2030,7 @@ finishedAt: "${new Date().toISOString()}"
1202
2030
  return;
1203
2031
  }
1204
2032
  const flowArchived = url.searchParams.get("archived") === "1";
1205
- const key = flowEditorSyncKey(flowId, flowSource, flowArchived);
2033
+ const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userCtx.userId);
1206
2034
  const serverVer = flowEditorSyncVersions.get(key) ?? 0;
1207
2035
  const clientVer = parseInt(url.searchParams.get("v") ?? "0", 10) || 0;
1208
2036
  json(res, 200, { version: serverVer, changed: serverVer > clientVer });
@@ -1232,7 +2060,7 @@ finishedAt: "${new Date().toISOString()}"
1232
2060
  json(res, 400, { error: "Invalid toSource" });
1233
2061
  return;
1234
2062
  }
1235
- const result = moveFlowDirectory(root, flowId.trim(), fromSource, toSource);
2063
+ const result = moveFlowDirectory(root, flowId.trim(), fromSource, toSource, userCtx);
1236
2064
  if (!result.success) {
1237
2065
  json(res, 400, { error: result.error || "Move failed" });
1238
2066
  return;
@@ -1269,7 +2097,7 @@ finishedAt: "${new Date().toISOString()}"
1269
2097
  json(res, 200, { success: true, flowId, flowSource });
1270
2098
  return;
1271
2099
  }
1272
- const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: false });
2100
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: false, ...userCtx });
1273
2101
  if (yamlRes.error || !yamlRes.path) {
1274
2102
  json(res, 404, { error: yamlRes.error || "找不到流水线" });
1275
2103
  return;
@@ -1312,7 +2140,7 @@ finishedAt: "${new Date().toISOString()}"
1312
2140
  json(res, 400, { error: "仅支持归档用户目录或工作区流水线" });
1313
2141
  return;
1314
2142
  }
1315
- const result = archiveFlowPipeline(root, flowId, flowSource);
2143
+ const result = archiveFlowPipeline(root, flowId, flowSource, userCtx);
1316
2144
  if (!result.success) {
1317
2145
  json(res, 400, { error: result.error || "归档失败" });
1318
2146
  return;
@@ -1345,7 +2173,7 @@ finishedAt: "${new Date().toISOString()}"
1345
2173
  json(res, 400, { error: "仅支持删除用户目录或工作区流水线" });
1346
2174
  return;
1347
2175
  }
1348
- const result = deleteFlowPipeline(root, flowId, flowSource, { archived: flowArchived });
2176
+ const result = deleteFlowPipeline(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
1349
2177
  if (!result.success) {
1350
2178
  json(res, 400, { error: result.error || "删除失败" });
1351
2179
  return;
@@ -1366,7 +2194,7 @@ finishedAt: "${new Date().toISOString()}"
1366
2194
  json(res, 400, { error: "Invalid flowSource" });
1367
2195
  return;
1368
2196
  }
1369
- const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived });
2197
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
1370
2198
  if (yamlRes.error) {
1371
2199
  json(res, 404, { error: yamlRes.error });
1372
2200
  return;
@@ -1407,7 +2235,7 @@ finishedAt: "${new Date().toISOString()}"
1407
2235
  json(res, 400, { error: "Cannot save config to builtin or archived flow" });
1408
2236
  return;
1409
2237
  }
1410
- const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived });
2238
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
1411
2239
  if (yamlRes.error) {
1412
2240
  json(res, 404, { error: yamlRes.error });
1413
2241
  return;
@@ -1437,12 +2265,12 @@ finishedAt: "${new Date().toISOString()}"
1437
2265
  json(res, 400, { error: "Invalid flowSource" });
1438
2266
  return;
1439
2267
  }
1440
- const result = readFlowSchedule(root, flowId, flowSource, { archived: flowArchived });
2268
+ const result = readFlowSchedule(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
1441
2269
  if (!result.success) {
1442
2270
  json(res, 400, { error: result.error || "Could not read schedule" });
1443
2271
  return;
1444
2272
  }
1445
- const status = listScheduleStatuses(root).find(
2273
+ const status = listScheduleStatuses(root, userCtx).find(
1446
2274
  (s) => s.flowId === flowId && (s.flowSource || "user") === (flowSource || "user"),
1447
2275
  );
1448
2276
  json(res, 200, { schedule: result.schedule, state: result.state || {}, status: status || null });
@@ -1468,7 +2296,7 @@ finishedAt: "${new Date().toISOString()}"
1468
2296
  json(res, 400, { error: "Cannot save schedule to builtin or archived flow" });
1469
2297
  return;
1470
2298
  }
1471
- const result = writeFlowSchedule(root, flowId, flowSource, payload.schedule || {});
2299
+ const result = writeFlowSchedule(root, flowId, flowSource, payload.schedule || {}, userCtx);
1472
2300
  if (!result.success) {
1473
2301
  json(res, 400, { error: result.error || "Could not save schedule" });
1474
2302
  return;
@@ -1491,7 +2319,8 @@ finishedAt: "${new Date().toISOString()}"
1491
2319
  return;
1492
2320
  }
1493
2321
  const runUuid = typeof payload.uuid === "string" ? payload.uuid.trim() : "";
1494
- if (activeFlowRuns.has(flowId)) {
2322
+ const runKey = `${userCtx.userId || ""}:${payload.flowSource || "user"}:${flowId}`;
2323
+ if (activeFlowRuns.has(runKey)) {
1495
2324
  json(res, 409, { error: "该流水线已在运行中" });
1496
2325
  return;
1497
2326
  }
@@ -1500,7 +2329,7 @@ finishedAt: "${new Date().toISOString()}"
1500
2329
  // UI 轮询会把 runMode 翻回 stopped,即便 CLI 正在运行也显示 PAUSED。
1501
2330
  if (runUuid) {
1502
2331
  try {
1503
- const runDir = getRunDir(root, flowId, runUuid);
2332
+ const runDir = getRunDir(root, flowId, runUuid, userCtx);
1504
2333
  const interruptedPath = path.join(runDir, RUN_INTERRUPTED_FILENAME);
1505
2334
  if (fs.existsSync(interruptedPath)) fs.unlinkSync(interruptedPath);
1506
2335
  } catch (e) {
@@ -1542,7 +2371,7 @@ finishedAt: "${new Date().toISOString()}"
1542
2371
  const endSafe = () => {
1543
2372
  if (responseEnded) return;
1544
2373
  responseEnded = true;
1545
- activeFlowRuns.delete(flowId);
2374
+ activeFlowRuns.delete(runKey);
1546
2375
  try {
1547
2376
  res.end();
1548
2377
  } catch (_) {}
@@ -1557,7 +2386,7 @@ finishedAt: "${new Date().toISOString()}"
1557
2386
  child = spawn(process.execPath, args, {
1558
2387
  cwd: root,
1559
2388
  stdio: ["ignore", "pipe", "pipe"],
1560
- env: { ...process.env, FORCE_COLOR: "0" },
2389
+ env: { ...process.env, FORCE_COLOR: "0", AGENTFLOW_USER_ID: userCtx.userId || "" },
1561
2390
  // detached: true 使 child 成为新进程组 leader,/api/flow/run/stop 时
1562
2391
  // 用 process.kill(-pid) 可以一次性 SIGTERM 整棵进程树(含 cursor-agent 等孙进程)
1563
2392
  detached: true,
@@ -1570,7 +2399,7 @@ finishedAt: "${new Date().toISOString()}"
1570
2399
 
1571
2400
  /** @type {{ child: import("child_process").ChildProcess, runUuid: string | null }} */
1572
2401
  const runEntry = { child, runUuid: runUuid || null };
1573
- activeFlowRuns.set(flowId, runEntry);
2402
+ activeFlowRuns.set(runKey, runEntry);
1574
2403
  log.debug(`[ui] flow/run: spawned pid=${child.pid} flowId=${flowId}${runUuid ? ` uuid=${runUuid}` : ""}`);
1575
2404
 
1576
2405
  let stdoutBuf = "";
@@ -1643,7 +2472,8 @@ finishedAt: "${new Date().toISOString()}"
1643
2472
  json(res, 400, { error: "Missing flowId" });
1644
2473
  return;
1645
2474
  }
1646
- const entry = activeFlowRuns.get(flowId);
2475
+ const runKey = `${userCtx.userId || ""}:${payload.flowSource || "user"}:${flowId}`;
2476
+ const entry = activeFlowRuns.get(runKey);
1647
2477
  if (!entry || !entry.child) {
1648
2478
  json(res, 404, { error: "该流水线未在运行" });
1649
2479
  return;
@@ -1661,10 +2491,10 @@ finishedAt: "${new Date().toISOString()}"
1661
2491
  try { entry.child.kill("SIGTERM"); } catch (_) {}
1662
2492
  }
1663
2493
  const uuid = entry.runUuid;
1664
- activeFlowRuns.delete(flowId);
2494
+ activeFlowRuns.delete(runKey);
1665
2495
  if (uuid) {
1666
2496
  try {
1667
- const runDir = getRunDir(root, flowId, uuid);
2497
+ const runDir = getRunDir(root, flowId, uuid, userCtx);
1668
2498
  fs.mkdirSync(runDir, { recursive: true });
1669
2499
  fs.writeFileSync(
1670
2500
  path.join(runDir, RUN_INTERRUPTED_FILENAME),
@@ -1684,6 +2514,17 @@ finishedAt: "${new Date().toISOString()}"
1684
2514
  return;
1685
2515
  }
1686
2516
 
2517
+ if (req.method === "GET" && url.pathname === "/api/skills/detail") {
2518
+ const key = url.searchParams.get("key") || url.searchParams.get("name") || "";
2519
+ const detail = readComposerSkillDetail(PACKAGE_ROOT, root, key);
2520
+ if (!detail) {
2521
+ json(res, 404, { error: "Skill not found" });
2522
+ return;
2523
+ }
2524
+ json(res, 200, { skill: detail });
2525
+ return;
2526
+ }
2527
+
1687
2528
  if (req.method === "POST" && url.pathname === "/api/composer-agent") {
1688
2529
  let payload;
1689
2530
  try {
@@ -1739,7 +2580,7 @@ finishedAt: "${new Date().toISOString()}"
1739
2580
  return;
1740
2581
  }
1741
2582
  const flowArchived = Boolean(payload.flowArchived);
1742
- const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived });
2583
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
1743
2584
  if (yamlRes.error || !yamlRes.path) {
1744
2585
  json(res, 400, { error: yamlRes.error || "Could not resolve flow.yaml" });
1745
2586
  return;
@@ -1750,7 +2591,7 @@ finishedAt: "${new Date().toISOString()}"
1750
2591
  let editorSyncFlowSource = flowSource;
1751
2592
  let flowDirForCli = path.dirname(flowYamlAbs);
1752
2593
  if (flowSource === "builtin") {
1753
- const w = resolveFlowDirForWrite(root, flowId, "workspace");
2594
+ const w = resolveFlowDirForWrite(root, flowId, "workspace", userCtx);
1754
2595
  if (w.error || !w.flowDir) {
1755
2596
  json(res, 400, { error: w.error || "Could not resolve workspace flow directory" });
1756
2597
  return;
@@ -1783,6 +2624,7 @@ finishedAt: "${new Date().toISOString()}"
1783
2624
  flowYamlAbs,
1784
2625
  flowId,
1785
2626
  flowSource,
2627
+ userId: userCtx.userId || "",
1786
2628
  intents: multiStepIntents,
1787
2629
  canvasInstanceIds: instanceIds,
1788
2630
  skillsHint: multiStepResources.skillsHint,
@@ -1939,6 +2781,7 @@ finishedAt: "${new Date().toISOString()}"
1939
2781
  thread,
1940
2782
  phaseContext,
1941
2783
  phaseRole: phaseRole || undefined,
2784
+ agentflowUserId: userCtx.userId || "",
1942
2785
  force: true,
1943
2786
  onStreamEvent,
1944
2787
  });
@@ -1952,7 +2795,7 @@ finishedAt: "${new Date().toISOString()}"
1952
2795
  flowSource: flowSource || null,
1953
2796
  });
1954
2797
  if (flowId && flowSource) {
1955
- broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived));
2798
+ broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived), userCtx.userId);
1956
2799
  }
1957
2800
  try { res.write(JSON.stringify({ type: "done" }) + "\n"); } catch (_) {}
1958
2801
  }
@@ -1989,6 +2832,7 @@ finishedAt: "${new Date().toISOString()}"
1989
2832
  cliWorkspace,
1990
2833
  prompt: finalPrompt,
1991
2834
  modelKey: typeof model === "string" ? model.trim() : "",
2835
+ agentflowUserId: userCtx.userId || "",
1992
2836
  onStreamEvent,
1993
2837
  });
1994
2838
  child = handle.child;
@@ -2007,6 +2851,7 @@ finishedAt: "${new Date().toISOString()}"
2007
2851
  flowYamlAbs,
2008
2852
  flowContext: flowContextForMultiStep,
2009
2853
  modelKey: typeof model === "string" ? model.trim() : "",
2854
+ agentflowUserId: userCtx.userId || "",
2010
2855
  force: true,
2011
2856
  onStreamEvent,
2012
2857
  getAborted: () => clientDisconnected || responseEnded,
@@ -2029,7 +2874,7 @@ finishedAt: "${new Date().toISOString()}"
2029
2874
  flowSource: flowSource || null,
2030
2875
  });
2031
2876
  if (flowYamlChanged && flowId && flowSource) {
2032
- broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived));
2877
+ broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived), userCtx.userId);
2033
2878
  }
2034
2879
  try { res.write(JSON.stringify({ type: "done" }) + "\n"); } catch (_) {}
2035
2880
  }