@swarmclawai/swarmclaw 0.6.4 → 0.6.7

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 (143) hide show
  1. package/README.md +62 -30
  2. package/package.json +10 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +34 -2
  8. package/src/app/api/chatrooms/route.ts +26 -3
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  14. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  15. package/src/app/api/sessions/route.ts +11 -2
  16. package/src/app/api/tasks/[id]/route.ts +18 -13
  17. package/src/app/api/tasks/route.ts +44 -1
  18. package/src/app/api/usage/route.ts +16 -7
  19. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  20. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  21. package/src/app/api/wallets/[id]/route.ts +118 -0
  22. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  23. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  24. package/src/app/api/wallets/route.ts +74 -0
  25. package/src/app/globals.css +8 -0
  26. package/src/cli/index.js +20 -0
  27. package/src/cli/index.ts +223 -39
  28. package/src/cli/spec.js +14 -0
  29. package/src/components/agents/agent-avatar.tsx +15 -1
  30. package/src/components/agents/agent-card.tsx +38 -6
  31. package/src/components/agents/agent-chat-list.tsx +79 -3
  32. package/src/components/agents/agent-sheet.tsx +191 -26
  33. package/src/components/auth/setup-wizard.tsx +268 -353
  34. package/src/components/chat/chat-area.tsx +24 -9
  35. package/src/components/chat/chat-header.tsx +48 -19
  36. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  37. package/src/components/chat/delegation-banner.test.ts +27 -0
  38. package/src/components/chat/delegation-banner.tsx +109 -23
  39. package/src/components/chat/message-bubble.tsx +17 -16
  40. package/src/components/chat/message-list.tsx +6 -5
  41. package/src/components/chat/streaming-bubble.tsx +3 -2
  42. package/src/components/chat/thinking-indicator.tsx +3 -2
  43. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  44. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  45. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  46. package/src/components/chatrooms/chatroom-message.tsx +165 -23
  47. package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
  48. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  49. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  50. package/src/components/connectors/connector-health.tsx +120 -0
  51. package/src/components/connectors/connector-list.tsx +1 -1
  52. package/src/components/connectors/connector-sheet.tsx +9 -0
  53. package/src/components/home/home-view.tsx +25 -3
  54. package/src/components/input/chat-input.tsx +8 -1
  55. package/src/components/knowledge/knowledge-list.tsx +1 -1
  56. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  57. package/src/components/layout/app-layout.tsx +35 -4
  58. package/src/components/memory/memory-agent-list.tsx +1 -1
  59. package/src/components/memory/memory-browser.tsx +1 -0
  60. package/src/components/memory/memory-card.tsx +3 -2
  61. package/src/components/memory/memory-detail.tsx +3 -3
  62. package/src/components/memory/memory-sheet.tsx +2 -2
  63. package/src/components/projects/project-detail.tsx +4 -4
  64. package/src/components/schedules/schedule-list.tsx +55 -9
  65. package/src/components/schedules/schedule-sheet.tsx +134 -23
  66. package/src/components/secrets/secret-sheet.tsx +1 -1
  67. package/src/components/secrets/secrets-list.tsx +1 -1
  68. package/src/components/sessions/session-card.tsx +1 -1
  69. package/src/components/shared/agent-picker-list.tsx +1 -1
  70. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  71. package/src/components/shared/command-palette.tsx +237 -0
  72. package/src/components/shared/connector-platform-icon.tsx +1 -0
  73. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  74. package/src/components/skills/skill-list.tsx +1 -1
  75. package/src/components/skills/skill-sheet.tsx +1 -1
  76. package/src/components/tasks/task-board.tsx +3 -3
  77. package/src/components/tasks/task-card.tsx +22 -2
  78. package/src/components/tasks/task-sheet.tsx +112 -17
  79. package/src/components/usage/metrics-dashboard.tsx +13 -25
  80. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  81. package/src/components/wallets/wallet-panel.tsx +616 -0
  82. package/src/components/wallets/wallet-section.tsx +100 -0
  83. package/src/hooks/use-swipe.ts +49 -0
  84. package/src/lib/providers/anthropic.ts +16 -2
  85. package/src/lib/providers/claude-cli.ts +7 -1
  86. package/src/lib/providers/index.ts +7 -0
  87. package/src/lib/providers/ollama.ts +16 -2
  88. package/src/lib/providers/openai.ts +7 -2
  89. package/src/lib/providers/openclaw.ts +6 -1
  90. package/src/lib/providers/provider-defaults.ts +7 -0
  91. package/src/lib/schedule-templates.ts +115 -0
  92. package/src/lib/server/agent-registry.ts +2 -2
  93. package/src/lib/server/alert-dispatch.ts +64 -0
  94. package/src/lib/server/chat-execution.ts +76 -4
  95. package/src/lib/server/chatroom-health.ts +60 -0
  96. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  97. package/src/lib/server/chatroom-helpers.ts +86 -12
  98. package/src/lib/server/chatroom-routing.ts +65 -0
  99. package/src/lib/server/connectors/discord.ts +3 -0
  100. package/src/lib/server/connectors/email.ts +267 -0
  101. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  102. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  103. package/src/lib/server/connectors/manager.ts +239 -5
  104. package/src/lib/server/connectors/openclaw.ts +3 -0
  105. package/src/lib/server/connectors/slack.ts +6 -0
  106. package/src/lib/server/connectors/telegram.ts +18 -0
  107. package/src/lib/server/connectors/types.ts +2 -0
  108. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  109. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  110. package/src/lib/server/connectors/whatsapp.ts +17 -5
  111. package/src/lib/server/cost.ts +70 -0
  112. package/src/lib/server/create-notification.ts +2 -0
  113. package/src/lib/server/daemon-state.ts +124 -0
  114. package/src/lib/server/dag-validation.ts +115 -0
  115. package/src/lib/server/memory-db.ts +12 -7
  116. package/src/lib/server/openclaw-doctor.ts +48 -0
  117. package/src/lib/server/orchestrator-lg.ts +12 -2
  118. package/src/lib/server/orchestrator.ts +6 -1
  119. package/src/lib/server/queue-followups.test.ts +224 -0
  120. package/src/lib/server/queue.ts +238 -24
  121. package/src/lib/server/scheduler.ts +3 -0
  122. package/src/lib/server/session-run-manager.ts +22 -1
  123. package/src/lib/server/session-tools/chatroom.ts +11 -2
  124. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  125. package/src/lib/server/session-tools/index.ts +8 -2
  126. package/src/lib/server/session-tools/memory.ts +23 -4
  127. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  128. package/src/lib/server/session-tools/shell.ts +1 -1
  129. package/src/lib/server/session-tools/wallet.ts +124 -0
  130. package/src/lib/server/session-tools/web.ts +2 -2
  131. package/src/lib/server/solana.ts +122 -0
  132. package/src/lib/server/storage.ts +158 -6
  133. package/src/lib/server/stream-agent-chat.ts +126 -63
  134. package/src/lib/server/task-mention.test.ts +41 -0
  135. package/src/lib/server/task-mention.ts +3 -2
  136. package/src/lib/setup-defaults.ts +277 -0
  137. package/src/lib/tool-definitions.ts +1 -0
  138. package/src/lib/validation/schemas.ts +69 -0
  139. package/src/lib/view-routes.ts +1 -0
  140. package/src/stores/use-app-store.ts +15 -3
  141. package/src/stores/use-chatroom-store.ts +52 -2
  142. package/src/types/index.ts +98 -2
  143. package/tsconfig.json +2 -1
@@ -0,0 +1,224 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { BoardTask } from '@/types'
4
+ import { resolveTaskOriginConnectorFollowupTarget } from './queue'
5
+
6
+ function makeTask(partial?: Partial<BoardTask> & { createdInSessionId?: string | null }): BoardTask {
7
+ const now = Date.now()
8
+ return {
9
+ id: 'task-1',
10
+ title: 'Test task',
11
+ description: 'desc',
12
+ status: 'queued',
13
+ agentId: 'agent-a',
14
+ createdAt: now,
15
+ updatedAt: now,
16
+ ...(partial || {}),
17
+ } as BoardTask
18
+ }
19
+
20
+ type SessionFixtureMap = Record<string, {
21
+ messages: Array<{
22
+ role: string
23
+ text?: string
24
+ source?: {
25
+ connectorId?: string
26
+ channelId?: string
27
+ }
28
+ }>
29
+ }>
30
+
31
+ describe('resolveTaskOriginConnectorFollowupTarget', () => {
32
+ it('uses connector source channel from origin session and normalizes WhatsApp numbers', () => {
33
+ const task = makeTask({ createdInSessionId: 'session-1' })
34
+ const sessions = {
35
+ 'session-1': {
36
+ messages: [
37
+ { role: 'assistant', text: 'ok' },
38
+ {
39
+ role: 'user',
40
+ text: 'please update me',
41
+ source: {
42
+ connectorId: 'conn-wa',
43
+ channelId: '+44 7700 900123',
44
+ },
45
+ },
46
+ ],
47
+ },
48
+ }
49
+ const connectors = {
50
+ 'conn-wa': {
51
+ id: 'conn-wa',
52
+ platform: 'whatsapp',
53
+ agentId: 'agent-a',
54
+ config: {},
55
+ },
56
+ }
57
+ const running = [
58
+ {
59
+ id: 'conn-wa',
60
+ platform: 'whatsapp',
61
+ agentId: 'agent-a',
62
+ supportsSend: true,
63
+ configuredTargets: [],
64
+ recentChannelId: '185200000000000@lid',
65
+ },
66
+ ]
67
+
68
+ const target = resolveTaskOriginConnectorFollowupTarget({
69
+ task,
70
+ sessions: sessions as SessionFixtureMap,
71
+ connectors,
72
+ running,
73
+ })
74
+
75
+ assert.deepEqual(target, {
76
+ connectorId: 'conn-wa',
77
+ channelId: '447700900123@s.whatsapp.net',
78
+ })
79
+ })
80
+
81
+ it('falls back to runtime recent channel when source channel is unavailable', () => {
82
+ const task = makeTask({ createdInSessionId: 'session-1' })
83
+ const sessions = {
84
+ 'session-1': {
85
+ messages: [
86
+ {
87
+ role: 'user',
88
+ text: 'run this later',
89
+ source: {
90
+ connectorId: 'conn-telegram',
91
+ },
92
+ },
93
+ ],
94
+ },
95
+ }
96
+ const connectors = {
97
+ 'conn-telegram': {
98
+ id: 'conn-telegram',
99
+ platform: 'telegram',
100
+ agentId: 'agent-a',
101
+ config: {},
102
+ },
103
+ }
104
+ const running = [
105
+ {
106
+ id: 'conn-telegram',
107
+ platform: 'telegram',
108
+ agentId: 'agent-a',
109
+ supportsSend: true,
110
+ configuredTargets: [],
111
+ recentChannelId: 'tg-chat-42',
112
+ },
113
+ ]
114
+
115
+ const target = resolveTaskOriginConnectorFollowupTarget({
116
+ task,
117
+ sessions: sessions as SessionFixtureMap,
118
+ connectors,
119
+ running,
120
+ })
121
+
122
+ assert.deepEqual(target, {
123
+ connectorId: 'conn-telegram',
124
+ channelId: 'tg-chat-42',
125
+ })
126
+ })
127
+
128
+ it('returns null when the source connector belongs to a different agent', () => {
129
+ const task = makeTask({ createdInSessionId: 'session-1' })
130
+ const sessions = {
131
+ 'session-1': {
132
+ messages: [
133
+ {
134
+ role: 'user',
135
+ text: 'do it',
136
+ source: {
137
+ connectorId: 'conn-wa',
138
+ channelId: '+15551230000',
139
+ },
140
+ },
141
+ ],
142
+ },
143
+ }
144
+ const connectors = {
145
+ 'conn-wa': {
146
+ id: 'conn-wa',
147
+ platform: 'whatsapp',
148
+ agentId: 'different-agent',
149
+ config: {},
150
+ },
151
+ }
152
+ const running = [
153
+ {
154
+ id: 'conn-wa',
155
+ platform: 'whatsapp',
156
+ agentId: 'different-agent',
157
+ supportsSend: true,
158
+ configuredTargets: [],
159
+ recentChannelId: null,
160
+ },
161
+ ]
162
+
163
+ const target = resolveTaskOriginConnectorFollowupTarget({
164
+ task,
165
+ sessions: sessions as SessionFixtureMap,
166
+ connectors,
167
+ running,
168
+ })
169
+
170
+ assert.equal(target, null)
171
+ })
172
+
173
+ it('allows delegated tasks to follow up via the delegating agent connector', () => {
174
+ const task = makeTask({
175
+ agentId: 'worker-agent',
176
+ delegatedByAgentId: 'delegator-agent',
177
+ createdInSessionId: 'session-1',
178
+ })
179
+ const sessions = {
180
+ 'session-1': {
181
+ messages: [
182
+ {
183
+ role: 'user',
184
+ text: 'run and update me here',
185
+ source: {
186
+ connectorId: 'conn-wa',
187
+ channelId: '+44 7700 900123',
188
+ },
189
+ },
190
+ ],
191
+ },
192
+ }
193
+ const connectors = {
194
+ 'conn-wa': {
195
+ id: 'conn-wa',
196
+ platform: 'whatsapp',
197
+ agentId: 'delegator-agent',
198
+ config: {},
199
+ },
200
+ }
201
+ const running = [
202
+ {
203
+ id: 'conn-wa',
204
+ platform: 'whatsapp',
205
+ agentId: 'delegator-agent',
206
+ supportsSend: true,
207
+ configuredTargets: [],
208
+ recentChannelId: null,
209
+ },
210
+ ]
211
+
212
+ const target = resolveTaskOriginConnectorFollowupTarget({
213
+ task,
214
+ sessions: sessions as SessionFixtureMap,
215
+ connectors,
216
+ running,
217
+ })
218
+
219
+ assert.deepEqual(target, {
220
+ connectorId: 'conn-wa',
221
+ channelId: '447700900123@s.whatsapp.net',
222
+ })
223
+ })
224
+ })
@@ -12,7 +12,8 @@ import { executeSessionChatTurn } from './chat-execution'
12
12
  import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
14
  import { isProtectedMainSession } from './main-session'
15
- import type { Agent, BoardTask, Message } from '@/types'
15
+ import { cascadeUnblock } from './dag-validation'
16
+ import type { Agent, BoardTask, Connector, Message } from '@/types'
16
17
 
17
18
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
18
19
  const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
@@ -22,6 +23,10 @@ interface SessionMessageLike {
22
23
  text?: string
23
24
  time?: number
24
25
  kind?: 'chat' | 'heartbeat' | 'system' | 'context-clear'
26
+ source?: {
27
+ connectorId?: string
28
+ channelId?: string
29
+ }
25
30
  toolEvents?: Array<{ name?: string; output?: string }>
26
31
  }
27
32
 
@@ -39,6 +44,20 @@ interface ScheduleTaskMeta extends BoardTask {
39
44
  createdByAgentId?: string | null
40
45
  }
41
46
 
47
+ interface RunningConnectorLike {
48
+ id: string
49
+ platform: string
50
+ agentId: string | null
51
+ supportsSend: boolean
52
+ configuredTargets: string[]
53
+ recentChannelId: string | null
54
+ }
55
+
56
+ interface ConnectorTaskFollowupTarget {
57
+ connectorId: string
58
+ channelId: string
59
+ }
60
+
42
61
  function sameReasons(a?: string[] | null, b?: string[] | null): boolean {
43
62
  const av = Array.isArray(a) ? a : []
44
63
  const bv = Array.isArray(b) ? b : []
@@ -164,6 +183,135 @@ function maybeResolveUploadMediaPathFromUrl(url: string | undefined): string | u
164
183
  return fs.existsSync(fullPath) ? fullPath : undefined
165
184
  }
166
185
 
186
+ const OUTPUT_FILE_BACKTICK_RE = /`([^`\n]+\.(?:txt|md|json|csv|pdf|png|jpe?g|webp|gif|svg|mp4|webm|mov|zip|tar|gz|log|yml|yaml|xml|html|css|js|ts|tsx|jsx|py|go|rs|java|swift|kt|sql))`/gi
187
+ const OUTPUT_FILE_PATH_RE = /\b((?:\.{1,2}\/|~\/|\/)?[\w./-]+\.(?:txt|md|json|csv|pdf|png|jpe?g|webp|gif|svg|mp4|webm|mov|zip|tar|gz|log|yml|yaml|xml|html|css|js|ts|tsx|jsx|py|go|rs|java|swift|kt|sql))\b/gi
188
+ const MAX_CONNECTOR_ATTACHMENT_BYTES = 25 * 1024 * 1024
189
+
190
+ function extractLikelyOutputFiles(text: string): string[] {
191
+ const out: string[] = []
192
+ const seen = new Set<string>()
193
+ const push = (raw: string) => {
194
+ const value = raw.trim().replace(/^['"]|['"]$/g, '')
195
+ if (!value || /^https?:\/\//i.test(value)) return
196
+ if (value.startsWith('/api/uploads/')) return
197
+ const key = value.toLowerCase()
198
+ if (seen.has(key)) return
199
+ seen.add(key)
200
+ out.push(value)
201
+ }
202
+
203
+ for (const match of text.matchAll(OUTPUT_FILE_BACKTICK_RE)) {
204
+ push(match[1] || '')
205
+ if (out.length >= 8) return out
206
+ }
207
+ for (const match of text.matchAll(OUTPUT_FILE_PATH_RE)) {
208
+ push(match[1] || '')
209
+ if (out.length >= 8) return out
210
+ }
211
+
212
+ return out
213
+ }
214
+
215
+ function resolveExistingOutputFilePath(fileRef: string, cwd: string): string | null {
216
+ const ref = (fileRef || '').trim()
217
+ if (!ref) return null
218
+ if (ref.startsWith('/api/uploads/')) {
219
+ return maybeResolveUploadMediaPathFromUrl(ref) || null
220
+ }
221
+ const withoutFileScheme = ref.replace(/^file:\/\//i, '')
222
+ const candidates = path.isAbsolute(withoutFileScheme)
223
+ ? [withoutFileScheme]
224
+ : [
225
+ cwd ? path.resolve(cwd, withoutFileScheme) : '',
226
+ path.resolve(WORKSPACE_DIR, withoutFileScheme),
227
+ ].filter(Boolean)
228
+
229
+ for (const candidate of candidates) {
230
+ try {
231
+ const stat = fs.statSync(candidate)
232
+ if (stat.isFile()) return candidate
233
+ } catch {
234
+ // ignore missing candidate
235
+ }
236
+ }
237
+ return null
238
+ }
239
+
240
+ function isSendableAttachment(filePath: string): boolean {
241
+ try {
242
+ const stat = fs.statSync(filePath)
243
+ return stat.isFile() && stat.size <= MAX_CONNECTOR_ATTACHMENT_BYTES
244
+ } catch {
245
+ return false
246
+ }
247
+ }
248
+
249
+ export function resolveTaskOriginConnectorFollowupTarget(params: {
250
+ task: BoardTask
251
+ sessions: Record<string, SessionLike>
252
+ connectors: Record<string, Connector>
253
+ running: RunningConnectorLike[]
254
+ }): ConnectorTaskFollowupTarget | null {
255
+ const { task, sessions, connectors, running } = params
256
+ const metaTask = task as ScheduleTaskMeta
257
+ const delegatedByAgentId = typeof metaTask.delegatedByAgentId === 'string'
258
+ ? metaTask.delegatedByAgentId.trim()
259
+ : ''
260
+ const sourceSessionId = typeof metaTask.createdInSessionId === 'string'
261
+ ? metaTask.createdInSessionId.trim()
262
+ : ''
263
+ if (!sourceSessionId) return null
264
+ const sourceSession = sessions[sourceSessionId]
265
+ if (!sourceSession || !Array.isArray(sourceSession.messages)) return null
266
+
267
+ const runningById = new Map<string, RunningConnectorLike>()
268
+ for (const entry of running) {
269
+ if (!entry?.id) continue
270
+ runningById.set(entry.id, entry)
271
+ }
272
+
273
+ for (let i = sourceSession.messages.length - 1; i >= 0; i--) {
274
+ const message = sourceSession.messages[i]
275
+ if (!message || message.role !== 'user') continue
276
+
277
+ const connectorId = typeof message.source?.connectorId === 'string'
278
+ ? message.source.connectorId.trim()
279
+ : ''
280
+ if (!connectorId) continue
281
+
282
+ const connector = connectors[connectorId]
283
+ if (!connector) continue
284
+ const ownerId = typeof connector.agentId === 'string' ? connector.agentId.trim() : ''
285
+ if (ownerId) {
286
+ const allowedOwners = new Set([task.agentId, delegatedByAgentId].filter(Boolean))
287
+ if (!allowedOwners.has(ownerId)) continue
288
+ }
289
+
290
+ const runtime = runningById.get(connectorId)
291
+ if (runtime && !runtime.supportsSend) continue
292
+
293
+ const sourceChannel = typeof message.source?.channelId === 'string'
294
+ ? message.source.channelId.trim()
295
+ : ''
296
+ const fallbackChannel = runtime?.recentChannelId
297
+ || runtime?.configuredTargets?.[0]
298
+ || connector.config?.outboundJid
299
+ || connector.config?.outboundTarget
300
+ || ''
301
+ const rawChannel = sourceChannel || fallbackChannel
302
+ if (!rawChannel) continue
303
+
304
+ return {
305
+ connectorId,
306
+ channelId: connector.platform === 'whatsapp'
307
+ ? normalizeWhatsappTarget(rawChannel)
308
+ : rawChannel,
309
+ }
310
+ }
311
+
312
+ return null
313
+ }
314
+
167
315
  // Task result extraction now uses Zod-validated structured data
168
316
  // from ./task-result.ts (extractTaskResult, formatResultBody)
169
317
 
@@ -270,37 +418,67 @@ async function notifyConnectorTaskFollowups(params: {
270
418
  statusLabel: string
271
419
  summaryText: string
272
420
  imageUrl?: string
421
+ mediaPath?: string
422
+ mediaFileName?: string
273
423
  }) {
274
- const { task, statusLabel, summaryText, imageUrl } = params
424
+ const { task, statusLabel, summaryText, imageUrl, mediaPath, mediaFileName } = params
275
425
 
276
426
  const connectors = loadConnectors()
277
427
  const running = (await import('./connectors/manager')).listRunningConnectors()
278
428
  const manager = await import('./connectors/manager')
429
+ const sessions = loadSessions()
279
430
 
280
- const candidates = running.filter((entry) => {
281
- if (!entry.supportsSend || !entry.id) return false
282
- const connector = connectors[entry.id]
283
- if (!connector) return false
284
- if (connector.agentId !== task.agentId) return false
285
- return isEnabledFlag(connector.config?.taskFollowups)
431
+ const candidateByKey = new Map<string, ConnectorTaskFollowupTarget>()
432
+ const addCandidate = (candidate: ConnectorTaskFollowupTarget | null | undefined) => {
433
+ if (!candidate?.connectorId || !candidate?.channelId) return
434
+ const key = `${candidate.connectorId}|${candidate.channelId}`
435
+ if (!candidateByKey.has(key)) candidateByKey.set(key, candidate)
436
+ }
437
+
438
+ const originTarget = resolveTaskOriginConnectorFollowupTarget({
439
+ task,
440
+ sessions: sessions as Record<string, SessionLike>,
441
+ connectors,
442
+ running: running as RunningConnectorLike[],
286
443
  })
287
- if (!candidates.length) return
444
+ addCandidate(originTarget)
445
+ const preferredTargetKey = originTarget
446
+ ? `${originTarget.connectorId}|${originTarget.channelId}`
447
+ : ''
288
448
 
289
- const summary = summaryText.trim().slice(0, 1400)
290
- for (const candidate of candidates) {
291
- const connector = connectors[candidate.id]
449
+ for (const entry of running) {
450
+ if (!entry.supportsSend || !entry.id) continue
451
+ const connector = connectors[entry.id]
292
452
  if (!connector) continue
293
-
294
- const channelTargetRaw = candidate.recentChannelId
295
- || candidate.configuredTargets[0]
453
+ if (connector.agentId !== task.agentId) continue
454
+ if (!isEnabledFlag(connector.config?.taskFollowups)) continue
455
+ const channelTargetRaw = entry.recentChannelId
456
+ || entry.configuredTargets[0]
296
457
  || connector.config?.outboundJid
297
458
  || connector.config?.outboundTarget
298
459
  || ''
299
460
  if (!channelTargetRaw) continue
461
+ addCandidate({
462
+ connectorId: entry.id,
463
+ channelId: connector.platform === 'whatsapp'
464
+ ? normalizeWhatsappTarget(channelTargetRaw)
465
+ : channelTargetRaw,
466
+ })
467
+ }
468
+ const targets = [...candidateByKey.values()].sort((a, b) => {
469
+ if (!preferredTargetKey) return 0
470
+ const aKey = `${a.connectorId}|${a.channelId}`
471
+ const bKey = `${b.connectorId}|${b.channelId}`
472
+ if (aKey === preferredTargetKey && bKey !== preferredTargetKey) return -1
473
+ if (bKey === preferredTargetKey && aKey !== preferredTargetKey) return 1
474
+ return 0
475
+ })
476
+ if (!targets.length) return
300
477
 
301
- const channelId = connector.platform === 'whatsapp'
302
- ? normalizeWhatsappTarget(channelTargetRaw)
303
- : channelTargetRaw
478
+ const summary = summaryText.trim().slice(0, 1400)
479
+ for (const target of targets) {
480
+ const connector = connectors[target.connectorId]
481
+ if (!connector) continue
304
482
 
305
483
  const template = typeof connector.config?.taskFollowupTemplate === 'string'
306
484
  ? connector.config.taskFollowupTemplate.trim()
@@ -316,23 +494,29 @@ async function notifyConnectorTaskFollowups(params: {
316
494
  `Task ${statusLabel}: ${task.title}`,
317
495
  summary || 'No summary provided.',
318
496
  ].join('\n\n')
497
+ const targetKey = `${target.connectorId}|${target.channelId}`
498
+ const preferredChannelNote = !template && preferredTargetKey && targetKey === preferredTargetKey
499
+ ? '\n\n(Update sent in the same channel that requested this task.)'
500
+ : ''
501
+ const outboundMessage = `${message}${preferredChannelNote}`
319
502
 
320
- const resolvedMediaPath = maybeResolveUploadMediaPathFromUrl(imageUrl)
503
+ const resolvedMediaPath = mediaPath || maybeResolveUploadMediaPathFromUrl(imageUrl)
321
504
  try {
322
505
  await manager.sendConnectorMessage({
323
- connectorId: candidate.id,
324
- channelId,
325
- text: message,
506
+ connectorId: target.connectorId,
507
+ channelId: target.channelId,
508
+ text: outboundMessage,
326
509
  ...(resolvedMediaPath
327
510
  ? {
328
511
  mediaPath: resolvedMediaPath,
329
- caption: message,
512
+ fileName: mediaFileName || path.basename(resolvedMediaPath),
513
+ caption: outboundMessage,
330
514
  }
331
515
  : {}),
332
516
  })
333
517
  } catch (err: unknown) {
334
518
  const errMsg = err instanceof Error ? err.message : String(err)
335
- console.warn(`[queue] Failed task follow-up send on connector ${candidate.id}: ${errMsg}`)
519
+ console.warn(`[queue] Failed task follow-up send on connector ${target.connectorId}: ${errMsg}`)
336
520
  }
337
521
  }
338
522
  }
@@ -358,10 +542,16 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
358
542
  { sinceTime: typeof task.startedAt === 'number' ? task.startedAt : null },
359
543
  )
360
544
  const resultBody = formatResultBody(taskResult)
545
+ const outputFileRefs = Array.isArray(task.outputFiles) && task.outputFiles.length > 0
546
+ ? task.outputFiles
547
+ : extractLikelyOutputFiles(resultBody)
361
548
 
362
549
  const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
363
550
  const taskLink = `[${task.title}](#task:${task.id})`
364
551
  const firstImage = taskResult.artifacts.find((a) => a.type === 'image')
552
+ const firstArtifactMediaPath = taskResult.artifacts
553
+ .map((artifact) => maybeResolveUploadMediaPathFromUrl(artifact.url))
554
+ .find((candidate): candidate is string => Boolean(candidate))
365
555
  const now = Date.now()
366
556
  let changed = false
367
557
 
@@ -377,6 +567,11 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
377
567
 
378
568
  // Get working directory from execution session
379
569
  const execCwd = runSession?.cwd || ''
570
+ const existingOutputPaths = outputFileRefs
571
+ .map((fileRef) => resolveExistingOutputFilePath(fileRef, execCwd))
572
+ .filter((candidate): candidate is string => Boolean(candidate))
573
+ const firstLocalOutputPath = existingOutputPaths.find((candidate) => isSendableAttachment(candidate))
574
+ const followupMediaPath = firstArtifactMediaPath || firstLocalOutputPath || undefined
380
575
 
381
576
  const buildMsg = (text: string): Message => {
382
577
  const msg: Message = { role: 'assistant', text, time: now, kind: 'system' }
@@ -387,6 +582,10 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
387
582
  const buildResultBlock = (prefix: string): string => {
388
583
  const parts = [prefix]
389
584
  if (execCwd) parts.push(`Working directory: \`${execCwd}\``)
585
+ if (outputFileRefs.length > 0) {
586
+ parts.push(`Output files:\n${outputFileRefs.slice(0, 8).map((fileRef) => `- \`${fileRef}\``).join('\n')}`)
587
+ }
588
+ if (task.completionReportPath) parts.push(`Task report: \`${task.completionReportPath}\``)
390
589
  if (resumeLines.length > 0) parts.push(resumeLines.join(' | '))
391
590
  parts.push(resultBody || 'No summary.')
392
591
  return parts.join('\n\n')
@@ -449,6 +648,8 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
449
648
  statusLabel,
450
649
  summaryText: resultBody || '',
451
650
  imageUrl: firstImage?.url,
651
+ mediaPath: followupMediaPath,
652
+ mediaFileName: followupMediaPath ? path.basename(followupMediaPath) : undefined,
452
653
  })
453
654
  }
454
655
 
@@ -812,6 +1013,8 @@ export async function processNext() {
812
1013
  )
813
1014
  const enrichedResult = formatResultBody(taskResult)
814
1015
  t2[taskId].result = enrichedResult.slice(0, 4000) || null
1016
+ t2[taskId].artifacts = taskResult.artifacts.slice(0, 24)
1017
+ t2[taskId].outputFiles = extractLikelyOutputFiles(enrichedResult).slice(0, 24)
815
1018
  t2[taskId].updatedAt = Date.now()
816
1019
  const report = ensureTaskCompletionReport(t2[taskId])
817
1020
  if (report?.relativePath) t2[taskId].completionReportPath = report.relativePath
@@ -909,6 +1112,17 @@ export async function processNext() {
909
1112
  getCheckpointSaver().deleteThread(taskId).catch((e) =>
910
1113
  console.warn(`[queue] Failed to clean up checkpoints for task ${taskId}:`, e)
911
1114
  )
1115
+ // Cascade unblock: auto-queue tasks whose blockers are all done
1116
+ const latestTasks = loadTasks()
1117
+ const unblockedIds = cascadeUnblock(latestTasks, taskId)
1118
+ if (unblockedIds.length > 0) {
1119
+ saveTasks(latestTasks)
1120
+ for (const uid of unblockedIds) {
1121
+ enqueueTask(uid)
1122
+ console.log(`[queue] Auto-unblocked task "${latestTasks[uid]?.title}" (${uid})`)
1123
+ }
1124
+ notify('tasks')
1125
+ }
912
1126
  console.log(`[queue] Task "${task.title}" completed`)
913
1127
  } else {
914
1128
  if (doneTask?.status === 'queued') {
@@ -146,7 +146,10 @@ async function tick() {
146
146
  existingTask.title = `[Sched] ${schedule.name} (run #${schedule.runNumber})`
147
147
  existingTask.result = null
148
148
  existingTask.error = null
149
+ existingTask.outputFiles = []
150
+ existingTask.artifacts = []
149
151
  existingTask.sessionId = null
152
+ existingTask.completionReportPath = null
150
153
  existingTask.updatedAt = now
151
154
  existingTask.queuedAt = null
152
155
  existingTask.startedAt = null
@@ -85,6 +85,17 @@ function registerRun(run: SessionRunRecord) {
85
85
  trimRecentRuns()
86
86
  }
87
87
 
88
+ /** Chain an external AbortSignal to an internal AbortController so that
89
+ * when the caller (e.g. HTTP request) disconnects, the run is cancelled. */
90
+ function chainCallerSignal(callerSignal: AbortSignal, controller: AbortController): void {
91
+ if (callerSignal.aborted) {
92
+ controller.abort()
93
+ return
94
+ }
95
+ const onAbort = () => controller.abort()
96
+ callerSignal.addEventListener('abort', onAbort, { once: true })
97
+ }
98
+
88
99
  function emitToSubscribers(entry: QueueEntry, event: SSEEvent) {
89
100
  for (const send of entry.onEvents) {
90
101
  try {
@@ -347,6 +358,8 @@ export interface EnqueueSessionRunInput {
347
358
  modelOverride?: string
348
359
  heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null }
349
360
  replyToId?: string
361
+ /** External abort signal (e.g. from the HTTP request) — chained to the run's internal AbortController */
362
+ callerSignal?: AbortSignal
350
363
  }
351
364
 
352
365
  export interface EnqueueSessionRunResult {
@@ -355,6 +368,8 @@ export interface EnqueueSessionRunResult {
355
368
  deduped?: boolean
356
369
  coalesced?: boolean
357
370
  promise: Promise<ExecuteChatTurnResult>
371
+ /** Abort the run's internal AbortController (cancels the LLM stream). */
372
+ abort: () => void
358
373
  }
359
374
 
360
375
  export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSessionRunResult {
@@ -371,11 +386,13 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
371
386
  const dedupe = findDedupeMatch(input.sessionId, input.dedupeKey)
372
387
  if (dedupe) {
373
388
  if (input.onEvent) dedupe.onEvents.push(input.onEvent)
389
+ if (input.callerSignal) chainCallerSignal(input.callerSignal, dedupe.signalController)
374
390
  return {
375
391
  runId: dedupe.run.id,
376
392
  position: 0,
377
393
  deduped: true,
378
394
  promise: dedupe.promise,
395
+ abort: () => dedupe.signalController.abort(),
379
396
  }
380
397
  }
381
398
 
@@ -413,12 +430,14 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
413
430
  candidate.run.queuedAt = nowMs
414
431
  }
415
432
  if (input.onEvent) candidate.onEvents.push(input.onEvent)
433
+ if (input.callerSignal) chainCallerSignal(input.callerSignal, candidate.signalController)
416
434
  emitRunMeta(candidate, 'queued', { position: 0, coalesced: true, mergedIntoRunId: candidate.run.id })
417
435
  return {
418
436
  runId: candidate.run.id,
419
437
  position: 0,
420
438
  coalesced: true,
421
439
  promise: candidate.promise,
440
+ abort: () => candidate.signalController.abort(),
422
441
  }
423
442
  }
424
443
  }
@@ -463,12 +482,14 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
463
482
  promise,
464
483
  }
465
484
 
485
+ if (input.callerSignal) chainCallerSignal(input.callerSignal, entry.signalController)
486
+
466
487
  q.push(entry)
467
488
  const position = (running ? 1 : 0) + q.length - 1
468
489
  emitRunMeta(entry, 'queued', { position })
469
490
  void drainExecution(executionKey)
470
491
 
471
- return { runId, position, promise }
492
+ return { runId, position, promise, abort: () => entry.signalController.abort() }
472
493
  }
473
494
 
474
495
  export function getSessionRunState(sessionId: string): {