@swarmclawai/swarmclaw 0.7.7 → 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 (63) hide show
  1. package/README.md +10 -9
  2. package/package.json +1 -1
  3. package/src/app/api/chats/route.ts +1 -0
  4. package/src/app/api/connectors/[id]/route.ts +20 -2
  5. package/src/app/api/connectors/route.ts +12 -8
  6. package/src/app/api/projects/[id]/route.ts +6 -2
  7. package/src/app/api/projects/route.ts +4 -3
  8. package/src/app/api/secrets/[id]/route.ts +1 -0
  9. package/src/app/api/secrets/route.ts +2 -1
  10. package/src/app/api/settings/route.ts +2 -0
  11. package/src/components/agents/agent-sheet.tsx +184 -14
  12. package/src/components/chat/chat-area.tsx +36 -19
  13. package/src/components/chat/chat-header.tsx +4 -0
  14. package/src/components/chat/delegation-banner.test.ts +14 -1
  15. package/src/components/chat/delegation-banner.tsx +1 -1
  16. package/src/components/layout/app-layout.tsx +40 -23
  17. package/src/components/projects/project-detail.tsx +217 -0
  18. package/src/components/projects/project-sheet.tsx +176 -4
  19. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  20. package/src/components/shared/settings/section-voice.tsx +11 -3
  21. package/src/components/tasks/approvals-panel.tsx +177 -18
  22. package/src/components/tasks/task-board.tsx +137 -23
  23. package/src/components/tasks/task-card.tsx +29 -0
  24. package/src/components/tasks/task-sheet.tsx +16 -4
  25. package/src/lib/server/capability-router.test.ts +22 -0
  26. package/src/lib/server/capability-router.ts +54 -18
  27. package/src/lib/server/chat-execution.ts +25 -1
  28. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  29. package/src/lib/server/connectors/manager.ts +99 -74
  30. package/src/lib/server/daemon-state.ts +83 -46
  31. package/src/lib/server/elevenlabs.test.ts +59 -1
  32. package/src/lib/server/heartbeat-service.ts +5 -1
  33. package/src/lib/server/main-agent-loop.test.ts +260 -0
  34. package/src/lib/server/main-agent-loop.ts +559 -14
  35. package/src/lib/server/orchestrator-lg.ts +1 -0
  36. package/src/lib/server/orchestrator.ts +2 -0
  37. package/src/lib/server/plugins.ts +6 -1
  38. package/src/lib/server/project-context.ts +162 -0
  39. package/src/lib/server/project-utils.ts +150 -0
  40. package/src/lib/server/queue-followups.test.ts +147 -2
  41. package/src/lib/server/queue.ts +234 -7
  42. package/src/lib/server/session-run-manager.ts +31 -0
  43. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  44. package/src/lib/server/session-tools/connector.ts +26 -1
  45. package/src/lib/server/session-tools/context.ts +5 -0
  46. package/src/lib/server/session-tools/crud.ts +265 -76
  47. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  48. package/src/lib/server/session-tools/delegate.ts +38 -2
  49. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  50. package/src/lib/server/session-tools/memory.ts +14 -2
  51. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  52. package/src/lib/server/session-tools/platform.ts +60 -19
  53. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  54. package/src/lib/server/session-tools/web.ts +153 -6
  55. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  56. package/src/lib/server/stream-agent-chat.ts +104 -30
  57. package/src/lib/server/tool-aliases.ts +2 -0
  58. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  59. package/src/lib/server/tool-capability-policy.ts +29 -1
  60. package/src/lib/server/tool-planning.test.ts +44 -0
  61. package/src/lib/server/tool-planning.ts +269 -0
  62. package/src/lib/tool-definitions.ts +2 -1
  63. package/src/types/index.ts +39 -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 }
@@ -128,6 +128,212 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
128
128
  if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
129
129
  }
130
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
+
131
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
132
338
  const TASK_CWD_NOISE_DIRS = new Set([
133
339
  'uploads',
@@ -1057,7 +1263,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
1057
1263
  return 'dead_lettered'
1058
1264
  }
1059
1265
 
1060
- function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
1266
+ export function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
1061
1267
  const now = Date.now()
1062
1268
 
1063
1269
  // Remove stale entries first.
@@ -1071,7 +1277,9 @@ function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTas
1071
1277
  const task = tasks[id]
1072
1278
  if (!task) return false
1073
1279
  const retryAt = typeof task.retryScheduledAt === 'number' ? task.retryScheduledAt : null
1074
- 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')
1075
1283
  })
1076
1284
  if (idx === -1) return null
1077
1285
  const [taskId] = queue.splice(idx, 1)
@@ -1164,6 +1372,8 @@ export async function processNext() {
1164
1372
  const sourceScheduleId = typeof scheduleTask.sourceScheduleId === 'string'
1165
1373
  ? scheduleTask.sourceScheduleId
1166
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>)
1167
1377
 
1168
1378
  // Resolve the agent's persistent thread session to use as parentSessionId
1169
1379
  const agentThreadSessionId = agent.threadSessionId || null
@@ -1197,7 +1407,7 @@ export async function processNext() {
1197
1407
  saveSchedules(schedules)
1198
1408
  }
1199
1409
  } else {
1200
- sessionId = createOrchestratorSession(
1410
+ sessionId = reusableTaskSessionId || createOrchestratorSession(
1201
1411
  agent,
1202
1412
  task.title,
1203
1413
  agentThreadSessionId || undefined,
@@ -1206,6 +1416,13 @@ export async function processNext() {
1206
1416
  )
1207
1417
  }
1208
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
+
1209
1426
  // Notify the agent's thread that a task has started
1210
1427
  if (agentThreadSessionId) {
1211
1428
  try {
@@ -1231,9 +1448,19 @@ export async function processNext() {
1231
1448
  }
1232
1449
 
1233
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
+ }
1234
1461
  task.checkpoint = {
1235
1462
  lastSessionId: sessionId,
1236
- note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started`,
1463
+ note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started${continuationBits.length ? ` (${continuationBits.join('; ')})` : ''}`,
1237
1464
  updatedAt: Date.now(),
1238
1465
  }
1239
1466
  saveTasks(tasks)
@@ -1251,9 +1478,9 @@ export async function processNext() {
1251
1478
  const delegatorId = (task as unknown as Record<string, unknown>).delegatedByAgentId as string | undefined
1252
1479
  const delegator = delegatorId ? agents[delegatorId] : null
1253
1480
  const prefix = `[delegation-source:${delegatorId || ''}:${delegator?.name || 'Agent'}:${delegator?.avatarSeed || ''}]`
1254
- 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.`
1255
1482
  } else {
1256
- 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.`
1257
1484
  }
1258
1485
  sessions[sessionId].messages.push({
1259
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 {