@swarmclawai/swarmclaw 0.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 (319) hide show
  1. package/README.md +577 -0
  2. package/bin/server-cmd.js +359 -0
  3. package/bin/swarmclaw.js +29 -0
  4. package/bin/swarmclaw.mjs +1504 -0
  5. package/next.config.ts +33 -0
  6. package/package.json +112 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/branding/swarmclaw-org-avatar.png +0 -0
  9. package/public/branding/swarmclaw-org-avatar.svg +58 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/screenshots/agents.png +0 -0
  14. package/public/screenshots/connectors.png +0 -0
  15. package/public/screenshots/dashboard.png +0 -0
  16. package/public/screenshots/new-session-openclaw.png +0 -0
  17. package/public/screenshots/providers.png +0 -0
  18. package/public/screenshots/schedules.png +0 -0
  19. package/public/screenshots/tasks.png +0 -0
  20. package/public/vercel.svg +1 -0
  21. package/public/window.svg +1 -0
  22. package/src/app/api/agents/[id]/route.ts +30 -0
  23. package/src/app/api/agents/[id]/thread/route.ts +66 -0
  24. package/src/app/api/agents/generate/route.ts +42 -0
  25. package/src/app/api/agents/route.ts +33 -0
  26. package/src/app/api/auth/route.ts +25 -0
  27. package/src/app/api/claude-skills/route.ts +42 -0
  28. package/src/app/api/clawhub/install/route.ts +39 -0
  29. package/src/app/api/clawhub/search/route.ts +11 -0
  30. package/src/app/api/connectors/[id]/route.ts +79 -0
  31. package/src/app/api/connectors/route.ts +60 -0
  32. package/src/app/api/credentials/[id]/route.ts +14 -0
  33. package/src/app/api/credentials/route.ts +31 -0
  34. package/src/app/api/daemon/health-check/route.ts +11 -0
  35. package/src/app/api/daemon/route.ts +22 -0
  36. package/src/app/api/dirs/pick/route.ts +60 -0
  37. package/src/app/api/dirs/route.ts +29 -0
  38. package/src/app/api/documents/[id]/route.ts +47 -0
  39. package/src/app/api/documents/route.ts +93 -0
  40. package/src/app/api/files/serve/route.ts +69 -0
  41. package/src/app/api/generate/info/route.ts +12 -0
  42. package/src/app/api/generate/route.ts +106 -0
  43. package/src/app/api/ip/route.ts +6 -0
  44. package/src/app/api/knowledge/[id]/route.ts +61 -0
  45. package/src/app/api/knowledge/route.ts +48 -0
  46. package/src/app/api/knowledge/upload/route.ts +86 -0
  47. package/src/app/api/logs/route.ts +65 -0
  48. package/src/app/api/mcp-servers/[id]/route.ts +32 -0
  49. package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
  50. package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
  51. package/src/app/api/mcp-servers/route.ts +27 -0
  52. package/src/app/api/memory/[id]/route.ts +126 -0
  53. package/src/app/api/memory/maintenance/route.ts +63 -0
  54. package/src/app/api/memory/route.ts +111 -0
  55. package/src/app/api/memory-images/[filename]/route.ts +36 -0
  56. package/src/app/api/orchestrator/run/route.ts +43 -0
  57. package/src/app/api/plugins/install/route.ts +58 -0
  58. package/src/app/api/plugins/marketplace/route.ts +33 -0
  59. package/src/app/api/plugins/route.ts +21 -0
  60. package/src/app/api/preview-server/route.ts +339 -0
  61. package/src/app/api/providers/[id]/models/route.ts +29 -0
  62. package/src/app/api/providers/[id]/route.ts +34 -0
  63. package/src/app/api/providers/configs/route.ts +7 -0
  64. package/src/app/api/providers/ollama/route.ts +30 -0
  65. package/src/app/api/providers/openclaw/health/route.ts +23 -0
  66. package/src/app/api/providers/route.ts +28 -0
  67. package/src/app/api/runs/[id]/route.ts +9 -0
  68. package/src/app/api/runs/route.ts +13 -0
  69. package/src/app/api/schedules/[id]/route.ts +28 -0
  70. package/src/app/api/schedules/[id]/run/route.ts +104 -0
  71. package/src/app/api/schedules/route.ts +78 -0
  72. package/src/app/api/secrets/[id]/route.ts +29 -0
  73. package/src/app/api/secrets/route.ts +42 -0
  74. package/src/app/api/sessions/[id]/browser/route.ts +13 -0
  75. package/src/app/api/sessions/[id]/chat/route.ts +96 -0
  76. package/src/app/api/sessions/[id]/clear/route.ts +19 -0
  77. package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
  78. package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
  79. package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
  80. package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
  81. package/src/app/api/sessions/[id]/messages/route.ts +9 -0
  82. package/src/app/api/sessions/[id]/retry/route.ts +28 -0
  83. package/src/app/api/sessions/[id]/route.ts +103 -0
  84. package/src/app/api/sessions/[id]/stop/route.ts +13 -0
  85. package/src/app/api/sessions/heartbeat/route.ts +26 -0
  86. package/src/app/api/sessions/route.ts +85 -0
  87. package/src/app/api/settings/route.ts +58 -0
  88. package/src/app/api/setup/check-provider/route.ts +326 -0
  89. package/src/app/api/setup/doctor/route.ts +250 -0
  90. package/src/app/api/skills/[id]/route.ts +40 -0
  91. package/src/app/api/skills/import/route.ts +69 -0
  92. package/src/app/api/skills/route.ts +28 -0
  93. package/src/app/api/tasks/[id]/route.ts +102 -0
  94. package/src/app/api/tasks/route.ts +115 -0
  95. package/src/app/api/tts/route.ts +40 -0
  96. package/src/app/api/upload/route.ts +18 -0
  97. package/src/app/api/uploads/[filename]/route.ts +59 -0
  98. package/src/app/api/usage/route.ts +35 -0
  99. package/src/app/api/version/route.ts +81 -0
  100. package/src/app/api/version/update/route.ts +95 -0
  101. package/src/app/api/webhooks/[id]/history/route.ts +13 -0
  102. package/src/app/api/webhooks/[id]/route.ts +204 -0
  103. package/src/app/api/webhooks/route.ts +37 -0
  104. package/src/app/favicon.ico +0 -0
  105. package/src/app/globals.css +370 -0
  106. package/src/app/layout.tsx +52 -0
  107. package/src/app/page.tsx +172 -0
  108. package/src/cli/index.js +1232 -0
  109. package/src/cli/index.test.js +281 -0
  110. package/src/cli/index.ts +1158 -0
  111. package/src/cli/spec.js +284 -0
  112. package/src/components/agents/agent-card.tsx +219 -0
  113. package/src/components/agents/agent-chat-list.tsx +165 -0
  114. package/src/components/agents/agent-list.tsx +110 -0
  115. package/src/components/agents/agent-sheet.tsx +1220 -0
  116. package/src/components/auth/access-key-gate.tsx +248 -0
  117. package/src/components/auth/setup-wizard.tsx +940 -0
  118. package/src/components/auth/user-picker.tsx +88 -0
  119. package/src/components/chat/chat-area.tsx +406 -0
  120. package/src/components/chat/chat-header.tsx +491 -0
  121. package/src/components/chat/chat-tool-toggles.tsx +161 -0
  122. package/src/components/chat/code-block.tsx +146 -0
  123. package/src/components/chat/dev-server-bar.tsx +39 -0
  124. package/src/components/chat/message-bubble.tsx +486 -0
  125. package/src/components/chat/message-list.tsx +299 -0
  126. package/src/components/chat/session-debug-panel.tsx +196 -0
  127. package/src/components/chat/streaming-bubble.tsx +85 -0
  128. package/src/components/chat/thinking-indicator.tsx +26 -0
  129. package/src/components/chat/tool-call-bubble.tsx +438 -0
  130. package/src/components/chat/tool-request-banner.tsx +103 -0
  131. package/src/components/connectors/connector-list.tsx +196 -0
  132. package/src/components/connectors/connector-sheet.tsx +804 -0
  133. package/src/components/input/chat-input.tsx +235 -0
  134. package/src/components/knowledge/knowledge-list.tsx +206 -0
  135. package/src/components/knowledge/knowledge-sheet.tsx +316 -0
  136. package/src/components/layout/app-layout.tsx +1016 -0
  137. package/src/components/layout/daemon-indicator.tsx +56 -0
  138. package/src/components/layout/mobile-header.tsx +31 -0
  139. package/src/components/layout/network-banner.tsx +17 -0
  140. package/src/components/layout/update-banner.tsx +130 -0
  141. package/src/components/logs/log-list.tsx +358 -0
  142. package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
  143. package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
  144. package/src/components/memory/memory-card.tsx +63 -0
  145. package/src/components/memory/memory-detail.tsx +339 -0
  146. package/src/components/memory/memory-list.tsx +198 -0
  147. package/src/components/memory/memory-sheet.tsx +70 -0
  148. package/src/components/plugins/plugin-list.tsx +60 -0
  149. package/src/components/plugins/plugin-sheet.tsx +311 -0
  150. package/src/components/providers/provider-list.tsx +96 -0
  151. package/src/components/providers/provider-sheet.tsx +542 -0
  152. package/src/components/runs/run-list.tsx +231 -0
  153. package/src/components/schedules/schedule-card.tsx +63 -0
  154. package/src/components/schedules/schedule-list.tsx +76 -0
  155. package/src/components/schedules/schedule-sheet.tsx +336 -0
  156. package/src/components/secrets/secret-sheet.tsx +180 -0
  157. package/src/components/secrets/secrets-list.tsx +91 -0
  158. package/src/components/sessions/new-session-sheet.tsx +478 -0
  159. package/src/components/sessions/session-card.tsx +144 -0
  160. package/src/components/sessions/session-list.tsx +202 -0
  161. package/src/components/shared/ai-gen-block.tsx +77 -0
  162. package/src/components/shared/avatar.tsx +48 -0
  163. package/src/components/shared/bottom-sheet.tsx +30 -0
  164. package/src/components/shared/confirm-dialog.tsx +47 -0
  165. package/src/components/shared/connector-platform-icon.tsx +113 -0
  166. package/src/components/shared/dir-browser.tsx +285 -0
  167. package/src/components/shared/dropdown.tsx +55 -0
  168. package/src/components/shared/icon-button.tsx +25 -0
  169. package/src/components/shared/settings/plugin-manager.tsx +207 -0
  170. package/src/components/shared/settings/section-capability-policy.tsx +93 -0
  171. package/src/components/shared/settings/section-embedding.tsx +99 -0
  172. package/src/components/shared/settings/section-heartbeat.tsx +168 -0
  173. package/src/components/shared/settings/section-memory.tsx +77 -0
  174. package/src/components/shared/settings/section-orchestrator.tsx +108 -0
  175. package/src/components/shared/settings/section-providers.tsx +181 -0
  176. package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
  177. package/src/components/shared/settings/section-secrets.tsx +132 -0
  178. package/src/components/shared/settings/section-user-preferences.tsx +24 -0
  179. package/src/components/shared/settings/section-voice.tsx +53 -0
  180. package/src/components/shared/settings/settings-sheet.tsx +88 -0
  181. package/src/components/shared/settings/types.ts +7 -0
  182. package/src/components/shared/settings/utils.ts +13 -0
  183. package/src/components/shared/settings-sheet.tsx +1 -0
  184. package/src/components/shared/skeleton.tsx +19 -0
  185. package/src/components/shared/usage-badge.tsx +28 -0
  186. package/src/components/skills/clawhub-browser.tsx +225 -0
  187. package/src/components/skills/skill-list.tsx +70 -0
  188. package/src/components/skills/skill-sheet.tsx +254 -0
  189. package/src/components/tasks/task-board.tsx +96 -0
  190. package/src/components/tasks/task-card.tsx +179 -0
  191. package/src/components/tasks/task-column.tsx +73 -0
  192. package/src/components/tasks/task-list.tsx +118 -0
  193. package/src/components/tasks/task-sheet.tsx +415 -0
  194. package/src/components/ui/avatar.tsx +109 -0
  195. package/src/components/ui/badge.tsx +48 -0
  196. package/src/components/ui/button.tsx +64 -0
  197. package/src/components/ui/card.tsx +92 -0
  198. package/src/components/ui/dialog.tsx +158 -0
  199. package/src/components/ui/dropdown-menu.tsx +257 -0
  200. package/src/components/ui/input.tsx +21 -0
  201. package/src/components/ui/scroll-area.tsx +58 -0
  202. package/src/components/ui/select.tsx +190 -0
  203. package/src/components/ui/separator.tsx +28 -0
  204. package/src/components/ui/sheet.tsx +143 -0
  205. package/src/components/ui/sonner.tsx +22 -0
  206. package/src/components/ui/textarea.tsx +18 -0
  207. package/src/components/ui/tooltip.tsx +56 -0
  208. package/src/components/usage/usage-list.tsx +105 -0
  209. package/src/components/webhooks/webhook-list.tsx +166 -0
  210. package/src/components/webhooks/webhook-sheet.tsx +402 -0
  211. package/src/hooks/use-auto-resize.ts +20 -0
  212. package/src/hooks/use-media-query.ts +21 -0
  213. package/src/hooks/use-speech-recognition.ts +83 -0
  214. package/src/instrumentation.ts +8 -0
  215. package/src/lib/agents.ts +13 -0
  216. package/src/lib/api-client.ts +100 -0
  217. package/src/lib/chat.ts +60 -0
  218. package/src/lib/memory.ts +42 -0
  219. package/src/lib/openclaw-endpoint.test.ts +48 -0
  220. package/src/lib/openclaw-endpoint.ts +67 -0
  221. package/src/lib/provider-config.ts +13 -0
  222. package/src/lib/providers/anthropic.ts +135 -0
  223. package/src/lib/providers/claude-cli.ts +202 -0
  224. package/src/lib/providers/codex-cli.ts +260 -0
  225. package/src/lib/providers/index.ts +351 -0
  226. package/src/lib/providers/ollama.ts +131 -0
  227. package/src/lib/providers/openai.ts +164 -0
  228. package/src/lib/providers/openclaw.ts +330 -0
  229. package/src/lib/providers/opencode-cli.ts +164 -0
  230. package/src/lib/runtime-loop.ts +15 -0
  231. package/src/lib/schedule-dedupe.test.ts +84 -0
  232. package/src/lib/schedule-dedupe.ts +174 -0
  233. package/src/lib/schedule-name.ts +62 -0
  234. package/src/lib/schedules.ts +16 -0
  235. package/src/lib/server/agent-registry.ts +70 -0
  236. package/src/lib/server/api-routes.test.ts +362 -0
  237. package/src/lib/server/autonomy-contract.ts +200 -0
  238. package/src/lib/server/build-llm.ts +155 -0
  239. package/src/lib/server/capability-router.test.ts +21 -0
  240. package/src/lib/server/capability-router.ts +172 -0
  241. package/src/lib/server/chat-execution.ts +894 -0
  242. package/src/lib/server/clawhub-client.test.ts +161 -0
  243. package/src/lib/server/clawhub-client.ts +26 -0
  244. package/src/lib/server/connectors/connector-routing.test.ts +243 -0
  245. package/src/lib/server/connectors/discord.ts +116 -0
  246. package/src/lib/server/connectors/googlechat.ts +66 -0
  247. package/src/lib/server/connectors/manager.ts +559 -0
  248. package/src/lib/server/connectors/matrix.ts +78 -0
  249. package/src/lib/server/connectors/media.ts +149 -0
  250. package/src/lib/server/connectors/openclaw.test.ts +375 -0
  251. package/src/lib/server/connectors/openclaw.ts +1132 -0
  252. package/src/lib/server/connectors/signal.ts +183 -0
  253. package/src/lib/server/connectors/slack.ts +258 -0
  254. package/src/lib/server/connectors/teams.ts +94 -0
  255. package/src/lib/server/connectors/telegram.ts +221 -0
  256. package/src/lib/server/connectors/types.ts +62 -0
  257. package/src/lib/server/connectors/whatsapp.ts +349 -0
  258. package/src/lib/server/context-manager.ts +232 -0
  259. package/src/lib/server/cost.ts +31 -0
  260. package/src/lib/server/daemon-state.ts +354 -0
  261. package/src/lib/server/data-dir.ts +3 -0
  262. package/src/lib/server/embeddings.ts +111 -0
  263. package/src/lib/server/execution-log.ts +257 -0
  264. package/src/lib/server/gateway/protocol.test.ts +54 -0
  265. package/src/lib/server/gateway/protocol.ts +114 -0
  266. package/src/lib/server/heartbeat-service.ts +366 -0
  267. package/src/lib/server/knowledge-db.test.ts +441 -0
  268. package/src/lib/server/logger.ts +47 -0
  269. package/src/lib/server/main-agent-loop.ts +1017 -0
  270. package/src/lib/server/mcp-client.test.ts +342 -0
  271. package/src/lib/server/mcp-client.ts +130 -0
  272. package/src/lib/server/memory-db.ts +1078 -0
  273. package/src/lib/server/memory-graph.test.ts +153 -0
  274. package/src/lib/server/memory-graph.ts +138 -0
  275. package/src/lib/server/openclaw-health.ts +245 -0
  276. package/src/lib/server/orchestrator-lg.ts +431 -0
  277. package/src/lib/server/orchestrator.ts +364 -0
  278. package/src/lib/server/playwright-proxy.mjs +70 -0
  279. package/src/lib/server/plugins.ts +229 -0
  280. package/src/lib/server/process-manager.ts +327 -0
  281. package/src/lib/server/provider-health.ts +113 -0
  282. package/src/lib/server/queue.ts +859 -0
  283. package/src/lib/server/runtime-settings.ts +119 -0
  284. package/src/lib/server/scheduler.ts +196 -0
  285. package/src/lib/server/session-mailbox.ts +129 -0
  286. package/src/lib/server/session-run-manager.ts +512 -0
  287. package/src/lib/server/session-tools/connector.ts +124 -0
  288. package/src/lib/server/session-tools/context-mgmt.ts +103 -0
  289. package/src/lib/server/session-tools/context.ts +114 -0
  290. package/src/lib/server/session-tools/crud.ts +673 -0
  291. package/src/lib/server/session-tools/delegate.ts +708 -0
  292. package/src/lib/server/session-tools/file.ts +264 -0
  293. package/src/lib/server/session-tools/index.ts +164 -0
  294. package/src/lib/server/session-tools/memory.ts +230 -0
  295. package/src/lib/server/session-tools/session-info.ts +422 -0
  296. package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
  297. package/src/lib/server/session-tools/shell.ts +171 -0
  298. package/src/lib/server/session-tools/web.ts +408 -0
  299. package/src/lib/server/session-tools.ts +9 -0
  300. package/src/lib/server/skills-normalize.ts +130 -0
  301. package/src/lib/server/storage-mcp.test.ts +161 -0
  302. package/src/lib/server/storage.ts +670 -0
  303. package/src/lib/server/stream-agent-chat.ts +571 -0
  304. package/src/lib/server/task-reports.ts +122 -0
  305. package/src/lib/server/task-result.ts +161 -0
  306. package/src/lib/server/task-validation.test.ts +27 -0
  307. package/src/lib/server/task-validation.ts +90 -0
  308. package/src/lib/server/tool-capability-policy.test.ts +58 -0
  309. package/src/lib/server/tool-capability-policy.ts +262 -0
  310. package/src/lib/sessions.ts +68 -0
  311. package/src/lib/tasks.ts +20 -0
  312. package/src/lib/tts.ts +42 -0
  313. package/src/lib/upload.ts +10 -0
  314. package/src/lib/utils.ts +6 -0
  315. package/src/proxy.ts +43 -0
  316. package/src/stores/use-app-store.ts +468 -0
  317. package/src/stores/use-chat-store.ts +323 -0
  318. package/src/types/index.ts +621 -0
  319. package/tsconfig.json +34 -0
@@ -0,0 +1,366 @@
1
+ import { loadAgents, loadSessions, loadSettings } from './storage'
2
+ import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
3
+ import { log } from './logger'
4
+ import { buildMainLoopHeartbeatPrompt, getMainLoopStateForSession, isMainSession } from './main-agent-loop'
5
+
6
+ const HEARTBEAT_TICK_MS = 5_000
7
+
8
+ interface HeartbeatState {
9
+ timer: ReturnType<typeof setInterval> | null
10
+ running: boolean
11
+ lastBySession: Map<string, number>
12
+ }
13
+
14
+ const globalKey = '__swarmclaw_heartbeat_service__' as const
15
+ const globalScope = globalThis as typeof globalThis & { [globalKey]?: HeartbeatState }
16
+ const state: HeartbeatState = globalScope[globalKey] ?? (globalScope[globalKey] = {
17
+ timer: null,
18
+ running: false,
19
+ lastBySession: new Map<string, number>(),
20
+ })
21
+
22
+ function parseIntBounded(value: unknown, fallback: number, min: number, max: number): number {
23
+ const parsed = typeof value === 'number'
24
+ ? value
25
+ : typeof value === 'string'
26
+ ? Number.parseInt(value, 10)
27
+ : Number.NaN
28
+ if (!Number.isFinite(parsed)) return fallback
29
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
30
+ }
31
+
32
+ /**
33
+ * Parse a duration value into seconds.
34
+ * Accepts: "30m", "1h", "2h30m", "45s", "1800", 1800, null/undefined.
35
+ * Returns integer seconds clamped to [0, 86400].
36
+ */
37
+ function parseDuration(value: unknown, fallbackSec: number): number {
38
+ if (value === null || value === undefined) return fallbackSec
39
+ if (typeof value === 'number') {
40
+ if (!Number.isFinite(value)) return fallbackSec
41
+ return Math.max(0, Math.min(86400, Math.trunc(value)))
42
+ }
43
+ if (typeof value !== 'string') return fallbackSec
44
+ const trimmed = value.trim().toLowerCase()
45
+ if (!trimmed) return fallbackSec
46
+ // Plain numeric string — treat as seconds (backward compat)
47
+ const asNum = Number(trimmed)
48
+ if (Number.isFinite(asNum)) {
49
+ return Math.max(0, Math.min(86400, Math.trunc(asNum)))
50
+ }
51
+ const m = trimmed.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
52
+ if (!m || (!m[1] && !m[2] && !m[3])) return fallbackSec
53
+ const hours = m[1] ? Number.parseInt(m[1], 10) : 0
54
+ const minutes = m[2] ? Number.parseInt(m[2], 10) : 0
55
+ const seconds = m[3] ? Number.parseInt(m[3], 10) : 0
56
+ const total = hours * 3600 + minutes * 60 + seconds
57
+ return Math.max(0, Math.min(86400, total))
58
+ }
59
+
60
+ function parseTimeHHMM(raw: unknown): { h: number; m: number } | null {
61
+ if (typeof raw !== 'string') return null
62
+ const val = raw.trim()
63
+ if (!val) return null
64
+ const m = val.match(/^(\d{1,2}):(\d{2})$/)
65
+ if (!m) return null
66
+ const h = Number.parseInt(m[1], 10)
67
+ const mm = Number.parseInt(m[2], 10)
68
+ if (!Number.isFinite(h) || !Number.isFinite(mm)) return null
69
+ if (h < 0 || h > 24 || mm < 0 || mm > 59) return null
70
+ if (h === 24 && mm !== 0) return null
71
+ return { h, m: mm }
72
+ }
73
+
74
+ function getMinutesInTimezone(date: Date, timezone?: string | null): number | null {
75
+ try {
76
+ const formatter = new Intl.DateTimeFormat('en-US', {
77
+ hour: '2-digit',
78
+ minute: '2-digit',
79
+ hour12: false,
80
+ timeZone: timezone || undefined,
81
+ })
82
+ const parts = formatter.formatToParts(date)
83
+ const hh = Number.parseInt(parts.find((p) => p.type === 'hour')?.value || '', 10)
84
+ const mm = Number.parseInt(parts.find((p) => p.type === 'minute')?.value || '', 10)
85
+ if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null
86
+ return hh * 60 + mm
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ function inActiveWindow(nowDate: Date, startRaw: unknown, endRaw: unknown, tzRaw: unknown): boolean {
93
+ const start = parseTimeHHMM(startRaw)
94
+ const end = parseTimeHHMM(endRaw)
95
+ if (!start || !end) return true
96
+
97
+ const tz = typeof tzRaw === 'string' && tzRaw.trim() ? tzRaw.trim() : undefined
98
+ const current = getMinutesInTimezone(nowDate, tz)
99
+ if (current == null) return true
100
+
101
+ const startM = start.h * 60 + start.m
102
+ const endM = end.h * 60 + end.m
103
+ if (startM === endM) return true
104
+ if (startM < endM) return current >= startM && current < endM
105
+ return current >= startM || current < endM
106
+ }
107
+
108
+ export interface HeartbeatConfig {
109
+ intervalSec: number
110
+ prompt: string
111
+ enabled: boolean
112
+ model: string | null
113
+ ackMaxChars: number
114
+ showOk: boolean
115
+ showAlerts: boolean
116
+ target: string | null
117
+ }
118
+
119
+ 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
+
121
+ function resolveInterval(obj: Record<string, any>, currentSec: number): number {
122
+ // Prefer heartbeatInterval (duration string) over heartbeatIntervalSec (raw number)
123
+ if (obj.heartbeatInterval !== undefined && obj.heartbeatInterval !== null) {
124
+ return parseDuration(obj.heartbeatInterval, currentSec)
125
+ }
126
+ if (obj.heartbeatIntervalSec !== undefined && obj.heartbeatIntervalSec !== null) {
127
+ return parseIntBounded(obj.heartbeatIntervalSec, currentSec, 0, 86400)
128
+ }
129
+ return currentSec
130
+ }
131
+
132
+ function resolveStr(obj: Record<string, any>, key: string, current: string | null): string | null {
133
+ const val = obj[key]
134
+ if (typeof val === 'string' && val.trim()) return val.trim()
135
+ return current
136
+ }
137
+
138
+ function resolveBool(obj: Record<string, any>, key: string, current: boolean): boolean {
139
+ if (obj[key] === true) return true
140
+ if (obj[key] === false) return false
141
+ return current
142
+ }
143
+
144
+ function resolveNum(obj: Record<string, any>, key: string, current: number): number {
145
+ const val = obj[key]
146
+ if (typeof val === 'number' && Number.isFinite(val)) return Math.trunc(val)
147
+ return current
148
+ }
149
+
150
+ function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
151
+ // Global defaults — 30 min interval (was 120s)
152
+ let intervalSec = resolveInterval(settings, 1800)
153
+ const globalPrompt = (typeof settings.heartbeatPrompt === 'string' && settings.heartbeatPrompt.trim())
154
+ ? settings.heartbeatPrompt.trim()
155
+ : DEFAULT_HEARTBEAT_PROMPT
156
+
157
+ let enabled = intervalSec > 0
158
+ let prompt = globalPrompt
159
+ let model: string | null = resolveStr(settings, 'heartbeatModel', null)
160
+ let ackMaxChars = resolveNum(settings, 'heartbeatAckMaxChars', 300)
161
+ let showOk = resolveBool(settings, 'heartbeatShowOk', false)
162
+ let showAlerts = resolveBool(settings, 'heartbeatShowAlerts', true)
163
+ let target: string | null = resolveStr(settings, 'heartbeatTarget', null)
164
+
165
+ // Agent layer overrides
166
+ if (session.agentId) {
167
+ const agent = agents[session.agentId]
168
+ if (agent) {
169
+ if (agent.heartbeatEnabled === false) enabled = false
170
+ if (agent.heartbeatEnabled === true) enabled = true
171
+ intervalSec = resolveInterval(agent, intervalSec)
172
+ if (typeof agent.heartbeatPrompt === 'string' && agent.heartbeatPrompt.trim()) {
173
+ prompt = agent.heartbeatPrompt.trim()
174
+ }
175
+ model = resolveStr(agent, 'heartbeatModel', model)
176
+ ackMaxChars = resolveNum(agent, 'heartbeatAckMaxChars', ackMaxChars)
177
+ showOk = resolveBool(agent, 'heartbeatShowOk', showOk)
178
+ showAlerts = resolveBool(agent, 'heartbeatShowAlerts', showAlerts)
179
+ target = resolveStr(agent, 'heartbeatTarget', target)
180
+ }
181
+ }
182
+
183
+ // Session layer overrides
184
+ if (session.heartbeatEnabled === false) enabled = false
185
+ if (session.heartbeatEnabled === true) enabled = true
186
+ intervalSec = resolveInterval(session, intervalSec)
187
+ if (typeof session.heartbeatPrompt === 'string' && session.heartbeatPrompt.trim()) {
188
+ prompt = session.heartbeatPrompt.trim()
189
+ }
190
+ target = resolveStr(session, 'heartbeatTarget', target)
191
+
192
+ return { enabled: enabled && intervalSec > 0, intervalSec, prompt, model, ackMaxChars, showOk, showAlerts, target }
193
+ }
194
+
195
+ function lastUserMessageAt(session: any): number {
196
+ if (!Array.isArray(session?.messages)) return 0
197
+ for (let i = session.messages.length - 1; i >= 0; i--) {
198
+ const msg = session.messages[i]
199
+ if (msg?.role === 'user' && typeof msg.time === 'number' && msg.time > 0) {
200
+ return msg.time
201
+ }
202
+ }
203
+ return 0
204
+ }
205
+
206
+ function resolveHeartbeatUserIdleSec(settings: Record<string, any>, fallbackSec: number): number {
207
+ const configured = settings.heartbeatUserIdleSec
208
+ if (configured === undefined || configured === null || configured === '') {
209
+ return fallbackSec
210
+ }
211
+ return parseIntBounded(configured, fallbackSec, 0, 86_400)
212
+ }
213
+
214
+ function shouldRunHeartbeats(settings: Record<string, any>): boolean {
215
+ const loopMode = settings.loopMode === 'ongoing' ? 'ongoing' : 'bounded'
216
+ return loopMode === 'ongoing'
217
+ }
218
+
219
+ async function tickHeartbeats() {
220
+ const settings = loadSettings()
221
+ const globalOngoing = shouldRunHeartbeats(settings)
222
+
223
+ const now = Date.now()
224
+ const nowDate = new Date(now)
225
+ if (!inActiveWindow(nowDate, settings.heartbeatActiveStart, settings.heartbeatActiveEnd, settings.heartbeatTimezone)) {
226
+ return
227
+ }
228
+
229
+ const sessions = loadSessions()
230
+ const agents = loadAgents()
231
+ const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
232
+
233
+ // Prune tracked sessions that no longer exist or have heartbeat disabled
234
+ for (const trackedId of state.lastBySession.keys()) {
235
+ const s = sessions[trackedId] as any
236
+ if (!s) {
237
+ state.lastBySession.delete(trackedId)
238
+ continue
239
+ }
240
+ const cfg = heartbeatConfigForSession(s, settings, agents)
241
+ if (!cfg.enabled) {
242
+ state.lastBySession.delete(trackedId)
243
+ }
244
+ }
245
+
246
+ for (const session of Object.values(sessions) as any[]) {
247
+ if (!session?.id) continue
248
+ if (!Array.isArray(session.tools) || session.tools.length === 0) continue
249
+ if (session.sessionType && session.sessionType !== 'human' && session.sessionType !== 'orchestrated') continue
250
+
251
+ // Check if this session or its agent has explicit heartbeat opt-in
252
+ const agent = session.agentId ? agents[session.agentId] : null
253
+ const explicitOptIn = session.heartbeatEnabled === true || (agent && agent.heartbeatEnabled === true)
254
+
255
+ // If global loopMode is bounded, only allow sessions with explicit opt-in
256
+ if (!globalOngoing && !explicitOptIn) continue
257
+
258
+ if (hasScopedAgents && !explicitOptIn) {
259
+ const sessionForcedOn = session.heartbeatEnabled === true
260
+ if (!sessionForcedOn && (!agent || agent.heartbeatEnabled !== true)) continue
261
+ }
262
+
263
+ const cfg = heartbeatConfigForSession(session, settings, agents)
264
+ if (!cfg.enabled) continue
265
+
266
+ // For sessions with explicit opt-in, use a shorter idle threshold (just intervalSec * 2).
267
+ // For inherited/global heartbeats, keep the 180s minimum to avoid noisy auto-fire.
268
+ const defaultIdleSec = explicitOptIn
269
+ ? cfg.intervalSec * 2
270
+ : Math.max(cfg.intervalSec * 2, 180)
271
+ const userIdleThresholdSec = resolveHeartbeatUserIdleSec(settings, defaultIdleSec)
272
+ const lastUserAt = lastUserMessageAt(session)
273
+ if (lastUserAt <= 0) continue
274
+ const idleMs = now - lastUserAt
275
+ if (idleMs < userIdleThresholdSec * 1000) continue
276
+
277
+ if (isMainSession(session)) {
278
+ const loopState = getMainLoopStateForSession(session.id)
279
+ if (loopState?.paused) continue
280
+ const loopStatus = loopState?.status || 'idle'
281
+ const pendingEvents = loopState?.pendingEvents?.length || 0
282
+ if ((loopStatus === 'ok' || loopStatus === 'idle') && pendingEvents === 0) continue
283
+ }
284
+
285
+ const last = state.lastBySession.get(session.id) || 0
286
+ if (now - last < cfg.intervalSec * 1000) continue
287
+
288
+ const runState = getSessionRunState(session.id)
289
+ if (runState.runningRunId) continue
290
+
291
+ const heartbeatMessage = isMainSession(session)
292
+ ? buildMainLoopHeartbeatPrompt(session, cfg.prompt)
293
+ : cfg.prompt
294
+
295
+ const enqueue = enqueueSessionRun({
296
+ sessionId: session.id,
297
+ message: heartbeatMessage,
298
+ internal: true,
299
+ source: 'heartbeat',
300
+ mode: 'collect',
301
+ dedupeKey: `heartbeat:${session.id}`,
302
+ modelOverride: cfg.model || undefined,
303
+ heartbeatConfig: {
304
+ ackMaxChars: cfg.ackMaxChars,
305
+ showOk: cfg.showOk,
306
+ showAlerts: cfg.showAlerts,
307
+ target: cfg.target,
308
+ },
309
+ })
310
+
311
+ // Set timestamp AFTER successful enqueue so a busy session retries next tick
312
+ state.lastBySession.set(session.id, now)
313
+
314
+ enqueue.promise.catch((err) => {
315
+ log.warn('heartbeat', `Heartbeat run failed for session ${session.id}`, err?.message || String(err))
316
+ })
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Seed lastBySession from persisted lastActiveAt values so that a cold restart
322
+ * doesn't cause every session to fire a heartbeat immediately on the first tick.
323
+ */
324
+ function seedLastActive() {
325
+ const sessions = loadSessions()
326
+ for (const session of Object.values(sessions) as any[]) {
327
+ if (!session?.id) continue
328
+ if (typeof session.lastActiveAt === 'number' && session.lastActiveAt > 0) {
329
+ // Only seed entries we don't already have (preserves HMR state)
330
+ if (!state.lastBySession.has(session.id)) {
331
+ state.lastBySession.set(session.id, session.lastActiveAt)
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ export function startHeartbeatService() {
338
+ // Always replace the timer so HMR picks up the latest tickHeartbeats function.
339
+ // Without this, the old setInterval closure keeps running stale code.
340
+ if (state.timer) {
341
+ clearInterval(state.timer)
342
+ state.timer = null
343
+ }
344
+ state.running = true
345
+ seedLastActive()
346
+ state.timer = setInterval(() => {
347
+ tickHeartbeats().catch((err) => {
348
+ log.error('heartbeat', 'Heartbeat tick failed', err?.message || String(err))
349
+ })
350
+ }, HEARTBEAT_TICK_MS)
351
+ }
352
+
353
+ export function stopHeartbeatService() {
354
+ state.running = false
355
+ if (state.timer) {
356
+ clearInterval(state.timer)
357
+ state.timer = null
358
+ }
359
+ }
360
+
361
+ export function getHeartbeatServiceStatus() {
362
+ return {
363
+ running: state.running,
364
+ trackedSessions: state.lastBySession.size,
365
+ }
366
+ }