@swarmclawai/swarmclaw 0.7.6 → 0.7.8

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 (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -6,6 +6,8 @@ import { copyTextToClipboard } from '@/lib/clipboard'
6
6
 
7
7
  type RemoteTemplate = 'docker' | 'render' | 'fly' | 'railway'
8
8
  type RemoteProvider = 'hetzner' | 'digitalocean' | 'vultr' | 'linode' | 'lightsail' | 'gcp' | 'azure' | 'oci' | 'generic'
9
+ type UseCaseTemplate = 'local-dev' | 'single-vps' | 'private-tailnet' | 'browser-heavy' | 'team-control'
10
+ type ExposurePreset = 'private-lan' | 'tailscale' | 'caddy' | 'nginx' | 'ssh-tunnel'
9
11
 
10
12
  interface LocalDeployStatus {
11
13
  running: boolean
@@ -22,6 +24,22 @@ interface LocalDeployStatus {
22
24
  installCommand: string
23
25
  }
24
26
 
27
+ interface RemoteDeployStatus {
28
+ active: boolean
29
+ processId: string | null
30
+ pid: number | null
31
+ action: string | null
32
+ target: string | null
33
+ startedAt: number | null
34
+ status: 'idle' | 'running' | 'exited' | 'killed' | 'failed' | 'timeout'
35
+ exitCode: number | null
36
+ tail: string
37
+ lastError: string | null
38
+ lastSummary: string | null
39
+ lastCommandPreview: string | null
40
+ lastBackupPath: string | null
41
+ }
42
+
25
43
  interface DeployFile {
26
44
  name: string
27
45
  language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
@@ -32,6 +50,8 @@ interface DeployBundle {
32
50
  template: RemoteTemplate
33
51
  provider: RemoteProvider
34
52
  providerLabel: string
53
+ useCase: UseCaseTemplate
54
+ exposure: ExposurePreset
35
55
  title: string
36
56
  summary: string
37
57
  endpoint: string
@@ -43,6 +63,7 @@ interface DeployBundle {
43
63
 
44
64
  interface DeployStatusResponse {
45
65
  local: LocalDeployStatus
66
+ remote?: RemoteDeployStatus
46
67
  }
47
68
 
48
69
  interface DeployActionResponse {
@@ -50,6 +71,19 @@ interface DeployActionResponse {
50
71
  local?: LocalDeployStatus
51
72
  token?: string
52
73
  bundle?: DeployBundle
74
+ processId?: string | null
75
+ remote?: RemoteDeployStatus
76
+ summary?: string
77
+ commandPreview?: string
78
+ verify?: {
79
+ ok: boolean
80
+ endpoint: string
81
+ wsUrl: string
82
+ authProvided: boolean
83
+ models: string[]
84
+ error?: string
85
+ hint?: string
86
+ }
53
87
  error?: string
54
88
  }
55
89
 
@@ -58,11 +92,32 @@ interface ApplyPatch {
58
92
  token?: string
59
93
  name?: string
60
94
  notes?: string
95
+ deployment?: {
96
+ method?: 'local' | 'bundle' | 'ssh' | 'imported' | null
97
+ provider?: string | null
98
+ remoteTarget?: RemoteTemplate | null
99
+ useCase?: UseCaseTemplate | null
100
+ exposure?: ExposurePreset | null
101
+ sshHost?: string | null
102
+ sshUser?: string | null
103
+ sshPort?: number | null
104
+ sshKeyPath?: string | null
105
+ sshTargetDir?: string | null
106
+ lastDeployAt?: number | null
107
+ lastDeployAction?: string | null
108
+ lastDeploySummary?: string | null
109
+ lastDeployProcessId?: string | null
110
+ lastVerifiedAt?: number | null
111
+ lastVerifiedOk?: boolean | null
112
+ lastVerifiedMessage?: string | null
113
+ lastBackupPath?: string | null
114
+ }
61
115
  }
62
116
 
63
117
  interface OpenClawDeployPanelProps {
64
118
  endpoint?: string | null
65
119
  token?: string | null
120
+ deployment?: ApplyPatch['deployment'] | null
66
121
  suggestedName?: string | null
67
122
  title?: string
68
123
  description?: string
@@ -113,6 +168,30 @@ const PROVIDER_OPTIONS: Array<{
113
168
  { id: 'generic', label: 'Generic', detail: 'Any Ubuntu 24.04 host' },
114
169
  ]
115
170
 
171
+ const USE_CASE_OPTIONS: Array<{
172
+ id: UseCaseTemplate
173
+ label: string
174
+ detail: string
175
+ }> = [
176
+ { id: 'local-dev', label: 'Local Dev', detail: 'Loopback-friendly defaults for one machine and quick setup.' },
177
+ { id: 'single-vps', label: 'Single VPS', detail: 'Balanced default for most public or private VPS installs.' },
178
+ { id: 'private-tailnet', label: 'Private Tailnet', detail: 'Keep the gateway private and expose it over a tailnet.' },
179
+ { id: 'browser-heavy', label: 'Browser Heavy', detail: 'Roomier defaults for browser-backed nodes and automation.' },
180
+ { id: 'team-control', label: 'Team Control', detail: 'Shared operator-friendly control plane defaults and backups.' },
181
+ ]
182
+
183
+ const EXPOSURE_OPTIONS: Array<{
184
+ id: ExposurePreset
185
+ label: string
186
+ detail: string
187
+ }> = [
188
+ { id: 'private-lan', label: 'Private LAN', detail: 'Expose on LAN only and rely on your own firewall rules.' },
189
+ { id: 'tailscale', label: 'Tailscale', detail: 'Loopback only plus a tailnet-facing Tailscale serve script.' },
190
+ { id: 'caddy', label: 'Caddy', detail: 'Bundled reverse proxy with simple HTTPS termination.' },
191
+ { id: 'nginx', label: 'Nginx', detail: 'Bundled reverse proxy config for teams with existing TLS handling.' },
192
+ { id: 'ssh-tunnel', label: 'SSH Tunnel', detail: 'Keep it private and access the gateway through SSH port-forwarding.' },
193
+ ]
194
+
116
195
  function buildLocalRunCommand(port: number, token?: string | null): string {
117
196
  const parts = ['npx', 'openclaw', 'gateway', 'run', '--allow-unconfigured', '--force', '--bind', 'loopback', '--port', String(port)]
118
197
  if (token) parts.push('--auth', 'token', '--token', token)
@@ -170,6 +249,7 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
170
249
  const {
171
250
  endpoint,
172
251
  token,
252
+ deployment,
173
253
  suggestedName,
174
254
  title = 'Smart Deploy OpenClaw',
175
255
  description = 'Launch a local gateway on this host or generate a remote bundle with opinionated defaults.',
@@ -179,6 +259,7 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
179
259
 
180
260
  const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local')
181
261
  const [localStatus, setLocalStatus] = useState<LocalDeployStatus | null>(null)
262
+ const [remoteStatus, setRemoteStatus] = useState<RemoteDeployStatus | null>(null)
182
263
  const [localPort, setLocalPort] = useState(() => inferPort(endpoint))
183
264
  const [deployToken, setDeployToken] = useState(token || '')
184
265
  const [remoteTarget, setRemoteTarget] = useState(() => inferRemoteTarget(endpoint))
@@ -187,12 +268,22 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
187
268
  ))
188
269
  const [remoteTemplate, setRemoteTemplate] = useState<RemoteTemplate>('docker')
189
270
  const [remoteProvider, setRemoteProvider] = useState<RemoteProvider>('hetzner')
271
+ const [useCase, setUseCase] = useState<UseCaseTemplate>(() => deployment?.useCase || 'single-vps')
272
+ const [exposure, setExposure] = useState<ExposurePreset>(() => deployment?.exposure || 'caddy')
273
+ const [sshHost, setSshHost] = useState(() => deployment?.sshHost || inferRemoteTarget(endpoint))
274
+ const [sshUser, setSshUser] = useState(() => deployment?.sshUser || 'root')
275
+ const [sshPort, setSshPort] = useState(() => deployment?.sshPort || 22)
276
+ const [sshKeyPath, setSshKeyPath] = useState(() => deployment?.sshKeyPath || '')
277
+ const [sshTargetDir, setSshTargetDir] = useState(() => deployment?.sshTargetDir || '/opt/openclaw')
278
+ const [restoreBackupPath, setRestoreBackupPath] = useState(() => deployment?.lastBackupPath || '')
190
279
  const [bundle, setBundle] = useState<DeployBundle | null>(null)
191
280
  const [bundleFile, setBundleFile] = useState('')
192
- const [loading, setLoading] = useState<'idle' | 'starting-local' | 'stopping-local' | 'generating-bundle'>('idle')
281
+ const [loading, setLoading] = useState<'idle' | 'starting-local' | 'stopping-local' | 'restarting-local' | 'generating-bundle' | 'ssh-deploy' | 'verifying' | 'remote-action'>('idle')
193
282
  const [message, setMessage] = useState('')
194
283
  const [error, setError] = useState('')
195
284
  const [copiedKey, setCopiedKey] = useState('')
285
+ const [commandPreview, setCommandPreview] = useState('')
286
+ const [verifySummary, setVerifySummary] = useState('')
196
287
 
197
288
  useEffect(() => {
198
289
  if (token && !deployToken) setDeployToken(token)
@@ -204,16 +295,30 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
204
295
  setActiveTab('local')
205
296
  } else if (endpoint && inferRemoteTarget(endpoint)) {
206
297
  setRemoteTarget(inferRemoteTarget(endpoint))
298
+ setSshHost((current) => current || inferRemoteTarget(endpoint))
207
299
  setActiveTab('remote')
208
300
  }
209
301
  }, [endpoint])
210
302
 
303
+ useEffect(() => {
304
+ if (!deployment) return
305
+ if (deployment.useCase) setUseCase(deployment.useCase)
306
+ if (deployment.exposure) setExposure(deployment.exposure)
307
+ if (deployment.sshHost) setSshHost(deployment.sshHost)
308
+ if (deployment.sshUser) setSshUser(deployment.sshUser)
309
+ if (deployment.sshPort) setSshPort(deployment.sshPort)
310
+ if (deployment.sshKeyPath) setSshKeyPath(deployment.sshKeyPath)
311
+ if (deployment.sshTargetDir) setSshTargetDir(deployment.sshTargetDir)
312
+ if (deployment.lastBackupPath) setRestoreBackupPath(deployment.lastBackupPath)
313
+ }, [deployment])
314
+
211
315
  useEffect(() => {
212
316
  let cancelled = false
213
317
  api<DeployStatusResponse>('GET', '/openclaw/deploy')
214
318
  .then((result) => {
215
319
  if (!cancelled) {
216
320
  setLocalStatus(result.local)
321
+ setRemoteStatus(result.remote || null)
217
322
  if (result.local.token) {
218
323
  setDeployToken((current) => current || result.local.token || '')
219
324
  }
@@ -225,6 +330,19 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
225
330
  }
226
331
  }, [])
227
332
 
333
+ useEffect(() => {
334
+ if (!remoteStatus?.active) return
335
+ const timer = window.setInterval(() => {
336
+ api<DeployStatusResponse>('GET', '/openclaw/deploy')
337
+ .then((result) => {
338
+ setLocalStatus(result.local)
339
+ setRemoteStatus(result.remote || null)
340
+ })
341
+ .catch(() => {})
342
+ }, 2500)
343
+ return () => window.clearInterval(timer)
344
+ }, [remoteStatus?.active])
345
+
228
346
  const selectedFile = useMemo(() => {
229
347
  if (!bundle) return null
230
348
  return bundle.files.find((file) => file.name === bundleFile) || bundle.files[0] || null
@@ -259,9 +377,35 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
259
377
  }, 1200)
260
378
  }
261
379
 
380
+ const applyDeploymentPatch = async (patch: ApplyPatch) => {
381
+ await Promise.resolve(onApply?.(patch))
382
+ }
383
+
384
+ const buildRemoteDeploymentPatch = (overrides?: Partial<NonNullable<ApplyPatch['deployment']>>): NonNullable<ApplyPatch['deployment']> => ({
385
+ method: overrides?.method || (remoteTemplate === 'docker' ? 'bundle' : 'bundle'),
386
+ provider: overrides?.provider || (remoteTemplate === 'docker' ? remoteProvider : remoteTemplate),
387
+ remoteTarget: overrides?.remoteTarget || remoteTemplate,
388
+ useCase,
389
+ exposure,
390
+ sshHost: sshHost.trim() || null,
391
+ sshUser: sshUser.trim() || null,
392
+ sshPort,
393
+ sshKeyPath: sshKeyPath.trim() || null,
394
+ sshTargetDir: sshTargetDir.trim() || null,
395
+ lastDeployAt: overrides?.lastDeployAt ?? null,
396
+ lastDeployAction: overrides?.lastDeployAction ?? null,
397
+ lastDeploySummary: overrides?.lastDeploySummary ?? null,
398
+ lastDeployProcessId: overrides?.lastDeployProcessId ?? null,
399
+ lastVerifiedAt: overrides?.lastVerifiedAt ?? null,
400
+ lastVerifiedOk: overrides?.lastVerifiedOk ?? null,
401
+ lastVerifiedMessage: overrides?.lastVerifiedMessage ?? null,
402
+ lastBackupPath: overrides?.lastBackupPath ?? deployment?.lastBackupPath ?? null,
403
+ })
404
+
262
405
  const handleStartLocal = async () => {
263
406
  setLoading('starting-local')
264
407
  setError('')
408
+ setVerifySummary('')
265
409
  try {
266
410
  const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
267
411
  action: 'start-local',
@@ -271,12 +415,36 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
271
415
  if (!result.ok || !result.local) throw new Error(result.error || 'Local OpenClaw deploy failed.')
272
416
  setLocalStatus(result.local)
273
417
  if (result.token) setDeployToken(result.token)
274
- await Promise.resolve(onApply?.({
418
+ const verify = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
419
+ action: 'verify',
420
+ endpoint: result.local.endpoint,
421
+ token: result.token || deployToken || undefined,
422
+ }).catch(() => ({ ok: false } as DeployActionResponse))
423
+ if (verify.verify) {
424
+ setVerifySummary(verify.verify.ok
425
+ ? `Verified ${verify.verify.endpoint} with ${verify.verify.models.length} model${verify.verify.models.length === 1 ? '' : 's'}.`
426
+ : (verify.verify.error || verify.verify.hint || 'Verification failed.'))
427
+ }
428
+ await applyDeploymentPatch({
275
429
  endpoint: result.local.endpoint,
276
430
  token: result.token || deployToken,
277
431
  name: suggestedName || `Local OpenClaw ${result.local.port}`,
278
432
  notes: 'Managed by SwarmClaw local deploy.',
279
- }))
433
+ deployment: {
434
+ method: 'local',
435
+ provider: 'local',
436
+ useCase: 'local-dev',
437
+ exposure: 'private-lan',
438
+ lastDeployAt: Date.now(),
439
+ lastDeployAction: 'start-local',
440
+ lastDeploySummary: 'Managed local OpenClaw runtime started from SwarmClaw.',
441
+ lastVerifiedAt: verify.verify ? Date.now() : null,
442
+ lastVerifiedOk: verify.verify?.ok ?? null,
443
+ lastVerifiedMessage: verify.verify
444
+ ? (verify.verify.error || verify.verify.hint || 'Verified successfully.')
445
+ : null,
446
+ },
447
+ })
280
448
  showMessage('Local OpenClaw started and applied to this connection.')
281
449
  } catch (err: unknown) {
282
450
  setError(err instanceof Error ? err.message : 'Local OpenClaw deploy failed.')
@@ -300,6 +468,41 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
300
468
  }
301
469
  }
302
470
 
471
+ const handleRestartLocal = async () => {
472
+ setLoading('restarting-local')
473
+ setError('')
474
+ try {
475
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
476
+ action: 'restart-local',
477
+ port: localPort,
478
+ token: deployToken.trim() || undefined,
479
+ })
480
+ if (!result.ok || !result.local) throw new Error(result.error || 'Failed to restart local OpenClaw.')
481
+ setLocalStatus(result.local)
482
+ if (result.token) setDeployToken(result.token)
483
+ await applyDeploymentPatch({
484
+ endpoint: result.local.endpoint,
485
+ token: result.token || deployToken,
486
+ name: suggestedName || `Local OpenClaw ${result.local.port}`,
487
+ notes: 'Managed by SwarmClaw local deploy.',
488
+ deployment: {
489
+ method: 'local',
490
+ provider: 'local',
491
+ useCase: 'local-dev',
492
+ exposure: 'private-lan',
493
+ lastDeployAt: Date.now(),
494
+ lastDeployAction: 'restart-local',
495
+ lastDeploySummary: 'Managed local OpenClaw runtime restarted from SwarmClaw.',
496
+ },
497
+ })
498
+ showMessage('Restarted managed local OpenClaw runtime.')
499
+ } catch (err: unknown) {
500
+ setError(err instanceof Error ? err.message : 'Failed to restart local OpenClaw.')
501
+ } finally {
502
+ setLoading('idle')
503
+ }
504
+ }
505
+
303
506
  const handleGenerateBundle = async () => {
304
507
  setLoading('generating-bundle')
305
508
  setError('')
@@ -311,17 +514,26 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
311
514
  scheme: remoteScheme,
312
515
  token: deployToken.trim() || undefined,
313
516
  provider: remoteProvider,
517
+ useCase,
518
+ exposure,
314
519
  })
315
520
  if (!result.ok || !result.bundle) throw new Error(result.error || 'Failed to generate OpenClaw deploy bundle.')
316
521
  setBundle(result.bundle)
317
522
  setBundleFile(result.bundle.files[0]?.name || '')
318
523
  setDeployToken(result.bundle.token)
319
- await Promise.resolve(onApply?.({
524
+ await applyDeploymentPatch({
320
525
  endpoint: result.bundle.endpoint,
321
526
  token: result.bundle.token,
322
527
  name: suggestedName || result.bundle.title,
323
528
  notes: `OpenClaw remote deploy template: ${result.bundle.title}`,
324
- }))
529
+ deployment: buildRemoteDeploymentPatch({
530
+ method: 'bundle',
531
+ provider: remoteTemplate === 'docker' ? remoteProvider : remoteTemplate,
532
+ remoteTarget: remoteTemplate,
533
+ lastDeployAction: 'bundle',
534
+ lastDeploySummary: `Generated ${result.bundle.title} from SwarmClaw.`,
535
+ }),
536
+ })
325
537
  showMessage('Remote bundle generated and applied to this connection.')
326
538
  } catch (err: unknown) {
327
539
  setError(err instanceof Error ? err.message : 'Failed to generate OpenClaw deploy bundle.')
@@ -330,6 +542,140 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
330
542
  }
331
543
  }
332
544
 
545
+ const handleVerify = async (overrideEndpoint?: string | null, overrideToken?: string | null) => {
546
+ setLoading('verifying')
547
+ setError('')
548
+ try {
549
+ const endpointToVerify = (overrideEndpoint || bundle?.endpoint || endpoint || '').trim()
550
+ const tokenToVerify = (overrideToken || deployToken || '').trim()
551
+ if (!endpointToVerify) throw new Error('Set an OpenClaw endpoint before verifying.')
552
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
553
+ action: 'verify',
554
+ endpoint: endpointToVerify,
555
+ token: tokenToVerify || undefined,
556
+ })
557
+ if (!result.verify) throw new Error(result.error || 'Verification failed.')
558
+ const summary = result.verify.ok
559
+ ? `Verified ${result.verify.endpoint} with ${result.verify.models.length} model${result.verify.models.length === 1 ? '' : 's'}.`
560
+ : (result.verify.error || result.verify.hint || 'Verification failed.')
561
+ setVerifySummary(summary)
562
+ await applyDeploymentPatch({
563
+ endpoint: result.verify.endpoint,
564
+ token: tokenToVerify || undefined,
565
+ deployment: buildRemoteDeploymentPatch({
566
+ method: deployment?.method || (isLocalEndpoint(result.verify.endpoint) ? 'local' : 'bundle'),
567
+ lastVerifiedAt: Date.now(),
568
+ lastVerifiedOk: result.verify.ok,
569
+ lastVerifiedMessage: summary,
570
+ }),
571
+ })
572
+ showMessage(result.verify.ok ? 'OpenClaw verification passed.' : summary)
573
+ } catch (err: unknown) {
574
+ setError(err instanceof Error ? err.message : 'Verification failed.')
575
+ } finally {
576
+ setLoading('idle')
577
+ }
578
+ }
579
+
580
+ const handleSshDeploy = async () => {
581
+ setLoading('ssh-deploy')
582
+ setError('')
583
+ setVerifySummary('')
584
+ try {
585
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
586
+ action: 'ssh-deploy',
587
+ template: remoteTemplate,
588
+ target: remoteTarget.trim(),
589
+ scheme: remoteScheme,
590
+ token: deployToken.trim() || undefined,
591
+ provider: remoteProvider,
592
+ useCase,
593
+ exposure,
594
+ ssh: {
595
+ host: sshHost.trim(),
596
+ user: sshUser.trim() || undefined,
597
+ port: sshPort,
598
+ keyPath: sshKeyPath.trim() || undefined,
599
+ targetDir: sshTargetDir.trim() || undefined,
600
+ },
601
+ })
602
+ if (!result.ok) throw new Error(result.error || 'Failed to start SSH deploy.')
603
+ if (result.bundle) {
604
+ setBundle(result.bundle)
605
+ setBundleFile(result.bundle.files[0]?.name || '')
606
+ }
607
+ if (result.token) setDeployToken(result.token)
608
+ setRemoteStatus(result.remote || null)
609
+ setCommandPreview(result.commandPreview || '')
610
+ await applyDeploymentPatch({
611
+ endpoint: result.bundle?.endpoint || endpoint || undefined,
612
+ token: result.token || deployToken,
613
+ name: suggestedName || result.bundle?.title || `SSH OpenClaw ${sshHost.trim()}`,
614
+ notes: `Official OpenClaw deployed over SSH to ${sshHost.trim()}.`,
615
+ deployment: buildRemoteDeploymentPatch({
616
+ method: 'ssh',
617
+ provider: remoteProvider,
618
+ lastDeployAt: Date.now(),
619
+ lastDeployAction: 'ssh-deploy',
620
+ lastDeploySummary: result.summary || `Started SSH deploy to ${sshHost.trim()}.`,
621
+ lastDeployProcessId: result.processId || null,
622
+ }),
623
+ })
624
+ showMessage(result.summary || 'Started SSH deploy.')
625
+ } catch (err: unknown) {
626
+ setError(err instanceof Error ? err.message : 'Failed to start SSH deploy.')
627
+ } finally {
628
+ setLoading('idle')
629
+ }
630
+ }
631
+
632
+ const handleRemoteLifecycle = async (
633
+ action: 'remote-start' | 'remote-stop' | 'remote-restart' | 'remote-upgrade' | 'remote-backup' | 'remote-restore' | 'remote-rotate-token',
634
+ ) => {
635
+ setLoading('remote-action')
636
+ setError('')
637
+ try {
638
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
639
+ action,
640
+ token: action === 'remote-rotate-token' ? (deployToken.trim() || undefined) : undefined,
641
+ backupPath: action === 'remote-restore' ? (restoreBackupPath.trim() || undefined) : undefined,
642
+ ssh: {
643
+ host: sshHost.trim(),
644
+ user: sshUser.trim() || undefined,
645
+ port: sshPort,
646
+ keyPath: sshKeyPath.trim() || undefined,
647
+ targetDir: sshTargetDir.trim() || undefined,
648
+ },
649
+ })
650
+ if (!result.ok) throw new Error(result.error || 'Remote lifecycle action failed.')
651
+ if (result.token) setDeployToken(result.token)
652
+ setRemoteStatus(result.remote || null)
653
+ setCommandPreview(result.commandPreview || '')
654
+ if (result.remote?.lastBackupPath) {
655
+ setRestoreBackupPath(result.remote.lastBackupPath)
656
+ }
657
+ await applyDeploymentPatch({
658
+ token: result.token || undefined,
659
+ deployment: buildRemoteDeploymentPatch({
660
+ method: 'ssh',
661
+ provider: remoteProvider,
662
+ lastDeployAt: Date.now(),
663
+ lastDeployAction: action,
664
+ lastDeploySummary: result.summary || action,
665
+ lastDeployProcessId: result.processId || null,
666
+ lastBackupPath: action === 'remote-backup' || action === 'remote-restore'
667
+ ? (result.remote?.lastBackupPath || restoreBackupPath.trim() || null)
668
+ : undefined,
669
+ }),
670
+ })
671
+ showMessage(result.summary || 'Remote lifecycle action started.')
672
+ } catch (err: unknown) {
673
+ setError(err instanceof Error ? err.message : 'Remote lifecycle action failed.')
674
+ } finally {
675
+ setLoading('idle')
676
+ }
677
+ }
678
+
333
679
  return (
334
680
  <div className={`rounded-[16px] border border-white/[0.08] bg-surface ${compact ? 'p-4' : 'p-5'} text-left`}>
335
681
  <div className="flex flex-wrap items-start justify-between gap-3">
@@ -405,6 +751,26 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
405
751
  >
406
752
  {loading === 'starting-local' ? 'Starting…' : 'Deploy on This Host'}
407
753
  </button>
754
+ {localStatus?.running && (
755
+ <button
756
+ type="button"
757
+ onClick={handleRestartLocal}
758
+ disabled={loading !== 'idle'}
759
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
760
+ >
761
+ {loading === 'restarting-local' ? 'Restarting…' : 'Restart'}
762
+ </button>
763
+ )}
764
+ {localStatus?.running && (
765
+ <button
766
+ type="button"
767
+ onClick={() => void handleVerify(localStatus.endpoint, deployToken || localStatus.token)}
768
+ disabled={loading !== 'idle'}
769
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
770
+ >
771
+ {loading === 'verifying' ? 'Verifying…' : 'Verify'}
772
+ </button>
773
+ )}
408
774
  {localStatus?.running && (
409
775
  <button
410
776
  type="button"
@@ -453,6 +819,12 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
453
819
  For a durable OS service, use the generated install command after the quick deploy works.
454
820
  </div>
455
821
  </div>
822
+ {verifySummary && (
823
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2 md:col-span-2">
824
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Verification</div>
825
+ <div className="mt-1 text-[12px] text-text-2 leading-relaxed">{verifySummary}</div>
826
+ </div>
827
+ )}
456
828
  </div>
457
829
  )}
458
830
 
@@ -509,7 +881,7 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
509
881
  </div>
510
882
 
511
883
  {remoteTemplate === 'docker' && (
512
- <div>
884
+ <div className="space-y-4">
513
885
  <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">VPS provider</div>
514
886
  <div className="grid gap-2 md:grid-cols-3 xl:grid-cols-5">
515
887
  {PROVIDER_OPTIONS.map((option) => (
@@ -527,6 +899,87 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
527
899
  <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
528
900
  SwarmClaw generates a provider-specific runbook plus a cloud-init quickstart, but the runtime itself still comes from the official OpenClaw Docker image.
529
901
  </p>
902
+
903
+ <div>
904
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Use case preset</div>
905
+ <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
906
+ {USE_CASE_OPTIONS.map((option) => (
907
+ <button
908
+ key={option.id}
909
+ type="button"
910
+ onClick={() => setUseCase(option.id)}
911
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(useCase === option.id)}`}
912
+ >
913
+ <div className="text-[13px] font-700">{option.label}</div>
914
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
915
+ </button>
916
+ ))}
917
+ </div>
918
+ </div>
919
+
920
+ <div>
921
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Safe exposure preset</div>
922
+ <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
923
+ {EXPOSURE_OPTIONS.map((option) => (
924
+ <button
925
+ key={option.id}
926
+ type="button"
927
+ onClick={() => setExposure(option.id)}
928
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(exposure === option.id)}`}
929
+ >
930
+ <div className="text-[13px] font-700">{option.label}</div>
931
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
932
+ </button>
933
+ ))}
934
+ </div>
935
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
936
+ Smart Deploy keeps the OpenClaw runtime official-only and generates the surrounding exposure config in-house so operators do not need third-party deploy services.
937
+ </p>
938
+ </div>
939
+
940
+ <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-4">
941
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-3">In-House SSH Deploy</div>
942
+ <div className="grid gap-3 md:grid-cols-2">
943
+ <input
944
+ type="text"
945
+ value={sshHost}
946
+ onChange={(e) => setSshHost(e.target.value)}
947
+ placeholder="gateway.your-vps.com"
948
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
949
+ />
950
+ <input
951
+ type="text"
952
+ value={sshUser}
953
+ onChange={(e) => setSshUser(e.target.value)}
954
+ placeholder="root"
955
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
956
+ />
957
+ <input
958
+ type="number"
959
+ value={sshPort}
960
+ onChange={(e) => setSshPort(Number.parseInt(e.target.value, 10) || 22)}
961
+ placeholder="22"
962
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
963
+ />
964
+ <input
965
+ type="text"
966
+ value={sshKeyPath}
967
+ onChange={(e) => setSshKeyPath(e.target.value)}
968
+ placeholder="~/.ssh/id_ed25519"
969
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
970
+ />
971
+ </div>
972
+ <input
973
+ type="text"
974
+ value={sshTargetDir}
975
+ onChange={(e) => setSshTargetDir(e.target.value)}
976
+ placeholder="/opt/openclaw"
977
+ className="mt-3 w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
978
+ />
979
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
980
+ SwarmClaw will push the generated official-image bundle to this host over SSH and run the bootstrap there. This stays inside your own infra and does not rely on outside OpenClaw deployers.
981
+ </p>
982
+ </div>
530
983
  </div>
531
984
  )}
532
985
 
@@ -539,6 +992,24 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
539
992
  >
540
993
  {loading === 'generating-bundle' ? 'Generating…' : 'Generate Bundle'}
541
994
  </button>
995
+ {remoteTemplate === 'docker' && (
996
+ <button
997
+ type="button"
998
+ onClick={handleSshDeploy}
999
+ disabled={loading !== 'idle' || !sshHost.trim()}
1000
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1001
+ >
1002
+ {loading === 'ssh-deploy' ? 'Deploying…' : 'Deploy Over SSH'}
1003
+ </button>
1004
+ )}
1005
+ <button
1006
+ type="button"
1007
+ onClick={() => void handleVerify(bundle?.endpoint || endpoint || remoteTarget, deployToken)}
1008
+ disabled={loading !== 'idle' || (!bundle?.endpoint && !endpoint && !remoteTarget.trim())}
1009
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1010
+ >
1011
+ {loading === 'verifying' ? 'Verifying…' : 'Verify Endpoint'}
1012
+ </button>
542
1013
  {bundle && (
543
1014
  <button
544
1015
  type="button"
@@ -550,6 +1021,117 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
550
1021
  )}
551
1022
  </div>
552
1023
 
1024
+ {remoteTemplate === 'docker' && sshHost.trim() && (
1025
+ <div className="space-y-3">
1026
+ <div className="flex flex-wrap gap-2">
1027
+ <button
1028
+ type="button"
1029
+ onClick={() => void handleRemoteLifecycle('remote-start')}
1030
+ disabled={loading !== 'idle'}
1031
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1032
+ >
1033
+ Start
1034
+ </button>
1035
+ <button
1036
+ type="button"
1037
+ onClick={() => void handleRemoteLifecycle('remote-restart')}
1038
+ disabled={loading !== 'idle'}
1039
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1040
+ >
1041
+ Restart
1042
+ </button>
1043
+ <button
1044
+ type="button"
1045
+ onClick={() => void handleRemoteLifecycle('remote-upgrade')}
1046
+ disabled={loading !== 'idle'}
1047
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1048
+ >
1049
+ Upgrade
1050
+ </button>
1051
+ <button
1052
+ type="button"
1053
+ onClick={() => void handleRemoteLifecycle('remote-backup')}
1054
+ disabled={loading !== 'idle'}
1055
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1056
+ >
1057
+ Backup
1058
+ </button>
1059
+ <button
1060
+ type="button"
1061
+ onClick={() => void handleRemoteLifecycle('remote-rotate-token')}
1062
+ disabled={loading !== 'idle'}
1063
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1064
+ >
1065
+ Rotate token
1066
+ </button>
1067
+ <button
1068
+ type="button"
1069
+ onClick={() => void handleRemoteLifecycle('remote-stop')}
1070
+ disabled={loading !== 'idle'}
1071
+ className="rounded-[10px] border border-red-400/20 bg-red-400/[0.06] px-3 py-1.5 text-[11px] font-700 text-red-300 cursor-pointer hover:bg-red-400/[0.1] transition-all disabled:opacity-40"
1072
+ >
1073
+ Stop
1074
+ </button>
1075
+ </div>
1076
+ <div className="grid gap-2 md:grid-cols-[1fr_auto]">
1077
+ <input
1078
+ type="text"
1079
+ value={restoreBackupPath}
1080
+ onChange={(e) => setRestoreBackupPath(e.target.value)}
1081
+ placeholder="/opt/openclaw/backups/openclaw-backup-123456789.tgz"
1082
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
1083
+ />
1084
+ <button
1085
+ type="button"
1086
+ onClick={() => void handleRemoteLifecycle('remote-restore')}
1087
+ disabled={loading !== 'idle' || !restoreBackupPath.trim()}
1088
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
1089
+ >
1090
+ Restore backup
1091
+ </button>
1092
+ </div>
1093
+ </div>
1094
+ )}
1095
+
1096
+ {(verifySummary || commandPreview || remoteStatus) && (
1097
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
1098
+ {verifySummary && (
1099
+ <div className="text-[12px] text-text-2 leading-relaxed">{verifySummary}</div>
1100
+ )}
1101
+ {remoteStatus && (
1102
+ <div className="mt-3 grid gap-3 md:grid-cols-3">
1103
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1104
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Remote action</div>
1105
+ <div className="mt-1 text-[12px] text-text-2">{remoteStatus.action || remoteStatus.lastSummary || 'Idle'}</div>
1106
+ </div>
1107
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1108
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Target</div>
1109
+ <div className="mt-1 text-[12px] text-text-2 font-mono break-all">{remoteStatus.target || sshHost || 'n/a'}</div>
1110
+ </div>
1111
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
1112
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Status</div>
1113
+ <div className="mt-1 text-[12px] text-text-2">{remoteStatus.status}</div>
1114
+ </div>
1115
+ </div>
1116
+ )}
1117
+ {(commandPreview || remoteStatus?.lastCommandPreview) && (
1118
+ <pre className="mt-3 overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-3 text-[11px] text-text-2/80 whitespace-pre-wrap">
1119
+ {commandPreview || remoteStatus?.lastCommandPreview}
1120
+ </pre>
1121
+ )}
1122
+ {!!remoteStatus?.tail && (
1123
+ <pre className="mt-3 overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-3 text-[11px] text-text-2/80 whitespace-pre-wrap">
1124
+ {remoteStatus.tail}
1125
+ </pre>
1126
+ )}
1127
+ {remoteStatus?.lastBackupPath && (
1128
+ <div className="mt-3 text-[12px] text-text-3">
1129
+ Last backup path: <code className="text-text-2">{remoteStatus.lastBackupPath}</code>
1130
+ </div>
1131
+ )}
1132
+ </div>
1133
+ )}
1134
+
553
1135
  {bundle && (
554
1136
  <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
555
1137
  <div className="flex flex-wrap items-start justify-between gap-3">
@@ -612,13 +1194,13 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
612
1194
  </div>
613
1195
  )}
614
1196
 
615
- {(message || error || localStatus?.lastError) && (
1197
+ {(message || error || localStatus?.lastError || remoteStatus?.lastError) && (
616
1198
  <div className={`mt-4 rounded-[12px] border px-3 py-2 text-[12px] ${
617
- error || localStatus?.lastError
1199
+ error || localStatus?.lastError || remoteStatus?.lastError
618
1200
  ? 'border-red-400/20 bg-red-400/[0.06] text-red-200'
619
1201
  : 'border-emerald-500/20 bg-emerald-500/[0.06] text-emerald-200'
620
1202
  }`}>
621
- {error || localStatus?.lastError || message}
1203
+ {error || localStatus?.lastError || remoteStatus?.lastError || message}
622
1204
  </div>
623
1205
  )}
624
1206
  </div>