@swarmclawai/swarmclaw 0.7.2 → 0.7.3

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 (197) hide show
  1. package/README.md +81 -22
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -1,16 +1,23 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
+ import crypto from 'crypto'
3
4
  import { createRequire } from 'module'
4
- import type { Plugin, PluginHooks, PluginMeta, PluginToolDef, PluginUIExtension, PluginProviderExtension, PluginConnectorExtension, Session } from '@/types'
5
+ import { spawn } from 'child_process'
6
+ import type { Plugin, PluginHooks, PluginMeta, PluginToolDef, PluginUIExtension, PluginProviderExtension, PluginConnectorExtension, Session, PluginPackageManager, PluginDependencyInstallStatus } from '@/types'
5
7
  import { DATA_DIR } from './data-dir'
6
- import { expandPluginIds } from './tool-aliases'
8
+ import { canonicalizePluginId, expandPluginIds, getPluginAliases } from './tool-aliases'
7
9
  import { log } from './logger'
8
10
  import { createNotification } from './create-notification'
9
11
  import { notify } from './ws-hub'
12
+ import { decryptKey, encryptKey, loadSettings, saveSettings } from './storage'
10
13
 
11
14
  const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
15
+ const PLUGIN_WORKSPACES_DIR = path.join(PLUGINS_DIR, '.workspaces')
12
16
  const PLUGINS_CONFIG = path.join(DATA_DIR, 'plugins.json')
13
17
  const PLUGIN_FAILURES = path.join(DATA_DIR, 'plugin-failures.json')
18
+ const MAX_EXTERNAL_PLUGIN_BYTES = 1024 * 1024
19
+ const SUPPORTED_PLUGIN_PACKAGE_MANAGERS: PluginPackageManager[] = ['npm', 'pnpm', 'yarn', 'bun']
20
+ const PACKAGE_INSTALL_TIMEOUT_MS = 5 * 60 * 1000
14
21
  const MAX_CONSECUTIVE_PLUGIN_FAILURES = (() => {
15
22
  const raw = Number.parseInt(process.env.SWARMCLAW_PLUGIN_FAILURE_THRESHOLD || '3', 10)
16
23
  if (!Number.isFinite(raw)) return 3
@@ -24,6 +31,55 @@ interface PluginFailureRecord {
24
31
  lastFailedAt: number
25
32
  }
26
33
 
34
+ interface PluginConfigEntry {
35
+ enabled?: boolean
36
+ createdByAgentId?: string
37
+ sourceUrl?: string
38
+ sourceHash?: string
39
+ installedAt?: number
40
+ updatedAt?: number
41
+ packageManager?: PluginPackageManager
42
+ dependencyInstallStatus?: PluginDependencyInstallStatus
43
+ dependencyInstallError?: string
44
+ dependencyInstalledAt?: number
45
+ }
46
+
47
+ interface InstalledPluginSource {
48
+ filename: string
49
+ sourceUrl: string
50
+ sourceHash: string
51
+ contentType?: string
52
+ }
53
+
54
+ interface PluginSourceDownload {
55
+ code: string
56
+ contentType: string
57
+ normalizedUrl: string
58
+ hash: string
59
+ }
60
+
61
+ interface PluginDependencyInfo {
62
+ hasManifest: boolean
63
+ dependencyCount: number
64
+ devDependencyCount: number
65
+ packageManager?: PluginPackageManager
66
+ installStatus: PluginDependencyInstallStatus
67
+ installError?: string
68
+ installedAt?: number
69
+ }
70
+
71
+ interface UpsertPluginOptions {
72
+ packageJson?: unknown
73
+ packageManager?: string | null
74
+ installDependencies?: boolean
75
+ meta?: Record<string, unknown>
76
+ }
77
+
78
+ interface PluginSecretSettingValue {
79
+ __pluginSecret: true
80
+ encrypted: string
81
+ }
82
+
27
83
  interface PluginLogger {
28
84
  info: (msg: string, m?: unknown) => void
29
85
  warn: (msg: string, m?: unknown) => void
@@ -68,10 +124,165 @@ interface OpenClawPluginApi {
68
124
  runtime: Record<string, unknown>
69
125
  }
70
126
 
127
+ export interface HookExecutionOptions {
128
+ enabledIds?: string[]
129
+ includeAllWhenEmpty?: boolean
130
+ }
131
+
71
132
  function isRecord(value: unknown): value is Record<string, unknown> {
72
133
  return !!value && typeof value === 'object' && !Array.isArray(value)
73
134
  }
74
135
 
136
+ function isPluginSecretSettingValue(value: unknown): value is PluginSecretSettingValue {
137
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
138
+ const rec = value as Record<string, unknown>
139
+ return rec.__pluginSecret === true && typeof rec.encrypted === 'string'
140
+ }
141
+
142
+ function hashPluginSource(content: string): string {
143
+ return crypto.createHash('sha256').update(content).digest('hex')
144
+ }
145
+
146
+ function normalizePluginPackageManager(raw: unknown): PluginPackageManager | null {
147
+ const text = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
148
+ if (!text) return null
149
+ const normalized = text.split('@')[0] as PluginPackageManager
150
+ return SUPPORTED_PLUGIN_PACKAGE_MANAGERS.includes(normalized) ? normalized : null
151
+ }
152
+
153
+ function pluginWorkspaceKey(filename: string): string {
154
+ return path.basename(filename).replace(/[^a-zA-Z0-9_-]/g, '_')
155
+ }
156
+
157
+ function trimProcessOutput(output: string): string {
158
+ return output.trim().slice(-4000)
159
+ }
160
+
161
+ function normalizePluginManifest(
162
+ rawManifest: unknown,
163
+ filename: string,
164
+ packageManager?: PluginPackageManager | null,
165
+ ): Record<string, unknown> {
166
+ const parsed = typeof rawManifest === 'string'
167
+ ? JSON.parse(rawManifest) as unknown
168
+ : rawManifest
169
+ if (!isRecord(parsed)) throw new Error('Plugin package.json must be a JSON object')
170
+
171
+ const manifest = { ...parsed } as Record<string, unknown>
172
+ if (typeof manifest.name !== 'string' || !manifest.name.trim()) {
173
+ manifest.name = path.basename(filename, path.extname(filename)).replace(/[^a-zA-Z0-9._-]/g, '-')
174
+ }
175
+ if (manifest.private === undefined) manifest.private = true
176
+ if (packageManager && typeof manifest.packageManager !== 'string') {
177
+ manifest.packageManager = packageManager
178
+ }
179
+ return manifest
180
+ }
181
+
182
+ function countManifestDependencies(manifest: Record<string, unknown> | null): {
183
+ dependencyCount: number
184
+ devDependencyCount: number
185
+ } {
186
+ const dependencies = isRecord(manifest?.dependencies) ? Object.keys(manifest.dependencies).length : 0
187
+ const devDependencies = isRecord(manifest?.devDependencies) ? Object.keys(manifest.devDependencies).length : 0
188
+ return {
189
+ dependencyCount: dependencies,
190
+ devDependencyCount: devDependencies,
191
+ }
192
+ }
193
+
194
+ function getInstallCommand(packageManager: PluginPackageManager): { command: string; args: string[] } {
195
+ switch (packageManager) {
196
+ case 'pnpm':
197
+ return { command: 'pnpm', args: ['install', '--ignore-scripts', '--config.ignore-workspace=true'] }
198
+ case 'yarn':
199
+ return { command: 'yarn', args: ['install', '--ignore-scripts'] }
200
+ case 'bun':
201
+ return { command: 'bun', args: ['install', '--ignore-scripts'] }
202
+ case 'npm':
203
+ default:
204
+ return { command: 'npm', args: ['install', '--ignore-scripts', '--no-audit', '--no-fund'] }
205
+ }
206
+ }
207
+
208
+ function toRawPluginUrl(url: string): string {
209
+ if (url.includes('github.com') && url.includes('/blob/')) {
210
+ return url.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
211
+ }
212
+ if (url.includes('gist.github.com')) {
213
+ return url.endsWith('/raw') ? url : `${url}/raw`
214
+ }
215
+ return url
216
+ }
217
+
218
+ export function normalizeMarketplacePluginUrl(url: string): string {
219
+ const trimmed = typeof url === 'string' ? url.trim() : ''
220
+ if (!trimmed) return trimmed
221
+
222
+ let normalized = trimmed
223
+ .replace('github.com/swarmclawai/plugins/', 'github.com/swarmclawai/swarmforge/')
224
+ .replace('raw.githubusercontent.com/swarmclawai/plugins/', 'raw.githubusercontent.com/swarmclawai/swarmforge/')
225
+
226
+ normalized = toRawPluginUrl(normalized)
227
+
228
+ return normalized
229
+ .replace('/swarmclawai/swarmforge/master/', '/swarmclawai/swarmforge/main/')
230
+ .replace('/swarmclawai/plugins/master/', '/swarmclawai/swarmforge/main/')
231
+ .replace('/swarmclawai/plugins/main/', '/swarmclawai/swarmforge/main/')
232
+ }
233
+
234
+ export function sanitizePluginFilename(filename: string): string {
235
+ const trimmed = typeof filename === 'string' ? filename.trim() : ''
236
+ if (!trimmed) throw new Error('Filename is required')
237
+ if (!trimmed.endsWith('.js') && !trimmed.endsWith('.mjs')) {
238
+ throw new Error('Filename must end in .js or .mjs')
239
+ }
240
+ const sanitized = path.basename(trimmed)
241
+ if (sanitized !== trimmed || trimmed.includes('..')) {
242
+ throw new Error('Invalid filename')
243
+ }
244
+ return sanitized
245
+ }
246
+
247
+ async function downloadPluginSource(url: string): Promise<PluginSourceDownload> {
248
+ const normalizedUrl = normalizeMarketplacePluginUrl(url)
249
+ if (!normalizedUrl || !normalizedUrl.startsWith('https://')) {
250
+ throw new Error('URL must be a valid HTTPS URL')
251
+ }
252
+
253
+ const res = await fetch(normalizedUrl, { signal: AbortSignal.timeout(15_000) })
254
+ if (!res.ok) {
255
+ throw new Error(`Download failed (HTTP ${res.status}) from ${normalizedUrl}`)
256
+ }
257
+
258
+ const contentType = res.headers.get('content-type') || ''
259
+ const lengthHeader = res.headers.get('content-length')
260
+ const declaredSize = lengthHeader ? Number.parseInt(lengthHeader, 10) : Number.NaN
261
+ if (Number.isFinite(declaredSize) && declaredSize > MAX_EXTERNAL_PLUGIN_BYTES) {
262
+ throw new Error(`Plugin file is too large (${declaredSize} bytes)`)
263
+ }
264
+
265
+ let code = await res.text()
266
+ if (Buffer.byteLength(code, 'utf8') > MAX_EXTERNAL_PLUGIN_BYTES) {
267
+ throw new Error(`Plugin file exceeds ${MAX_EXTERNAL_PLUGIN_BYTES} bytes`)
268
+ }
269
+
270
+ if (contentType.includes('text/html') && code.includes('<!DOCTYPE')) {
271
+ throw new Error('URL returned an HTML page instead of JavaScript. Use a raw/direct link to the plugin file.')
272
+ }
273
+
274
+ // Compatibility: modern Node exposes global fetch.
275
+ code = code.replace(/const\s+fetch\s*=\s*require\(['"]node-fetch['"]\);?/g, '// node-fetch stripped for compatibility')
276
+ code = code.replace(/import\s+fetch\s+from\s+['"]node-fetch['"];?/g, '// node-fetch stripped for compatibility')
277
+
278
+ return {
279
+ code,
280
+ contentType,
281
+ normalizedUrl,
282
+ hash: hashPluginSource(code),
283
+ }
284
+ }
285
+
75
286
  function coerceTools(rawTools: unknown): PluginToolDef[] {
76
287
  if (Array.isArray(rawTools)) {
77
288
  const tools: PluginToolDef[] = []
@@ -128,6 +339,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
128
339
  name: raw.name as string,
129
340
  version: (raw.version as string) || '0.0.1',
130
341
  description: (raw.description as string) || '',
342
+ author: typeof raw.author === 'string' ? raw.author : undefined,
343
+ openclaw: raw.openclaw === true,
131
344
  hooks,
132
345
  tools: coerceTools(raw.tools),
133
346
  ui: isRecord(raw.ui) ? (raw.ui as PluginUIExtension) : undefined,
@@ -214,6 +427,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
214
427
  name: pluginName,
215
428
  version: pluginVersion,
216
429
  description: pluginDesc || `OpenClaw plugin (v${pluginVersion})`,
430
+ author: typeof raw.author === 'string' ? raw.author : undefined,
431
+ openclaw: true,
217
432
  hooks,
218
433
  tools,
219
434
  }
@@ -253,6 +468,7 @@ function normalizePlugin(mod: unknown): Plugin | null {
253
468
  name: oc.name,
254
469
  version: oc.version,
255
470
  description: `OpenClaw plugin (v${oc.version || '0.0.0'})`,
471
+ openclaw: true,
256
472
  hooks,
257
473
  tools,
258
474
  }
@@ -281,13 +497,202 @@ class PluginManager {
281
497
  private plugins: Map<string, LoadedPlugin> = new Map()
282
498
  private builtins: Map<string, Plugin> = new Map()
283
499
  private loaded = false
500
+ private watcher: fs.FSWatcher | null = null
284
501
 
285
502
  registerBuiltin(id: string, plugin: Plugin) {
286
- this.builtins.set(id, plugin)
503
+ const canonicalId = this.canonicalPluginId(id)
504
+ this.builtins.set(canonicalId, plugin)
287
505
  // Builtins can be imported/registered after first load, so force re-evaluation.
288
506
  this.loaded = false
289
507
  }
290
508
 
509
+ private ensurePluginWatcher(): void {
510
+ if (this.watcher) return
511
+ try {
512
+ this.ensurePluginDirs()
513
+ this.watcher = fs.watch(PLUGINS_DIR, (_eventType, filename) => {
514
+ if (!filename || (!filename.endsWith('.js') && !filename.endsWith('.mjs'))) return
515
+ this.loaded = false
516
+ notify('plugins')
517
+ })
518
+ this.watcher.unref?.()
519
+ } catch (err: unknown) {
520
+ log.warn('plugins', 'Failed to watch plugins directory', {
521
+ error: err instanceof Error ? err.message : String(err),
522
+ })
523
+ }
524
+ }
525
+
526
+ private isExternalPluginFilename(id: string): boolean {
527
+ return id.endsWith('.js') || id.endsWith('.mjs')
528
+ }
529
+
530
+ private ensurePluginDirs(): void {
531
+ if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
532
+ if (!fs.existsSync(PLUGIN_WORKSPACES_DIR)) fs.mkdirSync(PLUGIN_WORKSPACES_DIR, { recursive: true })
533
+ }
534
+
535
+ private getWorkspaceDir(filename: string): string {
536
+ return path.join(PLUGIN_WORKSPACES_DIR, pluginWorkspaceKey(filename))
537
+ }
538
+
539
+ private getWorkspaceEntryPath(filename: string): string {
540
+ return path.join(this.getWorkspaceDir(filename), 'index.js')
541
+ }
542
+
543
+ private getWorkspaceManifestPath(filename: string): string {
544
+ return path.join(this.getWorkspaceDir(filename), 'package.json')
545
+ }
546
+
547
+ private hasWorkspace(filename: string): boolean {
548
+ return fs.existsSync(this.getWorkspaceEntryPath(filename))
549
+ }
550
+
551
+ private readWorkspaceManifest(filename: string): Record<string, unknown> | null {
552
+ const manifestPath = this.getWorkspaceManifestPath(filename)
553
+ try {
554
+ if (!fs.existsSync(manifestPath)) return null
555
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>
556
+ } catch {
557
+ return null
558
+ }
559
+ }
560
+
561
+ private getDependencyInfo(filename: string, explicitConfig?: PluginConfigEntry | null): PluginDependencyInfo {
562
+ const manifest = this.readWorkspaceManifest(filename)
563
+ const counts = countManifestDependencies(manifest)
564
+ return {
565
+ hasManifest: !!manifest,
566
+ dependencyCount: counts.dependencyCount,
567
+ devDependencyCount: counts.devDependencyCount,
568
+ packageManager:
569
+ normalizePluginPackageManager(explicitConfig?.packageManager)
570
+ || normalizePluginPackageManager(manifest?.packageManager)
571
+ || undefined,
572
+ installStatus: explicitConfig?.dependencyInstallStatus || (manifest ? 'ready' : 'none'),
573
+ installError: explicitConfig?.dependencyInstallError,
574
+ installedAt: explicitConfig?.dependencyInstalledAt,
575
+ }
576
+ }
577
+
578
+ private writeWorkspaceShim(filename: string): void {
579
+ const relEntry = `./.workspaces/${pluginWorkspaceKey(filename)}/index.js`
580
+ const shim = `// Auto-generated plugin workspace shim. Edit the managed source file instead.\nmodule.exports = require(${JSON.stringify(relEntry)})\n`
581
+ fs.writeFileSync(path.join(PLUGINS_DIR, filename), shim, 'utf8')
582
+ }
583
+
584
+ private clearPluginRequireCache(dynamicRequire: NodeRequire, filename: string): void {
585
+ const rootPath = path.join(PLUGINS_DIR, filename)
586
+ delete dynamicRequire.cache[rootPath]
587
+ const workspaceDir = this.getWorkspaceDir(filename)
588
+ for (const cacheKey of Object.keys(dynamicRequire.cache)) {
589
+ if (cacheKey.startsWith(`${workspaceDir}${path.sep}`)) {
590
+ delete dynamicRequire.cache[cacheKey]
591
+ }
592
+ }
593
+ }
594
+
595
+ private resolvePluginSourcePath(filename: string): string {
596
+ return this.hasWorkspace(filename)
597
+ ? this.getWorkspaceEntryPath(filename)
598
+ : path.join(PLUGINS_DIR, filename)
599
+ }
600
+
601
+ private async runDependencyInstall(packageManager: PluginPackageManager, cwd: string): Promise<void> {
602
+ const { command, args } = getInstallCommand(packageManager)
603
+
604
+ await new Promise<void>((resolve, reject) => {
605
+ const child = spawn(command, args, {
606
+ cwd,
607
+ env: { ...process.env },
608
+ stdio: ['ignore', 'pipe', 'pipe'],
609
+ })
610
+
611
+ let stderr = ''
612
+ let stdout = ''
613
+ const timer = setTimeout(() => {
614
+ child.kill('SIGTERM')
615
+ reject(new Error(`${command} install timed out after ${Math.round(PACKAGE_INSTALL_TIMEOUT_MS / 1000)}s`))
616
+ }, PACKAGE_INSTALL_TIMEOUT_MS)
617
+
618
+ child.stdout?.on('data', (chunk: Buffer | string) => {
619
+ stdout = trimProcessOutput(`${stdout}${chunk.toString()}`)
620
+ })
621
+ child.stderr?.on('data', (chunk: Buffer | string) => {
622
+ stderr = trimProcessOutput(`${stderr}${chunk.toString()}`)
623
+ })
624
+ child.on('error', (err) => {
625
+ clearTimeout(timer)
626
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
627
+ reject(new Error(`${command} is not installed on this machine`))
628
+ return
629
+ }
630
+ reject(err)
631
+ })
632
+ child.on('close', (code) => {
633
+ clearTimeout(timer)
634
+ if (code === 0) {
635
+ resolve()
636
+ return
637
+ }
638
+ reject(new Error(trimProcessOutput(`${stderr}\n${stdout}`) || `${command} install exited ${code}`))
639
+ })
640
+ })
641
+ }
642
+
643
+ private canonicalPluginId(id: string): string {
644
+ const trimmed = typeof id === 'string' ? id.trim() : ''
645
+ if (!trimmed) return ''
646
+ if (this.isExternalPluginFilename(trimmed)) return path.basename(trimmed)
647
+ return canonicalizePluginId(trimmed)
648
+ }
649
+
650
+ private configIdsFor(id: string): string[] {
651
+ const canonicalId = this.canonicalPluginId(id)
652
+ if (!canonicalId) return []
653
+ if (this.isExternalPluginFilename(canonicalId)) return [canonicalId]
654
+ const aliases = getPluginAliases(canonicalId)
655
+ const ids = new Set<string>([canonicalId, ...aliases])
656
+ return Array.from(ids)
657
+ }
658
+
659
+ private readConfigEntry(id: string, config?: Record<string, PluginConfigEntry>): PluginConfigEntry | null {
660
+ const cfg = config || this.loadConfig()
661
+ let merged: PluginConfigEntry | null = null
662
+ for (const key of this.configIdsFor(id)) {
663
+ const entry = cfg[key]
664
+ if (!entry) continue
665
+ merged = { ...(merged || {}), ...entry }
666
+ if (key === this.canonicalPluginId(id)) break
667
+ }
668
+ return merged
669
+ }
670
+
671
+ private writeConfig(config: Record<string, PluginConfigEntry>): void {
672
+ fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
673
+ }
674
+
675
+ private updateConfigEntry(id: string, patch: PluginConfigEntry | null): void {
676
+ const canonicalId = this.canonicalPluginId(id)
677
+ const config = this.loadConfig()
678
+ for (const key of this.configIdsFor(canonicalId)) {
679
+ if (key !== canonicalId) delete config[key]
680
+ }
681
+ if (patch) {
682
+ config[canonicalId] = { ...(config[canonicalId] || {}), ...patch }
683
+ } else {
684
+ delete config[canonicalId]
685
+ }
686
+ this.writeConfig(config)
687
+ }
688
+
689
+ private resolveEnabledFilter(enabledIds?: string[], includeAllWhenEmpty = false): Set<string> | null {
690
+ if (!Array.isArray(enabledIds) || enabledIds.length === 0) {
691
+ return includeAllWhenEmpty ? null : new Set<string>()
692
+ }
693
+ return new Set(expandPluginIds(enabledIds))
694
+ }
695
+
291
696
  private readFailureState(): Record<string, PluginFailureRecord> {
292
697
  try {
293
698
  const parsed = JSON.parse(fs.readFileSync(PLUGIN_FAILURES, 'utf8')) as Record<string, PluginFailureRecord>
@@ -308,17 +713,21 @@ class PluginManager {
308
713
 
309
714
  private clearFailureState(id: string): void {
310
715
  const state = this.readFailureState()
311
- if (!state[id]) return
312
- delete state[id]
716
+ let changed = false
717
+ for (const key of this.configIdsFor(id)) {
718
+ if (!state[key]) continue
719
+ delete state[key]
720
+ changed = true
721
+ }
722
+ if (!changed) return
313
723
  this.writeFailureState(state)
314
724
  }
315
725
 
316
726
  private autoDisableExternalPlugin(id: string, reason: string, failure: PluginFailureRecord): void {
317
- const config = this.loadConfig()
318
- if (config[id]?.enabled === false) return
319
- config[id] = { ...config[id], enabled: false }
320
727
  try {
321
- fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
728
+ const current = this.readConfigEntry(id)
729
+ if (current?.enabled === false) return
730
+ this.updateConfigEntry(id, { ...(current || {}), enabled: false })
322
731
  } catch (err: unknown) {
323
732
  log.error('plugins', 'Failed to write plugins config while auto-disabling plugin', {
324
733
  pluginId: id,
@@ -353,14 +762,15 @@ class PluginManager {
353
762
  private markPluginFailure(id: string, stage: string, err: unknown, disableEligible: boolean): void {
354
763
  const errorText = err instanceof Error ? err.message : String(err)
355
764
  const state = this.readFailureState()
356
- const nextCount = (state[id]?.count || 0) + 1
765
+ const failureKey = this.canonicalPluginId(id)
766
+ const nextCount = (state[failureKey]?.count || 0) + 1
357
767
  const record: PluginFailureRecord = {
358
768
  count: nextCount,
359
769
  lastError: errorText,
360
770
  lastStage: stage,
361
771
  lastFailedAt: Date.now(),
362
772
  }
363
- state[id] = record
773
+ state[failureKey] = record
364
774
  this.writeFailureState(state)
365
775
 
366
776
  log.warn('plugins', 'Plugin failure recorded', {
@@ -371,8 +781,12 @@ class PluginManager {
371
781
  error: errorText,
372
782
  })
373
783
 
374
- if (disableEligible && nextCount >= MAX_CONSECUTIVE_PLUGIN_FAILURES) {
375
- this.autoDisableExternalPlugin(id, `Plugin failure at ${stage}`, record)
784
+ if (
785
+ disableEligible
786
+ && nextCount >= MAX_CONSECUTIVE_PLUGIN_FAILURES
787
+ && !this.builtins.has(failureKey)
788
+ ) {
789
+ this.autoDisableExternalPlugin(failureKey, `Plugin failure at ${stage}`, record)
376
790
  }
377
791
  }
378
792
 
@@ -387,17 +801,27 @@ class PluginManager {
387
801
  load() {
388
802
  if (this.loaded) return
389
803
  this.plugins.clear()
804
+ this.ensurePluginWatcher()
390
805
 
391
806
  const config = this.loadConfig()
392
807
 
393
808
  // 1. Load Built-ins
394
809
  for (const [id, p] of this.builtins.entries()) {
395
- const explicitConfig = config[id]
810
+ const explicitConfig = this.readConfigEntry(id, config)
396
811
  const isEnabled = explicitConfig != null ? explicitConfig.enabled !== false : p.enabledByDefault !== false
397
812
  if (isEnabled) {
398
813
  this.plugins.set(id, {
399
814
  id,
400
- meta: { name: p.name, description: p.description || '', filename: id, enabled: true },
815
+ meta: {
816
+ name: p.name,
817
+ description: p.description || '',
818
+ filename: id,
819
+ enabled: true,
820
+ author: p.author || 'SwarmClaw',
821
+ version: p.version || '1.0.0',
822
+ source: 'local',
823
+ openclaw: p.openclaw === true,
824
+ },
401
825
  hooks: p.hooks || {},
402
826
  tools: p.tools || [],
403
827
  ui: p.ui,
@@ -411,7 +835,7 @@ class PluginManager {
411
835
 
412
836
  // 2. Load External
413
837
  try {
414
- if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
838
+ this.ensurePluginDirs()
415
839
  const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
416
840
 
417
841
  let dynamicRequire: NodeRequire | null = null
@@ -426,11 +850,12 @@ class PluginManager {
426
850
  if (dynamicRequire) {
427
851
  for (const file of files) {
428
852
  try {
429
- const isEnabled = config[file]?.enabled !== false
853
+ const explicitConfig = this.readConfigEntry(file, config)
854
+ const isEnabled = explicitConfig?.enabled !== false
430
855
  if (!isEnabled) continue
431
856
 
432
857
  const fullPath = path.join(PLUGINS_DIR, file)
433
- delete dynamicRequire.cache[fullPath]
858
+ this.clearPluginRequireCache(dynamicRequire, file)
434
859
  const plugin = normalizePlugin(dynamicRequire(fullPath))
435
860
  if (!plugin) {
436
861
  this.markPluginFailure(file, 'load.normalize', 'Plugin format unsupported or activate() failed', true)
@@ -439,7 +864,16 @@ class PluginManager {
439
864
 
440
865
  this.plugins.set(file, {
441
866
  id: file,
442
- meta: { name: plugin.name, description: plugin.description || '', filename: file, enabled: true },
867
+ meta: {
868
+ name: plugin.name,
869
+ description: plugin.description || '',
870
+ filename: file,
871
+ enabled: true,
872
+ author: plugin.author,
873
+ version: plugin.version || '0.0.1',
874
+ source: explicitConfig?.sourceUrl ? 'marketplace' : 'local',
875
+ openclaw: plugin.openclaw === true,
876
+ },
443
877
  hooks: plugin.hooks || {},
444
878
  tools: plugin.tools || [],
445
879
  ui: plugin.ui,
@@ -464,7 +898,7 @@ class PluginManager {
464
898
  getTools(enabledIds: string[]): Array<{ pluginId: string; tool: PluginToolDef }> {
465
899
  this.load()
466
900
  const all: Array<{ pluginId: string; tool: PluginToolDef }> = []
467
- const ids = new Set(enabledIds)
901
+ const ids = new Set(expandPluginIds(enabledIds))
468
902
  for (const [id, p] of this.plugins.entries()) {
469
903
  if (ids.has(id)) {
470
904
  const tools = Array.isArray(p.tools) ? p.tools : []
@@ -526,13 +960,17 @@ class PluginManager {
526
960
  return allUI
527
961
  }
528
962
 
529
- async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, enabledIds: string[] = []) {
963
+ listPluginIds(): string[] {
964
+ this.load()
965
+ return Array.from(this.plugins.keys())
966
+ }
967
+
968
+ async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, options?: HookExecutionOptions) {
530
969
  this.load()
531
- // If no enabledIds provided, run for all loaded plugins (legacy behavior)
532
- const filterIds = enabledIds.length > 0 ? new Set(enabledIds) : null
970
+ const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
533
971
 
534
972
  for (const [id, p] of this.plugins.entries()) {
535
- if (filterIds && !filterIds.has(id)) continue
973
+ if (filterIds !== null && !filterIds.has(id)) continue
536
974
  const hook = p.hooks[hookName]
537
975
  if (hook) {
538
976
  try {
@@ -551,22 +989,54 @@ class PluginManager {
551
989
  }
552
990
  }
553
991
 
992
+ async runBeforeToolExec(
993
+ params: { toolName: string; input: Record<string, unknown> | null },
994
+ options?: HookExecutionOptions,
995
+ ): Promise<Record<string, unknown> | null> {
996
+ this.load()
997
+ const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
998
+ let currentInput = params.input
999
+
1000
+ for (const [id, p] of this.plugins.entries()) {
1001
+ if (filterIds !== null && !filterIds.has(id)) continue
1002
+ const hook = p.hooks.beforeToolExec
1003
+ if (!hook) continue
1004
+ try {
1005
+ const result = await hook({ toolName: params.toolName, input: currentInput })
1006
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
1007
+ currentInput = result as Record<string, unknown>
1008
+ }
1009
+ this.markPluginSuccess(id)
1010
+ } catch (err: unknown) {
1011
+ log.error('plugins', 'beforeToolExec hook failed', {
1012
+ pluginId: id,
1013
+ pluginName: p.meta.name,
1014
+ toolName: params.toolName,
1015
+ error: err instanceof Error ? err.message : String(err),
1016
+ })
1017
+ this.markPluginFailure(id, 'hook.beforeToolExec', err, true)
1018
+ }
1019
+ }
1020
+
1021
+ return currentInput
1022
+ }
1023
+
554
1024
  async transformText(
555
1025
  hookName: 'transformInboundMessage' | 'transformOutboundMessage',
556
1026
  params: { session: Session; text: string },
557
- enabledIds: string[] = [],
1027
+ options?: HookExecutionOptions,
558
1028
  ): Promise<string> {
559
1029
  this.load()
560
- const filterIds = enabledIds.length > 0 ? new Set(enabledIds) : null
1030
+ const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
561
1031
  let currentText = params.text
562
1032
 
563
1033
  for (const [id, p] of this.plugins.entries()) {
564
- if (filterIds && !filterIds.has(id)) continue
1034
+ if (filterIds !== null && !filterIds.has(id)) continue
565
1035
  const hook = p.hooks[hookName]
566
1036
  if (hook) {
567
1037
  try {
568
- const result = await (hook as (ctx: typeof params) => Promise<string> | string)(params)
569
- currentText = result
1038
+ const result = await (hook as (ctx: typeof params) => Promise<string> | string)({ ...params, text: currentText })
1039
+ if (typeof result === 'string') currentText = result
570
1040
  this.markPluginSuccess(id)
571
1041
  } catch (err: unknown) {
572
1042
  log.error('plugins', 'Plugin transform hook failed', {
@@ -584,7 +1054,7 @@ class PluginManager {
584
1054
 
585
1055
  async collectAgentContext(session: import('@/types').Session, enabledPlugins: string[], message: string, history: import('@/types').Message[]): Promise<string[]> {
586
1056
  this.load()
587
- const enabledSet = new Set(enabledPlugins)
1057
+ const enabledSet = new Set(expandPluginIds(enabledPlugins))
588
1058
  const parts: string[] = []
589
1059
 
590
1060
  for (const [id, p] of this.plugins.entries()) {
@@ -678,6 +1148,150 @@ class PluginManager {
678
1148
  return result
679
1149
  }
680
1150
 
1151
+ getSettingsFields(pluginId: string): import('@/types').PluginSettingsField[] {
1152
+ this.load()
1153
+ const candidateIds = expandPluginIds([pluginId])
1154
+ for (const id of candidateIds) {
1155
+ const plugin = this.plugins.get(id) || (this.builtins.has(id) ? {
1156
+ ui: this.builtins.get(id)?.ui,
1157
+ } as LoadedPlugin : null)
1158
+ const fields = plugin?.ui?.settingsFields
1159
+ if (fields?.length) return fields
1160
+ }
1161
+ return []
1162
+ }
1163
+
1164
+ getPluginSettings(pluginId: string): Record<string, unknown> {
1165
+ const settings = loadSettings()
1166
+ const allSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
1167
+ const result: Record<string, unknown> = {}
1168
+
1169
+ for (const key of this.configIdsFor(pluginId)) {
1170
+ const values = allSettings[key]
1171
+ if (!values || typeof values !== 'object') continue
1172
+ for (const [fieldKey, fieldValue] of Object.entries(values)) {
1173
+ if (isPluginSecretSettingValue(fieldValue)) {
1174
+ try {
1175
+ result[fieldKey] = decryptKey(fieldValue.encrypted)
1176
+ } catch {
1177
+ result[fieldKey] = ''
1178
+ }
1179
+ continue
1180
+ }
1181
+ result[fieldKey] = fieldValue
1182
+ }
1183
+ }
1184
+
1185
+ for (const field of this.getSettingsFields(pluginId)) {
1186
+ if (result[field.key] === undefined && field.defaultValue !== undefined) {
1187
+ result[field.key] = field.defaultValue
1188
+ }
1189
+ }
1190
+
1191
+ return result
1192
+ }
1193
+
1194
+ getPublicPluginSettings(pluginId: string): { values: Record<string, unknown>; configuredSecretFields: string[] } {
1195
+ const values = this.getPluginSettings(pluginId)
1196
+ const configuredSecretFields: string[] = []
1197
+
1198
+ for (const field of this.getSettingsFields(pluginId)) {
1199
+ if (field.type !== 'secret') continue
1200
+ const current = values[field.key]
1201
+ if (typeof current === 'string' && current.trim()) {
1202
+ configuredSecretFields.push(field.key)
1203
+ }
1204
+ values[field.key] = ''
1205
+ }
1206
+
1207
+ return { values, configuredSecretFields }
1208
+ }
1209
+
1210
+ setPluginSettings(pluginId: string, values: Record<string, unknown>): Record<string, unknown> {
1211
+ const fields = this.getSettingsFields(pluginId)
1212
+ if (fields.length === 0 && Object.keys(values || {}).length > 0) {
1213
+ throw new Error(`Plugin "${pluginId}" does not declare configurable settings`)
1214
+ }
1215
+ const fieldMap = new Map(fields.map((field) => [field.key, field]))
1216
+ const nextValues: Record<string, unknown> = {}
1217
+
1218
+ for (const [key, rawValue] of Object.entries(values || {})) {
1219
+ const field = fieldMap.get(key)
1220
+ if (!field) continue
1221
+ if (rawValue === undefined) continue
1222
+ if (field.type === 'boolean') {
1223
+ nextValues[key] = rawValue === true || rawValue === 'true' || rawValue === 1 || rawValue === '1'
1224
+ continue
1225
+ }
1226
+ if (field.type === 'number') {
1227
+ const parsed = typeof rawValue === 'number' ? rawValue : Number(rawValue)
1228
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid number for setting "${key}"`)
1229
+ nextValues[key] = parsed
1230
+ continue
1231
+ }
1232
+ const text = typeof rawValue === 'string' ? rawValue : String(rawValue ?? '')
1233
+ if (field.required && !text.trim()) throw new Error(`Setting "${key}" is required`)
1234
+ if (field.type === 'select' && field.options?.length) {
1235
+ const allowed = new Set(field.options.map((option) => option.value))
1236
+ if (!allowed.has(text)) throw new Error(`Invalid value for setting "${key}"`)
1237
+ }
1238
+ if (field.type === 'secret') {
1239
+ nextValues[key] = text.trim()
1240
+ } else {
1241
+ nextValues[key] = text
1242
+ }
1243
+ }
1244
+
1245
+ const currentSettings = loadSettings()
1246
+ const pluginSettings = (currentSettings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
1247
+ const canonicalId = this.canonicalPluginId(pluginId)
1248
+ const existingStored: Record<string, unknown> = {}
1249
+ for (const alias of this.configIdsFor(canonicalId)) {
1250
+ const existing = pluginSettings[alias]
1251
+ if (!existing || typeof existing !== 'object') continue
1252
+ Object.assign(existingStored, existing)
1253
+ }
1254
+
1255
+ for (const field of fields) {
1256
+ if (!field.required) continue
1257
+ if (
1258
+ nextValues[field.key] === undefined
1259
+ && existingStored[field.key] === undefined
1260
+ && field.defaultValue === undefined
1261
+ ) {
1262
+ throw new Error(`Setting "${field.key}" is required`)
1263
+ }
1264
+ }
1265
+
1266
+ const stored: Record<string, unknown> = {}
1267
+
1268
+ for (const field of fields) {
1269
+ if (nextValues[field.key] === undefined) {
1270
+ if (existingStored[field.key] !== undefined) {
1271
+ stored[field.key] = existingStored[field.key]
1272
+ }
1273
+ continue
1274
+ }
1275
+ if (field.type === 'secret') {
1276
+ stored[field.key] = {
1277
+ __pluginSecret: true,
1278
+ encrypted: encryptKey(String(nextValues[field.key] ?? '')),
1279
+ } satisfies PluginSecretSettingValue
1280
+ } else {
1281
+ stored[field.key] = nextValues[field.key]
1282
+ }
1283
+ }
1284
+
1285
+ for (const alias of this.configIdsFor(canonicalId)) {
1286
+ delete pluginSettings[alias]
1287
+ }
1288
+ pluginSettings[canonicalId] = stored
1289
+ currentSettings.pluginSettings = pluginSettings
1290
+ saveSettings(currentSettings)
1291
+
1292
+ return this.getPublicPluginSettings(canonicalId).values
1293
+ }
1294
+
681
1295
  recordExternalToolFailure(pluginId: string, toolName: string, err: unknown): void {
682
1296
  this.markPluginFailure(pluginId, `tool.${toolName}`, err, true)
683
1297
  }
@@ -687,10 +1301,9 @@ class PluginManager {
687
1301
  }
688
1302
 
689
1303
  isEnabled(filename: string): boolean {
690
- const config = this.loadConfig()
691
- const explicit = config[filename]
1304
+ const explicit = this.readConfigEntry(filename)
692
1305
  if (explicit != null) return explicit.enabled !== false
693
- const builtin = this.builtins.get(filename)
1306
+ const builtin = this.builtins.get(this.canonicalPluginId(filename))
694
1307
  if (builtin) return builtin.enabledByDefault !== false
695
1308
  return true
696
1309
  }
@@ -722,18 +1335,20 @@ class PluginManager {
722
1335
  // Add all builtins
723
1336
  for (const [id, p] of this.builtins.entries()) {
724
1337
  const loaded = this.plugins.get(id)
725
- const explicitCfg = config[id]
1338
+ const explicitCfg = this.readConfigEntry(id, config)
726
1339
  const enabled = explicitCfg != null ? explicitCfg.enabled !== false : p.enabledByDefault !== false
727
- const failure = failures[id]
1340
+ const failure = failures[this.canonicalPluginId(id)]
728
1341
  const caps = describeCapabilities(loaded, p)
729
1342
  metas.push({
730
1343
  name: p.name,
731
1344
  description: p.description || '',
732
1345
  filename: id,
733
1346
  enabled,
734
- author: 'SwarmClaw',
1347
+ isBuiltin: true,
1348
+ author: p.author || 'SwarmClaw',
735
1349
  version: (p as { version?: string }).version || loaded?.meta.version || '1.0.0',
736
1350
  source: loaded?.meta.source || 'local',
1351
+ openclaw: p.openclaw === true,
737
1352
  failureCount: failure?.count,
738
1353
  lastFailureAt: failure?.lastFailedAt,
739
1354
  lastFailureStage: failure?.lastStage,
@@ -749,22 +1364,33 @@ class PluginManager {
749
1364
  for (const f of files) {
750
1365
  if (!metas.find(m => m.filename === f)) {
751
1366
  const loaded = this.plugins.get(f)
752
- const enabled = config[f]?.enabled !== false
1367
+ const explicitCfg = this.readConfigEntry(f, config)
1368
+ const enabled = explicitCfg?.enabled !== false
753
1369
  const failure = failures[f]
754
1370
  const caps = describeCapabilities(loaded)
1371
+ const dependencyInfo = this.getDependencyInfo(f, explicitCfg)
755
1372
  metas.push({
756
1373
  name: loaded?.meta.name || f.replace(/\.(js|mjs)$/, ''),
757
1374
  filename: f,
758
1375
  enabled,
1376
+ isBuiltin: false,
759
1377
  author: loaded?.meta.author,
760
1378
  version: loaded?.meta.version || '0.0.1',
761
- source: loaded?.meta.source || 'marketplace',
762
- createdByAgentId: config[f]?.createdByAgentId || null,
1379
+ source: loaded?.meta.source || (explicitCfg?.sourceUrl ? 'marketplace' : 'local'),
1380
+ openclaw: loaded?.meta.openclaw,
1381
+ createdByAgentId: explicitCfg?.createdByAgentId || null,
763
1382
  failureCount: failure?.count,
764
1383
  lastFailureAt: failure?.lastFailedAt,
765
1384
  lastFailureStage: failure?.lastStage,
766
1385
  lastFailureError: failure?.lastError,
767
1386
  autoDisabled: !enabled && !!failure && failure.count >= MAX_CONSECUTIVE_PLUGIN_FAILURES,
1387
+ hasDependencyManifest: dependencyInfo.hasManifest,
1388
+ dependencyCount: dependencyInfo.dependencyCount,
1389
+ devDependencyCount: dependencyInfo.devDependencyCount,
1390
+ packageManager: dependencyInfo.packageManager,
1391
+ dependencyInstallStatus: dependencyInfo.installStatus,
1392
+ dependencyInstallError: dependencyInfo.installError,
1393
+ dependencyInstalledAt: dependencyInfo.installedAt,
768
1394
  ...caps,
769
1395
  })
770
1396
  }
@@ -778,52 +1404,178 @@ class PluginManager {
778
1404
  }
779
1405
  }
780
1406
 
1407
+ readPluginSource(filename: string): string {
1408
+ const fullPath = this.resolvePluginSourcePath(filename)
1409
+ if (!fs.existsSync(fullPath)) throw new Error(`Plugin not found: ${filename}`)
1410
+ return fs.readFileSync(fullPath, 'utf8')
1411
+ }
1412
+
1413
+ async savePluginSource(filename: string, code: string, options?: UpsertPluginOptions): Promise<void> {
1414
+ const sanitizedFilename = sanitizePluginFilename(filename)
1415
+ this.ensurePluginDirs()
1416
+
1417
+ const shouldUseWorkspace = this.hasWorkspace(sanitizedFilename) || options?.packageJson !== undefined
1418
+ const sourcePath = shouldUseWorkspace
1419
+ ? this.getWorkspaceEntryPath(sanitizedFilename)
1420
+ : path.join(PLUGINS_DIR, sanitizedFilename)
1421
+
1422
+ if (shouldUseWorkspace) {
1423
+ fs.mkdirSync(this.getWorkspaceDir(sanitizedFilename), { recursive: true })
1424
+ fs.writeFileSync(sourcePath, code, 'utf8')
1425
+ this.writeWorkspaceShim(sanitizedFilename)
1426
+ } else {
1427
+ fs.writeFileSync(sourcePath, code, 'utf8')
1428
+ }
1429
+
1430
+ const normalizedPackageManager = normalizePluginPackageManager(options?.packageManager)
1431
+
1432
+ if (options?.packageJson !== undefined) {
1433
+ if (!shouldUseWorkspace) {
1434
+ throw new Error('Plugin workspace is required for package.json support')
1435
+ }
1436
+ const manifest = normalizePluginManifest(options.packageJson, sanitizedFilename, normalizedPackageManager)
1437
+ fs.writeFileSync(this.getWorkspaceManifestPath(sanitizedFilename), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
1438
+ this.setMeta(sanitizedFilename, {
1439
+ ...(options?.meta || {}),
1440
+ packageManager: normalizedPackageManager || normalizePluginPackageManager(manifest.packageManager) || undefined,
1441
+ dependencyInstallStatus: 'ready',
1442
+ dependencyInstallError: undefined,
1443
+ dependencyInstalledAt: undefined,
1444
+ })
1445
+ } else if (options?.meta && Object.keys(options.meta).length > 0) {
1446
+ this.setMeta(sanitizedFilename, options.meta)
1447
+ }
1448
+
1449
+ if (options?.installDependencies) {
1450
+ await this.installPluginDependencies(sanitizedFilename, {
1451
+ packageManager: normalizedPackageManager || undefined,
1452
+ })
1453
+ }
1454
+
1455
+ this.reload()
1456
+ }
1457
+
1458
+ async installPluginDependencies(filename: string, options?: { packageManager?: PluginPackageManager }): Promise<PluginDependencyInfo> {
1459
+ const sanitizedFilename = sanitizePluginFilename(filename)
1460
+ const fullPath = path.join(PLUGINS_DIR, sanitizedFilename)
1461
+ if (!fs.existsSync(fullPath) && !this.hasWorkspace(sanitizedFilename)) {
1462
+ throw new Error(`Plugin not found: ${sanitizedFilename}`)
1463
+ }
1464
+
1465
+ this.ensurePluginDirs()
1466
+ const workspaceDir = this.getWorkspaceDir(sanitizedFilename)
1467
+ const sourcePath = this.resolvePluginSourcePath(sanitizedFilename)
1468
+ const currentCode = fs.existsSync(sourcePath) ? fs.readFileSync(sourcePath, 'utf8') : ''
1469
+
1470
+ if (!this.hasWorkspace(sanitizedFilename)) {
1471
+ fs.mkdirSync(workspaceDir, { recursive: true })
1472
+ fs.writeFileSync(this.getWorkspaceEntryPath(sanitizedFilename), currentCode, 'utf8')
1473
+ this.writeWorkspaceShim(sanitizedFilename)
1474
+ }
1475
+
1476
+ const manifest = this.readWorkspaceManifest(sanitizedFilename)
1477
+ if (!manifest) throw new Error(`Plugin "${sanitizedFilename}" does not have a package.json manifest`)
1478
+
1479
+ const packageManager = options?.packageManager
1480
+ || normalizePluginPackageManager(this.readConfigEntry(sanitizedFilename)?.packageManager)
1481
+ || normalizePluginPackageManager(manifest.packageManager)
1482
+ || 'npm'
1483
+
1484
+ this.setMeta(sanitizedFilename, {
1485
+ packageManager,
1486
+ dependencyInstallStatus: 'installing',
1487
+ dependencyInstallError: undefined,
1488
+ })
1489
+
1490
+ try {
1491
+ await this.runDependencyInstall(packageManager, workspaceDir)
1492
+ this.setMeta(sanitizedFilename, {
1493
+ packageManager,
1494
+ dependencyInstallStatus: 'installed',
1495
+ dependencyInstallError: undefined,
1496
+ dependencyInstalledAt: Date.now(),
1497
+ })
1498
+ } catch (err: unknown) {
1499
+ const message = err instanceof Error ? err.message : String(err)
1500
+ this.setMeta(sanitizedFilename, {
1501
+ packageManager,
1502
+ dependencyInstallStatus: 'error',
1503
+ dependencyInstallError: message,
1504
+ })
1505
+ throw new Error(message)
1506
+ } finally {
1507
+ this.reload()
1508
+ }
1509
+
1510
+ return this.getDependencyInfo(sanitizedFilename, this.readConfigEntry(sanitizedFilename))
1511
+ }
1512
+
781
1513
  setEnabled(filename: string, enabled: boolean) {
782
- const config = this.loadConfig()
783
- config[filename] = { ...config[filename], enabled }
784
- fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
1514
+ const current = this.readConfigEntry(filename)
1515
+ this.updateConfigEntry(filename, { ...(current || {}), enabled })
785
1516
  if (enabled) this.clearFailureState(filename)
786
1517
  this.reload()
787
1518
  }
788
1519
 
789
1520
  deletePlugin(filename: string): boolean {
790
1521
  // Only allow deleting external plugins, not builtins
791
- if (this.builtins.has(filename)) return false
1522
+ if (this.builtins.has(this.canonicalPluginId(filename))) return false
792
1523
  const fullPath = path.join(PLUGINS_DIR, filename)
793
1524
  if (!fs.existsSync(fullPath)) return false
794
1525
  fs.unlinkSync(fullPath)
795
- // Remove from config
796
- const config = this.loadConfig()
797
- delete config[filename]
798
- fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
1526
+ const workspaceDir = this.getWorkspaceDir(filename)
1527
+ if (fs.existsSync(workspaceDir)) fs.rmSync(workspaceDir, { recursive: true, force: true })
1528
+ this.updateConfigEntry(filename, null)
1529
+ const settings = loadSettings()
1530
+ const pluginSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
1531
+ for (const key of this.configIdsFor(filename)) delete pluginSettings[key]
1532
+ settings.pluginSettings = pluginSettings
1533
+ saveSettings(settings)
799
1534
  this.clearFailureState(filename)
800
1535
  this.reload()
801
1536
  return true
802
1537
  }
803
1538
 
1539
+ async installPluginFromUrl(url: string, filename: string, meta?: Record<string, unknown>): Promise<InstalledPluginSource> {
1540
+ const sanitizedFilename = sanitizePluginFilename(filename)
1541
+ const download = await downloadPluginSource(url)
1542
+ await this.savePluginSource(sanitizedFilename, download.code, {
1543
+ meta: {
1544
+ ...(meta || {}),
1545
+ sourceUrl: download.normalizedUrl,
1546
+ sourceHash: download.hash,
1547
+ installedAt: Date.now(),
1548
+ updatedAt: Date.now(),
1549
+ },
1550
+ })
1551
+
1552
+ return {
1553
+ filename: sanitizedFilename,
1554
+ sourceUrl: download.normalizedUrl,
1555
+ sourceHash: download.hash,
1556
+ contentType: download.contentType,
1557
+ }
1558
+ }
1559
+
804
1560
  async updatePlugin(id: string) {
805
1561
  this.load()
806
1562
  const p = this.plugins.get(id)
807
1563
  if (!p) throw new Error('Plugin not found')
1564
+ if (p.isBuiltin) throw new Error('Built-in plugins are updated via application releases')
808
1565
 
809
1566
  log.info('plugins', 'Updating plugin', { pluginId: id, pluginName: p.meta.name })
810
- // If it's from marketplace, we'd refetch from URL.
811
- // For this demo, we'll just simulate a version bump if it's external.
812
- if (!p.isBuiltin) {
813
- const fullPath = path.join(PLUGINS_DIR, id)
814
- if (fs.existsSync(fullPath)) {
815
- let content = fs.readFileSync(fullPath, 'utf8')
816
- // Simulate a version bump in the file content
817
- const versionMatch = content.match(/version:\s*['"]([^'"]+)['"]/)
818
- if (versionMatch) {
819
- const current = versionMatch[1]
820
- const next = current.split('.').map((v, i) => i === 2 ? parseInt(v) + 1 : v).join('.')
821
- content = content.replace(`version: '${current}'`, `version: '${next}'`)
822
- content = content.replace(`version: "${current}"`, `version: "${next}"`)
823
- fs.writeFileSync(fullPath, content, 'utf8')
824
- }
825
- }
826
- }
1567
+ const current = this.readConfigEntry(id)
1568
+ const sourceUrl = current?.sourceUrl?.trim()
1569
+ if (!sourceUrl) throw new Error(`Plugin "${id}" has no recorded source URL and cannot be updated automatically`)
1570
+
1571
+ const download = await downloadPluginSource(sourceUrl)
1572
+ const fullPath = path.join(PLUGINS_DIR, id)
1573
+ fs.writeFileSync(fullPath, download.code, 'utf8')
1574
+ this.setMeta(id, {
1575
+ sourceUrl: download.normalizedUrl,
1576
+ sourceHash: download.hash,
1577
+ updatedAt: Date.now(),
1578
+ })
827
1579
 
828
1580
  this.reload()
829
1581
  return true
@@ -831,7 +1583,9 @@ class PluginManager {
831
1583
 
832
1584
  async updateAllPlugins() {
833
1585
  this.load()
834
- const ids = Array.from(this.plugins.keys())
1586
+ const ids = Array.from(this.plugins.entries())
1587
+ .filter(([, plugin]) => !plugin.isBuiltin)
1588
+ .map(([id]) => id)
835
1589
  for (const id of ids) {
836
1590
  try {
837
1591
  await this.updatePlugin(id)
@@ -841,12 +1595,11 @@ class PluginManager {
841
1595
  }
842
1596
 
843
1597
  setMeta(filename: string, meta: Record<string, unknown>) {
844
- const config = this.loadConfig()
845
- config[filename] = { ...config[filename], ...meta }
846
- fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
1598
+ const current = this.readConfigEntry(filename)
1599
+ this.updateConfigEntry(filename, { ...(current || {}), ...(meta as PluginConfigEntry) })
847
1600
  }
848
1601
 
849
- private loadConfig(): Record<string, { enabled?: boolean; createdByAgentId?: string }> {
1602
+ private loadConfig(): Record<string, PluginConfigEntry> {
850
1603
  try { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8')) } catch { return {} }
851
1604
  }
852
1605