@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
@@ -3,6 +3,7 @@
3
3
  import { useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { createMemory } from '@/lib/memory'
6
+ import { getMemoryTierForCategory } from '@/lib/memory-presentation'
6
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
7
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
8
9
  import { SheetFooter } from '@/components/shared/sheet-footer'
@@ -24,6 +25,7 @@ export function MemorySheet() {
24
25
  const [title, setTitle] = useState('')
25
26
  const [content, setContent] = useState('')
26
27
  const [category, setCategory] = useState('note')
28
+ const [tier, setTier] = useState<'working' | 'durable' | 'archive'>(getMemoryTierForCategory('note'))
27
29
  const [agentId, setAgentId] = useState<string | null>(defaultAgentId)
28
30
  const [sharedWith, setSharedWith] = useState<string[]>([])
29
31
  const [saving, setSaving] = useState(false)
@@ -36,6 +38,7 @@ export function MemorySheet() {
36
38
  setTitle('')
37
39
  setContent('')
38
40
  setCategory('note')
41
+ setTier(getMemoryTierForCategory('note'))
39
42
  setSaving(false)
40
43
  } else if (!open && prevOpen) {
41
44
  setPrevOpen(false)
@@ -56,6 +59,11 @@ export function MemorySheet() {
56
59
  agentId,
57
60
  sessionId: null,
58
61
  sharedWith: sharedWith.length ? sharedWith : undefined,
62
+ metadata: {
63
+ tier,
64
+ scope: agentId ? 'agent' : 'global',
65
+ visibility: agentId ? (sharedWith.length ? 'shared' : 'private') : 'global',
66
+ },
59
67
  })
60
68
  triggerRefresh()
61
69
  onClose()
@@ -77,7 +85,7 @@ export function MemorySheet() {
77
85
 
78
86
  {/* Agent selector */}
79
87
  <div className="mb-6">
80
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Assign to</label>
88
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Visibility</label>
81
89
  <div className="flex gap-2 flex-wrap">
82
90
  <button
83
91
  onClick={() => setAgentId(null)}
@@ -111,12 +119,12 @@ export function MemorySheet() {
111
119
  </div>
112
120
  {selectedAgent && (
113
121
  <p className="text-[11px] text-text-3/50 mt-2">
114
- This memory will be available to <span className="text-text-2">{selectedAgent.name}</span> during conversations
122
+ Owned by <span className="text-text-2">{selectedAgent.name}</span>. Add collaborators below if other agents should be able to recall it too.
115
123
  </p>
116
124
  )}
117
125
  {!agentId && (
118
126
  <p className="text-[11px] text-text-3/50 mt-2">
119
- Global memories are accessible to all agents
127
+ Global memories are accessible to every agent in the workspace.
120
128
  </p>
121
129
  )}
122
130
  </div>
@@ -167,7 +175,10 @@ export function MemorySheet() {
167
175
  {CATEGORIES.map((c) => (
168
176
  <button
169
177
  key={c}
170
- onClick={() => setCategory(c)}
178
+ onClick={() => {
179
+ setCategory(c)
180
+ setTier(getMemoryTierForCategory(c))
181
+ }}
171
182
  className={`px-3 py-1.5 rounded-[8px] text-[12px] font-600 capitalize cursor-pointer transition-all border-none
172
183
  ${category === c
173
184
  ? 'bg-accent-soft text-accent-bright'
@@ -180,6 +191,23 @@ export function MemorySheet() {
180
191
  </div>
181
192
  </div>
182
193
 
194
+ <div className="mb-6">
195
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Tier</label>
196
+ <select
197
+ value={tier}
198
+ onChange={(e) => setTier(e.target.value as typeof tier)}
199
+ className={inputClass}
200
+ style={{ fontFamily: 'inherit' }}
201
+ >
202
+ <option value="working">Working: short-horizon, active context</option>
203
+ <option value="durable">Durable: keep this around as reusable knowledge</option>
204
+ <option value="archive">Archive: preserve, but keep less salient</option>
205
+ </select>
206
+ <p className="text-[11px] text-text-3/50 mt-2">
207
+ Tier controls how aggressively this memory should stay in active recall.
208
+ </p>
209
+ </div>
210
+
183
211
  {/* Content */}
184
212
  <div className="mb-8">
185
213
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Content</label>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useMemo, useState } from 'react'
3
+ import { useEffect, useMemo, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { AgentAvatar } from '@/components/agents/agent-avatar'
6
6
  import { updateAgent } from '@/lib/agents'
@@ -102,7 +102,12 @@ export function ProjectDetail() {
102
102
  const setScheduleSheetOpen = useAppStore((s) => s.setScheduleSheetOpen)
103
103
 
104
104
  const [assignPickerOpen, setAssignPickerOpen] = useState(false)
105
- const now = Date.now()
105
+ const [now, setNow] = useState(() => Date.now())
106
+
107
+ useEffect(() => {
108
+ const intervalId = window.setInterval(() => setNow(Date.now()), 60_000)
109
+ return () => window.clearInterval(intervalId)
110
+ }, [])
106
111
 
107
112
  const project = activeProjectFilter ? projects[activeProjectFilter] : null
108
113
 
@@ -10,19 +10,27 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
10
10
  const providerConfigs = useAppStore((s) => s.providerConfigs)
11
11
  const loadProviders = useAppStore((s) => s.loadProviders)
12
12
  const loadProviderConfigs = useAppStore((s) => s.loadProviderConfigs)
13
+ const gatewayProfiles = useAppStore((s) => s.gatewayProfiles)
14
+ const loadGatewayProfiles = useAppStore((s) => s.loadGatewayProfiles)
15
+ const externalAgents = useAppStore((s) => s.externalAgents)
16
+ const loadExternalAgents = useAppStore((s) => s.loadExternalAgents)
13
17
  const credentials = useAppStore((s) => s.credentials)
14
18
  const loadCredentials = useAppStore((s) => s.loadCredentials)
15
19
  const setProviderSheetOpen = useAppStore((s) => s.setProviderSheetOpen)
16
20
  const setEditingProviderId = useAppStore((s) => s.setEditingProviderId)
21
+ const setGatewaySheetOpen = useAppStore((s) => s.setGatewaySheetOpen)
22
+ const setEditingGatewayId = useAppStore((s) => s.setEditingGatewayId)
17
23
  const [loaded, setLoaded] = useState(false)
18
24
 
19
25
  const refresh = useCallback(async () => {
20
- await Promise.all([loadProviders(), loadProviderConfigs(), loadCredentials()])
26
+ await Promise.all([loadProviders(), loadProviderConfigs(), loadGatewayProfiles(), loadExternalAgents(), loadCredentials()])
21
27
  setLoaded(true)
22
- }, [loadProviders, loadProviderConfigs, loadCredentials])
28
+ }, [loadProviders, loadProviderConfigs, loadGatewayProfiles, loadExternalAgents, loadCredentials])
23
29
 
24
30
  useEffect(() => { void refresh() }, [refresh])
25
31
  useWs('providers', loadProviders, 20_000)
32
+ useWs('gateways', loadGatewayProfiles, 20_000)
33
+ useWs('external_agents', loadExternalAgents, 20_000)
26
34
 
27
35
  const handleEdit = (id: string) => {
28
36
  setEditingProviderId(id)
@@ -41,6 +49,23 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
41
49
  await loadProviderConfigs()
42
50
  }
43
51
 
52
+ const handleEditGateway = (id: string | null) => {
53
+ setEditingGatewayId(id)
54
+ setGatewaySheetOpen(true)
55
+ }
56
+
57
+ const handleDeleteGateway = async (e: React.MouseEvent, id: string) => {
58
+ e.stopPropagation()
59
+ await api('DELETE', `/gateways/${id}`)
60
+ await loadGatewayProfiles()
61
+ }
62
+
63
+ const handleHealthCheckGateway = async (e: React.MouseEvent, id: string) => {
64
+ e.stopPropagation()
65
+ await api('GET', `/gateways/${id}/health`)
66
+ await loadGatewayProfiles()
67
+ }
68
+
44
69
  // Merge built-in providers with custom configs
45
70
  const builtinItems = providers.map((p) => ({
46
71
  id: p.id,
@@ -74,6 +99,18 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
74
99
 
75
100
  return (
76
101
  <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-5 pb-6'}`}>
102
+ <div className="mb-4 flex items-center justify-between">
103
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Model Providers</div>
104
+ {!inSidebar && (
105
+ <button
106
+ type="button"
107
+ onClick={() => handleEditGateway(null)}
108
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer"
109
+ >
110
+ + Gateway
111
+ </button>
112
+ )}
113
+ </div>
77
114
  <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
78
115
  {allItems.map((item, idx) => (
79
116
  <div
@@ -139,6 +176,125 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
139
176
  </div>
140
177
  ))}
141
178
  </div>
179
+
180
+ <div className="mt-8 mb-4 flex items-center justify-between">
181
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div>
182
+ {!inSidebar && (
183
+ <button
184
+ type="button"
185
+ onClick={() => handleEditGateway(null)}
186
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer"
187
+ >
188
+ + New Gateway
189
+ </button>
190
+ )}
191
+ </div>
192
+ <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
193
+ {gatewayProfiles.map((gateway, idx) => (
194
+ <div
195
+ key={gateway.id}
196
+ role="button"
197
+ tabIndex={0}
198
+ onClick={() => handleEditGateway(gateway.id)}
199
+ onKeyDown={(e) => {
200
+ if (e.key === 'Enter' || e.key === ' ') {
201
+ e.preventDefault()
202
+ handleEditGateway(gateway.id)
203
+ }
204
+ }}
205
+ className="w-full text-left p-4 rounded-[14px] border transition-all duration-200
206
+ cursor-pointer hover:bg-white/[0.02] bg-surface border-white/[0.06] hover:border-white/[0.12] hover:scale-[1.01]"
207
+ style={{
208
+ animation: 'spring-in 0.5s var(--ease-spring) both',
209
+ animationDelay: `${(allItems.length + idx) * 0.04}s`,
210
+ }}
211
+ >
212
+ <div className="flex items-center justify-between mb-2">
213
+ <div className="min-w-0">
214
+ <div className="font-display text-[14px] font-600 text-text truncate">{gateway.name}</div>
215
+ <div className="text-[11px] text-text-3/60 font-mono truncate">{gateway.endpoint}</div>
216
+ </div>
217
+ <div className="flex items-center gap-2 shrink-0">
218
+ {gateway.isDefault && (
219
+ <span className="text-[10px] font-700 px-2 py-0.5 rounded-[5px] bg-accent-bright/10 text-accent-bright uppercase tracking-wider">Default</span>
220
+ )}
221
+ <span className={`w-2 h-2 rounded-full ${
222
+ gateway.status === 'healthy'
223
+ ? 'bg-emerald-400'
224
+ : gateway.status === 'degraded'
225
+ ? 'bg-amber-400'
226
+ : gateway.status === 'offline'
227
+ ? 'bg-red-400'
228
+ : 'bg-white/10'
229
+ }`} />
230
+ </div>
231
+ </div>
232
+ <div className="text-[12px] text-text-3/70">
233
+ {gateway.tags?.length ? gateway.tags.join(', ') : (gateway.notes || 'Dedicated OpenClaw control plane')}
234
+ </div>
235
+ {!inSidebar && (
236
+ <div className="mt-3 flex items-center gap-2">
237
+ <button onClick={(e) => void handleHealthCheckGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
238
+ Health
239
+ </button>
240
+ <button onClick={(e) => handleDeleteGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-red-400/20 bg-red-400/[0.06] text-[11px] font-700 text-red-300 hover:bg-red-400/[0.1] cursor-pointer transition-all">
241
+ Delete
242
+ </button>
243
+ </div>
244
+ )}
245
+ </div>
246
+ ))}
247
+ {gatewayProfiles.length === 0 && (
248
+ <div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70">
249
+ No gateway profiles yet. Add one to route OpenClaw agents by named control plane instead of a singleton default.
250
+ </div>
251
+ )}
252
+ </div>
253
+
254
+ {!inSidebar && (
255
+ <>
256
+ <div className="mt-8 mb-4 flex items-center justify-between">
257
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">External Agent Runtimes</div>
258
+ <div className="text-[11px] text-text-3/60">Direct registration + heartbeat</div>
259
+ </div>
260
+ <div className="mb-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-[12px] text-text-3/70">
261
+ External workers can register themselves at <code className="text-text-2">/api/external-agents/register</code> and then send heartbeats to
262
+ {' '}
263
+ <code className="text-text-2">/api/external-agents/&lt;id&gt;/heartbeat</code>.
264
+ </div>
265
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
266
+ {externalAgents.map((runtime) => (
267
+ <div key={runtime.id} className="p-4 rounded-[14px] bg-surface border border-white/[0.06]">
268
+ <div className="flex items-center justify-between gap-3 mb-2">
269
+ <div className="min-w-0">
270
+ <div className="font-display text-[14px] font-600 text-text truncate">{runtime.name}</div>
271
+ <div className="text-[11px] text-text-3/60 truncate">{runtime.sourceType} · {runtime.transport || 'custom'}</div>
272
+ </div>
273
+ <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
274
+ runtime.status === 'online'
275
+ ? 'bg-emerald-400/10 text-emerald-300'
276
+ : runtime.status === 'stale'
277
+ ? 'bg-amber-400/10 text-amber-300'
278
+ : 'bg-white/[0.04] text-text-3'
279
+ }`}>
280
+ {runtime.status}
281
+ </span>
282
+ </div>
283
+ <div className="text-[12px] text-text-3/70">
284
+ {runtime.provider || 'No provider'}
285
+ {runtime.model ? ` · ${runtime.model}` : ''}
286
+ </div>
287
+ <div className="text-[11px] text-text-3/55 mt-2 font-mono truncate">{runtime.endpoint || runtime.workspace || runtime.id}</div>
288
+ </div>
289
+ ))}
290
+ {externalAgents.length === 0 && (
291
+ <div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70">
292
+ No external runtimes have registered yet.
293
+ </div>
294
+ )}
295
+ </div>
296
+ </>
297
+ )}
142
298
  </div>
143
299
  )
144
300
  }
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { createProviderConfig, updateProviderConfig, deleteProviderConfig } from '@/lib/provider-config'
6
6
  import { api } from '@/lib/api-client'
7
+ import { fetchProviderModelDiscovery } from '@/lib/provider-model-discovery-client'
7
8
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
9
  import { toast } from 'sonner'
9
10
 
@@ -35,10 +36,10 @@ export function ProviderSheet() {
35
36
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
36
37
  const [testMessage, setTestMessage] = useState('')
37
38
 
38
- // Ollama local models
39
- const [localModels, setLocalModels] = useState<string[]>([])
40
- const [localLoading, setLocalLoading] = useState(false)
41
- const [localError, setLocalError] = useState('')
39
+ const [liveModels, setLiveModels] = useState<string[]>([])
40
+ const [liveLoading, setLiveLoading] = useState(false)
41
+ const [liveMessage, setLiveMessage] = useState('')
42
+ const [liveCached, setLiveCached] = useState(false)
42
43
 
43
44
  // Find editing provider in custom configs OR built-in list
44
45
  const editingCustom = editingId ? providerConfigs.find((c) => c.id === editingId) : null
@@ -50,8 +51,9 @@ export function ProviderSheet() {
50
51
  if (open) {
51
52
  loadCredentials()
52
53
  setNewModel('')
53
- setLocalModels([])
54
- setLocalError('')
54
+ setLiveModels([])
55
+ setLiveMessage('')
56
+ setLiveCached(false)
55
57
  setTestStatus('idle')
56
58
  setTestMessage('')
57
59
  if (editingCustom) {
@@ -79,28 +81,7 @@ export function ProviderSheet() {
79
81
  setIsEnabled(true)
80
82
  }
81
83
  }
82
- }, [open, editingId])
83
-
84
- // Fetch local Ollama models when editing Ollama provider
85
- useEffect(() => {
86
- if (!open || editingId !== 'ollama') return
87
- setLocalLoading(true)
88
- const endpoint = baseUrl || 'http://localhost:11434'
89
- api<{ models: { name: string }[]; error?: string }>('GET', `/providers/ollama?endpoint=${encodeURIComponent(endpoint)}`)
90
- .then((res) => {
91
- if (res.error) {
92
- setLocalError(res.error)
93
- setLocalModels([])
94
- } else {
95
- setLocalModels(res.models.map((m) => m.name))
96
- }
97
- })
98
- .catch(() => {
99
- setLocalError('Failed to connect')
100
- setLocalModels([])
101
- })
102
- .finally(() => setLocalLoading(false))
103
- }, [open, editingId, baseUrl])
84
+ }, [open, editingId, credentials, editingBuiltin, editingCustom, loadCredentials])
104
85
 
105
86
  // Reset test status when connection params change
106
87
  useEffect(() => {
@@ -108,6 +89,12 @@ export function ProviderSheet() {
108
89
  setTestMessage('')
109
90
  }, [credentialId, baseUrl])
110
91
 
92
+ useEffect(() => {
93
+ setLiveModels([])
94
+ setLiveMessage('')
95
+ setLiveCached(false)
96
+ }, [editingId, credentialId, baseUrl, requiresApiKey])
97
+
111
98
  const handleTestConnection = async () => {
112
99
  setTestStatus('testing')
113
100
  setTestMessage('')
@@ -210,10 +197,43 @@ export function ProviderSheet() {
210
197
  setModels(list.join(', '))
211
198
  }
212
199
 
200
+ const handleLoadLiveModels = async (force = false) => {
201
+ if (!open) return
202
+ const providerId = editingId || 'custom'
203
+ setLiveLoading(true)
204
+ setLiveMessage('')
205
+ try {
206
+ const result = await fetchProviderModelDiscovery({
207
+ providerId,
208
+ credentialId,
209
+ endpoint: baseUrl,
210
+ force,
211
+ requiresApiKey: isBuiltin ? undefined : requiresApiKey,
212
+ })
213
+ setLiveModels(result.models)
214
+ setLiveCached(result.cached)
215
+ setLiveMessage(result.message || '')
216
+ if (!result.ok) {
217
+ toast.message(result.message || 'Live model discovery is unavailable.')
218
+ return
219
+ }
220
+ setModels(result.models.join(', '))
221
+ toast.success(`Loaded ${result.models.length} live model${result.models.length === 1 ? '' : 's'}`)
222
+ } catch (err: unknown) {
223
+ const message = err instanceof Error ? err.message : 'Failed to load live models'
224
+ setLiveMessage(message)
225
+ toast.error(message)
226
+ } finally {
227
+ setLiveLoading(false)
228
+ }
229
+ }
230
+
213
231
  const credList = Object.values(credentials)
214
232
  const modelList = models.split(',').map((m) => m.trim()).filter(Boolean)
215
- const isNew = !editing
216
233
  const showApiKey = isBuiltin ? editingBuiltin?.requiresApiKey || editingBuiltin?.optionalApiKey : requiresApiKey
234
+ const canDiscoverModels = isBuiltin
235
+ ? Boolean(editingBuiltin?.supportsModelDiscovery)
236
+ : !!baseUrl.trim()
217
237
 
218
238
  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"
219
239
 
@@ -254,26 +274,45 @@ export function ProviderSheet() {
254
274
  <div className="mb-8">
255
275
  <div className="flex items-center justify-between mb-3">
256
276
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Models</label>
257
- {isBuiltin && (
258
- <button onClick={handleResetModels}
259
- className="text-[11px] text-text-3 hover:text-text-2 transition-colors cursor-pointer bg-transparent border-none"
260
- style={{ fontFamily: 'inherit' }}>
261
- Reset to defaults
262
- </button>
263
- )}
277
+ <div className="flex items-center gap-3">
278
+ {canDiscoverModels && (
279
+ <button
280
+ onClick={() => { void handleLoadLiveModels(Boolean(liveModels.length)) }}
281
+ disabled={liveLoading}
282
+ className="text-[11px] text-accent-bright hover:brightness-110 transition-colors cursor-pointer bg-transparent border-none disabled:opacity-50 disabled:cursor-default"
283
+ style={{ fontFamily: 'inherit' }}
284
+ >
285
+ {liveLoading ? 'Loading live models...' : liveModels.length > 0 ? 'Refresh live list' : 'Load live models'}
286
+ </button>
287
+ )}
288
+ {isBuiltin && (
289
+ <button onClick={handleResetModels}
290
+ className="text-[11px] text-text-3 hover:text-text-2 transition-colors cursor-pointer bg-transparent border-none"
291
+ style={{ fontFamily: 'inherit' }}>
292
+ Reset to defaults
293
+ </button>
294
+ )}
295
+ </div>
264
296
  </div>
265
297
 
298
+ {(liveMessage || liveCached) && (
299
+ <p className="text-[11px] text-text-3/70 mb-3">
300
+ {liveMessage}
301
+ {liveCached ? ' Cached.' : ''}
302
+ </p>
303
+ )}
304
+
266
305
  {isBuiltin ? (
267
306
  <>
268
307
  <div className="flex flex-wrap gap-1.5 mb-3">
269
308
  {modelList.map((model, i) => {
270
- const isLocal = editingId === 'ollama' && localModels.includes(model)
309
+ const isLive = liveModels.includes(model)
271
310
  return (
272
311
  <div key={`${model}-${i}`} className={`group/model flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border
273
- ${isLocal ? 'bg-emerald-500/[0.08] border-emerald-500/20' : 'bg-white/[0.04] border-white/[0.06]'}`}>
312
+ ${isLive ? 'bg-emerald-500/[0.08] border-emerald-500/20' : 'bg-white/[0.04] border-white/[0.06]'}`}>
274
313
  <span className="text-[12px] text-text-2 font-mono">{model}</span>
275
- {isLocal && (
276
- <span className="text-[9px] font-600 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 text-emerald-400 uppercase tracking-wider">local</span>
314
+ {isLive && (
315
+ <span className="text-[9px] font-600 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 text-emerald-400 uppercase tracking-wider">live</span>
277
316
  )}
278
317
  <button
279
318
  onClick={() => handleRemoveModel(i)}
@@ -288,34 +327,6 @@ export function ProviderSheet() {
288
327
  })}
289
328
  </div>
290
329
 
291
- {/* Ollama: show available local models not yet in the list */}
292
- {editingId === 'ollama' && !localLoading && localModels.length > 0 && (() => {
293
- const missing = localModels.filter((m) => !modelList.includes(m))
294
- if (missing.length === 0) return null
295
- return (
296
- <div className="mb-3">
297
- <p className="text-[11px] text-text-3/60 mb-2">Available locally — click to add:</p>
298
- <div className="flex flex-wrap gap-1.5">
299
- {missing.map((m) => (
300
- <button key={m} onClick={() => { setModels(models ? models + ', ' + m : m) }}
301
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] bg-emerald-500/[0.05] border border-emerald-500/15
302
- hover:bg-emerald-500/10 transition-all cursor-pointer text-[12px] text-emerald-300/80 font-mono"
303
- style={{ fontFamily: 'inherit' }}>
304
- <span>+</span> {m}
305
- </button>
306
- ))}
307
- </div>
308
- </div>
309
- )
310
- })()}
311
-
312
- {editingId === 'ollama' && localLoading && (
313
- <p className="text-[11px] text-text-3/70 mb-3">Checking local Ollama instance...</p>
314
- )}
315
- {editingId === 'ollama' && localError && (
316
- <p className="text-[11px] text-amber-400/60 mb-3">{localError}</p>
317
- )}
318
-
319
330
  <div className="flex gap-2">
320
331
  <input
321
332
  type="text"
@@ -431,13 +442,13 @@ export function ProviderSheet() {
431
442
  onClick={async () => {
432
443
  setSavingKey(true)
433
444
  try {
434
- const cred = await api<any>('POST', '/credentials', { provider: editingId || name || 'custom', name: newKeyName.trim() || `${name || editingId || 'Custom'} key`, apiKey: newKeyValue.trim() })
445
+ const cred = await api<{ id: string }>('POST', '/credentials', { provider: editingId || name || 'custom', name: newKeyName.trim() || `${name || editingId || 'Custom'} key`, apiKey: newKeyValue.trim() })
435
446
  await loadCredentials()
436
447
  setCredentialId(cred.id)
437
448
  setAddingKey(false)
438
449
  setNewKeyName('')
439
450
  setNewKeyValue('')
440
- } catch (err: any) { toast.error(`Failed to save: ${err.message}`) }
451
+ } catch (err: unknown) { toast.error(`Failed to save: ${err instanceof Error ? err.message : String(err)}`) }
441
452
  finally { setSavingKey(false) }
442
453
  }}
443
454
  className="px-4 py-1.5 rounded-[8px] bg-accent-bright text-white text-[12px] font-600 cursor-pointer border-none hover:brightness-110 transition-all disabled:opacity-40"
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import type { ReactNode } from 'react'
4
+ import { XIcon } from 'lucide-react'
5
+ import { Dialog as DialogPrimitive } from 'radix-ui'
4
6
 
5
7
  interface Props {
6
8
  open: boolean
@@ -10,21 +12,35 @@ interface Props {
10
12
  }
11
13
 
12
14
  export function BottomSheet({ open, onClose, children, wide }: Props) {
13
- if (!open) return null
14
-
15
15
  return (
16
- <div className="fixed inset-0 z-100 flex items-center justify-center p-6">
17
- <div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
18
- <div
19
- className={`relative bg-raised w-full ${wide ? 'max-w-[640px]' : 'max-w-[520px]'} max-h-[85vh] flex flex-col
20
- rounded-[24px] border border-white/[0.06]
21
- shadow-[0_24px_80px_rgba(0,0,0,0.6),0_0_1px_rgba(255,255,255,0.05)]`}
22
- style={{ animation: 'modal-in 0.3s cubic-bezier(0.16, 1, 0.3, 1)' }}
23
- >
24
- <div className="flex-1 overflow-y-auto px-8 pt-8 pb-8">
25
- {children}
26
- </div>
27
- </div>
28
- </div>
16
+ <DialogPrimitive.Root open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
17
+ <DialogPrimitive.Portal>
18
+ <DialogPrimitive.Overlay
19
+ className="fixed inset-0 z-100 bg-black/72 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
20
+ />
21
+ <DialogPrimitive.Content
22
+ className={`fixed inset-x-0 bottom-0 z-100 mx-auto flex max-h-[92vh] w-full flex-col bg-raised shadow-[0_24px_80px_rgba(0,0,0,0.6),0_0_1px_rgba(255,255,255,0.05)] outline-none
23
+ rounded-t-[24px] border border-white/[0.06]
24
+ data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom
25
+ sm:inset-x-auto sm:top-[50%] sm:bottom-auto sm:left-[50%] sm:w-[calc(100%-2rem)] sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-[24px]
26
+ sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95
27
+ ${wide ? 'sm:max-w-[760px]' : 'sm:max-w-[560px]'}`}
28
+ style={{ animationDuration: '220ms' }}
29
+ >
30
+ <div className="relative shrink-0 px-4 pt-3 sm:px-5 sm:pt-5">
31
+ <div className="mx-auto h-1 w-10 rounded-full bg-white/[0.08] sm:hidden" />
32
+ <DialogPrimitive.Close
33
+ className="absolute right-3 top-2.5 inline-flex h-9 w-9 items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 sm:right-4 sm:top-4"
34
+ >
35
+ <XIcon className="size-4" />
36
+ <span className="sr-only">Close</span>
37
+ </DialogPrimitive.Close>
38
+ </div>
39
+ <div className="flex-1 overflow-y-auto px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-3 sm:px-8 sm:pb-8 sm:pt-5">
40
+ {children}
41
+ </div>
42
+ </DialogPrimitive.Content>
43
+ </DialogPrimitive.Portal>
44
+ </DialogPrimitive.Root>
29
45
  )
30
46
  }