@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
@@ -0,0 +1,278 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, useCallback } from 'react'
4
+ import {
5
+ LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
6
+ XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
7
+ } from 'recharts'
8
+ import { useAppStore } from '@/stores/use-app-store'
9
+ import { useWs } from '@/hooks/use-ws'
10
+ import { api } from '@/lib/api-client'
11
+ import type { BoardTask } from '@/types'
12
+
13
+ type Range = '24h' | '7d' | '30d'
14
+
15
+ interface TimePoint {
16
+ bucket: string
17
+ tokens: number
18
+ cost: number
19
+ }
20
+
21
+ interface UsageResponse {
22
+ records: unknown[]
23
+ totalTokens: number
24
+ totalCost: number
25
+ byAgent: Record<string, { tokens: number; cost: number }>
26
+ byProvider: Record<string, { tokens: number; cost: number }>
27
+ timeSeries: TimePoint[]
28
+ }
29
+
30
+ const RANGES: Range[] = ['24h', '7d', '30d']
31
+ const RANGE_LABELS: Record<Range, string> = { '24h': '24 Hours', '7d': '7 Days', '30d': '30 Days' }
32
+
33
+ const CHART_COLORS = [
34
+ '#818CF8', '#34D399', '#F59E0B', '#F87171',
35
+ '#A78BFA', '#2DD4BF', '#FB923C', '#E879F9',
36
+ '#60A5FA', '#4ADE80',
37
+ ]
38
+
39
+ function formatTokens(n: number): string {
40
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
41
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
42
+ return String(n)
43
+ }
44
+
45
+ function formatCost(n: number): string {
46
+ return `$${n.toFixed(4)}`
47
+ }
48
+
49
+ function formatBucketLabel(bucket: string, range: Range): string {
50
+ if (range === '24h') {
51
+ // "2026-03-01T14" → "14:00"
52
+ const hour = bucket.split('T')[1]
53
+ return hour ? `${hour}:00` : bucket
54
+ }
55
+ // "2026-03-01" → "Mar 1"
56
+ const parts = bucket.split('-')
57
+ if (parts.length === 3) {
58
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
59
+ const monthIdx = parseInt(parts[1], 10) - 1
60
+ return `${months[monthIdx]} ${parseInt(parts[2], 10)}`
61
+ }
62
+ return bucket
63
+ }
64
+
65
+ function computeCompletionRate(tasks: Record<string, BoardTask>): number {
66
+ const all = Object.values(tasks)
67
+ const eligible = all.filter((t) => t.status !== 'backlog' && t.status !== 'archived')
68
+ if (eligible.length === 0) return 0
69
+ const completed = eligible.filter((t) => t.status === 'completed').length
70
+ return Math.round((completed / eligible.length) * 100)
71
+ }
72
+
73
+ export function MetricsDashboard() {
74
+ const [range, setRange] = useState<Range>('24h')
75
+ const [data, setData] = useState<UsageResponse | null>(null)
76
+ const [loading, setLoading] = useState(true)
77
+ const tasks = useAppStore((s) => s.tasks)
78
+ const loadTasks = useAppStore((s) => s.loadTasks)
79
+
80
+ const loadData = useCallback(async () => {
81
+ try {
82
+ const res = await api<UsageResponse>('GET', `/usage?range=${range}`)
83
+ setData(res)
84
+ } catch {
85
+ // ignore
86
+ } finally {
87
+ setLoading(false)
88
+ }
89
+ }, [range])
90
+
91
+ useEffect(() => {
92
+ setLoading(true)
93
+ loadData()
94
+ }, [loadData])
95
+
96
+ useEffect(() => {
97
+ loadTasks()
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [])
100
+
101
+ useWs('usage', loadData, 30_000)
102
+
103
+ const completionRate = computeCompletionRate(tasks)
104
+
105
+ // Prepare chart data
106
+ const timeSeriesFormatted = (data?.timeSeries ?? []).map((pt) => ({
107
+ ...pt,
108
+ label: formatBucketLabel(pt.bucket, range),
109
+ }))
110
+
111
+ const providerData = Object.entries(data?.byProvider ?? {}).map(([name, v]) => ({
112
+ name,
113
+ cost: Math.round(v.cost * 10000) / 10000,
114
+ tokens: v.tokens,
115
+ }))
116
+
117
+ const agentData = Object.entries(data?.byAgent ?? {})
118
+ .sort((a, b) => b[1].cost - a[1].cost)
119
+ .slice(0, 10)
120
+ .map(([name, v]) => ({
121
+ name: name.length > 12 ? name.slice(0, 12) + '…' : name,
122
+ cost: Math.round(v.cost * 10000) / 10000,
123
+ }))
124
+
125
+ const tooltipStyle = {
126
+ contentStyle: {
127
+ background: '#1a1a2e',
128
+ border: '1px solid rgba(255,255,255,0.08)',
129
+ borderRadius: 8,
130
+ fontSize: 12,
131
+ color: '#e0e0e0',
132
+ },
133
+ itemStyle: { color: '#e0e0e0' },
134
+ labelStyle: { color: '#a0a0b0' },
135
+ }
136
+
137
+ return (
138
+ <div className="flex-1 flex flex-col h-full overflow-y-auto">
139
+ <div className="px-8 pt-6 pb-4 shrink-0">
140
+ <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Usage</h1>
141
+ <p className="text-[13px] text-text-3 mt-1">Token usage, cost tracking &amp; agent performance</p>
142
+ </div>
143
+
144
+ {/* Range tabs */}
145
+ <div className="px-8 pb-4 shrink-0">
146
+ <div className="flex gap-1 bg-surface-2 rounded-[10px] p-1 w-fit">
147
+ {RANGES.map((r) => (
148
+ <button
149
+ key={r}
150
+ onClick={() => setRange(r)}
151
+ className={`px-3.5 py-1.5 rounded-[8px] text-[12px] font-600 transition-all cursor-pointer ${
152
+ range === r
153
+ ? 'bg-accent-soft text-accent-bright'
154
+ : 'text-text-3 hover:text-text-2'
155
+ }`}
156
+ >
157
+ {RANGE_LABELS[r]}
158
+ </button>
159
+ ))}
160
+ </div>
161
+ </div>
162
+
163
+ {loading && !data ? (
164
+ <div className="flex-1 flex items-center justify-center">
165
+ <p className="text-text-3 text-[13px]">Loading metrics…</p>
166
+ </div>
167
+ ) : (
168
+ <div className="px-8 pb-8 space-y-6">
169
+ {/* Stats cards */}
170
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
171
+ <StatCard label="Total Tokens" value={formatTokens(data?.totalTokens ?? 0)} />
172
+ <StatCard label="Total Cost" value={formatCost(data?.totalCost ?? 0)} />
173
+ <StatCard label="Requests" value={String(data?.records.length ?? 0)} />
174
+ <StatCard label="Completion Rate" value={`${completionRate}%`} />
175
+ </div>
176
+
177
+ {/* Token usage over time */}
178
+ <ChartCard title="Token Usage Over Time">
179
+ {timeSeriesFormatted.length > 0 ? (
180
+ <ResponsiveContainer width="100%" height={280}>
181
+ <LineChart data={timeSeriesFormatted} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
182
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
183
+ <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
184
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
185
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatTokens(value ?? 0), 'Tokens']} />
186
+ <Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
187
+ </LineChart>
188
+ </ResponsiveContainer>
189
+ ) : (
190
+ <EmptyChart />
191
+ )}
192
+ </ChartCard>
193
+
194
+ {/* Cost by provider + cost by agent */}
195
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
196
+ <ChartCard title="Cost by Provider">
197
+ {providerData.length > 0 ? (
198
+ <ResponsiveContainer width="100%" height={280}>
199
+ <BarChart data={providerData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
200
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
201
+ <XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
202
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v}`} />
203
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
204
+ <Bar dataKey="cost" radius={[4, 4, 0, 0]}>
205
+ {providerData.map((_entry, i) => (
206
+ <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
207
+ ))}
208
+ </Bar>
209
+ </BarChart>
210
+ </ResponsiveContainer>
211
+ ) : (
212
+ <EmptyChart />
213
+ )}
214
+ </ChartCard>
215
+
216
+ <ChartCard title="Cost by Session">
217
+ {agentData.length > 0 ? (
218
+ <ResponsiveContainer width="100%" height={280}>
219
+ <PieChart>
220
+ <Pie
221
+ data={agentData}
222
+ cx="50%"
223
+ cy="50%"
224
+ innerRadius={60}
225
+ outerRadius={100}
226
+ paddingAngle={2}
227
+ dataKey="cost"
228
+ nameKey="name"
229
+ >
230
+ {agentData.map((_entry, i) => (
231
+ <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
232
+ ))}
233
+ </Pie>
234
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
235
+ <Legend
236
+ verticalAlign="bottom"
237
+ iconType="circle"
238
+ iconSize={8}
239
+ formatter={(value: string) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{value}</span>}
240
+ />
241
+ </PieChart>
242
+ </ResponsiveContainer>
243
+ ) : (
244
+ <EmptyChart />
245
+ )}
246
+ </ChartCard>
247
+ </div>
248
+ </div>
249
+ )}
250
+ </div>
251
+ )
252
+ }
253
+
254
+ function StatCard({ label, value }: { label: string; value: string }) {
255
+ return (
256
+ <div className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04]">
257
+ <p className="text-[11px] font-500 text-text-3 uppercase tracking-[0.05em] mb-1">{label}</p>
258
+ <p className="text-[22px] font-display font-700 tracking-[-0.02em] text-text">{value}</p>
259
+ </div>
260
+ )
261
+ }
262
+
263
+ function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
264
+ return (
265
+ <div className="bg-surface-2 rounded-[12px] p-5 border border-white/[0.04]">
266
+ <h3 className="font-display text-[14px] font-600 text-text-2 mb-4">{title}</h3>
267
+ {children}
268
+ </div>
269
+ )
270
+ }
271
+
272
+ function EmptyChart() {
273
+ return (
274
+ <div className="h-[280px] flex items-center justify-center">
275
+ <p className="text-text-3 text-[13px]">No data for this time range</p>
276
+ </div>
277
+ )
278
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ function subscribe(cb: () => void) {
6
+ document.addEventListener('visibilitychange', cb)
7
+ return () => document.removeEventListener('visibilitychange', cb)
8
+ }
9
+
10
+ function getSnapshot(): boolean {
11
+ return document.visibilityState === 'visible'
12
+ }
13
+
14
+ function getServerSnapshot(): boolean {
15
+ return true
16
+ }
17
+
18
+ /** Returns `true` when the page is visible, `false` when hidden. SSR-safe. */
19
+ export function usePageActive(): boolean {
20
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
21
+ }
@@ -2,12 +2,14 @@
2
2
 
3
3
  import { useEffect, useRef } from 'react'
4
4
  import { subscribeWs, unsubscribeWs, isWsConnected } from '@/lib/ws-client'
5
+ import { usePageActive } from './use-page-active'
5
6
 
6
7
  /**
7
8
  * Subscribe to a WebSocket topic. Calls `handler` on push events.
8
9
  * Falls back to polling at `fallbackMs` when WS is disconnected.
9
10
  */
10
11
  export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
12
+ const isActive = usePageActive()
11
13
  const handlerRef = useRef(handler)
12
14
  handlerRef.current = handler
13
15
  const fallbackMsRef = useRef(fallbackMs)
@@ -23,12 +25,21 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
23
25
  }, [topic])
24
26
 
25
27
  // Fallback polling — separate effect so it doesn't tear down WS subscription
28
+ // Pauses when tab is hidden; triggers immediate fetch when tab becomes visible again
26
29
  useEffect(() => {
27
30
  if (!topic) return
28
31
 
29
32
  let fallbackId: ReturnType<typeof setInterval> | null = null
30
33
  const cb = () => handlerRef.current()
31
34
 
35
+ // When page becomes visible again, fire an immediate refresh
36
+ if (isActive) {
37
+ cb()
38
+ }
39
+
40
+ // Don't run polling while the tab is hidden
41
+ if (!isActive) return
42
+
32
43
  const startFallback = () => {
33
44
  const ms = fallbackMsRef.current
34
45
  if (fallbackId || !ms || ms <= 0) return
@@ -62,5 +73,6 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
62
73
  stopFallback()
63
74
  clearInterval(checkId)
64
75
  }
65
- }, [topic])
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [topic, isActive])
66
78
  }
@@ -0,0 +1,20 @@
1
+ const inflight = new Map<string, Promise<Response>>()
2
+
3
+ /**
4
+ * Deduplicates concurrent GET requests to the same URL.
5
+ * Non-GET requests pass through without dedup.
6
+ */
7
+ export function dedupedFetch(url: string, init?: RequestInit): Promise<Response> {
8
+ const method = (init?.method ?? 'GET').toUpperCase()
9
+ if (method !== 'GET') return fetch(url, init)
10
+
11
+ const existing = inflight.get(url)
12
+ if (existing) return existing
13
+
14
+ const promise = fetch(url, init).finally(() => {
15
+ inflight.delete(url)
16
+ })
17
+
18
+ inflight.set(url, promise)
19
+ return promise
20
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Generic optimistic mutation helper for Zustand stores.
3
+ * Applies a patch immediately, then fires the API call.
4
+ * On failure, rolls back to the previous state and calls an optional error handler.
5
+ */
6
+ export async function optimistic<T>(
7
+ storeSetter: (updater: (state: T) => Partial<T>) => void,
8
+ patch: (state: T) => Partial<T>,
9
+ apiCall: () => Promise<unknown>,
10
+ rollback: (state: T) => Partial<T>,
11
+ onError?: (err: unknown) => void,
12
+ ): Promise<boolean> {
13
+ // Apply optimistic update
14
+ storeSetter(patch)
15
+
16
+ try {
17
+ await apiCall()
18
+ return true
19
+ } catch (err: unknown) {
20
+ // Rollback
21
+ storeSetter(rollback)
22
+ if (onError) onError(err)
23
+ return false
24
+ }
25
+ }
@@ -53,6 +53,21 @@ const lockKey = '__swarmclaw_connector_locks__' as const
53
53
  const locks: Map<string, Promise<void>> =
54
54
  g[lockKey] ?? (g[lockKey] = new Map<string, Promise<void>>())
55
55
 
56
+ /** Generation counter per connector — used to detect stale lifecycle events after restart */
57
+ const genCounterKey = '__swarmclaw_connector_gen__' as const
58
+ const generationCounter: Map<string, number> =
59
+ g[genCounterKey] ?? (g[genCounterKey] = new Map<string, number>())
60
+
61
+ /** Get the current generation number for a connector (0 if never started) */
62
+ export function getConnectorGeneration(connectorId: string): number {
63
+ return generationCounter.get(connectorId) ?? 0
64
+ }
65
+
66
+ /** Check whether a given generation is still the current one for a connector */
67
+ export function isCurrentGeneration(connectorId: string, gen: number): boolean {
68
+ return generationCounter.get(connectorId) === gen
69
+ }
70
+
56
71
  /** Get platform implementation lazily */
57
72
  export async function getPlatform(platform: string) {
58
73
  switch (platform) {
@@ -726,6 +741,9 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
726
741
 
727
742
  const platform = await getPlatform(connector.platform)
728
743
 
744
+ // Bump generation counter so stale events from previous instances are ignored
745
+ generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
746
+
729
747
  try {
730
748
  const instance = await platform.start(connector, botToken, (msg) => routeMessage(connector, msg))
731
749
  running.set(connectorId, instance)