@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
@@ -0,0 +1,101 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadTasks, loadAgents } from '@/lib/server/storage'
3
+
4
+ type Range = '24h' | '7d' | '30d'
5
+
6
+ const RANGE_MS: Record<Range, number> = {
7
+ '24h': 24 * 3600_000,
8
+ '7d': 7 * 86400_000,
9
+ '30d': 30 * 86400_000,
10
+ }
11
+
12
+ function bucketKey(ts: number, range: Range): string {
13
+ const d = new Date(ts)
14
+ if (range === '24h') return d.toISOString().slice(0, 13) // "2026-03-01T14"
15
+ return d.toISOString().slice(0, 10) // "2026-03-01"
16
+ }
17
+
18
+ export async function GET(req: Request) {
19
+ const { searchParams } = new URL(req.url)
20
+ const range = (searchParams.get('range') as Range) || '7d'
21
+ const cutoff = Date.now() - (RANGE_MS[range] || RANGE_MS['7d'])
22
+
23
+ const tasks = loadTasks()
24
+ const agents = loadAgents()
25
+ const all = Object.values(tasks)
26
+
27
+ // --- by-status counts ---
28
+ const byStatus: Record<string, number> = {}
29
+ for (const t of all) {
30
+ byStatus[t.status] = (byStatus[t.status] || 0) + 1
31
+ }
32
+
33
+ // WIP = queued + running
34
+ const wip = (byStatus['queued'] || 0) + (byStatus['running'] || 0)
35
+
36
+ // --- completions in range ---
37
+ const completedInRange = all.filter(
38
+ (t) => t.status === 'completed' && t.completedAt && t.completedAt >= cutoff,
39
+ )
40
+
41
+ // --- cycle times (queuedAt → completedAt) ---
42
+ const cycleTimes: number[] = []
43
+ for (const t of completedInRange) {
44
+ const start = t.queuedAt || t.createdAt
45
+ const end = t.completedAt!
46
+ if (end > start) cycleTimes.push(end - start)
47
+ }
48
+ cycleTimes.sort((a, b) => a - b)
49
+
50
+ const avgCycleMs = cycleTimes.length
51
+ ? Math.round(cycleTimes.reduce((s, v) => s + v, 0) / cycleTimes.length)
52
+ : 0
53
+ const p50CycleMs = cycleTimes.length ? cycleTimes[Math.floor(cycleTimes.length * 0.5)] : 0
54
+ const p90CycleMs = cycleTimes.length ? cycleTimes[Math.floor(cycleTimes.length * 0.9)] : 0
55
+
56
+ // --- velocity (completions per bucket) ---
57
+ const velocityMap: Record<string, number> = {}
58
+ for (const t of completedInRange) {
59
+ const key = bucketKey(t.completedAt!, range)
60
+ velocityMap[key] = (velocityMap[key] || 0) + 1
61
+ }
62
+ const velocity = Object.entries(velocityMap)
63
+ .sort(([a], [b]) => a.localeCompare(b))
64
+ .map(([bucket, count]) => ({ bucket, count }))
65
+
66
+ // --- by-agent completions ---
67
+ const byAgent: Record<string, { agentName: string; completed: number; failed: number }> = {}
68
+ const recentTasks = all.filter(
69
+ (t) => (t.completedAt && t.completedAt >= cutoff) || (t.status === 'failed' && t.updatedAt >= cutoff),
70
+ )
71
+ for (const t of recentTasks) {
72
+ if (!t.agentId) continue
73
+ if (!byAgent[t.agentId]) {
74
+ const agent = agents[t.agentId]
75
+ byAgent[t.agentId] = { agentName: agent?.name || t.agentId, completed: 0, failed: 0 }
76
+ }
77
+ if (t.status === 'completed') byAgent[t.agentId].completed++
78
+ else if (t.status === 'failed') byAgent[t.agentId].failed++
79
+ }
80
+ const byAgentList = Object.values(byAgent).sort((a, b) => b.completed - a.completed)
81
+
82
+ // --- by-priority counts ---
83
+ const byPriority: Record<string, number> = {}
84
+ for (const t of all) {
85
+ const p = t.priority || 'none'
86
+ byPriority[p] = (byPriority[p] || 0) + 1
87
+ }
88
+
89
+ return NextResponse.json({
90
+ range,
91
+ byStatus,
92
+ wip,
93
+ completedCount: completedInRange.length,
94
+ avgCycleMs,
95
+ p50CycleMs,
96
+ p90CycleMs,
97
+ velocity,
98
+ byAgent: byAgentList,
99
+ byPriority,
100
+ })
101
+ }
@@ -1,11 +1,13 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadTasks, saveTasks, loadSettings, logActivity } from '@/lib/server/storage'
3
+ import { loadTasks, saveTasks, loadSettings, loadAgents, logActivity } from '@/lib/server/storage'
4
4
  import { enqueueTask, validateCompletedTasksQueue } from '@/lib/server/queue'
5
5
  import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
6
6
  import { formatValidationFailure, validateTaskCompletion } from '@/lib/server/task-validation'
7
7
  import { pushMainLoopEventToMainSessions } from '@/lib/server/main-agent-loop'
8
8
  import { notify } from '@/lib/server/ws-hub'
9
+ import { computeTaskFingerprint, findDuplicateTask } from '@/lib/task-dedupe'
10
+ import { resolveTaskAgentFromDescription } from '@/lib/server/task-mention'
9
11
 
10
12
  export async function GET(req: Request) {
11
13
  // Keep completed queue integrity even if daemon is not running.
@@ -64,12 +66,17 @@ export async function POST(req: Request) {
64
66
  const retryBackoffSec = Number.isFinite(Number(body.retryBackoffSec))
65
67
  ? Math.max(1, Math.min(3600, Math.trunc(Number(body.retryBackoffSec))))
66
68
  : Math.max(1, Math.min(3600, Math.trunc(Number(settings.taskRetryBackoffSec ?? 30))))
69
+ // Resolve @mentions in description to auto-assign agent
70
+ const resolvedAgentId = body.description
71
+ ? resolveTaskAgentFromDescription(body.description, body.agentId || '', loadAgents())
72
+ : (body.agentId || '')
73
+
67
74
  tasks[id] = {
68
75
  id,
69
76
  title: body.title || 'Untitled Task',
70
77
  description: body.description || '',
71
78
  status: body.status || 'backlog',
72
- agentId: body.agentId || '',
79
+ agentId: resolvedAgentId,
73
80
  projectId: typeof body.projectId === 'string' && body.projectId ? body.projectId : null,
74
81
  goalContract: body.goalContract || null,
75
82
  cwd: typeof body.cwd === 'string' ? body.cwd : null,
@@ -94,6 +101,14 @@ export async function POST(req: Request) {
94
101
  tags: Array.isArray(body.tags) ? body.tags.filter((s: unknown) => typeof s === 'string') : [],
95
102
  dueAt: typeof body.dueAt === 'number' ? body.dueAt : null,
96
103
  customFields: body.customFields && typeof body.customFields === 'object' ? body.customFields : undefined,
104
+ priority: ['low', 'medium', 'high', 'critical'].includes(body.priority) ? body.priority : undefined,
105
+ fingerprint: computeTaskFingerprint(body.title || 'Untitled Task', body.agentId || ''),
106
+ }
107
+
108
+ // Dedup: if a non-terminal task with same fingerprint exists, return it
109
+ const dupe = findDuplicateTask(tasks, { fingerprint: tasks[id].fingerprint! })
110
+ if (dupe && dupe.id !== id) {
111
+ return NextResponse.json({ ...dupe, deduplicated: true })
97
112
  }
98
113
 
99
114
  if (tasks[id].status === 'completed') {
@@ -1,40 +1,21 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadSettings } from '@/lib/server/storage'
2
+ import { explainElevenLabsError, resolveElevenLabsConfig, synthesizeElevenLabsMp3 } from '@/lib/server/elevenlabs'
3
3
 
4
4
  export async function POST(req: Request) {
5
- const settings = loadSettings()
6
- const ELEVENLABS_KEY = settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY
7
- const ELEVENLABS_VOICE = settings.elevenLabsVoiceId || process.env.ELEVENLABS_VOICE || 'JBFqnCBsd6RMkjVDRZzb'
8
-
9
- if (!ELEVENLABS_KEY) {
10
- return new NextResponse('No ElevenLabs API key. Set one in Settings > Voice.', { status: 500 })
5
+ try {
6
+ const { text, voiceId } = await req.json()
7
+ if (!String(text || '').trim()) {
8
+ return new NextResponse('No text provided', { status: 400 })
9
+ }
10
+ resolveElevenLabsConfig(voiceId)
11
+ const audioBuffer = await synthesizeElevenLabsMp3({ text: String(text || ''), voiceId })
12
+ return new NextResponse(new Uint8Array(audioBuffer), {
13
+ headers: {
14
+ 'Content-Type': 'audio/mpeg',
15
+ 'Cache-Control': 'no-cache',
16
+ },
17
+ })
18
+ } catch (err: unknown) {
19
+ return new NextResponse(explainElevenLabsError(err), { status: 500 })
11
20
  }
12
-
13
- const { text } = await req.json()
14
- const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVENLABS_VOICE}`, {
15
- method: 'POST',
16
- headers: {
17
- 'xi-api-key': ELEVENLABS_KEY,
18
- 'Content-Type': 'application/json',
19
- 'Accept': 'audio/mpeg',
20
- },
21
- body: JSON.stringify({
22
- text,
23
- model_id: 'eleven_multilingual_v2',
24
- voice_settings: { stability: 0.5, similarity_boost: 0.75 },
25
- }),
26
- })
27
-
28
- if (!apiRes.ok) {
29
- const err = await apiRes.text()
30
- return new NextResponse(err, { status: apiRes.status })
31
- }
32
-
33
- const audioBuffer = await apiRes.arrayBuffer()
34
- return new NextResponse(audioBuffer, {
35
- headers: {
36
- 'Content-Type': 'audio/mpeg',
37
- 'Cache-Control': 'no-cache',
38
- },
39
- })
40
21
  }
@@ -1,48 +1,20 @@
1
- import { loadSettings } from '@/lib/server/storage'
1
+ import { explainElevenLabsError, requestElevenLabsMp3Stream } from '@/lib/server/elevenlabs'
2
2
 
3
3
  export async function POST(req: Request) {
4
- const settings = loadSettings()
5
- const ELEVENLABS_KEY = settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY
6
- const ELEVENLABS_VOICE = settings.elevenLabsVoiceId || process.env.ELEVENLABS_VOICE || 'JBFqnCBsd6RMkjVDRZzb'
7
-
8
- if (!ELEVENLABS_KEY) {
9
- return new Response('No ElevenLabs API key. Set one in Settings > Voice.', { status: 500 })
10
- }
11
-
12
- const { text } = await req.json()
13
- if (!text?.trim()) {
14
- return new Response('No text provided', { status: 400 })
15
- }
16
-
17
- const apiRes = await fetch(
18
- `https://api.elevenlabs.io/v1/text-to-speech/${ELEVENLABS_VOICE}/stream`,
19
- {
20
- method: 'POST',
4
+ try {
5
+ const { text, voiceId } = await req.json()
6
+ if (!String(text || '').trim()) {
7
+ return new Response('No text provided', { status: 400 })
8
+ }
9
+ const apiRes = await requestElevenLabsMp3Stream({ text: String(text || ''), voiceId })
10
+ return new Response(apiRes.body, {
21
11
  headers: {
22
- 'xi-api-key': ELEVENLABS_KEY,
23
- 'Content-Type': 'application/json',
24
- 'Accept': 'audio/mpeg',
12
+ 'Content-Type': 'audio/mpeg',
13
+ 'Transfer-Encoding': 'chunked',
14
+ 'Cache-Control': 'no-cache',
25
15
  },
26
- body: JSON.stringify({
27
- text: text.slice(0, 2000),
28
- model_id: 'eleven_multilingual_v2',
29
- voice_settings: { stability: 0.5, similarity_boost: 0.75 },
30
- output_format: 'mp3_22050_32',
31
- }),
32
- },
33
- )
34
-
35
- if (!apiRes.ok) {
36
- const err = await apiRes.text()
37
- return new Response(err, { status: apiRes.status })
16
+ })
17
+ } catch (err: unknown) {
18
+ return new Response(explainElevenLabsError(err), { status: 500 })
38
19
  }
39
-
40
- // Pipe the streaming response directly
41
- return new Response(apiRes.body, {
42
- headers: {
43
- 'Content-Type': 'audio/mpeg',
44
- 'Transfer-Encoding': 'chunked',
45
- 'Cache-Control': 'no-cache',
46
- },
47
- })
48
20
  }
@@ -3,40 +3,7 @@ import { notFound } from '@/lib/server/collection-helpers'
3
3
  import fs from 'fs'
4
4
  import path from 'path'
5
5
  import { UPLOAD_DIR } from '@/lib/server/storage'
6
-
7
- const MIME_TYPES: Record<string, string> = {
8
- '.png': 'image/png',
9
- '.jpg': 'image/jpeg',
10
- '.jpeg': 'image/jpeg',
11
- '.gif': 'image/gif',
12
- '.webp': 'image/webp',
13
- '.svg': 'image/svg+xml',
14
- '.bmp': 'image/bmp',
15
- '.ico': 'image/x-icon',
16
- '.mp4': 'video/mp4',
17
- '.webm': 'video/webm',
18
- '.mov': 'video/quicktime',
19
- '.avi': 'video/x-msvideo',
20
- '.mkv': 'video/x-matroska',
21
- '.pdf': 'application/pdf',
22
- '.json': 'application/json',
23
- '.csv': 'text/csv',
24
- '.txt': 'text/plain',
25
- '.html': 'text/html',
26
- '.xml': 'application/xml',
27
- '.zip': 'application/zip',
28
- '.tar': 'application/x-tar',
29
- '.gz': 'application/gzip',
30
- '.doc': 'application/msword',
31
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
32
- '.xls': 'application/vnd.ms-excel',
33
- '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
34
- '.ppt': 'application/vnd.ms-powerpoint',
35
- '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
36
- '.mp3': 'audio/mpeg',
37
- '.wav': 'audio/wav',
38
- '.ogg': 'audio/ogg',
39
- }
6
+ import { MIME_TYPES } from '@/lib/server/mime'
40
7
 
41
8
  export async function GET(_req: Request, { params }: { params: Promise<{ filename: string }> }) {
42
9
  const { filename } = await params
@@ -60,3 +27,21 @@ export async function GET(_req: Request, { params }: { params: Promise<{ filenam
60
27
  },
61
28
  })
62
29
  }
30
+
31
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ filename: string }> }) {
32
+ const { filename } = await params
33
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '')
34
+
35
+ if (safeName.includes('..') || safeName.includes('/')) {
36
+ return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
37
+ }
38
+
39
+ const filePath = path.join(UPLOAD_DIR, safeName)
40
+
41
+ if (!fs.existsSync(filePath)) {
42
+ return notFound()
43
+ }
44
+
45
+ fs.unlinkSync(filePath)
46
+ return NextResponse.json({ ok: true })
47
+ }
@@ -0,0 +1,94 @@
1
+ import { NextResponse } from 'next/server'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { UPLOAD_DIR } from '@/lib/server/storage'
5
+ import { getFileCategory } from '@/lib/server/mime'
6
+
7
+ interface UploadFile {
8
+ name: string
9
+ size: number
10
+ modified: number
11
+ category: string
12
+ url: string
13
+ }
14
+
15
+ function listUploadFiles(): UploadFile[] {
16
+ if (!fs.existsSync(UPLOAD_DIR)) return []
17
+ const entries = fs.readdirSync(UPLOAD_DIR)
18
+ const files: UploadFile[] = []
19
+ for (const name of entries) {
20
+ const filePath = path.join(UPLOAD_DIR, name)
21
+ try {
22
+ const stat = fs.statSync(filePath)
23
+ if (!stat.isFile()) continue
24
+ const ext = path.extname(name).toLowerCase()
25
+ files.push({
26
+ name,
27
+ size: stat.size,
28
+ modified: stat.mtimeMs,
29
+ category: getFileCategory(ext),
30
+ url: `/api/uploads/${encodeURIComponent(name)}`,
31
+ })
32
+ } catch {
33
+ // skip files we can't stat
34
+ }
35
+ }
36
+ return files
37
+ }
38
+
39
+ export async function GET() {
40
+ const files = listUploadFiles()
41
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0)
42
+ return NextResponse.json({ files, totalSize, count: files.length })
43
+ }
44
+
45
+ interface DeleteBody {
46
+ filenames?: string[]
47
+ olderThanDays?: number
48
+ category?: string
49
+ all?: boolean
50
+ }
51
+
52
+ function isUnsafeName(name: string): boolean {
53
+ return name.includes('/') || name.includes('\\') || name.includes('..')
54
+ }
55
+
56
+ export async function DELETE(req: Request) {
57
+ const body = (await req.json()) as DeleteBody
58
+ const files = listUploadFiles()
59
+ let toDelete: string[] = []
60
+
61
+ if (body.all) {
62
+ toDelete = files.map((f) => f.name)
63
+ } else if (body.filenames && Array.isArray(body.filenames)) {
64
+ for (const name of body.filenames) {
65
+ if (typeof name !== 'string' || isUnsafeName(name)) {
66
+ return NextResponse.json({ error: `Invalid filename: ${name}` }, { status: 400 })
67
+ }
68
+ }
69
+ toDelete = body.filenames
70
+ } else if (typeof body.olderThanDays === 'number') {
71
+ const cutoff = Date.now() - body.olderThanDays * 86_400_000
72
+ toDelete = files.filter((f) => f.modified < cutoff).map((f) => f.name)
73
+ } else if (typeof body.category === 'string') {
74
+ toDelete = files.filter((f) => f.category === body.category).map((f) => f.name)
75
+ } else {
76
+ return NextResponse.json({ error: 'Provide filenames, olderThanDays, category, or all' }, { status: 400 })
77
+ }
78
+
79
+ let deleted = 0
80
+ let freedBytes = 0
81
+ for (const name of toDelete) {
82
+ const filePath = path.join(UPLOAD_DIR, name)
83
+ try {
84
+ const stat = fs.statSync(filePath)
85
+ fs.unlinkSync(filePath)
86
+ freedBytes += stat.size
87
+ deleted++
88
+ } catch {
89
+ // file already gone or inaccessible
90
+ }
91
+ }
92
+
93
+ return NextResponse.json({ deleted, freedBytes })
94
+ }
@@ -269,6 +269,11 @@ textarea:hover::-webkit-scrollbar { width: 6px; }
269
269
  to { opacity: 0; transform: translateY(-8px) scale(0.95); }
270
270
  }
271
271
 
272
+ @keyframes delegation-handoff-in {
273
+ 0% { opacity: 0; transform: translateX(16px) scale(0.95); }
274
+ 100% { opacity: 1; transform: translateX(0) scale(1); }
275
+ }
276
+
272
277
  @keyframes avatar-moment-pulse {
273
278
  0% { transform: scale(1); }
274
279
  30% { transform: scale(1.15); }
package/src/cli/index.js CHANGED
@@ -79,6 +79,14 @@ const COMMAND_GROUPS = [
79
79
  }),
80
80
  ],
81
81
  },
82
+ {
83
+ name: 'canvas',
84
+ description: 'Read/update per-session canvas content',
85
+ commands: [
86
+ cmd('get', 'GET', '/canvas/:sessionId', 'Get current canvas content for a session'),
87
+ cmd('set', 'POST', '/canvas/:sessionId', 'Set/clear canvas content for a session', { expectsJsonBody: true }),
88
+ ],
89
+ },
82
90
  {
83
91
  name: 'connectors',
84
92
  description: 'Manage chat connectors',
@@ -154,6 +162,7 @@ const COMMAND_GROUPS = [
154
162
  description: 'Serve and manage local files',
155
163
  commands: [
156
164
  cmd('serve', 'GET', '/files/serve', 'Serve a local file (use --query path=/abs/path)'),
165
+ cmd('open', 'POST', '/files/open', 'Open a local file path via the host default app/browser', { expectsJsonBody: true }),
157
166
  ],
158
167
  },
159
168
  {
@@ -381,6 +390,8 @@ const COMMAND_GROUPS = [
381
390
  }),
382
391
  cmd('messages', 'GET', '/sessions/:id/messages', 'Get session messages'),
383
392
  cmd('messages-update', 'PUT', '/sessions/:id/messages', 'Update session message metadata (e.g. bookmark)', { expectsJsonBody: true }),
393
+ cmd('messages-send', 'POST', '/sessions/:id/messages', 'Append a user/system message to a session', { expectsJsonBody: true }),
394
+ cmd('messages-delete', 'DELETE', '/sessions/:id/messages', 'Delete a message from a session', { expectsJsonBody: true }),
384
395
  cmd('fork', 'POST', '/sessions/:id/fork', 'Fork session from a specific message index', { expectsJsonBody: true }),
385
396
  cmd('edit-resend', 'POST', '/sessions/:id/edit-resend', 'Edit and resend from a specific message index', { expectsJsonBody: true }),
386
397
  cmd('main-loop', 'GET', '/sessions/:id/main-loop', 'Get main mission loop state'),
@@ -455,6 +466,7 @@ const COMMAND_GROUPS = [
455
466
  cmd('delete', 'DELETE', '/tasks/:id', 'Delete task'),
456
467
  cmd('purge', 'DELETE', '/tasks', 'Bulk delete tasks', { expectsJsonBody: true }),
457
468
  cmd('approve', 'POST', '/tasks/:id/approve', 'Approve or reject a pending tool execution', { expectsJsonBody: true }),
469
+ cmd('metrics', 'GET', '/tasks/metrics', 'Get task board metrics (supports --query range=24h|7d|30d)'),
458
470
  ],
459
471
  },
460
472
  {
@@ -485,9 +497,12 @@ const COMMAND_GROUPS = [
485
497
  },
486
498
  {
487
499
  name: 'uploads',
488
- description: 'Fetch uploaded artifacts',
500
+ description: 'Manage uploaded artifacts',
489
501
  commands: [
502
+ cmd('list', 'GET', '/uploads', 'List uploaded artifacts'),
490
503
  cmd('get', 'GET', '/uploads/:filename', 'Download uploaded artifact', { responseType: 'binary' }),
504
+ cmd('delete', 'DELETE', '/uploads/:filename', 'Delete uploaded artifact by filename'),
505
+ cmd('delete-many', 'DELETE', '/uploads', 'Delete uploads by filter/body (filenames, olderThanDays, category, or all)', { expectsJsonBody: true }),
491
506
  ],
492
507
  },
493
508
  {
package/src/cli/spec.js CHANGED
@@ -40,6 +40,13 @@ const COMMAND_GROUPS = {
40
40
  pin: { description: 'Toggle pin on a chatroom message', method: 'POST', path: '/chatrooms/:id/pins', params: ['id'] },
41
41
  },
42
42
  },
43
+ canvas: {
44
+ description: 'Session canvas content',
45
+ commands: {
46
+ get: { description: 'Get current canvas content for a session', method: 'GET', path: '/canvas/:sessionId', params: ['sessionId'] },
47
+ set: { description: 'Set/clear canvas content for a session', method: 'POST', path: '/canvas/:sessionId', params: ['sessionId'] },
48
+ },
49
+ },
43
50
  connectors: {
44
51
  description: 'Manage chat connectors',
45
52
  commands: {
@@ -120,6 +127,22 @@ const COMMAND_GROUPS = {
120
127
  },
121
128
  },
122
129
  },
130
+ uploads: {
131
+ description: 'Manage uploaded artifacts',
132
+ commands: {
133
+ list: { description: 'List uploaded artifacts', method: 'GET', path: '/uploads' },
134
+ get: { description: 'Download uploaded artifact by filename', method: 'GET', path: '/uploads/:filename', params: ['filename'], binary: true },
135
+ delete: { description: 'Delete uploaded artifact by filename', method: 'DELETE', path: '/uploads/:filename', params: ['filename'] },
136
+ 'delete-many': { description: 'Delete uploads by filter/body (filenames, olderThanDays, category, or all)', method: 'DELETE', path: '/uploads' },
137
+ },
138
+ },
139
+ files: {
140
+ description: 'Serve/open local files',
141
+ commands: {
142
+ serve: { description: 'Serve a local file (supports --query path=/some/file)', method: 'GET', path: '/files/serve' },
143
+ open: { description: 'Open a local file path via host default app/browser', method: 'POST', path: '/files/open' },
144
+ },
145
+ },
123
146
  logs: {
124
147
  description: 'Application logs',
125
148
  commands: {
@@ -262,6 +285,8 @@ const COMMAND_GROUPS = {
262
285
  'heartbeat-disable-all': { description: 'Disable all session heartbeats and cancel queued heartbeat runs', method: 'POST', path: '/sessions/heartbeat' },
263
286
  messages: { description: 'Get session message history', method: 'GET', path: '/sessions/:id/messages', params: ['id'] },
264
287
  'messages-update': { description: 'Update session message metadata (e.g. bookmark)', method: 'PUT', path: '/sessions/:id/messages', params: ['id'] },
288
+ 'messages-send': { description: 'Append a user/system message to a session', method: 'POST', path: '/sessions/:id/messages', params: ['id'] },
289
+ 'messages-delete': { description: 'Delete a message from a session', method: 'DELETE', path: '/sessions/:id/messages', params: ['id'] },
265
290
  fork: { description: 'Fork session from a specific message index', method: 'POST', path: '/sessions/:id/fork', params: ['id'] },
266
291
  'edit-resend': { description: 'Edit and resend from a specific message index', method: 'POST', path: '/sessions/:id/edit-resend', params: ['id'] },
267
292
  'main-loop': { description: 'Get main mission loop state for a session', method: 'GET', path: '/sessions/:id/main-loop', params: ['id'] },
@@ -323,6 +348,7 @@ const COMMAND_GROUPS = {
323
348
  delete: { description: 'Archive task', method: 'DELETE', path: '/tasks/:id', params: ['id'] },
324
349
  archive: { description: 'Archive task', method: 'DELETE', path: '/tasks/:id', params: ['id'] },
325
350
  approve: { description: 'Approve or reject a pending tool execution', method: 'POST', path: '/tasks/:id/approve', params: ['id'] },
351
+ metrics: { description: 'Get task board metrics (supports --query range=24h|7d|30d)', method: 'GET', path: '/tasks/metrics' },
326
352
  },
327
353
  },
328
354
  webhooks: {
@@ -164,8 +164,8 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
164
164
  />
165
165
  <span className="font-display text-[14px] font-600 truncate flex-1 tracking-[-0.01em]">{agent.name}</span>
166
166
  {pendingApprovalCount > 0 && (
167
- <span className="shrink-0 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-700">
168
- {pendingApprovalCount}
167
+ <span className="shrink-0 text-[9px] font-600 uppercase tracking-wider px-2 py-0.5 rounded-[6px] text-amber-400 bg-amber-400/[0.08] border border-amber-400/15">
168
+ {pendingApprovalCount} {pendingApprovalCount === 1 ? 'approval' : 'approvals'}
169
169
  </span>
170
170
  )}
171
171
  {isDefault && (
@@ -178,7 +178,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
178
178
  onClick={handleRunClick}
179
179
  disabled={running}
180
180
  className="shrink-0 text-[10px] font-600 uppercase tracking-wider px-2.5 py-1 rounded-[6px] cursor-pointer
181
- transition-all border-none bg-accent-bright/20 text-[#818CF8] hover:bg-accent-bright/30 disabled:opacity-40"
181
+ transition-all border-none bg-accent-bright/20 text-accent-bright hover:bg-accent-bright/30 disabled:opacity-40"
182
182
  style={{ fontFamily: 'inherit' }}
183
183
  >
184
184
  {running ? '...' : 'Run'}
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
+ import { useChatroomStore } from '@/stores/use-chatroom-store'
6
7
  import { fetchMessages } from '@/lib/sessions'
7
8
  import type { Agent, Session } from '@/types'
8
9
  import { AgentAvatar } from './agent-avatar'
@@ -28,6 +29,8 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
28
29
  const streamingSessionId = useChatStore((s) => s.streamingSessionId)
29
30
  const chatFilter = useAppStore((s) => s.chatFilter ?? 'all')
30
31
  const setChatFilter = useAppStore((s) => s.setChatFilter)
32
+ const chatrooms = useChatroomStore((s) => s.chatrooms)
33
+ const chatroomStreaming = useChatroomStore((s) => s.streamingAgents)
31
34
  const [search, setSearch] = useState('')
32
35
 
33
36
  // FLIP animation refs
@@ -57,6 +60,21 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
57
60
  })
58
61
  }, [agents, sessions, search])
59
62
 
63
+ // Compute agents active in chatrooms (message in last 30min or currently streaming)
64
+ const chatroomActiveAgentIds = useMemo(() => {
65
+ const set = new Set<string>()
66
+ const cutoff = Date.now() - 30 * 60 * 1000
67
+ for (const chatroom of Object.values(chatrooms)) {
68
+ for (let i = chatroom.messages.length - 1; i >= 0; i--) {
69
+ const msg = chatroom.messages[i]
70
+ if (msg.time < cutoff) break
71
+ if (msg.role === 'assistant' && msg.senderId !== 'user') set.add(msg.senderId)
72
+ }
73
+ }
74
+ for (const agentId of chatroomStreaming.keys()) set.add(agentId)
75
+ return set
76
+ }, [chatrooms, chatroomStreaming])
77
+
60
78
  // Compute running tasks per agent
61
79
  const runningAgentIds = useMemo(() => {
62
80
  const set = new Set<string>()
@@ -74,12 +92,13 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
74
92
  const threadSession = a.threadSessionId ? sessions[a.threadSessionId] as Session | undefined : undefined
75
93
  const isRunning = runningAgentIds.has(a.id) || (threadSession?.active ?? false)
76
94
  const isStreaming = streamingSessionId === a.threadSessionId
77
- if (chatFilter === 'active') return isRunning || isStreaming
95
+ const isChatroomActive = chatroomActiveAgentIds.has(a.id)
96
+ if (chatFilter === 'active') return isRunning || isStreaming || isChatroomActive
78
97
  // 'recent' — activity within 24h
79
98
  const lastActive = threadSession?.lastActiveAt || a.updatedAt
80
99
  return now - lastActive < 86_400_000
81
100
  })
82
- }, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId])
101
+ }, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId, chatroomActiveAgentIds])
83
102
 
84
103
  // FLIP: animate row position changes
85
104
  useLayoutEffect(() => {
@@ -109,7 +128,9 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
109
128
  try {
110
129
  const msgs = await fetchMessages(state.currentSessionId)
111
130
  setMessages(msgs)
112
- } catch { /* ignore */ }
131
+ } catch (err: unknown) {
132
+ console.error('[agent-chat-list] Failed to load messages:', err instanceof Error ? err.message : String(err))
133
+ }
113
134
  }
114
135
  onSelect?.()
115
136
  // Delay scroll so React renders the new messages first
@@ -186,7 +207,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
186
207
  const isActive = currentAgentId === agent.id
187
208
  const heartbeatOn = agent.heartbeatEnabled === true && (agent.tools?.length ?? 0) > 0
188
209
  const recentlyActive = (threadSession?.lastActiveAt ?? 0) > Date.now() - 30 * 60 * 1000
189
- const isWorking = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive
210
+ const isWorking = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive || chatroomActiveAgentIds.has(agent.id)
190
211
  const isTyping = streamingSessionId === agent.threadSessionId
191
212
  const preview = lastMsg?.text?.slice(0, 80)?.replace(/\n/g, ' ') || ''
192
213
 
@@ -194,7 +215,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
194
215
  <div
195
216
  key={agent.id}
196
217
  ref={(el) => setRowRef(agent.id, el)}
197
- className={`group/row relative w-full text-left py-3 px-3.5 rounded-[12px] cursor-pointer transition-all duration-150 border-none
218
+ className={`group/row relative w-full text-left py-3 px-4 rounded-[12px] cursor-pointer transition-all duration-150 border-none
198
219
  ${isActive
199
220
  ? 'bg-accent-soft/80 border border-accent-bright/20'
200
221
  : 'bg-transparent hover:bg-white/[0.02]'}`}
@@ -213,7 +234,9 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
213
234
  {agent.name}
214
235
  </span>
215
236
  <span className="text-[10px] text-text-3/60 font-mono shrink-0">
216
- {agent.model ? agent.model.split('/').pop()?.split(':')[0] : agent.provider}
237
+ {(threadSession?.model || agent.model)
238
+ ? (threadSession?.model || agent.model)!.split('/').pop()?.split(':')[0]
239
+ : agent.provider}
217
240
  </span>
218
241
  {/* Set as default agent */}
219
242
  {(() => {