@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,23 +1,112 @@
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'
8
7
  import type { Plugin, PluginHooks } from '@/types'
9
8
  import { safePath, truncate } from './context'
10
9
  import { normalizeToolInputArgs } from './normalize-tool-args'
10
+ import { cancelWatchJob, createWatchJob, getWatchJob, listWatchJobs } from '../watch-jobs'
11
+ import { ensureSessionBrowserProfileId, loadBrowserSessionRecord } from '../browser-state'
12
+
13
+ type WatchKind = 'time' | 'http' | 'file' | 'task' | 'webhook' | 'page'
14
+
15
+ async function createDurableWatch(
16
+ normalized: Record<string, unknown>,
17
+ bctx: { cwd: string; sessionId?: string; agentId?: string | null },
18
+ explicitType?: WatchKind,
19
+ ) {
20
+ const watchType = (explicitType || String(normalized.watchType || normalized.type || '').trim().toLowerCase()) as WatchKind
21
+ if (!watchType) return 'Error: watchType is required.'
22
+ if (!['time', 'http', 'file', 'task', 'webhook', 'page'].includes(watchType)) {
23
+ return `Error: Unsupported watchType "${watchType}".`
24
+ }
25
+
26
+ const sessionId = typeof normalized.sessionId === 'string' ? normalized.sessionId : bctx.sessionId
27
+ const agentId = typeof normalized.agentId === 'string' ? normalized.agentId : (bctx.agentId || undefined)
28
+ const resumeMessage = String(normalized.resumeMessage || normalized.message || '').trim()
29
+ if (!resumeMessage) return 'Error: resumeMessage is required.'
30
+
31
+ const target = (normalized.target ?? normalized.url ?? normalized.path) as string | undefined
32
+ const delayMinutes = typeof normalized.delayMinutes === 'number' ? normalized.delayMinutes : undefined
33
+ const runAt = typeof normalized.runAt === 'number'
34
+ ? normalized.runAt
35
+ : delayMinutes !== undefined
36
+ ? Date.now() + Math.max(0, delayMinutes) * 60_000
37
+ : undefined
38
+ const intervalMs = typeof normalized.intervalSec === 'number'
39
+ ? Math.max(15, normalized.intervalSec) * 1000
40
+ : typeof normalized.intervalMs === 'number'
41
+ ? Math.max(15_000, normalized.intervalMs)
42
+ : undefined
43
+ const timeoutAt = typeof normalized.timeoutMinutes === 'number'
44
+ ? Date.now() + Math.max(1, normalized.timeoutMinutes) * 60_000
45
+ : typeof normalized.timeoutAt === 'number'
46
+ ? normalized.timeoutAt
47
+ : undefined
48
+ const browserProfileId = sessionId ? ensureSessionBrowserProfileId(sessionId).profileId : null
49
+ const targetPath = watchType === 'file' && target ? safePath(bctx.cwd, target) : target
50
+ const pageUrl = watchType === 'page' && !target && sessionId
51
+ ? loadBrowserSessionRecord(sessionId)?.currentUrl || undefined
52
+ : undefined
53
+ const pageTarget = target || pageUrl
54
+ if ((watchType === 'http' || watchType === 'page') && !pageTarget) {
55
+ return `Error: ${watchType === 'page' ? 'url or active browser page' : 'url'} is required.`
56
+ }
57
+
58
+ const job = await createWatchJob({
59
+ type: watchType,
60
+ sessionId: sessionId || null,
61
+ agentId: agentId || null,
62
+ createdByAgentId: agentId || null,
63
+ browserProfileId,
64
+ description: typeof normalized.description === 'string' ? normalized.description : null,
65
+ resumeMessage,
66
+ runAt,
67
+ intervalMs,
68
+ timeoutAt,
69
+ target: {
70
+ url: watchType === 'http' || watchType === 'page' ? pageTarget : undefined,
71
+ path: watchType === 'file' ? targetPath : undefined,
72
+ taskId: watchType === 'task' ? String(normalized.taskId || normalized.id || '') : undefined,
73
+ webhookId: watchType === 'webhook' ? String(normalized.webhookId || normalized.id || '') : undefined,
74
+ baselineHash: undefined,
75
+ },
76
+ condition: {
77
+ containsText: typeof normalized.containsText === 'string' ? normalized.containsText : undefined,
78
+ textGone: typeof normalized.textGone === 'string' ? normalized.textGone : undefined,
79
+ regex: typeof normalized.regex === 'string' ? normalized.regex : undefined,
80
+ changed: normalized.changed === true,
81
+ exists: normalized.exists,
82
+ status: typeof normalized.status === 'number' ? normalized.status : undefined,
83
+ statusIn: Array.isArray(normalized.statusIn) ? normalized.statusIn : undefined,
84
+ event: typeof normalized.event === 'string' ? normalized.event : undefined,
85
+ threshold: typeof normalized.threshold === 'number' ? normalized.threshold : undefined,
86
+ },
87
+ })
88
+ return JSON.stringify(job, null, 2)
89
+ }
90
+
91
+ function getErrorMessage(err: unknown): string {
92
+ return err instanceof Error ? err.message : String(err)
93
+ }
11
94
 
12
95
  /**
13
96
  * Unified Monitoring Logic
14
97
  */
15
- async function executeMonitorAction(args: any, bctx: { cwd: string }) {
98
+ async function executeMonitorAction(
99
+ args: Record<string, unknown> | undefined,
100
+ bctx: { cwd: string; sessionId?: string; agentId?: string | null },
101
+ ) {
16
102
  const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
17
103
  const action = normalized.action as string | undefined
18
104
  const target = (normalized.target ?? normalized.url ?? normalized.path) as string | undefined
19
105
  const limit = normalized.limit as number | undefined
20
106
  const threshold = normalized.threshold as number | undefined
107
+ const sessionId = typeof normalized.sessionId === 'string' ? normalized.sessionId : bctx.sessionId
108
+ void limit
109
+ void sessionId
21
110
 
22
111
  try {
23
112
  switch (action) {
@@ -65,22 +154,72 @@ async function executeMonitorAction(args: any, bctx: { cwd: string }) {
65
154
  status: res.status,
66
155
  ok: res.ok,
67
156
  latency: `${latency}ms`,
157
+ thresholdExceeded: typeof threshold === 'number' ? latency >= threshold : undefined,
68
158
  url
69
159
  }, null, 2)
70
- } catch (err: any) {
160
+ } catch (err: unknown) {
71
161
  return JSON.stringify({
72
162
  status: 'error',
73
- error: err.message,
163
+ error: getErrorMessage(err),
74
164
  url
75
165
  }, null, 2)
76
166
  }
77
167
  }
78
168
 
169
+ case 'create_watch': {
170
+ return createDurableWatch(normalized, bctx)
171
+ }
172
+
173
+ case 'wait_until': {
174
+ return createDurableWatch(normalized, bctx, 'time')
175
+ }
176
+
177
+ case 'wait_for_http': {
178
+ return createDurableWatch(normalized, bctx, 'http')
179
+ }
180
+
181
+ case 'wait_for_file': {
182
+ return createDurableWatch(normalized, bctx, 'file')
183
+ }
184
+
185
+ case 'wait_for_task': {
186
+ return createDurableWatch(normalized, bctx, 'task')
187
+ }
188
+
189
+ case 'wait_for_webhook': {
190
+ return createDurableWatch(normalized, bctx, 'webhook')
191
+ }
192
+
193
+ case 'wait_for_page_change': {
194
+ return createDurableWatch(normalized, bctx, 'page')
195
+ }
196
+
197
+ case 'list_watches': {
198
+ const filterSessionId = normalized.all === true ? undefined : sessionId
199
+ return JSON.stringify(listWatchJobs({ sessionId: filterSessionId || null }), null, 2)
200
+ }
201
+
202
+ case 'get_watch': {
203
+ const id = String(normalized.id || '').trim()
204
+ if (!id) return 'Error: id is required.'
205
+ const job = getWatchJob(id)
206
+ if (!job) return `Error: watch job "${id}" not found.`
207
+ return JSON.stringify(job, null, 2)
208
+ }
209
+
210
+ case 'cancel_watch': {
211
+ const id = String(normalized.id || '').trim()
212
+ if (!id) return 'Error: id is required.'
213
+ const job = cancelWatchJob(id)
214
+ if (!job) return `Error: watch job "${id}" not found.`
215
+ return JSON.stringify(job, null, 2)
216
+ }
217
+
79
218
  default:
80
219
  return `Error: Unknown action "${action}"`
81
220
  }
82
- } catch (err: any) {
83
- return `Error: ${err.message}`
221
+ } catch (err: unknown) {
222
+ return `Error: ${getErrorMessage(err)}`
84
223
  }
85
224
  }
86
225
 
@@ -89,22 +228,29 @@ async function executeMonitorAction(args: any, bctx: { cwd: string }) {
89
228
  */
90
229
  const MonitorPlugin: Plugin = {
91
230
  name: 'Core Monitor',
92
- description: 'System observability: check resource usage, watch logs, and ping endpoints.',
231
+ description: 'System observability and durable watch jobs: inspect system state, monitor files/endpoints/tasks, and resume agents when conditions trigger.',
93
232
  hooks: {} as PluginHooks,
94
233
  tools: [
95
234
  {
96
235
  name: 'monitor_tool',
97
- description: 'Observe system health, log activity, or endpoint availability.',
236
+ description: 'Observe system health, inspect logs/endpoints, or create durable waits like wait_for_http, wait_for_file, wait_for_webhook, and wait_for_page_change.',
98
237
  parameters: {
99
238
  type: 'object',
100
239
  properties: {
101
- action: { type: 'string', enum: ['sys_info', 'watch_log', 'ping'] },
240
+ action: { type: 'string', enum: ['sys_info', 'watch_log', 'ping', 'create_watch', 'wait_until', 'wait_for_http', 'wait_for_file', 'wait_for_task', 'wait_for_webhook', 'wait_for_page_change', 'list_watches', 'get_watch', 'cancel_watch'] },
102
241
  target: { type: 'string', description: 'Log file path (for watch_log) or URL (for ping)' },
103
- limit: { type: 'number', description: 'Number of lines or bytes to retrieve' }
242
+ limit: { type: 'number', description: 'Number of lines or bytes to retrieve' },
243
+ watchType: { type: 'string', enum: ['time', 'http', 'file', 'task', 'webhook', 'page'] },
244
+ resumeMessage: { type: 'string', description: 'Message injected when the watch triggers and the agent wakes up.' },
245
+ regex: { type: 'string', description: 'Regex pattern used by file/page/http watchers.' },
104
246
  },
105
247
  required: ['action']
106
248
  },
107
- execute: async (args, context) => executeMonitorAction(args, { cwd: context.session.cwd || process.cwd() })
249
+ execute: async (args, context) => executeMonitorAction(args, {
250
+ cwd: context.session.cwd || process.cwd(),
251
+ sessionId: context.session.id,
252
+ agentId: context.session.agentId,
253
+ })
108
254
  }
109
255
  ]
110
256
  }
@@ -115,7 +261,11 @@ export function buildMonitorTools(bctx: ToolBuildContext): StructuredToolInterfa
115
261
  if (!bctx.hasPlugin('monitor')) return []
116
262
  return [
117
263
  tool(
118
- async (args) => executeMonitorAction(args, { cwd: bctx.cwd }),
264
+ async (args) => executeMonitorAction(args, {
265
+ cwd: bctx.cwd,
266
+ sessionId: bctx.ctx?.sessionId || undefined,
267
+ agentId: bctx.ctx?.agentId || undefined,
268
+ }),
119
269
  {
120
270
  name: 'monitor_tool',
121
271
  description: MonitorPlugin.tools![0].description,
@@ -1,4 +1,5 @@
1
1
  export type ToolArgsRecord = Record<string, unknown>
2
+ const NESTED_WRAPPER_KEYS = ['input', 'args', 'arguments', 'payload', 'parameters'] as const
2
3
 
3
4
  function parseRecordCandidate(value: unknown): ToolArgsRecord | null {
4
5
  if (!value) return null
@@ -26,22 +27,24 @@ function parseRecordCandidate(value: unknown): ToolArgsRecord | null {
26
27
  * as either objects or JSON strings.
27
28
  */
28
29
  export function normalizeToolInputArgs(rawArgs: ToolArgsRecord): ToolArgsRecord {
29
- const nestedSources: Array<ToolArgsRecord | null> = [
30
- parseRecordCandidate(rawArgs.input),
31
- parseRecordCandidate(rawArgs.args),
32
- parseRecordCandidate(rawArgs.arguments),
33
- parseRecordCandidate(rawArgs.payload),
34
- ]
35
-
36
30
  const normalized: ToolArgsRecord = {}
37
- for (const nested of nestedSources) {
38
- if (!nested) continue
39
- Object.assign(normalized, nested)
40
- }
31
+ const queue: ToolArgsRecord[] = [rawArgs]
32
+ const visited = new Set<ToolArgsRecord>()
33
+
34
+ while (queue.length > 0) {
35
+ const current = queue.shift()
36
+ if (!current || visited.has(current)) continue
37
+ visited.add(current)
41
38
 
42
- for (const [key, value] of Object.entries(rawArgs)) {
43
- if (value === undefined || value === null) continue
44
- normalized[key] = value
39
+ for (const key of NESTED_WRAPPER_KEYS) {
40
+ const nested = parseRecordCandidate(current[key])
41
+ if (nested) queue.push(nested)
42
+ }
43
+
44
+ for (const [key, value] of Object.entries(current)) {
45
+ if (value === undefined || value === null) continue
46
+ normalized[key] = value
47
+ }
45
48
  }
46
49
 
47
50
  return normalized
@@ -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
  },
@@ -0,0 +1,142 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { normalizePlatformActionArgs } from './platform'
4
+
5
+ describe('normalizePlatformActionArgs', () => {
6
+ it('packs top-level create fields into data', () => {
7
+ const out = normalizePlatformActionArgs({
8
+ resource: 'tasks',
9
+ action: 'create',
10
+ title: 'Write docs',
11
+ agentId: 'default',
12
+ })
13
+
14
+ assert.equal(out.resource, 'tasks')
15
+ assert.equal(out.action, 'create')
16
+ assert.deepEqual(JSON.parse(String(out.data)), {
17
+ title: 'Write docs',
18
+ agentId: 'default',
19
+ })
20
+ })
21
+
22
+ it('merges object data with top-level overrides', () => {
23
+ const out = normalizePlatformActionArgs({
24
+ resource: 'tasks',
25
+ action: 'create',
26
+ data: { title: 'Old title', agentId: 'coder' },
27
+ title: 'New title',
28
+ })
29
+
30
+ assert.deepEqual(JSON.parse(String(out.data)), {
31
+ title: 'New title',
32
+ agentId: 'coder',
33
+ })
34
+ })
35
+
36
+ it('normalizes legacy resources envelope with parameters payload', () => {
37
+ const out = normalizePlatformActionArgs({
38
+ input: JSON.stringify({
39
+ resources: [
40
+ {
41
+ resource: 'tasks',
42
+ action: 'create',
43
+ parameters: {
44
+ title: 'Legacy task',
45
+ assigned_agent: 'default',
46
+ },
47
+ },
48
+ ],
49
+ }),
50
+ })
51
+
52
+ assert.equal(out.resource, 'tasks')
53
+ assert.equal(out.action, 'create')
54
+ assert.deepEqual(JSON.parse(String(out.data)), {
55
+ title: 'Legacy task',
56
+ assigned_agent: 'default',
57
+ })
58
+ })
59
+
60
+ it('normalizes singular resource names and resource payload objects', () => {
61
+ const out = normalizePlatformActionArgs({
62
+ input: JSON.stringify({
63
+ resource: 'task',
64
+ action: 'create',
65
+ task: {
66
+ title: 'Legacy singular task',
67
+ assigned_to: 'default',
68
+ },
69
+ }),
70
+ })
71
+
72
+ assert.equal(out.resource, 'tasks')
73
+ assert.equal(out.action, 'create')
74
+ assert.deepEqual(JSON.parse(String(out.data)), {
75
+ title: 'Legacy singular task',
76
+ assigned_to: 'default',
77
+ })
78
+ })
79
+
80
+ it('normalizes legacy backlog task resource names to tasks', () => {
81
+ const out = normalizePlatformActionArgs({
82
+ input: JSON.stringify({
83
+ resource: 'backlog_task',
84
+ action: 'create',
85
+ backlog_task: {
86
+ title: 'Legacy backlog task',
87
+ description: 'Keep the intended task payload',
88
+ },
89
+ }),
90
+ })
91
+
92
+ assert.equal(out.resource, 'tasks')
93
+ assert.equal(out.action, 'create')
94
+ assert.deepEqual(JSON.parse(String(out.data)), {
95
+ title: 'Legacy backlog task',
96
+ description: 'Keep the intended task payload',
97
+ })
98
+ })
99
+
100
+ it('normalizes resources entries that use type instead of resource', () => {
101
+ const out = normalizePlatformActionArgs({
102
+ input: JSON.stringify({
103
+ action: 'create',
104
+ resources: [
105
+ {
106
+ type: 'task',
107
+ parameters: {
108
+ title: 'Typed task resource',
109
+ description: 'Created through a typed resources envelope',
110
+ },
111
+ },
112
+ ],
113
+ }),
114
+ })
115
+
116
+ assert.equal(out.resource, 'tasks')
117
+ assert.equal(out.action, 'create')
118
+ assert.deepEqual(JSON.parse(String(out.data)), {
119
+ title: 'Typed task resource',
120
+ description: 'Created through a typed resources envelope',
121
+ })
122
+ })
123
+
124
+ it('infers schedules resource from create_schedule style actions', () => {
125
+ const out = normalizePlatformActionArgs({
126
+ input: JSON.stringify({
127
+ action: 'create_schedule',
128
+ data: {
129
+ name: 'Surgery check-in',
130
+ scheduleType: 'once',
131
+ },
132
+ }),
133
+ })
134
+
135
+ assert.equal(out.resource, 'schedules')
136
+ assert.equal(out.action, 'create')
137
+ assert.deepEqual(JSON.parse(String(out.data)), {
138
+ name: 'Surgery check-in',
139
+ scheduleType: 'once',
140
+ })
141
+ })
142
+ })