@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
@@ -54,6 +54,9 @@ interface ConfiguredProvider {
54
54
  endpoint: string | null
55
55
  defaultModel: string
56
56
  gatewayProfileId: string | null
57
+ notes?: string | null
58
+ tags?: string[]
59
+ deployment?: GatewayProfile['deployment'] | null
57
60
  }
58
61
 
59
62
  interface StarterDraftAgent {
@@ -88,6 +91,20 @@ const CONNECTOR_ICONS = [
88
91
  { name: 'Telegram', icon: 'T' },
89
92
  { name: 'WhatsApp', icon: 'W' },
90
93
  ]
94
+ const OPENCLAW_USE_CASE_LABELS: Record<NonNullable<NonNullable<GatewayProfile['deployment']>['useCase']>, string> = {
95
+ 'local-dev': 'Local Dev',
96
+ 'single-vps': 'Single VPS',
97
+ 'private-tailnet': 'Private Tailnet',
98
+ 'browser-heavy': 'Browser Heavy',
99
+ 'team-control': 'Team Control',
100
+ }
101
+ const OPENCLAW_EXPOSURE_LABELS: Record<NonNullable<NonNullable<GatewayProfile['deployment']>['exposure']>, string> = {
102
+ 'private-lan': 'Private LAN',
103
+ tailscale: 'Tailscale',
104
+ caddy: 'Caddy',
105
+ nginx: 'Nginx',
106
+ 'ssh-tunnel': 'SSH Tunnel',
107
+ }
91
108
 
92
109
  function stepIndex(step: SetupStep): number {
93
110
  if (step === 'connect') return STEP_ORDER.indexOf('providers')
@@ -224,6 +241,12 @@ function ConfiguredProviderChips({ providers }: { providers: ConfiguredProvider[
224
241
  {cp.provider === 'openclaw' && formatEndpointHost(cp.endpoint)
225
242
  ? `· ${formatEndpointHost(cp.endpoint)}`
226
243
  : ''}
244
+ {cp.provider === 'openclaw' && cp.deployment?.useCase
245
+ ? ` · ${OPENCLAW_USE_CASE_LABELS[cp.deployment.useCase]}`
246
+ : ''}
247
+ {cp.provider === 'openclaw' && cp.deployment?.exposure
248
+ ? ` · ${OPENCLAW_EXPOSURE_LABELS[cp.deployment.exposure]}`
249
+ : ''}
227
250
  {cp.defaultModel ? ` · ${cp.defaultModel}` : ''}
228
251
  </span>
229
252
  </span>
@@ -332,6 +355,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
332
355
  const [endpoint, setEndpoint] = useState('')
333
356
  const [apiKey, setApiKey] = useState('')
334
357
  const [credentialId, setCredentialId] = useState<string | null>(null)
358
+ const [providerNotes, setProviderNotes] = useState('')
359
+ const [providerTags, setProviderTags] = useState<string[]>([])
360
+ const [providerDeployment, setProviderDeployment] = useState<GatewayProfile['deployment'] | null>(null)
335
361
  const [checkState, setCheckState] = useState<CheckState>('idle')
336
362
  const [checkMessage, setCheckMessage] = useState('')
337
363
  const [checkErrorCode, setCheckErrorCode] = useState<string | null>(null)
@@ -382,6 +408,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
382
408
  setEndpoint('')
383
409
  setApiKey('')
384
410
  setCredentialId(null)
411
+ setProviderNotes('')
412
+ setProviderTags([])
413
+ setProviderDeployment(null)
385
414
  setCheckState('idle')
386
415
  setCheckMessage('')
387
416
  setCheckErrorCode(null)
@@ -432,6 +461,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
432
461
  setEndpoint(meta?.defaultEndpoint || '')
433
462
  setApiKey('')
434
463
  setCredentialId(null)
464
+ setProviderNotes('')
465
+ setProviderTags([])
466
+ setProviderDeployment(null)
435
467
  setCheckState('idle')
436
468
  setCheckMessage('')
437
469
  setCheckErrorCode(null)
@@ -445,6 +477,8 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
445
477
  endpoint?: string
446
478
  token?: string
447
479
  name?: string
480
+ notes?: string
481
+ deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null
448
482
  }) => {
449
483
  if (patch.endpoint) {
450
484
  setEndpoint(patch.endpoint)
@@ -456,6 +490,22 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
456
490
  if (patch.name && (!providerLabel.trim() || providerLabel.trim() === (selectedProvider?.name || ''))) {
457
491
  setProviderLabel(patch.name)
458
492
  }
493
+ if (patch.notes) {
494
+ setProviderNotes(patch.notes)
495
+ }
496
+ if (patch.deployment) {
497
+ const nextDeployment = patch.deployment as GatewayProfile['deployment']
498
+ setProviderDeployment((current) => ({
499
+ ...(current || {}),
500
+ ...(nextDeployment || {}),
501
+ }))
502
+ setProviderTags((current) => Array.from(new Set([
503
+ ...current,
504
+ 'onboarding',
505
+ ...(nextDeployment?.useCase ? [nextDeployment.useCase] : []),
506
+ ...(nextDeployment?.exposure ? [nextDeployment.exposure] : []),
507
+ ])))
508
+ }
459
509
  setCheckState('idle')
460
510
  setCheckMessage('')
461
511
  setCheckErrorCode(null)
@@ -552,6 +602,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
552
602
  endpoint: supportsEndpoint ? (endpoint.trim() || selectedProvider.defaultEndpoint || null) : null,
553
603
  defaultModel: providerSuggestedModel || getDefaultModelForProvider(provider),
554
604
  gatewayProfileId: null,
605
+ notes: providerNotes.trim() || null,
606
+ tags: providerTags,
607
+ deployment: providerDeployment,
555
608
  }
556
609
 
557
610
  const nextConfigured = [...configuredProviders, configuredProvider]
@@ -645,8 +698,13 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
645
698
  name: configuredProvider.name,
646
699
  endpoint: normalizedEndpoint,
647
700
  credentialId: configuredProvider.credentialId || null,
648
- tags: ['onboarding'],
649
- notes: `Created during setup for ${configuredProvider.name}.`,
701
+ tags: Array.from(new Set([
702
+ 'onboarding',
703
+ ...(configuredProvider.tags || []),
704
+ ])),
705
+ notes: configuredProvider.notes || `Created during setup for ${configuredProvider.name}.`,
706
+ deployment: configuredProvider.deployment || null,
707
+ status: configuredProvider.deployment?.lastVerifiedOk ? 'healthy' : 'pending',
650
708
  isDefault: shouldCreateDefault,
651
709
  })
652
710
  gatewayProfileIdsByProviderConfig.set(configuredProvider.id, createdGateway.id)
@@ -1084,6 +1142,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1084
1142
  <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1085
1143
  If you only have a WebSocket gateway URL, you can still paste it here. SwarmClaw will normalize it for agent chat.
1086
1144
  </p>
1145
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1146
+ Safer remote defaults: use <code className="text-text-2">private-tailnet</code> with <code className="text-text-2">tailscale</code> or <code className="text-text-2">ssh-tunnel</code> unless you intentionally want public HTTPS ingress.
1147
+ </p>
1087
1148
  </div>
1088
1149
  <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-3">
1089
1150
  <div className="text-[12px] uppercase tracking-[0.08em] text-text-3 mb-2">Safe defaults</div>
@@ -1093,6 +1154,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1093
1154
  <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1094
1155
  Local quickstart uses the bundled official OpenClaw CLI. Remote quickstart uses the official OpenClaw Docker image or the official repo for managed hosts.
1095
1156
  </p>
1157
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1158
+ Choose <code className="text-text-2">local-dev</code> for one-machine setup, <code className="text-text-2">single-vps</code> for most hosted installs, or <code className="text-text-2">private-tailnet</code> when the gateway should stay private.
1159
+ </p>
1096
1160
  </div>
1097
1161
  </div>
1098
1162
 
@@ -1104,6 +1168,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1104
1168
  <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1105
1169
  Current target: <span className="text-text-2">{openClawEndpointHost || 'localhost:18789'}</span>{openClawLocal ? ' · local route' : ' · remote route'}
1106
1170
  </p>
1171
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1172
+ Use <code className="text-text-2">caddy</code> or <code className="text-text-2">nginx</code> only when you intentionally want HTTPS/public ingress managed on the gateway side.
1173
+ </p>
1107
1174
  </div>
1108
1175
  </div>
1109
1176
  )}
@@ -1314,6 +1381,12 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1314
1381
  {configuredProvider.provider === 'openclaw' && formatEndpointHost(configuredProvider.endpoint)
1315
1382
  ? ` · ${formatEndpointHost(configuredProvider.endpoint)}`
1316
1383
  : ''}
1384
+ {configuredProvider.provider === 'openclaw' && configuredProvider.deployment?.useCase
1385
+ ? ` · ${OPENCLAW_USE_CASE_LABELS[configuredProvider.deployment.useCase]}`
1386
+ : ''}
1387
+ {configuredProvider.provider === 'openclaw' && configuredProvider.deployment?.exposure
1388
+ ? ` · ${OPENCLAW_EXPOSURE_LABELS[configuredProvider.deployment.exposure]}`
1389
+ : ''}
1317
1390
  {configuredProvider.defaultModel ? ` · ${configuredProvider.defaultModel}` : ''}
1318
1391
  </option>
1319
1392
  ))}
@@ -136,10 +136,12 @@ export function ChatArea() {
136
136
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
137
137
  setMessagesLoading(false)
138
138
  })
139
- // If server reports session is still active, show streaming state
140
- if (session?.active) {
139
+
140
+ const sessionAtLoad = useAppStore.getState().sessions[requestedSessionId]
141
+ if (sessionAtLoad?.active) {
141
142
  useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
142
143
  }
144
+
143
145
  // Refresh active state from server so returning to a session restores typing indicator.
144
146
  loadSessions().then(() => {
145
147
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
@@ -148,6 +150,7 @@ export function ChatArea() {
148
150
  useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
149
151
  }
150
152
  }).catch((err) => console.error('Failed to refresh messages:', err))
153
+
151
154
  devServer(requestedSessionId, 'status').then((r) => {
152
155
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
153
156
  setDevServer(r.running ? r : null)
@@ -155,23 +158,31 @@ export function ChatArea() {
155
158
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
156
159
  setDevServer(null)
157
160
  })
158
- // Check browser status
159
- if (sessionHasBrowserPlugin) {
160
- checkBrowser(requestedSessionId).then((r) => {
161
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
162
- setBrowserActive(r.active)
163
- }).catch((err) => {
164
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
165
- console.error('Browser check failed:', err)
166
- setBrowserActive(false)
167
- })
168
- } else {
161
+
162
+ return () => {
163
+ cancelled = true
164
+ }
165
+ }, [loadSessions, sessionId, setDevServer, setMessages])
166
+
167
+ useEffect(() => {
168
+ if (!sessionId) return
169
+ let cancelled = false
170
+ if (!sessionHasBrowserPlugin) {
169
171
  setBrowserActive(false)
172
+ return
170
173
  }
174
+ checkBrowser(sessionId).then((r) => {
175
+ if (cancelled || useAppStore.getState().currentSessionId !== sessionId) return
176
+ setBrowserActive(r.active)
177
+ }).catch((err) => {
178
+ if (cancelled || useAppStore.getState().currentSessionId !== sessionId) return
179
+ console.error('Browser check failed:', err)
180
+ setBrowserActive(false)
181
+ })
171
182
  return () => {
172
183
  cancelled = true
173
184
  }
174
- }, [loadSessions, session?.active, sessionHasBrowserPlugin, sessionId, setDevServer, setMessages])
185
+ }, [sessionHasBrowserPlugin, sessionId])
175
186
 
176
187
  // Auto-poll messages for sessions that are actively running on the server
177
188
  const isServerActive = session?.active === true
@@ -216,10 +227,16 @@ export function ChatArea() {
216
227
  shouldPollMessages ? 2000 : undefined,
217
228
  )
218
229
 
219
- // When server-active flag drops, stop the streaming indicator
230
+ // Keep the local typing indicator aligned with the server's active state
220
231
  useEffect(() => {
221
232
  if (!sessionId) return
222
233
  const state = useChatStore.getState()
234
+ if (isServerActive) {
235
+ if (!state.streaming && !state.streamText) {
236
+ useChatStore.setState({ streaming: true, streamingSessionId: sessionId, streamText: '' })
237
+ }
238
+ return
239
+ }
223
240
  if (
224
241
  !isServerActive
225
242
  && state.streaming
@@ -230,7 +247,7 @@ export function ChatArea() {
230
247
  fetchMessages(sessionId).then(setMessages).catch(() => {})
231
248
  useChatStore.setState({ streaming: false, streamingSessionId: null, streamText: '' })
232
249
  }
233
- }, [isServerActive, sessionId])
250
+ }, [isServerActive, sessionId, setMessages])
234
251
 
235
252
  // Poll browser status while session has browser tools
236
253
  const hasBrowserTool = session?.plugins?.includes('browser')
@@ -255,7 +272,7 @@ export function ChatArea() {
255
272
  if (!sessionId) return
256
273
  await devServer(sessionId, 'stop')
257
274
  setDevServer(null)
258
- }, [sessionId])
275
+ }, [sessionId, setDevServer])
259
276
 
260
277
  const handleClear = useCallback(async () => {
261
278
  setConfirmClear(false)
@@ -263,7 +280,7 @@ export function ChatArea() {
263
280
  await clearMessages(sessionId)
264
281
  setMessages([])
265
282
  loadSessions()
266
- }, [sessionId])
283
+ }, [loadSessions, sessionId, setMessages])
267
284
 
268
285
  const handleDelete = useCallback(async () => {
269
286
  setConfirmDelete(false)
@@ -271,7 +288,7 @@ export function ChatArea() {
271
288
  await deleteChat(sessionId)
272
289
  removeSessionFromStore(sessionId)
273
290
  setCurrentSession(null)
274
- }, [sessionId])
291
+ }, [removeSessionFromStore, sessionId, setCurrentSession])
275
292
 
276
293
  const handlePrompt = useCallback((text: string) => {
277
294
  sendMessage(text)
@@ -280,12 +280,16 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
280
280
  const fromDelegateOpenCode = session.delegateResumeIds?.opencode
281
281
  ? { label: 'OpenCode', id: session.delegateResumeIds.opencode, command: `opencode run \"<task>\" --session ${session.delegateResumeIds.opencode}` }
282
282
  : null
283
+ const fromDelegateGemini = session.delegateResumeIds?.gemini
284
+ ? { label: 'Gemini', id: session.delegateResumeIds.gemini, command: `gemini --resume ${session.delegateResumeIds.gemini} --prompt \"<task>\"` }
285
+ : null
283
286
  return fromSessionClaude
284
287
  || fromSessionCodex
285
288
  || fromSessionOpenCode
286
289
  || fromDelegateClaude
287
290
  || fromDelegateCodex
288
291
  || fromDelegateOpenCode
292
+ || fromDelegateGemini
289
293
  || null
290
294
  }, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.delegateResumeIds])
291
295
 
@@ -23,5 +23,18 @@ describe('parseTaskCompletion', () => {
23
23
  assert.equal(parsed?.reportPath, 'data/task-reports/abc12345.md')
24
24
  assert.equal(parsed?.workingDir, '/tmp/work')
25
25
  })
26
- })
27
26
 
27
+ it('captures Gemini resume lines from task completion payloads', () => {
28
+ const text = [
29
+ 'Task completed: **[Ship follow-up](#task:task-gemini)**',
30
+ '',
31
+ 'Gemini session: `gemini-session-7`',
32
+ '',
33
+ 'All done.',
34
+ ].join('\n')
35
+ const parsed = parseTaskCompletion(text)
36
+
37
+ assert.ok(parsed)
38
+ assert.equal(parsed?.resumeInfo, 'Gemini session: `gemini-session-7`')
39
+ })
40
+ })
@@ -169,7 +169,7 @@ export function parseTaskCompletion(text: string): TaskCompletionInfo | null {
169
169
  }
170
170
  } else if (section.startsWith('Task report: ')) {
171
171
  reportPath = section.replace('Task report: ', '').replace(/^`|`$/g, '')
172
- } else if (/^(Claude session|Codex thread|OpenCode session|CLI session):/.test(section)) {
172
+ } else if (/^(Claude session|Codex thread|OpenCode session|Gemini session|CLI session):/.test(section)) {
173
173
  resumeInfo = section
174
174
  } else if (section.trim()) {
175
175
  resultParts.push(section)
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import { BottomSheet } from '@/components/shared/bottom-sheet'
5
5
  import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
6
6
  import { useAppStore } from '@/stores/use-app-store'
@@ -12,6 +12,7 @@ import type {
12
12
  OpenClawNode,
13
13
  OpenClawNodePairRequest,
14
14
  OpenClawPairedDevice,
15
+ GatewayProfile,
15
16
  } from '@/types'
16
17
 
17
18
  interface DiscoveryResult {
@@ -37,6 +38,17 @@ interface PairingListResult<T> {
37
38
  paired?: OpenClawPairedDevice[]
38
39
  }
39
40
 
41
+ interface GatewayImportShape {
42
+ name?: string
43
+ endpoint?: string
44
+ credentialId?: string | null
45
+ token?: string | null
46
+ notes?: string | null
47
+ tags?: string[]
48
+ isDefault?: boolean
49
+ deployment?: GatewayProfile['deployment']
50
+ }
51
+
40
52
  export function GatewaySheet() {
41
53
  const open = useAppStore((s) => s.gatewaySheetOpen)
42
54
  const setOpen = useAppStore((s) => s.setGatewaySheetOpen)
@@ -73,6 +85,8 @@ export function GatewaySheet() {
73
85
  const [invokeParamsText, setInvokeParamsText] = useState('{}')
74
86
  const [invokeResult, setInvokeResult] = useState('')
75
87
  const [invoking, setInvoking] = useState(false)
88
+ const [deployment, setDeployment] = useState<GatewayProfile['deployment'] | null>(null)
89
+ const importFileRef = useRef<HTMLInputElement>(null)
76
90
 
77
91
  useEffect(() => {
78
92
  if (!open) return
@@ -93,6 +107,7 @@ export function GatewaySheet() {
93
107
  setNotes(editing.notes || '')
94
108
  setTags((editing.tags || []).join(', '))
95
109
  setIsDefault(editing.isDefault === true)
110
+ setDeployment(editing.deployment || null)
96
111
  return
97
112
  }
98
113
  setName('')
@@ -102,6 +117,7 @@ export function GatewaySheet() {
102
117
  setNotes('')
103
118
  setTags('')
104
119
  setIsDefault(gatewayProfiles.length === 0)
120
+ setDeployment(null)
105
121
  setNodes([])
106
122
  setNodePairings([])
107
123
  setDevicePairings([])
@@ -143,10 +159,18 @@ export function GatewaySheet() {
143
159
  setNodePairings(nextNodePairings)
144
160
  setDevicePairings(nextDevicePairings)
145
161
  setPairedDevices(nextPairedDevices)
162
+ const nextStats: NonNullable<GatewayProfile['stats']> = {
163
+ nodeCount: nextNodes.length,
164
+ connectedNodeCount: nextNodes.filter((node) => node.connected).length,
165
+ pendingNodePairings: nextNodePairings.length,
166
+ pairedDeviceCount: nextPairedDevices.length,
167
+ pendingDevicePairings: nextDevicePairings.length,
168
+ }
146
169
  if (nextNodes[0]) {
147
170
  setInvokeNodeId((current) => current || nextNodes[0].nodeId)
148
171
  setInvokeCommand((current) => current || nextNodes[0].commands?.[0] || '')
149
172
  }
173
+ void api('PUT', `/gateways/${profileId}`, { stats: nextStats }).catch(() => {})
150
174
  } catch (err: unknown) {
151
175
  setNodesError(err instanceof Error ? err.message : 'Failed to load nodes for this gateway.')
152
176
  } finally {
@@ -182,6 +206,7 @@ export function GatewaySheet() {
182
206
  credentialId: nextCredentialId || null,
183
207
  notes: notes.trim() || null,
184
208
  tags: tags.split(',').map((item) => item.trim()).filter(Boolean),
209
+ deployment,
185
210
  isDefault,
186
211
  }
187
212
  if (editing) {
@@ -298,7 +323,7 @@ export function GatewaySheet() {
298
323
 
299
324
  const inputClass = 'w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow'
300
325
 
301
- const applyDeployPatch = (patch: { endpoint?: string; token?: string; name?: string; notes?: string }) => {
326
+ const applyDeployPatch = (patch: { endpoint?: string; token?: string; name?: string; notes?: string; deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null }) => {
302
327
  if (patch.endpoint) {
303
328
  setEndpoint(patch.endpoint)
304
329
  setCheckMessage('')
@@ -313,17 +338,97 @@ export function GatewaySheet() {
313
338
  if (patch.notes && !notes.trim()) {
314
339
  setNotes(patch.notes)
315
340
  }
341
+ if (patch.deployment) {
342
+ setDeployment((current) => ({
343
+ ...(current || {}),
344
+ ...(patch.deployment as GatewayProfile['deployment']),
345
+ }))
346
+ }
347
+ }
348
+
349
+ const handleExportGateway = () => {
350
+ const payload: GatewayImportShape = {
351
+ name: name.trim() || 'OpenClaw Gateway',
352
+ endpoint: endpoint.trim() || 'http://localhost:18789',
353
+ credentialId: credentialId || null,
354
+ token: tokenDraft.trim() || null,
355
+ notes: notes.trim() || null,
356
+ tags: tags.split(',').map((item) => item.trim()).filter(Boolean),
357
+ isDefault,
358
+ deployment,
359
+ }
360
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
361
+ const url = URL.createObjectURL(blob)
362
+ const link = document.createElement('a')
363
+ link.href = url
364
+ link.download = `${(payload.name || 'openclaw-gateway').replace(/[^a-zA-Z0-9_-]/g, '_')}.gateway.json`
365
+ link.click()
366
+ URL.revokeObjectURL(url)
367
+ toast.success('Gateway config exported')
368
+ }
369
+
370
+ const handleImportGateway = (event: React.ChangeEvent<HTMLInputElement>) => {
371
+ const file = event.target.files?.[0]
372
+ if (!file) return
373
+ const reader = new FileReader()
374
+ reader.onload = (loadEvent) => {
375
+ try {
376
+ const parsed = JSON.parse(String(loadEvent.target?.result || '{}')) as GatewayImportShape
377
+ setName(typeof parsed.name === 'string' ? parsed.name : '')
378
+ setEndpoint(typeof parsed.endpoint === 'string' ? parsed.endpoint : 'http://localhost:18789')
379
+ setCredentialId(typeof parsed.credentialId === 'string' ? parsed.credentialId : null)
380
+ setTokenDraft(typeof parsed.token === 'string' ? parsed.token : '')
381
+ setNotes(typeof parsed.notes === 'string' ? parsed.notes : '')
382
+ setTags(Array.isArray(parsed.tags) ? parsed.tags.join(', ') : '')
383
+ setIsDefault(parsed.isDefault === true)
384
+ setDeployment(parsed.deployment || null)
385
+ setCheckMessage('')
386
+ toast.success('Gateway config imported into this form')
387
+ } catch {
388
+ toast.error('Invalid gateway JSON')
389
+ } finally {
390
+ event.target.value = ''
391
+ }
392
+ }
393
+ reader.readAsText(file)
316
394
  }
317
395
 
318
396
  return (
319
397
  <BottomSheet open={open} onClose={onClose} wide>
398
+ <input
399
+ ref={importFileRef}
400
+ type="file"
401
+ accept="application/json,.json"
402
+ onChange={handleImportGateway}
403
+ className="hidden"
404
+ />
320
405
  <div className="mb-10">
321
- <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
322
- {editing ? 'Edit Gateway' : 'New Gateway'}
323
- </h2>
324
- <p className="text-[14px] text-text-3">
325
- First-class OpenClaw gateway profiles for local or remote control planes.
326
- </p>
406
+ <div className="flex flex-wrap items-start justify-between gap-3">
407
+ <div>
408
+ <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
409
+ {editing ? 'Edit Gateway' : 'New Gateway'}
410
+ </h2>
411
+ <p className="text-[14px] text-text-3">
412
+ First-class OpenClaw gateway profiles for local or remote control planes.
413
+ </p>
414
+ </div>
415
+ <div className="flex flex-wrap gap-2">
416
+ <button
417
+ type="button"
418
+ onClick={() => importFileRef.current?.click()}
419
+ className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-2 text-[12px] font-600 hover:bg-white/[0.04] transition-all cursor-pointer"
420
+ >
421
+ Import JSON
422
+ </button>
423
+ <button
424
+ type="button"
425
+ onClick={handleExportGateway}
426
+ className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-2 text-[12px] font-600 hover:bg-white/[0.04] transition-all cursor-pointer"
427
+ >
428
+ Export JSON
429
+ </button>
430
+ </div>
431
+ </div>
327
432
  </div>
328
433
 
329
434
  <div className="mb-6">
@@ -350,6 +455,7 @@ export function GatewaySheet() {
350
455
  <OpenClawDeployPanel
351
456
  endpoint={endpoint}
352
457
  token={tokenDraft}
458
+ deployment={deployment}
353
459
  suggestedName={name || null}
354
460
  title="Deploy OpenClaw From SwarmClaw"
355
461
  description="Use official OpenClaw sources only. Start it on this host, or generate a pre-configured remote bundle for VPS and hosted deployments."
@@ -357,6 +463,32 @@ export function GatewaySheet() {
357
463
  />
358
464
  </div>
359
465
 
466
+ {deployment && (
467
+ <div className="mb-6 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
468
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Deploy metadata</div>
469
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-[12px] text-text-3/75">
470
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface px-3 py-3">
471
+ <div className="uppercase tracking-[0.08em] text-text-3/55">Method</div>
472
+ <div className="mt-1 text-text-2">{deployment.method || 'manual'}</div>
473
+ </div>
474
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface px-3 py-3">
475
+ <div className="uppercase tracking-[0.08em] text-text-3/55">Use case</div>
476
+ <div className="mt-1 text-text-2">{deployment.useCase || 'general'}</div>
477
+ </div>
478
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface px-3 py-3">
479
+ <div className="uppercase tracking-[0.08em] text-text-3/55">Exposure</div>
480
+ <div className="mt-1 text-text-2">{deployment.exposure || 'manual'}</div>
481
+ </div>
482
+ </div>
483
+ {deployment.lastDeploySummary && (
484
+ <p className="mt-3 text-[12px] text-text-3 leading-relaxed">{deployment.lastDeploySummary}</p>
485
+ )}
486
+ {deployment.lastVerifiedMessage && (
487
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">{deployment.lastVerifiedMessage}</p>
488
+ )}
489
+ </div>
490
+ )}
491
+
360
492
  {discoveries.length > 0 && (
361
493
  <div className="mb-6">
362
494
  <div className="text-[12px] text-text-3/70 mb-2">Detected healthy gateways</div>