@swarmclawai/swarmclaw 0.6.8 → 0.7.0

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 (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +179 -349
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -0,0 +1,102 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import type { Message } from '@/types'
4
+ import {
5
+ buildLlmResponseCacheKey,
6
+ clearLlmResponseCache,
7
+ getCachedLlmResponse,
8
+ resolveLlmResponseCacheConfig,
9
+ setCachedLlmResponse,
10
+ } from './llm-response-cache.ts'
11
+
12
+ const HISTORY: Message[] = [
13
+ { role: 'user', text: 'Plan a release.', time: 1 },
14
+ { role: 'assistant', text: 'Drafted plan.', time: 2 },
15
+ ]
16
+
17
+ test('buildLlmResponseCacheKey is deterministic for equivalent payloads', () => {
18
+ const keyA = buildLlmResponseCacheKey({
19
+ provider: 'openai',
20
+ model: 'gpt-4o-mini',
21
+ apiEndpoint: 'https://api.openai.com/v1',
22
+ systemPrompt: 'System prompt',
23
+ message: 'hello',
24
+ history: HISTORY,
25
+ attachedFiles: ['a.txt', 'b.txt'],
26
+ })
27
+ const keyB = buildLlmResponseCacheKey({
28
+ provider: 'openai',
29
+ model: 'gpt-4o-mini',
30
+ apiEndpoint: 'https://api.openai.com/v1',
31
+ systemPrompt: ' System prompt ',
32
+ message: 'hello',
33
+ history: [...HISTORY],
34
+ attachedFiles: ['a.txt', 'b.txt'],
35
+ })
36
+ assert.equal(keyA, keyB)
37
+ })
38
+
39
+ test('set/get cached responses returns hit and increments hit count', () => {
40
+ clearLlmResponseCache()
41
+ const config = { enabled: true, ttlMs: 60_000, maxEntries: 10 }
42
+ const keyInput = {
43
+ provider: 'openai',
44
+ model: 'gpt-4o',
45
+ message: 'status',
46
+ history: HISTORY,
47
+ }
48
+ setCachedLlmResponse(keyInput, 'cached answer', config, 1000)
49
+ const hit1 = getCachedLlmResponse(keyInput, config, 1500)
50
+ assert.ok(hit1)
51
+ assert.equal(hit1?.text, 'cached answer')
52
+ assert.equal(hit1?.hits, 1)
53
+ const hit2 = getCachedLlmResponse(keyInput, config, 1600)
54
+ assert.equal(hit2?.hits, 2)
55
+ })
56
+
57
+ test('expired cache entry is not returned', () => {
58
+ clearLlmResponseCache()
59
+ const config = { enabled: true, ttlMs: 1000, maxEntries: 10 }
60
+ const keyInput = {
61
+ provider: 'openai',
62
+ model: 'gpt-4o',
63
+ message: 'status',
64
+ history: HISTORY,
65
+ }
66
+ setCachedLlmResponse(keyInput, 'stale', config, 1000)
67
+ const miss = getCachedLlmResponse(keyInput, config, 3001)
68
+ assert.equal(miss, null)
69
+ })
70
+
71
+ test('cache evicts least recently used entries over maxEntries', () => {
72
+ clearLlmResponseCache()
73
+ const config = { enabled: true, ttlMs: 60_000, maxEntries: 2 }
74
+ const inputA = { provider: 'openai', model: 'gpt-4o', message: 'a', history: HISTORY }
75
+ const inputB = { provider: 'openai', model: 'gpt-4o', message: 'b', history: HISTORY }
76
+ const inputC = { provider: 'openai', model: 'gpt-4o', message: 'c', history: HISTORY }
77
+ setCachedLlmResponse(inputA, 'A', config, 1000)
78
+ setCachedLlmResponse(inputB, 'B', config, 1001)
79
+ // Touch A so B becomes LRU.
80
+ getCachedLlmResponse(inputA, config, 1002)
81
+ setCachedLlmResponse(inputC, 'C', config, 1003)
82
+
83
+ assert.equal(getCachedLlmResponse(inputB, config, 1004), null)
84
+ assert.equal(getCachedLlmResponse(inputA, config, 1004)?.text, 'A')
85
+ assert.equal(getCachedLlmResponse(inputC, config, 1004)?.text, 'C')
86
+ })
87
+
88
+ test('resolveLlmResponseCacheConfig applies defaults and bounds', () => {
89
+ const fallback = resolveLlmResponseCacheConfig({})
90
+ assert.equal(fallback.enabled, true)
91
+ assert.equal(fallback.ttlMs, 900_000)
92
+ assert.equal(fallback.maxEntries, 500)
93
+
94
+ const custom = resolveLlmResponseCacheConfig({
95
+ responseCacheEnabled: false,
96
+ responseCacheTtlSec: 1,
97
+ responseCacheMaxEntries: 999999,
98
+ })
99
+ assert.equal(custom.enabled, false)
100
+ assert.equal(custom.ttlMs, 5000)
101
+ assert.equal(custom.maxEntries, 20_000)
102
+ })
@@ -0,0 +1,227 @@
1
+ import crypto from 'node:crypto'
2
+ import type { AppSettings, Message } from '@/types'
3
+
4
+ export interface LlmResponseCacheConfig {
5
+ enabled: boolean
6
+ ttlMs: number
7
+ maxEntries: number
8
+ }
9
+
10
+ export interface LlmResponseCacheKeyInput {
11
+ provider: string
12
+ model: string
13
+ apiEndpoint?: string | null
14
+ systemPrompt?: string
15
+ message: string
16
+ imagePath?: string
17
+ imageUrl?: string
18
+ attachedFiles?: string[]
19
+ history: Message[]
20
+ }
21
+
22
+ export interface LlmResponseCacheHit {
23
+ key: string
24
+ text: string
25
+ provider: string
26
+ model: string
27
+ createdAt: number
28
+ ageMs: number
29
+ hits: number
30
+ }
31
+
32
+ interface LlmResponseCacheEntry {
33
+ key: string
34
+ text: string
35
+ provider: string
36
+ model: string
37
+ createdAt: number
38
+ expiresAt: number
39
+ hits: number
40
+ }
41
+
42
+ const DEFAULT_ENABLED = true
43
+ const DEFAULT_TTL_SEC = 15 * 60
44
+ const DEFAULT_MAX_ENTRIES = 500
45
+
46
+ const MIN_TTL_SEC = 5
47
+ const MAX_TTL_SEC = 7 * 24 * 3600
48
+ const MIN_ENTRIES = 1
49
+ const MAX_ENTRIES = 20_000
50
+
51
+ const responseCache = new Map<string, LlmResponseCacheEntry>()
52
+
53
+ function normalizeText(value: unknown): string {
54
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
55
+ }
56
+
57
+ function normalizeList(value: unknown): string[] {
58
+ if (!Array.isArray(value)) return []
59
+ return value
60
+ .filter((entry): entry is string => typeof entry === 'string')
61
+ .map((entry) => entry.trim())
62
+ .filter(Boolean)
63
+ }
64
+
65
+ function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
66
+ const parsed = typeof value === 'number'
67
+ ? value
68
+ : typeof value === 'string'
69
+ ? Number.parseInt(value, 10)
70
+ : Number.NaN
71
+ if (!Number.isFinite(parsed)) return fallback
72
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
73
+ }
74
+
75
+ function normalizeBool(value: unknown, fallback: boolean): boolean {
76
+ if (typeof value === 'boolean') return value
77
+ if (typeof value === 'string') {
78
+ const normalized = value.trim().toLowerCase()
79
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
80
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
81
+ }
82
+ return fallback
83
+ }
84
+
85
+ function stableStringify(value: unknown): string {
86
+ if (value === null) return 'null'
87
+ const kind = typeof value
88
+ if (kind === 'number' || kind === 'boolean') return JSON.stringify(value)
89
+ if (kind === 'string') return JSON.stringify(value)
90
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
91
+ if (kind === 'object') {
92
+ const entries = Object.entries(value as Record<string, unknown>)
93
+ .filter(([, v]) => v !== undefined)
94
+ .sort(([a], [b]) => a.localeCompare(b))
95
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
96
+ }
97
+ return JSON.stringify(String(value))
98
+ }
99
+
100
+ function normalizeHistory(history: Message[]): Array<Record<string, unknown>> {
101
+ return history.map((entry) => ({
102
+ role: entry.role,
103
+ text: normalizeText(entry.text),
104
+ kind: entry.kind || null,
105
+ imagePath: entry.imagePath || null,
106
+ imageUrl: entry.imageUrl || null,
107
+ attachedFiles: normalizeList(entry.attachedFiles),
108
+ replyToId: entry.replyToId || null,
109
+ }))
110
+ }
111
+
112
+ function trimToCapacity(maxEntries: number): void {
113
+ while (responseCache.size > maxEntries) {
114
+ const oldestKey = responseCache.keys().next().value as string | undefined
115
+ if (!oldestKey) break
116
+ responseCache.delete(oldestKey)
117
+ }
118
+ }
119
+
120
+ function moveToMostRecent(key: string, entry: LlmResponseCacheEntry): void {
121
+ responseCache.delete(key)
122
+ responseCache.set(key, entry)
123
+ }
124
+
125
+ export function resolveLlmResponseCacheConfig(
126
+ settings?: AppSettings | Record<string, unknown> | null,
127
+ ): LlmResponseCacheConfig {
128
+ const raw = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {}
129
+ const ttlSec = normalizeInt(raw.responseCacheTtlSec, DEFAULT_TTL_SEC, MIN_TTL_SEC, MAX_TTL_SEC)
130
+ const maxEntries = normalizeInt(raw.responseCacheMaxEntries, DEFAULT_MAX_ENTRIES, MIN_ENTRIES, MAX_ENTRIES)
131
+ const enabled = normalizeBool(raw.responseCacheEnabled, DEFAULT_ENABLED)
132
+ return {
133
+ enabled,
134
+ ttlMs: ttlSec * 1000,
135
+ maxEntries,
136
+ }
137
+ }
138
+
139
+ export function buildLlmResponseCacheKey(input: LlmResponseCacheKeyInput): string {
140
+ const payload = {
141
+ provider: normalizeText(input.provider).toLowerCase(),
142
+ model: normalizeText(input.model),
143
+ apiEndpoint: normalizeText(input.apiEndpoint || ''),
144
+ systemPrompt: normalizeText(input.systemPrompt || ''),
145
+ message: normalizeText(input.message),
146
+ imagePath: normalizeText(input.imagePath || ''),
147
+ imageUrl: normalizeText(input.imageUrl || ''),
148
+ attachedFiles: normalizeList(input.attachedFiles),
149
+ history: normalizeHistory(Array.isArray(input.history) ? input.history : []),
150
+ }
151
+ const stable = stableStringify(payload)
152
+ return crypto.createHash('sha256').update(stable).digest('hex')
153
+ }
154
+
155
+ export function getCachedLlmResponse(
156
+ input: LlmResponseCacheKeyInput,
157
+ config: LlmResponseCacheConfig,
158
+ now = Date.now(),
159
+ ): LlmResponseCacheHit | null {
160
+ if (!config.enabled) return null
161
+ const key = buildLlmResponseCacheKey(input)
162
+ const found = responseCache.get(key)
163
+ if (!found) return null
164
+ if (now >= found.expiresAt) {
165
+ responseCache.delete(key)
166
+ return null
167
+ }
168
+ const next = { ...found, hits: found.hits + 1 }
169
+ moveToMostRecent(key, next)
170
+ return {
171
+ key,
172
+ text: next.text,
173
+ provider: next.provider,
174
+ model: next.model,
175
+ createdAt: next.createdAt,
176
+ ageMs: Math.max(0, now - next.createdAt),
177
+ hits: next.hits,
178
+ }
179
+ }
180
+
181
+ export function setCachedLlmResponse(
182
+ input: LlmResponseCacheKeyInput,
183
+ text: string,
184
+ config: LlmResponseCacheConfig,
185
+ now = Date.now(),
186
+ ): void {
187
+ if (!config.enabled) return
188
+ const normalizedText = normalizeText(text)
189
+ if (!normalizedText) return
190
+ const key = buildLlmResponseCacheKey(input)
191
+ const existing = responseCache.get(key)
192
+ const createdAt = existing?.createdAt ?? now
193
+ const entry: LlmResponseCacheEntry = {
194
+ key,
195
+ text: normalizedText,
196
+ provider: normalizeText(input.provider).toLowerCase(),
197
+ model: normalizeText(input.model),
198
+ createdAt,
199
+ expiresAt: now + config.ttlMs,
200
+ hits: existing?.hits ?? 0,
201
+ }
202
+ moveToMostRecent(key, entry)
203
+ trimToCapacity(config.maxEntries)
204
+ }
205
+
206
+ export function getLlmResponseCacheStats(now = Date.now()): {
207
+ entries: number
208
+ expired: number
209
+ oldestAgeMs: number
210
+ } {
211
+ let expired = 0
212
+ let oldestCreatedAt = Number.POSITIVE_INFINITY
213
+ for (const entry of responseCache.values()) {
214
+ if (entry.expiresAt <= now) expired++
215
+ oldestCreatedAt = Math.min(oldestCreatedAt, entry.createdAt)
216
+ }
217
+ const oldestAgeMs = Number.isFinite(oldestCreatedAt) ? Math.max(0, now - oldestCreatedAt) : 0
218
+ return {
219
+ entries: responseCache.size,
220
+ expired,
221
+ oldestAgeMs,
222
+ }
223
+ }
224
+
225
+ export function clearLlmResponseCache(): void {
226
+ responseCache.clear()
227
+ }
@@ -935,7 +935,7 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
935
935
  const sessions = loadSessions()
936
936
  const session = sessions[input.sessionId]
937
937
  if (!session) return null
938
- if (!isMainSession(session)) return handleAgentHeartbeatResult(session, input)
938
+ if (!isProtectedMainSession(session)) return handleAgentHeartbeatResult(session, input)
939
939
 
940
940
  const now = Date.now()
941
941
  const state = normalizeState(session.mainLoopState, now)
@@ -3,12 +3,15 @@ const MAIN_SESSION_NAME = '__main__'
3
3
  export function isProtectedMainSession(session: any): boolean {
4
4
  if (!session || typeof session !== 'object') return false
5
5
  if (session.mainSession === true) return true
6
-
7
- const name = typeof session.name === 'string' ? session.name.trim() : ''
8
- if (name === MAIN_SESSION_NAME) return true
6
+ if (session.sessionType === 'orchestrated') return true
9
7
 
10
8
  const id = typeof session.id === 'string' ? session.id.trim() : ''
11
9
  if (id.startsWith('main-')) return true
10
+ if (id.startsWith('agent-thread-')) return true
11
+
12
+ const name = typeof session.name === 'string' ? session.name.trim() : ''
13
+ if (name === MAIN_SESSION_NAME) return true
14
+ if (name.startsWith('agent-thread:')) return true
12
15
 
13
16
  return false
14
17
  }
@@ -0,0 +1,18 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { runMcpConformanceCheck } from './mcp-conformance.ts'
4
+
5
+ test('runMcpConformanceCheck reports connect/list failure for unsupported transport', async () => {
6
+ const result = await runMcpConformanceCheck({
7
+ id: 'bad',
8
+ name: 'Bad MCP',
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ transport: 'invalid-transport' as any,
11
+ createdAt: Date.now(),
12
+ updatedAt: Date.now(),
13
+ })
14
+
15
+ assert.equal(result.ok, false)
16
+ assert.equal(result.toolsCount, 0)
17
+ assert.ok(result.issues.some((issue) => issue.code === 'connect_or_list_failed'))
18
+ })
@@ -0,0 +1,233 @@
1
+ import type { McpServerConfig } from '@/types'
2
+ import { connectMcpServer, disconnectMcpServer } from './mcp-client'
3
+
4
+ export interface McpConformanceIssue {
5
+ level: 'error' | 'warning'
6
+ code: string
7
+ message: string
8
+ toolName?: string
9
+ }
10
+
11
+ export interface McpConformanceOptions {
12
+ timeoutMs?: number
13
+ smokeToolName?: string | null
14
+ smokeToolArgs?: Record<string, unknown> | null
15
+ }
16
+
17
+ export interface McpConformanceResult {
18
+ ok: boolean
19
+ serverId: string
20
+ serverName: string
21
+ checkedAt: number
22
+ toolsCount: number
23
+ smokeToolName: string | null
24
+ issues: McpConformanceIssue[]
25
+ timings: {
26
+ connectMs: number
27
+ listToolsMs: number
28
+ smokeInvokeMs: number | null
29
+ }
30
+ }
31
+
32
+ const DEFAULT_TIMEOUT_MS = 12_000
33
+ const MIN_TIMEOUT_MS = 1_000
34
+ const MAX_TIMEOUT_MS = 120_000
35
+
36
+ function normalizeTimeoutMs(value: unknown): number {
37
+ const parsed = typeof value === 'number'
38
+ ? value
39
+ : typeof value === 'string'
40
+ ? Number.parseInt(value, 10)
41
+ : Number.NaN
42
+ if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS
43
+ return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.trunc(parsed)))
44
+ }
45
+
46
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
47
+ return new Promise<T>((resolve, reject) => {
48
+ const timer = setTimeout(() => {
49
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`))
50
+ }, timeoutMs)
51
+ promise.then(
52
+ (value) => {
53
+ clearTimeout(timer)
54
+ resolve(value)
55
+ },
56
+ (error) => {
57
+ clearTimeout(timer)
58
+ reject(error)
59
+ },
60
+ )
61
+ })
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return !!value && typeof value === 'object' && !Array.isArray(value)
66
+ }
67
+
68
+ function normalizedRequired(schema: Record<string, unknown>): string[] {
69
+ const required = schema.required
70
+ if (!Array.isArray(required)) return []
71
+ return required.filter((entry): entry is string => typeof entry === 'string')
72
+ }
73
+
74
+ function findSmokeTool(tools: Array<Record<string, unknown>>, preferredName?: string | null): string | null {
75
+ const preferred = typeof preferredName === 'string' ? preferredName.trim() : ''
76
+ if (preferred) {
77
+ const found = tools.find((tool) => tool.name === preferred)
78
+ if (found) return preferred
79
+ }
80
+
81
+ const noArg = tools.find((tool) => {
82
+ const schema = isRecord(tool.inputSchema) ? tool.inputSchema : {}
83
+ const required = normalizedRequired(schema)
84
+ return required.length === 0
85
+ })
86
+ if (noArg && typeof noArg.name === 'string' && noArg.name.trim()) return noArg.name
87
+ return null
88
+ }
89
+
90
+ function validateToolSchemas(tools: Array<Record<string, unknown>>, issues: McpConformanceIssue[]): void {
91
+ const seenNames = new Set<string>()
92
+ for (const tool of tools) {
93
+ const toolName = typeof tool.name === 'string' ? tool.name.trim() : ''
94
+ if (!toolName) {
95
+ issues.push({
96
+ level: 'error',
97
+ code: 'tool_name_missing',
98
+ message: 'Tool is missing a valid name.',
99
+ })
100
+ continue
101
+ }
102
+ if (seenNames.has(toolName)) {
103
+ issues.push({
104
+ level: 'error',
105
+ code: 'tool_name_duplicate',
106
+ message: `Duplicate tool name "${toolName}" detected.`,
107
+ toolName,
108
+ })
109
+ continue
110
+ }
111
+ seenNames.add(toolName)
112
+
113
+ const schema = isRecord(tool.inputSchema) ? tool.inputSchema : null
114
+ if (!schema) {
115
+ issues.push({
116
+ level: 'warning',
117
+ code: 'tool_schema_missing',
118
+ message: `Tool "${toolName}" is missing an input schema.`,
119
+ toolName,
120
+ })
121
+ continue
122
+ }
123
+
124
+ const schemaType = typeof schema.type === 'string' ? schema.type : 'object'
125
+ if (schemaType !== 'object') {
126
+ issues.push({
127
+ level: 'warning',
128
+ code: 'tool_schema_non_object',
129
+ message: `Tool "${toolName}" schema type is "${schemaType}" (expected "object").`,
130
+ toolName,
131
+ })
132
+ }
133
+
134
+ const properties = isRecord(schema.properties) ? schema.properties : {}
135
+ const required = normalizedRequired(schema)
136
+ for (const req of required) {
137
+ if (!Object.prototype.hasOwnProperty.call(properties, req)) {
138
+ issues.push({
139
+ level: 'warning',
140
+ code: 'tool_schema_required_missing_property',
141
+ message: `Tool "${toolName}" marks "${req}" as required but it is not present in schema.properties.`,
142
+ toolName,
143
+ })
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ export async function runMcpConformanceCheck(
150
+ server: McpServerConfig,
151
+ options: McpConformanceOptions = {},
152
+ ): Promise<McpConformanceResult> {
153
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs)
154
+ const issues: McpConformanceIssue[] = []
155
+ const checkedAt = Date.now()
156
+ const result: McpConformanceResult = {
157
+ ok: false,
158
+ serverId: server.id,
159
+ serverName: server.name,
160
+ checkedAt,
161
+ toolsCount: 0,
162
+ smokeToolName: null,
163
+ issues,
164
+ timings: {
165
+ connectMs: 0,
166
+ listToolsMs: 0,
167
+ smokeInvokeMs: null,
168
+ },
169
+ }
170
+
171
+ let client: unknown
172
+ let transport: unknown
173
+ const connectStart = Date.now()
174
+ try {
175
+ const conn = await withTimeout(connectMcpServer(server), timeoutMs, 'MCP connect')
176
+ client = conn.client
177
+ transport = conn.transport
178
+ result.timings.connectMs = Date.now() - connectStart
179
+
180
+ const mcpClient = client as { listTools: () => Promise<Record<string, unknown>>; callTool: (opts: Record<string, unknown>) => Promise<unknown> }
181
+ const listStart = Date.now()
182
+ const listResponse = await withTimeout(mcpClient.listTools(), timeoutMs, 'MCP listTools') as Record<string, unknown>
183
+ result.timings.listToolsMs = Date.now() - listStart
184
+ const tools = Array.isArray(listResponse?.tools) ? listResponse.tools as Array<Record<string, unknown>> : []
185
+ result.toolsCount = tools.length
186
+
187
+ validateToolSchemas(tools, issues)
188
+
189
+ const smokeToolName = findSmokeTool(tools, options.smokeToolName)
190
+ result.smokeToolName = smokeToolName
191
+ if (!smokeToolName) {
192
+ issues.push({
193
+ level: 'warning',
194
+ code: 'smoke_tool_missing',
195
+ message: 'No smoke-testable tool found (no no-arg tool and no explicit smokeToolName).',
196
+ })
197
+ } else {
198
+ const smokeArgs = options.smokeToolArgs && isRecord(options.smokeToolArgs)
199
+ ? options.smokeToolArgs
200
+ : {}
201
+ const smokeStart = Date.now()
202
+ try {
203
+ await withTimeout(
204
+ mcpClient.callTool({ name: smokeToolName, arguments: smokeArgs }),
205
+ timeoutMs,
206
+ `MCP callTool(${smokeToolName})`,
207
+ )
208
+ } catch (err) {
209
+ issues.push({
210
+ level: 'error',
211
+ code: 'smoke_tool_failed',
212
+ message: err instanceof Error ? err.message : String(err),
213
+ toolName: smokeToolName,
214
+ })
215
+ } finally {
216
+ result.timings.smokeInvokeMs = Date.now() - smokeStart
217
+ }
218
+ }
219
+ } catch (err) {
220
+ issues.push({
221
+ level: 'error',
222
+ code: 'connect_or_list_failed',
223
+ message: err instanceof Error ? err.message : String(err),
224
+ })
225
+ } finally {
226
+ if (client && transport) {
227
+ await disconnectMcpServer(client, transport)
228
+ }
229
+ }
230
+
231
+ result.ok = issues.every((issue) => issue.level !== 'error')
232
+ return result
233
+ }