@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,481 @@
1
+ import crypto from 'crypto'
2
+ import { getProviderList } from '@/lib/providers'
3
+ import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
4
+ import { decryptKey, loadCredentials } from '@/lib/server/storage'
5
+ import type { ProviderInfo, ProviderModelDiscoveryResult } from '@/types'
6
+
7
+ type DiscoveryStrategy = 'openai-compatible' | 'anthropic' | 'google' | 'ollama' | 'openclaw'
8
+
9
+ interface DiscoveryDescriptor {
10
+ providerId: string
11
+ providerName: string
12
+ strategy: DiscoveryStrategy
13
+ endpoint?: string
14
+ requiresApiKey: boolean
15
+ optionalApiKey: boolean
16
+ supportsDiscovery: boolean
17
+ }
18
+
19
+ interface DiscoverProviderModelsInput {
20
+ providerId: string
21
+ credentialId?: string | null
22
+ endpoint?: string | null
23
+ force?: boolean
24
+ requiresApiKey?: boolean
25
+ }
26
+
27
+ interface DiscoveryCacheEntry {
28
+ expiresAt: number
29
+ value: ProviderModelDiscoveryResult
30
+ }
31
+
32
+ const CLOUD_CACHE_TTL_MS = 15 * 60_000
33
+ const LOCAL_CACHE_TTL_MS = 60_000
34
+ const ERROR_CACHE_TTL_MS = 30_000
35
+ const DISCOVERY_TIMEOUT_MS = 10_000
36
+ const gk = '__swarmclaw_provider_model_discovery__' as const
37
+
38
+ type DiscoveryGlobals = typeof globalThis & {
39
+ [gk]?: {
40
+ cache: Map<string, DiscoveryCacheEntry>
41
+ pending: Map<string, Promise<ProviderModelDiscoveryResult>>
42
+ }
43
+ }
44
+
45
+ const discoveryGlobals = globalThis as DiscoveryGlobals
46
+ const discoveryState = discoveryGlobals[gk] ?? (discoveryGlobals[gk] = {
47
+ cache: new Map<string, DiscoveryCacheEntry>(),
48
+ pending: new Map<string, Promise<ProviderModelDiscoveryResult>>(),
49
+ })
50
+
51
+ function clean(value: string | null | undefined): string {
52
+ return typeof value === 'string' ? value.trim() : ''
53
+ }
54
+
55
+ function normalizeEndpoint(raw: string | null | undefined, fallback = ''): string {
56
+ return (clean(raw) || fallback).replace(/\/+$/, '')
57
+ }
58
+
59
+ function supportsBuiltInModelDiscovery(providerId: string): boolean {
60
+ return !['claude-cli', 'codex-cli', 'opencode-cli', 'fireworks'].includes(providerId)
61
+ }
62
+
63
+ function normalizeGoogleModelsEndpoint(raw: string | null | undefined): string {
64
+ const fallback = 'https://generativelanguage.googleapis.com/v1beta'
65
+ const normalized = normalizeEndpoint(raw, fallback)
66
+ .replace(/\/openai$/i, '')
67
+ .replace(/\/models$/i, '')
68
+ return `${normalized}/models`
69
+ }
70
+
71
+ function resolveProviderInfo(providerId: string): ProviderInfo | null {
72
+ return getProviderList().find((provider) => provider.id === providerId) || null
73
+ }
74
+
75
+ function resolveDescriptor(input: DiscoverProviderModelsInput): DiscoveryDescriptor | null {
76
+ const providerId = clean(input.providerId)
77
+ const provider = resolveProviderInfo(providerId)
78
+ const requiresApiKeyOverride = typeof input.requiresApiKey === 'boolean' ? input.requiresApiKey : undefined
79
+
80
+ if (providerId === 'custom') {
81
+ const endpoint = normalizeEndpoint(input.endpoint)
82
+ if (!endpoint) return null
83
+ return {
84
+ providerId,
85
+ providerName: 'Custom Provider',
86
+ strategy: 'openai-compatible',
87
+ endpoint,
88
+ requiresApiKey: requiresApiKeyOverride ?? true,
89
+ optionalApiKey: false,
90
+ supportsDiscovery: true,
91
+ }
92
+ }
93
+
94
+ if (providerId === 'openclaw') {
95
+ return {
96
+ providerId,
97
+ providerName: 'OpenClaw',
98
+ strategy: 'openclaw',
99
+ endpoint: normalizeEndpoint(input.endpoint, 'http://localhost:18789'),
100
+ requiresApiKey: requiresApiKeyOverride ?? false,
101
+ optionalApiKey: true,
102
+ supportsDiscovery: true,
103
+ }
104
+ }
105
+
106
+ if (!provider) return null
107
+ const supportsDiscovery = provider.supportsModelDiscovery ?? supportsBuiltInModelDiscovery(providerId)
108
+ if (!supportsDiscovery) {
109
+ return {
110
+ providerId,
111
+ providerName: provider.name,
112
+ strategy: 'openai-compatible',
113
+ endpoint: undefined,
114
+ requiresApiKey: provider.requiresApiKey,
115
+ optionalApiKey: Boolean(provider.optionalApiKey),
116
+ supportsDiscovery: false,
117
+ }
118
+ }
119
+
120
+ if (providerId === 'anthropic') {
121
+ return {
122
+ providerId,
123
+ providerName: provider.name,
124
+ strategy: 'anthropic',
125
+ requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
126
+ optionalApiKey: Boolean(provider.optionalApiKey),
127
+ supportsDiscovery,
128
+ }
129
+ }
130
+
131
+ if (providerId === 'google') {
132
+ return {
133
+ providerId,
134
+ providerName: provider.name,
135
+ strategy: 'google',
136
+ endpoint: normalizeGoogleModelsEndpoint(input.endpoint || provider.defaultEndpoint || ''),
137
+ requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
138
+ optionalApiKey: Boolean(provider.optionalApiKey),
139
+ supportsDiscovery,
140
+ }
141
+ }
142
+
143
+ if (providerId === 'ollama') {
144
+ return {
145
+ providerId,
146
+ providerName: provider.name,
147
+ strategy: 'ollama',
148
+ endpoint: normalizeEndpoint(input.endpoint, provider.defaultEndpoint || 'http://localhost:11434'),
149
+ requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
150
+ optionalApiKey: Boolean(provider.optionalApiKey),
151
+ supportsDiscovery,
152
+ }
153
+ }
154
+
155
+ const openAiDefault = OPENAI_COMPATIBLE_DEFAULTS[providerId as keyof typeof OPENAI_COMPATIBLE_DEFAULTS]?.defaultEndpoint
156
+ const endpoint = normalizeEndpoint(input.endpoint, provider.defaultEndpoint || openAiDefault || '')
157
+ return {
158
+ providerId,
159
+ providerName: provider.name,
160
+ strategy: 'openai-compatible',
161
+ endpoint,
162
+ requiresApiKey: requiresApiKeyOverride ?? provider.requiresApiKey,
163
+ optionalApiKey: Boolean(provider.optionalApiKey),
164
+ supportsDiscovery,
165
+ }
166
+ }
167
+
168
+ function parseErrorMessage(text: string, fallback: string): string {
169
+ const body = text.trim()
170
+ if (!body) return fallback
171
+ try {
172
+ const parsed = JSON.parse(body)
173
+ if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
174
+ if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
175
+ if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
176
+ if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
177
+ } catch {
178
+ // Ignore invalid JSON and fall back to the raw text.
179
+ }
180
+ return body.slice(0, 300) || fallback
181
+ }
182
+
183
+ function resolveCredentialApiKey(credentialId: string | null | undefined): string | null {
184
+ const id = clean(credentialId)
185
+ if (!id) return null
186
+ try {
187
+ const credentials = loadCredentials()
188
+ const credential = credentials[id]
189
+ if (!credential?.encryptedKey) return null
190
+ return decryptKey(credential.encryptedKey)
191
+ } catch {
192
+ return null
193
+ }
194
+ }
195
+
196
+ function hashApiKey(apiKey: string | null): string {
197
+ if (!apiKey) return 'anon'
198
+ return crypto.createHash('sha1').update(apiKey).digest('hex').slice(0, 12)
199
+ }
200
+
201
+ function dedupeModels(models: string[]): string[] {
202
+ const seen = new Set<string>()
203
+ const result: string[] = []
204
+ for (const model of models) {
205
+ const trimmed = model.trim()
206
+ if (!trimmed) continue
207
+ if (seen.has(trimmed)) continue
208
+ seen.add(trimmed)
209
+ result.push(trimmed)
210
+ }
211
+ return result
212
+ }
213
+
214
+ function normalizeModelId(modelId: string, strategy: DiscoveryStrategy): string {
215
+ const trimmed = modelId.trim()
216
+ if (!trimmed) return ''
217
+ if (strategy === 'ollama') return trimmed.replace(/:latest$/i, '')
218
+ if (strategy === 'google' && trimmed.startsWith('models/')) return trimmed.slice('models/'.length)
219
+ return trimmed
220
+ }
221
+
222
+ function looksLikeChatModel(providerId: string, modelId: string): boolean {
223
+ const normalized = modelId.toLowerCase()
224
+ if (!normalized) return false
225
+
226
+ const universalExclusions = [
227
+ 'embedding',
228
+ 'rerank',
229
+ 'moderation',
230
+ 'whisper',
231
+ 'transcribe',
232
+ 'transcription',
233
+ 'tts',
234
+ 'speech',
235
+ 'text-to-speech',
236
+ 'stable-diffusion',
237
+ 'sdxl',
238
+ 'flux',
239
+ 'playground-v2',
240
+ 'pix2pix',
241
+ 'clip',
242
+ ]
243
+ if (universalExclusions.some((token) => normalized.includes(token))) return false
244
+
245
+ if (providerId === 'openai') return /^(gpt-|o1($|-)|o3($|-)|o4($|-)|chatgpt-)/.test(normalized)
246
+ if (providerId === 'anthropic') return normalized.startsWith('claude-')
247
+ if (providerId === 'google') return normalized.startsWith('gemini-')
248
+ if (providerId === 'deepseek') return normalized.startsWith('deepseek-')
249
+ if (providerId === 'xai') return normalized.startsWith('grok-')
250
+
251
+ return true
252
+ }
253
+
254
+ function extractCandidateModelIds(payload: unknown, strategy: DiscoveryStrategy): string[] {
255
+ const source = payload as {
256
+ data?: unknown[]
257
+ models?: unknown[]
258
+ }
259
+ const candidates: string[] = []
260
+ const append = (value: unknown) => {
261
+ if (typeof value === 'string' && value.trim()) candidates.push(value.trim())
262
+ }
263
+
264
+ const readCollection = (items: unknown[] | undefined) => {
265
+ if (!Array.isArray(items)) return
266
+ for (const item of items) {
267
+ if (typeof item === 'string') {
268
+ append(item)
269
+ continue
270
+ }
271
+ if (!item || typeof item !== 'object') continue
272
+ const record = item as { id?: unknown; name?: unknown; model?: unknown; baseModelId?: unknown }
273
+ append(record.id)
274
+ append(record.name)
275
+ append(record.model)
276
+ append(record.baseModelId)
277
+ }
278
+ }
279
+
280
+ if (Array.isArray(payload)) readCollection(payload)
281
+ readCollection(source.data)
282
+ readCollection(source.models)
283
+
284
+ const normalized = candidates
285
+ .map((candidate) => normalizeModelId(candidate, strategy))
286
+ .filter(Boolean)
287
+ return dedupeModels(normalized)
288
+ }
289
+
290
+ function extractDiscoveredModels(
291
+ providerId: string,
292
+ strategy: DiscoveryStrategy,
293
+ payload: unknown,
294
+ ): { models: string[]; rawCount: number } {
295
+ const candidates = extractCandidateModelIds(payload, strategy)
296
+ const filtered = strategy === 'ollama'
297
+ ? candidates
298
+ : candidates.filter((candidate) => looksLikeChatModel(providerId, candidate))
299
+ return {
300
+ models: dedupeModels(filtered),
301
+ rawCount: candidates.length,
302
+ }
303
+ }
304
+
305
+ function ttlForDescriptor(descriptor: DiscoveryDescriptor, ok: boolean): number {
306
+ if (!ok) return ERROR_CACHE_TTL_MS
307
+ if (descriptor.strategy === 'ollama' || descriptor.strategy === 'openclaw') return LOCAL_CACHE_TTL_MS
308
+ return CLOUD_CACHE_TTL_MS
309
+ }
310
+
311
+ function buildCacheKey(
312
+ descriptor: DiscoveryDescriptor,
313
+ credentialId: string | null | undefined,
314
+ apiKey: string | null,
315
+ ): string {
316
+ return [
317
+ descriptor.providerId,
318
+ descriptor.strategy,
319
+ descriptor.endpoint || '',
320
+ clean(credentialId),
321
+ hashApiKey(apiKey),
322
+ ].join('::')
323
+ }
324
+
325
+ async function fetchModelsFromProvider(
326
+ descriptor: DiscoveryDescriptor,
327
+ apiKey: string | null,
328
+ ): Promise<{ ok: boolean; models: string[]; message: string }> {
329
+ const headers: Record<string, string> = {}
330
+ let url = descriptor.endpoint || ''
331
+
332
+ if (descriptor.strategy === 'anthropic') {
333
+ url = 'https://api.anthropic.com/v1/models'
334
+ if (apiKey) headers['x-api-key'] = apiKey
335
+ headers['anthropic-version'] = '2023-06-01'
336
+ } else if (descriptor.strategy === 'google') {
337
+ url = descriptor.endpoint || normalizeGoogleModelsEndpoint('')
338
+ if (apiKey) {
339
+ const searchParams = new URLSearchParams({ key: apiKey })
340
+ url = `${url}?${searchParams.toString()}`
341
+ }
342
+ } else if (descriptor.strategy === 'ollama') {
343
+ url = `${descriptor.endpoint}/api/tags`
344
+ } else {
345
+ url = `${descriptor.endpoint}/models`
346
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`
347
+ }
348
+
349
+ const res = await fetch(url, {
350
+ headers,
351
+ signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
352
+ cache: 'no-store',
353
+ })
354
+ if (!res.ok) {
355
+ const text = await res.text().catch(() => '')
356
+ return {
357
+ ok: false,
358
+ models: [],
359
+ message: parseErrorMessage(text, `${descriptor.providerName} returned ${res.status}.`),
360
+ }
361
+ }
362
+
363
+ const payload = await res.json().catch(() => ({}))
364
+ const { models, rawCount } = extractDiscoveredModels(descriptor.providerId, descriptor.strategy, payload)
365
+ if (models.length === 0) {
366
+ return {
367
+ ok: true,
368
+ models: [],
369
+ message: rawCount > 0
370
+ ? `${descriptor.providerName} returned ${rawCount} model(s), but none looked chat-capable.`
371
+ : `${descriptor.providerName} did not report any models.`,
372
+ }
373
+ }
374
+
375
+ const message = rawCount > models.length
376
+ ? `${descriptor.providerName} returned ${rawCount} model(s); showing ${models.length} likely chat models.`
377
+ : `${descriptor.providerName} returned ${models.length} live model(s).`
378
+ return { ok: true, models, message }
379
+ }
380
+
381
+ function buildResult(
382
+ descriptor: DiscoveryDescriptor,
383
+ data: Partial<ProviderModelDiscoveryResult> & Pick<ProviderModelDiscoveryResult, 'ok' | 'models'>,
384
+ ): ProviderModelDiscoveryResult {
385
+ return {
386
+ ok: data.ok,
387
+ providerId: descriptor.providerId,
388
+ providerName: descriptor.providerName,
389
+ models: data.models,
390
+ cached: Boolean(data.cached),
391
+ fetchedAt: data.fetchedAt ?? Date.now(),
392
+ cacheTtlMs: data.cacheTtlMs ?? ttlForDescriptor(descriptor, data.ok),
393
+ supportsDiscovery: descriptor.supportsDiscovery,
394
+ missingCredential: data.missingCredential,
395
+ message: data.message,
396
+ }
397
+ }
398
+
399
+ export async function discoverProviderModels(
400
+ input: DiscoverProviderModelsInput,
401
+ ): Promise<ProviderModelDiscoveryResult> {
402
+ const descriptor = resolveDescriptor(input)
403
+ if (!descriptor) {
404
+ return {
405
+ ok: false,
406
+ providerId: clean(input.providerId) || 'unknown',
407
+ providerName: undefined,
408
+ models: [],
409
+ cached: false,
410
+ fetchedAt: Date.now(),
411
+ cacheTtlMs: ERROR_CACHE_TTL_MS,
412
+ supportsDiscovery: false,
413
+ message: 'Live model discovery is not available for this provider configuration.',
414
+ }
415
+ }
416
+
417
+ if (!descriptor.supportsDiscovery) {
418
+ return buildResult(descriptor, {
419
+ ok: false,
420
+ models: [],
421
+ message: 'This provider does not expose a live model catalog here. You can still type a model name manually.',
422
+ })
423
+ }
424
+
425
+ const apiKey = resolveCredentialApiKey(input.credentialId)
426
+ if (descriptor.requiresApiKey && !apiKey) {
427
+ return buildResult(descriptor, {
428
+ ok: false,
429
+ models: [],
430
+ missingCredential: true,
431
+ message: 'Add an API key to fetch the live model list. Manual model entry still works.',
432
+ })
433
+ }
434
+
435
+ const cacheKey = buildCacheKey(descriptor, input.credentialId, apiKey)
436
+ const now = Date.now()
437
+ if (!input.force) {
438
+ const cached = discoveryState.cache.get(cacheKey)
439
+ if (cached && cached.expiresAt > now) {
440
+ return { ...cached.value, cached: true }
441
+ }
442
+ const pending = discoveryState.pending.get(cacheKey)
443
+ if (pending) return pending
444
+ }
445
+
446
+ const promise = (async () => {
447
+ const fetchedAt = Date.now()
448
+ try {
449
+ const result = await fetchModelsFromProvider(descriptor, apiKey)
450
+ const built = buildResult(descriptor, {
451
+ ok: result.ok,
452
+ models: result.models,
453
+ message: result.message,
454
+ fetchedAt,
455
+ })
456
+ discoveryState.cache.set(cacheKey, {
457
+ expiresAt: fetchedAt + ttlForDescriptor(descriptor, result.ok),
458
+ value: built,
459
+ })
460
+ return built
461
+ } catch (error) {
462
+ const message = error instanceof Error ? error.message : 'Failed to fetch live models.'
463
+ const built = buildResult(descriptor, {
464
+ ok: false,
465
+ models: [],
466
+ message,
467
+ fetchedAt,
468
+ })
469
+ discoveryState.cache.set(cacheKey, {
470
+ expiresAt: fetchedAt + ERROR_CACHE_TTL_MS,
471
+ value: built,
472
+ })
473
+ return built
474
+ } finally {
475
+ discoveryState.pending.delete(cacheKey)
476
+ }
477
+ })()
478
+
479
+ discoveryState.pending.set(cacheKey, promise)
480
+ return promise
481
+ }
@@ -550,7 +550,7 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
550
550
  if (task.status !== 'completed' && task.status !== 'failed') return
551
551
 
552
552
  const sessions = loadSessions()
553
- const ownerUser = resolveTaskOwnerUser(scheduleTask, sessions as Record<string, SessionLike>)
553
+ void resolveTaskOwnerUser(scheduleTask, sessions as Record<string, SessionLike>)
554
554
  const scheduleNameRaw = typeof scheduleTask.sourceScheduleName === 'string'
555
555
  ? scheduleTask.sourceScheduleName.trim()
556
556
  : ''
@@ -0,0 +1,119 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-runtime-settings-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: tempDir,
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('runtime settings defaults', () => {
36
+ it('backfills explicit runtime defaults for clean installs', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('./src/lib/server/storage.ts')
39
+ const runtimeMod = await import('./src/lib/server/runtime-settings.ts')
40
+ const storage = storageMod.default || storageMod
41
+ const runtime = runtimeMod.default || runtimeMod
42
+ console.log(JSON.stringify({
43
+ settings: storage.loadSettings(),
44
+ runtime: runtime.loadRuntimeSettings(),
45
+ }))
46
+ `)
47
+
48
+ assert.equal(output.settings.loopMode, 'bounded')
49
+ assert.equal(output.settings.agentLoopRecursionLimit, 60)
50
+ assert.equal(output.settings.orchestratorLoopRecursionLimit, 80)
51
+ assert.equal(output.settings.legacyOrchestratorMaxTurns, 16)
52
+ assert.equal(output.settings.ongoingLoopMaxIterations, 250)
53
+ assert.equal(output.settings.ongoingLoopMaxRuntimeMinutes, 60)
54
+ assert.equal(output.settings.delegationMaxDepth, 3)
55
+ assert.equal(output.settings.shellCommandTimeoutSec, 30)
56
+ assert.equal(output.settings.claudeCodeTimeoutSec, 1800)
57
+ assert.equal(output.settings.cliProcessTimeoutSec, 1800)
58
+ assert.equal(output.settings.heartbeatIntervalSec, 1800)
59
+ assert.equal(output.settings.heartbeatAckMaxChars, 300)
60
+ assert.equal(output.settings.heartbeatShowOk, false)
61
+ assert.equal(output.settings.heartbeatShowAlerts, true)
62
+ assert.equal(output.settings.heartbeatTarget, null)
63
+ assert.equal(output.settings.heartbeatPrompt, null)
64
+ assert.equal(output.runtime.agentLoopRecursionLimit, 60)
65
+ assert.equal(output.runtime.orchestratorLoopRecursionLimit, 80)
66
+ assert.equal(output.runtime.legacyOrchestratorMaxTurns, 16)
67
+ })
68
+
69
+ it('clamps invalid persisted runtime settings into the supported range', () => {
70
+ const output = runWithTempDataDir(`
71
+ const storageMod = await import('./src/lib/server/storage.ts')
72
+ const runtimeMod = await import('./src/lib/server/runtime-settings.ts')
73
+ const storage = storageMod.default || storageMod
74
+ const runtime = runtimeMod.default || runtimeMod
75
+
76
+ storage.saveSettings({
77
+ loopMode: 'invalid',
78
+ agentLoopRecursionLimit: 999,
79
+ orchestratorLoopRecursionLimit: -5,
80
+ legacyOrchestratorMaxTurns: 0,
81
+ ongoingLoopMaxIterations: 999999,
82
+ ongoingLoopMaxRuntimeMinutes: -1,
83
+ delegationMaxDepth: 99,
84
+ shellCommandTimeoutSec: 0,
85
+ claudeCodeTimeoutSec: 999999,
86
+ cliProcessTimeoutSec: 'abc',
87
+ heartbeatIntervalSec: 999999,
88
+ heartbeatAckMaxChars: -50,
89
+ heartbeatShowOk: 'yes',
90
+ heartbeatShowAlerts: 'off',
91
+ heartbeatTarget: ' ',
92
+ heartbeatPrompt: ' ',
93
+ })
94
+
95
+ console.log(JSON.stringify({
96
+ settings: storage.loadSettings(),
97
+ runtime: runtime.loadRuntimeSettings(),
98
+ }))
99
+ `)
100
+
101
+ assert.equal(output.settings.loopMode, 'bounded')
102
+ assert.equal(output.settings.agentLoopRecursionLimit, 200)
103
+ assert.equal(output.settings.orchestratorLoopRecursionLimit, 1)
104
+ assert.equal(output.settings.legacyOrchestratorMaxTurns, 1)
105
+ assert.equal(output.settings.ongoingLoopMaxIterations, 5000)
106
+ assert.equal(output.settings.ongoingLoopMaxRuntimeMinutes, 0)
107
+ assert.equal(output.settings.delegationMaxDepth, 12)
108
+ assert.equal(output.settings.shellCommandTimeoutSec, 1)
109
+ assert.equal(output.settings.claudeCodeTimeoutSec, 7200)
110
+ assert.equal(output.settings.cliProcessTimeoutSec, 1800)
111
+ assert.equal(output.settings.heartbeatIntervalSec, 86400)
112
+ assert.equal(output.settings.heartbeatAckMaxChars, 0)
113
+ assert.equal(output.settings.heartbeatShowOk, true)
114
+ assert.equal(output.settings.heartbeatShowAlerts, false)
115
+ assert.equal(output.settings.heartbeatTarget, null)
116
+ assert.equal(output.settings.heartbeatPrompt, null)
117
+ assert.equal(output.runtime.ongoingLoopMaxRuntimeMs, null)
118
+ })
119
+ })