@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,894 @@
1
+ import fs from 'fs'
2
+ import {
3
+ loadSessions,
4
+ saveSessions,
5
+ loadCredentials,
6
+ decryptKey,
7
+ getSessionMessages,
8
+ loadAgents,
9
+ loadSkills,
10
+ loadSettings,
11
+ loadUsage,
12
+ active,
13
+ } from './storage'
14
+ import { getProvider } from '@/lib/providers'
15
+ import { log } from './logger'
16
+ import { logExecution } from './execution-log'
17
+ import { streamAgentChat } from './stream-agent-chat'
18
+ import { buildSessionTools } from './session-tools'
19
+ import { stripMainLoopMetaForPersistence } from './main-agent-loop'
20
+ import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
21
+ import { getMemoryDb } from './memory-db'
22
+ import { routeTaskIntent } from './capability-router'
23
+ import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
24
+ import type { MessageToolEvent, SSEEvent } from '@/types'
25
+ import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
26
+
27
+ const CLI_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
28
+ type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
29
+
30
+ interface SessionWithTools {
31
+ tools?: string[] | null
32
+ }
33
+
34
+ interface SessionWithCredentials {
35
+ credentialId?: string | null
36
+ }
37
+
38
+ interface ProviderApiKeyConfig {
39
+ requiresApiKey?: boolean
40
+ optionalApiKey?: boolean
41
+ }
42
+
43
+ export interface ExecuteChatTurnInput {
44
+ sessionId: string
45
+ message: string
46
+ imagePath?: string
47
+ imageUrl?: string
48
+ internal?: boolean
49
+ source?: string
50
+ runId?: string
51
+ signal?: AbortSignal
52
+ onEvent?: (event: SSEEvent) => void
53
+ modelOverride?: string
54
+ heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null }
55
+ }
56
+
57
+ export interface ExecuteChatTurnResult {
58
+ runId?: string
59
+ sessionId: string
60
+ text: string
61
+ persisted: boolean
62
+ toolEvents: MessageToolEvent[]
63
+ error?: string
64
+ }
65
+
66
+ function extractEventJson(line: string): SSEEvent | null {
67
+ if (!line.startsWith('data: ')) return null
68
+ try {
69
+ return JSON.parse(line.slice(6).trim()) as SSEEvent
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
76
+ if (ev.t === 'tool_call') {
77
+ bag.push({
78
+ name: ev.toolName || 'unknown',
79
+ input: ev.toolInput || '',
80
+ })
81
+ return
82
+ }
83
+ if (ev.t === 'tool_result') {
84
+ const idx = bag.findLastIndex((e) => e.name === (ev.toolName || 'unknown') && !e.output)
85
+ if (idx === -1) return
86
+ const output = ev.toolOutput || ''
87
+ const isError = /^(Error:|error:)/i.test(output.trim())
88
+ || output.includes('ECONNREFUSED')
89
+ || output.includes('ETIMEDOUT')
90
+ || output.includes('Error:')
91
+ bag[idx] = {
92
+ ...bag[idx],
93
+ output,
94
+ error: isError || undefined,
95
+ }
96
+ }
97
+ }
98
+
99
+ function requestedToolNamesFromMessage(message: string): string[] {
100
+ const lower = message.toLowerCase()
101
+ const candidates = [
102
+ 'delegate_to_claude_code',
103
+ 'delegate_to_codex_cli',
104
+ 'delegate_to_opencode_cli',
105
+ 'connector_message_tool',
106
+ 'sessions_tool',
107
+ 'whoami_tool',
108
+ 'search_history_tool',
109
+ 'manage_agents',
110
+ 'manage_tasks',
111
+ 'manage_schedules',
112
+ 'manage_documents',
113
+ 'manage_webhooks',
114
+ 'manage_skills',
115
+ 'manage_connectors',
116
+ 'manage_sessions',
117
+ 'manage_secrets',
118
+ 'memory_tool',
119
+ 'browser',
120
+ 'web_search',
121
+ 'web_fetch',
122
+ 'execute_command',
123
+ 'read_file',
124
+ 'write_file',
125
+ 'list_files',
126
+ 'copy_file',
127
+ 'move_file',
128
+ 'delete_file',
129
+ 'edit_file',
130
+ 'send_file',
131
+ 'process_tool',
132
+ ]
133
+ return candidates.filter((name) => lower.includes(name.toLowerCase()))
134
+ }
135
+
136
+ function parseKeyValueArgs(raw: string): Record<string, string> {
137
+ const out: Record<string, string> = {}
138
+ const regex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s,]+)/g
139
+ let match: RegExpExecArray | null = null
140
+ while ((match = regex.exec(raw)) !== null) {
141
+ const key = match[1]
142
+ const value = match[3] ?? match[4] ?? match[2] ?? ''
143
+ out[key] = value.replace(/^['"]|['"]$/g, '').trim()
144
+ }
145
+ return out
146
+ }
147
+
148
+ function extractConnectorMessageArgs(message: string): {
149
+ action: 'list_running' | 'list_targets' | 'send'
150
+ platform?: string
151
+ connectorId?: string
152
+ to?: string
153
+ message?: string
154
+ imageUrl?: string
155
+ fileUrl?: string
156
+ mediaPath?: string
157
+ mimeType?: string
158
+ fileName?: string
159
+ caption?: string
160
+ } | null {
161
+ if (!message.toLowerCase().includes('connector_message_tool')) return null
162
+ const parsed = parseKeyValueArgs(message)
163
+
164
+ let payload = parsed.message
165
+ if (!payload) {
166
+ const quoted = message.match(/message\s*=\s*("(.*?)"|'(.*?)')/i)
167
+ if (quoted) payload = (quoted[2] || quoted[3] || '').trim()
168
+ }
169
+ if (!payload) {
170
+ const raw = message.match(/message\s*=\s*([^\n]+)/i)
171
+ if (raw?.[1]) {
172
+ payload = raw[1]
173
+ .replace(/\b(Return|Output|Then|Respond)\b[\s\S]*$/i, '')
174
+ .trim()
175
+ .replace(/^['"]|['"]$/g, '')
176
+ }
177
+ }
178
+
179
+ const actionRaw = (parsed.action || 'send').toLowerCase()
180
+ const action = actionRaw === 'list_running' || actionRaw === 'list_targets' || actionRaw === 'send'
181
+ ? actionRaw
182
+ : 'send'
183
+ const args: {
184
+ action: 'list_running' | 'list_targets' | 'send'
185
+ platform?: string
186
+ connectorId?: string
187
+ to?: string
188
+ message?: string
189
+ imageUrl?: string
190
+ fileUrl?: string
191
+ mediaPath?: string
192
+ mimeType?: string
193
+ fileName?: string
194
+ caption?: string
195
+ } = { action }
196
+ const quoted = (key: string): string | undefined => {
197
+ const m = message.match(new RegExp(`${key}\\s*=\\s*(\"([^\"]*)\"|'([^']*)')`, 'i'))
198
+ return (m?.[2] || m?.[3] || '').trim() || undefined
199
+ }
200
+ if (parsed.platform) args.platform = parsed.platform
201
+ if (parsed.connectorId) args.connectorId = parsed.connectorId
202
+ if (parsed.to) args.to = parsed.to
203
+ if (payload) args.message = payload
204
+ args.imageUrl = parsed.imageUrl || quoted('imageUrl')
205
+ args.fileUrl = parsed.fileUrl || quoted('fileUrl')
206
+ args.mediaPath = parsed.mediaPath || quoted('mediaPath')
207
+ args.mimeType = parsed.mimeType || quoted('mimeType')
208
+ args.fileName = parsed.fileName || quoted('fileName')
209
+ args.caption = parsed.caption || quoted('caption')
210
+ return args
211
+ }
212
+
213
+ function extractDelegationTask(message: string, toolName: string): string | null {
214
+ if (!message.toLowerCase().includes(toolName.toLowerCase())) return null
215
+ const patterns = [
216
+ /task\s+exactly\s*:\s*"([^"]+)"/i,
217
+ /task\s+exactly\s*:\s*'([^']+)'/i,
218
+ /task\s+exactly\s*:\s*([^\n]+?)(?:\.\s|$)/i,
219
+ /task\s*:\s*"([^"]+)"/i,
220
+ /task\s*:\s*'([^']+)'/i,
221
+ /task\s*:\s*([^\n]+?)(?:\.\s|$)/i,
222
+ ]
223
+ for (const re of patterns) {
224
+ const m = message.match(re)
225
+ const task = (m?.[1] || '').trim()
226
+ if (task) return task
227
+ }
228
+ return null
229
+ }
230
+
231
+ function hasToolEnabled(session: SessionWithTools, toolName: string): boolean {
232
+ return Array.isArray(session?.tools) && session.tools.includes(toolName)
233
+ }
234
+
235
+ function enabledDelegationTools(session: SessionWithTools): DelegateTool[] {
236
+ const tools: DelegateTool[] = []
237
+ if (hasToolEnabled(session, 'claude_code')) tools.push('delegate_to_claude_code')
238
+ if (hasToolEnabled(session, 'codex_cli')) tools.push('delegate_to_codex_cli')
239
+ if (hasToolEnabled(session, 'opencode_cli')) tools.push('delegate_to_opencode_cli')
240
+ return tools
241
+ }
242
+
243
+ function parseUsdLimit(value: unknown): number | null {
244
+ const parsed = typeof value === 'number'
245
+ ? value
246
+ : typeof value === 'string'
247
+ ? Number.parseFloat(value)
248
+ : Number.NaN
249
+ if (!Number.isFinite(parsed) || parsed <= 0) return null
250
+ return Math.max(0.01, Math.min(1_000_000, parsed))
251
+ }
252
+
253
+ function getTodaySpendUsd(): number {
254
+ const usage = loadUsage()
255
+ const dayStart = new Date()
256
+ dayStart.setHours(0, 0, 0, 0)
257
+ const minTs = dayStart.getTime()
258
+ let total = 0
259
+ for (const records of Object.values(usage)) {
260
+ for (const record of records || []) {
261
+ const ts = typeof (record as any)?.timestamp === 'number' ? (record as any).timestamp : 0
262
+ if (ts < minTs) continue
263
+ const cost = typeof (record as any)?.estimatedCost === 'number' ? (record as any).estimatedCost : 0
264
+ if (Number.isFinite(cost) && cost > 0) total += cost
265
+ }
266
+ }
267
+ return total
268
+ }
269
+
270
+ function findFirstUrl(text: string): string | null {
271
+ const m = text.match(/https?:\/\/[^\s<>"')]+/i)
272
+ return m?.[0] || null
273
+ }
274
+
275
+ function syncSessionFromAgent(sessionId: string): void {
276
+ const sessions = loadSessions()
277
+ const session = sessions[sessionId]
278
+ if (!session?.agentId) return
279
+ const agents = loadAgents()
280
+ const agent = agents[session.agentId]
281
+ if (!agent) return
282
+
283
+ let changed = false
284
+ if (agent.provider && agent.provider !== session.provider) { session.provider = agent.provider; changed = true }
285
+ if (agent.model !== undefined && agent.model !== session.model) { session.model = agent.model; changed = true }
286
+ if (agent.credentialId !== undefined && agent.credentialId !== session.credentialId) { session.credentialId = agent.credentialId ?? null; changed = true }
287
+ if (agent.apiEndpoint !== undefined) {
288
+ const normalized = normalizeProviderEndpoint(agent.provider, agent.apiEndpoint ?? null)
289
+ if (normalized !== session.apiEndpoint) { session.apiEndpoint = normalized; changed = true }
290
+ }
291
+ if (!Array.isArray(session.tools)) {
292
+ session.tools = Array.isArray(agent.tools) ? [...agent.tools] : []
293
+ changed = true
294
+ }
295
+
296
+ if (changed) {
297
+ sessions[sessionId] = session
298
+ saveSessions(sessions)
299
+ }
300
+ }
301
+
302
+ function buildAgentSystemPrompt(session: any): string | undefined {
303
+ if (!session.agentId) return undefined
304
+ const agents = loadAgents()
305
+ const agent = agents[session.agentId]
306
+ if (!agent?.systemPrompt && !agent?.soul) return undefined
307
+
308
+ const settings = loadSettings()
309
+ const parts: string[] = []
310
+ if (settings.userPrompt) parts.push(settings.userPrompt)
311
+ if (agent.soul) parts.push(agent.soul)
312
+ if (agent.systemPrompt) parts.push(agent.systemPrompt)
313
+ if (agent.skillIds?.length) {
314
+ const allSkills = loadSkills()
315
+ for (const skillId of agent.skillIds) {
316
+ const skill = allSkills[skillId]
317
+ if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
318
+ }
319
+ }
320
+ return parts.join('\n\n')
321
+ }
322
+
323
+ function resolveApiKeyForSession(session: SessionWithCredentials, provider: ProviderApiKeyConfig): string | null {
324
+ if (provider.requiresApiKey) {
325
+ if (!session.credentialId) throw new Error('No API key configured for this session')
326
+ const creds = loadCredentials()
327
+ const cred = creds[session.credentialId]
328
+ if (!cred) throw new Error('API key not found. Please add one in Settings.')
329
+ return decryptKey(cred.encryptedKey)
330
+ }
331
+ if (provider.optionalApiKey && session.credentialId) {
332
+ const creds = loadCredentials()
333
+ const cred = creds[session.credentialId]
334
+ if (cred) {
335
+ try { return decryptKey(cred.encryptedKey) } catch { return null }
336
+ }
337
+ }
338
+ return null
339
+ }
340
+
341
+ function classifyHeartbeatResponse(text: string, ackMaxChars: number): 'suppress' | 'strip' | 'keep' {
342
+ const trimmed = text.trim()
343
+ if (trimmed === 'HEARTBEAT_OK') return 'suppress'
344
+ const stripped = trimmed.replace(/HEARTBEAT_OK/gi, '').trim()
345
+ if (!stripped) return 'suppress'
346
+ if (stripped.length <= ackMaxChars) return 'suppress'
347
+ return stripped.length < trimmed.length ? 'strip' : 'keep'
348
+ }
349
+
350
+ const AUTO_MEMORY_MIN_INTERVAL_MS = 45 * 60 * 1000
351
+
352
+ function normalizeMemoryText(value: string): string {
353
+ return (value || '').replace(/\s+/g, ' ').trim()
354
+ }
355
+
356
+ function shouldStoreAutoMemoryNote(opts: {
357
+ session: any
358
+ source: string
359
+ internal: boolean
360
+ message: string
361
+ response: string
362
+ now: number
363
+ }): boolean {
364
+ const { session, source, internal, message, response, now } = opts
365
+ if (internal) return false
366
+ if (source !== 'chat' && source !== 'connector') return false
367
+ if (!session?.agentId) return false
368
+ if (!Array.isArray(session.tools) || !session.tools.includes('memory')) return false
369
+ const msg = (message || '').trim()
370
+ const resp = (response || '').trim()
371
+ if (msg.length < 20 || resp.length < 40) return false
372
+ if (/^(ok|okay|cool|thanks|thx|got it|nice)[.! ]*$/i.test(msg)) return false
373
+ if (resp === 'HEARTBEAT_OK') return false
374
+ const last = typeof session.lastAutoMemoryAt === 'number' ? session.lastAutoMemoryAt : 0
375
+ if (last > 0 && now - last < AUTO_MEMORY_MIN_INTERVAL_MS) return false
376
+ return true
377
+ }
378
+
379
+ function storeAutoMemoryNote(opts: {
380
+ session: any
381
+ message: string
382
+ response: string
383
+ source: string
384
+ now: number
385
+ }): string | null {
386
+ const { session, message, response, source, now } = opts
387
+ try {
388
+ const db = getMemoryDb()
389
+ const compactMessage = message.replace(/\s+/g, ' ').trim().slice(0, 220)
390
+ const compactResponse = response.replace(/\s+/g, ' ').trim().slice(0, 700)
391
+ const title = `[auto] ${compactMessage.slice(0, 90)}`
392
+ const content = [
393
+ `source: ${source}`,
394
+ `user_request: ${compactMessage}`,
395
+ `assistant_outcome: ${compactResponse}`,
396
+ ].join('\n')
397
+ const latest = db.getLatestBySessionCategory?.(session.id, 'execution')
398
+ if (latest) {
399
+ const sameTitle = normalizeMemoryText(latest.title) === normalizeMemoryText(title)
400
+ const sameContent = normalizeMemoryText(latest.content) === normalizeMemoryText(content)
401
+ if (sameTitle && sameContent) {
402
+ session.lastAutoMemoryAt = now
403
+ return latest.id
404
+ }
405
+ }
406
+ const created = db.add({
407
+ agentId: session.agentId,
408
+ sessionId: session.id,
409
+ category: 'execution',
410
+ title,
411
+ content,
412
+ } as any)
413
+ session.lastAutoMemoryAt = now
414
+ return created?.id || null
415
+ } catch {
416
+ return null
417
+ }
418
+ }
419
+
420
+ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promise<ExecuteChatTurnResult> {
421
+ const {
422
+ sessionId,
423
+ message,
424
+ imagePath,
425
+ imageUrl,
426
+ internal = false,
427
+ runId,
428
+ source = 'chat',
429
+ onEvent,
430
+ signal,
431
+ } = input
432
+
433
+ syncSessionFromAgent(sessionId)
434
+
435
+ const sessions = loadSessions()
436
+ const session = sessions[sessionId]
437
+ if (!session) throw new Error(`Session not found: ${sessionId}`)
438
+
439
+ const appSettings = loadSettings()
440
+ const toolPolicy = resolveSessionToolPolicy(session.tools, appSettings)
441
+ const isHeartbeatRun = internal && source === 'heartbeat'
442
+ const heartbeatStatus = session.mainLoopState?.status || 'idle'
443
+ const heartbeatStatusOnly = isHeartbeatRun
444
+ && (session.name !== '__main__' || heartbeatStatus === 'ok' || heartbeatStatus === 'idle')
445
+ const toolsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledTools
446
+ let sessionForRun = toolsForRun === session.tools
447
+ ? session
448
+ : { ...session, tools: toolsForRun }
449
+
450
+ // Apply model override for heartbeat runs (cheaper model)
451
+ if (isHeartbeatRun && input.modelOverride) {
452
+ sessionForRun = { ...sessionForRun, model: input.modelOverride }
453
+ }
454
+
455
+ if (!heartbeatStatusOnly && toolPolicy.blockedTools.length > 0) {
456
+ const blockedSummary = toolPolicy.blockedTools
457
+ .map((entry) => `${entry.tool} (${entry.reason})`)
458
+ .join(', ')
459
+ onEvent?.({ t: 'err', text: `Capability policy blocked tools for this run: ${blockedSummary}` })
460
+ }
461
+
462
+ const dailySpendLimitUsd = parseUsdLimit(appSettings.safetyMaxDailySpendUsd)
463
+ if (dailySpendLimitUsd !== null) {
464
+ const todaySpendUsd = getTodaySpendUsd()
465
+ if (todaySpendUsd >= dailySpendLimitUsd) {
466
+ const spendError = `Safety budget reached: today's spend is $${todaySpendUsd.toFixed(4)} (limit $${dailySpendLimitUsd.toFixed(4)}). Increase safetyMaxDailySpendUsd to continue autonomous runs.`
467
+ onEvent?.({ t: 'err', text: spendError })
468
+
469
+ let persisted = false
470
+ if (!internal) {
471
+ session.messages.push({
472
+ role: 'assistant',
473
+ text: spendError,
474
+ time: Date.now(),
475
+ })
476
+ session.lastActiveAt = Date.now()
477
+ saveSessions(sessions)
478
+ persisted = true
479
+ }
480
+
481
+ return {
482
+ runId,
483
+ sessionId,
484
+ text: spendError,
485
+ persisted,
486
+ toolEvents: [],
487
+ error: spendError,
488
+ }
489
+ }
490
+ }
491
+
492
+ // Log the trigger
493
+ logExecution(sessionId, 'trigger', `${source} message received`, {
494
+ runId,
495
+ agentId: session.agentId,
496
+ detail: {
497
+ source,
498
+ internal,
499
+ provider: session.provider,
500
+ model: session.model,
501
+ messagePreview: message.slice(0, 200),
502
+ hasImage: !!(imagePath || imageUrl),
503
+ },
504
+ })
505
+
506
+ const providerType = session.provider || 'claude-cli'
507
+ const provider = getProvider(providerType)
508
+ if (!provider) throw new Error(`Unknown provider: ${providerType}`)
509
+
510
+ if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
511
+ throw new Error(`Directory not found: ${session.cwd}`)
512
+ }
513
+
514
+ const apiKey = resolveApiKeyForSession(session, provider)
515
+
516
+ if (!internal) {
517
+ session.messages.push({
518
+ role: 'user',
519
+ text: message,
520
+ time: Date.now(),
521
+ imagePath: imagePath || undefined,
522
+ imageUrl: imageUrl || undefined,
523
+ })
524
+ session.lastActiveAt = Date.now()
525
+ saveSessions(sessions)
526
+ }
527
+
528
+ const systemPrompt = buildAgentSystemPrompt(session)
529
+ const toolEvents: MessageToolEvent[] = []
530
+ const streamErrors: string[] = []
531
+
532
+ const emit = (ev: SSEEvent) => {
533
+ if (ev.t === 'err' && typeof ev.text === 'string') {
534
+ const trimmed = ev.text.trim()
535
+ if (trimmed) {
536
+ streamErrors.push(trimmed)
537
+ if (streamErrors.length > 8) streamErrors.shift()
538
+ }
539
+ }
540
+ collectToolEvent(ev, toolEvents)
541
+ onEvent?.(ev)
542
+ }
543
+
544
+ const parseAndEmit = (raw: string) => {
545
+ const lines = raw.split('\n').filter(Boolean)
546
+ for (const line of lines) {
547
+ const ev = extractEventJson(line)
548
+ if (ev) emit(ev)
549
+ }
550
+ }
551
+
552
+ let fullResponse = ''
553
+ let errorMessage: string | undefined
554
+
555
+ const abortController = new AbortController()
556
+ const abortFromOutside = () => abortController.abort()
557
+ if (signal) {
558
+ if (signal.aborted) abortController.abort()
559
+ else signal.addEventListener('abort', abortFromOutside)
560
+ }
561
+
562
+ active.set(sessionId, {
563
+ runId: runId || null,
564
+ source,
565
+ kill: () => abortController.abort(),
566
+ })
567
+
568
+ try {
569
+ const hasTools = !!sessionForRun.tools?.length && !CLI_PROVIDER_IDS.has(providerType)
570
+ fullResponse = hasTools
571
+ ? (await streamAgentChat({
572
+ session: sessionForRun,
573
+ message,
574
+ imagePath,
575
+ apiKey,
576
+ systemPrompt,
577
+ write: (raw) => parseAndEmit(raw),
578
+ history: getSessionMessages(sessionId),
579
+ signal: abortController.signal,
580
+ })).fullText
581
+ : await provider.handler.streamChat({
582
+ session: sessionForRun,
583
+ message,
584
+ imagePath,
585
+ apiKey,
586
+ systemPrompt,
587
+ write: (raw: string) => parseAndEmit(raw),
588
+ active,
589
+ loadHistory: getSessionMessages,
590
+ })
591
+ } catch (err: any) {
592
+ errorMessage = err?.message || String(err)
593
+ const failureText = errorMessage || 'Run failed.'
594
+ markProviderFailure(providerType, failureText)
595
+ emit({ t: 'err', text: failureText })
596
+ log.error('chat-run', `Run failed for session ${sessionId}`, {
597
+ runId,
598
+ source,
599
+ internal,
600
+ error: failureText,
601
+ })
602
+ } finally {
603
+ active.delete(sessionId)
604
+ if (signal) signal.removeEventListener('abort', abortFromOutside)
605
+ }
606
+
607
+ if (!errorMessage) {
608
+ markProviderSuccess(providerType)
609
+ }
610
+
611
+ const requestedToolNames = (!internal && source === 'chat')
612
+ ? requestedToolNamesFromMessage(message)
613
+ : []
614
+ const routingDecision = (!internal && source === 'chat')
615
+ ? routeTaskIntent(message, toolsForRun, appSettings)
616
+ : null
617
+ const calledNames = new Set((toolEvents || []).map((t) => t.name))
618
+
619
+ const invokeSessionTool = async (toolName: string, args: Record<string, unknown>, failurePrefix: string): Promise<boolean> => {
620
+ const blockedReason = resolveConcreteToolPolicyBlock(toolName, toolPolicy, appSettings)
621
+ if (blockedReason) {
622
+ emit({ t: 'err', text: `Capability policy blocked tool invocation "${toolName}": ${blockedReason}` })
623
+ return false
624
+ }
625
+ if (
626
+ appSettings.safetyRequireApprovalForOutbound === true
627
+ && toolName === 'connector_message_tool'
628
+ && source !== 'chat'
629
+ ) {
630
+ emit({ t: 'err', text: 'Outbound connector messaging requires explicit user approval.' })
631
+ return false
632
+ }
633
+ const agent = session.agentId ? loadAgents()[session.agentId] : null
634
+ const { tools, cleanup } = await buildSessionTools(session.cwd, sessionForRun.tools || [], {
635
+ agentId: session.agentId || null,
636
+ sessionId,
637
+ platformAssignScope: agent?.platformAssignScope || 'self',
638
+ mcpServerIds: agent?.mcpServerIds,
639
+ mcpDisabledTools: agent?.mcpDisabledTools,
640
+ })
641
+ try {
642
+ const selectedTool = tools.find((t: any) => t?.name === toolName) as any
643
+ if (!selectedTool?.invoke) return false
644
+ const toolInput = JSON.stringify(args)
645
+ emit({ t: 'tool_call', toolName, toolInput })
646
+ const toolOutput = await selectedTool.invoke(args)
647
+ const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
648
+ emit({ t: 'tool_result', toolName, toolOutput: outputText })
649
+ if (outputText?.trim()) fullResponse = outputText.trim()
650
+ calledNames.add(toolName)
651
+ return true
652
+ } catch (forceErr: any) {
653
+ emit({ t: 'err', text: `${failurePrefix}: ${forceErr?.message || String(forceErr)}` })
654
+ return false
655
+ } finally {
656
+ await cleanup()
657
+ }
658
+ }
659
+
660
+ if (requestedToolNames.includes('connector_message_tool') && !calledNames.has('connector_message_tool')) {
661
+ const forcedArgs = extractConnectorMessageArgs(message)
662
+ if (forcedArgs) {
663
+ await invokeSessionTool(
664
+ 'connector_message_tool',
665
+ forcedArgs as unknown as Record<string, unknown>,
666
+ 'Forced connector_message_tool invocation failed',
667
+ )
668
+ }
669
+ }
670
+
671
+ const forcedDelegationTools: Array<'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'> = [
672
+ 'delegate_to_claude_code',
673
+ 'delegate_to_codex_cli',
674
+ 'delegate_to_opencode_cli',
675
+ ]
676
+ for (const toolName of forcedDelegationTools) {
677
+ if (!requestedToolNames.includes(toolName)) continue
678
+ if (calledNames.has(toolName)) continue
679
+ const task = extractDelegationTask(message, toolName)
680
+ if (!task) continue
681
+ await invokeSessionTool(toolName, { task }, `Forced ${toolName} invocation failed`)
682
+ }
683
+
684
+ const hasDelegationCall = forcedDelegationTools.some((toolName) => calledNames.has(toolName))
685
+ const enabledDelegateTools = enabledDelegationTools(sessionForRun)
686
+ const shouldAutoDelegateCoding = (!internal && source === 'chat')
687
+ && enabledDelegateTools.length > 0
688
+ && !hasDelegationCall
689
+ && routingDecision?.intent === 'coding'
690
+
691
+ if (shouldAutoDelegateCoding) {
692
+ const baseDelegationOrder = routingDecision?.preferredDelegates?.length
693
+ ? routingDecision.preferredDelegates
694
+ : forcedDelegationTools
695
+ const delegationOrder = rankDelegatesByHealth(baseDelegationOrder as DelegateTool[])
696
+ .filter((tool) => enabledDelegateTools.includes(tool))
697
+ for (const delegateTool of delegationOrder) {
698
+ const invoked = await invokeSessionTool(delegateTool, { task: message.trim() }, 'Auto-delegation failed')
699
+ if (invoked) break
700
+ }
701
+ }
702
+
703
+ const shouldFailoverDelegate = (!internal && source === 'chat')
704
+ && !!errorMessage
705
+ && !(fullResponse || '').trim()
706
+ && enabledDelegateTools.length > 0
707
+ && !hasDelegationCall
708
+ && (routingDecision?.intent === 'coding' || routingDecision?.intent === 'general')
709
+ if (shouldFailoverDelegate) {
710
+ const preferred = routingDecision?.preferredDelegates?.length
711
+ ? routingDecision.preferredDelegates
712
+ : forcedDelegationTools
713
+ const fallbackOrder = rankDelegatesByHealth(preferred as DelegateTool[])
714
+ .filter((tool) => enabledDelegateTools.includes(tool))
715
+ for (const delegateTool of fallbackOrder) {
716
+ const invoked = await invokeSessionTool(
717
+ delegateTool,
718
+ { task: message.trim() },
719
+ `Provider failover via ${delegateTool} failed`,
720
+ )
721
+ if (invoked) {
722
+ errorMessage = undefined
723
+ break
724
+ }
725
+ }
726
+ }
727
+
728
+ const canAutoRouteWithTools = (!internal && source === 'chat')
729
+ && !!routingDecision
730
+ && calledNames.size === 0
731
+ && requestedToolNames.length === 0
732
+
733
+ if (canAutoRouteWithTools && routingDecision?.intent === 'browsing' && routingDecision.primaryUrl && hasToolEnabled(sessionForRun, 'browser')) {
734
+ await invokeSessionTool(
735
+ 'browser',
736
+ { action: 'navigate', url: routingDecision.primaryUrl },
737
+ 'Auto browser routing failed',
738
+ )
739
+ }
740
+
741
+ if (canAutoRouteWithTools && routingDecision?.intent === 'research') {
742
+ const routeUrl = routingDecision.primaryUrl || findFirstUrl(message)
743
+ if (routeUrl && hasToolEnabled(sessionForRun, 'web_fetch')) {
744
+ await invokeSessionTool('web_fetch', { url: routeUrl }, 'Auto web_fetch routing failed')
745
+ } else if (hasToolEnabled(sessionForRun, 'web_search')) {
746
+ await invokeSessionTool('web_search', { query: message.trim(), maxResults: 5 }, 'Auto web_search routing failed')
747
+ }
748
+ }
749
+
750
+ if (requestedToolNames.length > 0) {
751
+ const missed = requestedToolNames.filter((name) => !calledNames.has(name))
752
+ if (missed.length > 0) {
753
+ const notice = `Tool execution notice: requested tool(s) ${missed.join(', ')} were not actually invoked in this run.`
754
+ emit({ t: 'err', text: notice })
755
+ if (!fullResponse.includes('Tool execution notice:')) {
756
+ const trimmedResponse = (fullResponse || '').trim()
757
+ fullResponse = trimmedResponse
758
+ ? `${trimmedResponse}\n\n${notice}`
759
+ : notice
760
+ }
761
+ }
762
+ }
763
+
764
+ if (!errorMessage && streamErrors.length > 0 && !(fullResponse || '').trim()) {
765
+ errorMessage = streamErrors[streamErrors.length - 1]
766
+ }
767
+
768
+ const finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
769
+ const textForPersistence = stripMainLoopMetaForPersistence(finalText, internal)
770
+
771
+ // HEARTBEAT_OK suppression
772
+ const heartbeatConfig = input.heartbeatConfig
773
+ let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
774
+ if (isHeartbeatRun && textForPersistence.length > 0) {
775
+ heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300)
776
+ }
777
+
778
+ const shouldPersistAssistant = textForPersistence.length > 0
779
+ && heartbeatClassification !== 'suppress'
780
+
781
+ const normalizeResumeId = (value: unknown): string | null =>
782
+ typeof value === 'string' && value.trim() ? value.trim() : null
783
+
784
+ const fresh = loadSessions()
785
+ const current = fresh[sessionId]
786
+ if (current) {
787
+ let changed = false
788
+ const persistField = (key: string, value: unknown) => {
789
+ const normalized = normalizeResumeId(value)
790
+ if ((current as any)[key] !== normalized) {
791
+ ;(current as any)[key] = normalized
792
+ changed = true
793
+ }
794
+ }
795
+
796
+ persistField('claudeSessionId', session.claudeSessionId)
797
+ persistField('codexThreadId', session.codexThreadId)
798
+ persistField('opencodeSessionId', session.opencodeSessionId)
799
+
800
+ const sourceResume = session.delegateResumeIds
801
+ if (sourceResume && typeof sourceResume === 'object') {
802
+ const currentResume = (current.delegateResumeIds && typeof current.delegateResumeIds === 'object')
803
+ ? current.delegateResumeIds
804
+ : {}
805
+ const nextResume = {
806
+ claudeCode: normalizeResumeId((sourceResume as any).claudeCode ?? (currentResume as any).claudeCode),
807
+ codex: normalizeResumeId((sourceResume as any).codex ?? (currentResume as any).codex),
808
+ opencode: normalizeResumeId((sourceResume as any).opencode ?? (currentResume as any).opencode),
809
+ }
810
+ if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
811
+ current.delegateResumeIds = nextResume
812
+ changed = true
813
+ }
814
+ }
815
+
816
+ if (shouldPersistAssistant) {
817
+ const persistedKind = internal && source !== 'session-awakening' ? 'heartbeat' : 'chat'
818
+ const persistedText = heartbeatClassification === 'strip'
819
+ ? textForPersistence.replace(/HEARTBEAT_OK/gi, '').trim()
820
+ : textForPersistence
821
+ current.messages.push({
822
+ role: 'assistant',
823
+ text: persistedText,
824
+ time: Date.now(),
825
+ toolEvents: toolEvents.length ? toolEvents : undefined,
826
+ kind: persistedKind,
827
+ })
828
+ changed = true
829
+
830
+ // Target routing for non-suppressed heartbeat alerts
831
+ if (isHeartbeatRun && heartbeatConfig?.target && heartbeatConfig.target !== 'none' && heartbeatConfig.showAlerts !== false) {
832
+ try {
833
+ const { listRunningConnectors, sendConnectorMessage } = require('./connectors/manager')
834
+ let connectorId: string | undefined
835
+ let channelId: string | undefined
836
+ if (heartbeatConfig.target === 'last') {
837
+ const running = listRunningConnectors()
838
+ const first = running.find((c: any) => c.recentChannelId)
839
+ if (first) {
840
+ connectorId = first.id
841
+ channelId = first.recentChannelId
842
+ }
843
+ } else if (heartbeatConfig.target.includes(':')) {
844
+ const [cId, chId] = heartbeatConfig.target.split(':', 2)
845
+ connectorId = cId
846
+ channelId = chId
847
+ } else {
848
+ channelId = heartbeatConfig.target
849
+ }
850
+ if (channelId) {
851
+ sendConnectorMessage({ connectorId, channelId, text: persistedText }).catch(() => {})
852
+ }
853
+ } catch {
854
+ // Best effort — connector manager may not be loaded
855
+ }
856
+ }
857
+ }
858
+
859
+ const autoMemoryEligible = shouldStoreAutoMemoryNote({
860
+ session: current,
861
+ source,
862
+ internal,
863
+ message,
864
+ response: textForPersistence,
865
+ now: Date.now(),
866
+ })
867
+ if (autoMemoryEligible) {
868
+ const storedId = storeAutoMemoryNote({
869
+ session: current,
870
+ message,
871
+ response: textForPersistence,
872
+ source,
873
+ now: Date.now(),
874
+ })
875
+ if (storedId) changed = true
876
+ }
877
+
878
+ // Don't extend idle timeout for heartbeat runs — only user-initiated activity counts
879
+ if (source !== 'heartbeat' && source !== 'main-loop-followup') {
880
+ current.lastActiveAt = Date.now()
881
+ }
882
+ fresh[sessionId] = current
883
+ saveSessions(fresh)
884
+ }
885
+
886
+ return {
887
+ runId,
888
+ sessionId,
889
+ text: finalText,
890
+ persisted: shouldPersistAssistant,
891
+ toolEvents,
892
+ error: errorMessage,
893
+ }
894
+ }