@swarmclawai/swarmclaw 0.7.6 → 0.7.8

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 (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -13,7 +13,7 @@ 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 type { Agent, BoardTask, Connector, Message } from '@/types'
16
+ import type { Agent, BoardTask, Connector, Message, Session } from '@/types'
17
17
 
18
18
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
19
19
  const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
@@ -80,6 +80,36 @@ function normalizeInt(value: unknown, fallback: number, min: number, max: number
80
80
  return Math.max(min, Math.min(max, Math.trunc(parsed)))
81
81
  }
82
82
 
83
+ const OPENCLAW_USE_CASE_TAGS = new Set([
84
+ 'local-dev',
85
+ 'single-vps',
86
+ 'private-tailnet',
87
+ 'browser-heavy',
88
+ 'team-control',
89
+ ])
90
+
91
+ function deriveTaskRoutePreferences(task: BoardTask): {
92
+ preferredGatewayTags?: string[]
93
+ preferredGatewayUseCase?: string | null
94
+ } {
95
+ const tags = Array.isArray(task.tags)
96
+ ? [...new Set(task.tags.map((tag) => (typeof tag === 'string' ? tag.trim().toLowerCase() : '')).filter(Boolean))]
97
+ : []
98
+ const customUseCase = typeof task.customFields?.openclawUseCase === 'string'
99
+ ? task.customFields.openclawUseCase
100
+ : typeof task.customFields?.gatewayUseCase === 'string'
101
+ ? task.customFields.gatewayUseCase
102
+ : null
103
+ const preferredGatewayUseCase = customUseCase && OPENCLAW_USE_CASE_TAGS.has(customUseCase)
104
+ ? customUseCase
105
+ : (tags.find((tag) => OPENCLAW_USE_CASE_TAGS.has(tag)) || null)
106
+ const preferredGatewayTags = tags.filter((tag) => tag !== preferredGatewayUseCase)
107
+ return {
108
+ preferredGatewayTags,
109
+ preferredGatewayUseCase,
110
+ }
111
+ }
112
+
83
113
  function resolveTaskPolicy(task: BoardTask): { maxAttempts: number; backoffSec: number } {
84
114
  const settings = loadSettings()
85
115
  const defaultMaxAttempts = normalizeInt(settings.defaultTaskMaxAttempts, 3, 1, 20)
@@ -98,6 +128,212 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
98
128
  if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
99
129
  }
100
130
 
131
+ export interface TaskResumeState {
132
+ claudeSessionId: string | null
133
+ codexThreadId: string | null
134
+ opencodeSessionId: string | null
135
+ delegateResumeIds: NonNullable<Session['delegateResumeIds']>
136
+ }
137
+
138
+ export interface TaskResumeContext {
139
+ source: 'self' | 'delegated_from_task' | 'blocked_by'
140
+ sourceTaskId: string
141
+ sourceTaskTitle: string
142
+ sourceSessionId: string | null
143
+ resume: TaskResumeState
144
+ }
145
+
146
+ function normalizeResumeHandle(value: unknown): string | null {
147
+ return typeof value === 'string' && value.trim() ? value.trim() : null
148
+ }
149
+
150
+ function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
151
+ return {
152
+ claudeCode: null,
153
+ codex: null,
154
+ opencode: null,
155
+ gemini: null,
156
+ }
157
+ }
158
+
159
+ function normalizeCliProvider(value: unknown): string | null {
160
+ return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null
161
+ }
162
+
163
+ function hasResumeState(state: TaskResumeState | null | undefined): state is TaskResumeState {
164
+ if (!state) return false
165
+ return Boolean(
166
+ state.claudeSessionId
167
+ || state.codexThreadId
168
+ || state.opencodeSessionId
169
+ || state.delegateResumeIds.claudeCode
170
+ || state.delegateResumeIds.codex
171
+ || state.delegateResumeIds.opencode
172
+ || state.delegateResumeIds.gemini
173
+ )
174
+ }
175
+
176
+ export function extractTaskResumeState(task: Partial<BoardTask> | null | undefined): TaskResumeState | null {
177
+ if (!task) return null
178
+
179
+ const legacyResumeId = normalizeResumeHandle(task.cliResumeId)
180
+ const legacyProvider = normalizeCliProvider(task.cliProvider)
181
+ const claudeSessionId = normalizeResumeHandle(task.claudeResumeId)
182
+ || (legacyProvider === 'claude-cli' ? legacyResumeId : null)
183
+ const codexThreadId = normalizeResumeHandle(task.codexResumeId)
184
+ || (legacyProvider === 'codex-cli' ? legacyResumeId : null)
185
+ const opencodeSessionId = normalizeResumeHandle(task.opencodeResumeId)
186
+ || (legacyProvider === 'opencode-cli' ? legacyResumeId : null)
187
+ const geminiSessionId = normalizeResumeHandle(task.geminiResumeId)
188
+ || (legacyProvider === 'gemini-cli' ? legacyResumeId : null)
189
+
190
+ const resume = {
191
+ claudeSessionId,
192
+ codexThreadId,
193
+ opencodeSessionId,
194
+ delegateResumeIds: {
195
+ claudeCode: claudeSessionId,
196
+ codex: codexThreadId,
197
+ opencode: opencodeSessionId,
198
+ gemini: geminiSessionId,
199
+ },
200
+ } satisfies TaskResumeState
201
+
202
+ return hasResumeState(resume) ? resume : null
203
+ }
204
+
205
+ export function extractSessionResumeState(session: Partial<Session> | null | undefined): TaskResumeState | null {
206
+ if (!session) return null
207
+
208
+ const claudeSessionId = normalizeResumeHandle(session.claudeSessionId)
209
+ const codexThreadId = normalizeResumeHandle(session.codexThreadId)
210
+ const opencodeSessionId = normalizeResumeHandle(session.opencodeSessionId)
211
+ const delegateResumeIds = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
212
+ ? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
213
+ : buildEmptyDelegateResumeIds()
214
+
215
+ const resume = {
216
+ claudeSessionId,
217
+ codexThreadId,
218
+ opencodeSessionId,
219
+ delegateResumeIds: {
220
+ claudeCode: normalizeResumeHandle(delegateResumeIds.claudeCode) || claudeSessionId,
221
+ codex: normalizeResumeHandle(delegateResumeIds.codex) || codexThreadId,
222
+ opencode: normalizeResumeHandle(delegateResumeIds.opencode) || opencodeSessionId,
223
+ gemini: normalizeResumeHandle(delegateResumeIds.gemini),
224
+ },
225
+ } satisfies TaskResumeState
226
+
227
+ return hasResumeState(resume) ? resume : null
228
+ }
229
+
230
+ export function resolveTaskResumeContext(
231
+ task: BoardTask,
232
+ tasksById: Record<string, BoardTask>,
233
+ sessionsById?: Record<string, SessionLike | Session>,
234
+ ): TaskResumeContext | null {
235
+ const candidates: Array<{ source: TaskResumeContext['source']; taskId: string | null | undefined }> = [
236
+ { source: 'self', taskId: task.id },
237
+ { source: 'delegated_from_task', taskId: task.delegatedFromTaskId },
238
+ ...((Array.isArray(task.blockedBy) ? task.blockedBy : []).map((taskId) => ({ source: 'blocked_by' as const, taskId }))),
239
+ ]
240
+ const seen = new Set<string>()
241
+
242
+ for (const candidate of candidates) {
243
+ const taskId = typeof candidate.taskId === 'string' ? candidate.taskId.trim() : ''
244
+ if (!taskId || seen.has(taskId)) continue
245
+ seen.add(taskId)
246
+ const sourceTask = taskId === task.id ? task : tasksById[taskId]
247
+ if (!sourceTask) continue
248
+ const sourceSessionId = normalizeResumeHandle(sourceTask.checkpoint?.lastSessionId) || normalizeResumeHandle(sourceTask.sessionId)
249
+ const resume = extractTaskResumeState(sourceTask)
250
+ || (sourceSessionId && sessionsById?.[sourceSessionId]
251
+ ? extractSessionResumeState(sessionsById[sourceSessionId] as Session)
252
+ : null)
253
+ if (!resume) continue
254
+ return {
255
+ source: candidate.source,
256
+ sourceTaskId: sourceTask.id,
257
+ sourceTaskTitle: sourceTask.title,
258
+ sourceSessionId,
259
+ resume,
260
+ }
261
+ }
262
+
263
+ return null
264
+ }
265
+
266
+ export function applyTaskResumeStateToSession(session: Session, resume: TaskResumeState | null | undefined): boolean {
267
+ if (!hasResumeState(resume)) return false
268
+
269
+ let changed = false
270
+ const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId', string | null]> = [
271
+ ['claudeSessionId', resume.claudeSessionId],
272
+ ['codexThreadId', resume.codexThreadId],
273
+ ['opencodeSessionId', resume.opencodeSessionId],
274
+ ]
275
+ for (const [key, value] of directFields) {
276
+ if (!value || session[key] === value) continue
277
+ session[key] = value
278
+ changed = true
279
+ }
280
+
281
+ const currentDelegateResume = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
282
+ ? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
283
+ : buildEmptyDelegateResumeIds()
284
+ for (const [key, value] of Object.entries(resume.delegateResumeIds) as Array<[keyof NonNullable<Session['delegateResumeIds']>, string | null]>) {
285
+ if (!value || currentDelegateResume[key] === value) continue
286
+ currentDelegateResume[key] = value
287
+ changed = true
288
+ }
289
+ if (changed) session.delegateResumeIds = currentDelegateResume
290
+ return changed
291
+ }
292
+
293
+ export function resolveReusableTaskSessionId(
294
+ task: BoardTask,
295
+ tasks: Record<string, BoardTask>,
296
+ sessions: Record<string, SessionLike>,
297
+ ): string {
298
+ const candidateTaskIds = [
299
+ task.id,
300
+ typeof task.delegatedFromTaskId === 'string' ? task.delegatedFromTaskId : '',
301
+ ...(Array.isArray(task.blockedBy) ? task.blockedBy : []),
302
+ ]
303
+ const seen = new Set<string>()
304
+ for (const candidateTaskId of candidateTaskIds) {
305
+ const taskId = typeof candidateTaskId === 'string' ? candidateTaskId.trim() : ''
306
+ if (!taskId || seen.has(taskId)) continue
307
+ seen.add(taskId)
308
+ const sourceTask = taskId === task.id ? task : tasks[taskId]
309
+ if (!sourceTask) continue
310
+ const candidates = [
311
+ normalizeResumeHandle(sourceTask.checkpoint?.lastSessionId),
312
+ normalizeResumeHandle(sourceTask.sessionId),
313
+ ]
314
+ for (const candidate of candidates) {
315
+ if (candidate && sessions[candidate]) return candidate
316
+ }
317
+ }
318
+ return ''
319
+ }
320
+
321
+ function buildTaskContinuationNote(
322
+ reusedExistingSession: boolean,
323
+ resumeContext: TaskResumeContext | null,
324
+ ): string {
325
+ const notes: string[] = []
326
+ if (reusedExistingSession) {
327
+ notes.push('Reusing the previous execution session for this task.')
328
+ }
329
+ if (resumeContext?.source === 'delegated_from_task' || resumeContext?.source === 'blocked_by') {
330
+ notes.push(`Stored CLI context is available from related task "${resumeContext.sourceTaskTitle}".`)
331
+ } else if (resumeContext?.source === 'self' && !reusedExistingSession) {
332
+ notes.push('Stored CLI resume handles are available for continuation.')
333
+ }
334
+ return notes.length ? `\n\n${notes.join(' ')}` : ''
335
+ }
336
+
101
337
  const DEV_TASK_HINT = /\b(dev(?:\s+server)?|start(?:ing)?\s+(?:the\s+)?server|run(?:ning)?\s+(?:the\s+)?(?:app|project|site)|serve|localhost|http\s+server|web\s+server|npm\b|pnpm\b|yarn\b|bun\b|vite|next(?:\.js)?|react|build|compile)\b/i
102
338
  const TASK_CWD_NOISE_DIRS = new Set([
103
339
  'uploads',
@@ -1027,7 +1263,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
1027
1263
  return 'dead_lettered'
1028
1264
  }
1029
1265
 
1030
- function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
1266
+ export function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
1031
1267
  const now = Date.now()
1032
1268
 
1033
1269
  // Remove stale entries first.
@@ -1041,7 +1277,9 @@ function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTas
1041
1277
  const task = tasks[id]
1042
1278
  if (!task) return false
1043
1279
  const retryAt = typeof task.retryScheduledAt === 'number' ? task.retryScheduledAt : null
1044
- return !retryAt || retryAt <= now
1280
+ if (retryAt && retryAt > now) return false
1281
+ const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
1282
+ return blockers.every((blockerId) => tasks[blockerId]?.status === 'completed')
1045
1283
  })
1046
1284
  if (idx === -1) return null
1047
1285
  const [taskId] = queue.splice(idx, 1)
@@ -1134,9 +1372,12 @@ export async function processNext() {
1134
1372
  const sourceScheduleId = typeof scheduleTask.sourceScheduleId === 'string'
1135
1373
  ? scheduleTask.sourceScheduleId
1136
1374
  : ''
1375
+ const reusableTaskSessionId = resolveReusableTaskSessionId(task, tasks as Record<string, BoardTask>, sessionsForCwd)
1376
+ const resumeContext = resolveTaskResumeContext(task, tasks as Record<string, BoardTask>, sessionsForCwd as Record<string, SessionLike | Session>)
1137
1377
 
1138
1378
  // Resolve the agent's persistent thread session to use as parentSessionId
1139
1379
  const agentThreadSessionId = agent.threadSessionId || null
1380
+ const taskRoutePreferences = deriveTaskRoutePreferences(task)
1140
1381
 
1141
1382
  if (isScheduleTask && sourceScheduleId) {
1142
1383
  const schedules = loadSchedules()
@@ -1151,7 +1392,13 @@ export async function processNext() {
1151
1392
  }
1152
1393
  }
1153
1394
  if (!sessionId) {
1154
- sessionId = createOrchestratorSession(agent, task.title, agentThreadSessionId || undefined, taskCwd)
1395
+ sessionId = createOrchestratorSession(
1396
+ agent,
1397
+ task.title,
1398
+ agentThreadSessionId || undefined,
1399
+ taskCwd,
1400
+ taskRoutePreferences,
1401
+ )
1155
1402
  }
1156
1403
  if (linkedSchedule && linkedSchedule.lastSessionId !== sessionId) {
1157
1404
  linkedSchedule.lastSessionId = sessionId
@@ -1160,9 +1407,22 @@ export async function processNext() {
1160
1407
  saveSchedules(schedules)
1161
1408
  }
1162
1409
  } else {
1163
- sessionId = createOrchestratorSession(agent, task.title, agentThreadSessionId || undefined, taskCwd)
1410
+ sessionId = reusableTaskSessionId || createOrchestratorSession(
1411
+ agent,
1412
+ task.title,
1413
+ agentThreadSessionId || undefined,
1414
+ taskCwd,
1415
+ taskRoutePreferences,
1416
+ )
1164
1417
  }
1165
1418
 
1419
+ const executionSessions = loadSessions() as Record<string, Session>
1420
+ const executionSession = executionSessions[sessionId]
1421
+ const seededResumeState = executionSession
1422
+ ? applyTaskResumeStateToSession(executionSession, resumeContext?.resume)
1423
+ : false
1424
+ if (seededResumeState) saveSessions(executionSessions)
1425
+
1166
1426
  // Notify the agent's thread that a task has started
1167
1427
  if (agentThreadSessionId) {
1168
1428
  try {
@@ -1188,9 +1448,19 @@ export async function processNext() {
1188
1448
  }
1189
1449
 
1190
1450
  task.sessionId = sessionId
1451
+ const reusedExistingSession = !isScheduleTask && Boolean(reusableTaskSessionId) && reusableTaskSessionId === sessionId
1452
+ const continuationBits: string[] = []
1453
+ if (reusedExistingSession) {
1454
+ continuationBits.push('reusing prior session')
1455
+ }
1456
+ if (resumeContext?.source === 'delegated_from_task' || resumeContext?.source === 'blocked_by') {
1457
+ continuationBits.push(`seeded from task ${resumeContext.sourceTaskId}`)
1458
+ } else if (seededResumeState) {
1459
+ continuationBits.push('restored CLI resume handles')
1460
+ }
1191
1461
  task.checkpoint = {
1192
1462
  lastSessionId: sessionId,
1193
- note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started`,
1463
+ note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started${continuationBits.length ? ` (${continuationBits.join('; ')})` : ''}`,
1194
1464
  updatedAt: Date.now(),
1195
1465
  }
1196
1466
  saveTasks(tasks)
@@ -1208,9 +1478,9 @@ export async function processNext() {
1208
1478
  const delegatorId = (task as unknown as Record<string, unknown>).delegatedByAgentId as string | undefined
1209
1479
  const delegator = delegatorId ? agents[delegatorId] : null
1210
1480
  const prefix = `[delegation-source:${delegatorId || ''}:${delegator?.name || 'Agent'}:${delegator?.avatarSeed || ''}]`
1211
- initialText = `${prefix}\nDelegated by **${delegator?.name || 'another agent'}** | [${task.title}](#task:${task.id})\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`
1481
+ initialText = `${prefix}\nDelegated by **${delegator?.name || 'another agent'}** | [${task.title}](#task:${task.id})\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`${buildTaskContinuationNote(Boolean(reusedExistingSession), resumeContext)}\n\nI'll begin working on this now.`
1212
1482
  } else {
1213
- initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`
1483
+ initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`${buildTaskContinuationNote(Boolean(reusedExistingSession), resumeContext)}\n\nI'll begin working on this now.`
1214
1484
  }
1215
1485
  sessions[sessionId].messages.push({
1216
1486
  role: 'assistant',
@@ -7,6 +7,7 @@ import { log } from './logger'
7
7
  import { isInternalHeartbeatRun } from './heartbeat-source'
8
8
  import { cleanupSessionBrowser } from './session-tools/web'
9
9
  import { cancelDelegationJobsForParentSession } from './delegation-jobs'
10
+ import { handleMainLoopRunResult } from './main-agent-loop'
10
11
 
11
12
  export type SessionRunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
12
13
  export type SessionQueueMode = 'followup' | 'steer' | 'collect'
@@ -276,6 +277,36 @@ async function drainExecution(executionKey: string): Promise<void> {
276
277
  error: next.run.error || null,
277
278
  durationMs: (next.run.endedAt || now()) - (next.run.startedAt || now()),
278
279
  })
280
+ const followup = handleMainLoopRunResult({
281
+ sessionId: next.run.sessionId,
282
+ message: next.message,
283
+ internal: next.run.internal,
284
+ source: next.run.source,
285
+ resultText: result.text,
286
+ error: next.run.error,
287
+ toolEvents: result.toolEvents,
288
+ inputTokens: result.inputTokens,
289
+ outputTokens: result.outputTokens,
290
+ estimatedCost: result.estimatedCost,
291
+ })
292
+ if (followup) {
293
+ setTimeout(() => {
294
+ try {
295
+ enqueueSessionRun({
296
+ sessionId: next.run.sessionId,
297
+ message: followup.message,
298
+ internal: true,
299
+ source: 'main-loop-followup',
300
+ mode: 'followup',
301
+ dedupeKey: followup.dedupeKey,
302
+ })
303
+ } catch (err: unknown) {
304
+ log.warn('session-run', `Main loop follow-up enqueue failed for ${next.run.sessionId}`, {
305
+ error: err instanceof Error ? err.message : String(err),
306
+ })
307
+ }
308
+ }, Math.max(0, followup.delayMs || 0))
309
+ }
279
310
  next.resolve(result)
280
311
  } catch (err: any) {
281
312
  const aborted = next.signalController.signal.aborted
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { describe, it } from 'node:test'
5
+ import { UPLOAD_DIR } from '../storage'
6
+ import { resolveConnectorMediaInput } from './connector'
7
+
8
+ describe('resolveConnectorMediaInput', () => {
9
+ it('resolves /api/uploads urls passed via mediaPath back to disk', () => {
10
+ const filename = `screenshot-test-${Date.now()}.png`
11
+ const uploadPath = path.join(UPLOAD_DIR, filename)
12
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true })
13
+ fs.writeFileSync(uploadPath, 'png')
14
+
15
+ try {
16
+ const resolved = resolveConnectorMediaInput({
17
+ cwd: process.cwd(),
18
+ mediaPath: `/api/uploads/${filename}`,
19
+ })
20
+ assert.equal(resolved.error, undefined)
21
+ assert.equal(resolved.mediaPath, uploadPath)
22
+ } finally {
23
+ fs.rmSync(uploadPath, { force: true })
24
+ }
25
+ })
26
+
27
+ it('treats remote urls passed via mediaPath as sendable urls instead of local files', () => {
28
+ const resolved = resolveConnectorMediaInput({
29
+ cwd: process.cwd(),
30
+ mediaPath: 'https://example.com/report.pdf',
31
+ })
32
+
33
+ assert.equal(resolved.error, undefined)
34
+ assert.equal(resolved.mediaPath, undefined)
35
+ assert.equal(resolved.fileUrl, 'https://example.com/report.pdf')
36
+ })
37
+ })
@@ -189,7 +189,7 @@ function pickChannelTarget(params: {
189
189
  return { channelId }
190
190
  }
191
191
 
192
- function resolveConnectorMediaInput(params: {
192
+ export function resolveConnectorMediaInput(params: {
193
193
  cwd: string
194
194
  mediaPath?: string
195
195
  imageUrl?: string
@@ -199,6 +199,23 @@ function resolveConnectorMediaInput(params: {
199
199
  let resolvedImageUrl = params.imageUrl?.trim() || undefined
200
200
  let resolvedFileUrl = params.fileUrl?.trim() || undefined
201
201
 
202
+ // Be forgiving when the model passes a served upload URL or remote URL in mediaPath.
203
+ if (resolvedMediaPath?.startsWith('/api/uploads/')) {
204
+ const fromUpload = resolveUploadUrl(resolvedMediaPath)
205
+ if (fromUpload) {
206
+ resolvedMediaPath = fromUpload.mediaPath
207
+ } else {
208
+ return { error: `Error: File not found: ${resolvedMediaPath}` }
209
+ }
210
+ } else if (resolvedMediaPath && /^https?:\/\//i.test(resolvedMediaPath)) {
211
+ if (/\.(png|jpe?g|webp|gif|svg)(?:[?#].*)?$/i.test(resolvedMediaPath)) {
212
+ resolvedImageUrl = resolvedMediaPath
213
+ } else {
214
+ resolvedFileUrl = resolvedMediaPath
215
+ }
216
+ resolvedMediaPath = undefined
217
+ }
218
+
202
219
  if (resolvedMediaPath && !path.isAbsolute(resolvedMediaPath) && !resolvedMediaPath.startsWith('/api/uploads/')) {
203
220
  const candidatePaths = [
204
221
  path.resolve(params.cwd, resolvedMediaPath),
@@ -540,6 +557,14 @@ const ConnectorPlugin: Plugin = {
540
557
  messageId: { type: 'string' },
541
558
  targetMessage: { type: 'string', enum: ['last_inbound', 'last_outbound'] },
542
559
  emoji: { type: 'string' },
560
+ voiceText: { type: 'string' },
561
+ voiceId: { type: 'string' },
562
+ imageUrl: { type: 'string' },
563
+ fileUrl: { type: 'string' },
564
+ mediaPath: { type: 'string' },
565
+ mimeType: { type: 'string' },
566
+ fileName: { type: 'string' },
567
+ caption: { type: 'string' },
543
568
  replyToMessageId: { type: 'string' },
544
569
  threadId: { type: 'string' },
545
570
  delaySec: { type: 'number' },
@@ -9,6 +9,11 @@ export interface ToolContext {
9
9
  platformAssignScope?: 'self' | 'all'
10
10
  mcpServerIds?: string[]
11
11
  mcpDisabledTools?: string[]
12
+ projectId?: string | null
13
+ projectRoot?: string | null
14
+ projectName?: string | null
15
+ projectDescription?: string | null
16
+ memoryScopeMode?: 'auto' | 'all' | 'global' | 'agent' | 'session' | 'project' | null
12
17
  }
13
18
 
14
19
  export interface SessionToolsResult {