@swarmclawai/swarmclaw 1.1.8 → 1.2.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 (38) hide show
  1. package/README.md +17 -0
  2. package/next.config.ts +0 -1
  3. package/package.json +3 -3
  4. package/src/app/activity/loading.tsx +5 -0
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  6. package/src/app/api/logs/route.ts +32 -5
  7. package/src/app/home/loading.tsx +5 -0
  8. package/src/app/logs/loading.tsx +5 -0
  9. package/src/app/memory/loading.tsx +5 -0
  10. package/src/app/tasks/loading.tsx +5 -0
  11. package/src/components/agents/agent-list.tsx +7 -12
  12. package/src/components/chat/chat-area.tsx +3 -3
  13. package/src/components/chat/chat-list.tsx +13 -2
  14. package/src/components/layout/sidebar-rail.tsx +14 -6
  15. package/src/components/memory/memory-graph-view.tsx +120 -68
  16. package/src/components/org-chart/mini-chat-bubble.tsx +58 -16
  17. package/src/components/shared/command-palette.tsx +35 -20
  18. package/src/components/shared/notification-center.tsx +1 -1
  19. package/src/hooks/use-app-bootstrap.ts +2 -1
  20. package/src/instrumentation.ts +27 -0
  21. package/src/lib/providers/anthropic.ts +14 -8
  22. package/src/lib/providers/openai.ts +3 -3
  23. package/src/lib/server/agents/agent-thread-session.ts +20 -14
  24. package/src/lib/server/chat-execution/continuation-evaluator.ts +2 -2
  25. package/src/lib/server/chat-execution/message-classifier.ts +2 -1
  26. package/src/lib/server/chat-execution/stream-agent-chat.ts +4 -19
  27. package/src/lib/server/chat-execution/stream-continuation.ts +4 -3
  28. package/src/lib/server/llm-response-cache.ts +2 -1
  29. package/src/lib/server/playwright-proxy.mjs +25 -6
  30. package/src/lib/server/runtime/run-ledger.ts +4 -4
  31. package/src/lib/server/runtime/scheduler.ts +9 -6
  32. package/src/lib/server/session-tools/skill-runtime.ts +10 -1
  33. package/src/lib/server/storage-cache.ts +2 -0
  34. package/src/lib/server/storage.ts +30 -3
  35. package/src/lib/server/tasks/task-quality-gate.test.ts +1 -1
  36. package/src/lib/server/tasks/task-quality-gate.ts +1 -1
  37. package/src/stores/slices/data-slice.ts +11 -0
  38. package/src/stores/slices/session-slice.ts +3 -0
@@ -8,6 +8,7 @@ import { useWs } from '@/hooks/use-ws'
8
8
  import { api } from '@/lib/app/api-client'
9
9
  import { fetchMessages } from '@/lib/chat/chats'
10
10
  import { streamChat } from '@/lib/chat/chat'
11
+ import { hmrSingleton } from '@/lib/shared-utils'
11
12
  import type { Agent, Message, Session, SSEEvent } from '@/types'
12
13
  import { INTERNAL_KEY_RE, stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
13
14
 
@@ -19,6 +20,9 @@ interface Props {
19
20
 
20
21
  const BUBBLE_W = 320
21
22
 
23
+ /** Client-side cache: agentId → sessionId, avoids redundant POST on reopen */
24
+ const sessionCache = hmrSingleton('miniChatBubble_sessionCache', () => new Map<string, string>())
25
+
22
26
  const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
23
27
 
24
28
  /** Filter status text that looks like raw IDs or data dumps */
@@ -39,30 +43,57 @@ export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) {
39
43
  const [streamText, setStreamText] = useState('')
40
44
  const [inputValue, setInputValue] = useState('')
41
45
  const [loading, setLoading] = useState(true)
46
+ const [error, setError] = useState<string | null>(null)
42
47
  const scrollRef = useRef<HTMLDivElement>(null)
43
48
  const inputRef = useRef<HTMLInputElement>(null)
49
+ const cancelledRef = useRef(false)
44
50
 
45
51
  // Initialize: get or create thread session, load messages
46
- useEffect(() => {
47
- let cancelled = false
48
- async function init() {
49
- try {
50
- const session = await api<Session>('POST', `/agents/${agent.id}/thread`)
51
- if (cancelled) return
52
- setSessionId(session.id)
52
+ const init = useCallback(async () => {
53
+ setError(null)
54
+ setLoading(true)
55
+ try {
56
+ const cachedSid = sessionCache.get(agent.id)
57
+ if (cachedSid) {
58
+ // Try cached session first — skip POST entirely
59
+ try {
60
+ const msgs = await fetchMessages(cachedSid)
61
+ if (cancelledRef.current) return
62
+ setSessionId(cachedSid)
63
+ setMessages(msgs)
64
+ return
65
+ } catch {
66
+ // Cached session gone — clear and fall through to POST
67
+ sessionCache.delete(agent.id)
68
+ }
69
+ }
70
+
71
+ const session = await api<Session>('POST', `/agents/${agent.id}/thread`)
72
+ if (cancelledRef.current) return
73
+ setSessionId(session.id)
74
+ sessionCache.set(agent.id, session.id)
75
+
76
+ // Use messages from POST response if present, otherwise fetch
77
+ if (Array.isArray(session.messages) && session.messages.length > 0) {
78
+ setMessages(session.messages as Message[])
79
+ } else {
53
80
  const msgs = await fetchMessages(session.id)
54
- if (cancelled) return
81
+ if (cancelledRef.current) return
55
82
  setMessages(msgs)
56
- } catch {
57
- // Agent may not be available
58
- } finally {
59
- if (!cancelled) setLoading(false)
60
83
  }
84
+ } catch {
85
+ if (!cancelledRef.current) setError('Could not connect to agent')
86
+ } finally {
87
+ if (!cancelledRef.current) setLoading(false)
61
88
  }
62
- init()
63
- return () => { cancelled = true }
64
89
  }, [agent.id])
65
90
 
91
+ useEffect(() => {
92
+ cancelledRef.current = false
93
+ init()
94
+ return () => { cancelledRef.current = true }
95
+ }, [init])
96
+
66
97
  // Real-time message refresh via WebSocket (mirrors main ChatArea pattern)
67
98
  const refreshMessages = useCallback(async () => {
68
99
  if (!sessionId || streaming) return
@@ -188,7 +219,18 @@ export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) {
188
219
  {loading && (
189
220
  <div className="text-[11px] text-text-3/50 text-center py-8">Loading...</div>
190
221
  )}
191
- {!loading && visibleMessages.length === 0 && !streaming && (
222
+ {!loading && error && (
223
+ <div className="text-center py-8 space-y-2">
224
+ <div className="text-[11px] text-red-400/70">{error}</div>
225
+ <button
226
+ onClick={() => { init() }}
227
+ className="text-[11px] text-accent-bright/70 hover:text-accent-bright cursor-pointer border-none bg-transparent underline"
228
+ >
229
+ Retry
230
+ </button>
231
+ </div>
232
+ )}
233
+ {!loading && !error && visibleMessages.length === 0 && !streaming && (
192
234
  <div className="text-[11px] text-text-3/40 text-center py-8">
193
235
  Start a conversation with {agent.name}
194
236
  </div>
@@ -228,7 +270,7 @@ export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) {
228
270
  onChange={(e) => setInputValue(e.target.value)}
229
271
  onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
230
272
  placeholder="Type a message..."
231
- disabled={loading || !sessionId}
273
+ disabled={loading || !!error || !sessionId}
232
274
  className="flex-1 text-[12px] bg-white/[0.04] border border-white/[0.06] rounded-[6px] px-2.5 py-1.5 text-text placeholder:text-text-3/30 outline-none focus:border-accent-bright/30 transition-colors disabled:opacity-40"
233
275
  />
234
276
  {streaming ? (
@@ -17,6 +17,33 @@ interface CommandItem {
17
17
 
18
18
  export function CommandPalette() {
19
19
  const [open, setOpen] = useState(false)
20
+
21
+ // Register keyboard shortcut (always mounted)
22
+ useEffect(() => {
23
+ const handler = (e: KeyboardEvent) => {
24
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
25
+ e.preventDefault()
26
+ setOpen((v) => !v)
27
+ }
28
+ }
29
+ window.addEventListener('keydown', handler)
30
+ return () => window.removeEventListener('keydown', handler)
31
+ }, [])
32
+
33
+ // Also listen for custom event
34
+ useEffect(() => {
35
+ const handler = () => setOpen(true)
36
+ window.addEventListener('swarmclaw:open-search', handler)
37
+ return () => window.removeEventListener('swarmclaw:open-search', handler)
38
+ }, [])
39
+
40
+ if (!open) return null
41
+
42
+ return <CommandPaletteInner setOpen={setOpen} />
43
+ }
44
+
45
+ /** Inner component — only mounted when palette is open, so store subscriptions are gated. */
46
+ function CommandPaletteInner({ setOpen }: { setOpen: (v: boolean) => void }) {
20
47
  const [query, setQuery] = useState('')
21
48
  const [selectedIndex, setSelectedIndex] = useState(0)
22
49
  const inputRef = useRef<HTMLInputElement>(null)
@@ -39,31 +66,21 @@ export function CommandPalette() {
39
66
  detail: { tabId, sectionId },
40
67
  }))
41
68
  }, 80)
42
- }, [navigateTo])
69
+ }, [navigateTo, setOpen])
43
70
 
44
- // Register keyboard shortcut
71
+ // Close on Escape
45
72
  useEffect(() => {
46
73
  const handler = (e: KeyboardEvent) => {
47
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
48
- e.preventDefault()
49
- setOpen((v) => !v)
50
- }
51
- if (e.key === 'Escape' && open) {
52
- setOpen(false)
53
- }
74
+ if (e.key === 'Escape') setOpen(false)
54
75
  }
55
76
  window.addEventListener('keydown', handler)
56
77
  return () => window.removeEventListener('keydown', handler)
57
- }, [open])
78
+ }, [setOpen])
58
79
 
59
- // Focus input when opened
80
+ // Focus input when mounted
60
81
  useEffect(() => {
61
- if (open) {
62
- setQuery('')
63
- setSelectedIndex(0)
64
- setTimeout(() => inputRef.current?.focus(), 50)
65
- }
66
- }, [open])
82
+ setTimeout(() => inputRef.current?.focus(), 50)
83
+ }, [])
67
84
 
68
85
  const items = useMemo<CommandItem[]>(() => {
69
86
  const result: CommandItem[] = []
@@ -177,7 +194,7 @@ export function CommandPalette() {
177
194
  }
178
195
 
179
196
  return result
180
- }, [agents, currentUser, navigateTo, openSettingsSection, sessions, setCurrentAgent, setEditingTaskId, setTaskSheetOpen, tasks])
197
+ }, [agents, currentUser, navigateTo, openSettingsSection, sessions, setCurrentAgent, setEditingTaskId, setOpen, setTaskSheetOpen, tasks])
181
198
 
182
199
  const filtered = useMemo(() => {
183
200
  if (!query.trim()) return items.slice(0, 20)
@@ -214,8 +231,6 @@ export function CommandPalette() {
214
231
  el?.scrollIntoView({ block: 'nearest' })
215
232
  }, [selectedIndex])
216
233
 
217
- if (!open) return null
218
-
219
234
  const categoryLabel = { agent: 'Agents', chat: 'Chats', task: 'Tasks', nav: 'Navigation', setting: 'Settings' } as const
220
235
  const categoryIcon = {
221
236
  agent: (
@@ -53,8 +53,8 @@ export function NotificationCenter({
53
53
  align?: 'left' | 'right'
54
54
  direction?: 'up' | 'down'
55
55
  }) {
56
- const now = useNow()
57
56
  const [open, setOpen] = useState(false)
57
+ const now = useNow({ enabled: open })
58
58
  const panelRef = useRef<HTMLDivElement>(null)
59
59
  const buttonRef = useRef<HTMLButtonElement>(null)
60
60
  const [panelStyle, setPanelStyle] = useState<CSSProperties>({
@@ -8,6 +8,7 @@ import { isDevelopmentLikeRuntime } from '@/lib/runtime/runtime-env'
8
8
  import { useWs } from '@/hooks/use-ws'
9
9
  import { useMountedRef } from '@/hooks/use-mounted-ref'
10
10
  import { resolveSetupDone } from '@/hooks/setup-done-detection'
11
+ import { setIfChanged } from '@/stores/set-if-changed'
11
12
  import type { Agent } from '@/types'
12
13
 
13
14
  const AUTH_CHECK_TIMEOUT_MS = isDevelopmentLikeRuntime() ? 20_000 : 8_000
@@ -155,7 +156,7 @@ export function useAppBootstrap() {
155
156
  retries: 0,
156
157
  })
157
158
  if (cancelled) return
158
- useAppStore.setState({ agents })
159
+ setIfChanged(useAppStore.setState, 'agents', agents)
159
160
 
160
161
  const { currentAgentId, appSettings } = useAppStore.getState()
161
162
  const targetId = (currentAgentId && agents[currentAgentId])
@@ -49,6 +49,33 @@ export async function register() {
49
49
  if (!shutdownState.registered) {
50
50
  process.on('SIGTERM', () => { void shutdown('SIGTERM') })
51
51
  process.on('SIGINT', () => { void shutdown('SIGINT') })
52
+
53
+ // Gracefully handle EPIPE errors from child processes (e.g. Playwright MCP proxy)
54
+ // that occur during dev server restarts when stdio pipes break
55
+ process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
56
+ if (err.code === 'EPIPE') {
57
+ console.warn('[instrumentation] Ignoring EPIPE (expected during dev server restart)')
58
+ return
59
+ }
60
+ console.error('[instrumentation] Uncaught exception:', err)
61
+ process.exit(1)
62
+ })
63
+
64
+ // LangGraph's streamEvents leaves dangling internal promises when the
65
+ // for-await loop exits early. Suppress expected LangGraph rejections;
66
+ // log all others so they're not silently dropped.
67
+ process.on('unhandledRejection', (err: unknown) => {
68
+ if (
69
+ err && typeof err === 'object'
70
+ && ('pregelTaskId' in err
71
+ || (err instanceof Error && (err.name === 'AbortError' || err.name === 'GraphRecursionError'))
72
+ || (err as Record<string, unknown>).lc_error_code === 'GRAPH_RECURSION_LIMIT')
73
+ ) {
74
+ return
75
+ }
76
+ console.error('[instrumentation] Unhandled rejection:', err)
77
+ })
78
+
52
79
  shutdownState.registered = true
53
80
  }
54
81
  }
@@ -4,17 +4,17 @@ import type { StreamChatOptions } from './index'
4
4
  import { PROVIDER_DEFAULTS, IMAGE_EXTS, TEXT_EXTS, ANTHROPIC_MAX_TOKENS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
5
5
  import { resolveImagePath } from '@/lib/server/resolve-image'
6
6
 
7
- function fileToContentBlocks(filePath: string): Array<Record<string, unknown>> {
7
+ async function fileToContentBlocks(filePath: string): Promise<Array<Record<string, unknown>>> {
8
8
  if (!filePath || !fs.existsSync(filePath)) return []
9
9
  if (IMAGE_EXTS.test(filePath)) {
10
- const data = fs.readFileSync(filePath).toString('base64')
10
+ const data = (await fs.promises.readFile(filePath)).toString('base64')
11
11
  const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
12
12
  const mediaType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
13
13
  return [{ type: 'image', source: { type: 'base64', media_type: mediaType, data } }]
14
14
  }
15
15
  if (TEXT_EXTS.test(filePath) || filePath.endsWith('.pdf')) {
16
16
  try {
17
- const text = fs.readFileSync(filePath, 'utf-8')
17
+ const text = await fs.promises.readFile(filePath, 'utf-8')
18
18
  const name = filePath.split('/').pop() || 'file'
19
19
  return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
20
20
  } catch { return [] }
@@ -23,8 +23,8 @@ function fileToContentBlocks(filePath: string): Array<Record<string, unknown>> {
23
23
  }
24
24
 
25
25
  export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
26
- return new Promise((resolve, reject) => {
27
- const messages = buildMessages(session, message, imagePath, loadHistory)
26
+ return new Promise(async (resolve, reject) => {
27
+ const messages = await buildMessages(session, message, imagePath, loadHistory)
28
28
  const model = session.model || 'claude-sonnet-4-6'
29
29
  let usageInput = 0
30
30
  let usageOutput = 0
@@ -59,6 +59,7 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
59
59
  hostname: PROVIDER_DEFAULTS.anthropic,
60
60
  path: '/v1/messages',
61
61
  method: 'POST',
62
+ timeout: 60_000,
62
63
  headers: {
63
64
  'x-api-key': apiKey || '',
64
65
  'anthropic-version': '2023-06-01',
@@ -122,6 +123,11 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
122
123
  apiReqRef = apiReq
123
124
  active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
124
125
 
126
+ apiReq.on('timeout', () => {
127
+ console.error(`[${session.id}] anthropic request timed out after 60s`)
128
+ apiReq.destroy(new Error('Request timed out after 60s'))
129
+ })
130
+
125
131
  apiReq.on('error', (e) => {
126
132
  console.error(`[${session.id}] anthropic request error:`, e.message)
127
133
  writeSSE(write, 'err', e.message)
@@ -133,7 +139,7 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
133
139
  })
134
140
  }
135
141
 
136
- function buildMessages(session: Record<string, unknown> & { id: string }, message: string, imagePath: string | undefined, loadHistory: (id: string) => Record<string, unknown>[]) {
142
+ async function buildMessages(session: Record<string, unknown> & { id: string }, message: string, imagePath: string | undefined, loadHistory: (id: string) => Record<string, unknown>[]) {
137
143
  const msgs: Array<{ role: string; content: unknown }> = []
138
144
 
139
145
  if (loadHistory) {
@@ -141,7 +147,7 @@ function buildMessages(session: Record<string, unknown> & { id: string }, messag
141
147
  for (const m of history) {
142
148
  const histImagePath = resolveImagePath(m.imagePath as string | undefined, m.imageUrl as string | undefined)
143
149
  if (m.role === 'user' && histImagePath) {
144
- const blocks = fileToContentBlocks(histImagePath)
150
+ const blocks = await fileToContentBlocks(histImagePath)
145
151
  msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: m.text }] })
146
152
  } else {
147
153
  msgs.push({ role: m.role as string, content: m.text })
@@ -150,7 +156,7 @@ function buildMessages(session: Record<string, unknown> & { id: string }, messag
150
156
  }
151
157
 
152
158
  if (imagePath) {
153
- const blocks = fileToContentBlocks(imagePath)
159
+ const blocks = await fileToContentBlocks(imagePath)
154
160
  msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: message }] })
155
161
  } else {
156
162
  msgs.push({ role: 'user', content: message })
@@ -7,7 +7,7 @@ async function fileToContentParts(filePath: string): Promise<Array<Record<string
7
7
  if (!filePath || !fs.existsSync(filePath)) return []
8
8
  const name = filePath.split('/').pop() || 'file'
9
9
  if (IMAGE_EXTS.test(filePath)) {
10
- const buf = fs.readFileSync(filePath)
10
+ const buf = await fs.promises.readFile(filePath)
11
11
  if (buf.length === 0) return [{ type: 'text', text: `[Attached image: ${name} — file is empty]` }]
12
12
  const data = buf.toString('base64')
13
13
  const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
@@ -22,7 +22,7 @@ async function fileToContentParts(filePath: string): Promise<Array<Record<string
22
22
  try {
23
23
  // @ts-ignore — pdf-parse types
24
24
  const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
25
- const buf = fs.readFileSync(filePath)
25
+ const buf = await fs.promises.readFile(filePath)
26
26
  const result = await pdfParse(buf)
27
27
  const pdfText = (result.text || '').trim()
28
28
  if (!pdfText) return [{ type: 'text', text: `[Attached PDF: ${name} — no extractable text]` }]
@@ -34,7 +34,7 @@ async function fileToContentParts(filePath: string): Promise<Array<Record<string
34
34
  }
35
35
  if (TEXT_EXTS.test(filePath)) {
36
36
  try {
37
- const text = fs.readFileSync(filePath, 'utf-8')
37
+ const text = await fs.promises.readFile(filePath, 'utf-8')
38
38
  return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
39
39
  } catch { return [] }
40
40
  }
@@ -3,7 +3,7 @@ import type { Agent, Session } from '@/types'
3
3
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
4
4
  import { isAgentDisabled } from '@/lib/server/agents/agent-availability'
5
5
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
6
- import { loadAgents, loadSessions, upsertAgent, upsertStoredItem } from '@/lib/server/storage'
6
+ import { loadAgents, loadSession, loadSessions, upsertAgent, upsertStoredItem } from '@/lib/server/storage'
7
7
  import { getEnabledCapabilitySelection } from '@/lib/capability-selection'
8
8
 
9
9
  function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
@@ -107,27 +107,33 @@ function shouldHealAgentCredentialId(agent: Agent, session: Session): boolean {
107
107
  return session.provider === agent.provider
108
108
  }
109
109
 
110
- export function ensureAgentThreadSession(agentId: string, user = 'default'): Session | null {
111
- const agents = loadAgents()
112
- const agent = agents[agentId] as Agent | undefined
110
+ export function ensureAgentThreadSession(agentId: string, user = 'default', preloadedAgent?: Agent): Session | null {
111
+ const agent = preloadedAgent ?? (loadAgents()[agentId] as Agent | undefined)
113
112
  if (!agent) return null
114
113
 
115
- const sessions = loadSessions()
116
114
  const now = Date.now()
117
- const disabled = isAgentDisabled(agent)
118
115
 
116
+ // Fast path: agent already has a threadSessionId — single-row lookup
119
117
  const existingId = typeof agent.threadSessionId === 'string' ? agent.threadSessionId : ''
120
- if (existingId && sessions[existingId]) {
121
- const session = buildThreadSession(agent, existingId, user, now, sessions[existingId] as Session)
122
- if (shouldHealAgentCredentialId(agent, session)) {
123
- agent.credentialId = session.credentialId ?? null
124
- agent.updatedAt = now
125
- upsertAgent(agentId, agent)
118
+ if (existingId) {
119
+ const existing = loadSession(existingId)
120
+ if (existing) {
121
+ const session = buildThreadSession(agent, existingId, user, now, existing)
122
+ if (shouldHealAgentCredentialId(agent, session)) {
123
+ agent.credentialId = session.credentialId ?? null
124
+ agent.updatedAt = now
125
+ upsertAgent(agentId, agent)
126
+ }
127
+ upsertStoredItem('sessions', existingId, session)
128
+ return session
126
129
  }
127
- upsertStoredItem('sessions', existingId, session)
128
- return session
130
+ // Session was deleted — fall through to legacy search / creation
129
131
  }
130
132
 
133
+ // Legacy search: full table scan only when threadSessionId is missing or stale
134
+ const sessions = loadSessions()
135
+ const disabled = isAgentDisabled(agent)
136
+
131
137
  const legacySession = Object.values(sessions).find((session) => (
132
138
  (session.shortcutForAgentId === agentId || session.name === `agent-thread:${agentId}`)
133
139
  && session.user === user
@@ -339,10 +339,10 @@ function checkCoordinatorDelegation(ctx: ContinuationContext): ContinuationDecis
339
339
  // Skip if already delegated
340
340
  const delegationTools = ['spawn_subagent', 'manage_protocols']
341
341
  if (delegationTools.some(t => ctx.state.usedToolNames.has(t))) return null
342
- // Only nudge if coordinator made 3+ direct substantial tool calls
342
+ // Only nudge if coordinator made 2+ direct substantial tool calls
343
343
  const directTools = ['files', 'edit_file', 'shell', 'web']
344
344
  const directCallCount = directTools.filter(t => ctx.state.usedToolNames.has(t)).length
345
- if (directCallCount < 3) return null
345
+ if (directCallCount < 2) return null
346
346
  ctx.limits.increment('coordinator_delegation_nudge')
347
347
  writeStatus(ctx, { coordinatorDelegationNudge: true })
348
348
  return { type: 'coordinator_delegation_nudge', requiredToolReminderNames: [] }
@@ -13,6 +13,7 @@ import crypto from 'node:crypto'
13
13
  import { HumanMessage } from '@langchain/core/messages'
14
14
  import { z } from 'zod'
15
15
  import { buildLLM } from '@/lib/server/build-llm'
16
+ import { hmrSingleton } from '@/lib/shared-utils'
16
17
  import type { Message } from '@/types'
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -37,7 +38,7 @@ export type MessageClassification = z.infer<typeof MessageClassificationSchema>
37
38
  // ---------------------------------------------------------------------------
38
39
 
39
40
  const MAX_CACHE_SIZE = 200
40
- const classificationCache = new Map<string, MessageClassification>()
41
+ const classificationCache = hmrSingleton('__swarmclaw_classification_cache__', () => new Map<string, MessageClassification>())
41
42
 
42
43
  function cacheKey(message: string): string {
43
44
  return crypto.createHash('sha256').update(message).digest('hex')
@@ -99,24 +99,8 @@ import {
99
99
  type MessageClassification,
100
100
  } from '@/lib/server/chat-execution/message-classifier'
101
101
 
102
- // LangGraph's streamEvents leaves dangling internal promises when the for-await
103
- // loop exits early (break on tool loop detection, execution boundary, etc.).
104
- // These promises may later reject with GraphRecursionError or AbortError.
105
- // Register a permanent handler to prevent process crashes from these expected
106
- // background rejections. Only LangGraph-specific errors (identified by
107
- // pregelTaskId or lc_error_code) are suppressed; all other rejections propagate
108
- // normally.
109
- process.on('unhandledRejection', (err: unknown) => {
110
- if (
111
- err && typeof err === 'object'
112
- && ('pregelTaskId' in err
113
- || (err instanceof Error && (err.name === 'AbortError' || err.name === 'GraphRecursionError'))
114
- || (err as Record<string, unknown>).lc_error_code === 'GRAPH_RECURSION_LIMIT')
115
- ) {
116
- // Silently suppress — expected background rejection from LangGraph
117
- return
118
- }
119
- })
102
+ // LangGraph unhandledRejection handler has been moved to src/instrumentation.ts
103
+ // to avoid re-registration on every HMR reload.
120
104
 
121
105
  // Re-export continuation functions so existing consumers don't need to change imports
122
106
  export {
@@ -912,7 +896,8 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
912
896
  && !abortController.signal.aborted)
913
897
  || (isTransientProviderError && !abortController.signal.aborted)
914
898
 
915
- console.error(`[stream-agent-chat] Error in streamEvents iteration=${iteration}`, {
899
+ const logLevel = abortController.signal.aborted ? 'warn' : 'error'
900
+ console[logLevel](`[stream-agent-chat] Error in streamEvents iteration=${iteration}`, {
916
901
  errName, errMsg, errStack,
917
902
  statusCode, retryAfterMs: extractedRetryAfterMs,
918
903
  isRecursionError, isContextOverflow, isTransientAbort,
@@ -820,9 +820,10 @@ export function buildContinuationPrompt(params: {
820
820
 
821
821
  case 'coordinator_delegation_nudge':
822
822
  return [
823
- 'You have specialist workers available but have been using tools directly for substantial work.',
824
- 'Consider delegating the remaining work via `spawn_subagent` to the appropriate worker.',
825
- 'Direct tool use is fine for quick validation, but substantial work should go to specialists.',
823
+ 'IMPORTANT: You have specialist workers available but you have been doing substantial work directly with tools.',
824
+ 'You MUST delegate the remaining work via `spawn_subagent` to the appropriate specialist worker NOW.',
825
+ 'As a coordinator, your job is to orchestrate not to do the work yourself. Direct tool use is only for quick lookups and validation.',
826
+ 'Review the workers listed in your system prompt and delegate immediately.',
826
827
  ].join('\n')
827
828
 
828
829
  case 'loop_recovery': {
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto'
2
+ import { hmrSingleton } from '@/lib/shared-utils'
2
3
  import type { AppSettings, Message } from '@/types'
3
4
 
4
5
  export interface LlmResponseCacheConfig {
@@ -48,7 +49,7 @@ const MAX_TTL_SEC = 7 * 24 * 3600
48
49
  const MIN_ENTRIES = 1
49
50
  const MAX_ENTRIES = 20_000
50
51
 
51
- const responseCache = new Map<string, LlmResponseCacheEntry>()
52
+ const responseCache = hmrSingleton('__swarmclaw_llm_response_cache__', () => new Map<string, LlmResponseCacheEntry>())
52
53
 
53
54
  function normalizeText(value: unknown): string {
54
55
  return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
@@ -39,9 +39,17 @@ const child = cliPath
39
39
  env: sanitizePlaywrightEnv(process.env),
40
40
  })
41
41
 
42
- // Forward stdin child
43
- process.stdin.on('data', (chunk) => child.stdin.write(chunk))
44
- process.stdin.on('end', () => child.stdin.end())
42
+ // Graceful EPIPE handling — dev server restarts break stdio pipes
43
+ function safeWrite(stream, chunk) {
44
+ try { stream.write(chunk) } catch { /* EPIPE during restart, ignore */ }
45
+ }
46
+
47
+ process.stdin.on('data', (chunk) => safeWrite(child.stdin, chunk))
48
+ process.stdin.on('end', () => { try { child.stdin.end() } catch { /* ignore */ } })
49
+ child.stdin.on('error', (err) => {
50
+ if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') return
51
+ process.stderr.write(`Proxy child stdin error: ${err.message}\n`)
52
+ })
45
53
 
46
54
  // Parse MCP Content-Length framed messages from child stdout, intercept screenshots
47
55
  let buf = ''
@@ -84,10 +92,21 @@ child.stdout.on('data', (chunk) => {
84
92
  output = body
85
93
  }
86
94
  const frame = `Content-Length: ${Buffer.byteLength(output)}\r\n\r\n${output}`
87
- process.stdout.write(frame)
95
+ safeWrite(process.stdout, frame)
88
96
  }
89
97
  })
98
+ child.stdout.on('error', (err) => {
99
+ if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') return
100
+ process.stderr.write(`Proxy child stdout error: ${err.message}\n`)
101
+ })
90
102
 
91
- child.stderr.on('data', (chunk) => process.stderr.write(chunk))
103
+ child.stderr.on('data', (chunk) => safeWrite(process.stderr, chunk))
104
+ child.stderr.on('error', () => { /* ignore stderr errors */ })
92
105
  child.on('close', (code) => process.exit(code || 0))
93
- child.on('error', (err) => { process.stderr.write(`Proxy error: ${err.message}\n`); process.exit(1) })
106
+ child.on('error', (err) => { safeWrite(process.stderr, `Proxy error: ${err.message}\n`); process.exit(1) })
107
+
108
+ // Handle parent stdout/stderr EPIPE (broken pipe when dev server restarts)
109
+ process.stdout.on('error', (err) => {
110
+ if (err.code === 'EPIPE') { child.kill(); process.exit(0) }
111
+ })
112
+ process.stderr.on('error', () => { /* ignore */ })
@@ -4,6 +4,7 @@ import {
4
4
  deleteStoredItem,
5
5
  loadRuntimeRun,
6
6
  loadRuntimeRunEvents,
7
+ loadRuntimeRunEventsByRunId,
7
8
  loadRuntimeRuns,
8
9
  patchRuntimeRun,
9
10
  upsertRuntimeRun,
@@ -96,10 +97,9 @@ export function appendPersistedRunEvent(input: {
96
97
 
97
98
  export function listPersistedRunEvents(runId: string, limit = 1000): RunEventRecord[] {
98
99
  const safeLimit = Math.max(1, Math.min(5000, Math.trunc(limit)))
99
- return Object.values(loadRuntimeRunEvents())
100
- .filter((event) => event.runId === runId)
101
- .sort((left, right) => left.timestamp - right.timestamp)
102
- .slice(-safeLimit)
100
+ // Query filtered at SQL level to avoid full-table scan
101
+ const events = loadRuntimeRunEventsByRunId(runId)
102
+ return events.slice(-safeLimit)
103
103
  }
104
104
 
105
105
  export function loadRecoverableStaleRuns(): SessionRunRecord[] {
@@ -10,10 +10,13 @@ import { prepareScheduledTaskRun } from '@/lib/server/tasks/task-lifecycle'
10
10
  import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-session'
11
11
  import { ensureMissionForTask, noteScheduleMissionTriggered } from '@/lib/server/missions/mission-service'
12
12
  import { hasActiveProtocolRunForSchedule, launchProtocolRunForSchedule } from '@/lib/server/protocols/protocol-service'
13
+ import { hmrSingleton } from '@/lib/shared-utils'
13
14
  import type { Schedule } from '@/types'
14
15
 
15
16
  const TICK_INTERVAL = 60_000 // 60 seconds
16
- let intervalId: ReturnType<typeof setInterval> | null = null
17
+ const schedulerState = hmrSingleton('__swarmclaw_scheduler_state__', () => ({
18
+ intervalId: null as ReturnType<typeof setInterval> | null,
19
+ }))
17
20
 
18
21
  interface ScheduleTaskLike {
19
22
  status?: string
@@ -41,19 +44,19 @@ function shouldLaunchScheduleProtocol(schedule: Schedule): boolean {
41
44
  }
42
45
 
43
46
  export function startScheduler() {
44
- if (intervalId) return
47
+ if (schedulerState.intervalId) return
45
48
  console.log('[scheduler] Starting scheduler engine (60s tick)')
46
49
 
47
50
  // Compute initial nextRunAt for cron schedules missing it
48
51
  computeNextRuns()
49
52
 
50
- intervalId = setInterval(tick, TICK_INTERVAL)
53
+ schedulerState.intervalId = setInterval(tick, TICK_INTERVAL)
51
54
  }
52
55
 
53
56
  export function stopScheduler() {
54
- if (intervalId) {
55
- clearInterval(intervalId)
56
- intervalId = null
57
+ if (schedulerState.intervalId) {
58
+ clearInterval(schedulerState.intervalId)
59
+ schedulerState.intervalId = null
57
60
  console.log('[scheduler] Stopped scheduler engine')
58
61
  }
59
62
  }