@swarmclawai/swarmclaw 0.7.3 → 0.7.5

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 (152) hide show
  1. package/README.md +47 -40
  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 +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +4 -87
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  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]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/agent-thread-session.test.ts +85 -0
  88. package/src/lib/server/agent-thread-session.ts +123 -0
  89. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  90. package/src/lib/server/build-llm.test.ts +13 -5
  91. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  92. package/src/lib/server/chat-execution.ts +159 -71
  93. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  94. package/src/lib/server/chatroom-helpers.ts +99 -6
  95. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  96. package/src/lib/server/connectors/manager.ts +89 -61
  97. package/src/lib/server/connectors/slack.ts +1 -1
  98. package/src/lib/server/daemon-state.ts +3 -2
  99. package/src/lib/server/data-dir.test.ts +56 -0
  100. package/src/lib/server/data-dir.ts +15 -9
  101. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  102. package/src/lib/server/eval/agent-regression.ts +1742 -0
  103. package/src/lib/server/eval/runner.ts +11 -1
  104. package/src/lib/server/eval/store.ts +2 -1
  105. package/src/lib/server/heartbeat-service.ts +23 -8
  106. package/src/lib/server/heartbeat-wake.ts +6 -2
  107. package/src/lib/server/main-agent-loop.ts +13 -6
  108. package/src/lib/server/openclaw-exec-config.ts +4 -2
  109. package/src/lib/server/openclaw-gateway.ts +123 -36
  110. package/src/lib/server/orchestrator-lg.ts +1 -2
  111. package/src/lib/server/orchestrator.ts +3 -2
  112. package/src/lib/server/plugins.test.ts +9 -1
  113. package/src/lib/server/plugins.ts +12 -2
  114. package/src/lib/server/provider-model-discovery.ts +481 -0
  115. package/src/lib/server/queue.ts +1 -1
  116. package/src/lib/server/runtime-settings.test.ts +119 -0
  117. package/src/lib/server/runtime-settings.ts +12 -92
  118. package/src/lib/server/schedule-normalization.ts +187 -0
  119. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  120. package/src/lib/server/session-tools/crud.ts +27 -3
  121. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  122. package/src/lib/server/session-tools/discovery.ts +18 -8
  123. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  124. package/src/lib/server/session-tools/file.ts +8 -2
  125. package/src/lib/server/session-tools/http.ts +9 -3
  126. package/src/lib/server/session-tools/index.ts +31 -1
  127. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  128. package/src/lib/server/session-tools/monitor.ts +14 -7
  129. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  130. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  131. package/src/lib/server/session-tools/platform.ts +1 -1
  132. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  133. package/src/lib/server/session-tools/sandbox.ts +51 -92
  134. package/src/lib/server/session-tools/session-info.ts +22 -1
  135. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  136. package/src/lib/server/session-tools/shell.ts +2 -2
  137. package/src/lib/server/session-tools/subagent.ts +3 -1
  138. package/src/lib/server/session-tools/web.ts +73 -30
  139. package/src/lib/server/storage.ts +29 -3
  140. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  141. package/src/lib/server/stream-agent-chat.ts +139 -4
  142. package/src/lib/server/structured-extract.ts +1 -1
  143. package/src/lib/server/task-mention.ts +0 -1
  144. package/src/lib/server/tool-aliases.ts +37 -6
  145. package/src/lib/server/tool-capability-policy.ts +1 -1
  146. package/src/lib/setup-defaults.ts +352 -11
  147. package/src/lib/tool-definitions.ts +3 -4
  148. package/src/lib/validation/schemas.ts +55 -1
  149. package/src/stores/use-app-store.ts +43 -1
  150. package/src/stores/use-chatroom-store.ts +153 -26
  151. package/src/types/index.ts +189 -6
  152. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -58,7 +58,7 @@ function inferFileAction(
58
58
  if (getFileEntryContent(normalized) !== undefined) return 'write'
59
59
  if (dirPath) return 'list'
60
60
  if (filePath) return 'read'
61
- return undefined
61
+ return 'list'
62
62
  }
63
63
 
64
64
  export function normalizeFileArgs(rawArgs: Record<string, unknown>): Record<string, unknown> {
@@ -417,7 +417,13 @@ const FilePlugin: Plugin = {
417
417
  name: 'Core Files',
418
418
  description: 'Complete file management: read, write, list, move, copy, delete, and send.',
419
419
  hooks: {
420
- getCapabilityDescription: () => 'I can read, write, copy, move, and send files (`read_file`, `write_file`, `list_files`, `copy_file`, `move_file`, `send_file`). When writing, I should always provide a target path (`filePath`, `path`, `filename`, or `name`) and the content (`content`, `text`, or `body`). When `send_file` returns a download link, I should copy that link exactly instead of rewriting it. Deleting files is destructive, so that may need explicit permission.',
420
+ getCapabilityDescription: () => 'I can manage files with the unified `files` tool (actions: `read`, `write`, `list`, `copy`, `move`, `delete`) and deliver finished artifacts with `send_file`.',
421
+ getOperatingGuidance: () => [
422
+ 'The `files` tool always works best with an explicit action. Use `{"action":"list","dirPath":"."}` to inspect the workspace, `{"action":"read","filePath":"path/to/file.md"}` to inspect a file, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}` to create or overwrite content.',
423
+ 'For follow-up revision requests, read the current file first, then overwrite it with the improved version or use `edit_file` for a surgical change.',
424
+ 'If a `files` call fails, correct the arguments and retry. Do not conclude that the workspace is inaccessible until an explicit read/list/write attempt with a path fails.',
425
+ 'When `send_file` returns a download link, copy that link exactly instead of rewriting it.',
426
+ ],
421
427
  } as PluginHooks,
422
428
  tools: [
423
429
  {
@@ -68,12 +68,18 @@ async function executeHttpAction(args: HttpRequestArgs) {
68
68
  */
69
69
  const HttpPlugin: Plugin = {
70
70
  name: 'Core HTTP',
71
- description: 'Make direct HTTP API calls with custom methods, headers, and bodies.',
72
- hooks: {} as PluginHooks,
71
+ description: 'Make direct HTTP API calls without generating throwaway code.',
72
+ hooks: {
73
+ getCapabilityDescription: () => 'I can make direct HTTP requests (`http_request`) without writing code. Use this for straightforward API calls or fetching JSON.',
74
+ getOperatingGuidance: () => [
75
+ 'Prefer `http_request` over `sandbox_exec` for straightforward REST or JSON API calls.',
76
+ 'Keep API keys in plugin settings or SwarmClaw secrets instead of hardcoding them in generated code.',
77
+ ],
78
+ } as PluginHooks,
73
79
  tools: [
74
80
  {
75
81
  name: 'http_request',
76
- description: 'Make an HTTP API request.',
82
+ description: 'Make an HTTP API request without generating code.',
77
83
  parameters: {
78
84
  type: 'object',
79
85
  properties: {
@@ -25,6 +25,7 @@ import { buildWalletTools } from './wallet'
25
25
  import { buildOpenClawWorkspaceTools } from './openclaw-workspace'
26
26
  import { buildScheduleTools } from './schedule'
27
27
  import { buildPlatformTools } from './platform'
28
+ import { buildCrudTools } from './crud'
28
29
  import { buildSessionInfoTools } from './session-info'
29
30
  import { buildOpenClawNodeTools } from './openclaw-nodes'
30
31
  import { buildContextTools } from './context-mgmt'
@@ -179,6 +180,12 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
179
180
  tools.push(...builtTools)
180
181
  }
181
182
 
183
+ const crudTools = buildCrudTools(bctx)
184
+ for (const toolEntry of crudTools) {
185
+ toolToPluginMap[toolEntry.name] = toolEntry.name
186
+ }
187
+ tools.push(...crudTools)
188
+
182
189
  // 2. Build Plugin Tools (Built-in + External)
183
190
  try {
184
191
  const pluginTools = pluginManager.getTools(activePlugins)
@@ -266,11 +273,34 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
266
273
  const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
267
274
  const toolId = normalized.toolId as string | undefined
268
275
  const reason = normalized.reason as string | undefined
276
+ if (!toolId?.trim()) {
277
+ return JSON.stringify({
278
+ error: 'toolId is required',
279
+ message: 'Specify the exact plugin ID to request access for.',
280
+ })
281
+ }
282
+ const { requestApprovalMaybeAutoApprove } = await import('../approvals')
283
+ const approval = await requestApprovalMaybeAutoApprove({
284
+ category: 'tool_access',
285
+ title: `Enable Plugin: ${toolId}`,
286
+ description: reason || `Agent is requesting access to "${toolId}".`,
287
+ data: { toolId, pluginId: toolId, reason: reason || '' },
288
+ agentId: ctx?.agentId,
289
+ sessionId: ctx?.sessionId,
290
+ })
291
+ if (approval.status === 'approved') {
292
+ return JSON.stringify({
293
+ type: 'tool_request',
294
+ toolId,
295
+ autoApproved: true,
296
+ message: `Tool access for "${toolId}" was granted. Proceed to use it directly.`,
297
+ })
298
+ }
269
299
  return JSON.stringify({
270
300
  type: 'tool_request',
271
301
  toolId,
272
302
  reason,
273
- message: `Tool access request sent to user for "${toolId}". The user will be prompted to grant access — once granted, a follow-up message will arrive and you should immediately proceed with the original task using the newly available tool.`,
303
+ message: `Tool access request sent to user for "${toolId}". Once granted, continue immediately with the original task using the newly available tool.`,
274
304
  })
275
305
  },
276
306
  {
@@ -0,0 +1,137 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-schedule-tool-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('manage_schedules tool', () => {
36
+ it('defaults schedules to the current agent and derives a runnable taskPrompt from run_script payloads', () => {
37
+ const output = runWithTempDataDir(`
38
+ import fs from 'node:fs'
39
+ import path from 'node:path'
40
+ const storageMod = await import('./src/lib/server/storage.ts')
41
+ const crudMod = await import('./src/lib/server/session-tools/crud.ts')
42
+ const storage = storageMod.default || storageMod
43
+ const crud = crudMod.default || crudMod
44
+
45
+ const now = Date.now()
46
+ storage.saveAgents({
47
+ default: {
48
+ id: 'default',
49
+ name: 'Molly',
50
+ description: '',
51
+ systemPrompt: '',
52
+ provider: 'openai',
53
+ model: 'gpt-test',
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ },
57
+ })
58
+
59
+ const cwd = process.env.WORKSPACE_DIR
60
+ fs.mkdirSync(path.join(cwd, 'weather_workspace'), { recursive: true })
61
+ fs.writeFileSync(path.join(cwd, 'weather_workspace', 'weather_fetch.py'), 'print("weather")\\n')
62
+
63
+ const tools = crud.buildCrudTools({
64
+ cwd,
65
+ ctx: { sessionId: 'session-1', agentId: 'default', platformAssignScope: 'self' },
66
+ hasPlugin: (name) => name === 'manage_schedules',
67
+ })
68
+ const tool = tools.find((entry) => entry.name === 'manage_schedules')
69
+ const raw = await tool.invoke({
70
+ action: 'create',
71
+ data: JSON.stringify({
72
+ name: 'Daily Weather Update',
73
+ scheduleType: 'interval',
74
+ intervalMs: 60000,
75
+ action: 'run_script',
76
+ path: 'weather_workspace/weather_fetch.py',
77
+ }),
78
+ })
79
+
80
+ const schedule = Object.values(storage.loadSchedules())[0]
81
+ console.log(JSON.stringify({
82
+ raw,
83
+ schedule,
84
+ }))
85
+ `)
86
+
87
+ assert.equal(output.schedule.agentId, 'default')
88
+ assert.equal(output.schedule.path, 'weather_workspace/weather_fetch.py')
89
+ assert.match(output.schedule.taskPrompt, /weather_workspace\/weather_fetch\.py/)
90
+ assert.equal(output.schedule.status, 'active')
91
+ assert.equal(typeof output.schedule.nextRunAt, 'number')
92
+ })
93
+
94
+ it('rejects schedules whose referenced script path does not exist', () => {
95
+ const output = runWithTempDataDir(`
96
+ const storageMod = await import('./src/lib/server/storage.ts')
97
+ const crudMod = await import('./src/lib/server/session-tools/crud.ts')
98
+ const storage = storageMod.default || storageMod
99
+ const crud = crudMod.default || crudMod
100
+
101
+ const now = Date.now()
102
+ storage.saveAgents({
103
+ default: {
104
+ id: 'default',
105
+ name: 'Molly',
106
+ description: '',
107
+ systemPrompt: '',
108
+ provider: 'openai',
109
+ model: 'gpt-test',
110
+ createdAt: now,
111
+ updatedAt: now,
112
+ },
113
+ })
114
+
115
+ const tools = crud.buildCrudTools({
116
+ cwd: process.env.WORKSPACE_DIR,
117
+ ctx: { sessionId: 'session-2', agentId: 'default', platformAssignScope: 'self' },
118
+ hasPlugin: (name) => name === 'manage_schedules',
119
+ })
120
+ const tool = tools.find((entry) => entry.name === 'manage_schedules')
121
+ const raw = await tool.invoke({
122
+ action: 'create',
123
+ data: JSON.stringify({
124
+ name: 'Broken Weather Update',
125
+ scheduleType: 'interval',
126
+ intervalMs: 60000,
127
+ action: 'run_script',
128
+ path: 'weather_workspace/missing.py',
129
+ }),
130
+ })
131
+
132
+ console.log(JSON.stringify({ raw }))
133
+ `)
134
+
135
+ assert.match(String(output.raw), /schedule path not found: weather_workspace\/missing\.py/i)
136
+ })
137
+ })
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import fs from 'fs'
4
- import path from 'path'
5
4
  import * as os from 'os'
6
5
  import type { ToolBuildContext } from './context'
7
6
  import { getPluginManager } from '../plugins'
@@ -89,17 +88,25 @@ async function createDurableWatch(
89
88
  return JSON.stringify(job, null, 2)
90
89
  }
91
90
 
91
+ function getErrorMessage(err: unknown): string {
92
+ return err instanceof Error ? err.message : String(err)
93
+ }
94
+
92
95
  /**
93
96
  * Unified Monitoring Logic
94
97
  */
95
- async function executeMonitorAction(args: any, bctx: { cwd: string; sessionId?: string; agentId?: string | null }) {
98
+ async function executeMonitorAction(
99
+ args: Record<string, unknown> | undefined,
100
+ bctx: { cwd: string; sessionId?: string; agentId?: string | null },
101
+ ) {
96
102
  const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
97
103
  const action = normalized.action as string | undefined
98
104
  const target = (normalized.target ?? normalized.url ?? normalized.path) as string | undefined
99
105
  const limit = normalized.limit as number | undefined
100
106
  const threshold = normalized.threshold as number | undefined
101
107
  const sessionId = typeof normalized.sessionId === 'string' ? normalized.sessionId : bctx.sessionId
102
- const agentId = typeof normalized.agentId === 'string' ? normalized.agentId : (bctx.agentId || undefined)
108
+ void limit
109
+ void sessionId
103
110
 
104
111
  try {
105
112
  switch (action) {
@@ -150,10 +157,10 @@ async function executeMonitorAction(args: any, bctx: { cwd: string; sessionId?:
150
157
  thresholdExceeded: typeof threshold === 'number' ? latency >= threshold : undefined,
151
158
  url
152
159
  }, null, 2)
153
- } catch (err: any) {
160
+ } catch (err: unknown) {
154
161
  return JSON.stringify({
155
162
  status: 'error',
156
- error: err.message,
163
+ error: getErrorMessage(err),
157
164
  url
158
165
  }, null, 2)
159
166
  }
@@ -211,8 +218,8 @@ async function executeMonitorAction(args: any, bctx: { cwd: string; sessionId?:
211
218
  default:
212
219
  return `Error: Unknown action "${action}"`
213
220
  }
214
- } catch (err: any) {
215
- return `Error: ${err.message}`
221
+ } catch (err: unknown) {
222
+ return `Error: ${getErrorMessage(err)}`
216
223
  }
217
224
  }
218
225
 
@@ -0,0 +1,111 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { executeNodesAction } from './openclaw-nodes'
4
+ import type { OpenClawGateway } from '../openclaw-gateway'
5
+
6
+ test('executeNodesAction returns not_connected when no gateway is available', async () => {
7
+ const raw = await executeNodesAction(
8
+ { action: 'list', profileId: 'gateway-1' },
9
+ { ensureGatewayConnected: async () => null },
10
+ )
11
+ const result = JSON.parse(raw)
12
+ assert.equal(result.status, 'not_connected')
13
+ assert.match(result.message, /gateway not connected/i)
14
+ })
15
+
16
+ test('executeNodesAction lists nodes against the selected gateway profile', async () => {
17
+ const calls: Array<{ method: string; params: unknown }> = []
18
+ const gateway = {
19
+ rpc: async (method: string, params?: unknown) => {
20
+ calls.push({ method, params })
21
+ return { ts: 1, nodes: [{ nodeId: 'node-1' }] }
22
+ },
23
+ }
24
+
25
+ const raw = await executeNodesAction(
26
+ { action: 'list', profileId: 'gateway-1' },
27
+ { ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway },
28
+ )
29
+ const result = JSON.parse(raw)
30
+ assert.equal(result.status, 'ok')
31
+ assert.equal(calls[0]?.method, 'node.list')
32
+ assert.deepEqual(calls[0]?.params, { profileId: 'gateway-1' })
33
+ assert.equal(result.result.nodes[0].nodeId, 'node-1')
34
+ })
35
+
36
+ test('executeNodesAction aggregates node and device pairings', async () => {
37
+ const calls: string[] = []
38
+ const gateway = {
39
+ rpc: async (method: string) => {
40
+ calls.push(method)
41
+ if (method === 'node.pair.list') return { pending: [{ requestId: 'node-req-1' }] }
42
+ if (method === 'device.pair.list') return { pending: [{ requestId: 'device-req-1' }], paired: [{ deviceId: 'device-1' }] }
43
+ throw new Error(`Unexpected RPC ${method}`)
44
+ },
45
+ }
46
+
47
+ const raw = await executeNodesAction(
48
+ { action: 'pairings', profileId: 'gateway-1' },
49
+ { ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway },
50
+ )
51
+ const result = JSON.parse(raw)
52
+ assert.equal(result.status, 'ok')
53
+ assert.deepEqual(calls, ['node.pair.list', 'device.pair.list'])
54
+ assert.equal(result.result.nodePairings.pending[0].requestId, 'node-req-1')
55
+ assert.equal(result.result.devicePairings.paired[0].deviceId, 'device-1')
56
+ })
57
+
58
+ test('executeNodesAction routes device pairing approvals to the device RPC surface', async () => {
59
+ const calls: Array<{ method: string; params: unknown }> = []
60
+ const gateway = {
61
+ rpc: async (method: string, params?: unknown) => {
62
+ calls.push({ method, params })
63
+ return { ok: true }
64
+ },
65
+ }
66
+
67
+ const raw = await executeNodesAction(
68
+ { action: 'approve_pairing', pairingType: 'device', requestId: 'req-1', profileId: 'gateway-1' },
69
+ { ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway },
70
+ )
71
+ const result = JSON.parse(raw)
72
+ assert.equal(result.status, 'ok')
73
+ assert.equal(calls[0]?.method, 'device.pair.approve')
74
+ assert.deepEqual(calls[0]?.params, { requestId: 'req-1', profileId: 'gateway-1' })
75
+ })
76
+
77
+ test('executeNodesAction forwards notify payloads through node.invoke with a generated idempotency key', async () => {
78
+ const calls: Array<{ method: string; params: unknown }> = []
79
+ const gateway = {
80
+ rpc: async (method: string, params?: unknown) => {
81
+ calls.push({ method, params })
82
+ return { delivered: true }
83
+ },
84
+ }
85
+
86
+ const raw = await executeNodesAction(
87
+ {
88
+ action: 'notify',
89
+ profileId: 'gateway-1',
90
+ nodeId: 'node-42',
91
+ message: 'hello from test',
92
+ params: { urgency: 'high' },
93
+ timeoutMs: 5000,
94
+ },
95
+ {
96
+ ensureGatewayConnected: async () => gateway as unknown as OpenClawGateway,
97
+ generateId: () => 'fixed-id',
98
+ },
99
+ )
100
+ const result = JSON.parse(raw)
101
+ assert.equal(result.status, 'ok')
102
+ assert.equal(calls[0]?.method, 'node.invoke')
103
+ assert.deepEqual(calls[0]?.params, {
104
+ nodeId: 'node-42',
105
+ command: 'notify',
106
+ params: { urgency: 'high', message: 'hello from test' },
107
+ timeoutMs: 5000,
108
+ idempotencyKey: 'fixed-id',
109
+ profileId: 'gateway-1',
110
+ })
111
+ })
@@ -1,46 +1,105 @@
1
1
  import { z } from 'zod'
2
+ import { randomUUID } from 'crypto'
2
3
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
4
  import type { ToolBuildContext } from './context'
4
5
  import type { Plugin, PluginHooks } from '@/types'
5
6
  import { getPluginManager } from '../plugins'
6
7
  import { normalizeToolInputArgs } from './normalize-tool-args'
8
+ import { ensureGatewayConnected } from '../openclaw-gateway'
9
+
10
+ interface OpenClawNodesDeps {
11
+ ensureGatewayConnected?: typeof ensureGatewayConnected
12
+ generateId?: () => string
13
+ }
7
14
 
8
15
  /**
9
16
  * Core OpenClaw Nodes Execution Logic
10
17
  */
11
- async function executeNodesAction(args: any) {
18
+ export async function executeNodesAction(args: any, deps: OpenClawNodesDeps = {}) {
12
19
  const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
13
20
  const action = normalized.action as string | undefined
14
21
  const nodeId = (normalized.nodeId ?? normalized.node_id) as string | undefined
22
+ const deviceId = (normalized.deviceId ?? normalized.device_id) as string | undefined
23
+ const requestId = (normalized.requestId ?? normalized.request_id) as string | undefined
15
24
  const message = normalized.message as string | undefined
16
25
  const params = normalized.params as Record<string, unknown> | undefined
26
+ const command = (normalized.command ?? params?.command ?? params?.action) as string | undefined
27
+ const pairingType = typeof normalized.pairingType === 'string' ? normalized.pairingType : (typeof normalized.kind === 'string' ? normalized.kind : 'node')
28
+ const profileId = (normalized.profileId ?? normalized.gatewayProfileId ?? normalized.gateway_profile_id) as string | undefined
29
+ const agentId = (normalized.agentId ?? normalized.agent_id) as string | undefined
30
+ const timeoutMs = typeof normalized.timeoutMs === 'number'
31
+ ? normalized.timeoutMs
32
+ : (typeof params?.timeoutMs === 'number' ? params.timeoutMs : undefined)
33
+ const ensureGatewayConnectedFn = deps.ensureGatewayConnected ?? ensureGatewayConnected
34
+ const generateId = deps.generateId ?? randomUUID
17
35
  try {
18
- const { listRunningConnectors, getRunningInstance } = await import('../connectors/manager')
19
- const openclawConnectors = listRunningConnectors('openclaw')
20
- if (!openclawConnectors.length) {
36
+ const gateway = await ensureGatewayConnectedFn({ profileId, agentId })
37
+ if (!gateway) {
21
38
  return JSON.stringify({
22
39
  status: 'not_connected',
23
- message: 'No running OpenClaw connector found.',
24
- hint: 'Start an OpenClaw connector in the Connectors panel, then retry.',
40
+ message: 'OpenClaw gateway not connected.',
41
+ hint: 'Connect an OpenClaw gateway profile in Providers, then retry.',
25
42
  })
26
43
  }
27
- const inst = getRunningInstance(openclawConnectors[0].id)
28
- if (!inst) {
44
+
45
+ if (action === 'list') {
46
+ const result = await gateway.rpc('node.list', { profileId })
47
+ return JSON.stringify({ status: 'ok', action, result })
48
+ }
49
+ if (action === 'describe') {
50
+ if (!nodeId) return JSON.stringify({ status: 'error', error: 'nodeId is required for describe.' })
51
+ const result = await gateway.rpc('node.describe', { nodeId, profileId })
52
+ return JSON.stringify({ status: 'ok', action, nodeId, result })
53
+ }
54
+ if (action === 'pairings') {
55
+ const [nodePairings, devicePairings] = await Promise.all([
56
+ gateway.rpc('node.pair.list', { profileId }),
57
+ gateway.rpc('device.pair.list', { profileId }),
58
+ ])
29
59
  return JSON.stringify({
30
- status: 'not_connected',
31
- message: 'OpenClaw connector instance not accessible.',
32
- connectorId: openclawConnectors[0].id,
60
+ status: 'ok',
61
+ action,
62
+ result: {
63
+ nodePairings,
64
+ devicePairings,
65
+ },
33
66
  })
34
67
  }
35
-
36
- if (action === 'list') {
37
- return JSON.stringify({ status: 'nodes.list not supported on gateway yet', connectorId: openclawConnectors[0].id })
68
+ if (action === 'approve_pairing') {
69
+ if (!requestId) return JSON.stringify({ status: 'error', error: 'requestId is required for approve_pairing.' })
70
+ const method = pairingType === 'device' ? 'device.pair.approve' : 'node.pair.approve'
71
+ const result = await gateway.rpc(method, { requestId, profileId })
72
+ return JSON.stringify({ status: 'ok', action, pairingType, requestId, result })
73
+ }
74
+ if (action === 'reject_pairing') {
75
+ if (!requestId) return JSON.stringify({ status: 'error', error: 'requestId is required for reject_pairing.' })
76
+ const method = pairingType === 'device' ? 'device.pair.reject' : 'node.pair.reject'
77
+ const result = await gateway.rpc(method, { requestId, profileId })
78
+ return JSON.stringify({ status: 'ok', action, pairingType, requestId, result })
38
79
  }
39
- if (action === 'notify') {
40
- return JSON.stringify({ status: 'nodes.notify not supported on gateway yet', nodeId, message })
80
+ if (action === 'remove_device') {
81
+ if (!deviceId) return JSON.stringify({ status: 'error', error: 'deviceId is required for remove_device.' })
82
+ const result = await gateway.rpc('device.pair.remove', { deviceId, profileId })
83
+ return JSON.stringify({ status: 'ok', action, deviceId, result })
41
84
  }
42
- if (action === 'invoke') {
43
- return JSON.stringify({ status: 'nodes.invoke not supported on gateway yet', nodeId, invokeAction: params?.action })
85
+ if (action === 'notify' || action === 'invoke') {
86
+ if (!nodeId) return JSON.stringify({ status: 'error', error: 'nodeId is required for invoke.' })
87
+ const invokeCommand = typeof command === 'string' && command.trim()
88
+ ? command.trim()
89
+ : (action === 'notify' ? 'notify' : '')
90
+ if (!invokeCommand) return JSON.stringify({ status: 'error', error: 'command is required for invoke.' })
91
+ const invokeParams = action === 'notify'
92
+ ? { ...(params || {}), message }
93
+ : (params || {})
94
+ const result = await gateway.rpc('node.invoke', {
95
+ nodeId,
96
+ command: invokeCommand,
97
+ params: invokeParams,
98
+ timeoutMs,
99
+ idempotencyKey: generateId(),
100
+ profileId,
101
+ })
102
+ return JSON.stringify({ status: 'ok', action, nodeId, command: invokeCommand, result })
44
103
  }
45
104
 
46
105
  return JSON.stringify({ status: 'error', error: `Unknown nodes action "${action}".` })
@@ -63,10 +122,17 @@ const NodesPlugin: Plugin = {
63
122
  parameters: {
64
123
  type: 'object',
65
124
  properties: {
66
- action: { type: 'string', enum: ['list', 'notify', 'invoke'] },
125
+ action: { type: 'string', enum: ['list', 'describe', 'pairings', 'approve_pairing', 'reject_pairing', 'remove_device', 'notify', 'invoke'] },
67
126
  nodeId: { type: 'string' },
127
+ deviceId: { type: 'string' },
128
+ requestId: { type: 'string' },
129
+ pairingType: { type: 'string', enum: ['node', 'device'] },
130
+ profileId: { type: 'string' },
131
+ agentId: { type: 'string' },
132
+ command: { type: 'string' },
68
133
  message: { type: 'string' },
69
- params: { type: 'object' }
134
+ params: { type: 'object' },
135
+ timeoutMs: { type: 'number' },
70
136
  },
71
137
  required: ['action']
72
138
  },
@@ -190,7 +190,7 @@ const PlatformPlugin: Plugin = {
190
190
  tools: [
191
191
  {
192
192
  name: 'manage_platform',
193
- description: 'Unified tool for managing all SwarmClaw resources. For create/update, pass resource + action, then either put fields inside data, pass them as top-level fields, or use a single resources[0].parameters envelope.',
193
+ description: 'Unified fallback tool for managing SwarmClaw resources when a more specific `manage_*` tool is not available. For create/update, pass resource + action, then either put fields inside data, pass them as top-level fields, or use a single resources[0].parameters envelope.',
194
194
  parameters: {
195
195
  type: 'object',
196
196
  properties: {
@@ -245,8 +245,15 @@ Key rules:
245
245
  */
246
246
  const PluginCreatorPlugin: Plugin = {
247
247
  name: 'Plugin Creator',
248
- description: 'Design, write, and test custom SwarmClaw plugins dynamically.',
249
- hooks: {} as PluginHooks,
248
+ description: 'Design focused SwarmClaw plugins for durable capabilities and recurring automations.',
249
+ hooks: {
250
+ getCapabilityDescription: () => 'I can scaffold focused plugins (`plugin_creator_tool`) when a capability should become durable instead of living in a one-off sandbox script.',
251
+ getOperatingGuidance: () => [
252
+ 'For recurring or scheduled automations, prefer a focused plugin plus `manage_schedules` over repeated sandbox runs.',
253
+ 'Put API keys in plugin settings or SwarmClaw secrets instead of hardcoding them in plugin source.',
254
+ 'Call `get_spec` before scaffolding so the plugin follows the current contract.',
255
+ ],
256
+ } as PluginHooks,
250
257
  tools: [
251
258
  {
252
259
  name: 'plugin_creator_tool',