bosun 0.36.0 → 0.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +98 -16
  2. package/README.md +27 -0
  3. package/agent-event-bus.mjs +5 -5
  4. package/agent-pool.mjs +129 -12
  5. package/agent-prompts.mjs +7 -1
  6. package/agent-sdk.mjs +13 -2
  7. package/agent-supervisor.mjs +2 -2
  8. package/agent-work-report.mjs +1 -1
  9. package/anomaly-detector.mjs +6 -6
  10. package/autofix.mjs +15 -15
  11. package/bosun-skills.mjs +4 -4
  12. package/bosun.schema.json +160 -4
  13. package/claude-shell.mjs +11 -11
  14. package/cli.mjs +21 -21
  15. package/codex-config.mjs +19 -19
  16. package/codex-shell.mjs +180 -29
  17. package/config-doctor.mjs +27 -2
  18. package/config.mjs +60 -7
  19. package/copilot-shell.mjs +4 -4
  20. package/error-detector.mjs +1 -1
  21. package/fleet-coordinator.mjs +2 -2
  22. package/gemini-shell.mjs +692 -0
  23. package/github-oauth-portal.mjs +1 -1
  24. package/github-reconciler.mjs +2 -2
  25. package/kanban-adapter.mjs +741 -168
  26. package/merge-strategy.mjs +25 -25
  27. package/monitor.mjs +123 -105
  28. package/opencode-shell.mjs +22 -22
  29. package/package.json +7 -1
  30. package/postinstall.mjs +22 -22
  31. package/pr-cleanup-daemon.mjs +6 -6
  32. package/prepublish-check.mjs +4 -4
  33. package/presence.mjs +2 -2
  34. package/primary-agent.mjs +85 -7
  35. package/publish.mjs +1 -1
  36. package/review-agent.mjs +1 -1
  37. package/session-tracker.mjs +11 -0
  38. package/setup-web-server.mjs +429 -21
  39. package/setup.mjs +367 -12
  40. package/shared-knowledge.mjs +1 -1
  41. package/startup-service.mjs +9 -9
  42. package/stream-resilience.mjs +58 -4
  43. package/sync-engine.mjs +2 -2
  44. package/task-assessment.mjs +9 -9
  45. package/task-cli.mjs +1 -1
  46. package/task-complexity.mjs +71 -2
  47. package/task-context.mjs +1 -2
  48. package/task-executor.mjs +104 -41
  49. package/telegram-bot.mjs +825 -494
  50. package/telegram-sentinel.mjs +28 -28
  51. package/ui/app.js +256 -23
  52. package/ui/app.monolith.js +1 -1
  53. package/ui/components/agent-selector.js +4 -3
  54. package/ui/components/chat-view.js +101 -28
  55. package/ui/components/diff-viewer.js +3 -3
  56. package/ui/components/kanban-board.js +3 -3
  57. package/ui/components/session-list.js +255 -35
  58. package/ui/components/workspace-switcher.js +3 -3
  59. package/ui/demo.html +209 -194
  60. package/ui/index.html +3 -3
  61. package/ui/modules/icon-utils.js +206 -142
  62. package/ui/modules/icons.js +2 -27
  63. package/ui/modules/settings-schema.js +29 -5
  64. package/ui/modules/streaming.js +30 -2
  65. package/ui/modules/vision-stream.js +275 -0
  66. package/ui/modules/voice-client.js +102 -9
  67. package/ui/modules/voice-fallback.js +62 -6
  68. package/ui/modules/voice-overlay.js +594 -59
  69. package/ui/modules/voice.js +31 -38
  70. package/ui/setup.html +284 -34
  71. package/ui/styles/components.css +47 -0
  72. package/ui/styles/sessions.css +75 -0
  73. package/ui/tabs/agents.js +73 -43
  74. package/ui/tabs/chat.js +37 -40
  75. package/ui/tabs/control.js +2 -2
  76. package/ui/tabs/dashboard.js +1 -1
  77. package/ui/tabs/infra.js +10 -10
  78. package/ui/tabs/library.js +8 -8
  79. package/ui/tabs/logs.js +10 -10
  80. package/ui/tabs/settings.js +20 -20
  81. package/ui/tabs/tasks.js +76 -47
  82. package/ui-server.mjs +1761 -124
  83. package/update-check.mjs +13 -13
  84. package/ve-kanban.mjs +1 -1
  85. package/whatsapp-channel.mjs +5 -5
  86. package/workflow-engine.mjs +20 -1
  87. package/workflow-nodes.mjs +904 -4
  88. package/workflow-templates/agents.mjs +321 -7
  89. package/workflow-templates/ci-cd.mjs +6 -6
  90. package/workflow-templates/github.mjs +156 -84
  91. package/workflow-templates/planning.mjs +8 -8
  92. package/workflow-templates/reliability.mjs +8 -8
  93. package/workflow-templates/security.mjs +3 -3
  94. package/workflow-templates.mjs +15 -9
  95. package/workspace-manager.mjs +85 -1
  96. package/workspace-monitor.mjs +2 -2
  97. package/workspace-registry.mjs +2 -2
  98. package/worktree-manager.mjs +1 -1
@@ -194,6 +194,28 @@ function summarizeAssistantUsage(data = {}) {
194
194
  return `Usage: ${parts.join(" · ")}`;
195
195
  }
196
196
 
197
+ async function createKanbanTaskWithProject(kanban, taskData = {}, projectIdValue = "") {
198
+ if (!kanban || typeof kanban.createTask !== "function") {
199
+ throw new Error("Kanban adapter not available");
200
+ }
201
+
202
+ const payload =
203
+ taskData && typeof taskData === "object" ? { ...taskData } : {};
204
+ const resolvedProjectId = String(projectIdValue || payload.projectId || "").trim();
205
+
206
+ if (resolvedProjectId) {
207
+ payload.projectId = resolvedProjectId;
208
+ }
209
+
210
+ if (kanban.createTask.length >= 2) {
211
+ const taskPayload = { ...payload };
212
+ delete taskPayload.projectId;
213
+ return kanban.createTask(resolvedProjectId, taskPayload);
214
+ }
215
+
216
+ return kanban.createTask(payload);
217
+ }
218
+
197
219
  function summarizeAssistantMessageData(data = {}) {
198
220
  const messageText = normalizeNarrativeText(
199
221
  extractStreamText(data?.content) ||
@@ -533,6 +555,162 @@ function normalizeLegacyWorkflowCommand(command) {
533
555
  return normalized;
534
556
  }
535
557
 
558
+ function resolveWorkflowNodeValue(value, ctx) {
559
+ if (typeof value === "string") return ctx.resolve(value);
560
+ if (Array.isArray(value)) {
561
+ return value.map((item) => resolveWorkflowNodeValue(item, ctx));
562
+ }
563
+ if (value && typeof value === "object") {
564
+ const resolved = {};
565
+ for (const [key, entry] of Object.entries(value)) {
566
+ resolved[key] = resolveWorkflowNodeValue(entry, ctx);
567
+ }
568
+ return resolved;
569
+ }
570
+ return value;
571
+ }
572
+
573
+ function parseBooleanSetting(value, defaultValue = false) {
574
+ if (value == null || value === "") return defaultValue;
575
+ if (typeof value === "boolean") return value;
576
+ if (typeof value === "number") return value !== 0;
577
+ if (typeof value === "string") {
578
+ const normalized = value.trim().toLowerCase();
579
+ if (!normalized) return defaultValue;
580
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
581
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
582
+ }
583
+ return defaultValue;
584
+ }
585
+
586
+ function getPathValue(value, pathExpression) {
587
+ const path = String(pathExpression || "").trim();
588
+ if (!path) return undefined;
589
+ const parts = path
590
+ .split(".")
591
+ .map((part) => String(part || "").trim())
592
+ .filter(Boolean);
593
+ if (parts.length === 0) return undefined;
594
+
595
+ let cursor = value;
596
+ for (const part of parts) {
597
+ if (cursor == null) return undefined;
598
+ if (Array.isArray(cursor)) {
599
+ const idx = Number.parseInt(part, 10);
600
+ if (!Number.isFinite(idx)) return undefined;
601
+ cursor = cursor[idx];
602
+ continue;
603
+ }
604
+ if (typeof cursor !== "object") return undefined;
605
+ cursor = cursor[part];
606
+ }
607
+ return cursor;
608
+ }
609
+
610
+ function collectWakePhraseCandidates(payload, payloadField = "") {
611
+ const candidates = [];
612
+ const seen = new Set();
613
+
614
+ const appendCandidate = (field, rawValue) => {
615
+ if (rawValue == null) return;
616
+ if (Array.isArray(rawValue)) {
617
+ rawValue.forEach((entry, idx) => appendCandidate(`${field}[${idx}]`, entry));
618
+ return;
619
+ }
620
+ if (typeof rawValue === "object") {
621
+ if (typeof rawValue.content === "string") {
622
+ appendCandidate(`${field}.content`, rawValue.content);
623
+ }
624
+ if (typeof rawValue.text === "string") {
625
+ appendCandidate(`${field}.text`, rawValue.text);
626
+ }
627
+ if (typeof rawValue.transcript === "string") {
628
+ appendCandidate(`${field}.transcript`, rawValue.transcript);
629
+ }
630
+ return;
631
+ }
632
+
633
+ const text = String(rawValue).trim();
634
+ if (!text) return;
635
+ const key = `${field}::${text}`;
636
+ if (seen.has(key)) return;
637
+ seen.add(key);
638
+ candidates.push({ field, text });
639
+ };
640
+
641
+ if (payloadField) {
642
+ appendCandidate(payloadField, getPathValue(payload, payloadField));
643
+ return candidates;
644
+ }
645
+
646
+ const commonFields = [
647
+ "content",
648
+ "text",
649
+ "transcript",
650
+ "message",
651
+ "utterance",
652
+ "payload.content",
653
+ "payload.text",
654
+ "payload.transcript",
655
+ "event.content",
656
+ "event.text",
657
+ "event.transcript",
658
+ "voice.content",
659
+ "voice.transcript",
660
+ "meta.transcript",
661
+ ];
662
+ for (const field of commonFields) {
663
+ appendCandidate(field, getPathValue(payload, field));
664
+ }
665
+
666
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
667
+ messages.forEach((entry, idx) => appendCandidate(`messages[${idx}]`, entry));
668
+
669
+ const transcriptEvents = Array.isArray(payload?.transcriptEvents) ? payload.transcriptEvents : [];
670
+ transcriptEvents.forEach((entry, idx) => appendCandidate(`transcriptEvents[${idx}]`, entry));
671
+
672
+ return candidates;
673
+ }
674
+
675
+ function detectWakePhraseMatch(text, phrase, options = {}) {
676
+ const mode = String(options.mode || "contains").trim().toLowerCase() || "contains";
677
+ const caseSensitive = options.caseSensitive === true;
678
+ const source = String(text || "");
679
+ const target = String(phrase || "");
680
+
681
+ if (!source || !target) return { matched: false, mode };
682
+
683
+ const sourceNormalized = caseSensitive ? source : source.toLowerCase();
684
+ const targetNormalized = caseSensitive ? target : target.toLowerCase();
685
+
686
+ if (mode === "exact") {
687
+ return { matched: sourceNormalized.trim() === targetNormalized.trim(), mode };
688
+ }
689
+ if (mode === "starts_with") {
690
+ return { matched: sourceNormalized.trimStart().startsWith(targetNormalized), mode };
691
+ }
692
+ if (mode === "regex") {
693
+ try {
694
+ const regex = new RegExp(target, caseSensitive ? "" : "i");
695
+ return { matched: regex.test(source), mode };
696
+ } catch (err) {
697
+ return {
698
+ matched: false,
699
+ mode,
700
+ error: `invalid regex: ${err?.message || err}`,
701
+ };
702
+ }
703
+ }
704
+ return { matched: sourceNormalized.includes(targetNormalized), mode: "contains" };
705
+ }
706
+
707
+ function normalizeWorkflowStack(value) {
708
+ if (!Array.isArray(value)) return [];
709
+ return value
710
+ .map((entry) => String(entry || "").trim())
711
+ .filter(Boolean);
712
+ }
713
+
536
714
  function isBosunStateComment(text) {
537
715
  const raw = String(text || "").toLowerCase();
538
716
  return raw.includes("bosun-state") || raw.includes("codex:ignore");
@@ -682,6 +860,165 @@ registerNodeType("trigger.event", {
682
860
  },
683
861
  });
684
862
 
863
+ registerNodeType("trigger.meeting.wake_phrase", {
864
+ describe: () => "Fires when a transcript/event payload contains the configured wake phrase",
865
+ schema: {
866
+ type: "object",
867
+ properties: {
868
+ wakePhrase: { type: "string", description: "Wake phrase to match (alias: phrase)" },
869
+ phrase: { type: "string", description: "Alias for wakePhrase" },
870
+ mode: {
871
+ type: "string",
872
+ enum: ["contains", "starts_with", "exact", "regex"],
873
+ default: "contains",
874
+ },
875
+ caseSensitive: { type: "boolean", default: false },
876
+ text: {
877
+ type: "string",
878
+ description: "Optional explicit text to inspect before payload-derived fields",
879
+ },
880
+ payloadField: {
881
+ type: "string",
882
+ description: "Optional payload path to inspect (e.g. content, payload.transcript)",
883
+ },
884
+ sessionId: { type: "string", description: "Optional sessionId filter" },
885
+ role: { type: "string", description: "Optional role filter (user|assistant|system)" },
886
+ failOnInvalidRegex: {
887
+ type: "boolean",
888
+ default: false,
889
+ description: "Throw when regex mode is invalid instead of soft-failing",
890
+ },
891
+ },
892
+ },
893
+ async execute(node, ctx) {
894
+ const eventData = ctx.data && typeof ctx.data === "object" ? ctx.data : {};
895
+ const resolveValue = (value) => (
896
+ typeof ctx?.resolve === "function" ? ctx.resolve(value) : value
897
+ );
898
+
899
+ const wakePhrase = String(
900
+ resolveValue(node.config?.wakePhrase || node.config?.phrase || eventData?.wakePhrase || ""),
901
+ ).trim();
902
+ if (!wakePhrase) {
903
+ return { triggered: false, reason: "wake_phrase_missing" };
904
+ }
905
+
906
+ const expectedSessionId = String(resolveValue(node.config?.sessionId || "")).trim();
907
+ const actualSessionId = String(
908
+ eventData?.sessionId || eventData?.meetingSessionId || eventData?.session?.id || "",
909
+ ).trim();
910
+ if (expectedSessionId) {
911
+ if (!actualSessionId) {
912
+ return {
913
+ triggered: false,
914
+ reason: "session_missing",
915
+ expectedSessionId,
916
+ };
917
+ }
918
+ if (expectedSessionId !== actualSessionId) {
919
+ return {
920
+ triggered: false,
921
+ reason: "session_mismatch",
922
+ expectedSessionId,
923
+ sessionId: actualSessionId,
924
+ };
925
+ }
926
+ }
927
+
928
+ const expectedRole = String(resolveValue(node.config?.role || "")).trim().toLowerCase();
929
+ const actualRole = String(
930
+ eventData?.role || eventData?.speakerRole || eventData?.participantRole || "",
931
+ ).trim().toLowerCase();
932
+ if (expectedRole) {
933
+ if (!actualRole) {
934
+ return {
935
+ triggered: false,
936
+ reason: "role_missing",
937
+ expectedRole,
938
+ sessionId: actualSessionId || null,
939
+ };
940
+ }
941
+ if (expectedRole !== actualRole) {
942
+ return {
943
+ triggered: false,
944
+ reason: "role_mismatch",
945
+ expectedRole,
946
+ role: actualRole,
947
+ sessionId: actualSessionId || null,
948
+ };
949
+ }
950
+ }
951
+
952
+ const payloadField = String(resolveValue(node.config?.payloadField || "")).trim();
953
+ const configuredText = String(resolveValue(node.config?.text || "") || "").trim();
954
+ const candidates = configuredText
955
+ ? [{ field: "text", text: configuredText }]
956
+ : [];
957
+ candidates.push(...collectWakePhraseCandidates(eventData, payloadField));
958
+ if (!candidates.length) {
959
+ return {
960
+ triggered: false,
961
+ reason: "payload_missing",
962
+ wakePhrase,
963
+ sessionId: actualSessionId || null,
964
+ role: actualRole || null,
965
+ };
966
+ }
967
+
968
+ const mode = String(resolveValue(node.config?.mode || "contains")).trim().toLowerCase() || "contains";
969
+ const caseSensitive = parseBooleanSetting(
970
+ resolveValue(node.config?.caseSensitive ?? false),
971
+ false,
972
+ );
973
+ const failOnInvalidRegex = parseBooleanSetting(
974
+ resolveValue(node.config?.failOnInvalidRegex ?? false),
975
+ false,
976
+ );
977
+
978
+ for (const candidate of candidates) {
979
+ const matched = detectWakePhraseMatch(candidate.text, wakePhrase, {
980
+ mode,
981
+ caseSensitive,
982
+ });
983
+ if (matched.error) {
984
+ if (failOnInvalidRegex) {
985
+ throw new Error(`trigger.meeting.wake_phrase: ${matched.error}`);
986
+ }
987
+ return {
988
+ triggered: false,
989
+ reason: "invalid_regex",
990
+ error: matched.error,
991
+ wakePhrase,
992
+ mode,
993
+ };
994
+ }
995
+ if (matched.matched) {
996
+ return {
997
+ triggered: true,
998
+ wakePhrase,
999
+ mode: matched.mode,
1000
+ sessionId: actualSessionId || null,
1001
+ role: actualRole || null,
1002
+ matchedField: candidate.field,
1003
+ matchedText: candidate.text.length > 240
1004
+ ? `${candidate.text.slice(0, 237)}...`
1005
+ : candidate.text,
1006
+ };
1007
+ }
1008
+ }
1009
+
1010
+ return {
1011
+ triggered: false,
1012
+ reason: "wake_phrase_not_found",
1013
+ wakePhrase,
1014
+ mode,
1015
+ sessionId: actualSessionId || null,
1016
+ role: actualRole || null,
1017
+ inspectedFields: candidates.slice(0, 12).map((entry) => entry.field),
1018
+ };
1019
+ },
1020
+ });
1021
+
685
1022
  registerNodeType("trigger.webhook", {
686
1023
  describe: () => "Fires when a webhook is received at the workflow's endpoint",
687
1024
  schema: {
@@ -1237,6 +1574,569 @@ registerNodeType("action.run_command", {
1237
1574
  },
1238
1575
  });
1239
1576
 
1577
+ registerNodeType("action.execute_workflow", {
1578
+ describe: () => "Execute another workflow by ID (synchronously or dispatch mode)",
1579
+ schema: {
1580
+ type: "object",
1581
+ properties: {
1582
+ workflowId: { type: "string", description: "Workflow ID to execute" },
1583
+ mode: { type: "string", enum: ["sync", "dispatch"], default: "sync" },
1584
+ input: {
1585
+ type: "object",
1586
+ description: "Input payload passed to the child workflow",
1587
+ additionalProperties: true,
1588
+ },
1589
+ inheritContext: {
1590
+ type: "boolean",
1591
+ default: false,
1592
+ description: "Copy parent workflow context data into child input before applying input overrides",
1593
+ },
1594
+ includeKeys: {
1595
+ type: "array",
1596
+ items: { type: "string" },
1597
+ description: "Optional allow-list of context keys to inherit when inheritContext=true",
1598
+ },
1599
+ outputVariable: {
1600
+ type: "string",
1601
+ description: "Optional context key to store execution summary output",
1602
+ },
1603
+ failOnChildError: {
1604
+ type: "boolean",
1605
+ default: true,
1606
+ description: "In sync mode, throw when child workflow completes with errors",
1607
+ },
1608
+ allowRecursive: {
1609
+ type: "boolean",
1610
+ default: false,
1611
+ description: "Allow recursive workflow execution when true",
1612
+ },
1613
+ },
1614
+ required: ["workflowId"],
1615
+ },
1616
+ async execute(node, ctx, engine) {
1617
+ const workflowId = String(ctx.resolve(node.config?.workflowId || "") || "").trim();
1618
+ const modeRaw = String(ctx.resolve(node.config?.mode || "sync") || "sync")
1619
+ .trim()
1620
+ .toLowerCase();
1621
+ const mode = modeRaw || "sync";
1622
+ const outputVariable = String(ctx.resolve(node.config?.outputVariable || "") || "").trim();
1623
+ const inheritContext = parseBooleanSetting(
1624
+ resolveWorkflowNodeValue(node.config?.inheritContext ?? false, ctx),
1625
+ false,
1626
+ );
1627
+ const failOnChildError = parseBooleanSetting(
1628
+ resolveWorkflowNodeValue(node.config?.failOnChildError ?? true, ctx),
1629
+ true,
1630
+ );
1631
+ const allowRecursive = parseBooleanSetting(
1632
+ resolveWorkflowNodeValue(node.config?.allowRecursive ?? false, ctx),
1633
+ false,
1634
+ );
1635
+ const includeKeys = Array.isArray(node.config?.includeKeys)
1636
+ ? node.config.includeKeys
1637
+ .map((value) => String(resolveWorkflowNodeValue(value, ctx) || "").trim())
1638
+ .filter(Boolean)
1639
+ : [];
1640
+
1641
+ if (!workflowId) {
1642
+ throw new Error("action.execute_workflow: 'workflowId' is required");
1643
+ }
1644
+ if (mode !== "sync" && mode !== "dispatch") {
1645
+ throw new Error(`action.execute_workflow: invalid mode "${mode}". Expected "sync" or "dispatch".`);
1646
+ }
1647
+ if (!engine || typeof engine.execute !== "function") {
1648
+ throw new Error("action.execute_workflow: workflow engine is not available");
1649
+ }
1650
+ if (typeof engine.get === "function" && !engine.get(workflowId)) {
1651
+ throw new Error(`action.execute_workflow: workflow "${workflowId}" not found`);
1652
+ }
1653
+
1654
+ const resolvedInputConfig = resolveWorkflowNodeValue(node.config?.input ?? {}, ctx);
1655
+ if (
1656
+ resolvedInputConfig != null &&
1657
+ (typeof resolvedInputConfig !== "object" || Array.isArray(resolvedInputConfig))
1658
+ ) {
1659
+ throw new Error("action.execute_workflow: 'input' must resolve to an object");
1660
+ }
1661
+ const configuredInput =
1662
+ resolvedInputConfig && typeof resolvedInputConfig === "object"
1663
+ ? resolvedInputConfig
1664
+ : {};
1665
+
1666
+ const sourceData =
1667
+ ctx.data && typeof ctx.data === "object"
1668
+ ? ctx.data
1669
+ : {};
1670
+ const inheritedInput = {};
1671
+ if (inheritContext) {
1672
+ if (includeKeys.length > 0) {
1673
+ for (const key of includeKeys) {
1674
+ if (Object.prototype.hasOwnProperty.call(sourceData, key)) {
1675
+ inheritedInput[key] = sourceData[key];
1676
+ }
1677
+ }
1678
+ } else {
1679
+ Object.assign(inheritedInput, sourceData);
1680
+ }
1681
+ }
1682
+
1683
+ const parentWorkflowId = String(ctx.data?._workflowId || "").trim();
1684
+ const workflowStack = normalizeWorkflowStack(ctx.data?._workflowStack);
1685
+ if (parentWorkflowId && workflowStack[workflowStack.length - 1] !== parentWorkflowId) {
1686
+ workflowStack.push(parentWorkflowId);
1687
+ }
1688
+ if (!allowRecursive && workflowStack.includes(workflowId)) {
1689
+ const cyclePath = [...workflowStack, workflowId].join(" -> ");
1690
+ throw new Error(
1691
+ `action.execute_workflow: recursive workflow call blocked (${cyclePath}). ` +
1692
+ "Set allowRecursive=true to override.",
1693
+ );
1694
+ }
1695
+
1696
+ const childInput = {
1697
+ ...inheritedInput,
1698
+ ...configuredInput,
1699
+ _workflowStack: [...workflowStack, workflowId],
1700
+ };
1701
+
1702
+ if (mode === "dispatch") {
1703
+ ctx.log(node.id, `Dispatching workflow "${workflowId}"`);
1704
+ const dispatched = engine.execute(workflowId, childInput);
1705
+ dispatched
1706
+ .then((childCtx) => {
1707
+ const status = childCtx?.errors?.length ? "failed" : "completed";
1708
+ ctx.log(node.id, `Dispatched workflow "${workflowId}" finished with status=${status}`);
1709
+ })
1710
+ .catch((err) => {
1711
+ ctx.log(node.id, `Dispatched workflow "${workflowId}" failed: ${err.message}`, "error");
1712
+ });
1713
+
1714
+ const output = {
1715
+ success: true,
1716
+ queued: true,
1717
+ mode: "dispatch",
1718
+ workflowId,
1719
+ parentRunId: ctx.id,
1720
+ stackDepth: childInput._workflowStack.length,
1721
+ };
1722
+ if (outputVariable) {
1723
+ ctx.data[outputVariable] = output;
1724
+ }
1725
+ return output;
1726
+ }
1727
+
1728
+ ctx.log(node.id, `Executing workflow "${workflowId}" (sync)`);
1729
+ const childCtx = await engine.execute(workflowId, childInput);
1730
+ const childErrors = Array.isArray(childCtx?.errors)
1731
+ ? childCtx.errors.map((entry) => ({
1732
+ nodeId: entry?.nodeId || null,
1733
+ error: String(entry?.error || "unknown child workflow error"),
1734
+ }))
1735
+ : [];
1736
+ const status = childErrors.length > 0 ? "failed" : "completed";
1737
+ const output = {
1738
+ success: status === "completed",
1739
+ queued: false,
1740
+ mode: "sync",
1741
+ workflowId,
1742
+ runId: childCtx?.id || null,
1743
+ status,
1744
+ errorCount: childErrors.length,
1745
+ errors: childErrors,
1746
+ };
1747
+
1748
+ if (outputVariable) {
1749
+ ctx.data[outputVariable] = output;
1750
+ }
1751
+
1752
+ if (status === "failed" && failOnChildError) {
1753
+ const reason = childErrors[0]?.error || "child workflow failed";
1754
+ const err = new Error(`action.execute_workflow: child workflow "${workflowId}" failed: ${reason}`);
1755
+ err.childWorkflow = output;
1756
+ throw err;
1757
+ }
1758
+
1759
+ return output;
1760
+ },
1761
+ });
1762
+
1763
+ registerNodeType("meeting.start", {
1764
+ describe: () => "Create or reuse a meeting session for workflow-driven voice/video orchestration",
1765
+ schema: {
1766
+ type: "object",
1767
+ properties: {
1768
+ sessionId: { type: "string", description: "Optional session ID (auto-generated when empty)" },
1769
+ title: { type: "string", description: "Optional human-readable session title" },
1770
+ executor: { type: "string", description: "Preferred executor for this meeting session" },
1771
+ mode: { type: "string", description: "Preferred agent mode for this meeting session" },
1772
+ model: { type: "string", description: "Preferred model override for this meeting session" },
1773
+ wakePhrase: { type: "string", description: "Optional wake phrase metadata for downstream workflow logic" },
1774
+ metadata: { type: "object", description: "Additional metadata stored with the meeting session" },
1775
+ activate: { type: "boolean", default: true, description: "Mark meeting session active after creation/reuse" },
1776
+ maxMessages: { type: "number", description: "Optional session max message retention override" },
1777
+ failOnError: { type: "boolean", default: true, description: "Throw when meeting setup fails" },
1778
+ },
1779
+ },
1780
+ async execute(node, ctx, engine) {
1781
+ const meeting = engine.services?.meeting;
1782
+ if (!meeting || typeof meeting.startMeeting !== "function") {
1783
+ throw new Error("Meeting service is not available");
1784
+ }
1785
+
1786
+ const failOnError = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.failOnError ?? true, ctx), true);
1787
+ try {
1788
+ const sessionId = String(
1789
+ ctx.resolve(node.config?.sessionId || ctx.data?.meetingSessionId || ctx.data?.sessionId || ""),
1790
+ ).trim() || undefined;
1791
+ const title = String(ctx.resolve(node.config?.title || "") || "").trim() || undefined;
1792
+ const executor = String(ctx.resolve(node.config?.executor || "") || "").trim() || undefined;
1793
+ const mode = String(ctx.resolve(node.config?.mode || "") || "").trim() || undefined;
1794
+ const model = String(ctx.resolve(node.config?.model || "") || "").trim() || undefined;
1795
+ const wakePhrase = String(ctx.resolve(node.config?.wakePhrase || "") || "").trim() || undefined;
1796
+ const metadataInput = resolveWorkflowNodeValue(node.config?.metadata || {}, ctx);
1797
+ const metadata =
1798
+ metadataInput && typeof metadataInput === "object" && !Array.isArray(metadataInput)
1799
+ ? { ...metadataInput }
1800
+ : {};
1801
+ if (title) metadata.title = title;
1802
+ if (wakePhrase) metadata.wakePhrase = wakePhrase;
1803
+
1804
+ const activate = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.activate ?? true, ctx), true);
1805
+ const maxMessagesRaw = Number(resolveWorkflowNodeValue(node.config?.maxMessages, ctx));
1806
+ const maxMessages = Number.isFinite(maxMessagesRaw) && maxMessagesRaw > 0
1807
+ ? Math.trunc(maxMessagesRaw)
1808
+ : undefined;
1809
+
1810
+ const result = await meeting.startMeeting({
1811
+ sessionId,
1812
+ metadata,
1813
+ agent: executor,
1814
+ mode,
1815
+ model,
1816
+ activate,
1817
+ maxMessages,
1818
+ });
1819
+
1820
+ const activeSessionId = String(result?.sessionId || sessionId || "").trim() || null;
1821
+ if (activeSessionId) {
1822
+ ctx.data.meetingSessionId = activeSessionId;
1823
+ ctx.data.sessionId = ctx.data.sessionId || activeSessionId;
1824
+ }
1825
+
1826
+ return {
1827
+ success: true,
1828
+ sessionId: activeSessionId,
1829
+ created: result?.created === true,
1830
+ session: result?.session || null,
1831
+ voice: result?.voice || null,
1832
+ };
1833
+ } catch (err) {
1834
+ if (failOnError) throw err;
1835
+ return {
1836
+ success: false,
1837
+ error: String(err?.message || err),
1838
+ };
1839
+ }
1840
+ },
1841
+ });
1842
+
1843
+ registerNodeType("meeting.send", {
1844
+ describe: () => "Send a meeting message through the meeting session dispatcher",
1845
+ schema: {
1846
+ type: "object",
1847
+ properties: {
1848
+ sessionId: { type: "string", description: "Meeting session ID (defaults to context session)" },
1849
+ message: { type: "string", description: "Message to send into the meeting session" },
1850
+ mode: { type: "string", description: "Optional per-message mode override" },
1851
+ model: { type: "string", description: "Optional per-message model override" },
1852
+ timeoutMs: { type: "number", description: "Optional per-message timeout in ms" },
1853
+ createIfMissing: { type: "boolean", default: true, description: "Create session automatically when missing" },
1854
+ allowInactive: { type: "boolean", default: false, description: "Allow sending when session is inactive" },
1855
+ failOnError: { type: "boolean", default: true, description: "Throw when sending fails" },
1856
+ },
1857
+ required: ["message"],
1858
+ },
1859
+ async execute(node, ctx, engine) {
1860
+ const meeting = engine.services?.meeting;
1861
+ if (!meeting || typeof meeting.sendMeetingMessage !== "function") {
1862
+ throw new Error("Meeting service is not available");
1863
+ }
1864
+
1865
+ const failOnError = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.failOnError ?? true, ctx), true);
1866
+ try {
1867
+ const sessionId = String(
1868
+ ctx.resolve(node.config?.sessionId || ctx.data?.meetingSessionId || ctx.data?.sessionId || ""),
1869
+ ).trim();
1870
+ if (!sessionId) {
1871
+ throw new Error("meeting.send requires sessionId (configure node.sessionId or run meeting.start first)");
1872
+ }
1873
+ const message = String(ctx.resolve(node.config?.message || "") || "").trim();
1874
+ if (!message) {
1875
+ throw new Error("meeting.send requires message");
1876
+ }
1877
+
1878
+ const mode = String(ctx.resolve(node.config?.mode || "") || "").trim() || undefined;
1879
+ const model = String(ctx.resolve(node.config?.model || "") || "").trim() || undefined;
1880
+ const timeoutMsRaw = Number(resolveWorkflowNodeValue(node.config?.timeoutMs, ctx));
1881
+ const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0
1882
+ ? Math.trunc(timeoutMsRaw)
1883
+ : undefined;
1884
+ const createIfMissing = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.createIfMissing ?? true, ctx), true);
1885
+ const allowInactive = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.allowInactive ?? false, ctx), false);
1886
+
1887
+ const result = await meeting.sendMeetingMessage(sessionId, message, {
1888
+ mode,
1889
+ model,
1890
+ timeoutMs,
1891
+ createIfMissing,
1892
+ allowInactive,
1893
+ });
1894
+
1895
+ const nextSessionId = String(result?.sessionId || sessionId).trim();
1896
+ if (nextSessionId) {
1897
+ ctx.data.meetingSessionId = nextSessionId;
1898
+ ctx.data.sessionId = ctx.data.sessionId || nextSessionId;
1899
+ }
1900
+
1901
+ return {
1902
+ success: result?.ok !== false,
1903
+ sessionId: nextSessionId || null,
1904
+ messageId: result?.messageId || null,
1905
+ status: result?.status || null,
1906
+ responseText: result?.responseText || "",
1907
+ adapter: result?.adapter || null,
1908
+ observedEventCount: Number(result?.observedEventCount || 0),
1909
+ };
1910
+ } catch (err) {
1911
+ if (failOnError) throw err;
1912
+ return {
1913
+ success: false,
1914
+ error: String(err?.message || err),
1915
+ };
1916
+ }
1917
+ },
1918
+ });
1919
+
1920
+ registerNodeType("meeting.transcript", {
1921
+ describe: () => "Fetch meeting transcript pages and optionally project as plain text",
1922
+ schema: {
1923
+ type: "object",
1924
+ properties: {
1925
+ sessionId: { type: "string", description: "Meeting session ID (defaults to context session)" },
1926
+ page: { type: "number", default: 1 },
1927
+ pageSize: { type: "number", default: 200 },
1928
+ includeMessages: { type: "boolean", default: true, description: "Include structured message array in output" },
1929
+ failOnError: { type: "boolean", default: true, description: "Throw when transcript retrieval fails" },
1930
+ },
1931
+ },
1932
+ async execute(node, ctx, engine) {
1933
+ const meeting = engine.services?.meeting;
1934
+ if (!meeting || typeof meeting.fetchMeetingTranscript !== "function") {
1935
+ throw new Error("Meeting service is not available");
1936
+ }
1937
+
1938
+ const failOnError = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.failOnError ?? true, ctx), true);
1939
+ try {
1940
+ const sessionId = String(
1941
+ ctx.resolve(node.config?.sessionId || ctx.data?.meetingSessionId || ctx.data?.sessionId || ""),
1942
+ ).trim();
1943
+ if (!sessionId) {
1944
+ throw new Error("meeting.transcript requires sessionId (configure node.sessionId or run meeting.start first)");
1945
+ }
1946
+
1947
+ const pageRaw = Number(resolveWorkflowNodeValue(node.config?.page ?? 1, ctx));
1948
+ const page = Number.isFinite(pageRaw) && pageRaw > 0 ? Math.trunc(pageRaw) : 1;
1949
+ const pageSizeRaw = Number(resolveWorkflowNodeValue(node.config?.pageSize ?? 200, ctx));
1950
+ const pageSize = Number.isFinite(pageSizeRaw) && pageSizeRaw > 0 ? Math.trunc(pageSizeRaw) : 200;
1951
+ const includeMessages = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.includeMessages ?? true, ctx), true);
1952
+
1953
+ const transcript = await meeting.fetchMeetingTranscript(sessionId, {
1954
+ page,
1955
+ pageSize,
1956
+ });
1957
+ const messages = Array.isArray(transcript?.messages) ? transcript.messages : [];
1958
+ const transcriptText = messages
1959
+ .map((msg) => {
1960
+ const role = String(msg?.role || msg?.type || "system").trim().toLowerCase();
1961
+ const content = String(msg?.content || "").trim();
1962
+ if (!content) return "";
1963
+ return `${role}: ${content}`;
1964
+ })
1965
+ .filter(Boolean)
1966
+ .join("\n");
1967
+
1968
+ return {
1969
+ success: true,
1970
+ sessionId,
1971
+ status: transcript?.status || null,
1972
+ page: Number(transcript?.page || page),
1973
+ pageSize: Number(transcript?.pageSize || pageSize),
1974
+ totalMessages: Number(transcript?.totalMessages || messages.length),
1975
+ totalPages: Number(transcript?.totalPages || 0),
1976
+ hasNextPage: transcript?.hasNextPage === true,
1977
+ hasPreviousPage: transcript?.hasPreviousPage === true,
1978
+ transcript: transcriptText,
1979
+ messages: includeMessages ? messages : undefined,
1980
+ };
1981
+ } catch (err) {
1982
+ if (failOnError) throw err;
1983
+ return {
1984
+ success: false,
1985
+ error: String(err?.message || err),
1986
+ };
1987
+ }
1988
+ },
1989
+ });
1990
+
1991
+ registerNodeType("meeting.vision", {
1992
+ describe: () => "Analyze a meeting video frame and persist a vision summary",
1993
+ schema: {
1994
+ type: "object",
1995
+ properties: {
1996
+ sessionId: { type: "string", description: "Meeting session ID (defaults to context session)" },
1997
+ frameDataUrl: { type: "string", description: "Base64 data URL for the current frame" },
1998
+ source: { type: "string", enum: ["screen", "camera"], default: "screen" },
1999
+ prompt: { type: "string", description: "Optional per-frame vision prompt override" },
2000
+ visionModel: { type: "string", description: "Optional vision model override" },
2001
+ minIntervalMs: { type: "number", description: "Minimum analysis interval for this session" },
2002
+ forceAnalyze: { type: "boolean", default: false, description: "Bypass dedupe/throttle checks" },
2003
+ width: { type: "number", description: "Optional frame width for transcript context" },
2004
+ height: { type: "number", description: "Optional frame height for transcript context" },
2005
+ executor: { type: "string", description: "Optional executor hint for vision context" },
2006
+ mode: { type: "string", description: "Optional mode hint for vision context" },
2007
+ model: { type: "string", description: "Optional model hint for vision context" },
2008
+ failOnError: { type: "boolean", default: true, description: "Throw when vision analysis fails" },
2009
+ },
2010
+ },
2011
+ async execute(node, ctx, engine) {
2012
+ const meeting = engine.services?.meeting;
2013
+ if (!meeting || typeof meeting.analyzeMeetingFrame !== "function") {
2014
+ throw new Error("Meeting service is not available");
2015
+ }
2016
+
2017
+ const failOnError = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.failOnError ?? true, ctx), true);
2018
+ try {
2019
+ const sessionId = String(
2020
+ ctx.resolve(node.config?.sessionId || ctx.data?.meetingSessionId || ctx.data?.sessionId || ""),
2021
+ ).trim();
2022
+ if (!sessionId) {
2023
+ throw new Error("meeting.vision requires sessionId (configure node.sessionId or run meeting.start first)");
2024
+ }
2025
+
2026
+ const frameDataUrl = String(
2027
+ ctx.resolve(node.config?.frameDataUrl || ctx.data?.frameDataUrl || ctx.data?.visionFrameDataUrl || ""),
2028
+ ).trim();
2029
+ if (!frameDataUrl) {
2030
+ throw new Error("meeting.vision requires frameDataUrl");
2031
+ }
2032
+
2033
+ const source = String(ctx.resolve(node.config?.source || "screen") || "screen").trim() || "screen";
2034
+ const prompt = String(ctx.resolve(node.config?.prompt || "") || "").trim() || undefined;
2035
+ const visionModel = String(ctx.resolve(node.config?.visionModel || "") || "").trim() || undefined;
2036
+ const minIntervalRaw = Number(resolveWorkflowNodeValue(node.config?.minIntervalMs, ctx));
2037
+ const minIntervalMs = Number.isFinite(minIntervalRaw) && minIntervalRaw > 0
2038
+ ? Math.trunc(minIntervalRaw)
2039
+ : undefined;
2040
+ const forceAnalyze = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.forceAnalyze ?? false, ctx), false);
2041
+ const widthRaw = Number(resolveWorkflowNodeValue(node.config?.width, ctx));
2042
+ const heightRaw = Number(resolveWorkflowNodeValue(node.config?.height, ctx));
2043
+ const width = Number.isFinite(widthRaw) && widthRaw > 0 ? Math.trunc(widthRaw) : undefined;
2044
+ const height = Number.isFinite(heightRaw) && heightRaw > 0 ? Math.trunc(heightRaw) : undefined;
2045
+ const executor = String(ctx.resolve(node.config?.executor || "") || "").trim() || undefined;
2046
+ const mode = String(ctx.resolve(node.config?.mode || "") || "").trim() || undefined;
2047
+ const model = String(ctx.resolve(node.config?.model || "") || "").trim() || undefined;
2048
+
2049
+ const result = await meeting.analyzeMeetingFrame(sessionId, frameDataUrl, {
2050
+ source,
2051
+ prompt,
2052
+ visionModel,
2053
+ minIntervalMs,
2054
+ forceAnalyze,
2055
+ width,
2056
+ height,
2057
+ executor,
2058
+ mode,
2059
+ model,
2060
+ });
2061
+
2062
+ ctx.data.meetingSessionId = sessionId;
2063
+ if (result?.summary) {
2064
+ ctx.data.meetingVisionSummary = String(result.summary);
2065
+ }
2066
+
2067
+ return {
2068
+ success: result?.ok !== false,
2069
+ sessionId: String(result?.sessionId || sessionId).trim(),
2070
+ analyzed: result?.analyzed === true,
2071
+ skipped: result?.skipped === true,
2072
+ reason: result?.reason || null,
2073
+ summary: result?.summary || "",
2074
+ provider: result?.provider || null,
2075
+ model: result?.model || null,
2076
+ frameHash: result?.frameHash || null,
2077
+ };
2078
+ } catch (err) {
2079
+ if (failOnError) throw err;
2080
+ return {
2081
+ success: false,
2082
+ error: String(err?.message || err),
2083
+ };
2084
+ }
2085
+ },
2086
+ });
2087
+
2088
+ registerNodeType("meeting.finalize", {
2089
+ describe: () => "Finalize a meeting session with status and optional note",
2090
+ schema: {
2091
+ type: "object",
2092
+ properties: {
2093
+ sessionId: { type: "string", description: "Meeting session ID (defaults to context session)" },
2094
+ status: {
2095
+ type: "string",
2096
+ enum: ["active", "paused", "completed", "archived", "failed", "cancelled"],
2097
+ default: "completed",
2098
+ },
2099
+ note: { type: "string", description: "Optional note recorded in session history" },
2100
+ failOnError: { type: "boolean", default: true, description: "Throw when finalization fails" },
2101
+ },
2102
+ },
2103
+ async execute(node, ctx, engine) {
2104
+ const meeting = engine.services?.meeting;
2105
+ if (!meeting || typeof meeting.stopMeeting !== "function") {
2106
+ throw new Error("Meeting service is not available");
2107
+ }
2108
+
2109
+ const failOnError = parseBooleanSetting(resolveWorkflowNodeValue(node.config?.failOnError ?? true, ctx), true);
2110
+ try {
2111
+ const sessionId = String(
2112
+ ctx.resolve(node.config?.sessionId || ctx.data?.meetingSessionId || ctx.data?.sessionId || ""),
2113
+ ).trim();
2114
+ if (!sessionId) {
2115
+ throw new Error("meeting.finalize requires sessionId (configure node.sessionId or run meeting.start first)");
2116
+ }
2117
+
2118
+ const status = String(
2119
+ ctx.resolve(node.config?.status || "completed") || "completed",
2120
+ ).trim().toLowerCase() || "completed";
2121
+ const note = String(ctx.resolve(node.config?.note || "") || "").trim() || undefined;
2122
+
2123
+ const result = await meeting.stopMeeting(sessionId, { status, note });
2124
+ return {
2125
+ success: result?.ok !== false,
2126
+ sessionId: String(result?.sessionId || sessionId).trim(),
2127
+ status: result?.status || status,
2128
+ session: result?.session || null,
2129
+ };
2130
+ } catch (err) {
2131
+ if (failOnError) throw err;
2132
+ return {
2133
+ success: false,
2134
+ error: String(err?.message || err),
2135
+ };
2136
+ }
2137
+ },
2138
+ });
2139
+
1240
2140
  registerNodeType("action.create_task", {
1241
2141
  describe: () => "Create a new task in the kanban board",
1242
2142
  schema: {
@@ -1259,14 +2159,14 @@ registerNodeType("action.create_task", {
1259
2159
  ctx.log(node.id, `Creating task: ${title}`);
1260
2160
 
1261
2161
  if (kanban?.createTask) {
1262
- const task = await kanban.createTask({
2162
+ const task = await createKanbanTaskWithProject(kanban, {
1263
2163
  title,
1264
2164
  description,
1265
2165
  status: node.config?.status || "todo",
1266
2166
  priority: node.config?.priority,
1267
2167
  tags: node.config?.tags,
1268
2168
  projectId: node.config?.projectId,
1269
- });
2169
+ }, node.config?.projectId);
1270
2170
  return { success: true, taskId: task.id, title };
1271
2171
  }
1272
2172
  return { success: false, error: "Kanban adapter not available" };
@@ -2311,7 +3211,7 @@ registerNodeType("action.materialize_planner_tasks", {
2311
3211
  status,
2312
3212
  };
2313
3213
  if (projectId) payload.projectId = projectId;
2314
- const createdTask = await kanban.createTask(payload);
3214
+ const createdTask = await createKanbanTaskWithProject(kanban, payload, projectId);
2315
3215
  created.push({
2316
3216
  id: createdTask?.id || null,
2317
3217
  title: task.title,
@@ -2841,7 +3741,7 @@ registerNodeType("action.ask_user", {
2841
3741
  // Send via Telegram if configured
2842
3742
  if ((channel === "telegram" || channel === "both") && engine.services?.telegram?.sendMessage) {
2843
3743
  const optionsText = options.length ? `\n\nOptions: ${options.join(" | ")}` : "";
2844
- await engine.services.telegram.sendMessage(undefined, `❓ **Workflow Question**\n\n${question}${optionsText}`);
3744
+ await engine.services.telegram.sendMessage(undefined, `:help: **Workflow Question**\n\n${question}${optionsText}`);
2845
3745
  }
2846
3746
 
2847
3747
  // Store question for UI polling