@fieldwangai/agentflow 0.1.33 → 0.1.35

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 (40) hide show
  1. package/bin/lib/agent-runners.mjs +17 -0
  2. package/bin/lib/composer-agent.mjs +2 -0
  3. package/bin/lib/composer-skill-router.mjs +23 -4
  4. package/bin/lib/git-worktree.mjs +15 -0
  5. package/bin/lib/locales/en.json +3 -7
  6. package/bin/lib/locales/zh.json +2 -6
  7. package/bin/lib/paths.mjs +0 -1
  8. package/bin/lib/scheduler.mjs +8 -3
  9. package/bin/lib/skill-registry.mjs +46 -2
  10. package/bin/lib/ui-server.mjs +896 -22
  11. package/bin/pipeline/build-node-prompt.mjs +27 -1
  12. package/bin/pipeline/pre-process-node.mjs +70 -34
  13. package/builtin/nodes/agent_subAgent.md +2 -0
  14. package/builtin/nodes/control_agent_toBool.md +4 -0
  15. package/builtin/nodes/control_cancelled.md +8 -4
  16. package/builtin/nodes/control_cd_workspace.md +2 -0
  17. package/builtin/nodes/control_delay.md +9 -0
  18. package/builtin/nodes/control_toBool.md +6 -2
  19. package/builtin/nodes/control_user_workspace.md +2 -0
  20. package/builtin/nodes/control_wait_until.md +9 -0
  21. package/builtin/nodes/provide_bool.md +2 -0
  22. package/builtin/nodes/provide_file.md +2 -0
  23. package/builtin/nodes/provide_str.md +2 -0
  24. package/builtin/nodes/tool_get_env.md +4 -0
  25. package/builtin/nodes/tool_git_checkout.md +6 -1
  26. package/builtin/nodes/tool_git_worktree_load.md +7 -2
  27. package/builtin/nodes/tool_git_worktree_unload.md +5 -5
  28. package/builtin/nodes/tool_load_key.md +4 -0
  29. package/builtin/nodes/tool_nodejs.md +4 -0
  30. package/builtin/nodes/tool_print.md +2 -0
  31. package/builtin/nodes/tool_save_key.md +4 -0
  32. package/builtin/nodes/tool_user_ask.md +3 -1
  33. package/builtin/nodes/tool_user_check.md +3 -1
  34. package/builtin/web-ui/dist/assets/index-BzmhleR9.css +1 -0
  35. package/builtin/web-ui/dist/assets/index-DEeZI5V6.js +214 -0
  36. package/builtin/web-ui/dist/index.html +2 -2
  37. package/package.json +1 -1
  38. package/builtin/nodes/control_deadline.md +0 -32
  39. package/builtin/web-ui/dist/assets/index-BWAb27N0.js +0 -198
  40. package/builtin/web-ui/dist/assets/index-DgfSfcjH.css +0 -1
@@ -82,7 +82,7 @@ import {
82
82
  publishFlowSnippet,
83
83
  publishNodeFromInstance,
84
84
  } from "./marketplace.mjs";
85
- import { buildGitContext, loadGitWorktree, normalizeGitContext, runGit, unloadGitWorktree } from "./git-worktree.mjs";
85
+ import { buildGitContext, inferGitRepoRootFromWorktree, loadGitWorktree, normalizeGitContext, runGit, unloadGitWorktree } from "./git-worktree.mjs";
86
86
  import { createGitLabMergeRequest } from "./gitlab-mr.mjs";
87
87
  import {
88
88
  authSetupRequired,
@@ -297,6 +297,45 @@ function writeSkillCollectionConfig(userCtx = {}, payload = {}, availableSkills
297
297
  return config;
298
298
  }
299
299
 
300
+ function upsertSkillhubCollectionGroup(userCtx = {}, collectionId = "", beforeSkills = [], afterSkills = [], collectionName = "") {
301
+ const rawCollectionId = String(collectionId || "").trim();
302
+ if (!rawCollectionId) return null;
303
+ const beforeKeys = new Set((Array.isArray(beforeSkills) ? beforeSkills : []).map((skill) => String(skill?.key || "")).filter(Boolean));
304
+ const addedKeys = (Array.isArray(afterSkills) ? afterSkills : [])
305
+ .map((skill) => String(skill?.key || "").trim())
306
+ .filter((key) => key && !beforeKeys.has(key));
307
+ const config = readSkillCollectionConfig(userCtx, afterSkills);
308
+ const groupId = slugifySkillCollectionId(`skillhub-collection-${rawCollectionId}`, "skillhub-collection");
309
+ const now = Date.now();
310
+ const existing = config.collections.find((collection) => collection.id === groupId);
311
+ const existingKeys = Array.isArray(existing?.skillKeys) ? existing.skillKeys : [];
312
+ const mergedKeys = Array.from(new Set([...existingKeys, ...addedKeys]));
313
+ const nextCollections = config.collections.filter((collection) => collection.id !== groupId);
314
+ nextCollections.push({
315
+ id: groupId,
316
+ name: String(collectionName || "").trim() || `SkillHub Collection ${rawCollectionId}`,
317
+ skillKeys: mergedKeys,
318
+ builtin: false,
319
+ createdAt: Number.isFinite(existing?.createdAt) ? existing.createdAt : now,
320
+ updatedAt: now,
321
+ });
322
+ return writeSkillCollectionConfig(userCtx, { version: 1, collections: nextCollections }, afterSkills);
323
+ }
324
+
325
+ function removeSkillhubCollectionGroup(userCtx = {}, collectionId = "", root = process.cwd()) {
326
+ const rawCollectionId = String(collectionId || "").trim();
327
+ if (!rawCollectionId) return null;
328
+ const availableSkills = listComposerSkills(PACKAGE_ROOT, root);
329
+ const config = readSkillCollectionConfig(userCtx, availableSkills);
330
+ const groupId = slugifySkillCollectionId(`skillhub-collection-${rawCollectionId}`, "skillhub-collection");
331
+ if (!config.collections.some((collection) => collection.id === groupId)) return config;
332
+ return writeSkillCollectionConfig(
333
+ userCtx,
334
+ { version: 1, collections: config.collections.filter((collection) => collection.id !== groupId) },
335
+ availableSkills,
336
+ );
337
+ }
338
+
300
339
  function runtimeEnvForUser(userCtx = {}, extra = {}) {
301
340
  return {
302
341
  ...process.env,
@@ -317,6 +356,420 @@ function readAgentflowUserConfigObject() {
317
356
  }
318
357
  }
319
358
 
359
+ function cursorMcpConfigPath() {
360
+ return path.join(os.homedir(), ".cursor", "mcp.json");
361
+ }
362
+
363
+ function readCursorMcpConfig() {
364
+ const p = cursorMcpConfigPath();
365
+ try {
366
+ if (!fs.existsSync(p)) return { mcpServers: {} };
367
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
368
+ return data && typeof data === "object" && !Array.isArray(data) ? data : { mcpServers: {} };
369
+ } catch {
370
+ return { mcpServers: {} };
371
+ }
372
+ }
373
+
374
+ function userMcpPrivatePath(userCtx = {}) {
375
+ return path.join(getAgentflowUserDataRoot(userCtx.userId), "mcp-private.json");
376
+ }
377
+
378
+ function readUserMcpPrivate(userCtx = {}) {
379
+ const p = userMcpPrivatePath(userCtx);
380
+ try {
381
+ if (!fs.existsSync(p)) return { version: 1, servers: {} };
382
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
383
+ const servers = data?.servers && typeof data.servers === "object" && !Array.isArray(data.servers) ? data.servers : {};
384
+ return { version: 1, servers };
385
+ } catch {
386
+ return { version: 1, servers: {} };
387
+ }
388
+ }
389
+
390
+ function writeUserMcpPrivate(userCtx = {}, data = {}) {
391
+ const p = userMcpPrivatePath(userCtx);
392
+ const servers = data?.servers && typeof data.servers === "object" && !Array.isArray(data.servers) ? data.servers : {};
393
+ fs.mkdirSync(path.dirname(p), { recursive: true });
394
+ fs.writeFileSync(p, JSON.stringify({ version: 1, servers }, null, 2) + "\n", "utf-8");
395
+ return { version: 1, servers };
396
+ }
397
+
398
+ function normalizeMcpPrivateKeys(keys) {
399
+ return new Set((Array.isArray(keys) ? keys : []).map((key) => String(key || "").trim()).filter(Boolean));
400
+ }
401
+
402
+ function pickObjectKeys(obj, keys) {
403
+ const out = {};
404
+ for (const key of keys) {
405
+ if (obj && Object.prototype.hasOwnProperty.call(obj, key)) out[key] = String(obj[key] ?? "");
406
+ }
407
+ return out;
408
+ }
409
+
410
+ function omitObjectKeys(obj, keys) {
411
+ const out = {};
412
+ for (const [key, value] of Object.entries(obj && typeof obj === "object" ? obj : {})) {
413
+ if (!keys.has(key)) out[key] = value;
414
+ }
415
+ return out;
416
+ }
417
+
418
+ function normalizeMcpServerConfig(value) {
419
+ const raw = value && typeof value === "object" && !Array.isArray(value) ? value : {};
420
+ const next = {};
421
+ const url = typeof raw.url === "string" ? raw.url.trim() : "";
422
+ const command = typeof raw.command === "string" ? raw.command.trim() : "";
423
+ const description = typeof raw.description === "string" ? raw.description.trim() : "";
424
+ if (url) next.url = url;
425
+ if (command) next.command = command;
426
+ if (Array.isArray(raw.args)) next.args = raw.args.map((x) => String(x)).filter((x) => x.length > 0);
427
+ if (raw.env && typeof raw.env === "object" && !Array.isArray(raw.env)) {
428
+ const env = {};
429
+ for (const [k, v] of Object.entries(raw.env)) {
430
+ const key = String(k || "").trim();
431
+ if (key) env[key] = String(v ?? "");
432
+ }
433
+ if (Object.keys(env).length) next.env = env;
434
+ }
435
+ if (raw.headers && typeof raw.headers === "object" && !Array.isArray(raw.headers)) {
436
+ const headers = {};
437
+ for (const [k, v] of Object.entries(raw.headers)) {
438
+ const key = String(k || "").trim();
439
+ if (key) headers[key] = String(v ?? "");
440
+ }
441
+ if (Object.keys(headers).length) next.headers = headers;
442
+ }
443
+ if (description) next.description = description;
444
+ for (const [k, v] of Object.entries(raw)) {
445
+ if (["url", "command", "args", "env", "headers", "description"].includes(k)) continue;
446
+ next[k] = v;
447
+ }
448
+ return next;
449
+ }
450
+
451
+ function readCursorMcpServers(userCtx = {}) {
452
+ const config = readCursorMcpConfig();
453
+ const privateConfig = readUserMcpPrivate(userCtx);
454
+ const rawServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
455
+ ? config.mcpServers
456
+ : {};
457
+ const servers = Object.entries(rawServers).map(([name, value]) => {
458
+ const publicValue = normalizeMcpServerConfig(value);
459
+ const privateValue = privateConfig.servers?.[name] && typeof privateConfig.servers[name] === "object" ? privateConfig.servers[name] : {};
460
+ const privateEnv = privateValue.env && typeof privateValue.env === "object" && !Array.isArray(privateValue.env) ? privateValue.env : {};
461
+ const privateHeaders = privateValue.headers && typeof privateValue.headers === "object" && !Array.isArray(privateValue.headers) ? privateValue.headers : {};
462
+ const configValue = {
463
+ ...publicValue,
464
+ env: { ...(publicValue.env || {}), ...privateEnv },
465
+ headers: { ...(publicValue.headers || {}), ...privateHeaders },
466
+ };
467
+ return {
468
+ name,
469
+ type: configValue.url ? "url" : "command",
470
+ url: typeof configValue.url === "string" ? configValue.url : "",
471
+ command: typeof configValue.command === "string" ? configValue.command : "",
472
+ args: Array.isArray(configValue.args) ? configValue.args : [],
473
+ env: configValue.env && typeof configValue.env === "object" ? configValue.env : {},
474
+ headers: configValue.headers && typeof configValue.headers === "object" ? configValue.headers : {},
475
+ description: typeof configValue.description === "string" ? configValue.description : "",
476
+ raw: configValue,
477
+ privateEnvKeys: Object.keys(privateEnv),
478
+ privateHeaderKeys: Object.keys(privateHeaders),
479
+ };
480
+ }).sort((a, b) => a.name.localeCompare(b.name));
481
+ return { path: cursorMcpConfigPath(), servers };
482
+ }
483
+
484
+ function writeCursorMcpServer(payload = {}, userCtx = {}) {
485
+ const name = String(payload?.name || "").trim();
486
+ const nextName = String(payload?.nextName || payload?.name || "").trim();
487
+ if (!/^[A-Za-z0-9_.-]+$/.test(nextName)) throw new Error("Invalid MCP name");
488
+ const server = normalizeMcpServerConfig(payload?.server);
489
+ if (!server.url && !server.command) throw new Error("MCP server requires url or command");
490
+ const privateEnvKeys = normalizeMcpPrivateKeys(payload?.privateEnvKeys);
491
+ const privateHeaderKeys = normalizeMcpPrivateKeys(payload?.privateHeaderKeys);
492
+ const privateEnv = pickObjectKeys(server.env || {}, privateEnvKeys);
493
+ const privateHeaders = pickObjectKeys(server.headers || {}, privateHeaderKeys);
494
+ const publicServer = {
495
+ ...server,
496
+ env: omitObjectKeys(server.env || {}, privateEnvKeys),
497
+ headers: omitObjectKeys(server.headers || {}, privateHeaderKeys),
498
+ };
499
+ if (!Object.keys(publicServer.env).length) delete publicServer.env;
500
+ if (!Object.keys(publicServer.headers).length) delete publicServer.headers;
501
+ const p = cursorMcpConfigPath();
502
+ const config = readCursorMcpConfig();
503
+ const mcpServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
504
+ ? { ...config.mcpServers }
505
+ : {};
506
+ if (name && name !== nextName) delete mcpServers[name];
507
+ mcpServers[nextName] = publicServer;
508
+ const next = { ...config, mcpServers };
509
+ fs.mkdirSync(path.dirname(p), { recursive: true });
510
+ fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n", "utf-8");
511
+ const privateConfig = readUserMcpPrivate(userCtx);
512
+ const privateServers = { ...(privateConfig.servers || {}) };
513
+ if (name && name !== nextName) delete privateServers[name];
514
+ if (Object.keys(privateEnv).length || Object.keys(privateHeaders).length) {
515
+ privateServers[nextName] = {
516
+ ...(Object.keys(privateEnv).length ? { env: privateEnv } : {}),
517
+ ...(Object.keys(privateHeaders).length ? { headers: privateHeaders } : {}),
518
+ };
519
+ } else {
520
+ delete privateServers[nextName];
521
+ }
522
+ writeUserMcpPrivate(userCtx, { servers: privateServers });
523
+ return readCursorMcpServers(userCtx);
524
+ }
525
+
526
+ function deleteCursorMcpServer(name, userCtx = {}) {
527
+ const key = String(name || "").trim();
528
+ if (!key) throw new Error("Missing MCP name");
529
+ const p = cursorMcpConfigPath();
530
+ const config = readCursorMcpConfig();
531
+ const mcpServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
532
+ ? { ...config.mcpServers }
533
+ : {};
534
+ delete mcpServers[key];
535
+ const next = { ...config, mcpServers };
536
+ fs.mkdirSync(path.dirname(p), { recursive: true });
537
+ fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n", "utf-8");
538
+ const privateConfig = readUserMcpPrivate(userCtx);
539
+ const privateServers = { ...(privateConfig.servers || {}) };
540
+ delete privateServers[key];
541
+ writeUserMcpPrivate(userCtx, { servers: privateServers });
542
+ return readCursorMcpServers(userCtx);
543
+ }
544
+
545
+ function compactErrorMessage(error) {
546
+ const text = String(error?.message || error || "").trim();
547
+ return text.length > 260 ? `${text.slice(0, 257)}...` : text;
548
+ }
549
+
550
+ function parseMcpSsePayload(text) {
551
+ const events = [];
552
+ let data = [];
553
+ for (const rawLine of String(text || "").split(/\r?\n/g)) {
554
+ const line = rawLine.trimEnd();
555
+ if (!line) {
556
+ if (data.length) {
557
+ const joined = data.join("\n").trim();
558
+ if (joined) events.push(joined);
559
+ data = [];
560
+ }
561
+ continue;
562
+ }
563
+ if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
564
+ }
565
+ if (data.length) events.push(data.join("\n").trim());
566
+ for (const event of events) {
567
+ try {
568
+ const parsed = JSON.parse(event);
569
+ if (parsed && typeof parsed === "object") return parsed;
570
+ } catch {}
571
+ }
572
+ return null;
573
+ }
574
+
575
+ async function mcpHttpRequest(url, headers, body, sessionId = "") {
576
+ const controller = new AbortController();
577
+ const timer = setTimeout(() => controller.abort(), 8000);
578
+ try {
579
+ const response = await fetch(url, {
580
+ method: "POST",
581
+ headers: {
582
+ "Accept": "application/json, text/event-stream",
583
+ "Content-Type": "application/json",
584
+ ...(headers || {}),
585
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
586
+ },
587
+ body: JSON.stringify(body),
588
+ signal: controller.signal,
589
+ });
590
+ const text = await response.text();
591
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 180)}`);
592
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
593
+ const parsed = contentType.includes("text/event-stream") ? parseMcpSsePayload(text) : JSON.parse(text || "{}");
594
+ return { message: parsed, sessionId: response.headers.get("mcp-session-id") || sessionId };
595
+ } finally {
596
+ clearTimeout(timer);
597
+ }
598
+ }
599
+
600
+ async function checkMcpHttpServer(server) {
601
+ const url = String(server?.raw?.url || server?.url || "").trim();
602
+ if (!url) throw new Error("Missing MCP URL");
603
+ const headers = server?.raw?.headers && typeof server.raw.headers === "object" ? server.raw.headers : {};
604
+ const init = await mcpHttpRequest(url, headers, {
605
+ jsonrpc: "2.0",
606
+ id: 1,
607
+ method: "initialize",
608
+ params: {
609
+ protocolVersion: "2024-11-05",
610
+ capabilities: {},
611
+ clientInfo: { name: "agentflow", version: "0.1.0" },
612
+ },
613
+ });
614
+ if (init.message?.error) throw new Error(init.message.error.message || "MCP initialize failed");
615
+ await mcpHttpRequest(url, headers, {
616
+ jsonrpc: "2.0",
617
+ method: "notifications/initialized",
618
+ params: {},
619
+ }, init.sessionId).catch(() => null);
620
+ const tools = await mcpHttpRequest(url, headers, {
621
+ jsonrpc: "2.0",
622
+ id: 2,
623
+ method: "tools/list",
624
+ params: {},
625
+ }, init.sessionId);
626
+ if (tools.message?.error) throw new Error(tools.message.error.message || "MCP tools/list failed");
627
+ return Array.isArray(tools.message?.result?.tools) ? tools.message.result.tools : [];
628
+ }
629
+
630
+ async function checkMcpStdioServer(server) {
631
+ const command = String(server?.raw?.command || server?.command || "").trim();
632
+ if (!command) throw new Error("Missing MCP command");
633
+ const args = Array.isArray(server?.raw?.args) ? server.raw.args.map(String) : [];
634
+ const env = server?.raw?.env && typeof server.raw.env === "object" ? server.raw.env : {};
635
+ const child = spawn(command, args, {
636
+ cwd: os.homedir(),
637
+ env: { ...process.env, ...env },
638
+ stdio: ["pipe", "pipe", "pipe"],
639
+ });
640
+ let buffer = "";
641
+ let stderr = "";
642
+ let processError = null;
643
+ const pending = new Map();
644
+ let nextId = 1;
645
+ const cleanup = () => {
646
+ for (const [, request] of pending) clearTimeout(request.timer);
647
+ pending.clear();
648
+ if (!child.killed) child.kill("SIGTERM");
649
+ };
650
+ const rejectPending = (error) => {
651
+ for (const [, request] of pending) {
652
+ clearTimeout(request.timer);
653
+ request.reject(error);
654
+ }
655
+ pending.clear();
656
+ };
657
+ child.on("error", (error) => {
658
+ processError = error;
659
+ rejectPending(error);
660
+ });
661
+ child.stdin.on("error", (error) => {
662
+ processError = error;
663
+ rejectPending(error);
664
+ });
665
+ child.stdout.setEncoding("utf8");
666
+ child.stderr.setEncoding("utf8");
667
+ child.stderr.on("data", (chunk) => {
668
+ stderr += String(chunk || "");
669
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
670
+ });
671
+ child.stdout.on("data", (chunk) => {
672
+ buffer += String(chunk || "");
673
+ const lines = buffer.split(/\r?\n/g);
674
+ buffer = lines.pop() || "";
675
+ for (const line of lines) {
676
+ const text = line.trim();
677
+ if (!text) continue;
678
+ let message = null;
679
+ try {
680
+ message = JSON.parse(text);
681
+ } catch {
682
+ continue;
683
+ }
684
+ const request = pending.get(message.id);
685
+ if (request) {
686
+ pending.delete(message.id);
687
+ clearTimeout(request.timer);
688
+ request.resolve(message);
689
+ }
690
+ }
691
+ });
692
+ const send = (method, params = {}, timeoutMs = 8000) => new Promise((resolve, reject) => {
693
+ if (processError) {
694
+ reject(processError);
695
+ return;
696
+ }
697
+ const id = nextId++;
698
+ const timer = setTimeout(() => {
699
+ pending.delete(id);
700
+ reject(new Error(`${method} timed out${stderr.trim() ? `: ${stderr.trim().slice(-220)}` : ""}`));
701
+ }, timeoutMs);
702
+ pending.set(id, { resolve, reject, timer });
703
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n", (error) => {
704
+ if (!error) return;
705
+ pending.delete(id);
706
+ clearTimeout(timer);
707
+ reject(error);
708
+ });
709
+ });
710
+ const notify = (method, params = {}) => {
711
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
712
+ };
713
+ try {
714
+ const init = await send("initialize", {
715
+ protocolVersion: "2024-11-05",
716
+ capabilities: {},
717
+ clientInfo: { name: "agentflow", version: "0.1.0" },
718
+ });
719
+ if (init?.error) throw new Error(init.error.message || "MCP initialize failed");
720
+ notify("notifications/initialized", {});
721
+ const tools = await send("tools/list", {}, 8000);
722
+ if (tools?.error) throw new Error(tools.error.message || "MCP tools/list failed");
723
+ return Array.isArray(tools?.result?.tools) ? tools.result.tools : [];
724
+ } finally {
725
+ cleanup();
726
+ }
727
+ }
728
+
729
+ async function checkMcpServer(server) {
730
+ const startedAt = Date.now();
731
+ try {
732
+ const tools = server?.type === "url" || server?.raw?.url
733
+ ? await checkMcpHttpServer(server)
734
+ : await checkMcpStdioServer(server);
735
+ return {
736
+ name: server.name,
737
+ ok: true,
738
+ status: "enabled",
739
+ toolCount: tools.length,
740
+ tools: tools.map((tool) => ({
741
+ name: String(tool?.name || ""),
742
+ description: String(tool?.description || ""),
743
+ })).filter((tool) => tool.name),
744
+ checkedAt: new Date().toISOString(),
745
+ elapsedMs: Date.now() - startedAt,
746
+ };
747
+ } catch (error) {
748
+ return {
749
+ name: server?.name || "",
750
+ ok: false,
751
+ status: "error",
752
+ error: compactErrorMessage(error),
753
+ toolCount: 0,
754
+ tools: [],
755
+ checkedAt: new Date().toISOString(),
756
+ elapsedMs: Date.now() - startedAt,
757
+ };
758
+ }
759
+ }
760
+
761
+ async function checkCursorMcpServers(name = "", userCtx = {}) {
762
+ const { servers } = readCursorMcpServers(userCtx);
763
+ const targetName = String(name || "").trim();
764
+ const targets = targetName ? servers.filter((server) => server.name === targetName) : servers;
765
+ if (targetName && targets.length === 0) throw new Error("MCP server not found");
766
+ const results = [];
767
+ for (const server of targets) {
768
+ results.push(await checkMcpServer(server));
769
+ }
770
+ return { results };
771
+ }
772
+
320
773
  function readModelListsFromDisk(workspaceRoot) {
321
774
  const p = getModelListsAbs();
322
775
  const empty = {
@@ -344,6 +797,8 @@ function readModelListsFromDisk(workspaceRoot) {
344
797
  }
345
798
 
346
799
  const SKILLHUB_TIMEOUT_MS = 60_000;
800
+ const SKILLHUB_API_BASE = String(process.env.SKILLHUB_API_BASE || "https://skillhub.bigo.sg/api/v1").replace(/\/+$/, "");
801
+ const skillhubCollectionInfoCache = new Map();
347
802
 
348
803
  function runSkillhub(args, opts = {}) {
349
804
  return new Promise((resolve) => {
@@ -369,6 +824,72 @@ function runSkillhub(args, opts = {}) {
369
824
  });
370
825
  }
371
826
 
827
+ function readSkillhubAuthToken() {
828
+ try {
829
+ const p = path.join(os.homedir(), ".skillhub", "auth.json");
830
+ if (!fs.existsSync(p)) return "";
831
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
832
+ return String(data?.token || data?.accessToken || data?.access_token || "").trim();
833
+ } catch {
834
+ return "";
835
+ }
836
+ }
837
+
838
+ function normalizeSkillhubCollectionInfo(raw, collectionId) {
839
+ const data = raw?.data && typeof raw.data === "object"
840
+ ? raw.data
841
+ : raw?.collection && typeof raw.collection === "object"
842
+ ? raw.collection
843
+ : raw?.item && typeof raw.item === "object"
844
+ ? raw.item
845
+ : raw && typeof raw === "object"
846
+ ? raw
847
+ : {};
848
+ const id = String(data.id ?? collectionId ?? "").trim();
849
+ const name = String(data.name ?? data.displayName ?? data.display_name ?? data.title ?? "").trim();
850
+ const summary = String(data.description ?? data.summary ?? data.subtitle ?? "").trim();
851
+ const version = String(data.version ?? data.latestVersion ?? data.latest_version ?? "").trim();
852
+ const tags = Array.isArray(data.tags) ? data.tags.map(String).filter(Boolean) : [];
853
+ if (!id && !name) return null;
854
+ return {
855
+ id: id || String(collectionId || ""),
856
+ collection: id || String(collectionId || ""),
857
+ kind: "collection",
858
+ slug: "",
859
+ name: name || `Collection ${collectionId}`,
860
+ summary: summary || "按 Collection ID 安装该合集中的全部 Skills。",
861
+ version,
862
+ tags,
863
+ };
864
+ }
865
+
866
+ async function fetchSkillhubCollectionInfo(collectionId) {
867
+ const id = String(collectionId || "").trim();
868
+ if (!id) return null;
869
+ const cached = skillhubCollectionInfoCache.get(id);
870
+ if (cached) return cached;
871
+ if (typeof fetch !== "function") return null;
872
+ const controller = new AbortController();
873
+ const timer = setTimeout(() => controller.abort(), 8000);
874
+ try {
875
+ const token = readSkillhubAuthToken();
876
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
877
+ const r = await fetch(`${SKILLHUB_API_BASE}/collections/${encodeURIComponent(id)}`, {
878
+ headers,
879
+ signal: controller.signal,
880
+ });
881
+ if (!r.ok) return null;
882
+ const raw = await r.json().catch(() => null);
883
+ const info = normalizeSkillhubCollectionInfo(raw, id);
884
+ if (info) skillhubCollectionInfoCache.set(id, info);
885
+ return info;
886
+ } catch {
887
+ return null;
888
+ } finally {
889
+ clearTimeout(timer);
890
+ }
891
+ }
892
+
372
893
  function parseJsonText(text, fallback = null) {
373
894
  const s = String(text || "").trim();
374
895
  if (!s) return fallback;
@@ -394,11 +915,13 @@ function normalizeSkillhubSearchPayload(raw) {
394
915
  const slug = String(x.slug ?? x.name ?? x.displayName ?? x.display_name ?? id ?? "").trim();
395
916
  return {
396
917
  id: String(id || slug),
918
+ skillId: String(id || ""),
397
919
  slug,
398
920
  name: String(x.displayName ?? x.display_name ?? x.name ?? slug),
399
921
  summary: String(x.summary ?? x.description ?? ""),
400
922
  version: String(x.version ?? x.latestVersion ?? x.latest_version ?? ""),
401
923
  tags: Array.isArray(x.tags) ? x.tags.map(String) : [],
924
+ kind: "skill",
402
925
  };
403
926
  }).filter((x) => x.slug || x.name),
404
927
  };
@@ -661,6 +1184,47 @@ function buildWorkspaceGeneratePrompt(payload) {
661
1184
  ].filter(Boolean).join("\n");
662
1185
  }
663
1186
 
1187
+ function buildWorkspaceNodeChatPrompt(payload) {
1188
+ const node = payload?.node && typeof payload.node === "object" ? payload.node : {};
1189
+ const userMessage = String(payload?.message || "").trim();
1190
+ const currentContent = String(payload?.currentContent || "").trim();
1191
+ const nodeKind = String(payload?.nodeKind || payload?.kind || "markdown").trim().toLowerCase();
1192
+ const history = Array.isArray(payload?.messages) ? payload.messages : [];
1193
+ const historyBlock = history
1194
+ .slice(-8)
1195
+ .map((msg) => {
1196
+ const role = String(msg?.role || "user").trim() === "assistant" ? "assistant" : "user";
1197
+ const text = String(msg?.text || "").trim();
1198
+ return text ? `${role}: ${text}` : "";
1199
+ })
1200
+ .filter(Boolean)
1201
+ .join("\n\n");
1202
+ const outputRule =
1203
+ nodeKind === "html"
1204
+ ? "只输出完整或片段 HTML,不要解释,不要包裹 Markdown 代码围栏。"
1205
+ : nodeKind === "image"
1206
+ ? "只输出新的图片 src,可以是 URL、data URL 或文件路径,不要解释。"
1207
+ : nodeKind === "mermaid"
1208
+ ? "只输出 Mermaid 源码,不要解释,不要包裹 Markdown 代码围栏。"
1209
+ : nodeKind === "ascii"
1210
+ ? "只输出 ASCII 正文,不要解释,不要包裹 Markdown 代码围栏。"
1211
+ : "只输出新的 Markdown 正文,不要解释,不要包裹 Markdown 代码围栏。";
1212
+ return [
1213
+ "你正在微调 AgentFlow Workspace 画布中的单个展示节点。",
1214
+ "根据用户 follow-up 和当前节点内容,生成一个可直接替换当前节点展示内容的候选版本。",
1215
+ outputRule,
1216
+ "",
1217
+ "## 当前节点",
1218
+ `- id: ${String(node.id || "").trim() || "(unknown)"}`,
1219
+ `- label: ${String(node.label || "").trim() || "(unnamed)"}`,
1220
+ `- definitionId: ${String(node.definitionId || "").trim() || "(unknown)"}`,
1221
+ `- kind: ${nodeKind}`,
1222
+ currentContent ? `\n## 当前展示内容\n\n${currentContent}` : "",
1223
+ historyBlock ? `\n## 本节点对话历史\n\n${historyBlock}` : "",
1224
+ `\n## 用户 follow-up\n\n${userMessage}`,
1225
+ ].filter(Boolean).join("\n");
1226
+ }
1227
+
664
1228
  function workspaceSlotValue(slot) {
665
1229
  if (!slot || typeof slot !== "object") return "";
666
1230
  for (const key of ["value", "default"]) {
@@ -720,6 +1284,75 @@ function workspaceDisplayKind(definitionId) {
720
1284
  return "";
721
1285
  }
722
1286
 
1287
+ function normalizeHtmlDisplayContent(content) {
1288
+ let text = String(content || "").trim();
1289
+ if (!text) return "";
1290
+ const fenced = text.match(/```(?:html|HTML)?\s*\n?([\s\S]*?)```/);
1291
+ if (fenced && fenced[1]) text = fenced[1].trim();
1292
+ else {
1293
+ const openFence = text.match(/```(?:html|HTML)?\s*\n?([\s\S]*)$/);
1294
+ if (openFence && openFence[1]) text = openFence[1].trim();
1295
+ }
1296
+ text = text.replace(/^html\s*\n/i, "").replace(/```\s*$/g, "").trim();
1297
+ const markerPatterns = [
1298
+ /<!doctype\b/i,
1299
+ /<html\b/i,
1300
+ /<head\b/i,
1301
+ /<body\b/i,
1302
+ /<style\b/i,
1303
+ /<script\b/i,
1304
+ /<main\b/i,
1305
+ /<section\b/i,
1306
+ /<article\b/i,
1307
+ /<div\b/i,
1308
+ /<svg\b/i,
1309
+ /<canvas\b/i,
1310
+ ];
1311
+ const firstHtmlIndex = markerPatterns.reduce((best, pattern) => {
1312
+ const match = pattern.exec(text);
1313
+ if (!match) return best;
1314
+ return best < 0 ? match.index : Math.min(best, match.index);
1315
+ }, -1);
1316
+ if (firstHtmlIndex > 0) text = text.slice(firstHtmlIndex).trim();
1317
+ return text;
1318
+ }
1319
+
1320
+ function workspaceDownstreamDisplayRequirements(graph, nodeId) {
1321
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
1322
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1323
+ const kinds = new Set();
1324
+ for (const edge of edges) {
1325
+ if (String(edge?.source || "") !== String(nodeId)) continue;
1326
+ const target = instances[String(edge?.target || "")];
1327
+ const kind = workspaceDisplayKind(target?.definitionId);
1328
+ if (kind) kinds.add(kind);
1329
+ }
1330
+ if (kinds.size === 0) return "";
1331
+ const rules = [];
1332
+ if (kinds.has("html")) {
1333
+ rules.push("- 下游连接了 HTML 展示节点:输出可直接放入 iframe 渲染的 HTML。可以是完整 HTML 文档或 HTML fragment;不要使用 Markdown 代码围栏;不要解释生成过程。");
1334
+ }
1335
+ if (kinds.has("markdown")) {
1336
+ rules.push("- 下游连接了 Markdown 展示节点:输出 Markdown 正文;不要包裹在代码围栏中,除非正文确实需要代码块。");
1337
+ }
1338
+ if (kinds.has("mermaid")) {
1339
+ rules.push("- 下游连接了 Mermaid 展示节点:只输出 Mermaid 图表代码,例如 flowchart/sequenceDiagram;不要使用 Markdown 代码围栏;不要附加解释。");
1340
+ }
1341
+ if (kinds.has("ascii")) {
1342
+ rules.push("- 下游连接了 ASCII 展示节点:输出纯文本/ASCII 图或表格;不要输出 HTML 或 Markdown 装饰。");
1343
+ }
1344
+ if (kinds.has("image")) {
1345
+ rules.push("- 下游连接了图片展示节点:输出可作为 img src 使用的图片地址、data URL 或 base64 data URL;不要输出 Markdown 图片语法或解释文字。");
1346
+ }
1347
+ return [
1348
+ "## 下游输出要求",
1349
+ "",
1350
+ ...rules,
1351
+ "",
1352
+ "如果用户任务与下游展示格式没有冲突,优先满足上述格式要求;如果用户明确指定了其他格式,以用户任务为准。",
1353
+ ].join("\n");
1354
+ }
1355
+
723
1356
  function workspaceRunPlan(graph, runNodeId) {
724
1357
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
725
1358
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
@@ -786,6 +1419,37 @@ function workspaceUpstreamText(graph, nodeId, outputs) {
786
1419
  return workspaceInstanceText(instances[sourceId]);
787
1420
  }
788
1421
 
1422
+ function workspaceHandleIndex(handle, prefix) {
1423
+ const match = String(handle || "").match(new RegExp(`^${prefix}-(\\d+)$`));
1424
+ return match ? Number(match[1]) : 0;
1425
+ }
1426
+
1427
+ function workspaceTargetSlotForEdge(graph, edge) {
1428
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
1429
+ const target = instances[String(edge?.target || "")];
1430
+ const input = Array.isArray(target?.input) ? target.input : [];
1431
+ return input[workspaceHandleIndex(edge?.targetHandle, "input")] || null;
1432
+ }
1433
+
1434
+ function isWorkspaceSemanticInputSlot(slot) {
1435
+ const name = String(slot?.name || "");
1436
+ const type = String(slot?.type || "");
1437
+ return type === "node" || name === "prev" || name === "next" || name === "skillsContext" || name === "workspaceContext" || name === "gitContext";
1438
+ }
1439
+
1440
+ function workspaceTaskUpstreamText(graph, nodeId, outputs) {
1441
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1442
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
1443
+ const incoming = edges.filter((edge) => String(edge?.target || "") === String(nodeId));
1444
+ const contentEdges = incoming.filter((edge) => !isWorkspaceSemanticInputSlot(workspaceTargetSlotForEdge(graph, edge)));
1445
+ const contentEdge = contentEdges.find((edge) => String(edge?.targetHandle || "") === "input-1") || contentEdges[0];
1446
+ if (!contentEdge) return "";
1447
+ const sourceId = String(contentEdge.source || "");
1448
+ const out = outputs.get(sourceId);
1449
+ if (out != null && String(out).trim()) return String(out);
1450
+ return workspaceInstanceText(instances[sourceId]);
1451
+ }
1452
+
789
1453
  function parseWorkspaceSkillKeys(raw) {
790
1454
  const text = String(raw || "").trim();
791
1455
  if (!text) return [];
@@ -808,17 +1472,56 @@ function selectedSkillKeysFromInstance(instance) {
808
1472
 
809
1473
  function workspaceUpstreamSkillBlocks(graph, nodeId, outputs) {
810
1474
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
811
- return edges
1475
+ const blocks = edges
812
1476
  .filter((edge) => String(edge?.target || "") === String(nodeId))
1477
+ .filter((edge) => {
1478
+ const slot = workspaceTargetSlotForEdge(graph, edge);
1479
+ return String(slot?.name || "") === "skillsContext";
1480
+ })
813
1481
  .map((edge) => String(outputs.get(String(edge.source || "")) || ""))
814
- .filter((text) => text.includes("##") || text.includes("Skill"))
815
- .join("\n\n---\n\n");
1482
+ .filter((text) => text.includes("Skill") || text.includes("skill"))
1483
+ .flatMap((text) => text.split(/\n\s*---\s*\n/g))
1484
+ .map((text) => text.trim())
1485
+ .filter(Boolean);
1486
+ return Array.from(new Set(blocks)).join("\n\n---\n\n");
1487
+ }
1488
+
1489
+ function mergeWorkspaceSkillBlocks(...values) {
1490
+ const blocks = values
1491
+ .map((value) => String(value || ""))
1492
+ .filter(Boolean)
1493
+ .flatMap((text) => text.split(/\n\s*---\s*\n/g))
1494
+ .map((text) => text.trim())
1495
+ .filter(Boolean);
1496
+ return Array.from(new Set(blocks)).join("\n\n---\n\n");
1497
+ }
1498
+
1499
+ function buildWorkspaceSkillManifestBlock(skills, selectedKeys = []) {
1500
+ const normalizedKeys = Array.from(new Set((selectedKeys || []).map((x) => String(x || "").trim()).filter(Boolean)));
1501
+ const rows = (Array.isArray(skills) ? skills : []).map((skill) => {
1502
+ const id = String(skill?.id || "").trim();
1503
+ const absPath = String(skill?.absPath || "").trim();
1504
+ if (!id && !absPath) return "";
1505
+ return `- \`${id || path.basename(absPath)}\`${absPath ? `: ${absPath}` : ""}`;
1506
+ }).filter(Boolean);
1507
+ if (!rows.length && !normalizedKeys.length) return "";
1508
+ return [
1509
+ "### Workspace Skills Manifest",
1510
+ "",
1511
+ "这些 skills 已在当前 workspace 中可用。不要默认展开或复述其内容;仅当节点任务明确需要时,按路径 Read 对应 SKILL.md。",
1512
+ "",
1513
+ ...(
1514
+ rows.length
1515
+ ? rows
1516
+ : normalizedKeys.map((key) => `- \`${key}\``)
1517
+ ),
1518
+ ].join("\n");
816
1519
  }
817
1520
 
818
1521
  function workspaceWriteDisplayContent(instance, content) {
819
1522
  const next = { ...(instance || {}) };
820
- const text = String(content || "");
821
1523
  const kind = workspaceDisplayKind(next.definitionId);
1524
+ const text = kind === "html" ? normalizeHtmlDisplayContent(content) : String(content || "");
822
1525
  const primaryName = kind === "image" ? "src" : "content";
823
1526
  next.body = text;
824
1527
  next.input = (Array.isArray(next.input) ? next.input : []).map((slot) => (
@@ -853,11 +1556,13 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock) {
853
1556
  const instance = graph.instances[nodeId] || {};
854
1557
  const body = String(instance.body || "").trim();
855
1558
  const label = String(instance.label || nodeId).trim();
1559
+ const downstreamRequirements = workspaceDownstreamDisplayRequirements(graph, nodeId);
856
1560
  return [
857
1561
  "你正在执行 AgentFlow Workspace 画布中的一个临时节点。",
858
1562
  "只输出该节点要传给下游展示/后续节点的正文,不要解释运行过程。",
859
- skillsBlock ? `\n## Selected Skills\n\n${skillsBlock}` : "",
1563
+ skillsBlock ? `\n## Available Skills\n\n${skillsBlock}` : "",
860
1564
  upstreamText ? `\n## 上游上下文\n\n${upstreamText}` : "",
1565
+ downstreamRequirements ? `\n${downstreamRequirements}` : "",
861
1566
  `\n## 当前节点\n\n- id: ${nodeId}\n- label: ${label}\n- definitionId: ${instance.definitionId || ""}`,
862
1567
  `\n## 节点任务\n\n${body || upstreamText}`,
863
1568
  ].filter(Boolean).join("\n");
@@ -879,7 +1584,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
879
1584
  ? loadResourcesForSkillKeys(normalized, PACKAGE_ROOT, scopedRoot)
880
1585
  : { skills: [], references: [] };
881
1586
  const block = normalized.length > 0
882
- ? buildSkillCompactInjectionBlock(selectedSkillResources.skills, selectedSkillResources.references)
1587
+ ? buildWorkspaceSkillManifestBlock(selectedSkillResources.skills, normalized)
883
1588
  : "";
884
1589
  skillsBlockCache.set(cacheKey, block);
885
1590
  return block;
@@ -890,6 +1595,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
890
1595
  events.push(event);
891
1596
  if (typeof opts.onEvent === "function") opts.onEvent(event);
892
1597
  };
1598
+ const emitTiming = (nodeId, label, startedAt, extra = {}) => {
1599
+ const elapsedMs = Math.max(0, Date.now() - startedAt);
1600
+ emit({ type: "status", nodeId, line: `Timing ${label}: ${elapsedMs}ms`, timing: { label, elapsedMs, ...extra } });
1601
+ };
893
1602
  let cwd = scopedRoot;
894
1603
  const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
895
1604
 
@@ -904,9 +1613,11 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
904
1613
  }
905
1614
 
906
1615
  if (defId === "control_load_skills") {
1616
+ const skillStartedAt = Date.now();
907
1617
  const nodeSkillKeys = selectedSkillKeysFromInstance(instance);
908
1618
  const activeSkillKeys = nodeSkillKeys.length > 0 ? nodeSkillKeys : fallbackSelectedSkillKeys;
909
1619
  const skillsBlock = loadSkillsBlockForKeys(activeSkillKeys);
1620
+ emitTiming(nodeId, "load-skills", skillStartedAt, { skillCount: activeSkillKeys.length, charCount: skillsBlock.length });
910
1621
  graph.instances[nodeId] = {
911
1622
  ...instance,
912
1623
  output: (Array.isArray(instance.output) ? instance.output : []).map((slot) => (
@@ -1088,20 +1799,23 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1088
1799
 
1089
1800
  if (defId === "tool_git_worktree_unload") {
1090
1801
  const gitContext = normalizeGitContext(workspaceSlotValue(workspaceSlotByName(instance, "gitContext")));
1091
- const repoPath = workspaceResolvePath(cwd, workspaceSlotValue(workspaceSlotByName(instance, "repoPath"))) ||
1092
- (gitContext?.repoPath ? path.resolve(gitContext.repoPath) : "");
1093
- const worktreePath = workspaceResolvePath(cwd, workspaceSlotValue(workspaceSlotByName(instance, "worktreePath"))) ||
1094
- (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "");
1095
- if (!repoPath) throw new Error("Unload Worktree requires repoPath");
1096
- if (!worktreePath) throw new Error("Unload Worktree requires worktreePath");
1802
+ const workspaceContext = parseJsonText(workspaceSlotValue(workspaceSlotByName(instance, "workspaceContext")), null);
1803
+ const contextCwd = workspaceContext?.cwd ? path.resolve(String(workspaceContext.cwd)) : cwd;
1804
+ const worktreePath = workspaceResolvePath(contextCwd, workspaceSlotValue(workspaceSlotByName(instance, "worktreePath"))) ||
1805
+ (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "") ||
1806
+ contextCwd;
1807
+ const repoPath = workspaceResolvePath(contextCwd, workspaceSlotValue(workspaceSlotByName(instance, "repoPath"))) ||
1808
+ (gitContext?.repoPath ? path.resolve(gitContext.repoPath) : "") ||
1809
+ inferGitRepoRootFromWorktree(worktreePath);
1097
1810
  const force = ["true", "1", "yes", "on"].includes(workspaceSlotValue(workspaceSlotByName(instance, "force")).trim().toLowerCase());
1098
1811
  const pruneRaw = workspaceSlotValue(workspaceSlotByName(instance, "prune")).trim().toLowerCase();
1099
1812
  const prune = pruneRaw !== "false";
1100
1813
  const result = unloadGitWorktree({ repoPath, worktreePath, force, prune });
1101
- cwd = scopedRoot;
1814
+ const previousContext = workspaceContext?.previous && typeof workspaceContext.previous === "object" ? workspaceContext.previous : null;
1815
+ cwd = previousContext?.cwd ? path.resolve(String(previousContext.cwd)) : scopedRoot;
1102
1816
  let nextInstance = workspaceSetOutputSlot(instance, "removed", "true");
1103
1817
  nextInstance = workspaceSetOutputSlot(nextInstance, "message", result.message);
1104
- nextInstance = workspaceSetOutputSlot(nextInstance, "workspaceContext", JSON.stringify({
1818
+ nextInstance = workspaceSetOutputSlot(nextInstance, "workspaceContext", JSON.stringify(previousContext || {
1105
1819
  version: 1,
1106
1820
  label: "workspace",
1107
1821
  cwd,
@@ -1151,18 +1865,24 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1151
1865
  continue;
1152
1866
  }
1153
1867
 
1154
- const upstreamText = workspaceUpstreamText(graph, nodeId, outputs);
1868
+ const prepareStartedAt = Date.now();
1869
+ const upstreamText = workspaceTaskUpstreamText(graph, nodeId, outputs);
1155
1870
  const body = String(instance.body || "").trim();
1156
1871
  if (defId === "agent_subAgent" && !body && !String(upstreamText || "").trim()) {
1157
1872
  throw new Error(`Workspace node ${nodeId} has no task. Fill the node body or connect upstream text.`);
1158
1873
  }
1159
1874
  const upstreamSkillBlocks = workspaceUpstreamSkillBlocks(graph, nodeId, outputs);
1160
- const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, upstreamSkillBlocks || loadSkillsBlockForKeys(fallbackSelectedSkillKeys));
1875
+ const promptSkillsBlock = mergeWorkspaceSkillBlocks(upstreamSkillBlocks, upstreamSkillBlocks ? "" : loadSkillsBlockForKeys(fallbackSelectedSkillKeys));
1876
+ const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, promptSkillsBlock);
1877
+ emitTiming(nodeId, "prepare-agent-prompt", prepareStartedAt, { promptChars: prompt.length, upstreamChars: String(upstreamText || "").length, skillsChars: promptSkillsBlock.length });
1878
+ emit({ type: "natural", kind: "prompt", nodeId, text: prompt });
1161
1879
  let content = "";
1162
1880
  const maxAttempts = 3;
1163
1881
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1164
1882
  let attemptContent = "";
1165
1883
  try {
1884
+ const spawnStartedAt = Date.now();
1885
+ let firstAgentEventSeen = false;
1166
1886
  const handle = startComposerAgent({
1167
1887
  uiWorkspaceRoot: scopedRoot,
1168
1888
  cliWorkspace: cwd,
@@ -1170,14 +1890,22 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1170
1890
  modelKey,
1171
1891
  agentflowUserId: userCtx.userId || "",
1172
1892
  onStreamEvent: (ev) => {
1893
+ if (!firstAgentEventSeen) {
1894
+ firstAgentEventSeen = true;
1895
+ emitTiming(nodeId, "agent-first-event", spawnStartedAt, { attempt, firstType: ev?.type || "" });
1896
+ }
1173
1897
  emit({ ...ev, nodeId });
1174
1898
  if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
1175
1899
  attemptContent += (attemptContent ? "\n" : "") + ev.text;
1176
- const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, attemptContent);
1177
- if (updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
1178
1900
  }
1179
1901
  },
1902
+ onToolCall: (subtype, toolName) => {
1903
+ const sub = subtype ? String(subtype) : "";
1904
+ const tool = toolName ? String(toolName) : "";
1905
+ emit({ type: "status", nodeId, line: `工具 ${tool || "thinking"}${sub ? ` (${sub})` : ""}` });
1906
+ },
1180
1907
  });
1908
+ emitTiming(nodeId, "spawn-agent", spawnStartedAt, { attempt });
1181
1909
  await handle.finished;
1182
1910
  content = attemptContent.trim();
1183
1911
  break;
@@ -2088,6 +2816,61 @@ export function startUiServer({
2088
2816
  return;
2089
2817
  }
2090
2818
 
2819
+ if (req.method === "POST" && url.pathname === "/api/workspace/node-chat") {
2820
+ let payload;
2821
+ try {
2822
+ payload = JSON.parse(await readBody(req));
2823
+ } catch {
2824
+ json(res, 400, { error: "Invalid JSON body" });
2825
+ return;
2826
+ }
2827
+ const message = String(payload?.message || "").trim();
2828
+ if (!message) {
2829
+ json(res, 400, { error: "Missing message" });
2830
+ return;
2831
+ }
2832
+ try {
2833
+ const scoped = resolveWorkspaceScopeRoot(root, {
2834
+ flowId: payload.flowId || "",
2835
+ flowSource: payload.flowSource || "user",
2836
+ archived: payload.archived === true || payload.flowArchived === true,
2837
+ }, userCtx);
2838
+ if (scoped.error) {
2839
+ json(res, 400, { error: scoped.error });
2840
+ return;
2841
+ }
2842
+ const promptText = buildWorkspaceNodeChatPrompt(payload);
2843
+ const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
2844
+ let content = "";
2845
+ const events = [];
2846
+ const handle = startComposerAgent({
2847
+ uiWorkspaceRoot: scoped.root,
2848
+ cliWorkspace: scoped.root,
2849
+ prompt: promptText,
2850
+ modelKey,
2851
+ agentflowUserId: userCtx.userId || "",
2852
+ onStreamEvent: (ev) => {
2853
+ events.push(ev);
2854
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
2855
+ content += (content ? "\n" : "") + ev.text;
2856
+ }
2857
+ },
2858
+ });
2859
+ await handle.finished;
2860
+ const candidateContent = content.trim();
2861
+ json(res, 200, {
2862
+ ok: true,
2863
+ sessionId: String(payload?.sessionId || "") || `nodechat_${Date.now()}`,
2864
+ reply: candidateContent,
2865
+ candidateContent,
2866
+ events,
2867
+ });
2868
+ } catch (e) {
2869
+ json(res, 500, { error: (e && e.message) || String(e) });
2870
+ }
2871
+ return;
2872
+ }
2873
+
2091
2874
  if (req.method === "GET" && url.pathname === "/api/pipeline-files") {
2092
2875
  const flowId = url.searchParams.get("flowId");
2093
2876
  const flowSource = url.searchParams.get("flowSource") || "user";
@@ -2304,6 +3087,64 @@ export function startUiServer({
2304
3087
  return;
2305
3088
  }
2306
3089
 
3090
+ if (req.method === "GET" && url.pathname === "/api/mcps") {
3091
+ try {
3092
+ json(res, 200, readCursorMcpServers(userCtx));
3093
+ } catch (e) {
3094
+ json(res, 500, { error: (e && e.message) || String(e) });
3095
+ }
3096
+ return;
3097
+ }
3098
+
3099
+ if (req.method === "POST" && url.pathname === "/api/mcps") {
3100
+ let payload;
3101
+ try {
3102
+ payload = JSON.parse(await readBody(req));
3103
+ } catch {
3104
+ json(res, 400, { error: "Invalid JSON body" });
3105
+ return;
3106
+ }
3107
+ try {
3108
+ json(res, 200, writeCursorMcpServer(payload, userCtx));
3109
+ } catch (e) {
3110
+ json(res, 400, { error: (e && e.message) || String(e) });
3111
+ }
3112
+ return;
3113
+ }
3114
+
3115
+ if (req.method === "POST" && url.pathname === "/api/mcps/delete") {
3116
+ let payload;
3117
+ try {
3118
+ payload = JSON.parse(await readBody(req));
3119
+ } catch {
3120
+ json(res, 400, { error: "Invalid JSON body" });
3121
+ return;
3122
+ }
3123
+ try {
3124
+ json(res, 200, deleteCursorMcpServer(payload?.name, userCtx));
3125
+ } catch (e) {
3126
+ json(res, 400, { error: (e && e.message) || String(e) });
3127
+ }
3128
+ return;
3129
+ }
3130
+
3131
+ if (req.method === "POST" && url.pathname === "/api/mcps/check") {
3132
+ let payload;
3133
+ try {
3134
+ const raw = await readBody(req);
3135
+ payload = raw && String(raw).trim() ? JSON.parse(raw) : {};
3136
+ } catch {
3137
+ json(res, 400, { error: "Invalid JSON body" });
3138
+ return;
3139
+ }
3140
+ try {
3141
+ json(res, 200, await checkCursorMcpServers(payload?.name || "", userCtx));
3142
+ } catch (e) {
3143
+ json(res, 400, { error: (e && e.message) || String(e) });
3144
+ }
3145
+ return;
3146
+ }
3147
+
2307
3148
  if (req.method === "GET" && url.pathname === "/api/user-env") {
2308
3149
  try {
2309
3150
  json(res, 200, { env: readUserEnvRows(userCtx.userId) });
@@ -2381,16 +3222,41 @@ export function startUiServer({
2381
3222
 
2382
3223
  if (req.method === "GET" && url.pathname === "/api/skillhub/search") {
2383
3224
  const q = (url.searchParams.get("q") || "").trim();
3225
+ const mode = (url.searchParams.get("mode") || "keyword").trim();
2384
3226
  if (!q) {
2385
3227
  json(res, 200, { total: 0, items: [] });
2386
3228
  return;
2387
3229
  }
3230
+ if (mode === "collectionId") {
3231
+ const info = await fetchSkillhubCollectionInfo(q);
3232
+ json(res, 200, {
3233
+ total: 1,
3234
+ mode,
3235
+ items: [info || {
3236
+ id: `collection:${q}`,
3237
+ collection: q,
3238
+ kind: "collection",
3239
+ slug: "",
3240
+ name: `Collection ${q}`,
3241
+ summary: "按 Collection ID 安装该合集中的全部 Skills。",
3242
+ version: "",
3243
+ tags: [],
3244
+ }],
3245
+ });
3246
+ return;
3247
+ }
2388
3248
  const result = await runSkillhub(["search", "-q", q], { cwd: root });
2389
3249
  if (!result.ok) {
2390
3250
  json(res, 500, { error: result.error, stdout: result.stdout });
2391
3251
  return;
2392
3252
  }
2393
- json(res, 200, normalizeSkillhubSearchPayload(parseJsonText(result.stdout, {})));
3253
+ const payload = normalizeSkillhubSearchPayload(parseJsonText(result.stdout, {}));
3254
+ if (mode === "skillId") {
3255
+ const filtered = payload.items.filter((item) => item.skillId === q || item.id === q);
3256
+ json(res, 200, { ...payload, mode, total: filtered.length, items: filtered });
3257
+ return;
3258
+ }
3259
+ json(res, 200, { ...payload, mode });
2394
3260
  return;
2395
3261
  }
2396
3262
 
@@ -2407,12 +3273,19 @@ export function startUiServer({
2407
3273
  json(res, 400, { error: "Missing skill slug or collection" });
2408
3274
  return;
2409
3275
  }
3276
+ const beforeSkills = payload?.collection ? listComposerSkills(PACKAGE_ROOT, root) : [];
2410
3277
  const result = await runSkillhub(args, { cwd: root, timeoutMs: 180_000, maxBuffer: 4 * 1024 * 1024 });
2411
3278
  if (!result.ok) {
2412
3279
  json(res, 500, { error: result.error, stdout: result.stdout });
2413
3280
  return;
2414
3281
  }
2415
- json(res, 200, { ok: true, stdout: result.stdout });
3282
+ let skillCollections = null;
3283
+ if (payload?.collection) {
3284
+ const afterSkills = listComposerSkills(PACKAGE_ROOT, root);
3285
+ const collectionName = String(payload.collectionName || payload.name || "").trim();
3286
+ skillCollections = upsertSkillhubCollectionGroup(userCtx, payload.collection, beforeSkills, afterSkills, collectionName);
3287
+ }
3288
+ json(res, 200, { ok: true, stdout: result.stdout, skillCollections });
2416
3289
  return;
2417
3290
  }
2418
3291
 
@@ -2434,7 +3307,8 @@ export function startUiServer({
2434
3307
  json(res, 500, { error: result.error, stdout: result.stdout });
2435
3308
  return;
2436
3309
  }
2437
- json(res, 200, { ok: true, stdout: result.stdout });
3310
+ const skillCollections = payload?.collection ? removeSkillhubCollectionGroup(userCtx, payload.collection, root) : null;
3311
+ json(res, 200, { ok: true, stdout: result.stdout, skillCollections });
2438
3312
  return;
2439
3313
  }
2440
3314