@swarmclawai/swarmclaw 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +12 -15
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -13,7 +13,9 @@ import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
14
  import { cascadeUnblock } from './dag-validation'
15
15
  import { performGuardianRollback } from './guardian'
16
+ import { shouldAutoDeleteScheduleAfterTerminalRun } from '@/lib/schedule-origin'
16
17
  import type { Agent, BoardTask, Connector, Message, Session } from '@/types'
18
+ import { buildAgentDisabledMessage, isAgentDisabled } from './agent-availability'
17
19
 
18
20
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
19
21
  const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
@@ -23,9 +25,11 @@ interface SessionMessageLike {
23
25
  text?: string
24
26
  time?: number
25
27
  kind?: string
28
+ historyExcluded?: boolean
26
29
  source?: {
27
30
  connectorId?: string
28
31
  channelId?: string
32
+ threadId?: string
29
33
  }
30
34
  toolEvents?: Array<{ name?: string; output?: string }>
31
35
  streaming?: boolean
@@ -37,7 +41,17 @@ interface SessionLike {
37
41
  user?: string
38
42
  cwd?: string
39
43
  messages?: SessionMessageLike[]
44
+ connectorContext?: {
45
+ connectorId?: string | null
46
+ channelId?: string | null
47
+ threadId?: string | null
48
+ senderId?: string | null
49
+ senderName?: string | null
50
+ }
40
51
  lastActiveAt?: number
52
+ heartbeatEnabled?: boolean | null
53
+ active?: boolean
54
+ currentRunId?: string | null
41
55
  }
42
56
 
43
57
  interface ScheduleTaskMeta extends BoardTask {
@@ -58,8 +72,11 @@ interface RunningConnectorLike {
58
72
  interface ConnectorTaskFollowupTarget {
59
73
  connectorId: string
60
74
  channelId: string
75
+ threadId?: string | null
61
76
  }
62
77
 
78
+ const DISABLED_AGENT_RETRY_MS = 60_000
79
+
63
80
  function sameReasons(a?: string[] | null, b?: string[] | null): boolean {
64
81
  const av = Array.isArray(a) ? a : []
65
82
  const bv = Array.isArray(b) ? b : []
@@ -666,12 +683,10 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
666
683
  const delegatedByAgentId = typeof metaTask.delegatedByAgentId === 'string'
667
684
  ? metaTask.delegatedByAgentId.trim()
668
685
  : ''
686
+ const allowedOwners = new Set([task.agentId, delegatedByAgentId].filter(Boolean))
669
687
  const sourceSessionId = typeof metaTask.createdInSessionId === 'string'
670
688
  ? metaTask.createdInSessionId.trim()
671
689
  : ''
672
- if (!sourceSessionId) return null
673
- const sourceSession = sessions[sourceSessionId]
674
- if (!sourceSession || !Array.isArray(sourceSession.messages)) return null
675
690
 
676
691
  const runningById = new Map<string, RunningConnectorLike>()
677
692
  for (const entry of running) {
@@ -679,9 +694,64 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
679
694
  runningById.set(entry.id, entry)
680
695
  }
681
696
 
697
+ const normalizeTarget = (raw: {
698
+ connectorId?: string | null
699
+ channelId?: string | null
700
+ threadId?: string | null
701
+ }): ConnectorTaskFollowupTarget | null => {
702
+ const connectorId = typeof raw.connectorId === 'string' ? raw.connectorId.trim() : ''
703
+ if (!connectorId) return null
704
+ const connector = connectors[connectorId]
705
+ if (!connector) return null
706
+ const ownerId = typeof connector.agentId === 'string' ? connector.agentId.trim() : ''
707
+ if (ownerId && !allowedOwners.has(ownerId)) return null
708
+
709
+ const runtime = runningById.get(connectorId)
710
+ if (runtime && !runtime.supportsSend) return null
711
+
712
+ const channelId = typeof raw.channelId === 'string' ? raw.channelId.trim() : ''
713
+ if (!channelId) return null
714
+ const normalizedChannelId = connector.platform === 'whatsapp'
715
+ ? normalizeWhatsappTarget(channelId)
716
+ : channelId
717
+ const threadId = typeof raw.threadId === 'string' ? raw.threadId.trim() : ''
718
+ return {
719
+ connectorId,
720
+ channelId: normalizedChannelId,
721
+ ...(threadId ? { threadId } : {}),
722
+ }
723
+ }
724
+
725
+ const explicitTarget = normalizeTarget({
726
+ connectorId: typeof metaTask.followupConnectorId === 'string' ? metaTask.followupConnectorId : null,
727
+ channelId: typeof metaTask.followupChannelId === 'string' ? metaTask.followupChannelId : null,
728
+ threadId: typeof metaTask.followupThreadId === 'string' ? metaTask.followupThreadId : null,
729
+ })
730
+ if (explicitTarget) return explicitTarget
731
+
732
+ if (!sourceSessionId) return null
733
+ const sourceSession = sessions[sourceSessionId]
734
+ if (!sourceSession) return null
735
+
736
+ const sessionContextTarget = normalizeTarget({
737
+ connectorId: typeof sourceSession.connectorContext?.connectorId === 'string'
738
+ ? sourceSession.connectorContext.connectorId
739
+ : null,
740
+ channelId: typeof sourceSession.connectorContext?.channelId === 'string'
741
+ ? sourceSession.connectorContext.channelId
742
+ : null,
743
+ threadId: typeof sourceSession.connectorContext?.threadId === 'string'
744
+ ? sourceSession.connectorContext.threadId
745
+ : null,
746
+ })
747
+ if (sessionContextTarget) return sessionContextTarget
748
+
749
+ if (!Array.isArray(sourceSession.messages)) return null
750
+
682
751
  for (let i = sourceSession.messages.length - 1; i >= 0; i--) {
683
752
  const message = sourceSession.messages[i]
684
753
  if (!message || message.role !== 'user') continue
754
+ if (message.historyExcluded === true) continue
685
755
 
686
756
  const connectorId = typeof message.source?.connectorId === 'string'
687
757
  ? message.source.connectorId.trim()
@@ -690,15 +760,7 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
690
760
 
691
761
  const connector = connectors[connectorId]
692
762
  if (!connector) continue
693
- const ownerId = typeof connector.agentId === 'string' ? connector.agentId.trim() : ''
694
- if (ownerId) {
695
- const allowedOwners = new Set([task.agentId, delegatedByAgentId].filter(Boolean))
696
- if (!allowedOwners.has(ownerId)) continue
697
- }
698
-
699
763
  const runtime = runningById.get(connectorId)
700
- if (runtime && !runtime.supportsSend) continue
701
-
702
764
  const sourceChannel = typeof message.source?.channelId === 'string'
703
765
  ? message.source.channelId.trim()
704
766
  : ''
@@ -707,20 +769,59 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
707
769
  || connector.config?.outboundJid
708
770
  || connector.config?.outboundTarget
709
771
  || ''
710
- const rawChannel = sourceChannel || fallbackChannel
711
- if (!rawChannel) continue
712
-
713
- return {
772
+ const target = normalizeTarget({
714
773
  connectorId,
715
- channelId: connector.platform === 'whatsapp'
716
- ? normalizeWhatsappTarget(rawChannel)
717
- : rawChannel,
718
- }
774
+ channelId: sourceChannel || fallbackChannel,
775
+ threadId: typeof message.source?.threadId === 'string' ? message.source.threadId : null,
776
+ })
777
+ if (target) return target
719
778
  }
720
779
 
721
780
  return null
722
781
  }
723
782
 
783
+ export function collectTaskConnectorFollowupTargets(params: {
784
+ task: BoardTask
785
+ sessions: Record<string, SessionLike>
786
+ connectors: Record<string, Connector>
787
+ running: RunningConnectorLike[]
788
+ }): ConnectorTaskFollowupTarget[] {
789
+ const { task, sessions, connectors, running } = params
790
+ const originTarget = resolveTaskOriginConnectorFollowupTarget({ task, sessions, connectors, running })
791
+ if (originTarget) return [originTarget]
792
+
793
+ const targets: ConnectorTaskFollowupTarget[] = []
794
+ const seen = new Set<string>()
795
+ const pushTarget = (target: ConnectorTaskFollowupTarget | null | undefined) => {
796
+ if (!target?.connectorId || !target?.channelId) return
797
+ const key = `${target.connectorId}|${target.channelId}|${target.threadId || ''}`
798
+ if (seen.has(key)) return
799
+ seen.add(key)
800
+ targets.push(target)
801
+ }
802
+
803
+ for (const entry of running) {
804
+ if (!entry.supportsSend || !entry.id) continue
805
+ const connector = connectors[entry.id]
806
+ if (!connector) continue
807
+ if (connector.agentId !== task.agentId) continue
808
+ if (!isEnabledFlag(connector.config?.taskFollowups)) continue
809
+ const channelTargetRaw = entry.configuredTargets[0]
810
+ || connector.config?.outboundJid
811
+ || connector.config?.outboundTarget
812
+ || ''
813
+ if (!channelTargetRaw) continue
814
+ pushTarget({
815
+ connectorId: entry.id,
816
+ channelId: connector.platform === 'whatsapp'
817
+ ? normalizeWhatsappTarget(channelTargetRaw)
818
+ : channelTargetRaw,
819
+ })
820
+ }
821
+
822
+ return targets
823
+ }
824
+
724
825
  // Task result extraction now uses Zod-validated structured data
725
826
  // from ./task-result.ts (extractTaskResult, formatResultBody)
726
827
 
@@ -779,6 +880,132 @@ async function executeTaskRun(
779
880
  return text
780
881
  }
781
882
 
883
+ function hasFinishedExecutionSession(session: SessionLike | Session | null | undefined): boolean {
884
+ if (!session) return false
885
+ return session.active === false && !session.currentRunId
886
+ }
887
+
888
+ export function reconcileFinishedRunningTasks(): { reconciled: number; deadLettered: number } {
889
+ const tasks = loadTasks()
890
+ const sessions = loadSessions() as Record<string, SessionLike>
891
+ const settings = loadSettings()
892
+ const queue = loadQueue()
893
+ const now = Date.now()
894
+ let reconciled = 0
895
+ let deadLettered = 0
896
+ let tasksDirty = false
897
+ let sessionsDirty = false
898
+ let queueDirty = false
899
+ const terminalTasks: BoardTask[] = []
900
+
901
+ for (const task of Object.values(tasks) as BoardTask[]) {
902
+ if (task.status !== 'running') continue
903
+ const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
904
+ if (!sessionId) continue
905
+ const session = sessions[sessionId]
906
+ if (!hasFinishedExecutionSession(session)) continue
907
+
908
+ const fallbackText = latestAssistantText(session)
909
+ if (!fallbackText && !task.result) continue
910
+
911
+ applyTaskPolicyDefaults(task)
912
+ const taskResult = extractTaskResult(
913
+ session,
914
+ task.result || fallbackText || null,
915
+ { sinceTime: typeof task.startedAt === 'number' ? task.startedAt : null },
916
+ )
917
+ const enrichedResult = formatResultBody(taskResult)
918
+ task.result = enrichedResult.slice(0, 4000) || null
919
+ task.artifacts = taskResult.artifacts.slice(0, 24)
920
+ task.outputFiles = extractLikelyOutputFiles(enrichedResult).slice(0, 24)
921
+ task.updatedAt = now
922
+ const report = ensureTaskCompletionReport(task)
923
+ if (report?.relativePath) task.completionReportPath = report.relativePath
924
+ const validation = validateTaskCompletion(task, { report, settings })
925
+ task.validation = validation
926
+ if (!task.comments) task.comments = []
927
+
928
+ if (validation.ok) {
929
+ task.status = 'completed'
930
+ task.completedAt = now
931
+ task.retryScheduledAt = null
932
+ task.deadLetteredAt = null
933
+ task.error = null
934
+ task.checkpoint = {
935
+ ...(task.checkpoint || {}),
936
+ lastRunId: sessionId,
937
+ lastSessionId: sessionId,
938
+ note: 'Recovered completed task state from finished session.',
939
+ updatedAt: now,
940
+ }
941
+ task.comments.push({
942
+ id: genId(),
943
+ author: 'System',
944
+ text: 'Recovered completed task state from a finished execution session.',
945
+ createdAt: now,
946
+ })
947
+ reconciled++
948
+ terminalTasks.push(task)
949
+ } else {
950
+ const failureReason = formatValidationFailure(validation.reasons).slice(0, 500)
951
+ const retryState = scheduleRetryOrDeadLetter(task, failureReason)
952
+ task.completedAt = retryState === 'dead_lettered' ? null : task.completedAt
953
+ task.comments.push({
954
+ id: genId(),
955
+ author: 'System',
956
+ text: `Recovered finished session but the task result failed validation.\n\n${validation.reasons.map((reason) => `- ${reason}`).join('\n')}`,
957
+ createdAt: now,
958
+ })
959
+ if (retryState === 'retry') {
960
+ pushQueueUnique(queue, task.id)
961
+ queueDirty = true
962
+ reconciled++
963
+ pushMainLoopEventToMainSessions({
964
+ type: 'task_retry_scheduled',
965
+ text: `Task retry scheduled: "${task.title}" (${task.id}) attempt ${task.attempts}/${task.maxAttempts} in ${task.retryBackoffSec}s.`,
966
+ })
967
+ } else {
968
+ deadLettered++
969
+ terminalTasks.push(task)
970
+ }
971
+ }
972
+
973
+ if (session.heartbeatEnabled !== false) {
974
+ session.heartbeatEnabled = false
975
+ session.lastActiveAt = now
976
+ sessionsDirty = true
977
+ }
978
+ tasksDirty = true
979
+ }
980
+
981
+ if (tasksDirty) {
982
+ saveTasks(tasks)
983
+ notify('tasks')
984
+ notify('runs')
985
+ }
986
+ if (sessionsDirty) saveSessions(sessions as Record<string, Session>)
987
+ if (queueDirty) saveQueue(queue)
988
+
989
+ for (const task of terminalTasks) {
990
+ if (task.status === 'completed') {
991
+ pushMainLoopEventToMainSessions({
992
+ type: 'task_completed',
993
+ text: `Task completed: "${task.title}" (${task.id})`,
994
+ })
995
+ } else if (task.status === 'failed') {
996
+ pushMainLoopEventToMainSessions({
997
+ type: 'task_failed',
998
+ text: `Task failed validation: "${task.title}" (${task.id})`,
999
+ })
1000
+ }
1001
+ notifyMainChatScheduleResult(task)
1002
+ notifyAgentThreadTaskResult(task)
1003
+ cleanupTerminalOneOffSchedule(task)
1004
+ }
1005
+
1006
+ return { reconciled, deadLettered }
1007
+ }
1008
+
782
1009
  function notifyMainChatScheduleResult(task: BoardTask): void {
783
1010
  const scheduleTask = task as ScheduleTaskMeta
784
1011
  const sourceType = typeof scheduleTask.sourceType === 'string' ? scheduleTask.sourceType : ''
@@ -844,6 +1071,22 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
844
1071
  if (changed) saveSessions(sessions)
845
1072
  }
846
1073
 
1074
+ function cleanupTerminalOneOffSchedule(task: BoardTask): void {
1075
+ const scheduleTask = task as ScheduleTaskMeta
1076
+ const sourceType = typeof scheduleTask.sourceType === 'string' ? scheduleTask.sourceType : ''
1077
+ if (sourceType !== 'schedule') return
1078
+ const scheduleId = typeof scheduleTask.sourceScheduleId === 'string' ? scheduleTask.sourceScheduleId : ''
1079
+ if (!scheduleId) return
1080
+
1081
+ const schedules = loadSchedules()
1082
+ const schedule = schedules[scheduleId]
1083
+ if (!shouldAutoDeleteScheduleAfterTerminalRun(schedule)) return
1084
+
1085
+ delete schedules[scheduleId]
1086
+ saveSchedules(schedules)
1087
+ notify('schedules')
1088
+ }
1089
+
847
1090
  async function notifyConnectorTaskFollowups(params: {
848
1091
  task: BoardTask
849
1092
  statusLabel: string
@@ -858,54 +1101,23 @@ async function notifyConnectorTaskFollowups(params: {
858
1101
  const running = (await import('./connectors/manager')).listRunningConnectors()
859
1102
  const manager = await import('./connectors/manager')
860
1103
  const sessions = loadSessions()
861
-
862
- const candidateByKey = new Map<string, ConnectorTaskFollowupTarget>()
863
- const addCandidate = (candidate: ConnectorTaskFollowupTarget | null | undefined) => {
864
- if (!candidate?.connectorId || !candidate?.channelId) return
865
- const key = `${candidate.connectorId}|${candidate.channelId}`
866
- if (!candidateByKey.has(key)) candidateByKey.set(key, candidate)
867
- }
868
-
1104
+ const targets = collectTaskConnectorFollowupTargets({
1105
+ task,
1106
+ sessions: sessions as Record<string, SessionLike>,
1107
+ connectors,
1108
+ running: running as RunningConnectorLike[],
1109
+ })
1110
+ if (!targets.length) return
869
1111
  const originTarget = resolveTaskOriginConnectorFollowupTarget({
870
1112
  task,
871
1113
  sessions: sessions as Record<string, SessionLike>,
872
1114
  connectors,
873
1115
  running: running as RunningConnectorLike[],
874
1116
  })
875
- addCandidate(originTarget)
876
1117
  const preferredTargetKey = originTarget
877
- ? `${originTarget.connectorId}|${originTarget.channelId}`
1118
+ ? `${originTarget.connectorId}|${originTarget.channelId}|${originTarget.threadId || ''}`
878
1119
  : ''
879
1120
 
880
- for (const entry of running) {
881
- if (!entry.supportsSend || !entry.id) continue
882
- const connector = connectors[entry.id]
883
- if (!connector) continue
884
- if (connector.agentId !== task.agentId) continue
885
- if (!isEnabledFlag(connector.config?.taskFollowups)) continue
886
- const channelTargetRaw = entry.recentChannelId
887
- || entry.configuredTargets[0]
888
- || connector.config?.outboundJid
889
- || connector.config?.outboundTarget
890
- || ''
891
- if (!channelTargetRaw) continue
892
- addCandidate({
893
- connectorId: entry.id,
894
- channelId: connector.platform === 'whatsapp'
895
- ? normalizeWhatsappTarget(channelTargetRaw)
896
- : channelTargetRaw,
897
- })
898
- }
899
- const targets = [...candidateByKey.values()].sort((a, b) => {
900
- if (!preferredTargetKey) return 0
901
- const aKey = `${a.connectorId}|${a.channelId}`
902
- const bKey = `${b.connectorId}|${b.channelId}`
903
- if (aKey === preferredTargetKey && bKey !== preferredTargetKey) return -1
904
- if (bKey === preferredTargetKey && aKey !== preferredTargetKey) return 1
905
- return 0
906
- })
907
- if (!targets.length) return
908
-
909
1121
  const summary = summaryText.trim().slice(0, 1400)
910
1122
  for (const target of targets) {
911
1123
  const connector = connectors[target.connectorId]
@@ -925,7 +1137,7 @@ async function notifyConnectorTaskFollowups(params: {
925
1137
  `Task ${statusLabel}: ${task.title}`,
926
1138
  summary || 'No summary provided.',
927
1139
  ].join('\n\n')
928
- const targetKey = `${target.connectorId}|${target.channelId}`
1140
+ const targetKey = `${target.connectorId}|${target.channelId}|${target.threadId || ''}`
929
1141
  const preferredChannelNote = !template && preferredTargetKey && targetKey === preferredTargetKey
930
1142
  ? '\n\n(Update sent in the same channel that requested this task.)'
931
1143
  : ''
@@ -936,6 +1148,7 @@ async function notifyConnectorTaskFollowups(params: {
936
1148
  await manager.sendConnectorMessage({
937
1149
  connectorId: target.connectorId,
938
1150
  channelId: target.channelId,
1151
+ threadId: target.threadId || undefined,
939
1152
  text: outboundMessage,
940
1153
  ...(resolvedMediaPath
941
1154
  ? {
@@ -1351,6 +1564,21 @@ export async function processNext() {
1351
1564
  })
1352
1565
  continue
1353
1566
  }
1567
+ if (isAgentDisabled(agent)) {
1568
+ const now = Date.now()
1569
+ task.retryScheduledAt = now + DISABLED_AGENT_RETRY_MS
1570
+ task.updatedAt = now
1571
+ task.error = buildAgentDisabledMessage(agent, 'process queued tasks')
1572
+ saveTasks(tasks)
1573
+ notify('tasks')
1574
+ pushQueueUnique(queue, taskId)
1575
+ saveQueue(queue)
1576
+ pushMainLoopEventToMainSessions({
1577
+ type: 'task_deferred',
1578
+ text: `Task deferred: "${task.title}" (${task.id}) — agent ${task.agentId} is disabled.`,
1579
+ })
1580
+ continue
1581
+ }
1354
1582
 
1355
1583
  // Mark as running
1356
1584
  applyTaskPolicyDefaults(task)
@@ -1606,6 +1834,7 @@ export async function processNext() {
1606
1834
  })
1607
1835
  notifyMainChatScheduleResult(doneTask)
1608
1836
  notifyAgentThreadTaskResult(doneTask)
1837
+ cleanupTerminalOneOffSchedule(doneTask)
1609
1838
  // Clean up LangGraph checkpoints for completed tasks
1610
1839
  getCheckpointSaver().deleteThread(taskId).catch((e) =>
1611
1840
  console.warn(`[queue] Failed to clean up checkpoints for task ${taskId}:`, e)
@@ -1633,6 +1862,7 @@ export async function processNext() {
1633
1862
  if (doneTask?.status === 'failed') {
1634
1863
  notifyMainChatScheduleResult(doneTask)
1635
1864
  notifyAgentThreadTaskResult(doneTask)
1865
+ cleanupTerminalOneOffSchedule(doneTask)
1636
1866
  }
1637
1867
  console.warn(`[queue] Task "${task.title}" failed completion validation`)
1638
1868
  }
@@ -1682,6 +1912,7 @@ export async function processNext() {
1682
1912
  if (latest?.status === 'failed') {
1683
1913
  notifyMainChatScheduleResult(latest)
1684
1914
  notifyAgentThreadTaskResult(latest)
1915
+ cleanupTerminalOneOffSchedule(latest)
1685
1916
  }
1686
1917
  }
1687
1918
  }
@@ -1719,14 +1950,15 @@ export function cleanupFinishedTaskSessions() {
1719
1950
 
1720
1951
  /** Recover running tasks that appear stalled and requeue/dead-letter them per retry policy. */
1721
1952
  export function recoverStalledRunningTasks(): { recovered: number; deadLettered: number } {
1953
+ const finished = reconcileFinishedRunningTasks()
1722
1954
  const settings = loadSettings()
1723
1955
  const stallTimeoutMin = normalizeInt(settings.taskStallTimeoutMin, 45, 5, 24 * 60)
1724
1956
  const staleMs = stallTimeoutMin * 60_000
1725
1957
  const now = Date.now()
1726
1958
  const tasks = loadTasks()
1727
1959
  const queue = loadQueue()
1728
- let recovered = 0
1729
- let deadLettered = 0
1960
+ let recovered = finished.reconciled
1961
+ let deadLettered = finished.deadLettered
1730
1962
  let changed = false
1731
1963
 
1732
1964
  for (const task of Object.values(tasks) as BoardTask[]) {
@@ -7,6 +7,7 @@ import { getScheduleSignatureKey } from '@/lib/schedule-dedupe'
7
7
  import { enqueueSystemEvent } from './system-events'
8
8
  import { requestHeartbeatNow } from './heartbeat-wake'
9
9
  import { processDueWatchJobs } from './watch-jobs'
10
+ import { isAgentDisabled } from './agent-availability'
10
11
 
11
12
  const TICK_INTERVAL = 60_000 // 60 seconds
12
13
  let intervalId: ReturnType<typeof setInterval> | null = null
@@ -32,6 +33,11 @@ interface SchedulerScheduleLike {
32
33
  runNumber?: number
33
34
  createdInSessionId?: string | null
34
35
  createdByAgentId?: string | null
36
+ followupConnectorId?: string | null
37
+ followupChannelId?: string | null
38
+ followupThreadId?: string | null
39
+ followupSenderId?: string | null
40
+ followupSenderName?: string | null
35
41
  }
36
42
 
37
43
  export function startScheduler() {
@@ -123,6 +129,16 @@ async function tick() {
123
129
  })
124
130
  continue
125
131
  }
132
+ if (isAgentDisabled(agent)) {
133
+ console.warn(`[scheduler] Skipping schedule "${schedule.name}" (${schedule.id}) because agent ${schedule.agentId} is disabled`)
134
+ advanceSchedule(schedule)
135
+ saveSchedules(schedules)
136
+ pushMainLoopEventToMainSessions({
137
+ type: 'schedule_skipped',
138
+ text: `Schedule skipped: "${schedule.name}" (${schedule.id}) — agent ${schedule.agentId} is disabled.`,
139
+ })
140
+ continue
141
+ }
126
142
 
127
143
  console.log(`[scheduler] Firing schedule "${schedule.name}" (${schedule.id})`)
128
144
  schedule.lastRunAt = now
@@ -185,6 +201,11 @@ async function tick() {
185
201
  sourceScheduleKey: scheduleSignature || null,
186
202
  createdInSessionId: schedule.createdInSessionId || null,
187
203
  createdByAgentId: schedule.createdByAgentId || null,
204
+ followupConnectorId: schedule.followupConnectorId || null,
205
+ followupChannelId: schedule.followupChannelId || null,
206
+ followupThreadId: schedule.followupThreadId || null,
207
+ followupSenderId: schedule.followupSenderId || null,
208
+ followupSenderName: schedule.followupSenderName || null,
188
209
  runNumber: schedule.runNumber,
189
210
  }
190
211
  schedule.linkedTaskId = taskId
@@ -204,6 +225,13 @@ async function tick() {
204
225
  if (schedule.createdInSessionId) {
205
226
  enqueueSystemEvent(schedule.createdInSessionId, `Schedule triggered: ${schedule.name}`)
206
227
  }
207
- requestHeartbeatNow({ agentId: schedule.agentId, reason: 'schedule' })
228
+ requestHeartbeatNow({
229
+ agentId: schedule.agentId,
230
+ eventId: `${schedule.id}:${schedule.runNumber}`,
231
+ reason: 'schedule',
232
+ source: `schedule:${schedule.id}`,
233
+ resumeMessage: `Schedule triggered: ${schedule.name}`,
234
+ detail: `Run #${schedule.runNumber} queued task ${taskId}.`,
235
+ })
208
236
  }
209
237
  }
@@ -0,0 +1,36 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { buildSessionNoteMessage } from './session-note'
5
+
6
+ test('buildSessionNoteMessage defaults to assistant/system note metadata', () => {
7
+ const result = buildSessionNoteMessage({
8
+ text: 'Live test passed',
9
+ })
10
+
11
+ assert.ok(result)
12
+ assert.equal(result?.role, 'assistant')
13
+ assert.equal(result?.kind, 'system')
14
+ assert.equal(result?.text, 'Live test passed')
15
+ assert.equal(typeof result?.time, 'number')
16
+ })
17
+
18
+ test('buildSessionNoteMessage trims text and preserves explicit role/kind', () => {
19
+ const result = buildSessionNoteMessage({
20
+ text: ' Visible smoke report ',
21
+ role: 'user',
22
+ kind: 'chat',
23
+ time: 123,
24
+ })
25
+
26
+ assert.deepEqual(result, {
27
+ role: 'user',
28
+ kind: 'chat',
29
+ text: 'Visible smoke report',
30
+ time: 123,
31
+ })
32
+ })
33
+
34
+ test('buildSessionNoteMessage returns null for empty text', () => {
35
+ assert.equal(buildSessionNoteMessage({ text: ' ' }), null)
36
+ })
@@ -0,0 +1,42 @@
1
+ import type { Message, MessageToolEvent } from '@/types'
2
+ import { loadSessions, saveSessions } from './storage'
3
+ import { notify } from './ws-hub'
4
+
5
+ export interface SessionNoteInput {
6
+ sessionId: string
7
+ text: string
8
+ role?: Message['role']
9
+ kind?: Message['kind']
10
+ toolEvents?: MessageToolEvent[]
11
+ time?: number
12
+ }
13
+
14
+ export function buildSessionNoteMessage(input: Omit<SessionNoteInput, 'sessionId'>): Message | null {
15
+ const trimmed = String(input.text || '').trim()
16
+ if (!trimmed) return null
17
+ return {
18
+ role: input.role || 'assistant',
19
+ kind: input.kind || 'system',
20
+ text: trimmed,
21
+ time: typeof input.time === 'number' && Number.isFinite(input.time) ? input.time : Date.now(),
22
+ ...(Array.isArray(input.toolEvents) && input.toolEvents.length ? { toolEvents: input.toolEvents } : {}),
23
+ }
24
+ }
25
+
26
+ export function appendSessionNote(input: SessionNoteInput): Message | null {
27
+ const sessions = loadSessions()
28
+ const session = sessions[input.sessionId]
29
+ if (!session) return null
30
+ if (!Array.isArray(session.messages)) session.messages = []
31
+
32
+ const next = buildSessionNoteMessage(input)
33
+ if (!next) return null
34
+
35
+ session.messages.push(next)
36
+ session.lastActiveAt = next.time
37
+ sessions[input.sessionId] = session
38
+ saveSessions(sessions)
39
+ notify('sessions')
40
+ notify(`messages:${input.sessionId}`)
41
+ return next
42
+ }