@swarmclawai/swarmclaw 0.6.3 → 0.6.6

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 (106) hide show
  1. package/README.md +5 -3
  2. package/package.json +5 -1
  3. package/src/app/api/chatrooms/[id]/chat/route.ts +41 -2
  4. package/src/app/api/chatrooms/[id]/route.ts +15 -1
  5. package/src/app/api/chatrooms/route.ts +15 -2
  6. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  7. package/src/app/api/tasks/route.ts +24 -0
  8. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  9. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  10. package/src/app/api/wallets/[id]/route.ts +118 -0
  11. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  12. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  13. package/src/app/api/wallets/route.ts +74 -0
  14. package/src/app/globals.css +8 -0
  15. package/src/app/page.tsx +7 -3
  16. package/src/cli/index.js +15 -0
  17. package/src/cli/spec.js +14 -0
  18. package/src/components/agents/agent-avatar.tsx +15 -1
  19. package/src/components/agents/agent-card.tsx +1 -0
  20. package/src/components/agents/agent-chat-list.tsx +1 -1
  21. package/src/components/agents/agent-sheet.tsx +112 -26
  22. package/src/components/auth/access-key-gate.tsx +22 -11
  23. package/src/components/chat/chat-area.tsx +2 -2
  24. package/src/components/chat/chat-header.tsx +48 -19
  25. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  26. package/src/components/chat/delegation-banner.test.ts +27 -0
  27. package/src/components/chat/delegation-banner.tsx +109 -23
  28. package/src/components/chat/message-bubble.tsx +14 -3
  29. package/src/components/chat/message-list.tsx +5 -4
  30. package/src/components/chat/streaming-bubble.tsx +3 -2
  31. package/src/components/chat/thinking-indicator.tsx +3 -2
  32. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  33. package/src/components/chat/tool-call-bubble.tsx +13 -1
  34. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  35. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-input.tsx +7 -6
  37. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  38. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  39. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  40. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  41. package/src/components/connectors/connector-list.tsx +1 -1
  42. package/src/components/home/home-view.tsx +2 -1
  43. package/src/components/input/chat-input.tsx +5 -4
  44. package/src/components/knowledge/knowledge-list.tsx +1 -1
  45. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  46. package/src/components/layout/app-layout.tsx +23 -9
  47. package/src/components/logs/log-list.tsx +7 -7
  48. package/src/components/memory/memory-agent-list.tsx +1 -1
  49. package/src/components/memory/memory-browser.tsx +1 -0
  50. package/src/components/memory/memory-card.tsx +3 -2
  51. package/src/components/memory/memory-detail.tsx +3 -3
  52. package/src/components/memory/memory-sheet.tsx +2 -2
  53. package/src/components/projects/project-detail.tsx +4 -4
  54. package/src/components/secrets/secret-sheet.tsx +1 -1
  55. package/src/components/secrets/secrets-list.tsx +1 -1
  56. package/src/components/sessions/new-session-sheet.tsx +4 -3
  57. package/src/components/sessions/session-card.tsx +1 -1
  58. package/src/components/shared/agent-picker-list.tsx +1 -1
  59. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  60. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  61. package/src/components/skills/skill-list.tsx +1 -1
  62. package/src/components/skills/skill-sheet.tsx +1 -1
  63. package/src/components/tasks/task-board.tsx +3 -3
  64. package/src/components/tasks/task-sheet.tsx +21 -1
  65. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  66. package/src/components/wallets/wallet-panel.tsx +616 -0
  67. package/src/components/wallets/wallet-section.tsx +100 -0
  68. package/src/hooks/use-media-query.ts +30 -4
  69. package/src/lib/api-client.ts +6 -18
  70. package/src/lib/fetch-timeout.ts +17 -0
  71. package/src/lib/notification-sounds.ts +4 -4
  72. package/src/lib/safe-storage.ts +42 -0
  73. package/src/lib/server/agent-registry.ts +2 -2
  74. package/src/lib/server/chat-execution.ts +35 -3
  75. package/src/lib/server/chatroom-health.ts +60 -0
  76. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  77. package/src/lib/server/chatroom-helpers.ts +64 -11
  78. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  79. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  80. package/src/lib/server/connectors/manager.ts +80 -2
  81. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  82. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  83. package/src/lib/server/connectors/whatsapp.ts +8 -5
  84. package/src/lib/server/orchestrator-lg.ts +12 -2
  85. package/src/lib/server/orchestrator.ts +6 -1
  86. package/src/lib/server/queue-followups.test.ts +224 -0
  87. package/src/lib/server/queue.ts +226 -24
  88. package/src/lib/server/scheduler.ts +3 -0
  89. package/src/lib/server/session-tools/chatroom.ts +11 -2
  90. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  91. package/src/lib/server/session-tools/index.ts +6 -2
  92. package/src/lib/server/session-tools/memory.ts +1 -1
  93. package/src/lib/server/session-tools/shell.ts +1 -1
  94. package/src/lib/server/session-tools/wallet.ts +124 -0
  95. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  96. package/src/lib/server/session-tools/web-output.ts +16 -0
  97. package/src/lib/server/session-tools/web.ts +7 -3
  98. package/src/lib/server/solana.ts +122 -0
  99. package/src/lib/server/storage.ts +38 -0
  100. package/src/lib/server/stream-agent-chat.ts +126 -63
  101. package/src/lib/server/task-mention.test.ts +41 -0
  102. package/src/lib/server/task-mention.ts +3 -2
  103. package/src/lib/tool-definitions.ts +1 -0
  104. package/src/lib/view-routes.ts +6 -1
  105. package/src/stores/use-app-store.ts +17 -11
  106. package/src/types/index.ts +60 -1
@@ -12,7 +12,7 @@ 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 type { Agent, BoardTask, Connector, Message } from '@/types'
16
16
 
17
17
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
18
18
  const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
@@ -22,6 +22,10 @@ interface SessionMessageLike {
22
22
  text?: string
23
23
  time?: number
24
24
  kind?: 'chat' | 'heartbeat' | 'system' | 'context-clear'
25
+ source?: {
26
+ connectorId?: string
27
+ channelId?: string
28
+ }
25
29
  toolEvents?: Array<{ name?: string; output?: string }>
26
30
  }
27
31
 
@@ -39,6 +43,20 @@ interface ScheduleTaskMeta extends BoardTask {
39
43
  createdByAgentId?: string | null
40
44
  }
41
45
 
46
+ interface RunningConnectorLike {
47
+ id: string
48
+ platform: string
49
+ agentId: string | null
50
+ supportsSend: boolean
51
+ configuredTargets: string[]
52
+ recentChannelId: string | null
53
+ }
54
+
55
+ interface ConnectorTaskFollowupTarget {
56
+ connectorId: string
57
+ channelId: string
58
+ }
59
+
42
60
  function sameReasons(a?: string[] | null, b?: string[] | null): boolean {
43
61
  const av = Array.isArray(a) ? a : []
44
62
  const bv = Array.isArray(b) ? b : []
@@ -164,6 +182,135 @@ function maybeResolveUploadMediaPathFromUrl(url: string | undefined): string | u
164
182
  return fs.existsSync(fullPath) ? fullPath : undefined
165
183
  }
166
184
 
185
+ 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
186
+ 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
187
+ const MAX_CONNECTOR_ATTACHMENT_BYTES = 25 * 1024 * 1024
188
+
189
+ function extractLikelyOutputFiles(text: string): string[] {
190
+ const out: string[] = []
191
+ const seen = new Set<string>()
192
+ const push = (raw: string) => {
193
+ const value = raw.trim().replace(/^['"]|['"]$/g, '')
194
+ if (!value || /^https?:\/\//i.test(value)) return
195
+ if (value.startsWith('/api/uploads/')) return
196
+ const key = value.toLowerCase()
197
+ if (seen.has(key)) return
198
+ seen.add(key)
199
+ out.push(value)
200
+ }
201
+
202
+ for (const match of text.matchAll(OUTPUT_FILE_BACKTICK_RE)) {
203
+ push(match[1] || '')
204
+ if (out.length >= 8) return out
205
+ }
206
+ for (const match of text.matchAll(OUTPUT_FILE_PATH_RE)) {
207
+ push(match[1] || '')
208
+ if (out.length >= 8) return out
209
+ }
210
+
211
+ return out
212
+ }
213
+
214
+ function resolveExistingOutputFilePath(fileRef: string, cwd: string): string | null {
215
+ const ref = (fileRef || '').trim()
216
+ if (!ref) return null
217
+ if (ref.startsWith('/api/uploads/')) {
218
+ return maybeResolveUploadMediaPathFromUrl(ref) || null
219
+ }
220
+ const withoutFileScheme = ref.replace(/^file:\/\//i, '')
221
+ const candidates = path.isAbsolute(withoutFileScheme)
222
+ ? [withoutFileScheme]
223
+ : [
224
+ cwd ? path.resolve(cwd, withoutFileScheme) : '',
225
+ path.resolve(WORKSPACE_DIR, withoutFileScheme),
226
+ ].filter(Boolean)
227
+
228
+ for (const candidate of candidates) {
229
+ try {
230
+ const stat = fs.statSync(candidate)
231
+ if (stat.isFile()) return candidate
232
+ } catch {
233
+ // ignore missing candidate
234
+ }
235
+ }
236
+ return null
237
+ }
238
+
239
+ function isSendableAttachment(filePath: string): boolean {
240
+ try {
241
+ const stat = fs.statSync(filePath)
242
+ return stat.isFile() && stat.size <= MAX_CONNECTOR_ATTACHMENT_BYTES
243
+ } catch {
244
+ return false
245
+ }
246
+ }
247
+
248
+ export function resolveTaskOriginConnectorFollowupTarget(params: {
249
+ task: BoardTask
250
+ sessions: Record<string, SessionLike>
251
+ connectors: Record<string, Connector>
252
+ running: RunningConnectorLike[]
253
+ }): ConnectorTaskFollowupTarget | null {
254
+ const { task, sessions, connectors, running } = params
255
+ const metaTask = task as ScheduleTaskMeta
256
+ const delegatedByAgentId = typeof metaTask.delegatedByAgentId === 'string'
257
+ ? metaTask.delegatedByAgentId.trim()
258
+ : ''
259
+ const sourceSessionId = typeof metaTask.createdInSessionId === 'string'
260
+ ? metaTask.createdInSessionId.trim()
261
+ : ''
262
+ if (!sourceSessionId) return null
263
+ const sourceSession = sessions[sourceSessionId]
264
+ if (!sourceSession || !Array.isArray(sourceSession.messages)) return null
265
+
266
+ const runningById = new Map<string, RunningConnectorLike>()
267
+ for (const entry of running) {
268
+ if (!entry?.id) continue
269
+ runningById.set(entry.id, entry)
270
+ }
271
+
272
+ for (let i = sourceSession.messages.length - 1; i >= 0; i--) {
273
+ const message = sourceSession.messages[i]
274
+ if (!message || message.role !== 'user') continue
275
+
276
+ const connectorId = typeof message.source?.connectorId === 'string'
277
+ ? message.source.connectorId.trim()
278
+ : ''
279
+ if (!connectorId) continue
280
+
281
+ const connector = connectors[connectorId]
282
+ if (!connector) continue
283
+ const ownerId = typeof connector.agentId === 'string' ? connector.agentId.trim() : ''
284
+ if (ownerId) {
285
+ const allowedOwners = new Set([task.agentId, delegatedByAgentId].filter(Boolean))
286
+ if (!allowedOwners.has(ownerId)) continue
287
+ }
288
+
289
+ const runtime = runningById.get(connectorId)
290
+ if (runtime && !runtime.supportsSend) continue
291
+
292
+ const sourceChannel = typeof message.source?.channelId === 'string'
293
+ ? message.source.channelId.trim()
294
+ : ''
295
+ const fallbackChannel = runtime?.recentChannelId
296
+ || runtime?.configuredTargets?.[0]
297
+ || connector.config?.outboundJid
298
+ || connector.config?.outboundTarget
299
+ || ''
300
+ const rawChannel = sourceChannel || fallbackChannel
301
+ if (!rawChannel) continue
302
+
303
+ return {
304
+ connectorId,
305
+ channelId: connector.platform === 'whatsapp'
306
+ ? normalizeWhatsappTarget(rawChannel)
307
+ : rawChannel,
308
+ }
309
+ }
310
+
311
+ return null
312
+ }
313
+
167
314
  // Task result extraction now uses Zod-validated structured data
168
315
  // from ./task-result.ts (extractTaskResult, formatResultBody)
169
316
 
@@ -270,37 +417,67 @@ async function notifyConnectorTaskFollowups(params: {
270
417
  statusLabel: string
271
418
  summaryText: string
272
419
  imageUrl?: string
420
+ mediaPath?: string
421
+ mediaFileName?: string
273
422
  }) {
274
- const { task, statusLabel, summaryText, imageUrl } = params
423
+ const { task, statusLabel, summaryText, imageUrl, mediaPath, mediaFileName } = params
275
424
 
276
425
  const connectors = loadConnectors()
277
426
  const running = (await import('./connectors/manager')).listRunningConnectors()
278
427
  const manager = await import('./connectors/manager')
428
+ const sessions = loadSessions()
279
429
 
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)
430
+ const candidateByKey = new Map<string, ConnectorTaskFollowupTarget>()
431
+ const addCandidate = (candidate: ConnectorTaskFollowupTarget | null | undefined) => {
432
+ if (!candidate?.connectorId || !candidate?.channelId) return
433
+ const key = `${candidate.connectorId}|${candidate.channelId}`
434
+ if (!candidateByKey.has(key)) candidateByKey.set(key, candidate)
435
+ }
436
+
437
+ const originTarget = resolveTaskOriginConnectorFollowupTarget({
438
+ task,
439
+ sessions: sessions as Record<string, SessionLike>,
440
+ connectors,
441
+ running: running as RunningConnectorLike[],
286
442
  })
287
- if (!candidates.length) return
443
+ addCandidate(originTarget)
444
+ const preferredTargetKey = originTarget
445
+ ? `${originTarget.connectorId}|${originTarget.channelId}`
446
+ : ''
288
447
 
289
- const summary = summaryText.trim().slice(0, 1400)
290
- for (const candidate of candidates) {
291
- const connector = connectors[candidate.id]
448
+ for (const entry of running) {
449
+ if (!entry.supportsSend || !entry.id) continue
450
+ const connector = connectors[entry.id]
292
451
  if (!connector) continue
293
-
294
- const channelTargetRaw = candidate.recentChannelId
295
- || candidate.configuredTargets[0]
452
+ if (connector.agentId !== task.agentId) continue
453
+ if (!isEnabledFlag(connector.config?.taskFollowups)) continue
454
+ const channelTargetRaw = entry.recentChannelId
455
+ || entry.configuredTargets[0]
296
456
  || connector.config?.outboundJid
297
457
  || connector.config?.outboundTarget
298
458
  || ''
299
459
  if (!channelTargetRaw) continue
460
+ addCandidate({
461
+ connectorId: entry.id,
462
+ channelId: connector.platform === 'whatsapp'
463
+ ? normalizeWhatsappTarget(channelTargetRaw)
464
+ : channelTargetRaw,
465
+ })
466
+ }
467
+ const targets = [...candidateByKey.values()].sort((a, b) => {
468
+ if (!preferredTargetKey) return 0
469
+ const aKey = `${a.connectorId}|${a.channelId}`
470
+ const bKey = `${b.connectorId}|${b.channelId}`
471
+ if (aKey === preferredTargetKey && bKey !== preferredTargetKey) return -1
472
+ if (bKey === preferredTargetKey && aKey !== preferredTargetKey) return 1
473
+ return 0
474
+ })
475
+ if (!targets.length) return
300
476
 
301
- const channelId = connector.platform === 'whatsapp'
302
- ? normalizeWhatsappTarget(channelTargetRaw)
303
- : channelTargetRaw
477
+ const summary = summaryText.trim().slice(0, 1400)
478
+ for (const target of targets) {
479
+ const connector = connectors[target.connectorId]
480
+ if (!connector) continue
304
481
 
305
482
  const template = typeof connector.config?.taskFollowupTemplate === 'string'
306
483
  ? connector.config.taskFollowupTemplate.trim()
@@ -316,23 +493,29 @@ async function notifyConnectorTaskFollowups(params: {
316
493
  `Task ${statusLabel}: ${task.title}`,
317
494
  summary || 'No summary provided.',
318
495
  ].join('\n\n')
496
+ const targetKey = `${target.connectorId}|${target.channelId}`
497
+ const preferredChannelNote = !template && preferredTargetKey && targetKey === preferredTargetKey
498
+ ? '\n\n(Update sent in the same channel that requested this task.)'
499
+ : ''
500
+ const outboundMessage = `${message}${preferredChannelNote}`
319
501
 
320
- const resolvedMediaPath = maybeResolveUploadMediaPathFromUrl(imageUrl)
502
+ const resolvedMediaPath = mediaPath || maybeResolveUploadMediaPathFromUrl(imageUrl)
321
503
  try {
322
504
  await manager.sendConnectorMessage({
323
- connectorId: candidate.id,
324
- channelId,
325
- text: message,
505
+ connectorId: target.connectorId,
506
+ channelId: target.channelId,
507
+ text: outboundMessage,
326
508
  ...(resolvedMediaPath
327
509
  ? {
328
510
  mediaPath: resolvedMediaPath,
329
- caption: message,
511
+ fileName: mediaFileName || path.basename(resolvedMediaPath),
512
+ caption: outboundMessage,
330
513
  }
331
514
  : {}),
332
515
  })
333
516
  } catch (err: unknown) {
334
517
  const errMsg = err instanceof Error ? err.message : String(err)
335
- console.warn(`[queue] Failed task follow-up send on connector ${candidate.id}: ${errMsg}`)
518
+ console.warn(`[queue] Failed task follow-up send on connector ${target.connectorId}: ${errMsg}`)
336
519
  }
337
520
  }
338
521
  }
@@ -358,10 +541,16 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
358
541
  { sinceTime: typeof task.startedAt === 'number' ? task.startedAt : null },
359
542
  )
360
543
  const resultBody = formatResultBody(taskResult)
544
+ const outputFileRefs = Array.isArray(task.outputFiles) && task.outputFiles.length > 0
545
+ ? task.outputFiles
546
+ : extractLikelyOutputFiles(resultBody)
361
547
 
362
548
  const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
363
549
  const taskLink = `[${task.title}](#task:${task.id})`
364
550
  const firstImage = taskResult.artifacts.find((a) => a.type === 'image')
551
+ const firstArtifactMediaPath = taskResult.artifacts
552
+ .map((artifact) => maybeResolveUploadMediaPathFromUrl(artifact.url))
553
+ .find((candidate): candidate is string => Boolean(candidate))
365
554
  const now = Date.now()
366
555
  let changed = false
367
556
 
@@ -377,6 +566,11 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
377
566
 
378
567
  // Get working directory from execution session
379
568
  const execCwd = runSession?.cwd || ''
569
+ const existingOutputPaths = outputFileRefs
570
+ .map((fileRef) => resolveExistingOutputFilePath(fileRef, execCwd))
571
+ .filter((candidate): candidate is string => Boolean(candidate))
572
+ const firstLocalOutputPath = existingOutputPaths.find((candidate) => isSendableAttachment(candidate))
573
+ const followupMediaPath = firstArtifactMediaPath || firstLocalOutputPath || undefined
380
574
 
381
575
  const buildMsg = (text: string): Message => {
382
576
  const msg: Message = { role: 'assistant', text, time: now, kind: 'system' }
@@ -387,6 +581,10 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
387
581
  const buildResultBlock = (prefix: string): string => {
388
582
  const parts = [prefix]
389
583
  if (execCwd) parts.push(`Working directory: \`${execCwd}\``)
584
+ if (outputFileRefs.length > 0) {
585
+ parts.push(`Output files:\n${outputFileRefs.slice(0, 8).map((fileRef) => `- \`${fileRef}\``).join('\n')}`)
586
+ }
587
+ if (task.completionReportPath) parts.push(`Task report: \`${task.completionReportPath}\``)
390
588
  if (resumeLines.length > 0) parts.push(resumeLines.join(' | '))
391
589
  parts.push(resultBody || 'No summary.')
392
590
  return parts.join('\n\n')
@@ -449,6 +647,8 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
449
647
  statusLabel,
450
648
  summaryText: resultBody || '',
451
649
  imageUrl: firstImage?.url,
650
+ mediaPath: followupMediaPath,
651
+ mediaFileName: followupMediaPath ? path.basename(followupMediaPath) : undefined,
452
652
  })
453
653
  }
454
654
 
@@ -812,6 +1012,8 @@ export async function processNext() {
812
1012
  )
813
1013
  const enrichedResult = formatResultBody(taskResult)
814
1014
  t2[taskId].result = enrichedResult.slice(0, 4000) || null
1015
+ t2[taskId].artifacts = taskResult.artifacts.slice(0, 24)
1016
+ t2[taskId].outputFiles = extractLikelyOutputFiles(enrichedResult).slice(0, 24)
815
1017
  t2[taskId].updatedAt = Date.now()
816
1018
  const report = ensureTaskCompletionReport(t2[taskId])
817
1019
  if (report?.relativePath) t2[taskId].completionReportPath = report.relativePath
@@ -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
@@ -13,7 +13,7 @@ export function buildChatroomTools(bctx: ToolBuildContext): StructuredToolInterf
13
13
  if (hasTool('manage_chatrooms')) {
14
14
  tools.push(
15
15
  tool(
16
- async ({ action, chatroomId, name, description, agentIds, agentId, message }) => {
16
+ async ({ action, chatroomId, name, description, agentIds, agentId, message, chatMode, autoAddress }) => {
17
17
  try {
18
18
  const chatrooms = loadChatrooms() as Record<string, Chatroom>
19
19
 
@@ -31,13 +31,20 @@ export function buildChatroomTools(bctx: ToolBuildContext): StructuredToolInterf
31
31
  if (action === 'create_chatroom') {
32
32
  const id = genId()
33
33
  const agents = loadAgents()
34
- const validAgentIds = (agentIds || []).filter((aid: string) => agents[aid])
34
+ const requestedAgentIds = agentIds || []
35
+ const invalidAgentIds = requestedAgentIds.filter((aid: string) => !agents[aid])
36
+ if (invalidAgentIds.length > 0) {
37
+ return `Error: unknown agent IDs: ${invalidAgentIds.join(', ')}`
38
+ }
39
+ const validAgentIds = requestedAgentIds
35
40
  const chatroom: Chatroom = {
36
41
  id,
37
42
  name: name || 'New Chatroom',
38
43
  description: description || '',
39
44
  agentIds: validAgentIds,
40
45
  messages: [],
46
+ chatMode: chatMode === 'parallel' ? 'parallel' : 'sequential',
47
+ autoAddress: Boolean(autoAddress),
41
48
  createdAt: Date.now(),
42
49
  updatedAt: Date.now(),
43
50
  }
@@ -124,6 +131,8 @@ export function buildChatroomTools(bctx: ToolBuildContext): StructuredToolInterf
124
131
  name: z.string().optional().describe('Chatroom name (for create_chatroom)'),
125
132
  description: z.string().optional().describe('Chatroom description (for create_chatroom)'),
126
133
  agentIds: z.array(z.string()).optional().describe('Initial agent IDs (for create_chatroom)'),
134
+ chatMode: z.enum(['sequential', 'parallel']).optional().describe('Optional orchestration mode for create_chatroom'),
135
+ autoAddress: z.boolean().optional().describe('Whether to auto-address all members when no @mention is present'),
127
136
  agentId: z.string().optional().describe('Agent ID (for add_agent/remove_agent)'),
128
137
  message: z.string().optional().describe('Message text (for send_message)'),
129
138
  }),
@@ -28,7 +28,7 @@ export function buildContextTools(bctx: ToolBuildContext): StructuredToolInterfa
28
28
  },
29
29
  {
30
30
  name: 'context_status',
31
- description: 'Check current context window usage for this session. Returns estimated tokens used, provider context limit, percentage used, and compaction strategy recommendation.',
31
+ description: 'Check how much of my context window I\'ve used. Returns my token usage, the model\'s limit, percentage used, and whether I should compact.',
32
32
  schema: z.object({}),
33
33
  },
34
34
  ),
@@ -108,7 +108,7 @@ export function buildContextTools(bctx: ToolBuildContext): StructuredToolInterfa
108
108
  },
109
109
  {
110
110
  name: 'context_summarize',
111
- description: 'Summarize and compact the conversation history to free context window space. Old messages are consolidated to memory (preserving decisions, key facts, results) and replaced with a summary. Use context_status first to check if compaction is needed.',
111
+ description: 'Compact my conversation history to free up context space. I\'ll save important decisions, facts, and results to memory, then replace older messages with a summary. I should check context_status first to see if this is needed.',
112
112
  schema: z.object({
113
113
  keepLastN: z.number().optional().describe('Number of recent messages to keep (default 10, min 2).'),
114
114
  }),
@@ -21,6 +21,7 @@ import { buildSubagentTools } from './subagent'
21
21
  import { buildCanvasTools } from './canvas'
22
22
  import { buildHttpTools } from './http'
23
23
  import { buildGitTools } from './git'
24
+ import { buildWalletTools } from './wallet'
24
25
 
25
26
  export type { ToolContext, SessionToolsResult }
26
27
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -34,7 +35,9 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
34
35
  const cliProcessTimeoutMs = runtime.cliProcessTimeoutMs
35
36
  const appSettings = loadSettings()
36
37
  const toolPolicy = resolveSessionToolPolicy(enabledTools, appSettings)
37
- const activeTools = toolPolicy.enabledTools
38
+ const activeTools = toolPolicy.enabledTools.includes('shell') && !toolPolicy.enabledTools.includes('process')
39
+ ? [...toolPolicy.enabledTools, 'process']
40
+ : toolPolicy.enabledTools
38
41
  const hasTool = (toolName: string) => activeTools.includes(toolName)
39
42
 
40
43
  if (toolPolicy.blockedTools.length > 0) {
@@ -107,6 +110,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
107
110
  ...buildCanvasTools(bctx),
108
111
  ...buildHttpTools(bctx),
109
112
  ...buildGitTools(bctx),
113
+ ...buildWalletTools(bctx),
110
114
  )
111
115
 
112
116
  // ---------------------------------------------------------------------------
@@ -158,7 +162,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
158
162
  },
159
163
  {
160
164
  name: 'request_tool_access',
161
- description: 'Request access to a tool that is currently disabled. The user will be prompted to grant access, and a follow-up "Continue" message will be sent automatically once granted. End your current response after calling this — do NOT tell the user to "let you know" or ask them to confirm; the continuation is automatic.',
165
+ description: 'Ask the user for access to a tool I don\'t currently have. They\'ll get a prompt to grant it, and once they do, I\'ll automatically continue where I left off. I should end my current response after calling this — no need to ask the user to confirm, it happens on its own.',
162
166
  schema: z.object({
163
167
  toolId: z.string().describe('The tool ID to request access for (e.g. manage_tasks, shell, claude_code)'),
164
168
  reason: z.string().describe('Brief explanation of why you need this tool'),
@@ -187,7 +187,7 @@ export function buildMemoryTools(bctx: ToolBuildContext): StructuredToolInterfac
187
187
  },
188
188
  {
189
189
  name: 'memory_tool',
190
- description: 'Store and retrieve long-term memories that persist across sessions. Memories can be shared or agent-scoped. Supports file references, image attachments, and linking memories together with depth traversal. Also supports a cross-agent knowledge base via "knowledge_store" and "knowledge_search". Use "store", "get", "search", "list", "delete", "link", "unlink", "knowledge_store", or "knowledge_search".',
190
+ description: 'My long-term memory things I remember across conversations. I can store personal notes, recall past context, and build up knowledge over time. Memories can be private to me or shared with other agents. I can also attach files, link related memories, and contribute to a shared knowledge base. Actions: store, get, search, list, delete, link, unlink, knowledge_store, knowledge_search.',
191
191
  schema: z.object({
192
192
  action: z.enum(['store', 'get', 'search', 'list', 'delete', 'link', 'unlink', 'knowledge_store', 'knowledge_search']).describe('The action to perform'),
193
193
  key: z.string().describe('For store: memory title. For get/delete/link/unlink: memory ID. For search: optional query fallback.'),
@@ -49,7 +49,7 @@ export function buildShellTools(bctx: ToolBuildContext): StructuredToolInterface
49
49
  },
50
50
  {
51
51
  name: 'execute_command',
52
- description: 'Execute a shell command in the session working directory. This is the PRIMARY tool for running servers, dev servers, installing packages, running scripts, git operations, and any command the user wants to run or test. Use background=true for long-running processes like servers. Supports timeout/yield controls.',
52
+ description: 'Run a shell command in my working directory. This is how I run servers, install packages, execute scripts, use git, and do anything hands-on. Use background=true for long-running processes like dev servers. Supports timeout/yield controls.',
53
53
  schema: z.object({
54
54
  command: z.string().describe('The shell command to execute'),
55
55
  background: z.boolean().optional().describe('If true, start command in background immediately'),
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import type { ToolBuildContext } from './context'
4
+ import { loadWallets, loadWalletTransactions } from '../storage'
5
+ import type { AgentWallet, WalletTransaction } from '@/types'
6
+
7
+ export function buildWalletTools(bctx: ToolBuildContext): StructuredToolInterface[] {
8
+ if (!bctx.hasTool('wallet')) return []
9
+
10
+ const agentId = bctx.ctx?.agentId
11
+
12
+ function getAgentWallet(): AgentWallet | null {
13
+ if (!agentId) return null
14
+ const wallets = loadWallets() as Record<string, AgentWallet>
15
+ return Object.values(wallets).find((w) => w.agentId === agentId) ?? null
16
+ }
17
+
18
+ return [
19
+ tool(
20
+ async ({ action, toAddress, amountSol, memo, limit }) => {
21
+ const wallet = getAgentWallet()
22
+ if (!wallet) {
23
+ return JSON.stringify({ error: 'No wallet linked to this agent. Ask the user to create one in the Wallets section.' })
24
+ }
25
+
26
+ switch (action) {
27
+ case 'balance': {
28
+ try {
29
+ const { getBalance, lamportsToSol } = await import('../solana')
30
+ const balanceLamports = await getBalance(wallet.publicKey)
31
+ return JSON.stringify({
32
+ address: wallet.publicKey,
33
+ chain: wallet.chain,
34
+ balanceLamports,
35
+ balanceSol: lamportsToSol(balanceLamports),
36
+ })
37
+ } catch (err: unknown) {
38
+ return JSON.stringify({ error: `Failed to fetch balance: ${err instanceof Error ? err.message : String(err)}` })
39
+ }
40
+ }
41
+
42
+ case 'address': {
43
+ return JSON.stringify({
44
+ address: wallet.publicKey,
45
+ chain: wallet.chain,
46
+ })
47
+ }
48
+
49
+ case 'send': {
50
+ if (!toAddress) return JSON.stringify({ error: 'toAddress is required for send action' })
51
+ if (!amountSol || amountSol <= 0) return JSON.stringify({ error: 'amountSol must be positive' })
52
+
53
+ const { isValidSolanaAddress, solToLamports, lamportsToSol } = await import('../solana')
54
+ if (!isValidSolanaAddress(toAddress)) {
55
+ return JSON.stringify({ error: 'Invalid Solana address' })
56
+ }
57
+
58
+ const amountLamports = solToLamports(amountSol)
59
+
60
+ // Check per-tx limit
61
+ const perTxLimit = wallet.spendingLimitLamports ?? 100_000_000
62
+ if (amountLamports > perTxLimit) {
63
+ return JSON.stringify({
64
+ error: `Amount ${amountSol} SOL exceeds per-transaction limit of ${lamportsToSol(perTxLimit)} SOL`,
65
+ })
66
+ }
67
+
68
+ // Send via API to enforce all limits and approval flow
69
+ try {
70
+ const baseUrl = process.env.NEXTAUTH_URL || `http://localhost:${process.env.PORT || 3456}`
71
+ const res = await fetch(`${baseUrl}/api/wallets/${wallet.id}/send`, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'X-Access-Key': process.env.ACCESS_KEY || '',
76
+ },
77
+ body: JSON.stringify({ toAddress, amountLamports, memo }),
78
+ })
79
+ const result = await res.json()
80
+ return JSON.stringify(result)
81
+ } catch (err: unknown) {
82
+ return JSON.stringify({ error: `Send failed: ${err instanceof Error ? err.message : String(err)}` })
83
+ }
84
+ }
85
+
86
+ case 'transactions': {
87
+ const allTxs = loadWalletTransactions() as Record<string, WalletTransaction>
88
+ const walletTxs = Object.values(allTxs)
89
+ .filter((tx) => tx.walletId === wallet.id)
90
+ .sort((a, b) => b.timestamp - a.timestamp)
91
+ .slice(0, limit ?? 10)
92
+ .map((tx) => ({
93
+ id: tx.id,
94
+ type: tx.type,
95
+ status: tx.status,
96
+ amountLamports: tx.amountLamports,
97
+ toAddress: tx.toAddress,
98
+ fromAddress: tx.fromAddress,
99
+ signature: tx.signature || undefined,
100
+ memo: tx.memo,
101
+ timestamp: tx.timestamp,
102
+ }))
103
+
104
+ return JSON.stringify({ transactions: walletTxs, count: walletTxs.length })
105
+ }
106
+
107
+ default:
108
+ return JSON.stringify({ error: `Unknown action: ${action}. Use balance, address, send, or transactions.` })
109
+ }
110
+ },
111
+ {
112
+ name: 'wallet_tool',
113
+ description: 'Manage your own crypto wallet. Actions: balance (check your SOL balance), address (get your wallet address), send (send SOL from your wallet — subject to your spending limits and user approval), transactions (view your recent transaction history).',
114
+ schema: z.object({
115
+ action: z.enum(['balance', 'address', 'send', 'transactions']).describe('Wallet action to perform'),
116
+ toAddress: z.string().optional().describe('Recipient Solana address (required for send)'),
117
+ amountSol: z.number().optional().describe('Amount in SOL to send (required for send)'),
118
+ memo: z.string().optional().describe('Reason or memo for the transaction'),
119
+ limit: z.number().optional().describe('Number of transactions to return (default 10, for transactions action)'),
120
+ }),
121
+ },
122
+ ),
123
+ ]
124
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { dedupeScreenshotMarkdownLines } from './web-output'
4
+
5
+ describe('dedupeScreenshotMarkdownLines', () => {
6
+ it('prefers screenshot-* image when both browser-* and screenshot-* variants are present', () => {
7
+ const parts = [
8
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
9
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
10
+ 'Saved to: example_screenshot.png',
11
+ ]
12
+
13
+ const next = dedupeScreenshotMarkdownLines(parts)
14
+ assert.deepEqual(next, [
15
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
16
+ 'Saved to: example_screenshot.png',
17
+ ])
18
+ })
19
+
20
+ it('keeps single image output untouched', () => {
21
+ const parts = [
22
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
23
+ 'Saved to: example_screenshot.png',
24
+ ]
25
+
26
+ const next = dedupeScreenshotMarkdownLines(parts)
27
+ assert.deepEqual(next, parts)
28
+ })
29
+ })
@@ -0,0 +1,16 @@
1
+ export function dedupeScreenshotMarkdownLines(parts: string[]): string[] {
2
+ const imageLineRe = /^!\[[^\]]*]\(\/api\/uploads\/([^)]+)\)$/
3
+ const imageLines = parts
4
+ .map((line, index) => ({ line: line.trim(), index }))
5
+ .map((entry) => {
6
+ const match = entry.line.match(imageLineRe)
7
+ return match ? { ...entry, filename: match[1] } : null
8
+ })
9
+ .filter((entry): entry is { line: string; index: number; filename: string } => !!entry)
10
+
11
+ if (imageLines.length <= 1) return parts
12
+
13
+ const preferred = imageLines.find((entry) => !entry.filename.startsWith('browser-')) || imageLines[0]
14
+ const keepIndex = preferred.index
15
+ return parts.filter((_, index) => !imageLines.some((entry) => entry.index === index) || index === keepIndex)
16
+ }