@swarmclawai/swarmclaw 0.3.0 → 0.4.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 (118) hide show
  1. package/README.md +20 -11
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +2 -0
  6. package/package.json +3 -1
  7. package/src/app/api/agents/[id]/route.ts +3 -0
  8. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  9. package/src/app/api/agents/route.ts +5 -1
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/connectors/[id]/route.ts +4 -0
  13. package/src/app/api/connectors/route.ts +6 -1
  14. package/src/app/api/credentials/route.ts +3 -1
  15. package/src/app/api/daemon/route.ts +6 -1
  16. package/src/app/api/ip/route.ts +3 -1
  17. package/src/app/api/mcp-servers/route.ts +3 -1
  18. package/src/app/api/orchestrator/graph/route.ts +25 -0
  19. package/src/app/api/plugins/marketplace/route.ts +3 -1
  20. package/src/app/api/plugins/route.ts +3 -1
  21. package/src/app/api/providers/[id]/route.ts +3 -0
  22. package/src/app/api/providers/configs/route.ts +3 -1
  23. package/src/app/api/providers/route.ts +5 -1
  24. package/src/app/api/schedules/[id]/route.ts +3 -0
  25. package/src/app/api/schedules/route.ts +6 -1
  26. package/src/app/api/secrets/route.ts +3 -1
  27. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  28. package/src/app/api/sessions/route.ts +9 -2
  29. package/src/app/api/settings/route.ts +3 -1
  30. package/src/app/api/setup/doctor/route.ts +1 -0
  31. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  32. package/src/app/api/skills/route.ts +3 -1
  33. package/src/app/api/tasks/[id]/approve/route.ts +73 -0
  34. package/src/app/api/tasks/[id]/route.ts +3 -0
  35. package/src/app/api/tasks/route.ts +3 -0
  36. package/src/app/api/usage/route.ts +3 -1
  37. package/src/app/api/version/route.ts +3 -1
  38. package/src/app/api/webhooks/[id]/route.ts +2 -1
  39. package/src/app/api/webhooks/route.ts +3 -1
  40. package/src/app/icon.svg +58 -0
  41. package/src/app/page.tsx +8 -2
  42. package/src/cli/index.js +1 -9
  43. package/src/cli/index.ts +51 -1
  44. package/src/cli/spec.js +0 -8
  45. package/src/components/agents/agent-card.tsx +1 -1
  46. package/src/components/agents/agent-sheet.tsx +63 -80
  47. package/src/components/chat/chat-area.tsx +44 -30
  48. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  49. package/src/components/chat/message-bubble.tsx +110 -42
  50. package/src/components/chat/tool-call-bubble.tsx +41 -3
  51. package/src/components/chat/tool-request-banner.tsx +1 -9
  52. package/src/components/connectors/connector-list.tsx +3 -8
  53. package/src/components/connectors/connector-sheet.tsx +24 -29
  54. package/src/components/input/chat-input.tsx +72 -56
  55. package/src/components/knowledge/knowledge-list.tsx +27 -31
  56. package/src/components/layout/app-layout.tsx +92 -71
  57. package/src/components/layout/daemon-indicator.tsx +3 -5
  58. package/src/components/logs/log-list.tsx +5 -9
  59. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  60. package/src/components/memory/memory-detail.tsx +1 -1
  61. package/src/components/plugins/plugin-list.tsx +227 -27
  62. package/src/components/providers/provider-list.tsx +46 -13
  63. package/src/components/providers/provider-sheet.tsx +0 -45
  64. package/src/components/runs/run-list.tsx +6 -15
  65. package/src/components/schedules/schedule-card.tsx +54 -4
  66. package/src/components/schedules/schedule-list.tsx +6 -3
  67. package/src/components/schedules/schedule-sheet.tsx +0 -47
  68. package/src/components/secrets/secrets-list.tsx +20 -2
  69. package/src/components/sessions/new-session-sheet.tsx +8 -9
  70. package/src/components/shared/connector-platform-icon.tsx +22 -20
  71. package/src/components/shared/model-combobox.tsx +148 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +7 -39
  73. package/src/components/shared/settings/section-orchestrator.tsx +8 -9
  74. package/src/components/skills/skill-list.tsx +260 -34
  75. package/src/components/skills/skill-sheet.tsx +0 -45
  76. package/src/components/tasks/task-board.tsx +3 -6
  77. package/src/components/tasks/task-card.tsx +43 -1
  78. package/src/components/tasks/task-list.tsx +3 -5
  79. package/src/components/tasks/task-sheet.tsx +0 -44
  80. package/src/components/usage/usage-list.tsx +12 -4
  81. package/src/hooks/use-ws.ts +66 -0
  82. package/src/instrumentation.ts +2 -0
  83. package/src/lib/chat.ts +14 -2
  84. package/src/lib/providers/anthropic.ts +1 -1
  85. package/src/lib/providers/index.ts +2 -0
  86. package/src/lib/providers/ollama.ts +1 -1
  87. package/src/lib/providers/openai.ts +33 -12
  88. package/src/lib/server/chat-execution.ts +19 -4
  89. package/src/lib/server/connectors/manager.ts +9 -3
  90. package/src/lib/server/context-manager.ts +1 -1
  91. package/src/lib/server/daemon-state.ts +3 -0
  92. package/src/lib/server/data-dir.ts +1 -0
  93. package/src/lib/server/heartbeat-service.ts +67 -3
  94. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  95. package/src/lib/server/main-agent-loop.ts +61 -2
  96. package/src/lib/server/orchestrator-lg.ts +394 -13
  97. package/src/lib/server/orchestrator.ts +25 -5
  98. package/src/lib/server/queue.ts +17 -3
  99. package/src/lib/server/session-run-manager.ts +6 -1
  100. package/src/lib/server/session-tools/delegate.ts +2 -2
  101. package/src/lib/server/session-tools/index.ts +2 -0
  102. package/src/lib/server/session-tools/sandbox.ts +164 -0
  103. package/src/lib/server/storage-mcp.test.ts +25 -2
  104. package/src/lib/server/storage.ts +24 -7
  105. package/src/lib/server/stream-agent-chat.ts +77 -22
  106. package/src/lib/server/task-validation.test.ts +23 -0
  107. package/src/lib/server/task-validation.ts +5 -3
  108. package/src/lib/server/ws-hub.ts +85 -0
  109. package/src/lib/tool-definitions.ts +42 -0
  110. package/src/lib/upload.ts +7 -1
  111. package/src/lib/ws-client.ts +124 -0
  112. package/src/stores/use-chat-store.ts +33 -13
  113. package/src/types/index.ts +8 -1
  114. package/src/app/api/agents/generate/route.ts +0 -42
  115. package/src/app/api/generate/info/route.ts +0 -12
  116. package/src/app/api/generate/route.ts +0 -106
  117. package/src/app/favicon.ico +0 -0
  118. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { subscribeWs, unsubscribeWs, isWsConnected } from '@/lib/ws-client'
5
+
6
+ /**
7
+ * Subscribe to a WebSocket topic. Calls `handler` on push events.
8
+ * Falls back to polling at `fallbackMs` when WS is disconnected.
9
+ */
10
+ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
11
+ const handlerRef = useRef(handler)
12
+ handlerRef.current = handler
13
+ const fallbackMsRef = useRef(fallbackMs)
14
+ fallbackMsRef.current = fallbackMs
15
+
16
+ // WS subscription — only re-runs when topic changes
17
+ useEffect(() => {
18
+ if (!topic) return
19
+
20
+ const cb = () => handlerRef.current()
21
+ subscribeWs(topic, cb)
22
+ return () => { unsubscribeWs(topic, cb) }
23
+ }, [topic])
24
+
25
+ // Fallback polling — separate effect so it doesn't tear down WS subscription
26
+ useEffect(() => {
27
+ if (!topic) return
28
+
29
+ let fallbackId: ReturnType<typeof setInterval> | null = null
30
+ const cb = () => handlerRef.current()
31
+
32
+ const startFallback = () => {
33
+ const ms = fallbackMsRef.current
34
+ if (fallbackId || !ms || ms <= 0) return
35
+ fallbackId = setInterval(cb, ms)
36
+ }
37
+ const stopFallback = () => {
38
+ if (fallbackId) {
39
+ clearInterval(fallbackId)
40
+ fallbackId = null
41
+ }
42
+ }
43
+
44
+ // Check WS connection state periodically to toggle fallback
45
+ const checkId = setInterval(() => {
46
+ const ms = fallbackMsRef.current
47
+ if (!ms || ms <= 0) {
48
+ stopFallback()
49
+ } else if (isWsConnected()) {
50
+ stopFallback()
51
+ } else {
52
+ startFallback()
53
+ }
54
+ }, 2000)
55
+
56
+ // Start fallback immediately if not connected and fallback is enabled
57
+ if (!isWsConnected() && fallbackMsRef.current && fallbackMsRef.current > 0) {
58
+ startFallback()
59
+ }
60
+
61
+ return () => {
62
+ stopFallback()
63
+ clearInterval(checkId)
64
+ }
65
+ }, [topic])
66
+ }
@@ -2,7 +2,9 @@ export async function register() {
2
2
  if (process.env.NEXT_RUNTIME === 'nodejs') {
3
3
  const { startScheduler } = await import('./lib/server/scheduler')
4
4
  const { resumeQueue } = await import('./lib/server/queue')
5
+ const { initWsServer } = await import('./lib/server/ws-hub')
5
6
  startScheduler()
6
7
  resumeQueue()
8
+ initWsServer()
7
9
  }
8
10
  }
package/src/lib/chat.ts CHANGED
@@ -12,8 +12,19 @@ export async function streamChat(
12
12
  imagePath?: string,
13
13
  imageUrl?: string,
14
14
  onEvent?: (event: SSEEvent) => void,
15
+ optionsOrFiles?: StreamChatOptions | string[],
15
16
  options?: StreamChatOptions,
16
17
  ): Promise<void> {
18
+ // Support both (options) and (attachedFiles, options) as 6th arg
19
+ let attachedFiles: string[] | undefined
20
+ let opts: StreamChatOptions | undefined
21
+ if (Array.isArray(optionsOrFiles)) {
22
+ attachedFiles = optionsOrFiles
23
+ opts = options
24
+ } else {
25
+ opts = optionsOrFiles
26
+ }
27
+
17
28
  const key = getStoredAccessKey()
18
29
  const res = await fetch(`/api/sessions/${sessionId}/chat`, {
19
30
  method: 'POST',
@@ -25,8 +36,9 @@ export async function streamChat(
25
36
  message,
26
37
  imagePath,
27
38
  imageUrl,
28
- internal: !!options?.internal,
29
- queueMode: options?.queueMode,
39
+ attachedFiles,
40
+ internal: !!opts?.internal,
41
+ queueMode: opts?.queueMode,
30
42
  }),
31
43
  })
32
44
 
@@ -113,7 +113,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
113
113
  const msgs: Array<{ role: string; content: any }> = []
114
114
 
115
115
  if (loadHistory) {
116
- const history = loadHistory(session.id)
116
+ const history = loadHistory(session.id).slice(-40)
117
117
  for (const m of history) {
118
118
  if (m.role === 'user' && m.imagePath) {
119
119
  const blocks = fileToContentBlocks(m.imagePath)
@@ -246,6 +246,7 @@ export function getProviderList(): ProviderInfo[] {
246
246
  .map(({ handler, ...info }) => ({
247
247
  ...info,
248
248
  models: overrides[info.id] || info.models,
249
+ defaultModels: info.models,
249
250
  }))
250
251
  const customs = Object.values(getCustomProviders())
251
252
  .filter((c) => c.isEnabled)
@@ -253,6 +254,7 @@ export function getProviderList(): ProviderInfo[] {
253
254
  id: c.id as any,
254
255
  name: c.name,
255
256
  models: c.models,
257
+ defaultModels: c.models,
256
258
  requiresApiKey: c.requiresApiKey,
257
259
  requiresEndpoint: false,
258
260
  defaultEndpoint: c.baseUrl,
@@ -116,7 +116,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
116
116
  const msgs: Array<{ role: string; content: string; images?: string[] }> = []
117
117
 
118
118
  if (loadHistory) {
119
- const history = loadHistory(session.id)
119
+ const history = loadHistory(session.id).slice(-40)
120
120
  for (const m of history) {
121
121
  if (m.role === 'user' && m.imagePath) {
122
122
  msgs.push({ role: 'user', ...fileToOllamaMsg(m.text, m.imagePath) })
@@ -4,27 +4,48 @@ import type { StreamChatOptions } from './index'
4
4
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
5
5
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
6
6
 
7
- function fileToContentParts(filePath: string): any[] {
7
+ async function fileToContentParts(filePath: string): Promise<any[]> {
8
8
  if (!filePath || !fs.existsSync(filePath)) return []
9
+ const name = filePath.split('/').pop() || 'file'
9
10
  if (IMAGE_EXTS.test(filePath)) {
10
- const data = fs.readFileSync(filePath).toString('base64')
11
+ const buf = fs.readFileSync(filePath)
12
+ if (buf.length === 0) return [{ type: 'text', text: `[Attached image: ${name} — file is empty]` }]
13
+ const data = buf.toString('base64')
11
14
  const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
12
- const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
13
- return [{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}` } }]
15
+ let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
16
+ if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
17
+ else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
18
+ else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
19
+ else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
20
+ return [{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }]
14
21
  }
15
- if (TEXT_EXTS.test(filePath) || filePath.endsWith('.pdf')) {
22
+ if (filePath.endsWith('.pdf')) {
23
+ try {
24
+ // @ts-ignore — pdf-parse types
25
+ const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
26
+ const buf = fs.readFileSync(filePath)
27
+ const result = await pdfParse(buf)
28
+ const pdfText = (result.text || '').trim()
29
+ if (!pdfText) return [{ type: 'text', text: `[Attached PDF: ${name} — no extractable text]` }]
30
+ const maxChars = 100_000
31
+ const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
32
+ return [{ type: 'text', text: `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}` }]
33
+ } catch {
34
+ return [{ type: 'text', text: `[Attached PDF: ${name} — could not extract text]` }]
35
+ }
36
+ }
37
+ if (TEXT_EXTS.test(filePath)) {
16
38
  try {
17
39
  const text = fs.readFileSync(filePath, 'utf-8')
18
- const name = filePath.split('/').pop() || 'file'
19
40
  return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
20
41
  } catch { return [] }
21
42
  }
22
- return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
43
+ return [{ type: 'text', text: `[Attached file: ${name}]` }]
23
44
  }
24
45
 
25
46
  export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
26
47
  return new Promise(async (resolve) => {
27
- const messages = buildMessages(session, message, imagePath, systemPrompt, loadHistory)
48
+ const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
28
49
  const model = session.model || 'gpt-4o'
29
50
 
30
51
  const payload = JSON.stringify({
@@ -134,7 +155,7 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
134
155
  })
135
156
  }
136
157
 
137
- function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
158
+ async function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
138
159
  const msgs: Array<{ role: string; content: any }> = []
139
160
 
140
161
  if (systemPrompt) {
@@ -142,10 +163,10 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
142
163
  }
143
164
 
144
165
  if (loadHistory) {
145
- const history = loadHistory(session.id)
166
+ const history = loadHistory(session.id).slice(-40)
146
167
  for (const m of history) {
147
168
  if (m.role === 'user' && m.imagePath) {
148
- const parts = fileToContentParts(m.imagePath)
169
+ const parts = await fileToContentParts(m.imagePath)
149
170
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: m.text }] })
150
171
  } else {
151
172
  msgs.push({ role: m.role, content: m.text })
@@ -155,7 +176,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
155
176
 
156
177
  // Current message with optional attachment
157
178
  if (imagePath) {
158
- const parts = fileToContentParts(imagePath)
179
+ const parts = await fileToContentParts(imagePath)
159
180
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: message }] })
160
181
  } else {
161
182
  msgs.push({ role: 'user', content: message })
@@ -20,6 +20,7 @@ import { stripMainLoopMetaForPersistence } from './main-agent-loop'
20
20
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
21
21
  import { getMemoryDb } from './memory-db'
22
22
  import { routeTaskIntent } from './capability-router'
23
+ import { notify } from './ws-hub'
23
24
  import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
24
25
  import type { MessageToolEvent, SSEEvent } from '@/types'
25
26
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
@@ -45,6 +46,7 @@ export interface ExecuteChatTurnInput {
45
46
  message: string
46
47
  imagePath?: string
47
48
  imageUrl?: string
49
+ attachedFiles?: string[]
48
50
  internal?: boolean
49
51
  source?: string
50
52
  runId?: string
@@ -423,6 +425,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
423
425
  message,
424
426
  imagePath,
425
427
  imageUrl,
428
+ attachedFiles,
426
429
  internal = false,
427
430
  runId,
428
431
  source = 'chat',
@@ -439,9 +442,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
439
442
  const appSettings = loadSettings()
440
443
  const toolPolicy = resolveSessionToolPolicy(session.tools, appSettings)
441
444
  const isHeartbeatRun = internal && source === 'heartbeat'
445
+ const isAutoRunNoHistory = isHeartbeatRun || (internal && source === 'main-loop-followup')
442
446
  const heartbeatStatus = session.mainLoopState?.status || 'idle'
443
- const heartbeatStatusOnly = isHeartbeatRun
444
- && (session.name !== '__main__' || heartbeatStatus === 'ok' || heartbeatStatus === 'idle')
447
+ const mainLoopIdle = session.name === '__main__'
448
+ && (heartbeatStatus === 'ok' || heartbeatStatus === 'idle')
449
+ && !(session.mainLoopState?.pendingEvents?.length > 0)
450
+ const heartbeatStatusOnly = isHeartbeatRun && mainLoopIdle
445
451
  const toolsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledTools
446
452
  let sessionForRun = toolsForRun === session.tools
447
453
  ? session
@@ -520,6 +526,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
520
526
  time: Date.now(),
521
527
  imagePath: imagePath || undefined,
522
528
  imageUrl: imageUrl || undefined,
529
+ attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
523
530
  })
524
531
  session.lastActiveAt = Date.now()
525
532
  saveSessions(sessions)
@@ -567,15 +574,22 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
567
574
 
568
575
  try {
569
576
  const hasTools = !!sessionForRun.tools?.length && !CLI_PROVIDER_IDS.has(providerType)
577
+ // Heartbeat runs are self-contained — skip conversation history to avoid
578
+ // blowing past the context window on long-lived sessions.
579
+ const heartbeatHistory = isAutoRunNoHistory ? [] : undefined
580
+
581
+ console.log(`[chat-execution] provider=${providerType}, hasTools=${hasTools}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, tools=${(sessionForRun.tools || []).length}`)
582
+
570
583
  fullResponse = hasTools
571
584
  ? (await streamAgentChat({
572
585
  session: sessionForRun,
573
586
  message,
574
587
  imagePath,
588
+ attachedFiles,
575
589
  apiKey,
576
590
  systemPrompt,
577
591
  write: (raw) => parseAndEmit(raw),
578
- history: getSessionMessages(sessionId),
592
+ history: heartbeatHistory ?? getSessionMessages(sessionId),
579
593
  signal: abortController.signal,
580
594
  })).fullText
581
595
  : await provider.handler.streamChat({
@@ -586,7 +600,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
586
600
  systemPrompt,
587
601
  write: (raw: string) => parseAndEmit(raw),
588
602
  active,
589
- loadHistory: getSessionMessages,
603
+ loadHistory: isAutoRunNoHistory ? () => [] : getSessionMessages,
590
604
  })
591
605
  } catch (err: any) {
592
606
  errorMessage = err?.message || String(err)
@@ -881,6 +895,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
881
895
  }
882
896
  fresh[sessionId] = current
883
897
  saveSessions(fresh)
898
+ notify(`messages:${sessionId}`)
884
899
  }
885
900
 
886
901
  return {
@@ -3,7 +3,9 @@ import {
3
3
  loadConnectors, saveConnectors, loadSessions, saveSessions,
4
4
  loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
5
5
  } from '../storage'
6
+ import { WORKSPACE_DIR } from '../data-dir'
6
7
  import { streamAgentChat } from '../stream-agent-chat'
8
+ import { notify } from '../ws-hub'
7
9
  import { logExecution } from '../execution-log'
8
10
  import type { Connector } from '@/types'
9
11
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
@@ -124,7 +126,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
124
126
  session = {
125
127
  id,
126
128
  name: sessionKey,
127
- cwd: process.cwd(),
129
+ cwd: WORKSPACE_DIR,
128
130
  user: 'connector',
129
131
  provider: agent.provider === 'claude-cli' ? 'anthropic' : agent.provider,
130
132
  model: agent.model,
@@ -203,7 +205,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
203
205
  apiKey,
204
206
  systemPrompt,
205
207
  write: () => {}, // no SSE needed for connectors
206
- history: session.messages,
208
+ history: session.messages.slice(-20),
207
209
  })
208
210
  // Use finalResponse for connectors — strips intermediate planning/tool-use text
209
211
  fullText = result.finalResponse
@@ -234,7 +236,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
234
236
  }
235
237
  },
236
238
  active: new Map(),
237
- loadHistory: () => session.messages,
239
+ loadHistory: () => session.messages.slice(-20),
238
240
  })
239
241
  }
240
242
 
@@ -268,6 +270,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
268
270
  const s2 = loadSessions()
269
271
  s2[session.id] = session
270
272
  saveSessions(s2)
273
+ notify(`messages:${session.id}`)
271
274
  }
272
275
 
273
276
  return fullText || '(no response)'
@@ -341,6 +344,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
341
344
  connector.updatedAt = Date.now()
342
345
  connectors[connectorId] = connector
343
346
  saveConnectors(connectors)
347
+ notify('connectors')
344
348
 
345
349
  console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
346
350
  } catch (err: any) {
@@ -350,6 +354,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
350
354
  connector.updatedAt = Date.now()
351
355
  connectors[connectorId] = connector
352
356
  saveConnectors(connectors)
357
+ notify('connectors')
353
358
  throw err
354
359
  }
355
360
  }
@@ -371,6 +376,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
371
376
  connector.updatedAt = Date.now()
372
377
  connectors[connectorId] = connector
373
378
  saveConnectors(connectors)
379
+ notify('connectors')
374
380
  }
375
381
 
376
382
  console.log(`[connector] Stopped connector: ${connectorId}`)
@@ -56,7 +56,7 @@ const PROVIDER_DEFAULT_WINDOWS: Record<string, number> = {
56
56
  mistral: 128_000,
57
57
  xai: 131_072,
58
58
  fireworks: 32_768,
59
- ollama: 8_192,
59
+ ollama: 32_768,
60
60
  openclaw: 128_000,
61
61
  }
62
62
 
@@ -1,4 +1,5 @@
1
1
  import { loadQueue, loadSchedules, loadSessions, saveSessions, loadConnectors } from './storage'
2
+ import { notify } from './ws-hub'
2
3
  import { processNext, cleanupFinishedTaskSessions, validateCompletedTasksQueue, recoverStalledRunningTasks } from './queue'
3
4
  import { startScheduler, stopScheduler } from './scheduler'
4
5
  import { sweepOrphanedBrowsers, getActiveBrowserCount } from './session-tools'
@@ -114,6 +115,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
114
115
  return
115
116
  }
116
117
  ds.running = true
118
+ notify('daemon')
117
119
  console.log(`[daemon] Starting daemon (source=${source}, scheduler + queue processor + heartbeat)`)
118
120
 
119
121
  validateCompletedTasksQueue()
@@ -135,6 +137,7 @@ export function stopDaemon(options?: { source?: string; manualStop?: boolean })
135
137
  if (options?.manualStop === true) ds.manualStopRequested = true
136
138
  if (!ds.running) return
137
139
  ds.running = false
140
+ notify('daemon')
138
141
  console.log(`[daemon] Stopping daemon (source=${source})`)
139
142
 
140
143
  stopScheduler()
@@ -1,3 +1,4 @@
1
1
  import path from 'path'
2
2
 
3
3
  export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
4
+ export const WORKSPACE_DIR = path.join(DATA_DIR, 'workspace')
@@ -1,7 +1,10 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
1
3
  import { loadAgents, loadSessions, loadSettings } from './storage'
2
4
  import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
3
5
  import { log } from './logger'
4
6
  import { buildMainLoopHeartbeatPrompt, getMainLoopStateForSession, isMainSession } from './main-agent-loop'
7
+ import { WORKSPACE_DIR } from './data-dir'
5
8
 
6
9
  const HEARTBEAT_TICK_MS = 5_000
7
10
 
@@ -118,6 +121,56 @@ export interface HeartbeatConfig {
118
121
 
119
122
  const DEFAULT_HEARTBEAT_PROMPT = 'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.'
120
123
 
124
+ function readHeartbeatFile(session: any): string {
125
+ try {
126
+ const filePath = path.join(session.cwd || WORKSPACE_DIR, 'HEARTBEAT.md')
127
+ if (fs.existsSync(filePath)) {
128
+ return fs.readFileSync(filePath, 'utf-8').trim()
129
+ }
130
+ } catch { /* ignore */ }
131
+ return ''
132
+ }
133
+
134
+ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
135
+ if (!agent) return fallbackPrompt
136
+
137
+ // Dynamic goal (agent-set) takes priority over static system prompt
138
+ const dynamicGoal = agent.heartbeatGoal || ''
139
+ const dynamicNextAction = agent.heartbeatNextAction || ''
140
+ const description = agent.description || ''
141
+ const systemPrompt = agent.systemPrompt || ''
142
+ const soul = agent.soul || ''
143
+ const goalSummary = systemPrompt.slice(0, 500)
144
+ const recentMessages = (session.messages || []).slice(-5)
145
+ const recentContext = recentMessages
146
+ .map((m: any) => `[${m.role}]: ${(m.text || '').slice(0, 200)}`)
147
+ .join('\n')
148
+
149
+ return [
150
+ 'AGENT_HEARTBEAT_TICK',
151
+ `Time: ${new Date().toISOString()}`,
152
+ `Agent: ${agent.name}`,
153
+ description ? `Description: ${description}` : '',
154
+ dynamicGoal
155
+ ? `Current goal (self-set): ${dynamicGoal}`
156
+ : goalSummary ? `System prompt (initial goal):\n${goalSummary}` : '',
157
+ dynamicNextAction ? `Planned next action: ${dynamicNextAction}` : '',
158
+ soul ? `Persona: ${soul.slice(0, 300)}` : '',
159
+ heartbeatFileContent ? `\nHEARTBEAT.md contents:\n${heartbeatFileContent.slice(0, 2000)}` : '',
160
+ recentContext ? `Recent conversation:\n${recentContext}` : '',
161
+ '',
162
+ 'You are running an autonomous heartbeat tick. Review your goal and recent context.',
163
+ 'If there is meaningful work to do toward your goal, use your tools and take action.',
164
+ 'If nothing needs attention right now, reply exactly HEARTBEAT_OK.',
165
+ 'Do not ask clarifying questions. Take the most reasonable next action.',
166
+ '',
167
+ 'To update your goal or plan, include this line in your response:',
168
+ '[AGENT_HEARTBEAT_META]{"goal": "your evolved goal", "status": "progress", "next_action": "what you plan to do next"}',
169
+ 'You can evolve your goal as you learn more. Set status to "progress" while working, "ok" when done, "idle" when waiting.',
170
+ fallbackPrompt !== DEFAULT_HEARTBEAT_PROMPT ? `\nAdditional instructions: ${fallbackPrompt}` : '',
171
+ ].filter(Boolean).join('\n')
172
+ }
173
+
121
174
  function resolveInterval(obj: Record<string, any>, currentSec: number): number {
122
175
  // Prefer heartbeatInterval (duration string) over heartbeatIntervalSec (raw number)
123
176
  if (obj.heartbeatInterval !== undefined && obj.heartbeatInterval !== null) {
@@ -288,9 +341,20 @@ async function tickHeartbeats() {
288
341
  const runState = getSessionRunState(session.id)
289
342
  if (runState.runningRunId) continue
290
343
 
291
- const heartbeatMessage = isMainSession(session)
292
- ? buildMainLoopHeartbeatPrompt(session, cfg.prompt)
293
- : cfg.prompt
344
+ let heartbeatMessage: string
345
+ if (isMainSession(session)) {
346
+ heartbeatMessage = buildMainLoopHeartbeatPrompt(session, cfg.prompt)
347
+ } else {
348
+ const heartbeatFileContent = readHeartbeatFile(session)
349
+ const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
350
+ const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
351
+ // Skip heartbeat only if there's truly nothing to drive it:
352
+ // no agent goal, no HEARTBEAT.md content, AND no custom prompt configured
353
+ if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
354
+ continue
355
+ }
356
+ heartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
357
+ }
294
358
 
295
359
  const enqueue = enqueueSessionRun({
296
360
  sessionId: session.id,