@swarmclawai/swarmclaw 0.6.0 → 0.6.3

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 (118) hide show
  1. package/README.md +56 -42
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +16 -35
  15. package/src/app/api/tts/stream/route.ts +14 -42
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +76 -24
  31. package/src/components/chat/chat-header.tsx +522 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +113 -8
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +84 -17
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +125 -14
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  78. package/src/lib/server/connectors/discord.ts +31 -8
  79. package/src/lib/server/connectors/manager.ts +594 -16
  80. package/src/lib/server/connectors/media.ts +5 -0
  81. package/src/lib/server/connectors/telegram.ts +12 -2
  82. package/src/lib/server/connectors/types.ts +2 -0
  83. package/src/lib/server/connectors/whatsapp.ts +28 -2
  84. package/src/lib/server/elevenlabs.test.ts +60 -0
  85. package/src/lib/server/elevenlabs.ts +103 -0
  86. package/src/lib/server/heartbeat-service.ts +8 -1
  87. package/src/lib/server/main-agent-loop.ts +1 -1
  88. package/src/lib/server/memory-consolidation.ts +15 -2
  89. package/src/lib/server/memory-db.ts +134 -6
  90. package/src/lib/server/mime.ts +51 -0
  91. package/src/lib/server/openclaw-gateway.ts +2 -2
  92. package/src/lib/server/orchestrator-lg.ts +2 -0
  93. package/src/lib/server/orchestrator.ts +5 -2
  94. package/src/lib/server/playwright-proxy.mjs +2 -3
  95. package/src/lib/server/prompt-runtime-context.ts +53 -0
  96. package/src/lib/server/queue.ts +182 -8
  97. package/src/lib/server/session-tools/canvas.ts +67 -0
  98. package/src/lib/server/session-tools/connector.ts +583 -63
  99. package/src/lib/server/session-tools/crud.ts +21 -0
  100. package/src/lib/server/session-tools/delegate.ts +68 -4
  101. package/src/lib/server/session-tools/file.ts +26 -7
  102. package/src/lib/server/session-tools/git.ts +71 -0
  103. package/src/lib/server/session-tools/http.ts +57 -0
  104. package/src/lib/server/session-tools/index.ts +8 -0
  105. package/src/lib/server/session-tools/memory.ts +1 -0
  106. package/src/lib/server/session-tools/search-providers.ts +16 -8
  107. package/src/lib/server/session-tools/subagent.ts +106 -0
  108. package/src/lib/server/session-tools/web.ts +118 -8
  109. package/src/lib/server/stream-agent-chat.ts +39 -10
  110. package/src/lib/server/task-mention.ts +41 -0
  111. package/src/lib/sessions.ts +10 -0
  112. package/src/lib/soul-library.ts +103 -0
  113. package/src/lib/task-dedupe.ts +26 -0
  114. package/src/lib/tool-definitions.ts +2 -0
  115. package/src/lib/tts.ts +2 -2
  116. package/src/stores/use-app-store.ts +5 -1
  117. package/src/stores/use-chat-store.ts +65 -2
  118. package/src/types/index.ts +32 -2
@@ -160,8 +160,8 @@ export class OpenClawGateway {
160
160
  this.doConnect().catch(() => {})
161
161
  }, this.reconnectDelay)
162
162
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, maxDelay)
163
- if (this.consecutiveFailures % 5 === 0) {
164
- console.log(`[openclaw-gateway] ${this.consecutiveFailures} consecutive failures, next retry in ${Math.round(this.reconnectDelay / 1000)}s`)
163
+ if (this.consecutiveFailures === 1 || this.consecutiveFailures % 5 === 0) {
164
+ console.log(`[openclaw-gateway] ${this.consecutiveFailures} consecutive failure${this.consecutiveFailures > 1 ? 's' : ''}, next retry in ${Math.round(this.reconnectDelay / 1000)}s`)
165
165
  }
166
166
  }
167
167
 
@@ -11,6 +11,7 @@ import { buildChatModel } from './build-llm'
11
11
  import { getCheckpointSaver } from './langgraph-checkpoint'
12
12
  import { notify } from './ws-hub'
13
13
  import { pushMainLoopEventToMainSessions } from './main-agent-loop'
14
+ import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
14
15
  import { genId } from '@/lib/id'
15
16
  import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
16
17
  import type { Agent, TaskComment, MessageToolEvent } from '@/types'
@@ -351,6 +352,7 @@ export async function executeLangGraphOrchestrator(
351
352
  const settings = loadSettings()
352
353
  const promptParts: string[] = []
353
354
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
355
+ promptParts.push(buildCurrentDateTimePromptContext())
354
356
  if (orchestrator.soul) promptParts.push(orchestrator.soul)
355
357
  if (orchestrator.systemPrompt) promptParts.push(orchestrator.systemPrompt)
356
358
  // Inject dynamic skills
@@ -6,6 +6,7 @@ import {
6
6
  import { WORKSPACE_DIR } from './data-dir'
7
7
  import { loadRuntimeSettings, getLegacyOrchestratorMaxTurns } from './runtime-settings'
8
8
  import { getMemoryDb } from './memory-db'
9
+ import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
9
10
  import { getProvider } from '../providers'
10
11
  import type { Agent } from '@/types'
11
12
 
@@ -109,6 +110,7 @@ async function executeOrchestratorLegacy(
109
110
  const settings = loadSettings()
110
111
  const promptParts: string[] = []
111
112
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
113
+ promptParts.push(buildCurrentDateTimePromptContext())
112
114
  if (orchestrator.soul) promptParts.push(orchestrator.soul)
113
115
  if (orchestrator.systemPrompt) promptParts.push(orchestrator.systemPrompt)
114
116
  if (orchestrator.skillIds?.length) {
@@ -308,8 +310,8 @@ async function executeSubTask(
308
310
 
309
311
  export async function callProvider(
310
312
  agent: Agent,
311
- systemPrompt: string,
312
- history: { role: string; text: string }[],
313
+ systemPrompt?: string,
314
+ history: { role: string; text: string }[] = [],
313
315
  ): Promise<string> {
314
316
  const provider = getProvider(agent.provider)
315
317
  if (!provider) throw new Error(`Unknown provider: ${agent.provider}`)
@@ -346,6 +348,7 @@ export async function callProvider(
346
348
  session: mockSession,
347
349
  message: history[history.length - 1].text,
348
350
  apiKey,
351
+ systemPrompt,
349
352
  write: (data: string) => {
350
353
  // Parse SSE data to extract text
351
354
  if (data.startsWith('data: ')) {
@@ -7,9 +7,8 @@
7
7
  import { spawn } from 'child_process'
8
8
  import fs from 'fs'
9
9
  import path from 'path'
10
- import os from 'os'
11
10
 
12
- const UPLOAD_DIR = process.env.SWARMCLAW_UPLOAD_DIR || path.join(os.tmpdir(), 'swarmclaw-uploads')
11
+ const UPLOAD_DIR = process.env.SWARMCLAW_UPLOAD_DIR || path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'uploads')
13
12
  if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
14
13
 
15
14
  const child = spawn('npx', ['@playwright/mcp@latest'], {
@@ -47,7 +46,7 @@ child.stdout.on('data', (chunk) => {
47
46
  fs.writeFileSync(path.join(UPLOAD_DIR, filename), Buffer.from(block.data, 'base64'))
48
47
  newContent.push({
49
48
  type: 'text',
50
- text: `Screenshot saved. Show it to the user with this markdown: ![Screenshot](/api/uploads/${filename})`,
49
+ text: `Screenshot saved to /api/uploads/${filename} — it is already displayed inline above (do not repeat it with markdown).`,
51
50
  })
52
51
  newContent.push(block) // keep image so Claude can see it
53
52
  } else {
@@ -0,0 +1,53 @@
1
+ function resolveLocalTimezone(): string {
2
+ try {
3
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
4
+ } catch {
5
+ return 'UTC'
6
+ }
7
+ }
8
+
9
+ function formatDateTimeInTimezone(date: Date, timezone: string): string | null {
10
+ try {
11
+ return new Intl.DateTimeFormat('en-US', {
12
+ weekday: 'long',
13
+ year: 'numeric',
14
+ month: 'long',
15
+ day: 'numeric',
16
+ hour: '2-digit',
17
+ minute: '2-digit',
18
+ second: '2-digit',
19
+ hour12: false,
20
+ timeZone: timezone,
21
+ timeZoneName: 'short',
22
+ }).format(date)
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ export function buildCurrentDateTimePromptContext(preferredTimezone?: string | null): string {
29
+ const now = new Date()
30
+ const utcIso = now.toISOString()
31
+ const utcFormatted = formatDateTimeInTimezone(now, 'UTC') || utcIso
32
+ const localTimezone = resolveLocalTimezone()
33
+ const requestedTimezone = (preferredTimezone || '').trim()
34
+ const chosenTimezone = requestedTimezone || localTimezone
35
+ const chosenFormatted = formatDateTimeInTimezone(now, chosenTimezone)
36
+
37
+ const lines = [
38
+ '## Runtime Date/Time Context',
39
+ `- Current timestamp (UTC): ${utcIso}`,
40
+ `- Current date/time (UTC): ${utcFormatted}`,
41
+ ]
42
+
43
+ if (chosenFormatted) {
44
+ lines.push(`- Current date/time (${chosenTimezone}): ${chosenFormatted}`)
45
+ } else if (requestedTimezone) {
46
+ lines.push(`- Requested timezone "${requestedTimezone}" could not be resolved. Use UTC time above.`)
47
+ }
48
+
49
+ lines.push('- Treat these as authoritative for terms like "today", "yesterday", "tomorrow", and "recent".')
50
+ lines.push('- For time-sensitive answers, use explicit dates (for example, "March 2, 2026").')
51
+
52
+ return lines.join('\n')
53
+ }
@@ -1,5 +1,7 @@
1
1
  import { genId } from '@/lib/id'
2
- import { loadTasks, saveTasks, loadQueue, saveQueue, loadAgents, loadSchedules, saveSchedules, loadSessions, saveSessions, loadSettings } from './storage'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { loadTasks, saveTasks, loadQueue, saveQueue, loadAgents, loadSchedules, saveSchedules, loadSessions, saveSessions, loadSettings, loadConnectors, UPLOAD_DIR } from './storage'
3
5
  import { notify } from './ws-hub'
4
6
  import { WORKSPACE_DIR } from './data-dir'
5
7
  import { createOrchestratorSession, executeOrchestrator } from './orchestrator'
@@ -13,13 +15,13 @@ import { isProtectedMainSession } from './main-session'
13
15
  import type { Agent, BoardTask, Message } from '@/types'
14
16
 
15
17
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
16
- const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false }) as { processing: boolean }
18
+ const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
17
19
 
18
20
  interface SessionMessageLike {
19
21
  role?: string
20
22
  text?: string
21
23
  time?: number
22
- kind?: 'chat' | 'heartbeat' | 'system'
24
+ kind?: 'chat' | 'heartbeat' | 'system' | 'context-clear'
23
25
  toolEvents?: Array<{ name?: string; output?: string }>
24
26
  }
25
27
 
@@ -114,6 +116,54 @@ function latestAssistantText(session: SessionLike | null | undefined): string {
114
116
  return ''
115
117
  }
116
118
 
119
+ function isEnabledFlag(value: unknown): boolean {
120
+ if (typeof value === 'boolean') return value
121
+ if (typeof value !== 'string') return false
122
+ const normalized = value.trim().toLowerCase()
123
+ return normalized === '1'
124
+ || normalized === 'true'
125
+ || normalized === 'yes'
126
+ || normalized === 'on'
127
+ || normalized === 'enabled'
128
+ }
129
+
130
+ function normalizeWhatsappTarget(raw: string): string {
131
+ const trimmed = raw.trim()
132
+ if (!trimmed) return trimmed
133
+ if (trimmed.includes('@')) return trimmed
134
+ let cleaned = trimmed.replace(/[^\d+]/g, '')
135
+ if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
136
+ if (cleaned.startsWith('0') && cleaned.length >= 10) {
137
+ cleaned = `44${cleaned.slice(1)}`
138
+ }
139
+ cleaned = cleaned.replace(/[^\d]/g, '')
140
+ return cleaned ? `${cleaned}@s.whatsapp.net` : trimmed
141
+ }
142
+
143
+ function fillTaskFollowupTemplate(template: string, data: {
144
+ status: string
145
+ title: string
146
+ summary: string
147
+ taskId: string
148
+ }): string {
149
+ return template
150
+ .replaceAll('{status}', data.status)
151
+ .replaceAll('{title}', data.title)
152
+ .replaceAll('{summary}', data.summary)
153
+ .replaceAll('{taskId}', data.taskId)
154
+ }
155
+
156
+ function maybeResolveUploadMediaPathFromUrl(url: string | undefined): string | undefined {
157
+ if (!url || !url.startsWith('/api/uploads/')) return undefined
158
+ const rawName = url.slice('/api/uploads/'.length).split(/[?#]/)[0] || ''
159
+ let decoded: string
160
+ try { decoded = decodeURIComponent(rawName) } catch { decoded = rawName }
161
+ const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
162
+ if (!safeName) return undefined
163
+ const fullPath = path.join(UPLOAD_DIR, safeName)
164
+ return fs.existsSync(fullPath) ? fullPath : undefined
165
+ }
166
+
117
167
  // Task result extraction now uses Zod-validated structured data
118
168
  // from ./task-result.ts (extractTaskResult, formatResultBody)
119
169
 
@@ -215,6 +265,78 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
215
265
  if (changed) saveSessions(sessions)
216
266
  }
217
267
 
268
+ async function notifyConnectorTaskFollowups(params: {
269
+ task: BoardTask
270
+ statusLabel: string
271
+ summaryText: string
272
+ imageUrl?: string
273
+ }) {
274
+ const { task, statusLabel, summaryText, imageUrl } = params
275
+
276
+ const connectors = loadConnectors()
277
+ const running = (await import('./connectors/manager')).listRunningConnectors()
278
+ const manager = await import('./connectors/manager')
279
+
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)
286
+ })
287
+ if (!candidates.length) return
288
+
289
+ const summary = summaryText.trim().slice(0, 1400)
290
+ for (const candidate of candidates) {
291
+ const connector = connectors[candidate.id]
292
+ if (!connector) continue
293
+
294
+ const channelTargetRaw = candidate.recentChannelId
295
+ || candidate.configuredTargets[0]
296
+ || connector.config?.outboundJid
297
+ || connector.config?.outboundTarget
298
+ || ''
299
+ if (!channelTargetRaw) continue
300
+
301
+ const channelId = connector.platform === 'whatsapp'
302
+ ? normalizeWhatsappTarget(channelTargetRaw)
303
+ : channelTargetRaw
304
+
305
+ const template = typeof connector.config?.taskFollowupTemplate === 'string'
306
+ ? connector.config.taskFollowupTemplate.trim()
307
+ : ''
308
+ const message = template
309
+ ? fillTaskFollowupTemplate(template, {
310
+ status: statusLabel,
311
+ title: task.title || task.id,
312
+ summary,
313
+ taskId: task.id,
314
+ })
315
+ : [
316
+ `Task ${statusLabel}: ${task.title}`,
317
+ summary || 'No summary provided.',
318
+ ].join('\n\n')
319
+
320
+ const resolvedMediaPath = maybeResolveUploadMediaPathFromUrl(imageUrl)
321
+ try {
322
+ await manager.sendConnectorMessage({
323
+ connectorId: candidate.id,
324
+ channelId,
325
+ text: message,
326
+ ...(resolvedMediaPath
327
+ ? {
328
+ mediaPath: resolvedMediaPath,
329
+ caption: message,
330
+ }
331
+ : {}),
332
+ })
333
+ } catch (err: unknown) {
334
+ const errMsg = err instanceof Error ? err.message : String(err)
335
+ console.warn(`[queue] Failed task follow-up send on connector ${candidate.id}: ${errMsg}`)
336
+ }
337
+ }
338
+ }
339
+
218
340
  /**
219
341
  * Notify agent thread sessions when a task completes or fails.
220
342
  * - Always pushes to the executing agent's thread
@@ -280,22 +402,54 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
280
402
  changed = true
281
403
  }
282
404
 
283
- // 2. If delegated, push to delegating agent's thread
405
+ // 2. If delegated, push to delegating agent's thread AND active chat sessions
284
406
  const delegatedBy = (task as unknown as Record<string, unknown>).delegatedByAgentId
285
407
  if (typeof delegatedBy === 'string' && delegatedBy !== task.agentId) {
286
408
  const delegator = agents[delegatedBy]
409
+ const agentName = agent?.name || task.agentId
410
+ const delegationBody = buildResultBlock(`Delegated task ${statusLabel}: **${taskLink}** (by ${agentName})`)
411
+
412
+ // Push to delegating agent's thread
287
413
  if (delegator?.threadSessionId && sessions[delegator.threadSessionId]) {
288
414
  const thread = sessions[delegator.threadSessionId]
289
415
  if (!Array.isArray(thread.messages)) thread.messages = []
290
- const agentName = agent?.name || task.agentId
291
- const body = buildResultBlock(`Delegated task ${statusLabel}: **${taskLink}** (by ${agentName})`)
292
- thread.messages.push(buildMsg(body))
416
+ thread.messages.push(buildMsg(delegationBody))
293
417
  thread.lastActiveAt = now
294
418
  changed = true
295
419
  }
420
+
421
+ // Push to delegating agent's active user-facing chat sessions
422
+ // so the result is visible in the chat the user is looking at
423
+ if (delegator) {
424
+ for (const session of Object.values(sessions)) {
425
+ if (!session || session.agentId !== delegatedBy) continue
426
+ // Skip thread sessions and orchestrated/subagent sessions
427
+ if (session.id === delegator.threadSessionId) continue
428
+ if (session.sessionType === 'orchestrated') continue
429
+ // Only push to recently-active sessions (within last 30 minutes)
430
+ const lastActive = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : 0
431
+ if (now - lastActive > 30 * 60_000) continue
432
+ if (!Array.isArray(session.messages)) session.messages = []
433
+ // Avoid duplicate push
434
+ const lastMsg = session.messages.at(-1)
435
+ if (lastMsg?.text === delegationBody && typeof lastMsg?.time === 'number' && now - lastMsg.time < 30_000) continue
436
+ session.messages.push(buildMsg(delegationBody))
437
+ session.lastActiveAt = now
438
+ changed = true
439
+ // Notify the specific session's message topic for real-time UI update
440
+ notify(`messages:${session.id}`)
441
+ }
442
+ }
296
443
  }
297
444
 
298
445
  if (changed) saveSessions(sessions)
446
+
447
+ void notifyConnectorTaskFollowups({
448
+ task,
449
+ statusLabel,
450
+ summaryText: resultBody || '',
451
+ imageUrl: firstImage?.url,
452
+ })
299
453
  }
300
454
 
301
455
  /** Disable heartbeat on a task's session when the task finishes. */
@@ -331,6 +485,10 @@ export function enqueueTask(taskId: string) {
331
485
  text: `Task queued: "${task.title}" (${task.id})`,
332
486
  })
333
487
 
488
+ // If processNext is already running, mark a pending kick so it re-enters after finishing
489
+ if (_queueState.processing) {
490
+ _queueState.pendingKick = true
491
+ }
334
492
  // Delay before kicking worker so UI shows the queued state
335
493
  setTimeout(() => processNext(), 2000)
336
494
  }
@@ -619,10 +777,21 @@ export async function processNext() {
619
777
  // Save initial assistant message so user sees context when opening the session
620
778
  const sessions = loadSessions()
621
779
  if (sessions[sessionId]) {
780
+ const isDelegation = (task as unknown as Record<string, unknown>).sourceType === 'delegation'
781
+ let initialText: string
782
+ if (isDelegation) {
783
+ const delegatorId = (task as unknown as Record<string, unknown>).delegatedByAgentId as string | undefined
784
+ const delegator = delegatorId ? agents[delegatorId] : null
785
+ const prefix = `[delegation-source:${delegatorId || ''}:${delegator?.name || 'Agent'}:${delegator?.avatarSeed || ''}]`
786
+ 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.`
787
+ } else {
788
+ initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`
789
+ }
622
790
  sessions[sessionId].messages.push({
623
791
  role: 'assistant',
624
- text: `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`,
792
+ text: initialText,
625
793
  time: Date.now(),
794
+ ...(isDelegation ? { kind: 'system' as const } : {}),
626
795
  })
627
796
  saveSessions(sessions)
628
797
  }
@@ -807,6 +976,11 @@ export async function processNext() {
807
976
  }
808
977
  } finally {
809
978
  _queueState.processing = false
979
+ // If tasks were enqueued while we were processing, kick another round
980
+ if (_queueState.pendingKick) {
981
+ _queueState.pendingKick = false
982
+ setTimeout(() => processNext(), 500)
983
+ }
810
984
  }
811
985
  }
812
986
 
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import { loadSessions, saveSessions } from '../storage'
4
+ import { notify } from '../ws-hub'
5
+ import type { ToolBuildContext } from './context'
6
+
7
+ export function buildCanvasTools(bctx: ToolBuildContext): StructuredToolInterface[] {
8
+ const { ctx, hasTool } = bctx
9
+ if (!hasTool('canvas')) return []
10
+
11
+ return [
12
+ tool(
13
+ async ({ action, content }) => {
14
+ try {
15
+ const sessionId = ctx?.sessionId
16
+ if (!sessionId) return 'Error: no active session for canvas.'
17
+
18
+ const sessions = loadSessions()
19
+ const session = sessions[sessionId]
20
+ if (!session) return 'Error: session not found.'
21
+
22
+ if (action === 'present') {
23
+ if (!content) return 'Error: content is required for present action.'
24
+ ;(session as Record<string, unknown>).canvasContent = content
25
+ session.lastActiveAt = Date.now()
26
+ sessions[sessionId] = session
27
+ saveSessions(sessions)
28
+ notify(`canvas:${sessionId}`)
29
+ return JSON.stringify({ ok: true, action: 'present', contentLength: content.length })
30
+ }
31
+
32
+ if (action === 'hide') {
33
+ ;(session as Record<string, unknown>).canvasContent = null
34
+ session.lastActiveAt = Date.now()
35
+ sessions[sessionId] = session
36
+ saveSessions(sessions)
37
+ notify(`canvas:${sessionId}`)
38
+ return JSON.stringify({ ok: true, action: 'hide' })
39
+ }
40
+
41
+ if (action === 'snapshot') {
42
+ const current = (session as Record<string, unknown>).canvasContent
43
+ return JSON.stringify({
44
+ ok: true,
45
+ action: 'snapshot',
46
+ hasContent: !!current,
47
+ contentLength: typeof current === 'string' ? current.length : 0,
48
+ preview: typeof current === 'string' ? current.slice(0, 500) : null,
49
+ })
50
+ }
51
+
52
+ return `Unknown canvas action "${action}". Valid: present, hide, snapshot`
53
+ } catch (err: unknown) {
54
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
55
+ }
56
+ },
57
+ {
58
+ name: 'canvas',
59
+ description: 'Present live HTML/CSS/JS content to the user in an interactive canvas panel. Use "present" to show content, "hide" to dismiss, "snapshot" to check current state. The canvas renders in a sandboxed iframe alongside the chat.',
60
+ schema: z.object({
61
+ action: z.enum(['present', 'hide', 'snapshot']).describe('Canvas action to perform'),
62
+ content: z.string().optional().describe('HTML content to render (required for "present"). Can include inline CSS and JS.'),
63
+ }),
64
+ },
65
+ ),
66
+ ]
67
+ }