@swarmclawai/swarmclaw 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +47 -40
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +3 -1
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  88. package/src/lib/server/build-llm.test.ts +13 -5
  89. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  90. package/src/lib/server/chat-execution.ts +159 -71
  91. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  92. package/src/lib/server/chatroom-helpers.ts +99 -6
  93. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  94. package/src/lib/server/connectors/manager.ts +89 -61
  95. package/src/lib/server/connectors/slack.ts +1 -1
  96. package/src/lib/server/daemon-state.ts +3 -2
  97. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  98. package/src/lib/server/eval/agent-regression.ts +1742 -0
  99. package/src/lib/server/eval/runner.ts +11 -1
  100. package/src/lib/server/eval/store.ts +2 -1
  101. package/src/lib/server/heartbeat-service.ts +10 -4
  102. package/src/lib/server/main-agent-loop.ts +13 -6
  103. package/src/lib/server/openclaw-exec-config.ts +4 -2
  104. package/src/lib/server/openclaw-gateway.ts +123 -36
  105. package/src/lib/server/orchestrator-lg.ts +1 -2
  106. package/src/lib/server/orchestrator.ts +3 -2
  107. package/src/lib/server/plugins.test.ts +9 -1
  108. package/src/lib/server/plugins.ts +12 -2
  109. package/src/lib/server/provider-model-discovery.ts +481 -0
  110. package/src/lib/server/queue.ts +1 -1
  111. package/src/lib/server/runtime-settings.test.ts +119 -0
  112. package/src/lib/server/runtime-settings.ts +12 -92
  113. package/src/lib/server/schedule-normalization.ts +187 -0
  114. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  115. package/src/lib/server/session-tools/crud.ts +27 -3
  116. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  117. package/src/lib/server/session-tools/discovery.ts +18 -8
  118. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  119. package/src/lib/server/session-tools/file.ts +8 -2
  120. package/src/lib/server/session-tools/http.ts +9 -3
  121. package/src/lib/server/session-tools/index.ts +31 -1
  122. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  123. package/src/lib/server/session-tools/monitor.ts +14 -7
  124. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  125. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  126. package/src/lib/server/session-tools/platform.ts +1 -1
  127. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  128. package/src/lib/server/session-tools/sandbox.ts +51 -92
  129. package/src/lib/server/session-tools/session-info.ts +22 -1
  130. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  131. package/src/lib/server/session-tools/shell.ts +2 -2
  132. package/src/lib/server/session-tools/subagent.ts +3 -1
  133. package/src/lib/server/session-tools/web.ts +73 -30
  134. package/src/lib/server/storage.ts +29 -3
  135. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  136. package/src/lib/server/stream-agent-chat.ts +139 -4
  137. package/src/lib/server/structured-extract.ts +1 -1
  138. package/src/lib/server/task-mention.ts +0 -1
  139. package/src/lib/server/tool-aliases.ts +37 -6
  140. package/src/lib/server/tool-capability-policy.ts +1 -1
  141. package/src/lib/setup-defaults.ts +352 -11
  142. package/src/lib/tool-definitions.ts +3 -4
  143. package/src/lib/validation/schemas.ts +55 -1
  144. package/src/stores/use-app-store.ts +43 -1
  145. package/src/stores/use-chatroom-store.ts +153 -26
  146. package/src/types/index.ts +189 -6
  147. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -0,0 +1,567 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { BottomSheet } from '@/components/shared/bottom-sheet'
5
+ import { useAppStore } from '@/stores/use-app-store'
6
+ import { api } from '@/lib/api-client'
7
+ import { toast } from 'sonner'
8
+ import type { OpenClawDevicePairRequest, OpenClawNode, OpenClawNodePairRequest, OpenClawPairedDevice } from '@/types'
9
+
10
+ interface DiscoveryResult {
11
+ host: string
12
+ port: number
13
+ healthy: boolean
14
+ models?: string[]
15
+ error?: string
16
+ }
17
+
18
+ interface GatewayRpcResponse<T> {
19
+ ok?: boolean
20
+ result?: T
21
+ error?: string
22
+ }
23
+
24
+ interface NodeListResult {
25
+ nodes?: OpenClawNode[]
26
+ }
27
+
28
+ interface PairingListResult<T> {
29
+ pending?: T[]
30
+ paired?: OpenClawPairedDevice[]
31
+ }
32
+
33
+ export function GatewaySheet() {
34
+ const open = useAppStore((s) => s.gatewaySheetOpen)
35
+ const setOpen = useAppStore((s) => s.setGatewaySheetOpen)
36
+ const editingId = useAppStore((s) => s.editingGatewayId)
37
+ const setEditingId = useAppStore((s) => s.setEditingGatewayId)
38
+ const gatewayProfiles = useAppStore((s) => s.gatewayProfiles)
39
+ const loadGatewayProfiles = useAppStore((s) => s.loadGatewayProfiles)
40
+ const credentials = useAppStore((s) => s.credentials)
41
+ const loadCredentials = useAppStore((s) => s.loadCredentials)
42
+
43
+ const editing = editingId ? gatewayProfiles.find((item) => item.id === editingId) : null
44
+ const openClawCredentials = Object.values(credentials).filter((item) => item.provider === 'openclaw')
45
+
46
+ const [name, setName] = useState('')
47
+ const [endpoint, setEndpoint] = useState('http://localhost:18789')
48
+ const [credentialId, setCredentialId] = useState<string | null>(null)
49
+ const [notes, setNotes] = useState('')
50
+ const [tags, setTags] = useState('')
51
+ const [isDefault, setIsDefault] = useState(false)
52
+ const [saving, setSaving] = useState(false)
53
+ const [checking, setChecking] = useState(false)
54
+ const [checkMessage, setCheckMessage] = useState('')
55
+ const [discovering, setDiscovering] = useState(false)
56
+ const [discoveries, setDiscoveries] = useState<DiscoveryResult[]>([])
57
+ const [nodesLoading, setNodesLoading] = useState(false)
58
+ const [nodesError, setNodesError] = useState('')
59
+ const [nodes, setNodes] = useState<OpenClawNode[]>([])
60
+ const [nodePairings, setNodePairings] = useState<OpenClawNodePairRequest[]>([])
61
+ const [devicePairings, setDevicePairings] = useState<OpenClawDevicePairRequest[]>([])
62
+ const [pairedDevices, setPairedDevices] = useState<OpenClawPairedDevice[]>([])
63
+ const [invokeNodeId, setInvokeNodeId] = useState('')
64
+ const [invokeCommand, setInvokeCommand] = useState('')
65
+ const [invokeParamsText, setInvokeParamsText] = useState('{}')
66
+ const [invokeResult, setInvokeResult] = useState('')
67
+ const [invoking, setInvoking] = useState(false)
68
+
69
+ useEffect(() => {
70
+ if (!open) return
71
+ void Promise.all([loadGatewayProfiles(), loadCredentials()])
72
+ }, [open, loadGatewayProfiles, loadCredentials])
73
+
74
+ useEffect(() => {
75
+ if (!open) return
76
+ setCheckMessage('')
77
+ setDiscoveries([])
78
+ setNodesError('')
79
+ setInvokeResult('')
80
+ if (editing) {
81
+ setName(editing.name)
82
+ setEndpoint(editing.endpoint)
83
+ setCredentialId(editing.credentialId || null)
84
+ setNotes(editing.notes || '')
85
+ setTags((editing.tags || []).join(', '))
86
+ setIsDefault(editing.isDefault === true)
87
+ return
88
+ }
89
+ setName('')
90
+ setEndpoint('http://localhost:18789')
91
+ setCredentialId(null)
92
+ setNotes('')
93
+ setTags('')
94
+ setIsDefault(gatewayProfiles.length === 0)
95
+ setNodes([])
96
+ setNodePairings([])
97
+ setDevicePairings([])
98
+ setPairedDevices([])
99
+ setInvokeNodeId('')
100
+ setInvokeCommand('')
101
+ setInvokeParamsText('{}')
102
+ }, [open, editing, gatewayProfiles.length])
103
+
104
+ const loadNodesAndDevices = async (profileId: string) => {
105
+ setNodesLoading(true)
106
+ setNodesError('')
107
+ try {
108
+ const [nodesRes, nodePairRes, devicePairRes] = await Promise.all([
109
+ api<GatewayRpcResponse<NodeListResult>>('POST', '/openclaw/gateway', {
110
+ method: 'node.list',
111
+ params: { profileId },
112
+ }),
113
+ api<GatewayRpcResponse<PairingListResult<OpenClawNodePairRequest>>>('POST', '/openclaw/gateway', {
114
+ method: 'node.pair.list',
115
+ params: { profileId },
116
+ }),
117
+ api<GatewayRpcResponse<PairingListResult<OpenClawDevicePairRequest>>>('POST', '/openclaw/gateway', {
118
+ method: 'device.pair.list',
119
+ params: { profileId },
120
+ }),
121
+ ])
122
+
123
+ if (nodesRes.error) throw new Error(nodesRes.error)
124
+ if (nodePairRes.error) throw new Error(nodePairRes.error)
125
+ if (devicePairRes.error) throw new Error(devicePairRes.error)
126
+
127
+ const nextNodes = Array.isArray(nodesRes.result?.nodes) ? nodesRes.result.nodes : []
128
+ const nextNodePairings = Array.isArray(nodePairRes.result?.pending) ? nodePairRes.result.pending : []
129
+ const nextDevicePairings = Array.isArray(devicePairRes.result?.pending) ? devicePairRes.result.pending : []
130
+ const nextPairedDevices = Array.isArray(devicePairRes.result?.paired) ? devicePairRes.result.paired : []
131
+
132
+ setNodes(nextNodes)
133
+ setNodePairings(nextNodePairings)
134
+ setDevicePairings(nextDevicePairings)
135
+ setPairedDevices(nextPairedDevices)
136
+ if (nextNodes[0]) {
137
+ setInvokeNodeId((current) => current || nextNodes[0].nodeId)
138
+ setInvokeCommand((current) => current || nextNodes[0].commands?.[0] || '')
139
+ }
140
+ } catch (err: unknown) {
141
+ setNodesError(err instanceof Error ? err.message : 'Failed to load nodes for this gateway.')
142
+ } finally {
143
+ setNodesLoading(false)
144
+ }
145
+ }
146
+
147
+ useEffect(() => {
148
+ if (!open || !editing?.id) return
149
+ void loadNodesAndDevices(editing.id)
150
+ }, [open, editing?.id])
151
+
152
+ const onClose = () => {
153
+ setOpen(false)
154
+ setEditingId(null)
155
+ }
156
+
157
+ const handleSave = async () => {
158
+ setSaving(true)
159
+ try {
160
+ const payload = {
161
+ name: name.trim() || 'OpenClaw Gateway',
162
+ endpoint: endpoint.trim() || 'http://localhost:18789',
163
+ credentialId: credentialId || null,
164
+ notes: notes.trim() || null,
165
+ tags: tags.split(',').map((item) => item.trim()).filter(Boolean),
166
+ isDefault,
167
+ }
168
+ if (editing) {
169
+ await api('PUT', `/gateways/${editing.id}`, payload)
170
+ toast.success('Gateway updated')
171
+ } else {
172
+ await api('POST', '/gateways', payload)
173
+ toast.success('Gateway added')
174
+ }
175
+ await loadGatewayProfiles()
176
+ onClose()
177
+ } catch (err: unknown) {
178
+ toast.error(err instanceof Error ? err.message : 'Failed to save gateway')
179
+ } finally {
180
+ setSaving(false)
181
+ }
182
+ }
183
+
184
+ const handleCheck = async () => {
185
+ setChecking(true)
186
+ setCheckMessage('')
187
+ try {
188
+ const params = new URLSearchParams()
189
+ params.set('endpoint', endpoint.trim() || 'http://localhost:18789')
190
+ if (credentialId) params.set('credentialId', credentialId)
191
+ const result = await api<{ ok: boolean; models: string[]; error?: string; hint?: string }>('GET', `/providers/openclaw/health?${params.toString()}`)
192
+ if (result.ok) {
193
+ setCheckMessage(`Connected. ${result.models?.length ? `${result.models.length} model${result.models.length === 1 ? '' : 's'} visible.` : 'Gateway responded normally.'}`)
194
+ } else {
195
+ setCheckMessage(result.error || result.hint || 'Gateway health check failed.')
196
+ }
197
+ } catch (err: unknown) {
198
+ setCheckMessage(err instanceof Error ? err.message : 'Gateway health check failed.')
199
+ } finally {
200
+ setChecking(false)
201
+ }
202
+ }
203
+
204
+ const handleDiscover = async () => {
205
+ setDiscovering(true)
206
+ try {
207
+ const result = await api<{ gateways: DiscoveryResult[] }>('GET', '/openclaw/discover')
208
+ setDiscoveries((result.gateways || []).filter((item) => item.healthy))
209
+ } catch {
210
+ setDiscoveries([])
211
+ } finally {
212
+ setDiscovering(false)
213
+ }
214
+ }
215
+
216
+ const handlePairingDecision = async (kind: 'node' | 'device', requestId: string, decision: 'approve' | 'reject') => {
217
+ if (!editing?.id) return
218
+ try {
219
+ await api<GatewayRpcResponse<unknown>>('POST', '/openclaw/gateway', {
220
+ method: kind === 'node'
221
+ ? (decision === 'approve' ? 'node.pair.approve' : 'node.pair.reject')
222
+ : (decision === 'approve' ? 'device.pair.approve' : 'device.pair.reject'),
223
+ params: { profileId: editing.id, requestId },
224
+ })
225
+ toast.success(`${kind === 'node' ? 'Node' : 'Device'} ${decision}d`)
226
+ await loadNodesAndDevices(editing.id)
227
+ } catch (err: unknown) {
228
+ toast.error(err instanceof Error ? err.message : `Failed to ${decision} pairing`)
229
+ }
230
+ }
231
+
232
+ const handleRemoveDevice = async (deviceId: string) => {
233
+ if (!editing?.id) return
234
+ try {
235
+ await api<GatewayRpcResponse<unknown>>('POST', '/openclaw/gateway', {
236
+ method: 'device.pair.remove',
237
+ params: { profileId: editing.id, deviceId },
238
+ })
239
+ toast.success('Device removed')
240
+ await loadNodesAndDevices(editing.id)
241
+ } catch (err: unknown) {
242
+ toast.error(err instanceof Error ? err.message : 'Failed to remove device')
243
+ }
244
+ }
245
+
246
+ const handleInvoke = async () => {
247
+ if (!editing?.id || !invokeNodeId.trim() || !invokeCommand.trim()) return
248
+ setInvoking(true)
249
+ setInvokeResult('')
250
+ try {
251
+ let parsedParams: Record<string, unknown> = {}
252
+ if (invokeParamsText.trim()) {
253
+ const next = JSON.parse(invokeParamsText)
254
+ if (next && typeof next === 'object' && !Array.isArray(next)) {
255
+ parsedParams = next as Record<string, unknown>
256
+ }
257
+ }
258
+ const result = await api<GatewayRpcResponse<unknown>>('POST', '/openclaw/gateway', {
259
+ method: 'node.invoke',
260
+ params: {
261
+ profileId: editing.id,
262
+ nodeId: invokeNodeId.trim(),
263
+ command: invokeCommand.trim(),
264
+ params: parsedParams,
265
+ },
266
+ })
267
+ if (result.error) throw new Error(result.error)
268
+ setInvokeResult(JSON.stringify(result.result, null, 2))
269
+ toast.success('Node command sent')
270
+ } catch (err: unknown) {
271
+ const message = err instanceof Error ? err.message : 'Failed to invoke node command'
272
+ setInvokeResult(message)
273
+ toast.error(message)
274
+ } finally {
275
+ setInvoking(false)
276
+ }
277
+ }
278
+
279
+ 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'
280
+
281
+ return (
282
+ <BottomSheet open={open} onClose={onClose} wide>
283
+ <div className="mb-10">
284
+ <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
285
+ {editing ? 'Edit Gateway' : 'New Gateway'}
286
+ </h2>
287
+ <p className="text-[14px] text-text-3">
288
+ First-class OpenClaw gateway profiles for local or remote control planes.
289
+ </p>
290
+ </div>
291
+
292
+ <div className="mb-6">
293
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Name</label>
294
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Local Mac Mini" className={inputClass} />
295
+ </div>
296
+
297
+ <div className="mb-6">
298
+ <div className="flex items-center justify-between mb-3">
299
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Gateway Endpoint</label>
300
+ <button
301
+ type="button"
302
+ onClick={handleDiscover}
303
+ className="text-[11px] text-text-3 hover:text-accent-bright transition-colors cursor-pointer bg-transparent border-none"
304
+ >
305
+ {discovering ? 'Discovering…' : 'Discover local gateways'}
306
+ </button>
307
+ </div>
308
+ <input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="http://localhost:18789" className={`${inputClass} font-mono text-[14px]`} />
309
+ <p className="text-[11px] text-text-3/60 mt-2">Remote HTTPS URLs and local loopback endpoints are both supported.</p>
310
+ </div>
311
+
312
+ {discoveries.length > 0 && (
313
+ <div className="mb-6">
314
+ <div className="text-[12px] text-text-3/70 mb-2">Detected healthy gateways</div>
315
+ <div className="flex flex-wrap gap-2">
316
+ {discoveries.map((item) => {
317
+ const detectedEndpoint = `http://${item.host}:${item.port}`
318
+ return (
319
+ <button
320
+ key={`${item.host}:${item.port}`}
321
+ type="button"
322
+ onClick={() => {
323
+ setEndpoint(detectedEndpoint)
324
+ if (!name.trim()) setName(`Gateway ${item.host}:${item.port}`)
325
+ }}
326
+ className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-white/[0.03] text-text-2 text-[12px] font-600 hover:bg-white/[0.05] cursor-pointer transition-all"
327
+ >
328
+ {item.host}:{item.port}
329
+ {item.models?.length ? ` · ${item.models[0]}` : ''}
330
+ </button>
331
+ )
332
+ })}
333
+ </div>
334
+ </div>
335
+ )}
336
+
337
+ <div className="mb-6">
338
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Gateway Token</label>
339
+ <select value={credentialId || ''} onChange={(e) => setCredentialId(e.target.value || null)} className={inputClass}>
340
+ <option value="">No token</option>
341
+ {openClawCredentials.map((item) => (
342
+ <option key={item.id} value={item.id}>{item.name}</option>
343
+ ))}
344
+ </select>
345
+ </div>
346
+
347
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
348
+ <div>
349
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Tags</label>
350
+ <input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="remote, prod, mac-mini" className={inputClass} />
351
+ </div>
352
+ <div>
353
+ <label className="flex items-center gap-3 pt-8">
354
+ <input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
355
+ <span className="text-[14px] text-text-2">Use as default OpenClaw gateway</span>
356
+ </label>
357
+ </div>
358
+ </div>
359
+
360
+ <div className="mb-8">
361
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Notes</label>
362
+ <textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={4} placeholder="Remote tailnet gateway for background coding agents." className={`${inputClass} resize-y min-h-[100px]`} />
363
+ </div>
364
+
365
+ {editing && (
366
+ <div className="mb-8 rounded-[18px] border border-white/[0.06] bg-white/[0.02] p-4 md:p-5">
367
+ <div className="flex items-start justify-between gap-3 mb-4">
368
+ <div>
369
+ <div className="font-display text-[18px] font-700 tracking-[-0.02em] text-text">Nodes & Devices</div>
370
+ <p className="text-[12px] text-text-3 mt-1">
371
+ Inspect paired nodes, approve incoming pair requests, and invoke commands on this gateway profile.
372
+ </p>
373
+ </div>
374
+ <button
375
+ type="button"
376
+ onClick={() => void loadNodesAndDevices(editing.id)}
377
+ 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"
378
+ >
379
+ {nodesLoading ? 'Refreshing…' : 'Refresh'}
380
+ </button>
381
+ </div>
382
+
383
+ {nodesError && (
384
+ <div className="mb-4 rounded-[12px] border border-red-400/20 bg-red-400/[0.06] px-3 py-2 text-[12px] text-red-200">
385
+ {nodesError}
386
+ </div>
387
+ )}
388
+
389
+ <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
390
+ <div className="space-y-4">
391
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4">
392
+ <div className="flex items-center justify-between mb-3">
393
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70">Pending Node Pairings</div>
394
+ <div className="text-[11px] text-text-3/50">{nodePairings.length}</div>
395
+ </div>
396
+ {nodePairings.length > 0 ? (
397
+ <div className="space-y-2">
398
+ {nodePairings.map((request) => (
399
+ <div key={request.requestId} className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
400
+ <div className="text-[13px] font-600 text-text-2">{request.displayName || request.nodeId || request.requestId}</div>
401
+ <div className="text-[11px] text-text-3/60 mt-1">{request.platform || 'Unknown platform'}{request.remoteIp ? ` · ${request.remoteIp}` : ''}</div>
402
+ <div className="mt-3 flex gap-2">
403
+ <button type="button" onClick={() => void handlePairingDecision('node', request.requestId, 'approve')} className="px-2.5 py-1.5 rounded-[8px] bg-emerald-400/10 text-emerald-300 text-[11px] font-700 border-none cursor-pointer hover:bg-emerald-400/15 transition-all">Approve</button>
404
+ <button type="button" onClick={() => void handlePairingDecision('node', request.requestId, 'reject')} className="px-2.5 py-1.5 rounded-[8px] bg-red-400/10 text-red-300 text-[11px] font-700 border-none cursor-pointer hover:bg-red-400/15 transition-all">Reject</button>
405
+ </div>
406
+ </div>
407
+ ))}
408
+ </div>
409
+ ) : (
410
+ <div className="text-[12px] text-text-3/60">No pending node approvals.</div>
411
+ )}
412
+ </div>
413
+
414
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4">
415
+ <div className="flex items-center justify-between mb-3">
416
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70">Pending Device Pairings</div>
417
+ <div className="text-[11px] text-text-3/50">{devicePairings.length}</div>
418
+ </div>
419
+ {devicePairings.length > 0 ? (
420
+ <div className="space-y-2">
421
+ {devicePairings.map((request) => (
422
+ <div key={request.requestId} className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
423
+ <div className="text-[13px] font-600 text-text-2">{request.displayName || request.deviceId || request.requestId}</div>
424
+ <div className="text-[11px] text-text-3/60 mt-1">{request.role || 'device'}{request.platform ? ` · ${request.platform}` : ''}{request.remoteIp ? ` · ${request.remoteIp}` : ''}</div>
425
+ <div className="mt-3 flex gap-2">
426
+ <button type="button" onClick={() => void handlePairingDecision('device', request.requestId, 'approve')} className="px-2.5 py-1.5 rounded-[8px] bg-emerald-400/10 text-emerald-300 text-[11px] font-700 border-none cursor-pointer hover:bg-emerald-400/15 transition-all">Approve</button>
427
+ <button type="button" onClick={() => void handlePairingDecision('device', request.requestId, 'reject')} className="px-2.5 py-1.5 rounded-[8px] bg-red-400/10 text-red-300 text-[11px] font-700 border-none cursor-pointer hover:bg-red-400/15 transition-all">Reject</button>
428
+ </div>
429
+ </div>
430
+ ))}
431
+ </div>
432
+ ) : (
433
+ <div className="text-[12px] text-text-3/60">No pending device approvals.</div>
434
+ )}
435
+ </div>
436
+ </div>
437
+
438
+ <div className="space-y-4">
439
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4">
440
+ <div className="flex items-center justify-between mb-3">
441
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70">Connected / Paired Nodes</div>
442
+ <div className="text-[11px] text-text-3/50">{nodes.length}</div>
443
+ </div>
444
+ {nodes.length > 0 ? (
445
+ <div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
446
+ {nodes.map((node) => (
447
+ <div key={node.nodeId} className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
448
+ <div className="flex items-start justify-between gap-3">
449
+ <div className="min-w-0">
450
+ <div className="text-[13px] font-600 text-text-2 truncate">{node.displayName || node.nodeId}</div>
451
+ <div className="text-[11px] text-text-3/60 mt-1">
452
+ {node.platform || 'Unknown platform'}
453
+ {node.remoteIp ? ` · ${node.remoteIp}` : ''}
454
+ {node.deviceFamily ? ` · ${node.deviceFamily}` : ''}
455
+ </div>
456
+ </div>
457
+ <div className={`text-[10px] font-700 uppercase tracking-[0.08em] px-2 py-0.5 rounded-[6px] ${
458
+ node.connected
459
+ ? 'bg-emerald-400/10 text-emerald-300'
460
+ : 'bg-white/[0.05] text-text-3/70'
461
+ }`}>
462
+ {node.connected ? 'online' : (node.paired ? 'paired' : 'offline')}
463
+ </div>
464
+ </div>
465
+ {node.commands?.length ? (
466
+ <div className="mt-2 text-[11px] text-text-3/60 truncate">
467
+ {node.commands.slice(0, 4).join(', ')}
468
+ {node.commands.length > 4 ? ` +${node.commands.length - 4}` : ''}
469
+ </div>
470
+ ) : null}
471
+ <div className="mt-3 flex gap-2">
472
+ <button
473
+ type="button"
474
+ onClick={() => {
475
+ setInvokeNodeId(node.nodeId)
476
+ setInvokeCommand(node.commands?.[0] || invokeCommand)
477
+ }}
478
+ className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-text-2 text-[11px] font-700 hover:bg-white/[0.04] cursor-pointer transition-all"
479
+ >
480
+ Use in Invoke
481
+ </button>
482
+ </div>
483
+ </div>
484
+ ))}
485
+ </div>
486
+ ) : (
487
+ <div className="text-[12px] text-text-3/60">No nodes are paired to this gateway yet.</div>
488
+ )}
489
+ </div>
490
+
491
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4">
492
+ <div className="flex items-center justify-between mb-3">
493
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70">Paired Devices</div>
494
+ <div className="text-[11px] text-text-3/50">{pairedDevices.length}</div>
495
+ </div>
496
+ {pairedDevices.length > 0 ? (
497
+ <div className="space-y-2">
498
+ {pairedDevices.map((device) => (
499
+ <div key={device.deviceId} className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
500
+ <div className="flex items-start justify-between gap-3">
501
+ <div className="min-w-0">
502
+ <div className="text-[13px] font-600 text-text-2 truncate">{device.displayName || device.deviceId}</div>
503
+ <div className="text-[11px] text-text-3/60 mt-1">{device.role || 'device'}{device.platform ? ` · ${device.platform}` : ''}{device.remoteIp ? ` · ${device.remoteIp}` : ''}</div>
504
+ </div>
505
+ <button type="button" onClick={() => void handleRemoveDevice(device.deviceId)} className="px-2.5 py-1.5 rounded-[8px] bg-red-400/10 text-red-300 text-[11px] font-700 border-none cursor-pointer hover:bg-red-400/15 transition-all">Remove</button>
506
+ </div>
507
+ </div>
508
+ ))}
509
+ </div>
510
+ ) : (
511
+ <div className="text-[12px] text-text-3/60">No paired devices on this gateway.</div>
512
+ )}
513
+ </div>
514
+ </div>
515
+ </div>
516
+
517
+ <div className="mt-4 rounded-[14px] border border-white/[0.06] bg-surface p-4">
518
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-3">Invoke Node Command</div>
519
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
520
+ <select value={invokeNodeId} onChange={(e) => setInvokeNodeId(e.target.value)} className={inputClass}>
521
+ <option value="">Select node</option>
522
+ {nodes.map((node) => (
523
+ <option key={node.nodeId} value={node.nodeId}>{node.displayName || node.nodeId}</option>
524
+ ))}
525
+ </select>
526
+ <input value={invokeCommand} onChange={(e) => setInvokeCommand(e.target.value)} placeholder="command" className={inputClass} />
527
+ </div>
528
+ <textarea
529
+ value={invokeParamsText}
530
+ onChange={(e) => setInvokeParamsText(e.target.value)}
531
+ rows={5}
532
+ placeholder='{"message":"hello"}'
533
+ className={`${inputClass} font-mono text-[13px] resize-y min-h-[120px]`}
534
+ />
535
+ <div className="mt-3 flex items-center justify-between gap-3">
536
+ <p className="text-[11px] text-text-3/55">
537
+ Use commands exposed by the selected node, such as file, shell, or notification actions that gateway policy allows.
538
+ </p>
539
+ <button type="button" onClick={handleInvoke} disabled={invoking || !invokeNodeId || !invokeCommand.trim()} className="px-3 py-2 rounded-[10px] bg-accent-bright text-white text-[12px] font-700 border-none hover:brightness-110 transition-all cursor-pointer disabled:opacity-40">
540
+ {invoking ? 'Sending…' : 'Invoke'}
541
+ </button>
542
+ </div>
543
+ {invokeResult && (
544
+ <pre className="mt-3 rounded-[12px] border border-white/[0.06] bg-black/20 p-3 text-[11px] text-text-2/80 overflow-x-auto whitespace-pre-wrap">
545
+ {invokeResult}
546
+ </pre>
547
+ )}
548
+ </div>
549
+ </div>
550
+ )}
551
+
552
+ <div className="flex items-center justify-between gap-3">
553
+ <div className="text-[12px] text-text-3/70">
554
+ {checkMessage || 'Run a health check before saving if you want to verify endpoint + token.'}
555
+ </div>
556
+ <div className="flex items-center gap-2">
557
+ <button type="button" onClick={handleCheck} 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">
558
+ {checking ? 'Checking…' : 'Health Check'}
559
+ </button>
560
+ <button type="button" onClick={handleSave} disabled={saving} className="px-4 py-2 rounded-[10px] bg-accent-bright text-white text-[12px] font-700 border-none hover:brightness-110 transition-all cursor-pointer disabled:opacity-40">
561
+ {saving ? 'Saving…' : (editing ? 'Save Gateway' : 'Create Gateway')}
562
+ </button>
563
+ </div>
564
+ </div>
565
+ </BottomSheet>
566
+ )
567
+ }