@swarmclawai/swarmclaw 0.6.3 → 0.6.6

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 (106) hide show
  1. package/README.md +5 -3
  2. package/package.json +5 -1
  3. package/src/app/api/chatrooms/[id]/chat/route.ts +41 -2
  4. package/src/app/api/chatrooms/[id]/route.ts +15 -1
  5. package/src/app/api/chatrooms/route.ts +15 -2
  6. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  7. package/src/app/api/tasks/route.ts +24 -0
  8. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  9. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  10. package/src/app/api/wallets/[id]/route.ts +118 -0
  11. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  12. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  13. package/src/app/api/wallets/route.ts +74 -0
  14. package/src/app/globals.css +8 -0
  15. package/src/app/page.tsx +7 -3
  16. package/src/cli/index.js +15 -0
  17. package/src/cli/spec.js +14 -0
  18. package/src/components/agents/agent-avatar.tsx +15 -1
  19. package/src/components/agents/agent-card.tsx +1 -0
  20. package/src/components/agents/agent-chat-list.tsx +1 -1
  21. package/src/components/agents/agent-sheet.tsx +112 -26
  22. package/src/components/auth/access-key-gate.tsx +22 -11
  23. package/src/components/chat/chat-area.tsx +2 -2
  24. package/src/components/chat/chat-header.tsx +48 -19
  25. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  26. package/src/components/chat/delegation-banner.test.ts +27 -0
  27. package/src/components/chat/delegation-banner.tsx +109 -23
  28. package/src/components/chat/message-bubble.tsx +14 -3
  29. package/src/components/chat/message-list.tsx +5 -4
  30. package/src/components/chat/streaming-bubble.tsx +3 -2
  31. package/src/components/chat/thinking-indicator.tsx +3 -2
  32. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  33. package/src/components/chat/tool-call-bubble.tsx +13 -1
  34. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  35. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-input.tsx +7 -6
  37. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  38. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  39. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  40. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  41. package/src/components/connectors/connector-list.tsx +1 -1
  42. package/src/components/home/home-view.tsx +2 -1
  43. package/src/components/input/chat-input.tsx +5 -4
  44. package/src/components/knowledge/knowledge-list.tsx +1 -1
  45. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  46. package/src/components/layout/app-layout.tsx +23 -9
  47. package/src/components/logs/log-list.tsx +7 -7
  48. package/src/components/memory/memory-agent-list.tsx +1 -1
  49. package/src/components/memory/memory-browser.tsx +1 -0
  50. package/src/components/memory/memory-card.tsx +3 -2
  51. package/src/components/memory/memory-detail.tsx +3 -3
  52. package/src/components/memory/memory-sheet.tsx +2 -2
  53. package/src/components/projects/project-detail.tsx +4 -4
  54. package/src/components/secrets/secret-sheet.tsx +1 -1
  55. package/src/components/secrets/secrets-list.tsx +1 -1
  56. package/src/components/sessions/new-session-sheet.tsx +4 -3
  57. package/src/components/sessions/session-card.tsx +1 -1
  58. package/src/components/shared/agent-picker-list.tsx +1 -1
  59. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  60. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  61. package/src/components/skills/skill-list.tsx +1 -1
  62. package/src/components/skills/skill-sheet.tsx +1 -1
  63. package/src/components/tasks/task-board.tsx +3 -3
  64. package/src/components/tasks/task-sheet.tsx +21 -1
  65. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  66. package/src/components/wallets/wallet-panel.tsx +616 -0
  67. package/src/components/wallets/wallet-section.tsx +100 -0
  68. package/src/hooks/use-media-query.ts +30 -4
  69. package/src/lib/api-client.ts +6 -18
  70. package/src/lib/fetch-timeout.ts +17 -0
  71. package/src/lib/notification-sounds.ts +4 -4
  72. package/src/lib/safe-storage.ts +42 -0
  73. package/src/lib/server/agent-registry.ts +2 -2
  74. package/src/lib/server/chat-execution.ts +35 -3
  75. package/src/lib/server/chatroom-health.ts +60 -0
  76. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  77. package/src/lib/server/chatroom-helpers.ts +64 -11
  78. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  79. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  80. package/src/lib/server/connectors/manager.ts +80 -2
  81. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  82. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  83. package/src/lib/server/connectors/whatsapp.ts +8 -5
  84. package/src/lib/server/orchestrator-lg.ts +12 -2
  85. package/src/lib/server/orchestrator.ts +6 -1
  86. package/src/lib/server/queue-followups.test.ts +224 -0
  87. package/src/lib/server/queue.ts +226 -24
  88. package/src/lib/server/scheduler.ts +3 -0
  89. package/src/lib/server/session-tools/chatroom.ts +11 -2
  90. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  91. package/src/lib/server/session-tools/index.ts +6 -2
  92. package/src/lib/server/session-tools/memory.ts +1 -1
  93. package/src/lib/server/session-tools/shell.ts +1 -1
  94. package/src/lib/server/session-tools/wallet.ts +124 -0
  95. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  96. package/src/lib/server/session-tools/web-output.ts +16 -0
  97. package/src/lib/server/session-tools/web.ts +7 -3
  98. package/src/lib/server/solana.ts +122 -0
  99. package/src/lib/server/storage.ts +38 -0
  100. package/src/lib/server/stream-agent-chat.ts +126 -63
  101. package/src/lib/server/task-mention.test.ts +41 -0
  102. package/src/lib/server/task-mention.ts +3 -2
  103. package/src/lib/tool-definitions.ts +1 -0
  104. package/src/lib/view-routes.ts +6 -1
  105. package/src/stores/use-app-store.ts +17 -11
  106. package/src/types/index.ts +60 -1
@@ -5,6 +5,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
5
  import { FilePreview } from '@/components/shared/file-preview'
6
6
  import { useChatroomStore } from '@/stores/use-chatroom-store'
7
7
  import { uploadImage } from '@/lib/upload'
8
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
8
9
  import type { Agent } from '@/types'
9
10
 
10
11
  interface Props {
@@ -33,7 +34,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
33
34
  const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
34
35
  useEffect(() => {
35
36
  if (!chatroomId) return
36
- const draft = localStorage.getItem(`sc_draft_cr_${chatroomId}`)
37
+ const draft = safeStorageGet(`sc_draft_cr_${chatroomId}`)
37
38
  setText(draft || '')
38
39
  }, [chatroomId])
39
40
 
@@ -42,8 +43,8 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
42
43
  if (!chatroomId) return
43
44
  if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
44
45
  draftTimerRef.current = setTimeout(() => {
45
- if (text) localStorage.setItem(`sc_draft_cr_${chatroomId}`, text)
46
- else localStorage.removeItem(`sc_draft_cr_${chatroomId}`)
46
+ if (text) safeStorageSet(`sc_draft_cr_${chatroomId}`, text)
47
+ else safeStorageRemove(`sc_draft_cr_${chatroomId}`)
47
48
  }, 300)
48
49
  return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
49
50
  }, [text, chatroomId])
@@ -167,7 +168,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
167
168
  if ((text.trim() || pendingFiles.length) && !disabled) {
168
169
  onSend(text)
169
170
  setText('')
170
- if (chatroomId) localStorage.removeItem(`sc_draft_cr_${chatroomId}`)
171
+ if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
171
172
  setShowMentions(false)
172
173
  }
173
174
  }
@@ -202,7 +203,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
202
203
  selectedIndex === i + 1 ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]'
203
204
  }`}
204
205
  >
205
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={20} />
206
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
206
207
  <span className="text-[13px] text-text">{agent.name}</span>
207
208
  </button>
208
209
  ))}
@@ -294,7 +295,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
294
295
  if ((text.trim() || pendingFiles.length) && !disabled) {
295
296
  onSend(text)
296
297
  setText('')
297
- if (chatroomId) localStorage.removeItem(`sc_draft_cr_${chatroomId}`)
298
+ if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
298
299
  setShowMentions(false)
299
300
  }
300
301
  }}
@@ -172,7 +172,7 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
172
172
  className="bg-transparent border-none p-0 cursor-pointer transition-all duration-150 hover:scale-110 hover:-translate-y-0.5"
173
173
  style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}
174
174
  >
175
- <AgentAvatar seed={agent.avatarSeed || null} name={message.senderName} size={28} status={streamingAgentIds?.has(message.senderId) ? 'busy' : 'online'} />
175
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={message.senderName} size={28} status={streamingAgentIds?.has(message.senderId) ? 'busy' : 'online'} />
176
176
  </button>
177
177
  ) : (
178
178
  <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
@@ -178,7 +178,7 @@ export function ChatroomSheet() {
178
178
  selected ? 'bg-accent-soft/40' : 'hover:bg-white/[0.04]'
179
179
  }`}
180
180
  >
181
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
181
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
182
182
  <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
183
183
  {selected && (
184
184
  <CheckIcon size={14} className="text-accent-bright shrink-0" />
@@ -63,7 +63,7 @@ export function ChatroomTypingBar({ streamingAgents }: Props) {
63
63
  return (
64
64
  <div key={agentId} className="flex gap-2.5 px-4 py-1.5" style={{ animation: 'msg-in 0.2s ease-out both' }}>
65
65
  <div className="shrink-0 mt-0.5 w-7">
66
- <AgentAvatar seed={agent?.avatarSeed || null} name={a.name} size={28} />
66
+ <AgentAvatar seed={agent?.avatarSeed || null} avatarUrl={agent?.avatarUrl} name={a.name} size={28} />
67
67
  </div>
68
68
  <div className="flex-1 min-w-0">
69
69
  <div className="flex items-baseline gap-2 mb-0.5">
@@ -192,7 +192,7 @@ export function ChatroomView() {
192
192
  onClick={() => navigateToAgent(agent.id)}
193
193
  className="relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0"
194
194
  >
195
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
195
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
196
196
  </button>
197
197
  </TooltipTrigger>
198
198
  <TooltipContent side="bottom" sideOffset={6}>
@@ -206,7 +206,7 @@ export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
206
206
  </>
207
207
  ) : agent ? (
208
208
  <>
209
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={24} />
209
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
210
210
  <div className="flex-1 min-w-0">
211
211
  <span className="text-[12px] font-600 text-text-2 block truncate">{agent.name}</span>
212
212
  <span className="text-[10px] text-text-3/60 block">{agent.provider}/{agent.model}</span>
@@ -353,7 +353,7 @@ export function HomeView() {
353
353
  style={{ fontFamily: 'inherit' }}
354
354
  >
355
355
  <div className="relative">
356
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={36} />
356
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={36} />
357
357
  <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-surface ${
358
358
  isTyping ? 'bg-accent-bright animate-pulse'
359
359
  : isOnline ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]'
@@ -414,6 +414,7 @@ export function HomeView() {
414
414
  >
415
415
  <AgentAvatar
416
416
  seed={agent?.avatarSeed}
417
+ avatarUrl={agent?.avatarUrl}
417
418
  name={displayName}
418
419
  size={28}
419
420
  />
@@ -8,6 +8,7 @@ import { useAutoResize } from '@/hooks/use-auto-resize'
8
8
  import { useSpeechRecognition } from '@/hooks/use-speech-recognition'
9
9
  import { FilePreview } from '@/components/shared/file-preview'
10
10
  import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
11
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
11
12
 
12
13
  interface Props {
13
14
  streaming: boolean
@@ -36,7 +37,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
36
37
  const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
37
38
  useEffect(() => {
38
39
  if (!sessionId) return
39
- const draft = localStorage.getItem(`sc_draft_${sessionId}`)
40
+ const draft = safeStorageGet(`sc_draft_${sessionId}`)
40
41
  setValue(draft || '')
41
42
  }, [sessionId])
42
43
 
@@ -45,8 +46,8 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
45
46
  if (!sessionId) return
46
47
  if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
47
48
  draftTimerRef.current = setTimeout(() => {
48
- if (value) localStorage.setItem(`sc_draft_${sessionId}`, value)
49
- else localStorage.removeItem(`sc_draft_${sessionId}`)
49
+ if (value) safeStorageSet(`sc_draft_${sessionId}`, value)
50
+ else safeStorageRemove(`sc_draft_${sessionId}`)
50
51
  }, 300)
51
52
  return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
52
53
  }, [value, sessionId])
@@ -65,7 +66,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
65
66
  }
66
67
  onSend(text || 'See attached file(s).')
67
68
  setValue('')
68
- if (sessionId) localStorage.removeItem(`sc_draft_${sessionId}`)
69
+ if (sessionId) safeStorageRemove(`sc_draft_${sessionId}`)
69
70
  if (textareaRef.current) {
70
71
  textareaRef.current.style.height = 'auto'
71
72
  }
@@ -183,7 +183,7 @@ export function KnowledgeList() {
183
183
  <div className="flex items-center gap-1.5">
184
184
  <div className="flex items-center -space-x-1.5">
185
185
  {scopedAgents.slice(0, 5).map((agent) => (
186
- <AgentAvatar key={agent.id} seed={agent.avatarSeed} name={agent.name} size={16} className="ring-1 ring-surface" />
186
+ <AgentAvatar key={agent.id} seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={16} className="ring-1 ring-surface" />
187
187
  ))}
188
188
  </div>
189
189
  {scopedAgents.length > 5 && (
@@ -360,7 +360,7 @@ export function KnowledgeSheet() {
360
360
  }`}
361
361
  style={{ fontFamily: 'inherit' }}
362
362
  >
363
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
363
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
364
364
  <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
365
365
  {selected && (
366
366
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright shrink-0">
@@ -41,6 +41,7 @@ import { PluginSheet } from '@/components/plugins/plugin-sheet'
41
41
  import { RunList } from '@/components/runs/run-list'
42
42
  import { ActivityFeed } from '@/components/activity/activity-feed'
43
43
  import { MetricsDashboard } from '@/components/usage/metrics-dashboard'
44
+ import { WalletPanel } from '@/components/wallets/wallet-panel'
44
45
  import { ProjectList } from '@/components/projects/project-list'
45
46
  import { ProjectDetail } from '@/components/projects/project-detail'
46
47
  import { ProjectSheet } from '@/components/projects/project-sheet'
@@ -58,6 +59,7 @@ import { ChatArea } from '@/components/chat/chat-area'
58
59
  import { CanvasPanel } from '@/components/canvas/canvas-panel'
59
60
  import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
60
61
  import { api } from '@/lib/api-client'
62
+ import { safeStorageGet, safeStorageSet } from '@/lib/safe-storage'
61
63
  import type { AppView } from '@/types'
62
64
 
63
65
  const RAIL_EXPANDED_KEY = 'sc_rail_expanded'
@@ -118,9 +120,8 @@ export function AppLayout() {
118
120
  }, [handleShortcutKey])
119
121
 
120
122
  useEffect(() => {
121
- if (typeof window === 'undefined') return
122
- if (localStorage.getItem(STAR_NOTIFICATION_KEY)) return
123
- localStorage.setItem(STAR_NOTIFICATION_KEY, '1')
123
+ if (safeStorageGet(STAR_NOTIFICATION_KEY)) return
124
+ safeStorageSet(STAR_NOTIFICATION_KEY, '1')
124
125
  void api('POST', '/notifications', {
125
126
  type: 'info',
126
127
  title: 'Enjoying SwarmClaw?',
@@ -143,15 +144,14 @@ export function AppLayout() {
143
144
  }, [appSettings.themeHue])
144
145
 
145
146
  const [railExpanded, setRailExpanded] = useState(() => {
146
- if (typeof window === 'undefined') return true
147
- const stored = localStorage.getItem(RAIL_EXPANDED_KEY)
147
+ const stored = safeStorageGet(RAIL_EXPANDED_KEY)
148
148
  return stored === null ? true : stored === 'true'
149
149
  })
150
150
 
151
151
  const toggleRail = () => {
152
152
  const next = !railExpanded
153
153
  setRailExpanded(next)
154
- localStorage.setItem(RAIL_EXPANDED_KEY, String(next))
154
+ safeStorageSet(RAIL_EXPANDED_KEY, String(next))
155
155
  }
156
156
 
157
157
  const handleSwitchUser = () => {
@@ -399,6 +399,11 @@ export function AppLayout() {
399
399
  <line x1="18" y1="20" x2="18" y2="10" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="6" y1="20" x2="6" y2="14" />
400
400
  </svg>
401
401
  </NavItem>
402
+ <NavItem view="wallets" label="Wallets" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('wallets')}>
403
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
404
+ <rect x="2" y="6" width="20" height="14" rx="2" /><path d="M22 10H18a2 2 0 0 0 0 4h4" /><path d="M6 6V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2" />
405
+ </svg>
406
+ </NavItem>
402
407
  <NavItem view="runs" label="Runs" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('runs')}>
403
408
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
404
409
  <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
@@ -520,8 +525,8 @@ export function AppLayout() {
520
525
  </div>
521
526
  )}
522
527
 
523
- {/* Desktop: Side panel */}
524
- {isDesktop && sidebarOpen && (
528
+ {/* Desktop: Side panel (wallets has its own built-in sidebar) */}
529
+ {isDesktop && sidebarOpen && activeView !== 'wallets' && (
525
530
  <div
526
531
  className="w-[280px] shrink-0 bg-raised border-r border-white/[0.04] flex flex-col h-full"
527
532
  style={{ animation: 'panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1)' }}
@@ -728,6 +733,8 @@ export function AppLayout() {
728
733
  <ActivityFeed />
729
734
  ) : activeView === 'usage' ? (
730
735
  <MetricsDashboard />
736
+ ) : activeView === 'wallets' ? (
737
+ <WalletPanel />
731
738
  ) : activeView === 'chatrooms' ? (
732
739
  <div className="flex-1 flex h-full min-w-0">
733
740
  <div className="w-[280px] shrink-0 border-r border-white/[0.06] flex flex-col">
@@ -887,6 +894,7 @@ const VIEW_DESCRIPTIONS: Record<AppView, string> = {
887
894
  logs: 'Application logs & error tracking',
888
895
  plugins: 'Extend agent capabilities with custom plugins',
889
896
  usage: 'Usage metrics, cost tracking & agent performance',
897
+ wallets: 'Agent crypto wallets — hold funds, send SOL, manage spending',
890
898
  runs: 'Live run monitoring & history',
891
899
  settings: 'Manage providers, API keys & orchestrator engine',
892
900
  projects: 'Group agents, tasks & schedules into projects',
@@ -896,7 +904,7 @@ const VIEW_DESCRIPTIONS: Record<AppView, string> = {
896
904
  const FULL_WIDTH_VIEWS = new Set<AppView>([
897
905
  'home', 'chatrooms', 'schedules', 'secrets', 'providers', 'skills',
898
906
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'plugins',
899
- 'usage', 'runs', 'logs', 'settings', 'activity', 'projects',
907
+ 'usage', 'wallets', 'runs', 'logs', 'settings', 'activity', 'projects',
900
908
  ])
901
909
 
902
910
  const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { icon: string; title: string; description: string; features: string[] }> = {
@@ -1008,6 +1016,12 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { icon: str
1008
1016
  description: 'Audit trail of all entity mutations across the system.',
1009
1017
  features: ['Track agent, task, and connector changes', 'Filter by entity type and action', 'Real-time updates via WebSocket', 'Relative timestamps'],
1010
1018
  },
1019
+ wallets: {
1020
+ icon: 'wallet',
1021
+ title: 'Wallets',
1022
+ description: 'Agent crypto wallets for autonomous financial operations on Solana.',
1023
+ features: ['Create Solana wallets for agents', 'Per-transaction and daily spending limits', 'User approval for transactions', 'Balance tracking and transaction history'],
1024
+ },
1011
1025
  }
1012
1026
 
1013
1027
  function ViewEmptyState({ view }: { view: AppView }) {
@@ -5,6 +5,7 @@ import { api } from '@/lib/api-client'
5
5
  import { useWs } from '@/hooks/use-ws'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
+ import { safeStorageGetJson, safeStorageSet } from '@/lib/safe-storage'
8
9
 
9
10
  interface LogEntry {
10
11
  time: string
@@ -38,10 +39,9 @@ export function LogList() {
38
39
  const [selected, setSelected] = useState<LogEntry | null>(null)
39
40
  const [creatingTask, setCreatingTask] = useState(false)
40
41
  const [taskAgentId, setTaskAgentId] = useState('')
41
- const [savedFilters, setSavedFilters] = useState<Array<{ name: string; levels: string[]; search: string }>>(() => {
42
- if (typeof window === 'undefined') return []
43
- try { return JSON.parse(localStorage.getItem('sc_log_filters') || '[]') } catch { return [] }
44
- })
42
+ const [savedFilters, setSavedFilters] = useState<Array<{ name: string; levels: string[]; search: string }>>(
43
+ () => safeStorageGetJson<Array<{ name: string; levels: string[]; search: string }>>('sc_log_filters', []),
44
+ )
45
45
  const scrollRef = useRef<HTMLDivElement>(null)
46
46
 
47
47
  const agents = useAppStore((s) => s.agents)
@@ -169,7 +169,7 @@ export function LogList() {
169
169
  onClick={(e) => {
170
170
  e.stopPropagation()
171
171
  const next = savedFilters.filter((_, j) => j !== i)
172
- localStorage.setItem('sc_log_filters', JSON.stringify(next))
172
+ safeStorageSet('sc_log_filters', JSON.stringify(next))
173
173
  setSavedFilters(next)
174
174
  }}
175
175
  className="text-accent-bright/50 hover:text-red-400 ml-0.5"
@@ -232,9 +232,9 @@ export function LogList() {
232
232
  const name = prompt('Filter name:')
233
233
  if (!name?.trim()) return
234
234
  const filter = { name: name.trim(), levels: levelFilter, search }
235
- const existing = JSON.parse(localStorage.getItem('sc_log_filters') || '[]')
235
+ const existing = safeStorageGetJson<Array<{ name: string; levels: string[]; search: string }>>('sc_log_filters', [])
236
236
  existing.push(filter)
237
- localStorage.setItem('sc_log_filters', JSON.stringify(existing))
237
+ safeStorageSet('sc_log_filters', JSON.stringify(existing))
238
238
  setSavedFilters(existing)
239
239
  }}
240
240
  className="px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none bg-white/[0.04] text-text-3 hover:text-accent-bright hover:bg-accent-soft"
@@ -120,7 +120,7 @@ export function MemoryAgentList() {
120
120
  {isActive && (
121
121
  <div className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-full bg-accent-bright" />
122
122
  )}
123
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={28} />
123
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={28} />
124
124
  <span className={`text-[13px] font-600 flex-1 truncate ${isActive ? 'text-accent-bright' : 'text-text-2'}`}>
125
125
  {agent.name}
126
126
  </span>
@@ -163,6 +163,7 @@ export function MemoryBrowser() {
163
163
  active={e.id === selectedMemoryId}
164
164
  agentName={showAgent ? (agent?.name || null) : undefined}
165
165
  agentAvatarSeed={showAgent ? (agent?.avatarSeed || null) : undefined}
166
+ agentAvatarUrl={showAgent ? (agent?.avatarUrl || null) : undefined}
166
167
  onClick={() => setSelectedMemoryId(e.id)}
167
168
  />
168
169
  )
@@ -17,10 +17,11 @@ interface Props {
17
17
  active?: boolean
18
18
  agentName?: string | null
19
19
  agentAvatarSeed?: string | null
20
+ agentAvatarUrl?: string | null
20
21
  onClick: () => void
21
22
  }
22
23
 
23
- export function MemoryCard({ entry, active, agentName, agentAvatarSeed, onClick }: Props) {
24
+ export function MemoryCard({ entry, active, agentName, agentAvatarSeed, agentAvatarUrl, onClick }: Props) {
24
25
  return (
25
26
  <div
26
27
  onClick={onClick}
@@ -73,7 +74,7 @@ export function MemoryCard({ entry, active, agentName, agentAvatarSeed, onClick
73
74
  )}
74
75
  {agentName ? (
75
76
  <div className="flex items-center gap-1.5 mt-1.5">
76
- <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={16} />
77
+ <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={16} />
77
78
  <span className="text-[10px] text-text-3/60 truncate">{agentName}</span>
78
79
  </div>
79
80
  ) : !entry.agentId ? (
@@ -329,7 +329,7 @@ export function MemoryDetail() {
329
329
  : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
330
330
  style={{ fontFamily: 'inherit' }}
331
331
  >
332
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={16} />
332
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={16} />
333
333
  <span className="truncate max-w-[100px]">{agent.name}</span>
334
334
  </button>
335
335
  ))}
@@ -360,7 +360,7 @@ export function MemoryDetail() {
360
360
  : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
361
361
  style={{ fontFamily: 'inherit' }}
362
362
  >
363
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={16} />
363
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={16} />
364
364
  <span className="truncate max-w-[100px]">{agent.name}</span>
365
365
  </button>
366
366
  )
@@ -406,7 +406,7 @@ export function MemoryDetail() {
406
406
  const a = agents[aid]
407
407
  return (
408
408
  <span key={aid} className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-white/[0.03] text-[11px] text-text-3">
409
- <AgentAvatar seed={a?.avatarSeed || null} name={a?.name || aid} size={16} />
409
+ <AgentAvatar seed={a?.avatarSeed || null} avatarUrl={a?.avatarUrl} name={a?.name || aid} size={16} />
410
410
  {a?.name || aid}
411
411
  </span>
412
412
  )
@@ -104,7 +104,7 @@ export function MemorySheet() {
104
104
  : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
105
105
  style={{ fontFamily: 'inherit' }}
106
106
  >
107
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={20} />
107
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
108
108
  <span className="truncate max-w-[120px]">{agent.name}</span>
109
109
  </button>
110
110
  ))}
@@ -140,7 +140,7 @@ export function MemorySheet() {
140
140
  : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
141
141
  style={{ fontFamily: 'inherit' }}
142
142
  >
143
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={20} />
143
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
144
144
  <span className="truncate max-w-[120px]">{agent.name}</span>
145
145
  </button>
146
146
  )
@@ -68,7 +68,7 @@ function AssignAgentPicker({ projectId, onClose }: { projectId: string; onClose:
68
68
  className="w-full flex items-center gap-2.5 px-3 py-2 rounded-[8px] text-left hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none"
69
69
  style={{ fontFamily: 'inherit' }}
70
70
  >
71
- <AgentAvatar seed={a.avatarSeed} name={a.name} size={22} />
71
+ <AgentAvatar seed={a.avatarSeed} avatarUrl={a.avatarUrl} name={a.name} size={22} />
72
72
  <div className="min-w-0 flex-1">
73
73
  <div className="text-[12px] text-text truncate">{a.name}</div>
74
74
  <div className="text-[10px] text-text-3/40 truncate">{a.model || a.provider}</div>
@@ -312,7 +312,7 @@ export function ProjectDetail() {
312
312
  className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer bg-transparent border-none text-left p-0"
313
313
  style={{ fontFamily: 'inherit' }}
314
314
  >
315
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={28} />
315
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={28} />
316
316
  <div className="min-w-0 flex-1">
317
317
  <div className="text-[13px] font-600 text-text truncate">{agent.name}</div>
318
318
  <div className="text-[11px] text-text-3/50 truncate">{agent.model || agent.provider}</div>
@@ -381,7 +381,7 @@ export function ProjectDetail() {
381
381
  <span className="text-[13px] text-text truncate flex-1">{task.title}</span>
382
382
  {agent && (
383
383
  <span className="shrink-0 flex items-center gap-1.5 text-[11px] text-text-3/40">
384
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={16} />
384
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={16} />
385
385
  {agent.name}
386
386
  </span>
387
387
  )}
@@ -448,7 +448,7 @@ export function ProjectDetail() {
448
448
  </span>
449
449
  {agent && (
450
450
  <span className="shrink-0 flex items-center gap-1.5 text-[11px] text-text-3/40">
451
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={16} />
451
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={16} />
452
452
  </span>
453
453
  )}
454
454
  {schedule.nextRunAt && (
@@ -161,7 +161,7 @@ export function SecretSheet() {
161
161
  }`}
162
162
  style={{ fontFamily: 'inherit' }}
163
163
  >
164
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
164
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
165
165
  <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
166
166
  {selected && (
167
167
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright shrink-0">
@@ -102,7 +102,7 @@ export function SecretsList({ inSidebar }: Props) {
102
102
  <div className="flex items-center gap-1.5 mt-1.5 pl-[22px]">
103
103
  <div className="flex items-center -space-x-1.5">
104
104
  {scopedAgents.slice(0, 5).map((agent) => (
105
- <AgentAvatar key={agent.id} seed={agent.avatarSeed} name={agent.name} size={16} className="ring-1 ring-surface" />
105
+ <AgentAvatar key={agent.id} seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={16} className="ring-1 ring-surface" />
106
106
  ))}
107
107
  </div>
108
108
  {scopedAgents.length > 5 && (
@@ -13,6 +13,7 @@ import { SheetFooter } from '@/components/shared/sheet-footer'
13
13
  import { inputClass } from '@/components/shared/form-styles'
14
14
  import type { ProviderType, SessionTool } from '@/types'
15
15
  import { SectionLabel } from '@/components/shared/section-label'
16
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
16
17
 
17
18
  export function NewSessionSheet() {
18
19
  const open = useAppStore((s) => s.newSessionOpen)
@@ -64,7 +65,7 @@ export function NewSessionSheet() {
64
65
  setOllamaMode('local')
65
66
  // Auto-select last used agent, or default agent if no history
66
67
  const agentsList = Object.values(agents)
67
- const lastAgentId = typeof window !== 'undefined' ? localStorage.getItem('swarmclaw-last-agent') : null
68
+ const lastAgentId = safeStorageGet('swarmclaw-last-agent')
68
69
  const lastAgent = lastAgentId ? agentsList.find((a) => a.id === lastAgentId) : null
69
70
  const defaultAgent = lastAgent || agentsList.find((a) => a.id === 'default') || agentsList[0]
70
71
  if (defaultAgent) {
@@ -153,9 +154,9 @@ export function NewSessionSheet() {
153
154
  )
154
155
  // Remember agent selection for next time
155
156
  if (selectedAgentId) {
156
- localStorage.setItem('swarmclaw-last-agent', selectedAgentId)
157
+ safeStorageSet('swarmclaw-last-agent', selectedAgentId)
157
158
  } else {
158
- localStorage.removeItem('swarmclaw-last-agent')
159
+ safeStorageRemove('swarmclaw-last-agent')
159
160
  }
160
161
  updateSessionInStore(s)
161
162
  setCurrentSession(s.id)
@@ -86,7 +86,7 @@ export function SessionCard({ session, active, onClick }: Props) {
86
86
  <div className="flex items-center gap-2.5">
87
87
  {agent && (
88
88
  <div className="relative shrink-0">
89
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
89
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
90
90
  {(heartbeatEnabled || session.active) && (
91
91
  <span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 ring-2 ring-[#0f0f1a]" />
92
92
  )}
@@ -72,7 +72,7 @@ export function AgentPickerList({
72
72
  {active && (
73
73
  <div className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-full bg-accent-bright" />
74
74
  )}
75
- <AgentAvatar seed={a.avatarSeed || null} name={a.name} size={28} />
75
+ <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={28} />
76
76
  <span className={`text-[13px] font-600 flex-1 truncate ${active ? 'text-accent-bright' : 'text-text-2'}`}>
77
77
  {a.name}
78
78
  </span>
@@ -115,7 +115,7 @@ export function AgentSwitchDialog() {
115
115
  ${idx === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.04]'}`}
116
116
  style={{ fontFamily: 'inherit' }}
117
117
  >
118
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={28} />
118
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={28} />
119
119
  <div className="flex-1 min-w-0">
120
120
  <div className="flex items-center gap-2">
121
121
  <span className="text-[13px] font-500 text-text truncate">{agent.name}</span>
@@ -35,11 +35,11 @@ export function UserPreferencesSection({ appSettings, patchSettings, inputClass
35
35
  </div>
36
36
  <button
37
37
  type="button"
38
- onClick={() => patchSettings({ suggestionsEnabled: appSettings.suggestionsEnabled === false })}
39
- className={`relative w-9 h-5 rounded-full transition-colors ${appSettings.suggestionsEnabled !== false ? 'bg-accent-bright' : 'bg-white/[0.10]'}`}
38
+ onClick={() => patchSettings({ suggestionsEnabled: !appSettings.suggestionsEnabled })}
39
+ className={`relative w-9 h-5 rounded-full transition-colors ${appSettings.suggestionsEnabled ? 'bg-accent-bright' : 'bg-white/[0.10]'}`}
40
40
  style={{ fontFamily: 'inherit' }}
41
41
  >
42
- <span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${appSettings.suggestionsEnabled !== false ? 'translate-x-4' : ''}`} />
42
+ <span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${appSettings.suggestionsEnabled ? 'translate-x-4' : ''}`} />
43
43
  </button>
44
44
  </div>
45
45
 
@@ -70,7 +70,7 @@ export function UserPreferencesSection({ appSettings, patchSettings, inputClass
70
70
  : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03]'}`}
71
71
  style={{ fontFamily: 'inherit' }}
72
72
  >
73
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={18} />
73
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={18} />
74
74
  {agent.name}
75
75
  </button>
76
76
  ))}
@@ -309,7 +309,7 @@ export function SkillList({ inSidebar }: { inSidebar?: boolean }) {
309
309
  <div className="flex items-center gap-1.5 mt-1.5">
310
310
  <div className="flex items-center -space-x-1.5">
311
311
  {scopedAgents.slice(0, 5).map((agent) => (
312
- <AgentAvatar key={agent.id} seed={agent.avatarSeed} name={agent.name} size={16} className="ring-1 ring-surface" />
312
+ <AgentAvatar key={agent.id} seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={16} className="ring-1 ring-surface" />
313
313
  ))}
314
314
  </div>
315
315
  {scopedAgents.length > 5 && (
@@ -255,7 +255,7 @@ export function SkillSheet() {
255
255
  }`}
256
256
  style={{ fontFamily: 'inherit' }}
257
257
  >
258
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
258
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
259
259
  <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
260
260
  {selected && (
261
261
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright shrink-0">
@@ -264,7 +264,7 @@ export function TaskBoard() {
264
264
  >
265
265
  {filterAgentId && agents[filterAgentId] ? (
266
266
  <>
267
- <AgentAvatar seed={agents[filterAgentId].avatarSeed || null} name={agents[filterAgentId].name} size={18} />
267
+ <AgentAvatar seed={agents[filterAgentId].avatarSeed || null} avatarUrl={agents[filterAgentId].avatarUrl} name={agents[filterAgentId].name} size={18} />
268
268
  {agents[filterAgentId].name}
269
269
  </>
270
270
  ) : 'All Agents'}
@@ -290,7 +290,7 @@ export function TaskBoard() {
290
290
  ${filterAgentId === a.id ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
291
291
  style={{ fontFamily: 'inherit' }}
292
292
  >
293
- <AgentAvatar seed={a.avatarSeed || null} name={a.name} size={20} />
293
+ <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={20} />
294
294
  {a.name}
295
295
  </button>
296
296
  ))}
@@ -480,7 +480,7 @@ export function TaskBoard() {
480
480
  className="w-full flex items-center gap-2 px-3 py-2 text-[12px] font-600 cursor-pointer border-none text-left bg-transparent text-text-3 hover:bg-white/[0.06] hover:text-text transition-colors"
481
481
  style={{ fontFamily: 'inherit' }}
482
482
  >
483
- <AgentAvatar seed={a.avatarSeed || null} name={a.name} size={16} />
483
+ <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={16} />
484
484
  {a.name}
485
485
  </button>
486
486
  ))}