@swarmclawai/swarmclaw 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/README.md +116 -50
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +43 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +39 -8
  12. package/src/app/api/agents/route.ts +35 -2
  13. package/src/app/api/auth/route.ts +77 -8
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  19. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  20. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  21. package/src/app/api/chats/[id]/route.ts +30 -0
  22. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  23. package/src/app/api/chats/heartbeat/route.ts +2 -1
  24. package/src/app/api/chats/route.ts +23 -1
  25. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  26. package/src/app/api/connectors/doctor/route.ts +13 -0
  27. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  28. package/src/app/api/external-agents/[id]/route.ts +31 -0
  29. package/src/app/api/external-agents/register/route.ts +3 -0
  30. package/src/app/api/external-agents/route.ts +66 -0
  31. package/src/app/api/files/open/route.ts +16 -14
  32. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  33. package/src/app/api/gateways/[id]/route.ts +79 -0
  34. package/src/app/api/gateways/route.ts +57 -0
  35. package/src/app/api/memory/maintenance/route.ts +11 -1
  36. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  37. package/src/app/api/openclaw/gateway/route.ts +10 -7
  38. package/src/app/api/openclaw/skills/route.ts +12 -4
  39. package/src/app/api/plugins/dependencies/route.ts +24 -0
  40. package/src/app/api/plugins/install/route.ts +15 -92
  41. package/src/app/api/plugins/route.ts +3 -26
  42. package/src/app/api/plugins/settings/route.ts +17 -12
  43. package/src/app/api/plugins/ui/route.ts +1 -0
  44. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  45. package/src/app/api/schedules/[id]/route.ts +38 -9
  46. package/src/app/api/schedules/route.ts +51 -28
  47. package/src/app/api/settings/route.ts +55 -17
  48. package/src/app/api/setup/doctor/route.ts +6 -4
  49. package/src/app/api/tasks/[id]/route.ts +16 -6
  50. package/src/app/api/tasks/bulk/route.ts +3 -3
  51. package/src/app/api/tasks/route.ts +9 -4
  52. package/src/app/api/webhooks/[id]/route.ts +8 -1
  53. package/src/app/page.tsx +135 -17
  54. package/src/cli/binary.test.js +142 -0
  55. package/src/cli/index.js +38 -11
  56. package/src/cli/index.test.js +195 -0
  57. package/src/cli/index.ts +21 -12
  58. package/src/cli/server-cmd.test.js +59 -0
  59. package/src/cli/spec.js +20 -2
  60. package/src/components/agents/agent-card.tsx +15 -12
  61. package/src/components/agents/agent-chat-list.tsx +101 -1
  62. package/src/components/agents/agent-list.tsx +46 -9
  63. package/src/components/agents/agent-sheet.tsx +456 -23
  64. package/src/components/agents/inspector-panel.tsx +110 -49
  65. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  66. package/src/components/auth/access-key-gate.tsx +36 -97
  67. package/src/components/auth/setup-wizard.tsx +970 -275
  68. package/src/components/chat/chat-area.tsx +70 -27
  69. package/src/components/chat/chat-card.tsx +6 -21
  70. package/src/components/chat/chat-header.tsx +263 -366
  71. package/src/components/chat/chat-list.tsx +62 -26
  72. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  73. package/src/components/chat/message-list.tsx +145 -19
  74. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  75. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  76. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  77. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  78. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  79. package/src/components/chatrooms/chatroom-view.tsx +422 -209
  80. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  81. package/src/components/connectors/connector-list.tsx +265 -127
  82. package/src/components/connectors/connector-sheet.tsx +217 -0
  83. package/src/components/gateways/gateway-sheet.tsx +567 -0
  84. package/src/components/home/home-view.tsx +128 -4
  85. package/src/components/input/chat-input.tsx +135 -86
  86. package/src/components/layout/app-layout.tsx +385 -194
  87. package/src/components/layout/mobile-header.tsx +26 -8
  88. package/src/components/memory/memory-browser.tsx +71 -6
  89. package/src/components/memory/memory-card.tsx +18 -0
  90. package/src/components/memory/memory-detail.tsx +58 -31
  91. package/src/components/memory/memory-sheet.tsx +32 -4
  92. package/src/components/plugins/plugin-list.tsx +15 -3
  93. package/src/components/plugins/plugin-sheet.tsx +118 -9
  94. package/src/components/projects/project-detail.tsx +189 -1
  95. package/src/components/providers/provider-list.tsx +158 -2
  96. package/src/components/providers/provider-sheet.tsx +81 -70
  97. package/src/components/shared/agent-picker-list.tsx +2 -2
  98. package/src/components/shared/bottom-sheet.tsx +31 -15
  99. package/src/components/shared/command-palette.tsx +111 -24
  100. package/src/components/shared/confirm-dialog.tsx +45 -30
  101. package/src/components/shared/model-combobox.tsx +90 -8
  102. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  103. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  104. package/src/components/shared/settings/section-heartbeat.tsx +88 -6
  105. package/src/components/shared/settings/section-orchestrator.tsx +6 -3
  106. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  107. package/src/components/shared/settings/section-secrets.tsx +6 -6
  108. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  109. package/src/components/shared/settings/section-voice.tsx +5 -1
  110. package/src/components/shared/settings/section-web-search.tsx +10 -2
  111. package/src/components/shared/settings/settings-page.tsx +248 -47
  112. package/src/components/tasks/approvals-panel.tsx +211 -18
  113. package/src/components/tasks/task-board.tsx +242 -46
  114. package/src/components/ui/dialog.tsx +2 -2
  115. package/src/components/usage/metrics-dashboard.tsx +74 -1
  116. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  117. package/src/components/wallets/wallet-panel.tsx +17 -5
  118. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  119. package/src/lib/auth.ts +17 -0
  120. package/src/lib/chat-streaming-state.test.ts +108 -0
  121. package/src/lib/chat-streaming-state.ts +108 -0
  122. package/src/lib/heartbeat-defaults.ts +48 -0
  123. package/src/lib/memory-presentation.ts +59 -0
  124. package/src/lib/openclaw-agent-id.test.ts +14 -0
  125. package/src/lib/openclaw-agent-id.ts +31 -0
  126. package/src/lib/provider-model-discovery-client.ts +29 -0
  127. package/src/lib/providers/index.ts +12 -5
  128. package/src/lib/runtime-loop.ts +105 -3
  129. package/src/lib/safe-storage.ts +6 -1
  130. package/src/lib/server/agent-assignment.test.ts +112 -0
  131. package/src/lib/server/agent-assignment.ts +169 -0
  132. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  133. package/src/lib/server/agent-runtime-config.ts +277 -0
  134. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  135. package/src/lib/server/approvals-auto-approve.test.ts +264 -0
  136. package/src/lib/server/approvals.ts +483 -75
  137. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  138. package/src/lib/server/browser-state.test.ts +118 -0
  139. package/src/lib/server/browser-state.ts +123 -0
  140. package/src/lib/server/build-llm.test.ts +44 -0
  141. package/src/lib/server/build-llm.ts +11 -4
  142. package/src/lib/server/builtin-plugins.ts +34 -0
  143. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  144. package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
  145. package/src/lib/server/chat-execution.ts +402 -125
  146. package/src/lib/server/chatroom-health.test.ts +26 -0
  147. package/src/lib/server/chatroom-health.ts +2 -3
  148. package/src/lib/server/chatroom-helpers.test.ts +74 -2
  149. package/src/lib/server/chatroom-helpers.ts +144 -11
  150. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  151. package/src/lib/server/connectors/discord.ts +175 -11
  152. package/src/lib/server/connectors/doctor.test.ts +80 -0
  153. package/src/lib/server/connectors/doctor.ts +116 -0
  154. package/src/lib/server/connectors/manager.ts +994 -130
  155. package/src/lib/server/connectors/policy.test.ts +222 -0
  156. package/src/lib/server/connectors/policy.ts +452 -0
  157. package/src/lib/server/connectors/slack.ts +189 -10
  158. package/src/lib/server/connectors/telegram.ts +65 -15
  159. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  160. package/src/lib/server/connectors/thread-context.ts +72 -0
  161. package/src/lib/server/connectors/types.ts +41 -11
  162. package/src/lib/server/daemon-state.ts +62 -3
  163. package/src/lib/server/data-dir.ts +13 -0
  164. package/src/lib/server/delegation-jobs.test.ts +140 -0
  165. package/src/lib/server/delegation-jobs.ts +248 -0
  166. package/src/lib/server/document-utils.test.ts +47 -0
  167. package/src/lib/server/document-utils.ts +397 -0
  168. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  169. package/src/lib/server/eval/agent-regression.ts +1742 -0
  170. package/src/lib/server/eval/runner.ts +11 -1
  171. package/src/lib/server/eval/store.ts +2 -1
  172. package/src/lib/server/heartbeat-service.ts +23 -43
  173. package/src/lib/server/heartbeat-source.test.ts +22 -0
  174. package/src/lib/server/heartbeat-source.ts +7 -0
  175. package/src/lib/server/identity-continuity.test.ts +77 -0
  176. package/src/lib/server/identity-continuity.ts +127 -0
  177. package/src/lib/server/mailbox-utils.ts +347 -0
  178. package/src/lib/server/main-agent-loop.ts +31 -964
  179. package/src/lib/server/memory-db.ts +4 -6
  180. package/src/lib/server/memory-tiers.ts +40 -0
  181. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  182. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  183. package/src/lib/server/openclaw-exec-config.ts +6 -5
  184. package/src/lib/server/openclaw-gateway.ts +123 -36
  185. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  186. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  187. package/src/lib/server/openclaw-sync.ts +3 -2
  188. package/src/lib/server/orchestrator-lg.ts +18 -8
  189. package/src/lib/server/orchestrator.ts +5 -4
  190. package/src/lib/server/playwright-proxy.mjs +27 -3
  191. package/src/lib/server/plugins.test.ts +215 -0
  192. package/src/lib/server/plugins.ts +832 -69
  193. package/src/lib/server/provider-health.ts +33 -3
  194. package/src/lib/server/provider-model-discovery.ts +481 -0
  195. package/src/lib/server/queue.ts +4 -21
  196. package/src/lib/server/runtime-settings.test.ts +119 -0
  197. package/src/lib/server/runtime-settings.ts +12 -92
  198. package/src/lib/server/schedule-normalization.ts +187 -0
  199. package/src/lib/server/scheduler.ts +2 -0
  200. package/src/lib/server/session-archive-memory.test.ts +85 -0
  201. package/src/lib/server/session-archive-memory.ts +230 -0
  202. package/src/lib/server/session-mailbox.ts +8 -18
  203. package/src/lib/server/session-reset-policy.test.ts +99 -0
  204. package/src/lib/server/session-reset-policy.ts +311 -0
  205. package/src/lib/server/session-run-manager.ts +33 -80
  206. package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
  207. package/src/lib/server/session-tools/calendar.ts +2 -12
  208. package/src/lib/server/session-tools/connector.ts +109 -8
  209. package/src/lib/server/session-tools/context.ts +14 -2
  210. package/src/lib/server/session-tools/crawl.ts +447 -0
  211. package/src/lib/server/session-tools/crud.ts +96 -34
  212. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  213. package/src/lib/server/session-tools/delegate.ts +406 -20
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  215. package/src/lib/server/session-tools/discovery.ts +40 -12
  216. package/src/lib/server/session-tools/document.ts +283 -0
  217. package/src/lib/server/session-tools/email.ts +1 -3
  218. package/src/lib/server/session-tools/extract.ts +137 -0
  219. package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
  220. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  221. package/src/lib/server/session-tools/file.ts +243 -24
  222. package/src/lib/server/session-tools/http.ts +9 -3
  223. package/src/lib/server/session-tools/human-loop.ts +227 -0
  224. package/src/lib/server/session-tools/image-gen.ts +1 -3
  225. package/src/lib/server/session-tools/index.ts +87 -2
  226. package/src/lib/server/session-tools/mailbox.ts +276 -0
  227. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  228. package/src/lib/server/session-tools/memory.ts +35 -3
  229. package/src/lib/server/session-tools/monitor.ts +162 -12
  230. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  231. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  232. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  233. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  234. package/src/lib/server/session-tools/platform.ts +142 -4
  235. package/src/lib/server/session-tools/plugin-creator.ts +95 -25
  236. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  237. package/src/lib/server/session-tools/replicate.ts +1 -3
  238. package/src/lib/server/session-tools/sandbox.ts +51 -92
  239. package/src/lib/server/session-tools/schedule.ts +20 -10
  240. package/src/lib/server/session-tools/session-info.ts +58 -4
  241. package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
  242. package/src/lib/server/session-tools/shell.ts +2 -2
  243. package/src/lib/server/session-tools/subagent.ts +195 -27
  244. package/src/lib/server/session-tools/table.ts +587 -0
  245. package/src/lib/server/session-tools/wallet.ts +13 -10
  246. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  247. package/src/lib/server/session-tools/web.ts +947 -108
  248. package/src/lib/server/storage.ts +255 -10
  249. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  250. package/src/lib/server/stream-agent-chat.ts +185 -25
  251. package/src/lib/server/structured-extract.test.ts +72 -0
  252. package/src/lib/server/structured-extract.ts +373 -0
  253. package/src/lib/server/task-mention.test.ts +16 -2
  254. package/src/lib/server/task-mention.ts +61 -11
  255. package/src/lib/server/tool-aliases.ts +80 -12
  256. package/src/lib/server/tool-capability-policy.ts +7 -1
  257. package/src/lib/server/tool-retry.ts +2 -0
  258. package/src/lib/server/watch-jobs.test.ts +173 -0
  259. package/src/lib/server/watch-jobs.ts +532 -0
  260. package/src/lib/server/ws-hub.ts +5 -3
  261. package/src/lib/setup-defaults.ts +352 -11
  262. package/src/lib/tool-definitions.ts +3 -4
  263. package/src/lib/validation/schemas.test.ts +26 -0
  264. package/src/lib/validation/schemas.ts +62 -1
  265. package/src/lib/ws-client.ts +14 -12
  266. package/src/proxy.ts +5 -5
  267. package/src/stores/use-app-store.ts +43 -7
  268. package/src/stores/use-chat-store.ts +31 -2
  269. package/src/stores/use-chatroom-store.ts +153 -26
  270. package/src/types/index.ts +470 -44
  271. package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
  272. package/src/components/chat/new-chat-sheet.tsx +0 -253
  273. package/src/lib/server/main-session.ts +0 -17
  274. 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,212 @@ 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
+ const 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
+ watcher.on('error', (err: unknown) => {
519
+ log.warn('plugins', 'Plugin watcher disabled after runtime watch failure', {
520
+ error: err instanceof Error ? err.message : String(err),
521
+ })
522
+ if (this.watcher === watcher) {
523
+ try { watcher.close() } catch { /* ignore */ }
524
+ this.watcher = null
525
+ }
526
+ })
527
+ watcher.unref?.()
528
+ this.watcher = watcher
529
+ } catch (err: unknown) {
530
+ log.warn('plugins', 'Failed to watch plugins directory', {
531
+ error: err instanceof Error ? err.message : String(err),
532
+ })
533
+ }
534
+ }
535
+
536
+ private isExternalPluginFilename(id: string): boolean {
537
+ return id.endsWith('.js') || id.endsWith('.mjs')
538
+ }
539
+
540
+ private ensurePluginDirs(): void {
541
+ if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
542
+ if (!fs.existsSync(PLUGIN_WORKSPACES_DIR)) fs.mkdirSync(PLUGIN_WORKSPACES_DIR, { recursive: true })
543
+ }
544
+
545
+ private getWorkspaceDir(filename: string): string {
546
+ return path.join(PLUGIN_WORKSPACES_DIR, pluginWorkspaceKey(filename))
547
+ }
548
+
549
+ private getWorkspaceEntryPath(filename: string): string {
550
+ return path.join(this.getWorkspaceDir(filename), 'index.js')
551
+ }
552
+
553
+ private getWorkspaceManifestPath(filename: string): string {
554
+ return path.join(this.getWorkspaceDir(filename), 'package.json')
555
+ }
556
+
557
+ private hasWorkspace(filename: string): boolean {
558
+ return fs.existsSync(this.getWorkspaceEntryPath(filename))
559
+ }
560
+
561
+ private readWorkspaceManifest(filename: string): Record<string, unknown> | null {
562
+ const manifestPath = this.getWorkspaceManifestPath(filename)
563
+ try {
564
+ if (!fs.existsSync(manifestPath)) return null
565
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>
566
+ } catch {
567
+ return null
568
+ }
569
+ }
570
+
571
+ private getDependencyInfo(filename: string, explicitConfig?: PluginConfigEntry | null): PluginDependencyInfo {
572
+ const manifest = this.readWorkspaceManifest(filename)
573
+ const counts = countManifestDependencies(manifest)
574
+ return {
575
+ hasManifest: !!manifest,
576
+ dependencyCount: counts.dependencyCount,
577
+ devDependencyCount: counts.devDependencyCount,
578
+ packageManager:
579
+ normalizePluginPackageManager(explicitConfig?.packageManager)
580
+ || normalizePluginPackageManager(manifest?.packageManager)
581
+ || undefined,
582
+ installStatus: explicitConfig?.dependencyInstallStatus || (manifest ? 'ready' : 'none'),
583
+ installError: explicitConfig?.dependencyInstallError,
584
+ installedAt: explicitConfig?.dependencyInstalledAt,
585
+ }
586
+ }
587
+
588
+ private writeWorkspaceShim(filename: string): void {
589
+ const relEntry = `./.workspaces/${pluginWorkspaceKey(filename)}/index.js`
590
+ const shim = `// Auto-generated plugin workspace shim. Edit the managed source file instead.\nmodule.exports = require(${JSON.stringify(relEntry)})\n`
591
+ fs.writeFileSync(path.join(PLUGINS_DIR, filename), shim, 'utf8')
592
+ }
593
+
594
+ private clearPluginRequireCache(dynamicRequire: NodeRequire, filename: string): void {
595
+ const rootPath = path.join(PLUGINS_DIR, filename)
596
+ delete dynamicRequire.cache[rootPath]
597
+ const workspaceDir = this.getWorkspaceDir(filename)
598
+ for (const cacheKey of Object.keys(dynamicRequire.cache)) {
599
+ if (cacheKey.startsWith(`${workspaceDir}${path.sep}`)) {
600
+ delete dynamicRequire.cache[cacheKey]
601
+ }
602
+ }
603
+ }
604
+
605
+ private resolvePluginSourcePath(filename: string): string {
606
+ return this.hasWorkspace(filename)
607
+ ? this.getWorkspaceEntryPath(filename)
608
+ : path.join(PLUGINS_DIR, filename)
609
+ }
610
+
611
+ private async runDependencyInstall(packageManager: PluginPackageManager, cwd: string): Promise<void> {
612
+ const { command, args } = getInstallCommand(packageManager)
613
+
614
+ await new Promise<void>((resolve, reject) => {
615
+ const child = spawn(command, args, {
616
+ cwd,
617
+ env: { ...process.env },
618
+ stdio: ['ignore', 'pipe', 'pipe'],
619
+ })
620
+
621
+ let stderr = ''
622
+ let stdout = ''
623
+ const timer = setTimeout(() => {
624
+ child.kill('SIGTERM')
625
+ reject(new Error(`${command} install timed out after ${Math.round(PACKAGE_INSTALL_TIMEOUT_MS / 1000)}s`))
626
+ }, PACKAGE_INSTALL_TIMEOUT_MS)
627
+
628
+ child.stdout?.on('data', (chunk: Buffer | string) => {
629
+ stdout = trimProcessOutput(`${stdout}${chunk.toString()}`)
630
+ })
631
+ child.stderr?.on('data', (chunk: Buffer | string) => {
632
+ stderr = trimProcessOutput(`${stderr}${chunk.toString()}`)
633
+ })
634
+ child.on('error', (err) => {
635
+ clearTimeout(timer)
636
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
637
+ reject(new Error(`${command} is not installed on this machine`))
638
+ return
639
+ }
640
+ reject(err)
641
+ })
642
+ child.on('close', (code) => {
643
+ clearTimeout(timer)
644
+ if (code === 0) {
645
+ resolve()
646
+ return
647
+ }
648
+ reject(new Error(trimProcessOutput(`${stderr}\n${stdout}`) || `${command} install exited ${code}`))
649
+ })
650
+ })
651
+ }
652
+
653
+ private canonicalPluginId(id: string): string {
654
+ const trimmed = typeof id === 'string' ? id.trim() : ''
655
+ if (!trimmed) return ''
656
+ if (this.isExternalPluginFilename(trimmed)) return path.basename(trimmed)
657
+ return canonicalizePluginId(trimmed)
658
+ }
659
+
660
+ private configIdsFor(id: string): string[] {
661
+ const canonicalId = this.canonicalPluginId(id)
662
+ if (!canonicalId) return []
663
+ if (this.isExternalPluginFilename(canonicalId)) return [canonicalId]
664
+ const aliases = getPluginAliases(canonicalId)
665
+ const ids = new Set<string>([canonicalId, ...aliases])
666
+ return Array.from(ids)
667
+ }
668
+
669
+ private readConfigEntry(id: string, config?: Record<string, PluginConfigEntry>): PluginConfigEntry | null {
670
+ const cfg = config || this.loadConfig()
671
+ let merged: PluginConfigEntry | null = null
672
+ for (const key of this.configIdsFor(id)) {
673
+ const entry = cfg[key]
674
+ if (!entry) continue
675
+ merged = { ...(merged || {}), ...entry }
676
+ if (key === this.canonicalPluginId(id)) break
677
+ }
678
+ return merged
679
+ }
680
+
681
+ private writeConfig(config: Record<string, PluginConfigEntry>): void {
682
+ fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
683
+ }
684
+
685
+ private updateConfigEntry(id: string, patch: PluginConfigEntry | null): void {
686
+ const canonicalId = this.canonicalPluginId(id)
687
+ const config = this.loadConfig()
688
+ for (const key of this.configIdsFor(canonicalId)) {
689
+ if (key !== canonicalId) delete config[key]
690
+ }
691
+ if (patch) {
692
+ config[canonicalId] = { ...(config[canonicalId] || {}), ...patch }
693
+ } else {
694
+ delete config[canonicalId]
695
+ }
696
+ this.writeConfig(config)
697
+ }
698
+
699
+ private resolveEnabledFilter(enabledIds?: string[], includeAllWhenEmpty = false): Set<string> | null {
700
+ if (!Array.isArray(enabledIds) || enabledIds.length === 0) {
701
+ return includeAllWhenEmpty ? null : new Set<string>()
702
+ }
703
+ return new Set(expandPluginIds(enabledIds))
704
+ }
705
+
291
706
  private readFailureState(): Record<string, PluginFailureRecord> {
292
707
  try {
293
708
  const parsed = JSON.parse(fs.readFileSync(PLUGIN_FAILURES, 'utf8')) as Record<string, PluginFailureRecord>
@@ -308,17 +723,21 @@ class PluginManager {
308
723
 
309
724
  private clearFailureState(id: string): void {
310
725
  const state = this.readFailureState()
311
- if (!state[id]) return
312
- delete state[id]
726
+ let changed = false
727
+ for (const key of this.configIdsFor(id)) {
728
+ if (!state[key]) continue
729
+ delete state[key]
730
+ changed = true
731
+ }
732
+ if (!changed) return
313
733
  this.writeFailureState(state)
314
734
  }
315
735
 
316
736
  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
737
  try {
321
- fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
738
+ const current = this.readConfigEntry(id)
739
+ if (current?.enabled === false) return
740
+ this.updateConfigEntry(id, { ...(current || {}), enabled: false })
322
741
  } catch (err: unknown) {
323
742
  log.error('plugins', 'Failed to write plugins config while auto-disabling plugin', {
324
743
  pluginId: id,
@@ -353,14 +772,15 @@ class PluginManager {
353
772
  private markPluginFailure(id: string, stage: string, err: unknown, disableEligible: boolean): void {
354
773
  const errorText = err instanceof Error ? err.message : String(err)
355
774
  const state = this.readFailureState()
356
- const nextCount = (state[id]?.count || 0) + 1
775
+ const failureKey = this.canonicalPluginId(id)
776
+ const nextCount = (state[failureKey]?.count || 0) + 1
357
777
  const record: PluginFailureRecord = {
358
778
  count: nextCount,
359
779
  lastError: errorText,
360
780
  lastStage: stage,
361
781
  lastFailedAt: Date.now(),
362
782
  }
363
- state[id] = record
783
+ state[failureKey] = record
364
784
  this.writeFailureState(state)
365
785
 
366
786
  log.warn('plugins', 'Plugin failure recorded', {
@@ -371,8 +791,12 @@ class PluginManager {
371
791
  error: errorText,
372
792
  })
373
793
 
374
- if (disableEligible && nextCount >= MAX_CONSECUTIVE_PLUGIN_FAILURES) {
375
- this.autoDisableExternalPlugin(id, `Plugin failure at ${stage}`, record)
794
+ if (
795
+ disableEligible
796
+ && nextCount >= MAX_CONSECUTIVE_PLUGIN_FAILURES
797
+ && !this.builtins.has(failureKey)
798
+ ) {
799
+ this.autoDisableExternalPlugin(failureKey, `Plugin failure at ${stage}`, record)
376
800
  }
377
801
  }
378
802
 
@@ -387,17 +811,27 @@ class PluginManager {
387
811
  load() {
388
812
  if (this.loaded) return
389
813
  this.plugins.clear()
814
+ this.ensurePluginWatcher()
390
815
 
391
816
  const config = this.loadConfig()
392
817
 
393
818
  // 1. Load Built-ins
394
819
  for (const [id, p] of this.builtins.entries()) {
395
- const explicitConfig = config[id]
820
+ const explicitConfig = this.readConfigEntry(id, config)
396
821
  const isEnabled = explicitConfig != null ? explicitConfig.enabled !== false : p.enabledByDefault !== false
397
822
  if (isEnabled) {
398
823
  this.plugins.set(id, {
399
824
  id,
400
- meta: { name: p.name, description: p.description || '', filename: id, enabled: true },
825
+ meta: {
826
+ name: p.name,
827
+ description: p.description || '',
828
+ filename: id,
829
+ enabled: true,
830
+ author: p.author || 'SwarmClaw',
831
+ version: p.version || '1.0.0',
832
+ source: 'local',
833
+ openclaw: p.openclaw === true,
834
+ },
401
835
  hooks: p.hooks || {},
402
836
  tools: p.tools || [],
403
837
  ui: p.ui,
@@ -411,7 +845,7 @@ class PluginManager {
411
845
 
412
846
  // 2. Load External
413
847
  try {
414
- if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
848
+ this.ensurePluginDirs()
415
849
  const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
416
850
 
417
851
  let dynamicRequire: NodeRequire | null = null
@@ -426,11 +860,12 @@ class PluginManager {
426
860
  if (dynamicRequire) {
427
861
  for (const file of files) {
428
862
  try {
429
- const isEnabled = config[file]?.enabled !== false
863
+ const explicitConfig = this.readConfigEntry(file, config)
864
+ const isEnabled = explicitConfig?.enabled !== false
430
865
  if (!isEnabled) continue
431
866
 
432
867
  const fullPath = path.join(PLUGINS_DIR, file)
433
- delete dynamicRequire.cache[fullPath]
868
+ this.clearPluginRequireCache(dynamicRequire, file)
434
869
  const plugin = normalizePlugin(dynamicRequire(fullPath))
435
870
  if (!plugin) {
436
871
  this.markPluginFailure(file, 'load.normalize', 'Plugin format unsupported or activate() failed', true)
@@ -439,7 +874,16 @@ class PluginManager {
439
874
 
440
875
  this.plugins.set(file, {
441
876
  id: file,
442
- meta: { name: plugin.name, description: plugin.description || '', filename: file, enabled: true },
877
+ meta: {
878
+ name: plugin.name,
879
+ description: plugin.description || '',
880
+ filename: file,
881
+ enabled: true,
882
+ author: plugin.author,
883
+ version: plugin.version || '0.0.1',
884
+ source: explicitConfig?.sourceUrl ? 'marketplace' : 'local',
885
+ openclaw: plugin.openclaw === true,
886
+ },
443
887
  hooks: plugin.hooks || {},
444
888
  tools: plugin.tools || [],
445
889
  ui: plugin.ui,
@@ -464,7 +908,7 @@ class PluginManager {
464
908
  getTools(enabledIds: string[]): Array<{ pluginId: string; tool: PluginToolDef }> {
465
909
  this.load()
466
910
  const all: Array<{ pluginId: string; tool: PluginToolDef }> = []
467
- const ids = new Set(enabledIds)
911
+ const ids = new Set(expandPluginIds(enabledIds))
468
912
  for (const [id, p] of this.plugins.entries()) {
469
913
  if (ids.has(id)) {
470
914
  const tools = Array.isArray(p.tools) ? p.tools : []
@@ -526,13 +970,17 @@ class PluginManager {
526
970
  return allUI
527
971
  }
528
972
 
529
- async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, enabledIds: string[] = []) {
973
+ listPluginIds(): string[] {
530
974
  this.load()
531
- // If no enabledIds provided, run for all loaded plugins (legacy behavior)
532
- const filterIds = enabledIds.length > 0 ? new Set(enabledIds) : null
975
+ return Array.from(this.plugins.keys())
976
+ }
977
+
978
+ async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, options?: HookExecutionOptions) {
979
+ this.load()
980
+ const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
533
981
 
534
982
  for (const [id, p] of this.plugins.entries()) {
535
- if (filterIds && !filterIds.has(id)) continue
983
+ if (filterIds !== null && !filterIds.has(id)) continue
536
984
  const hook = p.hooks[hookName]
537
985
  if (hook) {
538
986
  try {
@@ -551,22 +999,54 @@ class PluginManager {
551
999
  }
552
1000
  }
553
1001
 
1002
+ async runBeforeToolExec(
1003
+ params: { toolName: string; input: Record<string, unknown> | null },
1004
+ options?: HookExecutionOptions,
1005
+ ): Promise<Record<string, unknown> | null> {
1006
+ this.load()
1007
+ const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
1008
+ let currentInput = params.input
1009
+
1010
+ for (const [id, p] of this.plugins.entries()) {
1011
+ if (filterIds !== null && !filterIds.has(id)) continue
1012
+ const hook = p.hooks.beforeToolExec
1013
+ if (!hook) continue
1014
+ try {
1015
+ const result = await hook({ toolName: params.toolName, input: currentInput })
1016
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
1017
+ currentInput = result as Record<string, unknown>
1018
+ }
1019
+ this.markPluginSuccess(id)
1020
+ } catch (err: unknown) {
1021
+ log.error('plugins', 'beforeToolExec hook failed', {
1022
+ pluginId: id,
1023
+ pluginName: p.meta.name,
1024
+ toolName: params.toolName,
1025
+ error: err instanceof Error ? err.message : String(err),
1026
+ })
1027
+ this.markPluginFailure(id, 'hook.beforeToolExec', err, true)
1028
+ }
1029
+ }
1030
+
1031
+ return currentInput
1032
+ }
1033
+
554
1034
  async transformText(
555
1035
  hookName: 'transformInboundMessage' | 'transformOutboundMessage',
556
1036
  params: { session: Session; text: string },
557
- enabledIds: string[] = [],
1037
+ options?: HookExecutionOptions,
558
1038
  ): Promise<string> {
559
1039
  this.load()
560
- const filterIds = enabledIds.length > 0 ? new Set(enabledIds) : null
1040
+ const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
561
1041
  let currentText = params.text
562
1042
 
563
1043
  for (const [id, p] of this.plugins.entries()) {
564
- if (filterIds && !filterIds.has(id)) continue
1044
+ if (filterIds !== null && !filterIds.has(id)) continue
565
1045
  const hook = p.hooks[hookName]
566
1046
  if (hook) {
567
1047
  try {
568
- const result = await (hook as (ctx: typeof params) => Promise<string> | string)(params)
569
- currentText = result
1048
+ const result = await (hook as (ctx: typeof params) => Promise<string> | string)({ ...params, text: currentText })
1049
+ if (typeof result === 'string') currentText = result
570
1050
  this.markPluginSuccess(id)
571
1051
  } catch (err: unknown) {
572
1052
  log.error('plugins', 'Plugin transform hook failed', {
@@ -584,7 +1064,7 @@ class PluginManager {
584
1064
 
585
1065
  async collectAgentContext(session: import('@/types').Session, enabledPlugins: string[], message: string, history: import('@/types').Message[]): Promise<string[]> {
586
1066
  this.load()
587
- const enabledSet = new Set(enabledPlugins)
1067
+ const enabledSet = new Set(expandPluginIds(enabledPlugins))
588
1068
  const parts: string[] = []
589
1069
 
590
1070
  for (const [id, p] of this.plugins.entries()) {
@@ -678,6 +1158,150 @@ class PluginManager {
678
1158
  return result
679
1159
  }
680
1160
 
1161
+ getSettingsFields(pluginId: string): import('@/types').PluginSettingsField[] {
1162
+ this.load()
1163
+ const candidateIds = expandPluginIds([pluginId])
1164
+ for (const id of candidateIds) {
1165
+ const plugin = this.plugins.get(id) || (this.builtins.has(id) ? {
1166
+ ui: this.builtins.get(id)?.ui,
1167
+ } as LoadedPlugin : null)
1168
+ const fields = plugin?.ui?.settingsFields
1169
+ if (fields?.length) return fields
1170
+ }
1171
+ return []
1172
+ }
1173
+
1174
+ getPluginSettings(pluginId: string): Record<string, unknown> {
1175
+ const settings = loadSettings()
1176
+ const allSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
1177
+ const result: Record<string, unknown> = {}
1178
+
1179
+ for (const key of this.configIdsFor(pluginId)) {
1180
+ const values = allSettings[key]
1181
+ if (!values || typeof values !== 'object') continue
1182
+ for (const [fieldKey, fieldValue] of Object.entries(values)) {
1183
+ if (isPluginSecretSettingValue(fieldValue)) {
1184
+ try {
1185
+ result[fieldKey] = decryptKey(fieldValue.encrypted)
1186
+ } catch {
1187
+ result[fieldKey] = ''
1188
+ }
1189
+ continue
1190
+ }
1191
+ result[fieldKey] = fieldValue
1192
+ }
1193
+ }
1194
+
1195
+ for (const field of this.getSettingsFields(pluginId)) {
1196
+ if (result[field.key] === undefined && field.defaultValue !== undefined) {
1197
+ result[field.key] = field.defaultValue
1198
+ }
1199
+ }
1200
+
1201
+ return result
1202
+ }
1203
+
1204
+ getPublicPluginSettings(pluginId: string): { values: Record<string, unknown>; configuredSecretFields: string[] } {
1205
+ const values = this.getPluginSettings(pluginId)
1206
+ const configuredSecretFields: string[] = []
1207
+
1208
+ for (const field of this.getSettingsFields(pluginId)) {
1209
+ if (field.type !== 'secret') continue
1210
+ const current = values[field.key]
1211
+ if (typeof current === 'string' && current.trim()) {
1212
+ configuredSecretFields.push(field.key)
1213
+ }
1214
+ values[field.key] = ''
1215
+ }
1216
+
1217
+ return { values, configuredSecretFields }
1218
+ }
1219
+
1220
+ setPluginSettings(pluginId: string, values: Record<string, unknown>): Record<string, unknown> {
1221
+ const fields = this.getSettingsFields(pluginId)
1222
+ if (fields.length === 0 && Object.keys(values || {}).length > 0) {
1223
+ throw new Error(`Plugin "${pluginId}" does not declare configurable settings`)
1224
+ }
1225
+ const fieldMap = new Map(fields.map((field) => [field.key, field]))
1226
+ const nextValues: Record<string, unknown> = {}
1227
+
1228
+ for (const [key, rawValue] of Object.entries(values || {})) {
1229
+ const field = fieldMap.get(key)
1230
+ if (!field) continue
1231
+ if (rawValue === undefined) continue
1232
+ if (field.type === 'boolean') {
1233
+ nextValues[key] = rawValue === true || rawValue === 'true' || rawValue === 1 || rawValue === '1'
1234
+ continue
1235
+ }
1236
+ if (field.type === 'number') {
1237
+ const parsed = typeof rawValue === 'number' ? rawValue : Number(rawValue)
1238
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid number for setting "${key}"`)
1239
+ nextValues[key] = parsed
1240
+ continue
1241
+ }
1242
+ const text = typeof rawValue === 'string' ? rawValue : String(rawValue ?? '')
1243
+ if (field.required && !text.trim()) throw new Error(`Setting "${key}" is required`)
1244
+ if (field.type === 'select' && field.options?.length) {
1245
+ const allowed = new Set(field.options.map((option) => option.value))
1246
+ if (!allowed.has(text)) throw new Error(`Invalid value for setting "${key}"`)
1247
+ }
1248
+ if (field.type === 'secret') {
1249
+ nextValues[key] = text.trim()
1250
+ } else {
1251
+ nextValues[key] = text
1252
+ }
1253
+ }
1254
+
1255
+ const currentSettings = loadSettings()
1256
+ const pluginSettings = (currentSettings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
1257
+ const canonicalId = this.canonicalPluginId(pluginId)
1258
+ const existingStored: Record<string, unknown> = {}
1259
+ for (const alias of this.configIdsFor(canonicalId)) {
1260
+ const existing = pluginSettings[alias]
1261
+ if (!existing || typeof existing !== 'object') continue
1262
+ Object.assign(existingStored, existing)
1263
+ }
1264
+
1265
+ for (const field of fields) {
1266
+ if (!field.required) continue
1267
+ if (
1268
+ nextValues[field.key] === undefined
1269
+ && existingStored[field.key] === undefined
1270
+ && field.defaultValue === undefined
1271
+ ) {
1272
+ throw new Error(`Setting "${field.key}" is required`)
1273
+ }
1274
+ }
1275
+
1276
+ const stored: Record<string, unknown> = {}
1277
+
1278
+ for (const field of fields) {
1279
+ if (nextValues[field.key] === undefined) {
1280
+ if (existingStored[field.key] !== undefined) {
1281
+ stored[field.key] = existingStored[field.key]
1282
+ }
1283
+ continue
1284
+ }
1285
+ if (field.type === 'secret') {
1286
+ stored[field.key] = {
1287
+ __pluginSecret: true,
1288
+ encrypted: encryptKey(String(nextValues[field.key] ?? '')),
1289
+ } satisfies PluginSecretSettingValue
1290
+ } else {
1291
+ stored[field.key] = nextValues[field.key]
1292
+ }
1293
+ }
1294
+
1295
+ for (const alias of this.configIdsFor(canonicalId)) {
1296
+ delete pluginSettings[alias]
1297
+ }
1298
+ pluginSettings[canonicalId] = stored
1299
+ currentSettings.pluginSettings = pluginSettings
1300
+ saveSettings(currentSettings)
1301
+
1302
+ return this.getPublicPluginSettings(canonicalId).values
1303
+ }
1304
+
681
1305
  recordExternalToolFailure(pluginId: string, toolName: string, err: unknown): void {
682
1306
  this.markPluginFailure(pluginId, `tool.${toolName}`, err, true)
683
1307
  }
@@ -687,10 +1311,9 @@ class PluginManager {
687
1311
  }
688
1312
 
689
1313
  isEnabled(filename: string): boolean {
690
- const config = this.loadConfig()
691
- const explicit = config[filename]
1314
+ const explicit = this.readConfigEntry(filename)
692
1315
  if (explicit != null) return explicit.enabled !== false
693
- const builtin = this.builtins.get(filename)
1316
+ const builtin = this.builtins.get(this.canonicalPluginId(filename))
694
1317
  if (builtin) return builtin.enabledByDefault !== false
695
1318
  return true
696
1319
  }
@@ -722,18 +1345,20 @@ class PluginManager {
722
1345
  // Add all builtins
723
1346
  for (const [id, p] of this.builtins.entries()) {
724
1347
  const loaded = this.plugins.get(id)
725
- const explicitCfg = config[id]
1348
+ const explicitCfg = this.readConfigEntry(id, config)
726
1349
  const enabled = explicitCfg != null ? explicitCfg.enabled !== false : p.enabledByDefault !== false
727
- const failure = failures[id]
1350
+ const failure = failures[this.canonicalPluginId(id)]
728
1351
  const caps = describeCapabilities(loaded, p)
729
1352
  metas.push({
730
1353
  name: p.name,
731
1354
  description: p.description || '',
732
1355
  filename: id,
733
1356
  enabled,
734
- author: 'SwarmClaw',
1357
+ isBuiltin: true,
1358
+ author: p.author || 'SwarmClaw',
735
1359
  version: (p as { version?: string }).version || loaded?.meta.version || '1.0.0',
736
1360
  source: loaded?.meta.source || 'local',
1361
+ openclaw: p.openclaw === true,
737
1362
  failureCount: failure?.count,
738
1363
  lastFailureAt: failure?.lastFailedAt,
739
1364
  lastFailureStage: failure?.lastStage,
@@ -749,22 +1374,33 @@ class PluginManager {
749
1374
  for (const f of files) {
750
1375
  if (!metas.find(m => m.filename === f)) {
751
1376
  const loaded = this.plugins.get(f)
752
- const enabled = config[f]?.enabled !== false
1377
+ const explicitCfg = this.readConfigEntry(f, config)
1378
+ const enabled = explicitCfg?.enabled !== false
753
1379
  const failure = failures[f]
754
1380
  const caps = describeCapabilities(loaded)
1381
+ const dependencyInfo = this.getDependencyInfo(f, explicitCfg)
755
1382
  metas.push({
756
1383
  name: loaded?.meta.name || f.replace(/\.(js|mjs)$/, ''),
757
1384
  filename: f,
758
1385
  enabled,
1386
+ isBuiltin: false,
759
1387
  author: loaded?.meta.author,
760
1388
  version: loaded?.meta.version || '0.0.1',
761
- source: loaded?.meta.source || 'marketplace',
762
- createdByAgentId: config[f]?.createdByAgentId || null,
1389
+ source: loaded?.meta.source || (explicitCfg?.sourceUrl ? 'marketplace' : 'local'),
1390
+ openclaw: loaded?.meta.openclaw,
1391
+ createdByAgentId: explicitCfg?.createdByAgentId || null,
763
1392
  failureCount: failure?.count,
764
1393
  lastFailureAt: failure?.lastFailedAt,
765
1394
  lastFailureStage: failure?.lastStage,
766
1395
  lastFailureError: failure?.lastError,
767
1396
  autoDisabled: !enabled && !!failure && failure.count >= MAX_CONSECUTIVE_PLUGIN_FAILURES,
1397
+ hasDependencyManifest: dependencyInfo.hasManifest,
1398
+ dependencyCount: dependencyInfo.dependencyCount,
1399
+ devDependencyCount: dependencyInfo.devDependencyCount,
1400
+ packageManager: dependencyInfo.packageManager,
1401
+ dependencyInstallStatus: dependencyInfo.installStatus,
1402
+ dependencyInstallError: dependencyInfo.installError,
1403
+ dependencyInstalledAt: dependencyInfo.installedAt,
768
1404
  ...caps,
769
1405
  })
770
1406
  }
@@ -778,52 +1414,178 @@ class PluginManager {
778
1414
  }
779
1415
  }
780
1416
 
1417
+ readPluginSource(filename: string): string {
1418
+ const fullPath = this.resolvePluginSourcePath(filename)
1419
+ if (!fs.existsSync(fullPath)) throw new Error(`Plugin not found: ${filename}`)
1420
+ return fs.readFileSync(fullPath, 'utf8')
1421
+ }
1422
+
1423
+ async savePluginSource(filename: string, code: string, options?: UpsertPluginOptions): Promise<void> {
1424
+ const sanitizedFilename = sanitizePluginFilename(filename)
1425
+ this.ensurePluginDirs()
1426
+
1427
+ const shouldUseWorkspace = this.hasWorkspace(sanitizedFilename) || options?.packageJson !== undefined
1428
+ const sourcePath = shouldUseWorkspace
1429
+ ? this.getWorkspaceEntryPath(sanitizedFilename)
1430
+ : path.join(PLUGINS_DIR, sanitizedFilename)
1431
+
1432
+ if (shouldUseWorkspace) {
1433
+ fs.mkdirSync(this.getWorkspaceDir(sanitizedFilename), { recursive: true })
1434
+ fs.writeFileSync(sourcePath, code, 'utf8')
1435
+ this.writeWorkspaceShim(sanitizedFilename)
1436
+ } else {
1437
+ fs.writeFileSync(sourcePath, code, 'utf8')
1438
+ }
1439
+
1440
+ const normalizedPackageManager = normalizePluginPackageManager(options?.packageManager)
1441
+
1442
+ if (options?.packageJson !== undefined) {
1443
+ if (!shouldUseWorkspace) {
1444
+ throw new Error('Plugin workspace is required for package.json support')
1445
+ }
1446
+ const manifest = normalizePluginManifest(options.packageJson, sanitizedFilename, normalizedPackageManager)
1447
+ fs.writeFileSync(this.getWorkspaceManifestPath(sanitizedFilename), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
1448
+ this.setMeta(sanitizedFilename, {
1449
+ ...(options?.meta || {}),
1450
+ packageManager: normalizedPackageManager || normalizePluginPackageManager(manifest.packageManager) || undefined,
1451
+ dependencyInstallStatus: 'ready',
1452
+ dependencyInstallError: undefined,
1453
+ dependencyInstalledAt: undefined,
1454
+ })
1455
+ } else if (options?.meta && Object.keys(options.meta).length > 0) {
1456
+ this.setMeta(sanitizedFilename, options.meta)
1457
+ }
1458
+
1459
+ if (options?.installDependencies) {
1460
+ await this.installPluginDependencies(sanitizedFilename, {
1461
+ packageManager: normalizedPackageManager || undefined,
1462
+ })
1463
+ }
1464
+
1465
+ this.reload()
1466
+ }
1467
+
1468
+ async installPluginDependencies(filename: string, options?: { packageManager?: PluginPackageManager }): Promise<PluginDependencyInfo> {
1469
+ const sanitizedFilename = sanitizePluginFilename(filename)
1470
+ const fullPath = path.join(PLUGINS_DIR, sanitizedFilename)
1471
+ if (!fs.existsSync(fullPath) && !this.hasWorkspace(sanitizedFilename)) {
1472
+ throw new Error(`Plugin not found: ${sanitizedFilename}`)
1473
+ }
1474
+
1475
+ this.ensurePluginDirs()
1476
+ const workspaceDir = this.getWorkspaceDir(sanitizedFilename)
1477
+ const sourcePath = this.resolvePluginSourcePath(sanitizedFilename)
1478
+ const currentCode = fs.existsSync(sourcePath) ? fs.readFileSync(sourcePath, 'utf8') : ''
1479
+
1480
+ if (!this.hasWorkspace(sanitizedFilename)) {
1481
+ fs.mkdirSync(workspaceDir, { recursive: true })
1482
+ fs.writeFileSync(this.getWorkspaceEntryPath(sanitizedFilename), currentCode, 'utf8')
1483
+ this.writeWorkspaceShim(sanitizedFilename)
1484
+ }
1485
+
1486
+ const manifest = this.readWorkspaceManifest(sanitizedFilename)
1487
+ if (!manifest) throw new Error(`Plugin "${sanitizedFilename}" does not have a package.json manifest`)
1488
+
1489
+ const packageManager = options?.packageManager
1490
+ || normalizePluginPackageManager(this.readConfigEntry(sanitizedFilename)?.packageManager)
1491
+ || normalizePluginPackageManager(manifest.packageManager)
1492
+ || 'npm'
1493
+
1494
+ this.setMeta(sanitizedFilename, {
1495
+ packageManager,
1496
+ dependencyInstallStatus: 'installing',
1497
+ dependencyInstallError: undefined,
1498
+ })
1499
+
1500
+ try {
1501
+ await this.runDependencyInstall(packageManager, workspaceDir)
1502
+ this.setMeta(sanitizedFilename, {
1503
+ packageManager,
1504
+ dependencyInstallStatus: 'installed',
1505
+ dependencyInstallError: undefined,
1506
+ dependencyInstalledAt: Date.now(),
1507
+ })
1508
+ } catch (err: unknown) {
1509
+ const message = err instanceof Error ? err.message : String(err)
1510
+ this.setMeta(sanitizedFilename, {
1511
+ packageManager,
1512
+ dependencyInstallStatus: 'error',
1513
+ dependencyInstallError: message,
1514
+ })
1515
+ throw new Error(message)
1516
+ } finally {
1517
+ this.reload()
1518
+ }
1519
+
1520
+ return this.getDependencyInfo(sanitizedFilename, this.readConfigEntry(sanitizedFilename))
1521
+ }
1522
+
781
1523
  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))
1524
+ const current = this.readConfigEntry(filename)
1525
+ this.updateConfigEntry(filename, { ...(current || {}), enabled })
785
1526
  if (enabled) this.clearFailureState(filename)
786
1527
  this.reload()
787
1528
  }
788
1529
 
789
1530
  deletePlugin(filename: string): boolean {
790
1531
  // Only allow deleting external plugins, not builtins
791
- if (this.builtins.has(filename)) return false
1532
+ if (this.builtins.has(this.canonicalPluginId(filename))) return false
792
1533
  const fullPath = path.join(PLUGINS_DIR, filename)
793
1534
  if (!fs.existsSync(fullPath)) return false
794
1535
  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))
1536
+ const workspaceDir = this.getWorkspaceDir(filename)
1537
+ if (fs.existsSync(workspaceDir)) fs.rmSync(workspaceDir, { recursive: true, force: true })
1538
+ this.updateConfigEntry(filename, null)
1539
+ const settings = loadSettings()
1540
+ const pluginSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
1541
+ for (const key of this.configIdsFor(filename)) delete pluginSettings[key]
1542
+ settings.pluginSettings = pluginSettings
1543
+ saveSettings(settings)
799
1544
  this.clearFailureState(filename)
800
1545
  this.reload()
801
1546
  return true
802
1547
  }
803
1548
 
1549
+ async installPluginFromUrl(url: string, filename: string, meta?: Record<string, unknown>): Promise<InstalledPluginSource> {
1550
+ const sanitizedFilename = sanitizePluginFilename(filename)
1551
+ const download = await downloadPluginSource(url)
1552
+ await this.savePluginSource(sanitizedFilename, download.code, {
1553
+ meta: {
1554
+ ...(meta || {}),
1555
+ sourceUrl: download.normalizedUrl,
1556
+ sourceHash: download.hash,
1557
+ installedAt: Date.now(),
1558
+ updatedAt: Date.now(),
1559
+ },
1560
+ })
1561
+
1562
+ return {
1563
+ filename: sanitizedFilename,
1564
+ sourceUrl: download.normalizedUrl,
1565
+ sourceHash: download.hash,
1566
+ contentType: download.contentType,
1567
+ }
1568
+ }
1569
+
804
1570
  async updatePlugin(id: string) {
805
1571
  this.load()
806
1572
  const p = this.plugins.get(id)
807
1573
  if (!p) throw new Error('Plugin not found')
1574
+ if (p.isBuiltin) throw new Error('Built-in plugins are updated via application releases')
808
1575
 
809
1576
  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
- }
1577
+ const current = this.readConfigEntry(id)
1578
+ const sourceUrl = current?.sourceUrl?.trim()
1579
+ if (!sourceUrl) throw new Error(`Plugin "${id}" has no recorded source URL and cannot be updated automatically`)
1580
+
1581
+ const download = await downloadPluginSource(sourceUrl)
1582
+ const fullPath = path.join(PLUGINS_DIR, id)
1583
+ fs.writeFileSync(fullPath, download.code, 'utf8')
1584
+ this.setMeta(id, {
1585
+ sourceUrl: download.normalizedUrl,
1586
+ sourceHash: download.hash,
1587
+ updatedAt: Date.now(),
1588
+ })
827
1589
 
828
1590
  this.reload()
829
1591
  return true
@@ -831,7 +1593,9 @@ class PluginManager {
831
1593
 
832
1594
  async updateAllPlugins() {
833
1595
  this.load()
834
- const ids = Array.from(this.plugins.keys())
1596
+ const ids = Array.from(this.plugins.entries())
1597
+ .filter(([, plugin]) => !plugin.isBuiltin)
1598
+ .map(([id]) => id)
835
1599
  for (const id of ids) {
836
1600
  try {
837
1601
  await this.updatePlugin(id)
@@ -841,12 +1605,11 @@ class PluginManager {
841
1605
  }
842
1606
 
843
1607
  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))
1608
+ const current = this.readConfigEntry(filename)
1609
+ this.updateConfigEntry(filename, { ...(current || {}), ...(meta as PluginConfigEntry) })
847
1610
  }
848
1611
 
849
- private loadConfig(): Record<string, { enabled?: boolean; createdByAgentId?: string }> {
1612
+ private loadConfig(): Record<string, PluginConfigEntry> {
850
1613
  try { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8')) } catch { return {} }
851
1614
  }
852
1615