@swarmclawai/swarmclaw 0.6.8 → 0.7.0

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 (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +179 -349
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -3,25 +3,46 @@
3
3
  import { useEffect, useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
- import type { MarketplacePlugin } from '@/types'
6
+ import { toast } from 'sonner'
7
+ import type { MarketplacePlugin, PluginMeta } from '@/types'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
9
+ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
7
10
 
8
11
  export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
9
12
  const plugins = useAppStore((s) => s.plugins)
10
13
  const loadPlugins = useAppStore((s) => s.loadPlugins)
11
14
  const setPluginSheetOpen = useAppStore((s) => s.setPluginSheetOpen)
12
15
  const setEditingPluginFilename = useAppStore((s) => s.setEditingPluginFilename)
16
+ const agents = useAppStore((s) => s.agents)
17
+ const sessions = useAppStore((s) => s.sessions)
18
+ const setCurrentSession = useAppStore((s) => s.setCurrentSession)
19
+ const setActiveView = useAppStore((s) => s.setActiveView)
20
+
21
+ const navigateToAgentChat = useCallback((agentId: string) => {
22
+ // Find the most recent chat for this agent
23
+ const agentSession = Object.values(sessions)
24
+ .filter((s) => s.agentId === agentId)
25
+ .sort((a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0))[0]
26
+ if (agentSession) {
27
+ setCurrentSession(agentSession.id)
28
+ setActiveView('agents')
29
+ }
30
+ // eslint-disable-next-line react-hooks/exhaustive-deps
31
+ }, [sessions])
13
32
 
14
33
  const [tab, setTab] = useState<'installed' | 'marketplace'>('installed')
15
34
  const [marketplace, setMarketplace] = useState<MarketplacePlugin[]>([])
16
35
  const [mpLoading, setMpLoading] = useState(false)
17
36
  const [installing, setInstalling] = useState<string | null>(null)
37
+ const [deleting, setDeleting] = useState(false)
38
+ const [confirmDelete, setConfirmDelete] = useState<{ filename: string; name: string } | null>(null)
18
39
  const [search, setSearch] = useState('')
19
40
  const [activeTag, setActiveTag] = useState<string | null>(null)
20
41
  const [sort, setSort] = useState<'name' | 'downloads'>('downloads')
21
42
 
22
43
  useEffect(() => {
23
- loadPlugins()
24
- }, [])
44
+ void loadPlugins()
45
+ }, [loadPlugins])
25
46
 
26
47
  const loadMarketplace = useCallback(async () => {
27
48
  setMpLoading(true)
@@ -33,10 +54,16 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
33
54
  }, [])
34
55
 
35
56
  useEffect(() => {
36
- if (!inSidebar && tab === 'marketplace') loadMarketplace()
57
+ if (inSidebar || tab !== 'marketplace') return
58
+ const timer = setTimeout(() => {
59
+ void loadMarketplace()
60
+ }, 0)
61
+ return () => clearTimeout(timer)
37
62
  }, [tab, inSidebar, loadMarketplace])
38
63
 
39
64
  const pluginList = Object.values(plugins)
65
+ const corePlugins = pluginList.filter((plugin) => plugin.source === 'local')
66
+ const extensionPlugins = pluginList.filter((plugin) => plugin.source !== 'local')
40
67
 
41
68
  const handleEdit = (filename: string) => {
42
69
  setEditingPluginFilename(filename)
@@ -45,22 +72,46 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
45
72
 
46
73
  const handleToggle = async (e: React.MouseEvent, filename: string, enabled: boolean) => {
47
74
  e.stopPropagation()
48
- await api('POST', '/plugins', { filename, enabled: !enabled })
49
- loadPlugins()
75
+ try {
76
+ await api('POST', '/plugins', { filename, enabled: !enabled })
77
+ toast.success(!enabled ? 'Plugin enabled' : 'Plugin disabled')
78
+ loadPlugins()
79
+ } catch (err: unknown) {
80
+ toast.error(err instanceof Error ? err.message : 'Failed to toggle plugin')
81
+ }
50
82
  }
51
83
 
52
- const handleDelete = async (e: React.MouseEvent, filename: string) => {
84
+ const handleDeleteClick = (e: React.MouseEvent, filename: string, name: string) => {
53
85
  e.stopPropagation()
54
- await api('DELETE', `/plugins/${encodeURIComponent(filename)}`)
55
- loadPlugins()
86
+ setConfirmDelete({ filename, name })
87
+ }
88
+
89
+ const handleDeleteConfirm = async () => {
90
+ if (!confirmDelete) return
91
+ setDeleting(true)
92
+ try {
93
+ await api('DELETE', `/plugins?filename=${encodeURIComponent(confirmDelete.filename)}`)
94
+ toast.success('Plugin deleted')
95
+ await loadPlugins()
96
+ } catch (err: unknown) {
97
+ toast.error(err instanceof Error ? err.message : 'Delete failed')
98
+ } finally {
99
+ setDeleting(false)
100
+ setConfirmDelete(null)
101
+ }
56
102
  }
57
103
 
58
104
  const installFromMarketplace = async (p: MarketplacePlugin) => {
59
105
  setInstalling(p.id)
106
+ const toastId = toast.loading(`Installing ${p.name}...`)
60
107
  try {
61
- await api('POST', '/plugins/install', { url: p.url, filename: `${p.id}.js` })
108
+ const safeFilename = `${p.id.replace(/[^a-zA-Z0-9.-]/g, '_')}.js`
109
+ await api('POST', '/plugins/install', { url: p.url, filename: safeFilename })
62
110
  await loadPlugins()
63
- } catch { /* ignore */ }
111
+ toast.success(`Installed ${p.name}`, { id: toastId })
112
+ } catch (err: unknown) {
113
+ toast.error(err instanceof Error ? err.message : 'Install failed', { id: toastId })
114
+ }
64
115
  setInstalling(null)
65
116
  }
66
117
 
@@ -72,20 +123,127 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
72
123
  ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
73
124
  : 'bg-transparent border-transparent text-text-3 hover:text-text-2'}`
74
125
 
126
+ const pluginDescription = (plugin: PluginMeta): string => {
127
+ const raw = (plugin.description || '').trim()
128
+ if (raw) return raw
129
+ const sourceLabel = plugin.source === 'local' ? 'core plugin' : 'installed plugin'
130
+ return `No description provided. Click to view metadata and controls for this ${sourceLabel}.`
131
+ }
132
+
133
+ const pluginCapabilityBadges = (plugin: PluginMeta): string[] => {
134
+ const badges: string[] = []
135
+ if (plugin.toolCount && plugin.toolCount > 0) badges.push(`${plugin.toolCount} tool${plugin.toolCount === 1 ? '' : 's'}`)
136
+ if (plugin.hookCount && plugin.hookCount > 0) badges.push(`${plugin.hookCount} hook${plugin.hookCount === 1 ? '' : 's'}`)
137
+ if (plugin.hasUI) badges.push('UI')
138
+ if (plugin.providerCount && plugin.providerCount > 0) badges.push(`${plugin.providerCount} provider${plugin.providerCount === 1 ? '' : 's'}`)
139
+ if (plugin.connectorCount && plugin.connectorCount > 0) badges.push(`${plugin.connectorCount} connector${plugin.connectorCount === 1 ? '' : 's'}`)
140
+ return badges
141
+ }
142
+
143
+ const renderInstalledPlugin = (plugin: (typeof pluginList)[number], allowDelete: boolean) => (
144
+ <div
145
+ key={plugin.filename}
146
+ role="button"
147
+ tabIndex={0}
148
+ onClick={() => handleEdit(plugin.filename)}
149
+ onKeyDown={(e) => {
150
+ if (e.key === 'Enter' || e.key === ' ') {
151
+ e.preventDefault()
152
+ handleEdit(plugin.filename)
153
+ }
154
+ }}
155
+ className="w-full text-left p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
156
+ >
157
+ <div className="flex items-center justify-between mb-1">
158
+ <div className="flex items-center gap-2 min-w-0">
159
+ {plugin.createdByAgentId && agents[plugin.createdByAgentId] && (
160
+ <button
161
+ type="button"
162
+ title={`Created by ${agents[plugin.createdByAgentId].name} — click to open chat`}
163
+ onClick={(e) => { e.stopPropagation(); navigateToAgentChat(plugin.createdByAgentId!) }}
164
+ className="shrink-0 rounded-full hover:ring-2 hover:ring-accent-bright/40 transition-all cursor-pointer bg-transparent border-none p-0"
165
+ >
166
+ <AgentAvatar
167
+ seed={agents[plugin.createdByAgentId].avatarSeed || null}
168
+ avatarUrl={agents[plugin.createdByAgentId].avatarUrl}
169
+ name={agents[plugin.createdByAgentId].name || 'Agent'}
170
+ size={20}
171
+ />
172
+ </button>
173
+ )}
174
+ <span className="font-display text-[14px] font-600 text-text truncate">{plugin.name}</span>
175
+ </div>
176
+ <div className="flex items-center gap-2 shrink-0 ml-2">
177
+ {!inSidebar ? (
178
+ <>
179
+ <div
180
+ onClick={(e) => handleToggle(e, plugin.filename, plugin.enabled)}
181
+ className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
182
+ ${plugin.enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
183
+ >
184
+ <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
185
+ ${plugin.enabled ? 'left-[18px]' : 'left-0.5'}`} />
186
+ </div>
187
+ {allowDelete && (
188
+ <button
189
+ onClick={(e) => handleDeleteClick(e, plugin.filename, plugin.name)}
190
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
191
+ title="Delete"
192
+ >
193
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
194
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
195
+ </svg>
196
+ </button>
197
+ )}
198
+ </>
199
+ ) : (
200
+ <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${plugin.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'}`}>
201
+ {plugin.enabled ? 'Enabled' : 'Disabled'}
202
+ </span>
203
+ )}
204
+ </div>
205
+ </div>
206
+ <div className="text-[11px] font-mono text-text-3/50 mb-1">{plugin.filename}</div>
207
+ <p className="text-[12px] text-text-3/70 leading-relaxed">{pluginDescription(plugin)}</p>
208
+ <div className="flex items-center gap-1.5 mt-2 flex-wrap">
209
+ <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${
210
+ plugin.source === 'local' ? 'text-indigo-300 bg-indigo-500/10' : 'text-emerald-300 bg-emerald-500/10'
211
+ }`}>
212
+ {plugin.source === 'local' ? 'Core' : 'Extension'}
213
+ </span>
214
+ {pluginCapabilityBadges(plugin).map((badge) => (
215
+ <span key={badge} className="text-[10px] font-600 px-1.5 py-0.5 rounded-full text-text-3/80 bg-white/[0.05]">
216
+ {badge}
217
+ </span>
218
+ ))}
219
+ </div>
220
+ {!inSidebar && (
221
+ <p className="text-[10px] text-text-3/50 mt-2">Click for full details and controls</p>
222
+ )}
223
+ {plugin.autoDisabled && (
224
+ <p className="mt-1 text-[11px] text-amber-400/90 line-clamp-2">
225
+ Auto-disabled after {plugin.failureCount ?? 0} failures
226
+ {plugin.lastFailureStage ? ` (${plugin.lastFailureStage})` : ''}.
227
+ {plugin.lastFailureError ? ` ${plugin.lastFailureError}` : ''}
228
+ </p>
229
+ )}
230
+ </div>
231
+ )
232
+
75
233
  // Marketplace tab content (full-width only)
76
234
  const renderMarketplace = () => {
77
235
  if (mpLoading) return <p className="text-[12px] text-text-3/70 py-8 text-center">Loading marketplace...</p>
78
236
  if (marketplace.length === 0) return <p className="text-[12px] text-text-3/70 py-8 text-center">No plugins available</p>
79
237
 
80
- const allTags = Array.from(new Set(marketplace.flatMap((p) => p.tags))).sort()
238
+ const allTags = Array.from(new Set(marketplace.flatMap((p) => p.tags ?? []))).sort()
81
239
  const q = search.toLowerCase()
82
240
  const filtered = marketplace
83
241
  .filter((p) => {
84
- if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !p.tags.some((t) => t.toLowerCase().includes(q))) return false
85
- if (activeTag && !p.tags.includes(activeTag)) return false
242
+ if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !(p.tags ?? []).some((t) => t.toLowerCase().includes(q))) return false
243
+ if (activeTag && !(p.tags ?? []).includes(activeTag)) return false
86
244
  return true
87
245
  })
88
- .sort((a, b) => sort === 'downloads' ? b.downloads - a.downloads : a.name.localeCompare(b.name))
246
+ .sort((a, b) => sort === 'downloads' ? (b.downloads ?? 0) - (a.downloads ?? 0) : a.name.localeCompare(b.name))
89
247
 
90
248
  return (
91
249
  <div className="space-y-3">
@@ -146,7 +304,7 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
146
304
  <div className="flex items-center gap-2 mt-2">
147
305
  <span className="text-[10px] text-text-3/70">by {p.author}</span>
148
306
  <span className="text-[10px] text-text-3/50">&middot;</span>
149
- {p.tags.slice(0, 3).map((t) => (
307
+ {(p.tags ?? []).slice(0, 3).map((t) => (
150
308
  <button
151
309
  key={t}
152
310
  onClick={() => setActiveTag(activeTag === t ? null : t)}
@@ -209,52 +367,48 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
209
367
  </button>
210
368
  </div>
211
369
  ) : (
212
- <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
213
- {pluginList.map((plugin) => (
214
- <button
215
- key={plugin.filename}
216
- onClick={() => handleEdit(plugin.filename)}
217
- className="w-full text-left p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
218
- >
219
- <div className="flex items-center justify-between mb-1">
220
- <span className="font-display text-[14px] font-600 text-text truncate">{plugin.name}</span>
221
- <div className="flex items-center gap-2 shrink-0 ml-2">
222
- {!inSidebar ? (
223
- <>
224
- <div
225
- onClick={(e) => handleToggle(e, plugin.filename, plugin.enabled)}
226
- className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
227
- ${plugin.enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
228
- >
229
- <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
230
- ${plugin.enabled ? 'left-[18px]' : 'left-0.5'}`} />
231
- </div>
232
- <button
233
- onClick={(e) => handleDelete(e, plugin.filename)}
234
- className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
235
- title="Delete"
236
- >
237
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
238
- <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
239
- </svg>
240
- </button>
241
- </>
242
- ) : (
243
- <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${plugin.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'}`}>
244
- {plugin.enabled ? 'Enabled' : 'Disabled'}
245
- </span>
246
- )}
370
+ inSidebar ? (
371
+ <div className="space-y-2">
372
+ {pluginList.map((plugin) => renderInstalledPlugin(plugin, plugin.source !== 'local'))}
373
+ </div>
374
+ ) : (
375
+ <div className="space-y-6">
376
+ {corePlugins.length > 0 && (
377
+ <section>
378
+ <div className="mb-3 px-1">
379
+ <h3 className="text-[13px] font-700 text-text-2">Core Platform</h3>
380
+ <p className="text-[12px] text-text-3/60 mt-0.5">Official SwarmClaw plugins shipped with the platform.</p>
247
381
  </div>
248
- </div>
249
- <div className="text-[11px] font-mono text-text-3/50 mb-1">{plugin.filename}</div>
250
- {plugin.description && (
251
- <p className="text-[12px] text-text-3/60 line-clamp-2">{plugin.description}</p>
252
- )}
253
- </button>
254
- ))}
255
- </div>
382
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
383
+ {corePlugins.map((plugin) => renderInstalledPlugin(plugin, false))}
384
+ </div>
385
+ </section>
386
+ )}
387
+
388
+ {extensionPlugins.length > 0 && (
389
+ <section>
390
+ <div className="mb-3 px-1">
391
+ <h3 className="text-[13px] font-700 text-text-2">Extensions</h3>
392
+ <p className="text-[12px] text-text-3/60 mt-0.5">Marketplace and custom plugins installed by your team.</p>
393
+ </div>
394
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
395
+ {extensionPlugins.map((plugin) => renderInstalledPlugin(plugin, true))}
396
+ </div>
397
+ </section>
398
+ )}
399
+ </div>
400
+ )
256
401
  )
257
402
  )}
403
+ <ConfirmDialog
404
+ open={!!confirmDelete}
405
+ title="Delete Plugin"
406
+ message={confirmDelete ? `Delete "${confirmDelete.name}"? This cannot be undone.` : ''}
407
+ confirmLabel={deleting ? 'Deleting...' : 'Delete'}
408
+ danger
409
+ onConfirm={() => { void handleDeleteConfirm() }}
410
+ onCancel={() => { if (!deleting) setConfirmDelete(null) }}
411
+ />
258
412
  </div>
259
413
  )
260
414
  }
@@ -3,9 +3,28 @@
3
3
  import { useEffect, useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
+ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
6
7
  import { api } from '@/lib/api-client'
8
+ import { toast } from 'sonner'
7
9
  import type { PluginMeta, MarketplacePlugin } from '@/types'
8
10
 
11
+ function pluginDescription(plugin: PluginMeta): string {
12
+ const raw = (plugin.description || '').trim()
13
+ if (raw) return raw
14
+ const sourceLabel = plugin.source === 'local' ? 'core plugin' : 'installed plugin'
15
+ return `No description provided. This ${sourceLabel} is available and can be configured from this panel.`
16
+ }
17
+
18
+ function pluginCapabilityBadges(plugin: PluginMeta): string[] {
19
+ const badges: string[] = []
20
+ if (plugin.toolCount && plugin.toolCount > 0) badges.push(`${plugin.toolCount} tool${plugin.toolCount === 1 ? '' : 's'}`)
21
+ if (plugin.hookCount && plugin.hookCount > 0) badges.push(`${plugin.hookCount} hook${plugin.hookCount === 1 ? '' : 's'}`)
22
+ if (plugin.hasUI) badges.push('UI extension')
23
+ if (plugin.providerCount && plugin.providerCount > 0) badges.push(`${plugin.providerCount} provider${plugin.providerCount === 1 ? '' : 's'}`)
24
+ if (plugin.connectorCount && plugin.connectorCount > 0) badges.push(`${plugin.connectorCount} connector${plugin.connectorCount === 1 ? '' : 's'}`)
25
+ return badges
26
+ }
27
+
9
28
  export function PluginSheet() {
10
29
  const open = useAppStore((s) => s.pluginSheetOpen)
11
30
  const setOpen = useAppStore((s) => s.setPluginSheetOpen)
@@ -22,6 +41,7 @@ export function PluginSheet() {
22
41
  const [urlFilename, setUrlFilename] = useState('')
23
42
  const [urlStatus, setUrlStatus] = useState<{ ok: boolean; message: string } | null>(null)
24
43
  const [deleting, setDeleting] = useState(false)
44
+ const [confirmDelete, setConfirmDelete] = useState(false)
25
45
  const [search, setSearch] = useState('')
26
46
  const [activeTag, setActiveTag] = useState<string | null>(null)
27
47
  const [sort, setSort] = useState<'name' | 'downloads'>('downloads')
@@ -38,8 +58,12 @@ export function PluginSheet() {
38
58
  }, [])
39
59
 
40
60
  useEffect(() => {
41
- if (open && !editingFilename && tab === 'marketplace') loadMarketplace()
42
- }, [open, editingFilename, tab])
61
+ if (!open || !!editingFilename || tab !== 'marketplace') return
62
+ const timer = setTimeout(() => {
63
+ void loadMarketplace()
64
+ }, 0)
65
+ return () => clearTimeout(timer)
66
+ }, [open, editingFilename, tab, loadMarketplace])
43
67
 
44
68
  const handleClose = () => {
45
69
  setOpen(false)
@@ -47,29 +71,43 @@ export function PluginSheet() {
47
71
  setUrlInput('')
48
72
  setUrlFilename('')
49
73
  setUrlStatus(null)
74
+ setConfirmDelete(false)
50
75
  }
51
76
 
52
77
  const togglePlugin = async (filename: string, enabled: boolean) => {
53
- await api('POST', '/plugins', { filename, enabled })
54
- loadPlugins()
78
+ try {
79
+ await api('POST', '/plugins', { filename, enabled })
80
+ toast.success(enabled ? 'Plugin enabled' : 'Plugin disabled')
81
+ loadPlugins()
82
+ } catch (err: unknown) {
83
+ toast.error(err instanceof Error ? err.message : 'Failed to toggle plugin')
84
+ }
55
85
  }
56
86
 
57
87
  const deletePlugin = async (filename: string) => {
58
88
  setDeleting(true)
59
89
  try {
60
- await api('DELETE', `/plugins/${encodeURIComponent(filename)}`)
90
+ await api('DELETE', `/plugins?filename=${encodeURIComponent(filename)}`)
91
+ toast.success('Plugin deleted')
61
92
  await loadPlugins()
62
93
  handleClose()
63
- } catch { /* ignore */ }
94
+ } catch (err: unknown) {
95
+ toast.error(err instanceof Error ? err.message : 'Delete failed')
96
+ }
64
97
  setDeleting(false)
65
98
  }
66
99
 
67
100
  const installFromMarketplace = async (p: MarketplacePlugin) => {
68
101
  setInstalling(p.id)
102
+ const toastId = toast.loading(`Installing ${p.name}...`)
69
103
  try {
70
- await api('POST', '/plugins/install', { url: p.url, filename: `${p.id}.js` })
104
+ const safeFilename = `${p.id.replace(/[^a-zA-Z0-9.-]/g, '_')}.js`
105
+ await api('POST', '/plugins/install', { url: p.url, filename: safeFilename })
71
106
  await loadPlugins()
72
- } catch { /* ignore */ }
107
+ toast.success(`Installed ${p.name}`, { id: toastId })
108
+ } catch (err: unknown) {
109
+ toast.error(err instanceof Error ? err.message : 'Install failed', { id: toastId })
110
+ }
73
111
  setInstalling(null)
74
112
  }
75
113
 
@@ -81,10 +119,11 @@ export function PluginSheet() {
81
119
  await api('POST', '/plugins/install', { url: urlInput, filename: urlFilename })
82
120
  await loadPlugins()
83
121
  setUrlStatus({ ok: true, message: 'Installed successfully' })
122
+ toast.success('Plugin installed from URL')
84
123
  setUrlInput('')
85
124
  setUrlFilename('')
86
- } catch (err: any) {
87
- setUrlStatus({ ok: false, message: err.message || 'Install failed' })
125
+ } catch (err: unknown) {
126
+ setUrlStatus({ ok: false, message: err instanceof Error ? err.message : 'Install failed' })
88
127
  }
89
128
  setInstalling(null)
90
129
  }
@@ -101,18 +140,61 @@ export function PluginSheet() {
101
140
  <BottomSheet open={open} onClose={handleClose}>
102
141
  {editing ? (
103
142
  <div className="space-y-5">
104
- <div className="py-3 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
105
- <div className="flex items-center gap-2 mb-1">
106
- <span className="text-[14px] font-600 text-text">{editing.name}</span>
107
- {editing.openclaw && <span className="text-[9px] font-600 text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded-full">OpenClaw</span>}
143
+ <div className="py-4 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
144
+ <div className="flex items-start justify-between gap-3 mb-2">
145
+ <div className="min-w-0">
146
+ <div className="flex items-center gap-2 mb-1">
147
+ <span className="text-[15px] font-700 text-text truncate">{editing.name}</span>
148
+ <span className="text-[10px] font-mono text-text-3/70">v{editing.version || '1.0.0'}</span>
149
+ {editing.openclaw && <span className="text-[9px] font-600 text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded-full">OpenClaw</span>}
150
+ </div>
151
+ <p className="text-[12px] text-text-3/80 leading-relaxed">{pluginDescription(editing)}</p>
152
+ </div>
153
+ <span className={`shrink-0 text-[10px] font-600 px-2 py-1 rounded-full ${
154
+ editing.enabled ? 'text-emerald-300 bg-emerald-500/10' : 'text-text-3/80 bg-white/[0.05]'
155
+ }`}>
156
+ {editing.enabled ? 'Enabled' : 'Disabled'}
157
+ </span>
108
158
  </div>
109
- <div className="text-[11px] font-mono text-text-3">{editing.filename}</div>
110
- {editing.description && <div className="text-[11px] text-text-3/60 mt-1">{editing.description}</div>}
111
- {editing.author && <div className="text-[10px] text-text-3/70 mt-1">by {editing.author}</div>}
159
+
160
+ <div className="grid grid-cols-2 gap-2 mt-3">
161
+ <div className="rounded-[10px] bg-bg/50 border border-white/[0.05] px-2.5 py-2">
162
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60 mb-0.5">Source</div>
163
+ <div className="text-[11px] text-text-2">{editing.source === 'local' ? 'Core Platform' : 'Extension'}</div>
164
+ </div>
165
+ <div className="rounded-[10px] bg-bg/50 border border-white/[0.05] px-2.5 py-2">
166
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60 mb-0.5">Author</div>
167
+ <div className="text-[11px] text-text-2">{editing.author || 'Unknown'}</div>
168
+ </div>
169
+ </div>
170
+
171
+ <div className="text-[11px] font-mono text-text-3/60 mt-3 break-all">{editing.filename}</div>
172
+ <div className="flex items-center gap-1.5 mt-2 flex-wrap">
173
+ {pluginCapabilityBadges(editing).length > 0 ? (
174
+ pluginCapabilityBadges(editing).map((badge) => (
175
+ <span key={badge} className="text-[10px] font-600 px-1.5 py-0.5 rounded-full text-text-3/80 bg-white/[0.05]">
176
+ {badge}
177
+ </span>
178
+ ))
179
+ ) : (
180
+ <span className="text-[10px] text-text-3/50">No declared tools/hooks metadata</span>
181
+ )}
182
+ </div>
183
+
184
+ {editing.autoDisabled && (
185
+ <div className="mt-3 p-2.5 rounded-[10px] bg-amber-500/[0.06] border border-amber-500/20 text-[11px] text-amber-300/90">
186
+ Auto-disabled after {editing.failureCount ?? 0} failures
187
+ {editing.lastFailureStage ? ` at ${editing.lastFailureStage}` : ''}.
188
+ {editing.lastFailureError ? ` ${editing.lastFailureError}` : ''}
189
+ </div>
190
+ )}
112
191
  </div>
113
192
 
114
193
  <div className="flex items-center justify-between py-3 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
115
- <span className="text-[13px] font-600 text-text">Enabled</span>
194
+ <div>
195
+ <span className="text-[13px] font-600 text-text block">Enabled</span>
196
+ <span className="text-[11px] text-text-3/60">Disable to keep the plugin installed but inactive.</span>
197
+ </div>
116
198
  <div
117
199
  onClick={() => togglePlugin(editing.filename, !editing.enabled)}
118
200
  className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0
@@ -124,7 +206,7 @@ export function PluginSheet() {
124
206
  </div>
125
207
 
126
208
  <button
127
- onClick={() => deletePlugin(editing.filename)}
209
+ onClick={() => setConfirmDelete(true)}
128
210
  disabled={deleting}
129
211
  className="w-full py-2.5 rounded-[10px] text-[13px] font-600 bg-red-500/10 text-red-400 border border-red-500/20
130
212
  hover:bg-red-500/20 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default"
@@ -150,15 +232,15 @@ export function PluginSheet() {
150
232
  : marketplace.length === 0
151
233
  ? <p className="text-[12px] text-text-3/70">No plugins available</p>
152
234
  : (() => {
153
- const allTags = Array.from(new Set(marketplace.flatMap((p) => p.tags))).sort()
235
+ const allTags = Array.from(new Set(marketplace.flatMap((p) => (p.tags ?? [])))).sort()
154
236
  const q = search.toLowerCase()
155
237
  const filtered = marketplace
156
238
  .filter((p) => {
157
- if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !p.tags.some((t) => t.toLowerCase().includes(q))) return false
158
- if (activeTag && !p.tags.includes(activeTag)) return false
239
+ if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !(p.tags ?? []).some((t) => t.toLowerCase().includes(q))) return false
240
+ if (activeTag && !(p.tags ?? []).includes(activeTag)) return false
159
241
  return true
160
242
  })
161
- .sort((a, b) => sort === 'downloads' ? b.downloads - a.downloads : a.name.localeCompare(b.name))
243
+ .sort((a, b) => sort === 'downloads' ? (b.downloads ?? 0) - (a.downloads ?? 0) : a.name.localeCompare(b.name))
162
244
 
163
245
  return (
164
246
  <div className="space-y-3">
@@ -224,7 +306,7 @@ export function PluginSheet() {
224
306
  <div className="flex items-center gap-2 mt-2">
225
307
  <span className="text-[10px] text-text-3/70">by {p.author}</span>
226
308
  <span className="text-[10px] text-text-3/50">&middot;</span>
227
- {p.tags.slice(0, 3).map((t) => (
309
+ {(p.tags ?? []).slice(0, 3).map((t) => (
228
310
  <button
229
311
  key={t}
230
312
  onClick={() => setActiveTag(activeTag === t ? null : t)}
@@ -306,6 +388,19 @@ export function PluginSheet() {
306
388
  )}
307
389
  </div>
308
390
  )}
391
+ <ConfirmDialog
392
+ open={!!editing && confirmDelete}
393
+ title="Delete Plugin"
394
+ message={editing ? `Delete "${editing.name}"? This cannot be undone.` : ''}
395
+ confirmLabel={deleting ? 'Deleting...' : 'Delete'}
396
+ danger
397
+ onConfirm={() => {
398
+ if (!editing) return
399
+ setConfirmDelete(false)
400
+ void deletePlugin(editing.filename)
401
+ }}
402
+ onCancel={() => { if (!deleting) setConfirmDelete(false) }}
403
+ />
309
404
  </BottomSheet>
310
405
  )
311
406
  }