@swarmclawai/swarmclaw 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +12 -15
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -12,6 +12,7 @@ import {
12
12
  } from './process-manager'
13
13
  import { normalizeOpenClawEndpoint, deriveOpenClawWsUrl } from '@/lib/openclaw-endpoint'
14
14
  import { probeOpenClawHealth, type OpenClawHealthResult } from './openclaw-health'
15
+ import { DATA_DIR } from './data-dir'
15
16
 
16
17
  export type OpenClawRemoteDeployTemplate = 'docker' | 'render' | 'fly' | 'railway'
17
18
  export type OpenClawRemoteDeployProvider =
@@ -28,6 +29,9 @@ export type OpenClawUseCaseTemplate = 'local-dev' | 'single-vps' | 'private-tail
28
29
  export type OpenClawExposurePreset = 'private-lan' | 'tailscale' | 'caddy' | 'nginx' | 'ssh-tunnel'
29
30
 
30
31
  export interface OpenClawLocalDeployStatus {
32
+ id: string
33
+ name: string
34
+ isPrimary: boolean
31
35
  running: boolean
32
36
  processId: string | null
33
37
  pid: number | null
@@ -35,20 +39,35 @@ export interface OpenClawLocalDeployStatus {
35
39
  endpoint: string
36
40
  wsUrl: string
37
41
  token: string | null
42
+ stateDir: string
43
+ configPath: string
44
+ workspaceDir: string
38
45
  startedAt: number | null
46
+ createdAt: number
47
+ updatedAt: number
39
48
  tail: string
40
49
  lastError: string | null
41
50
  launchCommand: string
42
51
  installCommand: string
43
52
  }
44
53
 
54
+ export interface OpenClawLocalDeployCollectionStatus {
55
+ primaryId: string | null
56
+ items: OpenClawLocalDeployStatus[]
57
+ }
58
+
45
59
  export interface OpenClawRemoteDeployStatus {
60
+ id: string
61
+ name: string
62
+ isPrimary: boolean
46
63
  active: boolean
47
64
  processId: string | null
48
65
  pid: number | null
49
66
  action: string | null
50
67
  target: string | null
51
68
  startedAt: number | null
69
+ createdAt: number
70
+ updatedAt: number
52
71
  status: ProcessStatus | 'idle'
53
72
  exitCode: number | null
54
73
  tail: string
@@ -58,6 +77,11 @@ export interface OpenClawRemoteDeployStatus {
58
77
  lastBackupPath: string | null
59
78
  }
60
79
 
80
+ export interface OpenClawRemoteDeployCollectionStatus {
81
+ primaryId: string | null
82
+ items: OpenClawRemoteDeployStatus[]
83
+ }
84
+
61
85
  export interface OpenClawDeployBundleFile {
62
86
  name: string
63
87
  language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
@@ -90,6 +114,7 @@ export interface OpenClawSshConfig {
90
114
  export interface OpenClawRemoteCommandResult {
91
115
  ok: boolean
92
116
  started: boolean
117
+ remoteId?: string | null
93
118
  processId?: string | null
94
119
  summary: string
95
120
  commandPreview: string
@@ -98,20 +123,35 @@ export interface OpenClawRemoteCommandResult {
98
123
  }
99
124
 
100
125
  interface LocalRuntimeState {
126
+ id: string
127
+ name: string | null
101
128
  processId: string | null
102
129
  port: number
103
130
  endpoint: string
104
131
  wsUrl: string
105
132
  token: string | null
106
133
  startedAt: number | null
134
+ createdAt: number
135
+ updatedAt: number
107
136
  lastError: string | null
108
137
  }
109
138
 
139
+ interface LocalRuntimePaths {
140
+ rootDir: string
141
+ stateDir: string
142
+ configPath: string
143
+ workspaceDir: string
144
+ }
145
+
110
146
  interface RemoteRuntimeState {
147
+ id: string
148
+ name: string | null
111
149
  processId: string | null
112
150
  action: string | null
113
151
  target: string | null
114
152
  startedAt: number | null
153
+ createdAt: number
154
+ updatedAt: number
115
155
  lastError: string | null
116
156
  lastSummary: string | null
117
157
  lastCommandPreview: string | null
@@ -119,8 +159,10 @@ interface RemoteRuntimeState {
119
159
  }
120
160
 
121
161
  interface DeployRuntimeState {
122
- local: LocalRuntimeState
123
- remote: RemoteRuntimeState
162
+ locals: Record<string, LocalRuntimeState>
163
+ primaryLocalId: string | null
164
+ remotes: Record<string, RemoteRuntimeState>
165
+ primaryRemoteId: string | null
124
166
  }
125
167
 
126
168
  interface RemoteProviderMeta {
@@ -293,33 +335,220 @@ const EXPOSURE_META: Record<OpenClawExposurePreset, ExposureMeta> = {
293
335
  },
294
336
  }
295
337
 
296
- function getRuntimeState(): DeployRuntimeState {
297
- const fallback: DeployRuntimeState = {
298
- local: {
299
- processId: null,
300
- port: DEFAULT_LOCAL_PORT,
301
- endpoint: normalizeOpenClawEndpoint(`http://127.0.0.1:${DEFAULT_LOCAL_PORT}`),
302
- wsUrl: deriveOpenClawWsUrl(`http://127.0.0.1:${DEFAULT_LOCAL_PORT}`),
303
- token: null,
304
- startedAt: null,
305
- lastError: null,
338
+ function createLocalRuntimeState(
339
+ id: string,
340
+ patch?: Partial<LocalRuntimeState>,
341
+ ): LocalRuntimeState {
342
+ const port = typeof patch?.port === 'number' && Number.isFinite(patch.port)
343
+ ? patch.port
344
+ : DEFAULT_LOCAL_PORT
345
+ const endpoint = normalizeOpenClawEndpoint(
346
+ typeof patch?.endpoint === 'string' && patch.endpoint.trim()
347
+ ? patch.endpoint
348
+ : `http://127.0.0.1:${port}`,
349
+ )
350
+ return {
351
+ id,
352
+ name: typeof patch?.name === 'string' && patch.name.trim() ? patch.name.trim() : `Local OpenClaw ${port}`,
353
+ processId: typeof patch?.processId === 'string' && patch.processId.trim() ? patch.processId.trim() : null,
354
+ port,
355
+ endpoint,
356
+ wsUrl: typeof patch?.wsUrl === 'string' && patch.wsUrl.trim() ? patch.wsUrl.trim() : deriveOpenClawWsUrl(endpoint),
357
+ token: typeof patch?.token === 'string' && patch.token.trim() ? patch.token.trim() : null,
358
+ startedAt: typeof patch?.startedAt === 'number' ? patch.startedAt : null,
359
+ createdAt: typeof patch?.createdAt === 'number' ? patch.createdAt : Date.now(),
360
+ updatedAt: typeof patch?.updatedAt === 'number' ? patch.updatedAt : Date.now(),
361
+ lastError: typeof patch?.lastError === 'string' && patch.lastError.trim() ? patch.lastError : null,
362
+ }
363
+ }
364
+
365
+ function createRemoteRuntimeState(
366
+ id: string,
367
+ patch?: Partial<RemoteRuntimeState>,
368
+ ): RemoteRuntimeState {
369
+ const target = typeof patch?.target === 'string' && patch.target.trim() ? patch.target.trim() : null
370
+ return {
371
+ id,
372
+ name: typeof patch?.name === 'string' && patch.name.trim()
373
+ ? patch.name.trim()
374
+ : deriveRemoteDeploymentName(target || ''),
375
+ processId: typeof patch?.processId === 'string' && patch.processId.trim() ? patch.processId.trim() : null,
376
+ action: typeof patch?.action === 'string' && patch.action.trim() ? patch.action.trim() : null,
377
+ target,
378
+ startedAt: typeof patch?.startedAt === 'number' ? patch.startedAt : null,
379
+ createdAt: typeof patch?.createdAt === 'number' ? patch.createdAt : Date.now(),
380
+ updatedAt: typeof patch?.updatedAt === 'number' ? patch.updatedAt : Date.now(),
381
+ lastError: typeof patch?.lastError === 'string' && patch.lastError.trim() ? patch.lastError : null,
382
+ lastSummary: typeof patch?.lastSummary === 'string' && patch.lastSummary.trim() ? patch.lastSummary : null,
383
+ lastCommandPreview: typeof patch?.lastCommandPreview === 'string' && patch.lastCommandPreview.trim()
384
+ ? patch.lastCommandPreview
385
+ : null,
386
+ lastBackupPath: typeof patch?.lastBackupPath === 'string' && patch.lastBackupPath.trim()
387
+ ? patch.lastBackupPath
388
+ : null,
389
+ }
390
+ }
391
+
392
+ function resolveLocalRuntimePaths(id: string): LocalRuntimePaths {
393
+ const safeId = id.trim() || 'local-default'
394
+ const rootDir = path.join(DATA_DIR, 'openclaw-local', safeId)
395
+ return {
396
+ rootDir,
397
+ stateDir: rootDir,
398
+ configPath: path.join(rootDir, 'openclaw.json'),
399
+ workspaceDir: path.join(rootDir, 'workspace'),
400
+ }
401
+ }
402
+
403
+ function buildManagedLocalConfig(paths: LocalRuntimePaths): Record<string, unknown> {
404
+ const config: Record<string, unknown> = {
405
+ gateway: {
406
+ mode: 'local',
407
+ bind: 'custom',
408
+ customBindHost: '127.0.0.1',
409
+ http: {
410
+ endpoints: {
411
+ chatCompletions: {
412
+ enabled: true,
413
+ },
414
+ },
415
+ },
306
416
  },
307
- remote: {
308
- processId: null,
309
- action: null,
310
- target: null,
311
- startedAt: null,
312
- lastError: null,
313
- lastSummary: null,
314
- lastCommandPreview: null,
315
- lastBackupPath: null,
417
+ agents: {
418
+ defaults: {
419
+ workspace: paths.workspaceDir,
420
+ },
316
421
  },
317
422
  }
423
+
424
+ if (process.env.OLLAMA_API_KEY?.trim()) {
425
+ ;(config.agents as Record<string, Record<string, unknown>>).defaults.model = {
426
+ primary: 'ollama/glm-5:cloud',
427
+ }
428
+ ;(config.agents as Record<string, Record<string, unknown>>).defaults.models = {
429
+ 'ollama/glm-5:cloud': {
430
+ alias: 'glm5cloud',
431
+ },
432
+ }
433
+ }
434
+
435
+ return config
436
+ }
437
+
438
+ async function ensureManagedLocalRuntimeFiles(id: string): Promise<LocalRuntimePaths> {
439
+ const paths = resolveLocalRuntimePaths(id)
440
+ await fs.mkdir(paths.workspaceDir, { recursive: true })
441
+ const config = buildManagedLocalConfig(paths)
442
+ await fs.writeFile(paths.configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
443
+ return paths
444
+ }
445
+
446
+ function buildLocalProcessEnv(id: string): Record<string, string> {
447
+ const paths = resolveLocalRuntimePaths(id)
448
+ return {
449
+ OPENCLAW_CONFIG_PATH: paths.configPath,
450
+ OPENCLAW_STATE_DIR: paths.stateDir,
451
+ }
452
+ }
453
+
454
+ function normalizeLocalRuntimeState(raw: unknown, id: string): LocalRuntimeState | null {
455
+ if (!raw || typeof raw !== 'object') return null
456
+ return createLocalRuntimeState(id, raw as Partial<LocalRuntimeState>)
457
+ }
458
+
459
+ function normalizeRemoteRuntimeState(raw: unknown, id: string): RemoteRuntimeState | null {
460
+ if (!raw || typeof raw !== 'object') return null
461
+ return createRemoteRuntimeState(id, raw as Partial<RemoteRuntimeState>)
462
+ }
463
+
464
+ function sortLocalRuntimeStates(items: LocalRuntimeState[]): LocalRuntimeState[] {
465
+ return [...items].sort((left, right) => {
466
+ if (left.updatedAt !== right.updatedAt) return right.updatedAt - left.updatedAt
467
+ if (left.createdAt !== right.createdAt) return right.createdAt - left.createdAt
468
+ if (left.port !== right.port) return left.port - right.port
469
+ return left.id.localeCompare(right.id)
470
+ })
471
+ }
472
+
473
+ function sortRemoteRuntimeStates(items: RemoteRuntimeState[]): RemoteRuntimeState[] {
474
+ return [...items].sort((left, right) => {
475
+ if (left.updatedAt !== right.updatedAt) return right.updatedAt - left.updatedAt
476
+ if (left.createdAt !== right.createdAt) return right.createdAt - left.createdAt
477
+ const leftTarget = left.target || ''
478
+ const rightTarget = right.target || ''
479
+ if (leftTarget !== rightTarget) return leftTarget.localeCompare(rightTarget)
480
+ return left.id.localeCompare(right.id)
481
+ })
482
+ }
483
+
484
+ function defaultRuntimeState(): DeployRuntimeState {
485
+ return {
486
+ locals: {},
487
+ primaryLocalId: null,
488
+ remotes: {},
489
+ primaryRemoteId: null,
490
+ }
491
+ }
492
+
493
+ function normalizeRuntimeState(raw: unknown): DeployRuntimeState {
494
+ const fallback = defaultRuntimeState()
495
+ if (!raw || typeof raw !== 'object') return fallback
496
+ const state = raw as Partial<DeployRuntimeState> & { local?: unknown; locals?: Record<string, unknown> }
497
+
498
+ const locals: Record<string, LocalRuntimeState> = {}
499
+ if (state.locals && typeof state.locals === 'object') {
500
+ for (const [id, value] of Object.entries(state.locals)) {
501
+ const normalized = normalizeLocalRuntimeState(value, id)
502
+ if (normalized) locals[id] = normalized
503
+ }
504
+ } else if (state.local && typeof state.local === 'object') {
505
+ const normalized = normalizeLocalRuntimeState(state.local, 'local-default')
506
+ if (normalized) locals[normalized.id] = normalized
507
+ }
508
+
509
+ const orderedLocals = sortLocalRuntimeStates(Object.values(locals))
510
+ const primaryLocalId = typeof state.primaryLocalId === 'string' && locals[state.primaryLocalId]
511
+ ? state.primaryLocalId
512
+ : orderedLocals[0]?.id || null
513
+
514
+ const remotes: Record<string, RemoteRuntimeState> = {}
515
+ const stateWithRemote = state as Partial<DeployRuntimeState> & {
516
+ local?: unknown
517
+ locals?: Record<string, unknown>
518
+ remote?: unknown
519
+ remotes?: Record<string, unknown>
520
+ primaryRemoteId?: unknown
521
+ }
522
+ if (stateWithRemote.remotes && typeof stateWithRemote.remotes === 'object') {
523
+ for (const [id, value] of Object.entries(stateWithRemote.remotes)) {
524
+ const normalized = normalizeRemoteRuntimeState(value, id)
525
+ if (normalized) remotes[id] = normalized
526
+ }
527
+ } else if (stateWithRemote.remote && typeof stateWithRemote.remote === 'object') {
528
+ const normalized = normalizeRemoteRuntimeState(stateWithRemote.remote, 'remote-default')
529
+ if (normalized) remotes[normalized.id] = normalized
530
+ }
531
+ const orderedRemotes = sortRemoteRuntimeStates(Object.values(remotes))
532
+ const primaryRemoteId = typeof stateWithRemote.primaryRemoteId === 'string' && remotes[stateWithRemote.primaryRemoteId]
533
+ ? stateWithRemote.primaryRemoteId
534
+ : orderedRemotes[0]?.id || null
535
+
536
+ return {
537
+ locals,
538
+ primaryLocalId,
539
+ remotes,
540
+ primaryRemoteId,
541
+ }
542
+ }
543
+
544
+ function getRuntimeState(): DeployRuntimeState {
318
545
  const globalState = globalThis as typeof globalThis & { [GLOBAL_KEY]?: DeployRuntimeState }
319
546
  if (!globalState[GLOBAL_KEY]) {
320
- globalState[GLOBAL_KEY] = fallback
547
+ globalState[GLOBAL_KEY] = defaultRuntimeState()
548
+ } else {
549
+ globalState[GLOBAL_KEY] = normalizeRuntimeState(globalState[GLOBAL_KEY])
321
550
  }
322
- return globalState[GLOBAL_KEY] || fallback
551
+ return globalState[GLOBAL_KEY] || defaultRuntimeState()
323
552
  }
324
553
 
325
554
  function shellEscape(value: string): string {
@@ -338,8 +567,11 @@ function resolveBundledOpenClawBinary(): string {
338
567
  return 'openclaw'
339
568
  }
340
569
 
341
- function buildLocalRunCommand(port: number, token?: string | null): string {
570
+ function buildLocalRunCommand(port: number, token?: string | null, localId = 'local-default'): string {
571
+ const env = buildLocalProcessEnv(localId)
342
572
  const parts = [
573
+ `OPENCLAW_CONFIG_PATH=${shellEscape(env.OPENCLAW_CONFIG_PATH)}`,
574
+ `OPENCLAW_STATE_DIR=${shellEscape(env.OPENCLAW_STATE_DIR)}`,
343
575
  'npx',
344
576
  'openclaw',
345
577
  'gateway',
@@ -347,7 +579,7 @@ function buildLocalRunCommand(port: number, token?: string | null): string {
347
579
  '--allow-unconfigured',
348
580
  '--force',
349
581
  '--bind',
350
- 'loopback',
582
+ 'custom',
351
583
  '--port',
352
584
  String(port),
353
585
  ]
@@ -357,8 +589,11 @@ function buildLocalRunCommand(port: number, token?: string | null): string {
357
589
  return parts.join(' ')
358
590
  }
359
591
 
360
- function buildLocalInstallCommand(port: number, token?: string | null): string {
592
+ function buildLocalInstallCommand(port: number, token?: string | null, localId = 'local-default'): string {
593
+ const env = buildLocalProcessEnv(localId)
361
594
  const parts = [
595
+ `OPENCLAW_CONFIG_PATH=${shellEscape(env.OPENCLAW_CONFIG_PATH)}`,
596
+ `OPENCLAW_STATE_DIR=${shellEscape(env.OPENCLAW_STATE_DIR)}`,
362
597
  'npx',
363
598
  'openclaw',
364
599
  'gateway',
@@ -468,17 +703,39 @@ async function materializeBundleFiles(bundle: OpenClawDeployBundle): Promise<{ d
468
703
  return { dir, filePaths }
469
704
  }
470
705
 
471
- function updateRemoteRuntimeState(patch: Partial<RemoteRuntimeState>) {
472
- Object.assign(getRuntimeState().remote, patch)
473
- }
474
-
475
706
  async function startRemoteCommand(params: {
707
+ remoteId?: string | null
708
+ name?: string | null
476
709
  action: string
477
710
  target: string
478
711
  command: string
479
712
  summary: string
480
713
  backupPath?: string | null
714
+ makePrimary?: boolean
481
715
  }): Promise<OpenClawRemoteCommandResult> {
716
+ const state = getRuntimeState()
717
+ const requestedRemoteId = typeof params.remoteId === 'string' && params.remoteId.trim()
718
+ ? params.remoteId.trim()
719
+ : null
720
+ const existingById = requestedRemoteId ? state.remotes[requestedRemoteId] || null : null
721
+ const existingByTarget = Object.values(state.remotes).find((item) => (
722
+ normalizeRemoteTargetKey(item.target) === normalizeRemoteTargetKey(params.target)
723
+ )) || null
724
+ const remoteId = existingById?.id || existingByTarget?.id || requestedRemoteId || generateRemoteDeployId()
725
+ const existing = existingById || existingByTarget || null
726
+ const current = existing || createRemoteRuntimeState(remoteId, {
727
+ name: params.name || deriveRemoteDeploymentName(params.target),
728
+ target: params.target,
729
+ })
730
+
731
+ if (current.processId) {
732
+ const currentProcess = getManagedProcess(current.processId)
733
+ if (currentProcess?.status === 'running') {
734
+ killManagedProcess(current.processId)
735
+ }
736
+ removeManagedProcess(current.processId)
737
+ }
738
+
482
739
  const result = await startManagedProcess({
483
740
  command: params.command,
484
741
  cwd: process.cwd(),
@@ -487,19 +744,24 @@ async function startRemoteCommand(params: {
487
744
  })
488
745
 
489
746
  if (result.status === 'completed' && (result.exitCode ?? 0) === 0) {
490
- updateRemoteRuntimeState({
747
+ state.remotes[remoteId] = createRemoteRuntimeState(remoteId, {
748
+ ...current,
749
+ name: typeof params.name === 'string' && params.name.trim() ? params.name.trim() : current.name,
491
750
  processId: null,
492
751
  action: params.action,
493
752
  target: params.target,
494
- startedAt: Date.now(),
753
+ startedAt: null,
754
+ updatedAt: Date.now(),
495
755
  lastError: null,
496
756
  lastSummary: params.summary,
497
757
  lastCommandPreview: params.command,
498
758
  lastBackupPath: params.backupPath || null,
499
759
  })
760
+ if (params.makePrimary !== false || !state.primaryRemoteId) state.primaryRemoteId = remoteId
500
761
  return {
501
762
  ok: true,
502
763
  started: false,
764
+ remoteId,
503
765
  processId: null,
504
766
  summary: params.summary,
505
767
  commandPreview: params.command,
@@ -508,38 +770,48 @@ async function startRemoteCommand(params: {
508
770
 
509
771
  if (result.status !== 'running') {
510
772
  const message = result.output || result.tail || params.summary
511
- updateRemoteRuntimeState({
773
+ state.remotes[remoteId] = createRemoteRuntimeState(remoteId, {
774
+ ...current,
775
+ name: typeof params.name === 'string' && params.name.trim() ? params.name.trim() : current.name,
512
776
  processId: null,
513
777
  action: params.action,
514
778
  target: params.target,
515
779
  startedAt: null,
780
+ updatedAt: Date.now(),
516
781
  lastError: message,
517
782
  lastSummary: params.summary,
518
783
  lastCommandPreview: params.command,
519
784
  lastBackupPath: params.backupPath || null,
520
785
  })
786
+ if (params.makePrimary !== false || !state.primaryRemoteId) state.primaryRemoteId = remoteId
521
787
  return {
522
788
  ok: false,
523
789
  started: false,
790
+ remoteId,
524
791
  processId: null,
525
792
  summary: message,
526
793
  commandPreview: params.command,
527
794
  }
528
795
  }
529
796
 
530
- updateRemoteRuntimeState({
797
+ state.remotes[remoteId] = createRemoteRuntimeState(remoteId, {
798
+ ...current,
799
+ name: typeof params.name === 'string' && params.name.trim() ? params.name.trim() : current.name,
531
800
  processId: result.processId,
532
801
  action: params.action,
533
802
  target: params.target,
534
803
  startedAt: Date.now(),
804
+ updatedAt: Date.now(),
535
805
  lastError: null,
536
806
  lastSummary: params.summary,
537
807
  lastCommandPreview: params.command,
538
808
  lastBackupPath: params.backupPath || null,
539
809
  })
810
+ if (params.makePrimary !== false || !state.primaryRemoteId) state.primaryRemoteId = remoteId
540
811
  return {
541
812
  ok: true,
542
813
  started: true,
814
+ remoteId,
543
815
  processId: result.processId,
544
816
  summary: params.summary,
545
817
  commandPreview: params.command,
@@ -564,90 +836,245 @@ function readTail(text: string, size = 1200): string {
564
836
  return text.length <= size ? text : text.slice(text.length - size)
565
837
  }
566
838
 
567
- function currentLocalStatus(): OpenClawLocalDeployStatus {
839
+ function currentLocalStatusFromState(localState: LocalRuntimeState, isPrimary: boolean): OpenClawLocalDeployStatus {
568
840
  const state = getRuntimeState()
569
- const processId = state.local.processId
841
+ const runtime = state.locals[localState.id] || localState
842
+ const paths = resolveLocalRuntimePaths(runtime.id)
843
+ const processId = runtime.processId
570
844
  const process = processId ? getManagedProcess(processId) : null
571
845
  const running = !!process && process.status === 'running'
572
846
 
573
847
  if (!running && processId && process && process.status !== 'running') {
574
- state.local.lastError = readTail(process.log || '') || state.local.lastError
575
- state.local.processId = null
576
- state.local.startedAt = null
848
+ runtime.lastError = readTail(process.log || '') || runtime.lastError
849
+ runtime.processId = null
850
+ runtime.startedAt = null
851
+ runtime.updatedAt = Date.now()
852
+ state.locals[runtime.id] = runtime
577
853
  }
578
854
 
579
- const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${state.local.port}`)
855
+ const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${runtime.port}`)
580
856
  return {
857
+ id: runtime.id,
858
+ name: runtime.name || `Local OpenClaw ${runtime.port}`,
859
+ isPrimary,
581
860
  running,
582
861
  processId: running ? processId : null,
583
862
  pid: running ? (process?.pid ?? null) : null,
584
- port: state.local.port,
863
+ port: runtime.port,
585
864
  endpoint,
586
865
  wsUrl: deriveOpenClawWsUrl(endpoint),
587
- token: state.local.token || null,
588
- startedAt: running ? state.local.startedAt : null,
866
+ token: runtime.token || null,
867
+ stateDir: paths.stateDir,
868
+ configPath: paths.configPath,
869
+ workspaceDir: paths.workspaceDir,
870
+ startedAt: running ? runtime.startedAt : null,
871
+ createdAt: runtime.createdAt,
872
+ updatedAt: runtime.updatedAt,
589
873
  tail: process ? readTail(process.log || '') : '',
590
- lastError: running ? null : (state.local.lastError || null),
591
- launchCommand: buildLocalRunCommand(state.local.port, state.local.token),
592
- installCommand: buildLocalInstallCommand(state.local.port, state.local.token),
874
+ lastError: running ? null : (runtime.lastError || null),
875
+ launchCommand: buildLocalRunCommand(runtime.port, runtime.token, runtime.id),
876
+ installCommand: buildLocalInstallCommand(runtime.port, runtime.token, runtime.id),
877
+ }
878
+ }
879
+
880
+ function getPrimaryLocalRuntimeState(state: DeployRuntimeState): LocalRuntimeState | null {
881
+ if (state.primaryLocalId && state.locals[state.primaryLocalId]) {
882
+ return state.locals[state.primaryLocalId]
593
883
  }
884
+ const ordered = sortLocalRuntimeStates(Object.values(state.locals))
885
+ const next = ordered[0] || null
886
+ state.primaryLocalId = next?.id || null
887
+ return next
888
+ }
889
+
890
+ function findLocalRuntimeState(
891
+ state: DeployRuntimeState,
892
+ input?: { localId?: string | null; port?: number | null },
893
+ ): LocalRuntimeState | null {
894
+ const localId = typeof input?.localId === 'string' && input.localId.trim() ? input.localId.trim() : ''
895
+ if (localId && state.locals[localId]) return state.locals[localId]
896
+ const port = typeof input?.port === 'number' && Number.isFinite(input.port) ? input.port : null
897
+ if (port !== null) {
898
+ return Object.values(state.locals).find((item) => item.port === port) || null
899
+ }
900
+ return getPrimaryLocalRuntimeState(state)
594
901
  }
595
902
 
596
- export function getOpenClawLocalDeployStatus(): OpenClawLocalDeployStatus {
597
- return currentLocalStatus()
903
+ function defaultLocalStatus(): OpenClawLocalDeployStatus {
904
+ const fallback = createLocalRuntimeState('local-default')
905
+ return currentLocalStatusFromState(fallback, true)
598
906
  }
599
907
 
600
- function currentRemoteStatus(): OpenClawRemoteDeployStatus {
908
+ export function getOpenClawLocalDeployStatuses(): OpenClawLocalDeployStatus[] {
601
909
  const state = getRuntimeState()
602
- const processId = state.remote.processId
910
+ return sortLocalRuntimeStates(Object.values(state.locals))
911
+ .map((runtime) => currentLocalStatusFromState(runtime, runtime.id === state.primaryLocalId))
912
+ }
913
+
914
+ export function getOpenClawLocalDeployCollectionStatus(): OpenClawLocalDeployCollectionStatus {
915
+ const state = getRuntimeState()
916
+ const items = getOpenClawLocalDeployStatuses()
917
+ const primaryId = items.find((item) => item.isPrimary)?.id || null
918
+ state.primaryLocalId = primaryId
919
+ return { primaryId, items }
920
+ }
921
+
922
+ export function getOpenClawLocalDeployStatus(localId?: string | null): OpenClawLocalDeployStatus {
923
+ const state = getRuntimeState()
924
+ const runtime = findLocalRuntimeState(state, { localId })
925
+ return runtime
926
+ ? currentLocalStatusFromState(runtime, runtime.id === state.primaryLocalId)
927
+ : defaultLocalStatus()
928
+ }
929
+
930
+ function currentRemoteStatusFromState(remoteState: RemoteRuntimeState, isPrimary: boolean): OpenClawRemoteDeployStatus {
931
+ const state = getRuntimeState()
932
+ const runtime = state.remotes[remoteState.id] || remoteState
933
+ const processId = runtime.processId
603
934
  const process = processId ? getManagedProcess(processId) : null
604
935
  const active = !!process && process.status === 'running'
605
936
 
606
937
  if (!active && processId && process && process.status !== 'running') {
607
- state.remote.lastError = readTail(process.log || '') || state.remote.lastError
608
- state.remote.processId = null
938
+ runtime.lastError = readTail(process.log || '') || runtime.lastError
939
+ runtime.processId = null
940
+ runtime.startedAt = null
941
+ runtime.updatedAt = Date.now()
942
+ state.remotes[runtime.id] = runtime
609
943
  }
610
944
 
611
945
  return {
946
+ id: runtime.id,
947
+ name: runtime.name || deriveRemoteDeploymentName(runtime.target || ''),
948
+ isPrimary,
612
949
  active,
613
950
  processId: active ? processId : null,
614
951
  pid: active ? (process?.pid ?? null) : null,
615
- action: state.remote.action || null,
616
- target: state.remote.target || null,
617
- startedAt: state.remote.startedAt || null,
952
+ action: runtime.action || null,
953
+ target: runtime.target || null,
954
+ startedAt: runtime.startedAt || null,
955
+ createdAt: runtime.createdAt,
956
+ updatedAt: runtime.updatedAt,
618
957
  status: process?.status || 'idle',
619
958
  exitCode: process?.exitCode ?? null,
620
959
  tail: process ? readTail(process.log || '') : '',
621
- lastError: active ? null : (state.remote.lastError || null),
622
- lastSummary: state.remote.lastSummary || null,
623
- lastCommandPreview: state.remote.lastCommandPreview || null,
624
- lastBackupPath: state.remote.lastBackupPath || null,
960
+ lastError: active ? null : (runtime.lastError || null),
961
+ lastSummary: runtime.lastSummary || null,
962
+ lastCommandPreview: runtime.lastCommandPreview || null,
963
+ lastBackupPath: runtime.lastBackupPath || null,
964
+ }
965
+ }
966
+
967
+ function getPrimaryRemoteRuntimeState(state: DeployRuntimeState): RemoteRuntimeState | null {
968
+ if (state.primaryRemoteId && state.remotes[state.primaryRemoteId]) {
969
+ return state.remotes[state.primaryRemoteId]
970
+ }
971
+ const ordered = sortRemoteRuntimeStates(Object.values(state.remotes))
972
+ const next = ordered[0] || null
973
+ state.primaryRemoteId = next?.id || null
974
+ return next
975
+ }
976
+
977
+ function normalizeRemoteTargetKey(value: unknown): string {
978
+ return typeof value === 'string' ? value.trim().toLowerCase() : ''
979
+ }
980
+
981
+ function findRemoteRuntimeState(
982
+ state: DeployRuntimeState,
983
+ input?: { remoteId?: string | null; target?: string | null },
984
+ ): RemoteRuntimeState | null {
985
+ const remoteId = typeof input?.remoteId === 'string' && input.remoteId.trim() ? input.remoteId.trim() : ''
986
+ if (remoteId && state.remotes[remoteId]) return state.remotes[remoteId]
987
+ const targetKey = normalizeRemoteTargetKey(input?.target)
988
+ if (targetKey) {
989
+ return Object.values(state.remotes).find((item) => normalizeRemoteTargetKey(item.target) === targetKey) || null
625
990
  }
991
+ return getPrimaryRemoteRuntimeState(state)
626
992
  }
627
993
 
628
- export function getOpenClawRemoteDeployStatus(): OpenClawRemoteDeployStatus {
629
- return currentRemoteStatus()
994
+ function defaultRemoteStatus(): OpenClawRemoteDeployStatus {
995
+ const fallback = createRemoteRuntimeState('remote-default', { name: 'Remote OpenClaw' })
996
+ return currentRemoteStatusFromState(fallback, true)
997
+ }
998
+
999
+ export function getOpenClawRemoteDeployStatuses(): OpenClawRemoteDeployStatus[] {
1000
+ const state = getRuntimeState()
1001
+ return sortRemoteRuntimeStates(Object.values(state.remotes))
1002
+ .map((runtime) => currentRemoteStatusFromState(runtime, runtime.id === state.primaryRemoteId))
1003
+ }
1004
+
1005
+ export function getOpenClawRemoteDeployCollectionStatus(): OpenClawRemoteDeployCollectionStatus {
1006
+ const state = getRuntimeState()
1007
+ const items = getOpenClawRemoteDeployStatuses()
1008
+ const primaryId = items.find((item) => item.isPrimary)?.id || null
1009
+ state.primaryRemoteId = primaryId
1010
+ return { primaryId, items }
1011
+ }
1012
+
1013
+ export function getOpenClawRemoteDeployStatus(remoteId?: string | null): OpenClawRemoteDeployStatus {
1014
+ const state = getRuntimeState()
1015
+ const runtime = findRemoteRuntimeState(state, { remoteId })
1016
+ return runtime
1017
+ ? currentRemoteStatusFromState(runtime, runtime.id === state.primaryRemoteId)
1018
+ : defaultRemoteStatus()
630
1019
  }
631
1020
 
632
1021
  export function generateOpenClawGatewayToken(): string {
633
1022
  return randomBytes(24).toString('base64url')
634
1023
  }
635
1024
 
1025
+ function generateLocalDeployId(): string {
1026
+ return `local-${randomBytes(8).toString('hex')}`
1027
+ }
1028
+
1029
+ function generateRemoteDeployId(): string {
1030
+ return `remote-${randomBytes(8).toString('hex')}`
1031
+ }
1032
+
636
1033
  export async function startOpenClawLocalDeploy(input?: {
1034
+ localId?: string | null
1035
+ name?: string | null
637
1036
  port?: number
638
1037
  token?: string | null
639
- }): Promise<{ local: OpenClawLocalDeployStatus; token: string }> {
1038
+ makePrimary?: boolean
1039
+ }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
640
1040
  const state = getRuntimeState()
641
- const current = currentLocalStatus()
642
- if (current.running && current.processId) {
643
- killManagedProcess(current.processId)
1041
+ const port = sanitizePort(input?.port, DEFAULT_LOCAL_PORT)
1042
+ const requestedLocalId = typeof input?.localId === 'string' && input.localId.trim()
1043
+ ? input.localId.trim()
1044
+ : null
1045
+ const existingById = requestedLocalId ? state.locals[requestedLocalId] || null : null
1046
+ const existingByPort = Object.values(state.locals).find((item) => item.port === port) || null
1047
+ const localId = existingById?.id || existingByPort?.id || requestedLocalId || generateLocalDeployId()
1048
+ const existing = existingById || existingByPort || null
1049
+ const current = existing || createLocalRuntimeState(localId, {
1050
+ port,
1051
+ name: typeof input?.name === 'string' && input.name.trim() ? input.name.trim() : `Local OpenClaw ${port}`,
1052
+ })
1053
+
1054
+ if (existingByPort && existingByPort.id !== localId && existingByPort.processId) {
1055
+ const conflictProcess = getManagedProcess(existingByPort.processId)
1056
+ if (conflictProcess?.status === 'running') {
1057
+ killManagedProcess(existingByPort.processId)
1058
+ }
1059
+ removeManagedProcess(existingByPort.processId)
1060
+ existingByPort.processId = null
1061
+ existingByPort.startedAt = null
1062
+ existingByPort.updatedAt = Date.now()
1063
+ state.locals[existingByPort.id] = existingByPort
1064
+ }
1065
+
1066
+ if (current.processId) {
1067
+ const currentProcess = getManagedProcess(current.processId)
1068
+ if (currentProcess?.status === 'running') {
1069
+ killManagedProcess(current.processId)
1070
+ }
644
1071
  removeManagedProcess(current.processId)
645
1072
  }
646
1073
 
647
- const port = sanitizePort(input?.port, DEFAULT_LOCAL_PORT)
648
1074
  const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
649
1075
  const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${port}`)
650
1076
  const wsUrl = deriveOpenClawWsUrl(endpoint)
1077
+ await ensureManagedLocalRuntimeFiles(localId)
651
1078
  const binary = resolveBundledOpenClawBinary()
652
1079
  const args = [
653
1080
  binary,
@@ -656,7 +1083,7 @@ export async function startOpenClawLocalDeploy(input?: {
656
1083
  '--allow-unconfigured',
657
1084
  '--force',
658
1085
  '--bind',
659
- 'loopback',
1086
+ 'custom',
660
1087
  '--port',
661
1088
  String(port),
662
1089
  '--auth',
@@ -669,45 +1096,62 @@ export async function startOpenClawLocalDeploy(input?: {
669
1096
  const result = await startManagedProcess({
670
1097
  command: args.map(shellEscape).join(' '),
671
1098
  cwd: process.cwd(),
1099
+ env: buildLocalProcessEnv(localId),
672
1100
  background: true,
673
1101
  timeoutMs: 24 * 60 * 60_000,
674
1102
  })
675
1103
 
676
1104
  if (result.status !== 'running') {
677
1105
  const message = result.output || result.tail || 'OpenClaw failed to start.'
678
- state.local = {
1106
+ state.locals[localId] = createLocalRuntimeState(localId, {
1107
+ ...current,
1108
+ name: typeof input?.name === 'string' && input.name.trim() ? input.name.trim() : current.name,
679
1109
  processId: null,
680
1110
  port,
681
1111
  endpoint,
682
1112
  wsUrl,
683
1113
  token,
684
1114
  startedAt: null,
1115
+ updatedAt: Date.now(),
685
1116
  lastError: message,
686
- }
1117
+ })
1118
+ if (input?.makePrimary !== false || !state.primaryLocalId) state.primaryLocalId = localId
687
1119
  throw new Error(message)
688
1120
  }
689
1121
 
690
- state.local = {
1122
+ state.locals[localId] = createLocalRuntimeState(localId, {
1123
+ ...current,
1124
+ name: typeof input?.name === 'string' && input.name.trim() ? input.name.trim() : current.name,
691
1125
  processId: result.processId,
692
1126
  port,
693
1127
  endpoint,
694
1128
  wsUrl,
695
1129
  token,
696
1130
  startedAt: Date.now(),
1131
+ updatedAt: Date.now(),
697
1132
  lastError: null,
698
- }
1133
+ })
1134
+ if (input?.makePrimary !== false || !state.primaryLocalId) state.primaryLocalId = localId
699
1135
 
700
1136
  await waitForLocalRuntime(result.processId)
701
1137
 
1138
+ const local = getOpenClawLocalDeployStatus(localId)
702
1139
  return {
703
- local: currentLocalStatus(),
1140
+ local,
1141
+ locals: getOpenClawLocalDeployStatuses(),
704
1142
  token,
705
1143
  }
706
1144
  }
707
1145
 
708
- export function stopOpenClawLocalDeploy(): OpenClawLocalDeployStatus {
1146
+ export function stopOpenClawLocalDeploy(localId?: string | null): { local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[] } {
709
1147
  const state = getRuntimeState()
710
- const processId = state.local.processId
1148
+ const runtime = findLocalRuntimeState(state, { localId })
1149
+ if (!runtime) {
1150
+ const local = defaultLocalStatus()
1151
+ return { local, locals: getOpenClawLocalDeployStatuses() }
1152
+ }
1153
+
1154
+ const processId = runtime.processId
711
1155
  if (processId) {
712
1156
  const process = getManagedProcess(processId)
713
1157
  if (process?.status === 'running') {
@@ -715,19 +1159,39 @@ export function stopOpenClawLocalDeploy(): OpenClawLocalDeployStatus {
715
1159
  }
716
1160
  removeManagedProcess(processId)
717
1161
  }
718
- state.local.processId = null
719
- state.local.startedAt = null
720
- return currentLocalStatus()
1162
+ runtime.processId = null
1163
+ runtime.startedAt = null
1164
+ runtime.updatedAt = Date.now()
1165
+ state.locals[runtime.id] = runtime
1166
+ if (state.primaryLocalId === runtime.id) {
1167
+ state.primaryLocalId = sortLocalRuntimeStates(Object.values(state.locals).filter((item) => item.id !== runtime.id && item.processId))
1168
+ [0]?.id
1169
+ || runtime.id
1170
+ }
1171
+ return {
1172
+ local: getOpenClawLocalDeployStatus(runtime.id),
1173
+ locals: getOpenClawLocalDeployStatuses(),
1174
+ }
721
1175
  }
722
1176
 
723
1177
  export async function restartOpenClawLocalDeploy(input?: {
1178
+ localId?: string | null
1179
+ name?: string | null
724
1180
  port?: number
725
1181
  token?: string | null
726
- }): Promise<{ local: OpenClawLocalDeployStatus; token: string }> {
727
- const current = currentLocalStatus()
1182
+ makePrimary?: boolean
1183
+ }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
1184
+ const state = getRuntimeState()
1185
+ const current = findLocalRuntimeState(state, {
1186
+ localId: input?.localId ?? null,
1187
+ port: typeof input?.port === 'number' ? input.port : null,
1188
+ })
728
1189
  return startOpenClawLocalDeploy({
729
- port: input?.port ?? current.port,
730
- token: input?.token ?? current.token,
1190
+ localId: input?.localId ?? current?.id ?? null,
1191
+ name: input?.name ?? current?.name ?? null,
1192
+ port: input?.port ?? current?.port ?? DEFAULT_LOCAL_PORT,
1193
+ token: input?.token ?? current?.token ?? null,
1194
+ makePrimary: input?.makePrimary,
731
1195
  })
732
1196
  }
733
1197
 
@@ -1276,6 +1740,8 @@ export async function verifyOpenClawDeployment(input?: {
1276
1740
  }
1277
1741
 
1278
1742
  export async function deployOpenClawBundleOverSsh(input?: {
1743
+ remoteId?: string | null
1744
+ name?: string | null
1279
1745
  template?: OpenClawRemoteDeployTemplate
1280
1746
  target?: string | null
1281
1747
  token?: string | null
@@ -1285,6 +1751,7 @@ export async function deployOpenClawBundleOverSsh(input?: {
1285
1751
  useCase?: OpenClawUseCaseTemplate
1286
1752
  exposure?: OpenClawExposurePreset
1287
1753
  ssh?: Partial<OpenClawSshConfig> | null
1754
+ makePrimary?: boolean
1288
1755
  }): Promise<OpenClawRemoteCommandResult> {
1289
1756
  const sshConfig = sanitizeSshConfig(input?.ssh)
1290
1757
  if (!sshConfig) throw new Error('SSH host is required for remote deploy.')
@@ -1309,10 +1776,13 @@ export async function deployOpenClawBundleOverSsh(input?: {
1309
1776
  )
1310
1777
  const command = `${mkdirCommand} && ${scpCommand} && ${bootstrapCommand}`
1311
1778
  const result = await startRemoteCommand({
1779
+ remoteId: input?.remoteId ?? null,
1780
+ name: input?.name ?? bundle.title,
1312
1781
  action: 'ssh-deploy',
1313
1782
  target: sshConfig.host,
1314
1783
  command,
1315
1784
  summary: `Deploying OpenClaw to ${sshConfig.host} over SSH.`,
1785
+ makePrimary: input?.makePrimary,
1316
1786
  })
1317
1787
  return {
1318
1788
  ...result,
@@ -1324,11 +1794,14 @@ export async function deployOpenClawBundleOverSsh(input?: {
1324
1794
  export const deployOpenClawOverSsh = deployOpenClawBundleOverSsh
1325
1795
 
1326
1796
  export async function runOpenClawRemoteLifecycleAction(input?: {
1797
+ remoteId?: string | null
1798
+ name?: string | null
1327
1799
  action: 'start' | 'stop' | 'restart' | 'upgrade' | 'backup' | 'restore' | 'rotate-token'
1328
1800
  ssh?: Partial<OpenClawSshConfig> | null
1329
1801
  image?: string | null
1330
1802
  token?: string | null
1331
1803
  backupPath?: string | null
1804
+ makePrimary?: boolean
1332
1805
  }): Promise<OpenClawRemoteCommandResult> {
1333
1806
  const sshConfig = sanitizeSshConfig(input?.ssh)
1334
1807
  if (!sshConfig) throw new Error('SSH host is required for remote lifecycle actions.')
@@ -1369,11 +1842,14 @@ export async function runOpenClawRemoteLifecycleAction(input?: {
1369
1842
 
1370
1843
  const command = buildSshInvocation(sshConfig, remoteCommand)
1371
1844
  const result = await startRemoteCommand({
1845
+ remoteId: input?.remoteId ?? null,
1846
+ name: input?.name ?? deriveRemoteDeploymentName(sshConfig.host),
1372
1847
  action,
1373
1848
  target: sshConfig.host,
1374
1849
  command,
1375
1850
  summary,
1376
1851
  backupPath,
1852
+ makePrimary: input?.makePrimary,
1377
1853
  })
1378
1854
  return {
1379
1855
  ...result,