@swarmclawai/swarmclaw 0.8.2 → 0.8.4

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 (45) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -2
  3. package/src/app/api/agents/route.ts +6 -3
  4. package/src/app/api/auth/route.ts +20 -10
  5. package/src/app/api/chats/[id]/devserver/route.ts +74 -48
  6. package/src/app/api/chats/[id]/route.ts +16 -1
  7. package/src/app/api/chats/route.ts +14 -6
  8. package/src/app/api/daemon/route.ts +4 -3
  9. package/src/app/api/openclaw/approvals/route.ts +3 -3
  10. package/src/app/api/wallets/[id]/route.ts +18 -4
  11. package/src/app/page.tsx +19 -23
  12. package/src/cli/index.js +1 -1
  13. package/src/cli/spec.js +1 -1
  14. package/src/components/auth/access-key-gate.tsx +5 -3
  15. package/src/components/chat/chat-area.tsx +50 -29
  16. package/src/components/chat/chat-card.tsx +4 -7
  17. package/src/components/chat/chat-header.tsx +19 -13
  18. package/src/components/chat/chat-list.tsx +11 -9
  19. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  20. package/src/components/home/home-view.tsx +6 -2
  21. package/src/components/layout/app-layout.tsx +2 -3
  22. package/src/hooks/use-ws.ts +33 -7
  23. package/src/instrumentation.ts +21 -11
  24. package/src/lib/api-client.test.ts +49 -0
  25. package/src/lib/api-client.ts +53 -30
  26. package/src/lib/chats.ts +3 -0
  27. package/src/lib/runtime-env.test.ts +28 -0
  28. package/src/lib/runtime-env.ts +13 -0
  29. package/src/lib/server/chat-execution.ts +1 -1
  30. package/src/lib/server/connectors/manager.ts +4 -2
  31. package/src/lib/server/daemon-state.test.ts +23 -0
  32. package/src/lib/server/daemon-state.ts +34 -16
  33. package/src/lib/server/heartbeat-service.ts +61 -8
  34. package/src/lib/server/plugins.ts +12 -9
  35. package/src/lib/server/queue.ts +6 -1
  36. package/src/lib/server/storage.ts +100 -8
  37. package/src/lib/server/wallet-portfolio.ts +6 -0
  38. package/src/lib/session-summary.test.ts +49 -0
  39. package/src/lib/session-summary.ts +59 -0
  40. package/src/lib/ws-client.ts +1 -2
  41. package/src/proxy.test.ts +40 -0
  42. package/src/proxy.ts +23 -17
  43. package/src/stores/use-app-store.ts +66 -22
  44. package/src/stores/use-chat-store.ts +2 -2
  45. package/src/types/index.ts +4 -0
@@ -23,6 +23,7 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
23
23
  import { speak } from '@/lib/tts'
24
24
  import { api } from '@/lib/api-client'
25
25
  import { messagesDiffer } from '@/lib/chat-streaming-state'
26
+ import { getSessionLastMessage } from '@/lib/session-summary'
26
27
 
27
28
  const DIRECT_PROMPT_SUGGESTIONS = [
28
29
  { text: 'What can you help me with?', icon: 'book', gradient: 'from-[#6366F1]/10 to-[#818CF8]/5' },
@@ -47,7 +48,7 @@ export function ChatArea() {
47
48
  const currentUser = useAppStore((s) => s.currentUser)
48
49
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
49
50
  const removeSessionFromStore = useAppStore((s) => s.removeSession)
50
- const loadSessions = useAppStore((s) => s.loadSessions)
51
+ const refreshSession = useAppStore((s) => s.refreshSession)
51
52
  const appSettings = useAppStore((s) => s.appSettings)
52
53
  const { messages, setMessages, streaming, streamingSessionId, sendMessage, stopStreaming, devServer: devServerStatus, setDevServer, debugOpen, setDebugOpen, ttsEnabled, previewContent, setPreviewContent } = useChatStore()
53
54
  const isDesktop = useMediaQuery('(min-width: 768px)')
@@ -82,13 +83,17 @@ export function ChatArea() {
82
83
  const [pluginChatActions, setPluginChatActions] = useState<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>([])
83
84
  const sessionHasBrowserPlugin = session?.plugins?.includes('browser') === true
84
85
 
86
+ const refreshPluginChatActions = useCallback(() => {
87
+ api<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>('GET', '/plugins/ui?type=chat_actions').then((actions) => {
88
+ if (Array.isArray(actions)) setPluginChatActions(actions)
89
+ }).catch(() => {})
90
+ }, [])
91
+
85
92
  useEffect(() => {
86
- if (sessionId) {
87
- api<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>('GET', '/plugins/ui?type=chat_actions').then(actions => {
88
- if (Array.isArray(actions)) setPluginChatActions(actions)
89
- }).catch(() => {})
90
- }
91
- }, [sessionId])
93
+ void refreshPluginChatActions()
94
+ }, [refreshPluginChatActions])
95
+
96
+ useWs('plugins', refreshPluginChatActions)
92
97
 
93
98
  // Collect unique connector sources from messages for filter UI
94
99
  const { connectorSources, hasDirectMessages } = useMemo(() => {
@@ -131,7 +136,12 @@ export function ChatArea() {
131
136
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
132
137
  console.error('Failed to load messages:', err)
133
138
  const fallbackSession = useAppStore.getState().sessions[requestedSessionId]
134
- setMessages(fallbackSession?.messages || [])
139
+ const fallbackLastMessage = fallbackSession ? getSessionLastMessage(fallbackSession) : null
140
+ setMessages(
141
+ fallbackSession?.messages?.length
142
+ ? fallbackSession.messages
143
+ : (fallbackLastMessage ? [fallbackLastMessage] : []),
144
+ )
135
145
  }).finally(() => {
136
146
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
137
147
  setMessagesLoading(false)
@@ -142,30 +152,41 @@ export function ChatArea() {
142
152
  useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
143
153
  }
144
154
 
145
- // Refresh active state from server so returning to a session restores typing indicator.
146
- loadSessions().then(() => {
147
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
148
- const refreshed = useAppStore.getState().sessions[requestedSessionId]
149
- if (refreshed?.active) {
150
- useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
151
- }
152
- }).catch((err) => console.error('Failed to refresh messages:', err))
155
+ return () => {
156
+ cancelled = true
157
+ }
158
+ }, [refreshSession, sessionId, setDevServer, setMessages])
153
159
 
154
- devServer(requestedSessionId, 'status').then((r) => {
155
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
156
- setDevServer(r.running ? r : null)
157
- }).catch(() => {
158
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
159
- setDevServer(null)
160
- })
160
+ useEffect(() => {
161
+ if (!sessionId || messagesLoading) return
162
+ let cancelled = false
163
+ const requestedSessionId = sessionId
164
+ const timer = window.setTimeout(() => {
165
+ void refreshSession(requestedSessionId).then(() => {
166
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
167
+ const refreshed = useAppStore.getState().sessions[requestedSessionId]
168
+ if (refreshed?.active) {
169
+ useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
170
+ }
171
+ }).catch((err) => console.error('Failed to refresh session:', err))
172
+
173
+ void devServer(requestedSessionId, 'status').then((r) => {
174
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
175
+ setDevServer(r.running ? r : null)
176
+ }).catch(() => {
177
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
178
+ setDevServer(null)
179
+ })
180
+ }, 200)
161
181
 
162
182
  return () => {
163
183
  cancelled = true
184
+ window.clearTimeout(timer)
164
185
  }
165
- }, [loadSessions, sessionId, setDevServer, setMessages])
186
+ }, [messagesLoading, refreshSession, sessionId, setDevServer])
166
187
 
167
188
  useEffect(() => {
168
- if (!sessionId) return
189
+ if (!sessionId || messagesLoading) return
169
190
  let cancelled = false
170
191
  if (!sessionHasBrowserPlugin) {
171
192
  setBrowserActive(false)
@@ -182,7 +203,7 @@ export function ChatArea() {
182
203
  return () => {
183
204
  cancelled = true
184
205
  }
185
- }, [sessionHasBrowserPlugin, sessionId])
206
+ }, [messagesLoading, sessionHasBrowserPlugin, sessionId])
186
207
 
187
208
  // Auto-poll messages for sessions that are actively running on the server
188
209
  const isServerActive = session?.active === true
@@ -214,7 +235,7 @@ export function ChatArea() {
214
235
  }
215
236
  }
216
237
  }
217
- if (isServerActiveRef.current) await loadSessions()
238
+ if (isServerActiveRef.current) await refreshSession(sessionId)
218
239
  } catch (err) { console.error('Failed to refresh messages:', err) }
219
240
  // eslint-disable-next-line react-hooks/exhaustive-deps
220
241
  }, [sessionId])
@@ -279,8 +300,8 @@ export function ChatArea() {
279
300
  if (!sessionId) return
280
301
  await clearMessages(sessionId)
281
302
  setMessages([])
282
- loadSessions()
283
- }, [loadSessions, sessionId, setMessages])
303
+ await refreshSession(sessionId)
304
+ }, [refreshSession, sessionId, setMessages])
284
305
 
285
306
  const handleDelete = useCallback(async () => {
286
307
  setConfirmDelete(false)
@@ -4,6 +4,7 @@ import { useState } from 'react'
4
4
  import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/heartbeat-defaults'
5
5
  import type { Session } from '@/types'
6
6
  import { api } from '@/lib/api-client'
7
+ import { getSessionLastAssistantAt, getSessionLastMessage } from '@/lib/session-summary'
7
8
  import { useAppStore } from '@/stores/use-app-store'
8
9
  import { useChatStore } from '@/stores/use-chat-store'
9
10
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
@@ -69,9 +70,7 @@ export function ChatCard({ session, active, onClick }: Props) {
69
70
  }
70
71
  }
71
72
 
72
- const last = session.messages?.length
73
- ? session.messages[session.messages.length - 1]
74
- : null
73
+ const last = getSessionLastMessage(session)
75
74
  const preview = last
76
75
  ? (last.role === 'user' ? 'You: ' : '') + last.text.slice(0, 70)
77
76
  : 'No messages'
@@ -132,12 +131,10 @@ export function ChatCard({ session, active, onClick }: Props) {
132
131
  )}
133
132
  {(() => {
134
133
  const lastRead = lastReadTimestamps[session.id] || 0
135
- const unread = (session.messages || []).filter(
136
- (m) => m.role === 'assistant' && (m.time || 0) > lastRead,
137
- ).length
134
+ const unread = (getSessionLastAssistantAt(session) || 0) > lastRead ? 1 : 0
138
135
  return unread > 0 ? (
139
136
  <span className="shrink-0 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-accent-bright text-white text-[10px] font-600 px-1">
140
- {unread > 99 ? '99+' : unread}
137
+ {unread}
141
138
  </span>
142
139
  ) : null
143
140
  })()}
@@ -138,7 +138,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
138
138
  const setMemoryAgentFilter = useAppStore((s) => s.setMemoryAgentFilter)
139
139
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
140
140
  const appSettings = useAppStore((s) => s.appSettings)
141
- const loadSessions = useAppStore((s) => s.loadSessions)
141
+ const refreshSession = useAppStore((s) => s.refreshSession)
142
142
  const loadAgents = useAppStore((s) => s.loadAgents)
143
143
  const inspectorOpen = useAppStore((s) => s.inspectorOpen)
144
144
  const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
@@ -174,11 +174,17 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
174
174
  const agentWalletIds = useMemo(() => getAgentWalletIds(agent), [agent])
175
175
  const activeWalletId = useMemo(() => getAgentActiveWalletId(agent), [agent])
176
176
 
177
- useEffect(() => {
178
- api<Array<{ id: string; label: string; icon?: string }>>('GET', `/plugins/ui?type=header&sessionId=${session.id}`).then(widgets => {
177
+ const refreshHeaderWidgets = useCallback(() => {
178
+ api<Array<{ id: string; label: string; icon?: string }>>('GET', '/plugins/ui?type=header').then((widgets) => {
179
179
  if (Array.isArray(widgets)) setHeaderWidgets(widgets)
180
180
  }).catch(() => {})
181
- }, [session.id])
181
+ }, [])
182
+
183
+ useEffect(() => {
184
+ void refreshHeaderWidgets()
185
+ }, [refreshHeaderWidgets])
186
+
187
+ useWs('plugins', refreshHeaderWidgets)
182
188
 
183
189
  const fetchWalletBalance = useCallback(async () => {
184
190
  if (!activeWalletId) {
@@ -186,7 +192,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
186
192
  return
187
193
  }
188
194
  try {
189
- const data = await api<{ balanceFormatted?: string; balanceSymbol?: string; portfolioSummary?: { nonZeroAssets?: number } }>('GET', `/wallets/${activeWalletId}`)
195
+ const data = await api<{ balanceFormatted?: string; balanceSymbol?: string; portfolioSummary?: { nonZeroAssets?: number } }>('GET', `/wallets/${activeWalletId}?cached=1`)
190
196
  if (data.balanceFormatted && data.balanceSymbol) {
191
197
  setWalletBalance({
192
198
  formatted: data.balanceFormatted,
@@ -340,7 +346,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
340
346
  opencodeSessionId: null,
341
347
  delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
342
348
  })
343
- await loadSessions()
349
+ await refreshSession(session.id)
344
350
  } catch { /* best-effort */ }
345
351
  }
346
352
 
@@ -401,10 +407,10 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
401
407
  await api('PUT', `/agents/${session.agentId}`, { heartbeatEnabled: next })
402
408
  // Clear any stale session-level override so the agent value wins
403
409
  await api('PUT', `/chats/${session.id}`, { heartbeatEnabled: null })
404
- await Promise.all([loadAgents(), loadSessions()])
410
+ await Promise.all([loadAgents(), refreshSession(session.id)])
405
411
  } else {
406
412
  await api('PUT', `/chats/${session.id}`, { heartbeatEnabled: next })
407
- await loadSessions()
413
+ await refreshSession(session.id)
408
414
  }
409
415
  toast.success(`Heartbeat ${next ? 'enabled' : 'disabled'}`)
410
416
  } finally {
@@ -425,10 +431,10 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
425
431
  })
426
432
  // Clear stale session-level overrides
427
433
  await api('PUT', `/chats/${session.id}`, { heartbeatIntervalSec: null, heartbeatEnabled: null })
428
- await Promise.all([loadAgents(), loadSessions()])
434
+ await Promise.all([loadAgents(), refreshSession(session.id)])
429
435
  } else {
430
436
  await api('PUT', `/chats/${session.id}`, { heartbeatIntervalSec: sec })
431
- await loadSessions()
437
+ await refreshSession(session.id)
432
438
  }
433
439
  } finally {
434
440
  setHeartbeatSaving(false)
@@ -455,7 +461,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
455
461
  { sessionKey: openclawSessionKey, epoch: preview.epoch, localSessionId: session.id },
456
462
  )
457
463
  setSyncResult(result.merged > 0 ? `Synced ${result.merged} message${result.merged !== 1 ? 's' : ''}.` : 'Already up to date.')
458
- if (result.merged > 0) await loadSessions()
464
+ if (result.merged > 0) await refreshSession(session.id)
459
465
  } catch (err: unknown) {
460
466
  setSyncResult(err instanceof Error ? err.message : 'Sync failed.')
461
467
  } finally {
@@ -548,7 +554,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
548
554
  setModelSwitcherOpen(false)
549
555
  try {
550
556
  await api('PUT', `/chats/${session.id}`, { provider: nextProvider, model: nextModel })
551
- await loadSessions()
557
+ await refreshSession(session.id)
552
558
  } catch (err: unknown) {
553
559
  toast.error(err instanceof Error ? err.message : 'Failed to switch model')
554
560
  }
@@ -863,7 +869,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
863
869
  </Tip>
864
870
  {hbDropdownOpen && (
865
871
  <div className="absolute top-full right-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[88px]">
866
- {[...(typeof window !== 'undefined' && window.location.hostname === 'localhost' ? [10, 15, 30, 60] : []), 1800, 3600, 7200, 21600, 43200].map((sec) => (
872
+ {[...(typeof window !== 'undefined' && window.location.hostname === 'localhost' ? [60, 300] : []), 1800, 3600, 7200, 21600, 43200].map((sec) => (
867
873
  <button
868
874
  key={sec}
869
875
  onClick={() => handleSelectHeartbeatInterval(sec)}
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { ChatCard } from './chat-card'
7
7
  import { fetchMessages } from '@/lib/chats'
8
+ import { getSessionLastAssistantAt, getSessionLastMessage, getSessionMessageCount } from '@/lib/session-summary'
8
9
  import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
9
10
  import { toast } from 'sonner'
10
11
  import { Skeleton } from '@/components/shared/skeleton'
@@ -25,7 +26,6 @@ export function ChatList({ inSidebar, onSelect }: Props) {
25
26
  const currentUser = useAppStore((s) => s.currentUser)
26
27
  const currentSessionId = useAppStore((s) => s.currentSessionId)
27
28
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
28
- const loadSessions = useAppStore((s) => s.loadSessions)
29
29
  const loadConnectors = useAppStore((s) => s.loadConnectors)
30
30
  const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
31
31
  const clearSessions = useAppStore((s) => s.clearSessions)
@@ -49,7 +49,10 @@ export function ChatList({ inSidebar, onSelect }: Props) {
49
49
  }, [sessions, loaded])
50
50
 
51
51
  useEffect(() => {
52
- void loadConnectors()
52
+ const timer = window.setTimeout(() => {
53
+ void loadConnectors()
54
+ }, 1200)
55
+ return () => window.clearTimeout(timer)
53
56
  }, [loadConnectors])
54
57
 
55
58
  useEffect(() => {
@@ -65,13 +68,11 @@ export function ChatList({ inSidebar, onSelect }: Props) {
65
68
  const filtered = useMemo(() => {
66
69
  return allUserSessions
67
70
  .filter((s) => {
68
- const unreadCount = (s.messages || []).filter(
69
- (m) => m.role === 'assistant' && (m.time || 0) > (lastReadTimestamps[s.id] || 0),
70
- ).length
71
+ const unreadCount = (getSessionLastAssistantAt(s) || 0) > (lastReadTimestamps[s.id] || 0) ? 1 : 0
71
72
  if (search) {
72
73
  const agent = s.agentId ? agents[s.agentId] : null
73
74
  const connector = Object.values(connectors).find((item) => item.chatroomId == null && item.agentId === s.agentId && item.isEnabled !== false)
74
- const lastMessage = s.messages?.[s.messages.length - 1]
75
+ const lastMessage = getSessionLastMessage(s)
75
76
  const haystack = [
76
77
  s.name,
77
78
  agent?.name,
@@ -99,7 +100,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
99
100
  if (!a.pinned && b.pinned) return 1
100
101
  // Then by sort mode
101
102
  if (sortMode === 'name') return a.name.localeCompare(b.name)
102
- if (sortMode === 'messages') return (b.messages?.length || 0) - (a.messages?.length || 0)
103
+ if (sortMode === 'messages') return getSessionMessageCount(b) - getSessionMessageCount(a)
103
104
  return (b.lastActiveAt || 0) - (a.lastActiveAt || 0)
104
105
  })
105
106
  }, [agents, allUserSessions, connectors, lastReadTimestamps, search, sortMode, typeFilter])
@@ -114,9 +115,10 @@ export function ChatList({ inSidebar, onSelect }: Props) {
114
115
  const msgs = await fetchMessages(id)
115
116
  setMessages(msgs)
116
117
  } catch {
117
- setMessages(sessions[id]?.messages || [])
118
+ const fallback = sessions[id]
119
+ const fallbackLastMessage = fallback ? getSessionLastMessage(fallback) : null
120
+ setMessages(fallback?.messages?.length ? fallback.messages : (fallbackLastMessage ? [fallbackLastMessage] : []))
118
121
  }
119
- await loadSessions()
120
122
  onSelect?.()
121
123
  }
122
124
 
@@ -22,7 +22,7 @@ interface Props {
22
22
  export function ChatToolToggles({ session }: Props) {
23
23
  const [open, setOpen] = useState(false)
24
24
  const ref = useRef<HTMLDivElement>(null)
25
- const loadSessions = useAppStore((s) => s.loadSessions)
25
+ const refreshSession = useAppStore((s) => s.refreshSession)
26
26
  const agents = useAppStore((s) => s.agents)
27
27
  const skills = useAppStore((s) => s.skills)
28
28
 
@@ -46,7 +46,7 @@ export function ChatToolToggles({ session }: Props) {
46
46
  ? sessionTools.filter((t) => t !== toolId)
47
47
  : [...sessionTools, toolId]
48
48
  await api('PUT', `/chats/${session.id}`, { plugins: updated })
49
- loadSessions()
49
+ await refreshSession(session.id)
50
50
  }
51
51
 
52
52
  const enabledCount = sessionTools.length
@@ -7,6 +7,7 @@ import { useChatStore } from '@/stores/use-chat-store'
7
7
  import { AgentAvatar } from '@/components/agents/agent-avatar'
8
8
  import { api } from '@/lib/api-client'
9
9
  import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
10
+ import { getSessionLastMessage } from '@/lib/session-summary'
10
11
  import { getNotificationActivityAt, getNotificationOccurrenceCount } from '@/lib/notification-utils'
11
12
  import type { Agent, Session, ActivityEntry, BoardTask, AppNotification } from '@/types'
12
13
  import { HintTip } from '@/components/shared/hint-tip'
@@ -150,7 +151,9 @@ export function HomeView() {
150
151
  void loadActivity({ limit: 8 })
151
152
  void loadSchedules()
152
153
  void loadNotifications()
153
- void loadConnectors()
154
+ const connectorTimer = window.setTimeout(() => {
155
+ void loadConnectors()
156
+ }, 1200)
154
157
  api<{ records: Array<{ estimatedCost: number }>; timeSeries: Array<{ cost: number; bucket: string }> }>('GET', '/usage?range=7d')
155
158
  .then((data) => {
156
159
  const series = (data.timeSeries || []).map((pt: { cost: number; bucket?: string }) => ({ cost: pt.cost, bucket: pt.bucket || '' }))
@@ -160,6 +163,7 @@ export function HomeView() {
160
163
  setTodayCost(todayPt?.cost || 0)
161
164
  })
162
165
  .catch(() => {})
166
+ return () => window.clearTimeout(connectorTimer)
163
167
  // eslint-disable-next-line react-hooks/exhaustive-deps
164
168
  }, [])
165
169
 
@@ -563,7 +567,7 @@ export function HomeView() {
563
567
  <div className="flex flex-col gap-1">
564
568
  {recentChats.map((session) => {
565
569
  const agent = session.agentId ? agents[session.agentId] : null
566
- const lastMsg = session.messages?.[session.messages.length - 1]
570
+ const lastMsg = getSessionLastMessage(session)
567
571
  const displayName = agent?.name || 'Chat'
568
572
  return (
569
573
  <button
@@ -155,9 +155,8 @@ export function AppLayout() {
155
155
  useEffect(() => {
156
156
  loadApprovals()
157
157
  void loadExecApprovals()
158
+ pruneExecApprovals()
158
159
  const interval = setInterval(() => {
159
- loadApprovals()
160
- void loadExecApprovals()
161
160
  pruneExecApprovals()
162
161
  }, 10000)
163
162
  return () => clearInterval(interval)
@@ -256,7 +255,7 @@ export function AppLayout() {
256
255
 
257
256
  useEffect(() => { refreshPluginState() }, [refreshPluginState])
258
257
 
259
- useWs('plugins', refreshPluginState)
258
+ useWs('plugins', refreshPluginState, 30000)
260
259
 
261
260
  const [railExpanded, setRailExpanded] = useState(() => {
262
261
  const stored = safeStorageGet(RAIL_EXPANDED_KEY)
@@ -8,18 +8,42 @@ import { usePageActive } from './use-page-active'
8
8
  * Subscribe to a WebSocket topic. Calls `handler` on push events.
9
9
  * Falls back to polling at `fallbackMs` when WS is disconnected.
10
10
  */
11
- export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
11
+ export function useWs(topic: string, handler: () => void | Promise<void>, fallbackMs?: number) {
12
12
  const isActive = usePageActive()
13
13
  const handlerRef = useRef(handler)
14
- handlerRef.current = handler
15
14
  const fallbackMsRef = useRef(fallbackMs)
16
- fallbackMsRef.current = fallbackMs
15
+ const inFlightRef = useRef<Promise<void> | null>(null)
16
+ const wasActiveRef = useRef(isActive)
17
+
18
+ useEffect(() => {
19
+ handlerRef.current = handler
20
+ fallbackMsRef.current = fallbackMs
21
+ }, [handler, fallbackMs])
22
+
23
+ const runHandler = () => {
24
+ if (inFlightRef.current) return
25
+ try {
26
+ const result = handlerRef.current()
27
+ if (result && typeof (result as PromiseLike<void>).then === 'function') {
28
+ const promise = Promise.resolve(result)
29
+ .catch(() => {})
30
+ .finally(() => {
31
+ if (inFlightRef.current === promise) {
32
+ inFlightRef.current = null
33
+ }
34
+ })
35
+ inFlightRef.current = promise
36
+ }
37
+ } catch {
38
+ // Individual handlers already own their error reporting
39
+ }
40
+ }
17
41
 
18
42
  // WS subscription — only re-runs when topic changes
19
43
  useEffect(() => {
20
44
  if (!topic) return
21
45
 
22
- const cb = () => handlerRef.current()
46
+ const cb = () => runHandler()
23
47
  subscribeWs(topic, cb)
24
48
  return () => { unsubscribeWs(topic, cb) }
25
49
  }, [topic])
@@ -30,12 +54,15 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
30
54
  if (!topic) return
31
55
 
32
56
  let fallbackId: ReturnType<typeof setInterval> | null = null
33
- const cb = () => handlerRef.current()
57
+ const cb = () => runHandler()
58
+
59
+ const becameActive = !wasActiveRef.current && isActive
60
+ wasActiveRef.current = isActive
34
61
 
35
62
  // When page becomes visible again, fire an immediate refresh —
36
63
  // but only for topics that use fallback polling (i.e. data-fetch topics).
37
64
  // Event-only topics (like heartbeat pulses) should never fire from this effect.
38
- if (isActive && fallbackMsRef.current && fallbackMsRef.current > 0) {
65
+ if (becameActive && fallbackMsRef.current && fallbackMsRef.current > 0 && !isWsConnected()) {
39
66
  cb()
40
67
  }
41
68
 
@@ -75,6 +102,5 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
75
102
  stopFallback()
76
103
  clearInterval(checkId)
77
104
  }
78
- // eslint-disable-next-line react-hooks/exhaustive-deps
79
105
  }, [topic, isActive])
80
106
  }
@@ -1,24 +1,34 @@
1
1
  export async function register() {
2
2
  if (process.env.NEXT_RUNTIME === 'nodejs') {
3
- const { startScheduler } = await import('./lib/server/scheduler')
4
- const { resumeQueue } = await import('./lib/server/queue')
5
3
  const { initWsServer, closeWsServer } = await import('./lib/server/ws-hub')
6
- const { stopDaemon } = await import('./lib/server/daemon-state')
7
- startScheduler()
8
- resumeQueue()
4
+ const { ensureDaemonStarted } = await import('./lib/server/daemon-state')
5
+ ensureDaemonStarted('instrumentation')
6
+
9
7
  initWsServer()
10
8
 
11
9
  // Graceful shutdown: stop background services and close WS connections
12
- let shuttingDown = false
10
+ const shutdownState = (
11
+ (globalThis as Record<string, unknown>).__swarmclaw_shutdown_state__
12
+ ??= { registered: false, shuttingDown: false }
13
+ ) as { registered: boolean; shuttingDown: boolean }
14
+
13
15
  const shutdown = async (signal: string) => {
14
- if (shuttingDown) return
15
- shuttingDown = true
16
+ if (shutdownState.shuttingDown) return
17
+ shutdownState.shuttingDown = true
16
18
  console.log(`[server] ${signal} received, shutting down gracefully...`)
17
- stopDaemon({ source: signal })
19
+ try {
20
+ const { stopDaemon } = await import('./lib/server/daemon-state')
21
+ stopDaemon({ source: signal })
22
+ } catch (err) {
23
+ console.error('[instrumentation] Failed to stop daemon during shutdown:', err)
24
+ }
18
25
  await closeWsServer()
19
26
  process.exit(0)
20
27
  }
21
- process.on('SIGTERM', () => shutdown('SIGTERM'))
22
- process.on('SIGINT', () => shutdown('SIGINT'))
28
+ if (!shutdownState.registered) {
29
+ process.on('SIGTERM', () => { void shutdown('SIGTERM') })
30
+ process.on('SIGINT', () => { void shutdown('SIGINT') })
31
+ shutdownState.registered = true
32
+ }
23
33
  }
24
34
  }
@@ -0,0 +1,49 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { api } from './api-client'
5
+
6
+ const originalFetch = global.fetch
7
+
8
+ test.afterEach(() => {
9
+ global.fetch = originalFetch
10
+ })
11
+
12
+ test('dedupes concurrent GET requests for the same path', async () => {
13
+ let calls = 0
14
+ global.fetch = (async () => {
15
+ calls += 1
16
+ await new Promise((resolve) => setTimeout(resolve, 25))
17
+ return new Response(JSON.stringify({ ok: true }), {
18
+ headers: { 'content-type': 'application/json' },
19
+ status: 200,
20
+ })
21
+ }) as typeof fetch
22
+
23
+ const [first, second] = await Promise.all([
24
+ api<{ ok: boolean }>('GET', '/dedupe-check'),
25
+ api<{ ok: boolean }>('GET', '/dedupe-check'),
26
+ ])
27
+
28
+ assert.deepEqual(first, { ok: true })
29
+ assert.deepEqual(second, { ok: true })
30
+ assert.equal(calls, 1)
31
+ })
32
+
33
+ test('does not dedupe non-GET requests', async () => {
34
+ let calls = 0
35
+ global.fetch = (async () => {
36
+ calls += 1
37
+ return new Response(JSON.stringify({ ok: true }), {
38
+ headers: { 'content-type': 'application/json' },
39
+ status: 200,
40
+ })
41
+ }) as typeof fetch
42
+
43
+ await Promise.all([
44
+ api<{ ok: boolean }>('POST', '/dedupe-check', { hello: 'one' }),
45
+ api<{ ok: boolean }>('POST', '/dedupe-check', { hello: 'two' }),
46
+ ])
47
+
48
+ assert.equal(calls, 2)
49
+ })