@swarmclawai/swarmclaw 0.5.1 → 0.5.2

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -1
  4. package/public/screenshots/agents.png +0 -0
  5. package/public/screenshots/dashboard.png +0 -0
  6. package/public/screenshots/providers.png +0 -0
  7. package/public/screenshots/tasks.png +0 -0
  8. package/src/app/api/activity/route.ts +30 -0
  9. package/src/app/api/agents/[id]/route.ts +3 -1
  10. package/src/app/api/agents/route.ts +2 -1
  11. package/src/app/api/connectors/[id]/route.ts +4 -1
  12. package/src/app/api/openclaw/approvals/route.ts +20 -0
  13. package/src/app/api/tasks/[id]/route.ts +37 -1
  14. package/src/app/api/tasks/route.ts +7 -1
  15. package/src/app/api/usage/route.ts +74 -22
  16. package/src/app/api/webhooks/[id]/route.ts +62 -22
  17. package/src/cli/index.js +7 -0
  18. package/src/cli/spec.js +6 -0
  19. package/src/components/activity/activity-feed.tsx +91 -0
  20. package/src/components/chat/exec-approval-card.tsx +6 -3
  21. package/src/components/layout/app-layout.tsx +21 -7
  22. package/src/components/tasks/task-board.tsx +40 -2
  23. package/src/components/tasks/task-card.tsx +40 -2
  24. package/src/components/tasks/task-sheet.tsx +147 -1
  25. package/src/components/usage/metrics-dashboard.tsx +278 -0
  26. package/src/hooks/use-page-active.ts +21 -0
  27. package/src/hooks/use-ws.ts +13 -1
  28. package/src/lib/fetch-dedup.ts +20 -0
  29. package/src/lib/optimistic.ts +25 -0
  30. package/src/lib/server/connectors/manager.ts +18 -0
  31. package/src/lib/server/daemon-state.ts +205 -20
  32. package/src/lib/server/queue.ts +16 -0
  33. package/src/lib/server/storage.ts +34 -0
  34. package/src/lib/view-routes.ts +1 -0
  35. package/src/lib/ws-client.ts +2 -1
  36. package/src/stores/use-app-store.ts +48 -1
  37. package/src/stores/use-approval-store.ts +21 -7
  38. package/src/types/index.ts +40 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SwarmClaw Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -81,7 +81,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
81
81
  ```
82
82
 
83
83
  The installer resolves the latest stable release tag and installs that version by default.
84
- To pin a version: `SWARMCLAW_VERSION=v0.5.1 curl ... | bash`
84
+ To pin a version: `SWARMCLAW_VERSION=v0.5.2 curl ... | bash`
85
85
 
86
86
  Or run locally from the repo (friendly for non-technical users):
87
87
 
@@ -600,4 +600,4 @@ swarmclaw memory maintenance
600
600
 
601
601
  ## License
602
602
 
603
- MIT
603
+ [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -84,6 +84,7 @@
84
84
  "react-dom": "19.2.3",
85
85
  "react-icons": "^5.5.0",
86
86
  "react-markdown": "^10.1.0",
87
+ "recharts": "^3.7.0",
87
88
  "rehype-highlight": "^7.0.2",
88
89
  "remark-gfm": "^4.0.1",
89
90
  "sonner": "^2.0.7",
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,30 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadActivity } from '@/lib/server/storage'
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export async function GET(req: Request) {
6
+ const { searchParams } = new URL(req.url)
7
+ const entityType = searchParams.get('entityType')
8
+ const entityId = searchParams.get('entityId')
9
+ const action = searchParams.get('action')
10
+ const since = searchParams.get('since')
11
+ const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 50))
12
+
13
+ const all = loadActivity()
14
+ let entries = Object.values(all) as Array<Record<string, unknown>>
15
+
16
+ if (entityType) entries = entries.filter((e) => e.entityType === entityType)
17
+ if (entityId) entries = entries.filter((e) => e.entityId === entityId)
18
+ if (action) entries = entries.filter((e) => e.action === action)
19
+ if (since) {
20
+ const sinceMs = Number(since)
21
+ if (Number.isFinite(sinceMs)) {
22
+ entries = entries.filter((e) => typeof e.timestamp === 'number' && e.timestamp >= sinceMs)
23
+ }
24
+ }
25
+
26
+ entries.sort((a, b) => (b.timestamp as number) - (a.timestamp as number))
27
+ entries = entries.slice(0, limit)
28
+
29
+ return NextResponse.json(entries)
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadAgents, saveAgents, loadSessions, saveSessions } from '@/lib/server/storage'
2
+ import { loadAgents, saveAgents, loadSessions, saveSessions, logActivity } from '@/lib/server/storage'
3
3
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
4
4
  import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
5
5
 
@@ -22,6 +22,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
22
22
  return agent
23
23
  })
24
24
  if (!result) return notFound()
25
+ logActivity({ entityType: 'agent', entityId: id, action: 'updated', actor: 'user', summary: `Agent updated: "${result.name}"` })
25
26
  return NextResponse.json(result)
26
27
  }
27
28
 
@@ -33,6 +34,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
33
34
  return agent
34
35
  })
35
36
  if (!result) return notFound()
37
+ logActivity({ entityType: 'agent', entityId: id, action: 'deleted', actor: 'user', summary: `Agent trashed: "${result.name}"` })
36
38
 
37
39
  // Detach sessions from the trashed agent
38
40
  const sessions = loadSessions()
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadAgents, saveAgents } from '@/lib/server/storage'
3
+ import { loadAgents, saveAgents, logActivity } from '@/lib/server/storage'
4
4
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
6
  export const dynamic = 'force-dynamic'
@@ -33,6 +33,7 @@ export async function POST(req: Request) {
33
33
  updatedAt: now,
34
34
  }
35
35
  saveAgents(agents)
36
+ logActivity({ entityType: 'agent', entityId: id, action: 'created', actor: 'user', summary: `Agent created: "${agents[id].name}"` })
36
37
  notify('agents')
37
38
  return NextResponse.json(agents[id])
38
39
  }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadConnectors, saveConnectors } from '@/lib/server/storage'
2
+ import { loadConnectors, saveConnectors, logActivity } from '@/lib/server/storage'
3
3
  import { notify } from '@/lib/server/ws-hub'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
 
@@ -39,10 +39,13 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
39
39
  const manager = await import('@/lib/server/connectors/manager')
40
40
  if (body.action === 'start') {
41
41
  await manager.startConnector(id)
42
+ logActivity({ entityType: 'connector', entityId: id, action: 'started', actor: 'user', summary: `Connector started: "${connector.name}"` })
42
43
  } else if (body.action === 'stop') {
43
44
  await manager.stopConnector(id)
45
+ logActivity({ entityType: 'connector', entityId: id, action: 'stopped', actor: 'user', summary: `Connector stopped: "${connector.name}"` })
44
46
  } else {
45
47
  await manager.repairConnector(id)
48
+ logActivity({ entityType: 'connector', entityId: id, action: 'started', actor: 'user', summary: `Connector repaired: "${connector.name}"` })
46
49
  }
47
50
  } catch (err: unknown) {
48
51
  // Re-read to get the error state saved by startConnector
@@ -17,6 +17,19 @@ export async function GET() {
17
17
  }
18
18
  }
19
19
 
20
+ /* ── Conflict-detection: track recently resolved approval IDs in-process ── */
21
+ const resolvedKey = '__swarmclaw_resolved_approvals__'
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ const resolved: Map<string, number> = (globalThis as any)[resolvedKey] ?? ((globalThis as any)[resolvedKey] = new Map<string, number>())
24
+ const RESOLVED_TTL_MS = 5 * 60 * 1000
25
+
26
+ function pruneResolved() {
27
+ const cutoff = Date.now() - RESOLVED_TTL_MS
28
+ for (const [k, ts] of resolved) {
29
+ if (ts < cutoff) resolved.delete(k)
30
+ }
31
+ }
32
+
20
33
  /** POST { id, decision } — resolve an execution approval */
21
34
  export async function POST(req: Request) {
22
35
  const body = await req.json()
@@ -31,6 +44,12 @@ export async function POST(req: Request) {
31
44
  return NextResponse.json({ error: 'Invalid decision' }, { status: 400 })
32
45
  }
33
46
 
47
+ // Conflict detection — prevent duplicate resolution
48
+ pruneResolved()
49
+ if (resolved.has(id)) {
50
+ return NextResponse.json({ error: 'Already resolved' }, { status: 409 })
51
+ }
52
+
34
53
  const gw = await ensureGatewayConnected()
35
54
  if (!gw) {
36
55
  return NextResponse.json({ error: 'OpenClaw gateway not connected' }, { status: 503 })
@@ -38,6 +57,7 @@ export async function POST(req: Request) {
38
57
 
39
58
  try {
40
59
  await gw.rpc('exec.approvals.resolve', { id, decision })
60
+ resolved.set(id, Date.now())
41
61
  return NextResponse.json({ ok: true })
42
62
  } catch (err: unknown) {
43
63
  const message = err instanceof Error ? err.message : String(err)
@@ -1,6 +1,6 @@
1
1
  import { genId } from '@/lib/id'
2
2
  import { NextResponse } from 'next/server'
3
- import { loadTasks, saveTasks } from '@/lib/server/storage'
3
+ import { loadTasks, saveTasks, logActivity } from '@/lib/server/storage'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { disableSessionHeartbeat, enqueueTask, validateCompletedTasksQueue } from '@/lib/server/queue'
6
6
  import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
@@ -65,6 +65,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
65
65
  }
66
66
 
67
67
  saveTasks(tasks)
68
+ logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Task updated: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})` })
68
69
  if (prevStatus !== tasks[id].status) {
69
70
  pushMainLoopEventToMainSessions({
70
71
  type: 'task_status_changed',
@@ -77,6 +78,40 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
77
78
  disableSessionHeartbeat(tasks[id].sessionId)
78
79
  }
79
80
 
81
+ // Dependency check: cannot queue a task if any blocker is incomplete
82
+ if (tasks[id].status === 'queued') {
83
+ const blockers = Array.isArray(tasks[id].blockedBy) ? tasks[id].blockedBy : []
84
+ const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
85
+ if (incompleteBlocker) {
86
+ // Revert status change and reject
87
+ tasks[id].status = prevStatus
88
+ tasks[id].updatedAt = Date.now()
89
+ saveTasks(tasks)
90
+ return NextResponse.json(
91
+ { error: 'Cannot queue: blocked by incomplete tasks', blockedBy: incompleteBlocker },
92
+ { status: 409 },
93
+ )
94
+ }
95
+ }
96
+
97
+ // When a task is completed, auto-unblock dependent tasks
98
+ if (tasks[id].status === 'completed') {
99
+ const blockedIds = Array.isArray(tasks[id].blocks) ? tasks[id].blocks as string[] : []
100
+ for (const blockedId of blockedIds) {
101
+ const blocked = tasks[blockedId]
102
+ if (!blocked) continue
103
+ const deps = Array.isArray(blocked.blockedBy) ? blocked.blockedBy as string[] : []
104
+ const allDone = deps.every((depId: string) => tasks[depId]?.status === 'completed')
105
+ if (allDone && (blocked.status === 'backlog' || blocked.status === 'todo')) {
106
+ blocked.status = 'queued'
107
+ blocked.queuedAt = Date.now()
108
+ blocked.updatedAt = Date.now()
109
+ saveTasks(tasks)
110
+ enqueueTask(blockedId)
111
+ }
112
+ }
113
+ }
114
+
80
115
  // If status changed to 'queued', enqueue it
81
116
  if (prevStatus !== 'queued' && tasks[id].status === 'queued') {
82
117
  enqueueTask(id)
@@ -96,6 +131,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
96
131
  tasks[id].archivedAt = Date.now()
97
132
  tasks[id].updatedAt = Date.now()
98
133
  saveTasks(tasks)
134
+ logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Task archived: "${tasks[id].title}"` })
99
135
  pushMainLoopEventToMainSessions({
100
136
  type: 'task_archived',
101
137
  text: `Task archived: "${tasks[id].title}" (${id}).`,
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadTasks, saveTasks, loadSettings } from '@/lib/server/storage'
3
+ import { loadTasks, saveTasks, loadSettings, 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'
@@ -88,6 +88,11 @@ export async function POST(req: Request) {
88
88
  retryScheduledAt: null,
89
89
  deadLetteredAt: null,
90
90
  checkpoint: null,
91
+ blockedBy: Array.isArray(body.blockedBy) ? body.blockedBy.filter((s: unknown) => typeof s === 'string') : [],
92
+ blocks: Array.isArray(body.blocks) ? body.blocks.filter((s: unknown) => typeof s === 'string') : [],
93
+ tags: Array.isArray(body.tags) ? body.tags.filter((s: unknown) => typeof s === 'string') : [],
94
+ dueAt: typeof body.dueAt === 'number' ? body.dueAt : null,
95
+ customFields: body.customFields && typeof body.customFields === 'object' ? body.customFields : undefined,
91
96
  }
92
97
 
93
98
  if (tasks[id].status === 'completed') {
@@ -106,6 +111,7 @@ export async function POST(req: Request) {
106
111
  }
107
112
 
108
113
  saveTasks(tasks)
114
+ logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${tasks[id].title}"` })
109
115
  pushMainLoopEventToMainSessions({
110
116
  type: 'task_created',
111
117
  text: `Task created: "${tasks[id].title}" (${id}) with status ${tasks[id].status}.`,
@@ -1,37 +1,89 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { loadUsage } from '@/lib/server/storage'
3
+ import type { UsageRecord } from '@/types'
3
4
  export const dynamic = 'force-dynamic'
4
5
 
6
+ type Range = '24h' | '7d' | '30d'
5
7
 
6
- export async function GET(_req: Request) {
7
- const usage = loadUsage()
8
- // Compute summary
8
+ const RANGE_MS: Record<Range, number> = {
9
+ '24h': 24 * 60 * 60 * 1000,
10
+ '7d': 7 * 24 * 60 * 60 * 1000,
11
+ '30d': 30 * 24 * 60 * 60 * 1000,
12
+ }
13
+
14
+ function bucketKey(ts: number, range: Range): string {
15
+ const d = new Date(ts)
16
+ if (range === '24h') {
17
+ // hourly buckets: "2026-03-01T14"
18
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T${String(d.getHours()).padStart(2, '0')}`
19
+ }
20
+ // daily buckets: "2026-03-01"
21
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
22
+ }
23
+
24
+ export async function GET(req: Request) {
25
+ const { searchParams } = new URL(req.url)
26
+ const rangeParam = searchParams.get('range') ?? '24h'
27
+ const range: Range = rangeParam === '7d' || rangeParam === '30d' ? rangeParam : '24h'
28
+
29
+ const now = Date.now()
30
+ const cutoff = now - RANGE_MS[range]
31
+
32
+ const usage = loadUsage() as Record<string, UsageRecord[]>
33
+
34
+ // Flatten and filter by time range
35
+ const records: UsageRecord[] = []
36
+ for (const sessionRecords of Object.values(usage)) {
37
+ for (const r of sessionRecords) {
38
+ if ((r.timestamp || 0) >= cutoff) {
39
+ records.push(r)
40
+ }
41
+ }
42
+ }
43
+
44
+ // Compute summaries
9
45
  let totalTokens = 0
10
46
  let totalCost = 0
11
- const bySession: Record<string, { tokens: number; cost: number; count: number }> = {}
12
- const byProvider: Record<string, { tokens: number; cost: number; count: number }> = {}
13
-
14
- for (const [sessionId, records] of Object.entries(usage)) {
15
- for (const r of records) {
16
- totalTokens += r.totalTokens || 0
17
- totalCost += r.estimatedCost || 0
18
- if (!bySession[sessionId]) bySession[sessionId] = { tokens: 0, cost: 0, count: 0 }
19
- bySession[sessionId].tokens += r.totalTokens || 0
20
- bySession[sessionId].cost += r.estimatedCost || 0
21
- bySession[sessionId].count++
22
- const prov = r.provider || 'unknown'
23
- if (!byProvider[prov]) byProvider[prov] = { tokens: 0, cost: 0, count: 0 }
24
- byProvider[prov].tokens += r.totalTokens || 0
25
- byProvider[prov].cost += r.estimatedCost || 0
26
- byProvider[prov].count++
27
- }
47
+ const byAgent: Record<string, { tokens: number; cost: number }> = {}
48
+ const byProvider: Record<string, { tokens: number; cost: number }> = {}
49
+ const bucketMap: Record<string, { tokens: number; cost: number }> = {}
50
+
51
+ for (const r of records) {
52
+ const tokens = r.totalTokens || 0
53
+ const cost = r.estimatedCost || 0
54
+ totalTokens += tokens
55
+ totalCost += cost
56
+
57
+ // by provider
58
+ const prov = r.provider || 'unknown'
59
+ if (!byProvider[prov]) byProvider[prov] = { tokens: 0, cost: 0 }
60
+ byProvider[prov].tokens += tokens
61
+ byProvider[prov].cost += cost
62
+
63
+ // by agent (using sessionId as proxy — agents map to sessions)
64
+ const agentKey = r.sessionId || 'unknown'
65
+ if (!byAgent[agentKey]) byAgent[agentKey] = { tokens: 0, cost: 0 }
66
+ byAgent[agentKey].tokens += tokens
67
+ byAgent[agentKey].cost += cost
68
+
69
+ // time series bucketing
70
+ const bk = bucketKey(r.timestamp || now, range)
71
+ if (!bucketMap[bk]) bucketMap[bk] = { tokens: 0, cost: 0 }
72
+ bucketMap[bk].tokens += tokens
73
+ bucketMap[bk].cost += cost
28
74
  }
29
75
 
76
+ // Sort time series
77
+ const timeSeries = Object.entries(bucketMap)
78
+ .sort(([a], [b]) => a.localeCompare(b))
79
+ .map(([bucket, data]) => ({ bucket, tokens: data.tokens, cost: Math.round(data.cost * 10000) / 10000 }))
80
+
30
81
  return NextResponse.json({
82
+ records,
31
83
  totalTokens,
32
84
  totalCost: Math.round(totalCost * 10000) / 10000,
33
- bySession,
85
+ byAgent,
34
86
  byProvider,
35
- raw: usage,
87
+ timeSeries,
36
88
  })
37
89
  }
@@ -1,9 +1,10 @@
1
1
  import { genId } from '@/lib/id'
2
2
  import { NextResponse } from 'next/server'
3
- import { loadAgents, loadSessions, loadWebhooks, saveSessions, saveWebhooks, appendWebhookLog } from '@/lib/server/storage'
3
+ import { loadAgents, loadSessions, loadWebhooks, saveSessions, saveWebhooks, appendWebhookLog, upsertWebhookRetry } from '@/lib/server/storage'
4
4
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
5
5
  import { enqueueSessionRun } from '@/lib/server/session-run-manager'
6
6
  import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
7
+ import type { WebhookRetryEntry } from '@/types'
7
8
 
8
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
10
  const ops: CollectionOps<any> = { load: loadWebhooks, save: saveWebhooks }
@@ -129,7 +130,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
129
130
 
130
131
  const sessions = loadSessions()
131
132
  const sessionName = `webhook:${id}`
132
- let session = Object.values(sessions).find((s: any) => s.name === sessionName && s.agentId === agent.id) as any
133
+ let session = Object.values(sessions).find((s: unknown) => {
134
+ const rec = s as Record<string, unknown>
135
+ return rec.name === sessionName && rec.agentId === agent.id
136
+ }) as Record<string, unknown> | undefined
133
137
  if (!session) {
134
138
  const sessionId = genId()
135
139
  const now = Date.now()
@@ -160,10 +164,11 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
160
164
  heartbeatEnabled: agent.heartbeatEnabled ?? true,
161
165
  heartbeatIntervalSec: agent.heartbeatIntervalSec ?? null,
162
166
  }
163
- sessions[session.id] = session
167
+ sessions[session.id as string] = session
164
168
  saveSessions(sessions)
165
169
  }
166
170
 
171
+ const sid = session.id as string
167
172
  const payloadPreview = (rawBody || '').slice(0, 12_000)
168
173
  const prompt = [
169
174
  'Webhook event received.',
@@ -179,25 +184,60 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
179
184
  'Handle this event now. If this requires notifying the user, use configured connector tools.',
180
185
  ].join('\n')
181
186
 
182
- const run = enqueueSessionRun({
183
- sessionId: session.id,
184
- message: prompt,
185
- source: 'webhook',
186
- internal: false,
187
- mode: 'followup',
188
- })
187
+ try {
188
+ const run = enqueueSessionRun({
189
+ sessionId: sid,
190
+ message: prompt,
191
+ source: 'webhook',
192
+ internal: false,
193
+ mode: 'followup',
194
+ })
189
195
 
190
- appendWebhookLog(genId(8), {
191
- id: genId(8), webhookId: id, event: incomingEvent,
192
- payload: (rawBody || '').slice(0, 2000), status: 'success',
193
- sessionId: session.id, runId: run.runId, timestamp: Date.now(),
194
- })
196
+ appendWebhookLog(genId(8), {
197
+ id: genId(8), webhookId: id, event: incomingEvent,
198
+ payload: (rawBody || '').slice(0, 2000), status: 'success',
199
+ sessionId: sid, runId: run.runId, timestamp: Date.now(),
200
+ })
195
201
 
196
- return NextResponse.json({
197
- ok: true,
198
- webhookId: id,
199
- event: incomingEvent,
200
- sessionId: session.id,
201
- runId: run.runId,
202
- })
202
+ return NextResponse.json({
203
+ ok: true,
204
+ webhookId: id,
205
+ event: incomingEvent,
206
+ sessionId: sid,
207
+ runId: run.runId,
208
+ })
209
+ } catch (err: unknown) {
210
+ const errorMsg = err instanceof Error ? err.message : String(err)
211
+
212
+ // Enqueue for retry with exponential backoff
213
+ const retryId = genId()
214
+ const now = Date.now()
215
+ const retryEntry: WebhookRetryEntry = {
216
+ id: retryId,
217
+ webhookId: id,
218
+ event: incomingEvent,
219
+ payload: (rawBody || '').slice(0, 12_000),
220
+ attempts: 1,
221
+ maxAttempts: 3,
222
+ nextRetryAt: now + 30_000,
223
+ deadLettered: false,
224
+ createdAt: now,
225
+ }
226
+ upsertWebhookRetry(retryId, retryEntry)
227
+
228
+ appendWebhookLog(genId(8), {
229
+ id: genId(8), webhookId: id, event: incomingEvent,
230
+ payload: (rawBody || '').slice(0, 2000), status: 'error',
231
+ error: `Dispatch failed, queued for retry: ${errorMsg}`, timestamp: Date.now(),
232
+ })
233
+
234
+ return NextResponse.json({
235
+ ok: true,
236
+ webhookId: id,
237
+ event: incomingEvent,
238
+ retryQueued: true,
239
+ retryId,
240
+ error: errorMsg,
241
+ })
242
+ }
203
243
  }
package/src/cli/index.js CHANGED
@@ -23,6 +23,13 @@ const COMMAND_GROUPS = [
23
23
  cmd('thread', 'POST', '/agents/:id/thread', 'Get or create agent thread session'),
24
24
  ],
25
25
  },
26
+ {
27
+ name: 'activity',
28
+ description: 'Query activity feed events',
29
+ commands: [
30
+ cmd('list', 'GET', '/activity', 'List activity events (use --query limit=50, --query entityType=task, --query action=updated)'),
31
+ ],
32
+ },
26
33
  {
27
34
  name: 'auth',
28
35
  description: 'Access key auth helpers',
package/src/cli/spec.js CHANGED
@@ -12,6 +12,12 @@ const COMMAND_GROUPS = {
12
12
  purge: { description: 'Permanently delete a trashed agent', method: 'DELETE', path: '/agents/trash' },
13
13
  },
14
14
  },
15
+ activity: {
16
+ description: 'Query activity feed events',
17
+ commands: {
18
+ list: { description: 'List activity events (supports --query limit=50,entityType=task,action=updated)', method: 'GET', path: '/activity' },
19
+ },
20
+ },
15
21
  auth: {
16
22
  description: 'Access-key auth checks',
17
23
  commands: {
@@ -0,0 +1,91 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { useWs } from '@/hooks/use-ws'
6
+ import type { ActivityEntry } from '@/types'
7
+
8
+ const ENTITY_ICONS: Record<string, string> = {
9
+ agent: 'A', task: 'T', connector: 'C', session: 'S', webhook: 'W', schedule: 'R',
10
+ }
11
+
12
+ const ACTION_COLORS: Record<string, string> = {
13
+ created: 'bg-emerald-500/15 text-emerald-400',
14
+ updated: 'bg-blue-500/15 text-blue-400',
15
+ deleted: 'bg-red-500/15 text-red-400',
16
+ started: 'bg-green-500/15 text-green-400',
17
+ stopped: 'bg-gray-500/15 text-gray-400',
18
+ queued: 'bg-amber-500/15 text-amber-400',
19
+ completed: 'bg-emerald-500/15 text-emerald-400',
20
+ failed: 'bg-red-500/15 text-red-400',
21
+ approved: 'bg-green-500/15 text-green-400',
22
+ rejected: 'bg-red-500/15 text-red-400',
23
+ }
24
+
25
+ function timeAgo(ts: number) {
26
+ const diff = Date.now() - ts
27
+ if (diff < 60_000) return 'just now'
28
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`
29
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`
30
+ return `${Math.floor(diff / 86400_000)}d ago`
31
+ }
32
+
33
+ const ENTITY_TYPES = ['', 'agent', 'task', 'connector', 'session', 'webhook', 'schedule'] as const
34
+
35
+ export function ActivityFeed() {
36
+ const entries = useAppStore((s) => s.activityEntries)
37
+ const loadActivity = useAppStore((s) => s.loadActivity)
38
+ const [filterType, setFilterType] = useState('')
39
+
40
+ useEffect(() => { loadActivity({ entityType: filterType || undefined, limit: 100 }) }, [filterType])
41
+ useWs('activity', () => loadActivity({ entityType: filterType || undefined, limit: 100 }), 10_000)
42
+
43
+ return (
44
+ <div className="flex-1 flex flex-col h-full overflow-hidden">
45
+ <div className="flex items-center justify-between px-8 pt-6 pb-4 shrink-0">
46
+ <div>
47
+ <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Activity</h1>
48
+ <p className="text-[13px] text-text-3 mt-1">Audit trail of all entity mutations</p>
49
+ </div>
50
+ <select
51
+ value={filterType}
52
+ onChange={(e) => setFilterType(e.target.value)}
53
+ className="px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03] appearance-none"
54
+ style={{ fontFamily: 'inherit', minWidth: 130 }}
55
+ >
56
+ <option value="">All Types</option>
57
+ {ENTITY_TYPES.filter(Boolean).map((t) => (
58
+ <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}s</option>
59
+ ))}
60
+ </select>
61
+ </div>
62
+
63
+ <div className="flex-1 overflow-y-auto px-8 pb-6">
64
+ {entries.length === 0 ? (
65
+ <div className="text-center text-text-3 text-[14px] mt-16">No activity yet</div>
66
+ ) : (
67
+ <div className="space-y-1">
68
+ {entries.map((entry: ActivityEntry) => (
69
+ <div key={entry.id} className="flex items-start gap-3 py-3 border-b border-white/[0.04]">
70
+ <div className="w-8 h-8 rounded-[8px] bg-surface-2 flex items-center justify-center text-[12px] font-700 text-text-3 shrink-0">
71
+ {ENTITY_ICONS[entry.entityType] || '?'}
72
+ </div>
73
+ <div className="flex-1 min-w-0">
74
+ <div className="flex items-center gap-2 mb-1">
75
+ <span className={`px-1.5 py-0.5 rounded-[5px] text-[10px] font-600 ${ACTION_COLORS[entry.action] || 'bg-white/[0.06] text-text-3'}`}>
76
+ {entry.action}
77
+ </span>
78
+ <span className="text-[10px] text-text-3/50 font-mono">{entry.entityType}</span>
79
+ <span className="text-[10px] text-text-3/40">{entry.actor}</span>
80
+ </div>
81
+ <p className="text-[13px] text-text-2 leading-[1.4] truncate">{entry.summary}</p>
82
+ </div>
83
+ <span className="text-[11px] text-text-3/50 shrink-0 pt-1">{timeAgo(entry.timestamp)}</span>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ )}
88
+ </div>
89
+ </div>
90
+ )
91
+ }