@swarmclawai/swarmclaw 0.7.8 → 0.8.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 (251) hide show
  1. package/README.md +12 -15
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -14,6 +14,7 @@ import { drainSystemEvents } from './system-events'
14
14
  import { buildIdentityContinuityContext } from './identity-continuity'
15
15
  import { buildMainLoopHeartbeatPrompt, isMainSession } from './main-agent-loop'
16
16
  import { ensureAgentThreadSession } from './agent-thread-session'
17
+ import { isAgentDisabled } from './agent-availability'
17
18
 
18
19
  const HEARTBEAT_TICK_MS = 5_000
19
20
 
@@ -134,7 +135,7 @@ interface HeartbeatFileSession {
134
135
 
135
136
  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.'
136
137
 
137
- function readHeartbeatFile(session: HeartbeatFileSession): string {
138
+ export function readHeartbeatFile(session: HeartbeatFileSession): string {
138
139
  try {
139
140
  const filePath = path.join(session.cwd || WORKSPACE_DIR, 'HEARTBEAT.md')
140
141
  if (fs.existsSync(filePath)) {
@@ -196,7 +197,7 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
196
197
  return true
197
198
  }
198
199
 
199
- function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
200
+ export function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
200
201
  if (!agent) return fallbackPrompt
201
202
 
202
203
  const identityContext = buildIdentityContext(session, agent)
@@ -285,7 +286,7 @@ function resolveNum(obj: Record<string, any>, key: string, current: number): num
285
286
  return current
286
287
  }
287
288
 
288
- function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
289
+ export function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
289
290
  // Global defaults — 30 min interval (was 120s)
290
291
  let intervalSec = resolveInterval(settings, DEFAULT_HEARTBEAT_INTERVAL_SEC)
291
292
  const globalPrompt = (typeof settings.heartbeatPrompt === 'string' && settings.heartbeatPrompt.trim())
@@ -366,11 +367,11 @@ async function tickHeartbeats() {
366
367
 
367
368
  const agents = loadAgents()
368
369
  for (const agent of Object.values(agents) as any[]) {
369
- if (!agent?.id || agent.heartbeatEnabled !== true) continue
370
+ if (!agent?.id || agent.heartbeatEnabled !== true || isAgentDisabled(agent)) continue
370
371
  ensureAgentThreadSession(String(agent.id))
371
372
  }
372
373
  const sessions = loadSessions()
373
- const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
374
+ const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true && !isAgentDisabled(a))
374
375
 
375
376
  // Prune tracked sessions that no longer exist or have heartbeat disabled
376
377
  for (const trackedId of state.lastBySession.keys()) {
@@ -391,6 +392,7 @@ async function tickHeartbeats() {
391
392
 
392
393
  // Check if this session or its agent has explicit heartbeat opt-in
393
394
  const agent = session.agentId ? agents[session.agentId] : null
395
+ if (isAgentDisabled(agent)) continue
394
396
  const explicitOptIn = session.heartbeatEnabled === true || (agent && agent.heartbeatEnabled === true)
395
397
 
396
398
  // If global loopMode is bounded, only allow sessions with explicit opt-in
@@ -428,12 +430,15 @@ async function tickHeartbeats() {
428
430
 
429
431
  const rawHeartbeatFileContent = readHeartbeatFile(session)
430
432
  const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
431
- const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
433
+ const hasExplicitGoal = !!(agent?.heartbeatGoal || agent?.heartbeatNextAction)
434
+ const hasAgentContext = !!(agent?.description || agent?.systemPrompt || agent?.soul)
432
435
  const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
433
- // Skip heartbeat only if there's truly nothing to drive it:
434
- // no agent goal, no HEARTBEAT.md content, AND no custom prompt configured
435
- if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
436
- continue
436
+ const hasUserMessages = lastUserMessageAt(session) > 0
437
+ // Skip heartbeat if there's nothing to drive it. An agent description alone
438
+ // is not enough the session needs at least one user message or an explicit
439
+ // heartbeat goal/HEARTBEAT.md content. This prevents noise on unused sessions.
440
+ if (!hasExplicitGoal && !heartbeatFileContent && !hasCustomPrompt) {
441
+ if (!hasAgentContext || !hasUserMessages) continue
437
442
  }
438
443
  const baseHeartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
439
444
  const heartbeatMessage = isMainSession(session)
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, describe, it } from 'node:test'
3
+
4
+ import {
5
+ buildHeartbeatWakePrompt,
6
+ buildWakeTriggerContext,
7
+ hasPendingHeartbeatWake,
8
+ mergeHeartbeatWakeRequest,
9
+ requestHeartbeatNow,
10
+ resetHeartbeatWakeStateForTests,
11
+ snapshotPendingHeartbeatWakesForTests,
12
+ } from './heartbeat-wake'
13
+
14
+ describe('heartbeat-wake helpers', () => {
15
+ afterEach(() => {
16
+ resetHeartbeatWakeStateForTests()
17
+ })
18
+
19
+ it('retains distinct wake events per target and keeps the latest requested timestamp', () => {
20
+ const first = mergeHeartbeatWakeRequest(undefined, {
21
+ agentId: 'ops',
22
+ reason: 'connector-message',
23
+ source: 'connector:slack',
24
+ resumeMessage: 'Slack says the deploy is red.',
25
+ requestedAt: 100,
26
+ })
27
+ const merged = mergeHeartbeatWakeRequest(first, {
28
+ agentId: 'ops',
29
+ reason: 'schedule',
30
+ source: 'schedule:nightly',
31
+ resumeMessage: 'Nightly check-in fired.',
32
+ requestedAt: 250,
33
+ })
34
+
35
+ assert.equal(merged.agentId, 'ops')
36
+ assert.equal(merged.requestedAt, 250)
37
+ assert.equal(merged.events.length, 2)
38
+ assert.deepEqual(merged.events.map((event) => event.reason), ['connector-message', 'schedule'])
39
+ })
40
+
41
+ it('deduplicates identical events but preserves differently sourced triggers', () => {
42
+ let wake = mergeHeartbeatWakeRequest(undefined, {
43
+ sessionId: 's1',
44
+ reason: 'schedule',
45
+ source: 'schedule:nightly',
46
+ requestedAt: 1,
47
+ })
48
+ wake = mergeHeartbeatWakeRequest(wake, {
49
+ sessionId: 's1',
50
+ reason: 'schedule',
51
+ source: 'schedule:nightly',
52
+ requestedAt: 2,
53
+ })
54
+ wake = mergeHeartbeatWakeRequest(wake, {
55
+ sessionId: 's1',
56
+ reason: 'schedule',
57
+ source: 'schedule:hourly',
58
+ requestedAt: 3,
59
+ })
60
+
61
+ assert.equal(wake.events.length, 2)
62
+ assert.deepEqual(wake.events.map((event) => event.source), ['schedule:hourly', 'schedule:nightly'])
63
+ })
64
+
65
+ it('builds a structured trigger context for event-driven wakes', () => {
66
+ const wake = mergeHeartbeatWakeRequest(undefined, {
67
+ sessionId: 'sess-1',
68
+ reason: 'connector-message',
69
+ source: 'connector:slack',
70
+ resumeMessage: 'Slack says deploy is still red.',
71
+ detail: 'Text: prod deploy is still failing health checks',
72
+ requestedAt: 10,
73
+ priority: 90,
74
+ })
75
+ const triggerContext = buildWakeTriggerContext(wake.events, '2026-03-08T15:30:00.000Z')
76
+ const prompt = buildHeartbeatWakePrompt({
77
+ wake,
78
+ basePrompt: 'BASE_PROMPT',
79
+ nowIso: '2026-03-08T15:30:00.000Z',
80
+ })
81
+
82
+ assert.match(triggerContext, /## Wake Trigger Context/)
83
+ assert.match(triggerContext, /reason=connector-message \| source=connector:slack \| priority=90/)
84
+ assert.match(triggerContext, /Resume: Slack says deploy is still red\./)
85
+ assert.match(triggerContext, /Detail: Text: prod deploy is still failing health checks/)
86
+ assert.match(prompt, /^BASE_PROMPT/m)
87
+ assert.match(prompt, /Reply HEARTBEAT_OK only if every trigger above is already handled/)
88
+ })
89
+
90
+ it('tracks pending wake state while coalesced wakes are queued', () => {
91
+ requestHeartbeatNow({
92
+ sessionId: 'sess-2',
93
+ reason: 'watch_job',
94
+ source: 'watch:http',
95
+ resumeMessage: 'Check the changed API response.',
96
+ })
97
+ requestHeartbeatNow({
98
+ sessionId: 'sess-2',
99
+ reason: 'connector-message',
100
+ source: 'connector:slack',
101
+ resumeMessage: 'Slack asks for an update.',
102
+ })
103
+
104
+ assert.equal(hasPendingHeartbeatWake(), true)
105
+ const wakes = snapshotPendingHeartbeatWakesForTests()
106
+ assert.equal(wakes.length, 1)
107
+ assert.deepEqual(
108
+ [...wakes[0].events.map((event) => event.reason)].sort(),
109
+ ['connector-message', 'watch_job'],
110
+ )
111
+ })
112
+ })
@@ -1,84 +1,338 @@
1
1
  /**
2
2
  * On-demand heartbeat wake — triggers an immediate heartbeat for an agent/session.
3
- * Requests are debounced with a 250ms coalesce window to batch rapid-fire events.
3
+ * Requests are debounced with a short coalesce window, retain distinct trigger
4
+ * events per target, and retry when the session lane is already busy.
4
5
  */
5
6
 
6
7
  import { ensureAgentThreadSession } from './agent-thread-session'
8
+ import {
9
+ buildAgentHeartbeatPrompt,
10
+ heartbeatConfigForSession,
11
+ isHeartbeatContentEffectivelyEmpty,
12
+ readHeartbeatFile,
13
+ } from './heartbeat-service'
14
+ import { buildMainLoopHeartbeatPrompt, isMainSession } from './main-agent-loop'
7
15
  import { loadSessions, loadAgents, loadSettings } from './storage'
8
- import { enqueueSessionRun } from './session-run-manager'
16
+ import { enqueueSessionRun, getSessionExecutionState } from './session-run-manager'
9
17
  import { log } from './logger'
18
+ import { isAgentDisabled } from './agent-availability'
10
19
 
11
- interface WakeRequest {
20
+ export interface WakeRequestInput {
21
+ eventId?: string
12
22
  agentId?: string
13
23
  sessionId?: string
14
24
  reason?: string
25
+ source?: string
26
+ resumeMessage?: string
27
+ detail?: string
28
+ requestedAt?: number
29
+ occurredAt?: number
30
+ priority?: number
31
+ retryCount?: number
32
+ }
33
+
34
+ export interface WakeEvent {
35
+ eventId?: string
36
+ reason: string
37
+ source?: string
38
+ resumeMessage?: string
39
+ detail?: string
40
+ occurredAt: number
41
+ priority: number
42
+ }
43
+
44
+ export interface WakeRequest {
45
+ agentId?: string
46
+ sessionId?: string
47
+ requestedAt: number
48
+ retryCount: number
49
+ events: WakeEvent[]
15
50
  }
16
51
 
17
52
  const COALESCE_MS = 250
53
+ const RETRY_MS = 1_000
54
+ const MAX_WAKE_EVENTS = 6
55
+ const MAX_RESUME_CHARS = 280
56
+ const MAX_DETAIL_CHARS = 800
57
+ type WakeTimerKind = 'normal' | 'retry'
18
58
 
19
59
  const globalKey = '__swarmclaw_heartbeat_wake__' as const
20
60
  const globalScope = globalThis as typeof globalThis & {
21
- [globalKey]?: { pending: Map<string, WakeRequest>; timer: ReturnType<typeof setTimeout> | null }
61
+ [globalKey]?: {
62
+ pending: Map<string, WakeRequest>
63
+ timer: ReturnType<typeof setTimeout> | null
64
+ timerDueAt: number | null
65
+ timerKind: WakeTimerKind | null
66
+ }
22
67
  }
23
68
  const state = globalScope[globalKey] ?? (globalScope[globalKey] = {
24
69
  pending: new Map(),
25
70
  timer: null,
71
+ timerDueAt: null,
72
+ timerKind: null,
26
73
  })
27
74
 
75
+ function trimText(value: unknown, maxChars: number): string | undefined {
76
+ if (typeof value !== 'string') return undefined
77
+ const normalized = value.replace(/\s+/g, ' ').trim()
78
+ return normalized ? normalized.slice(0, maxChars) : undefined
79
+ }
80
+
81
+ function normalizeWakeReason(reason?: string): string {
82
+ return trimText(reason, 80) || 'on-demand'
83
+ }
84
+
85
+ function normalizeWakeTarget(value?: string): string | undefined {
86
+ return trimText(value, 160)
87
+ }
88
+
89
+ function normalizeOccurredAt(value?: number): number {
90
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : Date.now()
91
+ }
92
+
93
+ function reasonPriority(reason: string): number {
94
+ const normalized = reason.toLowerCase()
95
+ if (/(approval|connector-message|webhook|watch_job|scheduled_wake|task-completed)/.test(normalized)) return 90
96
+ if (/(schedule)/.test(normalized)) return 70
97
+ if (/(comparison|manual|on-demand)/.test(normalized)) return 50
98
+ return 40
99
+ }
100
+
101
+ function normalizeWakeEvent(input: WakeRequestInput): WakeEvent {
102
+ const reason = normalizeWakeReason(input.reason)
103
+ const explicitPriority = typeof input.priority === 'number' && Number.isFinite(input.priority)
104
+ ? Math.trunc(input.priority)
105
+ : reasonPriority(reason)
106
+ return {
107
+ ...(trimText(input.eventId, 160) ? { eventId: trimText(input.eventId, 160) } : {}),
108
+ reason,
109
+ ...(trimText(input.source, 120) ? { source: trimText(input.source, 120) } : {}),
110
+ ...(trimText(input.resumeMessage, MAX_RESUME_CHARS) ? { resumeMessage: trimText(input.resumeMessage, MAX_RESUME_CHARS) } : {}),
111
+ ...(trimText(input.detail, MAX_DETAIL_CHARS) ? { detail: trimText(input.detail, MAX_DETAIL_CHARS) } : {}),
112
+ occurredAt: normalizeOccurredAt(input.occurredAt ?? input.requestedAt),
113
+ priority: Math.max(0, Math.min(100, explicitPriority)),
114
+ }
115
+ }
116
+
117
+ function uniqueWakeEvents(existing: WakeEvent[], incoming: WakeEvent): WakeEvent[] {
118
+ const merged = [...existing]
119
+ const matchIndex = merged.findIndex((candidate) => {
120
+ if (candidate.eventId && incoming.eventId) return candidate.eventId === incoming.eventId
121
+ return candidate.reason === incoming.reason
122
+ && candidate.source === incoming.source
123
+ && candidate.resumeMessage === incoming.resumeMessage
124
+ && candidate.detail === incoming.detail
125
+ })
126
+
127
+ if (matchIndex >= 0) {
128
+ const previous = merged[matchIndex]
129
+ merged[matchIndex] = {
130
+ ...previous,
131
+ ...incoming,
132
+ priority: Math.max(previous.priority, incoming.priority),
133
+ occurredAt: Math.max(previous.occurredAt, incoming.occurredAt),
134
+ resumeMessage: incoming.resumeMessage || previous.resumeMessage,
135
+ detail: incoming.detail || previous.detail,
136
+ }
137
+ } else {
138
+ merged.push(incoming)
139
+ }
140
+
141
+ merged.sort((left, right) => {
142
+ if (right.priority !== left.priority) return right.priority - left.priority
143
+ return right.occurredAt - left.occurredAt
144
+ })
145
+ return merged.slice(0, MAX_WAKE_EVENTS)
146
+ }
147
+
148
+ function wakeTargetKey(input: { agentId?: string; sessionId?: string }): string {
149
+ return `${normalizeWakeTarget(input.agentId) || ''}::${normalizeWakeTarget(input.sessionId) || ''}`
150
+ }
151
+
152
+ export function mergeHeartbeatWakeRequest(
153
+ existing: WakeRequest | undefined,
154
+ next: WakeRequestInput,
155
+ ): WakeRequest {
156
+ const agentId = normalizeWakeTarget(next.agentId) || existing?.agentId
157
+ const sessionId = normalizeWakeTarget(next.sessionId) || existing?.sessionId
158
+ const requestedAt = Math.max(existing?.requestedAt || 0, normalizeOccurredAt(next.requestedAt))
159
+ const retryCount = Math.max(existing?.retryCount || 0, typeof next.retryCount === 'number' ? Math.trunc(next.retryCount) : 0)
160
+ const events = uniqueWakeEvents(existing?.events || [], normalizeWakeEvent(next))
161
+ return {
162
+ ...(agentId ? { agentId } : {}),
163
+ ...(sessionId ? { sessionId } : {}),
164
+ requestedAt,
165
+ retryCount,
166
+ events,
167
+ }
168
+ }
169
+
170
+ function queuePendingWake(next: WakeRequestInput): void {
171
+ const key = wakeTargetKey(next)
172
+ const existing = state.pending.get(key)
173
+ state.pending.set(key, mergeHeartbeatWakeRequest(existing, next))
174
+ }
175
+
176
+ function queuePendingWakeRequest(next: WakeRequest): void {
177
+ const key = wakeTargetKey(next)
178
+ let merged = state.pending.get(key)
179
+ for (const event of next.events) {
180
+ merged = mergeHeartbeatWakeRequest(merged, {
181
+ agentId: next.agentId,
182
+ sessionId: next.sessionId,
183
+ requestedAt: next.requestedAt,
184
+ retryCount: next.retryCount,
185
+ eventId: event.eventId,
186
+ reason: event.reason,
187
+ source: event.source,
188
+ resumeMessage: event.resumeMessage,
189
+ detail: event.detail,
190
+ occurredAt: event.occurredAt,
191
+ priority: event.priority,
192
+ })
193
+ }
194
+ if (merged) state.pending.set(key, merged)
195
+ }
196
+
197
+ function scheduleFlush(delayMs: number, kind: WakeTimerKind = 'normal'): void {
198
+ const delay = Math.max(0, Number.isFinite(delayMs) ? Math.trunc(delayMs) : COALESCE_MS)
199
+ const dueAt = Date.now() + delay
200
+ if (state.timer) {
201
+ if (state.timerKind === 'retry' && kind !== 'normal') return
202
+ if (typeof state.timerDueAt === 'number' && state.timerDueAt <= dueAt) return
203
+ clearTimeout(state.timer)
204
+ state.timer = null
205
+ state.timerDueAt = null
206
+ state.timerKind = null
207
+ }
208
+
209
+ state.timerDueAt = dueAt
210
+ state.timerKind = kind
211
+ state.timer = setTimeout(() => {
212
+ flushWakes()
213
+ }, delay)
214
+ }
215
+
216
+ export function buildWakeTriggerContext(events: WakeEvent[], nowIso?: string): string {
217
+ const lines = [
218
+ '## Wake Trigger Context',
219
+ `Triggered at: ${nowIso || new Date().toISOString()}`,
220
+ 'These new events caused this immediate wake. Prioritize them over generic background polling and avoid repeating already-completed work.',
221
+ 'If the base heartbeat instructions require an exact file change or exact acknowledgment phrase, follow that exactly and do not add extra commentary.',
222
+ ]
223
+ for (const event of events.slice(0, MAX_WAKE_EVENTS)) {
224
+ const tags = [
225
+ `reason=${event.reason}`,
226
+ event.source ? `source=${event.source}` : '',
227
+ `priority=${event.priority}`,
228
+ `at=${new Date(event.occurredAt).toISOString()}`,
229
+ ].filter(Boolean).join(' | ')
230
+ lines.push(`- ${tags}`)
231
+ if (event.resumeMessage) lines.push(` Resume: ${event.resumeMessage}`)
232
+ if (event.detail) lines.push(` Detail: ${event.detail}`)
233
+ }
234
+ lines.push('Reply HEARTBEAT_OK only if every trigger above is already handled or truly needs no action.')
235
+ return lines.join('\n')
236
+ }
237
+
238
+ export function buildHeartbeatWakePrompt(input: {
239
+ wake: WakeRequest
240
+ basePrompt?: string
241
+ nowIso?: string
242
+ }): string {
243
+ const triggerContext = buildWakeTriggerContext(input.wake.events, input.nowIso)
244
+ if (input.basePrompt?.trim()) {
245
+ return [
246
+ input.basePrompt.trim(),
247
+ '',
248
+ triggerContext,
249
+ ].join('\n')
250
+ }
251
+ return [
252
+ 'AGENT_HEARTBEAT_WAKE',
253
+ `Time: ${input.nowIso || new Date().toISOString()}`,
254
+ triggerContext,
255
+ 'Take the highest-value next step now, or reply HEARTBEAT_OK if nothing needs attention.',
256
+ ].join('\n')
257
+ }
258
+
259
+ function resolveWakeSessionId(
260
+ wake: WakeRequest,
261
+ sessions: Record<string, Record<string, unknown>>,
262
+ ): string | undefined {
263
+ if (wake.sessionId) return wake.sessionId
264
+ if (!wake.agentId) return undefined
265
+
266
+ let bestSession: { id: string; lastActiveAt: number } | null = null
267
+ for (const session of Object.values(sessions)) {
268
+ if (session.agentId !== wake.agentId) continue
269
+ const lastActive = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : 0
270
+ if (!bestSession || lastActive > bestSession.lastActiveAt) {
271
+ bestSession = { id: String(session.id), lastActiveAt: lastActive }
272
+ }
273
+ }
274
+ if (bestSession?.id) return bestSession.id
275
+ return ensureAgentThreadSession(wake.agentId)?.id
276
+ }
277
+
28
278
  function flushWakes(): void {
29
279
  state.timer = null
30
- const wakes = new Map(state.pending)
280
+ state.timerDueAt = null
281
+ state.timerKind = null
282
+ const wakes = [...state.pending.values()]
31
283
  state.pending.clear()
32
284
 
285
+ if (!wakes.length) return
286
+
33
287
  const agents = loadAgents()
34
288
  const settings = loadSettings()
289
+ const sessions = loadSessions() as Record<string, Record<string, unknown>>
290
+ let delayedForRetry = false
35
291
 
36
- for (const [_key, wake] of wakes) {
292
+ for (const wake of wakes) {
37
293
  try {
38
- let sessionId = wake.sessionId
39
-
40
- // If only agentId provided, find the agent's most recently active session
41
- if (!sessionId && wake.agentId) {
42
- const sessions = loadSessions()
43
- let bestSession: { id: string; lastActiveAt: number } | null = null
44
- for (const s of Object.values(sessions) as Array<Record<string, unknown>>) {
45
- if (s.agentId !== wake.agentId) continue
46
- const lastActive = typeof s.lastActiveAt === 'number' ? s.lastActiveAt : 0
47
- if (!bestSession || lastActive > bestSession.lastActiveAt) {
48
- bestSession = { id: s.id as string, lastActiveAt: lastActive }
49
- }
50
- }
51
- sessionId = bestSession?.id
52
- if (!sessionId) {
53
- sessionId = ensureAgentThreadSession(wake.agentId)?.id
54
- }
55
- }
56
-
294
+ const sessionId = resolveWakeSessionId(wake, sessions)
57
295
  if (!sessionId) continue
58
296
 
59
- const session = loadSessions()[sessionId] as Record<string, unknown> | undefined
297
+ const session = (sessions[sessionId] || loadSessions()[sessionId]) as Record<string, unknown> | undefined
60
298
  if (!session) continue
61
299
 
300
+ const execution = getSessionExecutionState(sessionId)
301
+ if (execution.hasRunning || execution.hasQueued) {
302
+ queuePendingWakeRequest({
303
+ ...wake,
304
+ sessionId,
305
+ retryCount: wake.retryCount + 1,
306
+ })
307
+ delayedForRetry = true
308
+ log.info('heartbeat-wake', `Wake delayed for busy session ${sessionId}`, {
309
+ running: execution.hasRunning,
310
+ queued: execution.queueLength,
311
+ })
312
+ continue
313
+ }
314
+
62
315
  const agentId = (session.agentId || wake.agentId) as string | undefined
63
- const agent = agentId ? agents[agentId] : null
64
-
65
- // Build a minimal heartbeat prompt for the wake
66
- const reason = wake.reason || 'on-demand'
67
- const prompt = [
68
- 'AGENT_HEARTBEAT_WAKE',
69
- `Time: ${new Date().toISOString()}`,
70
- agent ? `Agent: ${(agent as Record<string, unknown>).name}` : '',
71
- `Wake reason: ${reason}`,
72
- '',
73
- 'An event has occurred that may require your attention.',
74
- 'Review and take appropriate action, or reply HEARTBEAT_OK if nothing is needed.',
75
- ].filter(Boolean).join('\n')
76
-
77
- // Resolve heartbeat model from agent/settings
78
- const heartbeatModel =
79
- (agent as Record<string, unknown> | null)?.heartbeatModel as string | undefined
80
- || settings.heartbeatModel as string | undefined
81
- || undefined
316
+ const agent = agentId ? agents[agentId] as Record<string, unknown> | null : null
317
+ if (isAgentDisabled(agent)) continue
318
+
319
+ const cfg = heartbeatConfigForSession(session, settings, agents)
320
+ if (!cfg.enabled) {
321
+ log.info('heartbeat-wake', `Wake skipped for session ${sessionId}: heartbeat disabled`, {
322
+ agentId,
323
+ })
324
+ continue
325
+ }
326
+ const rawHeartbeatFileContent = readHeartbeatFile(session)
327
+ const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
328
+ const baseHeartbeatPrompt = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
329
+ const promptCore = isMainSession(session)
330
+ ? buildMainLoopHeartbeatPrompt(session, baseHeartbeatPrompt)
331
+ : baseHeartbeatPrompt
332
+ const prompt = buildHeartbeatWakePrompt({
333
+ wake,
334
+ basePrompt: promptCore,
335
+ })
82
336
 
83
337
  enqueueSessionRun({
84
338
  sessionId,
@@ -87,28 +341,55 @@ function flushWakes(): void {
87
341
  source: 'heartbeat-wake',
88
342
  mode: 'collect',
89
343
  dedupeKey: `heartbeat-wake:${sessionId}`,
90
- modelOverride: heartbeatModel,
344
+ modelOverride: cfg.model || undefined,
91
345
  heartbeatConfig: {
92
- ackMaxChars: 300,
93
- showOk: false,
94
- showAlerts: true,
95
- target: null,
346
+ ackMaxChars: cfg.ackMaxChars,
347
+ showOk: cfg.showOk,
348
+ showAlerts: cfg.showAlerts,
349
+ target: cfg.target,
96
350
  },
97
351
  })
98
352
 
99
- log.info('heartbeat-wake', `Wake fired for session ${sessionId} (reason: ${reason})`)
353
+ log.info('heartbeat-wake', `Wake fired for session ${sessionId}`, {
354
+ reasons: wake.events.map((event: WakeEvent) => event.reason),
355
+ retryCount: wake.retryCount,
356
+ })
100
357
  } catch (err: unknown) {
358
+ queuePendingWakeRequest({
359
+ ...wake,
360
+ retryCount: wake.retryCount + 1,
361
+ })
362
+ delayedForRetry = true
101
363
  log.warn('heartbeat-wake', `Wake failed: ${err instanceof Error ? err.message : String(err)}`)
102
364
  }
103
365
  }
366
+
367
+ if (delayedForRetry && state.pending.size > 0) {
368
+ scheduleFlush(RETRY_MS, 'retry')
369
+ }
104
370
  }
105
371
 
106
372
  /** Queue a heartbeat wake. Multiple rapid calls are coalesced into a single flush. */
107
- export function requestHeartbeatNow(opts: WakeRequest): void {
108
- const key = opts.agentId || opts.sessionId || 'unknown'
109
- state.pending.set(key, opts)
373
+ export function requestHeartbeatNow(opts: WakeRequestInput): void {
374
+ queuePendingWake(opts)
375
+ scheduleFlush(COALESCE_MS, 'normal')
376
+ }
110
377
 
111
- if (!state.timer) {
112
- state.timer = setTimeout(flushWakes, COALESCE_MS)
113
- }
378
+ export function resetHeartbeatWakeStateForTests(): void {
379
+ if (state.timer) clearTimeout(state.timer)
380
+ state.timer = null
381
+ state.timerDueAt = null
382
+ state.timerKind = null
383
+ state.pending.clear()
384
+ }
385
+
386
+ export function hasPendingHeartbeatWake(): boolean {
387
+ return state.pending.size > 0 || Boolean(state.timer)
388
+ }
389
+
390
+ export function snapshotPendingHeartbeatWakesForTests(): WakeRequest[] {
391
+ return [...state.pending.values()].map((wake) => ({
392
+ ...wake,
393
+ events: wake.events.map((event: WakeEvent) => ({ ...event })),
394
+ }))
114
395
  }