@poolzin/pool-bot 2026.2.21 → 2026.2.22

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 (369) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/agents/api-key-rotation.js +47 -0
  3. package/dist/agents/apply-patch-update.js +19 -9
  4. package/dist/agents/apply-patch.js +72 -47
  5. package/dist/agents/bash-tools.exec.js +141 -559
  6. package/dist/agents/cli-backends.js +49 -6
  7. package/dist/agents/cli-runner/helpers.js +69 -152
  8. package/dist/agents/cli-runner.js +70 -19
  9. package/dist/agents/identity.js +20 -1
  10. package/dist/agents/image-sanitization.js +9 -0
  11. package/dist/agents/live-auth-keys.js +123 -26
  12. package/dist/agents/live-model-filter.js +13 -4
  13. package/dist/agents/model-catalog.js +40 -9
  14. package/dist/agents/model-forward-compat.js +60 -23
  15. package/dist/agents/model-selection.js +134 -41
  16. package/dist/agents/pi-auth-json.js +2 -2
  17. package/dist/agents/pi-embedded-helpers/bootstrap.js +65 -15
  18. package/dist/agents/pi-embedded-helpers/errors.js +140 -15
  19. package/dist/agents/pi-embedded-helpers/images.js +22 -12
  20. package/dist/agents/pi-embedded-helpers.js +2 -2
  21. package/dist/agents/pi-embedded-runner/abort.js +10 -3
  22. package/dist/agents/pi-embedded-runner/compact.js +230 -32
  23. package/dist/agents/pi-embedded-runner/extra-params.js +203 -12
  24. package/dist/agents/pi-embedded-runner/google.js +109 -19
  25. package/dist/agents/pi-embedded-runner/history.js +35 -17
  26. package/dist/agents/pi-embedded-runner/run/attempt.js +386 -95
  27. package/dist/agents/pi-embedded-runner/run/images.js +81 -55
  28. package/dist/agents/pi-embedded-runner/run/payloads.js +89 -39
  29. package/dist/agents/pi-embedded-runner/run.js +193 -25
  30. package/dist/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.js +2 -2
  31. package/dist/agents/pi-embedded-runner/runs.js +17 -8
  32. package/dist/agents/pi-embedded-runner/tool-result-context-guard.js +262 -0
  33. package/dist/agents/pi-embedded-runner.js +1 -1
  34. package/dist/agents/pi-embedded-subscribe.handlers.tools.js +180 -10
  35. package/dist/agents/pi-embedded-subscribe.js +37 -0
  36. package/dist/agents/pi-embedded-subscribe.tools.js +127 -30
  37. package/dist/agents/pi-model-discovery.js +9 -2
  38. package/dist/agents/pi-tool-definition-adapter.js +60 -8
  39. package/dist/agents/pi-tools.before-tool-call.js +1 -1
  40. package/dist/agents/pi-tools.js +113 -94
  41. package/dist/agents/pi-tools.read.js +337 -38
  42. package/dist/agents/poolbot-tools.js +14 -5
  43. package/dist/agents/sandbox/docker.js +10 -5
  44. package/dist/agents/sandbox/registry.js +96 -46
  45. package/dist/agents/sandbox/sanitize-env-vars.js +82 -0
  46. package/dist/agents/sandbox-paths.js +43 -10
  47. package/dist/agents/session-tool-result-guard-wrapper.js +23 -11
  48. package/dist/agents/session-tool-result-guard.js +39 -39
  49. package/dist/agents/session-transcript-repair.js +36 -33
  50. package/dist/agents/session-write-lock.js +62 -44
  51. package/dist/agents/skills/frontmatter.js +49 -88
  52. package/dist/agents/skills/workspace.js +335 -28
  53. package/dist/agents/subagent-announce.js +508 -174
  54. package/dist/agents/subagent-registry.js +45 -4
  55. package/dist/agents/subagent-spawn.js +16 -33
  56. package/dist/agents/system-prompt-report.js +27 -10
  57. package/dist/agents/system-prompt.js +26 -32
  58. package/dist/agents/tool-call-id.js +69 -17
  59. package/dist/agents/tool-display-common.js +1 -1
  60. package/dist/agents/tool-images.js +64 -31
  61. package/dist/agents/tools/canvas-tool.js +17 -11
  62. package/dist/agents/tools/common.js +37 -19
  63. package/dist/agents/tools/cron-tool.js +40 -38
  64. package/dist/agents/tools/gateway.js +70 -2
  65. package/dist/agents/tools/message-tool.js +181 -40
  66. package/dist/agents/tools/nodes-tool.js +128 -36
  67. package/dist/agents/tools/nodes-utils.js +12 -38
  68. package/dist/agents/tools/session-status-tool.js +24 -71
  69. package/dist/agents/tools/sessions-helpers.js +38 -210
  70. package/dist/agents/tools/sessions-spawn-tool.js +28 -198
  71. package/dist/agents/tools/telegram-actions.js +58 -7
  72. package/dist/agents/tools/web-fetch-utils.js +112 -7
  73. package/dist/agents/tools/web-fetch.js +279 -175
  74. package/dist/agents/tools/web-shared.js +71 -8
  75. package/dist/agents/usage.js +25 -16
  76. package/dist/auto-reply/commands-registry.data.js +85 -11
  77. package/dist/auto-reply/dispatch.js +40 -21
  78. package/dist/auto-reply/reply/abort.js +102 -33
  79. package/dist/auto-reply/reply/commands-core.js +82 -33
  80. package/dist/auto-reply/reply/commands-export-session.js +1 -1
  81. package/dist/auto-reply/reply/commands-info.js +41 -12
  82. package/dist/auto-reply/reply/commands-subagents.js +352 -100
  83. package/dist/auto-reply/reply/commands-system-prompt.js +2 -2
  84. package/dist/auto-reply/reply/dispatch-from-config.js +100 -29
  85. package/dist/auto-reply/reply/elevated-unavailable.js +1 -1
  86. package/dist/auto-reply/reply/inbound-meta.js +12 -1
  87. package/dist/auto-reply/reply/mentions.js +18 -11
  88. package/dist/auto-reply/reply/normalize-reply.js +17 -8
  89. package/dist/auto-reply/reply/reply-dispatcher.js +62 -10
  90. package/dist/auto-reply/reply/session.js +102 -21
  91. package/dist/auto-reply/reply/streaming-directives.js +16 -5
  92. package/dist/auto-reply/status.js +73 -50
  93. package/dist/browser/extension-relay.js +3 -3
  94. package/dist/browser/http-auth.js +1 -1
  95. package/dist/browser/paths.js +2 -2
  96. package/dist/build-info.json +3 -3
  97. package/dist/channels/allowlist-match.js +20 -0
  98. package/dist/channels/allowlists/resolve-utils.js +65 -2
  99. package/dist/channels/chat-type.js +8 -4
  100. package/dist/channels/dock.js +127 -35
  101. package/dist/channels/draft-stream-loop.js +6 -2
  102. package/dist/channels/plugins/actions/telegram.js +42 -18
  103. package/dist/channels/plugins/allowlist-match.js +1 -1
  104. package/dist/channels/plugins/group-mentions.js +51 -41
  105. package/dist/channels/plugins/message-action-names.js +2 -0
  106. package/dist/channels/plugins/message-actions.js +24 -5
  107. package/dist/channels/plugins/normalize/discord.js +26 -4
  108. package/dist/channels/plugins/normalize/signal.js +35 -22
  109. package/dist/channels/plugins/onboarding/helpers.js +8 -26
  110. package/dist/channels/plugins/outbound/imessage.js +15 -14
  111. package/dist/channels/registry.js +20 -7
  112. package/dist/cli/acp-cli.js +7 -5
  113. package/dist/cli/browser-cli-extension.js +25 -12
  114. package/dist/cli/browser-cli-state.cookies-storage.js +25 -6
  115. package/dist/cli/browser-cli-state.js +101 -145
  116. package/dist/cli/command-options.js +28 -0
  117. package/dist/cli/completion-cli.js +6 -6
  118. package/dist/cli/cron-cli/register.cron-add.js +25 -1
  119. package/dist/cli/cron-cli/register.cron-edit.js +44 -0
  120. package/dist/cli/cron-cli/shared.js +7 -1
  121. package/dist/cli/daemon-cli/lifecycle-core.js +23 -21
  122. package/dist/cli/daemon-cli/lifecycle.js +23 -247
  123. package/dist/cli/daemon-cli/register-service-commands.js +25 -4
  124. package/dist/cli/daemon-cli.js +1 -0
  125. package/dist/cli/devices-cli.js +33 -20
  126. package/dist/cli/gateway-cli/register.js +37 -105
  127. package/dist/cli/gateway-cli/run.js +49 -11
  128. package/dist/cli/nodes-camera.js +59 -4
  129. package/dist/cli/nodes-cli/register.camera.js +27 -24
  130. package/dist/cli/nodes-cli/rpc.js +21 -38
  131. package/dist/cli/qr-cli.js +2 -2
  132. package/dist/cli/skills-cli.format.js +2 -2
  133. package/dist/cli/update-cli/progress.js +2 -2
  134. package/dist/cli/update-cli/restart-helper.js +28 -7
  135. package/dist/cli/update-cli/shared.js +7 -7
  136. package/dist/cli/update-cli/status.js +1 -1
  137. package/dist/cli/update-cli/update-command.js +14 -8
  138. package/dist/cli/update-cli/wizard.js +2 -2
  139. package/dist/cli/update-cli.js +21 -1027
  140. package/dist/commands/auth-choice.apply.anthropic.js +10 -2
  141. package/dist/commands/channels/add-mutators.js +3 -35
  142. package/dist/commands/channels/add.js +39 -51
  143. package/dist/commands/config-validation.js +1 -1
  144. package/dist/commands/configure.gateway-auth.js +52 -15
  145. package/dist/commands/configure.gateway.js +84 -40
  146. package/dist/commands/doctor-completion.js +3 -3
  147. package/dist/commands/doctor-config-flow.js +536 -16
  148. package/dist/commands/doctor-gateway-services.js +103 -79
  149. package/dist/commands/doctor-memory-search.js +9 -9
  150. package/dist/commands/doctor-platform-notes.js +57 -30
  151. package/dist/commands/doctor-prompter.js +26 -15
  152. package/dist/commands/doctor-session-locks.js +1 -1
  153. package/dist/commands/doctor.js +21 -9
  154. package/dist/commands/model-picker.js +120 -95
  155. package/dist/commands/models/set.js +2 -21
  156. package/dist/commands/models/shared.js +65 -37
  157. package/dist/commands/onboard-helpers.js +81 -39
  158. package/dist/commands/openai-codex-oauth.js +1 -1
  159. package/dist/commands/sessions.js +52 -53
  160. package/dist/commands/status.summary.js +52 -34
  161. package/dist/commands/test-wizard-helpers.js +2 -2
  162. package/dist/config/defaults.js +79 -42
  163. package/dist/config/group-policy.js +50 -18
  164. package/dist/config/includes.js +37 -10
  165. package/dist/config/schema.help.js +5 -4
  166. package/dist/config/schema.hints.js +2 -2
  167. package/dist/config/schema.labels.js +1 -0
  168. package/dist/config/sessions/group.js +12 -11
  169. package/dist/config/sessions/paths.js +137 -11
  170. package/dist/config/sessions/store.js +185 -65
  171. package/dist/config/sessions/types.js +15 -1
  172. package/dist/config/sessions.js +1 -0
  173. package/dist/config/telegram-custom-commands.js +3 -2
  174. package/dist/config/types.js +2 -0
  175. package/dist/config/zod-schema.agent-defaults.js +6 -27
  176. package/dist/config/zod-schema.agent-runtime.js +171 -79
  177. package/dist/config/zod-schema.providers-core.js +138 -65
  178. package/dist/config/zod-schema.session.js +49 -22
  179. package/dist/control-ui/assets/index-HRr1grwl.js.map +1 -1
  180. package/dist/cron/isolated-agent/run.js +224 -57
  181. package/dist/cron/normalize.js +48 -45
  182. package/dist/cron/run-log.js +14 -0
  183. package/dist/cron/service/jobs.js +190 -28
  184. package/dist/cron/service/normalize.js +29 -11
  185. package/dist/cron/service/store.js +30 -44
  186. package/dist/cron/service/timer.js +182 -96
  187. package/dist/cron/service.js +3 -0
  188. package/dist/cron/stagger.js +37 -0
  189. package/dist/daemon/inspect.js +132 -92
  190. package/dist/daemon/runtime-paths.js +25 -4
  191. package/dist/daemon/service-audit.js +47 -16
  192. package/dist/discord/accounts.js +23 -20
  193. package/dist/discord/monitor/agent-components.js +1115 -219
  194. package/dist/discord/monitor/allow-list.js +114 -34
  195. package/dist/discord/monitor/listeners.js +204 -97
  196. package/dist/discord/monitor/message-handler.js +21 -10
  197. package/dist/discord/monitor/message-handler.preflight.js +195 -101
  198. package/dist/discord/monitor/message-handler.process.js +384 -123
  199. package/dist/discord/monitor/message-utils.js +86 -23
  200. package/dist/discord/monitor/native-command.js +77 -57
  201. package/dist/discord/monitor/provider.js +122 -117
  202. package/dist/discord/monitor/reply-context.js +20 -16
  203. package/dist/discord/monitor/reply-delivery.js +40 -8
  204. package/dist/discord/monitor/rest-fetch.js +22 -0
  205. package/dist/discord/monitor/threading.js +117 -24
  206. package/dist/discord/send.js +2 -1
  207. package/dist/discord/send.outbound.js +124 -11
  208. package/dist/discord/send.shared.js +112 -72
  209. package/dist/discord/voice-message.js +3 -3
  210. package/dist/gateway/auth.js +119 -44
  211. package/dist/gateway/call.js +76 -34
  212. package/dist/gateway/channel-health-monitor.js +57 -50
  213. package/dist/gateway/client.js +63 -29
  214. package/dist/gateway/control-ui-contract.js +1 -1
  215. package/dist/gateway/gateway-config-prompts.shared.js +2 -2
  216. package/dist/gateway/net.js +109 -1
  217. package/dist/gateway/protocol/index.js +5 -8
  218. package/dist/gateway/protocol/schema/agent.js +19 -1
  219. package/dist/gateway/protocol/schema/channels.js +21 -0
  220. package/dist/gateway/protocol/schema/cron.js +43 -30
  221. package/dist/gateway/protocol/schema/protocol-schemas.js +6 -11
  222. package/dist/gateway/protocol/schema/sessions.js +5 -1
  223. package/dist/gateway/protocol/schema.js +0 -1
  224. package/dist/gateway/server/presence-events.js +12 -0
  225. package/dist/gateway/server/ws-connection/message-handler.js +203 -212
  226. package/dist/gateway/server/ws-connection.js +58 -21
  227. package/dist/gateway/server-broadcast.js +18 -13
  228. package/dist/gateway/server-cron.js +177 -10
  229. package/dist/gateway/server-methods/agent-job.js +131 -38
  230. package/dist/gateway/server-methods/send.js +60 -14
  231. package/dist/gateway/server-methods/sessions.js +160 -96
  232. package/dist/gateway/server-methods/system.js +5 -7
  233. package/dist/gateway/server-methods-list.js +8 -0
  234. package/dist/gateway/server-methods.js +24 -8
  235. package/dist/gateway/server-node-events.js +278 -68
  236. package/dist/gateway/session-utils.fs.js +316 -75
  237. package/dist/gateway/session-utils.js +224 -70
  238. package/dist/gateway/sessions-patch.js +63 -20
  239. package/dist/gateway/test-temp-config.js +1 -1
  240. package/dist/gateway/tools-invoke-http.js +118 -70
  241. package/dist/gateway/ws-log.js +135 -107
  242. package/dist/hooks/frontmatter.js +36 -82
  243. package/dist/hooks/install.js +149 -139
  244. package/dist/hooks/internal-hooks.js +29 -4
  245. package/dist/hooks/plugin-hooks.js +2 -1
  246. package/dist/imessage/monitor/deliver.js +10 -4
  247. package/dist/imessage/monitor/monitor-provider.js +138 -375
  248. package/dist/imessage/monitor/runtime.js +4 -8
  249. package/dist/imessage/send.js +65 -19
  250. package/dist/infra/exec-approvals-allowlist.js +7 -0
  251. package/dist/infra/exec-approvals.js +35 -920
  252. package/dist/infra/exec-safe-bin-trust.js +64 -0
  253. package/dist/infra/heartbeat-runner.js +207 -134
  254. package/dist/infra/heartbeat-wake.js +183 -22
  255. package/dist/infra/install-source-utils.js +47 -0
  256. package/dist/infra/net/ssrf.js +170 -36
  257. package/dist/infra/outbound/deliver.js +224 -58
  258. package/dist/infra/outbound/message-action-spec.js +12 -5
  259. package/dist/infra/outbound/outbound-session.js +27 -25
  260. package/dist/infra/poolbot-root.js +32 -22
  261. package/dist/infra/ports.js +14 -11
  262. package/dist/infra/skills-remote.js +48 -37
  263. package/dist/infra/system-events.js +25 -11
  264. package/dist/infra/system-presence.js +26 -33
  265. package/dist/infra/tmp-poolbot-dir.js +81 -2
  266. package/dist/infra/wsl.js +37 -1
  267. package/dist/line/bot-message-context.js +163 -191
  268. package/dist/logging/subsystem.js +59 -22
  269. package/dist/markdown/ir.js +124 -50
  270. package/dist/media/store.js +1 -1
  271. package/dist/media-understanding/runner.entries.js +42 -25
  272. package/dist/media-understanding/runner.js +53 -488
  273. package/dist/memory/embeddings-gemini.js +53 -38
  274. package/dist/memory/manager-embedding-ops.js +48 -69
  275. package/dist/pairing/pairing-store.js +178 -119
  276. package/dist/plugin-sdk/index.js +34 -6
  277. package/dist/plugins/hooks.js +135 -14
  278. package/dist/plugins/install.js +190 -152
  279. package/dist/polls.js +11 -0
  280. package/dist/routing/resolve-route.js +190 -56
  281. package/dist/routing/session-key.js +38 -22
  282. package/dist/runtime.js +35 -9
  283. package/dist/security/audit-channel.js +1 -1
  284. package/dist/sessions/session-key-utils.js +29 -11
  285. package/dist/shared/frontmatter.js +5 -5
  286. package/dist/shared/node-list-types.js +1 -0
  287. package/dist/shared/string-normalization.js +15 -0
  288. package/dist/signal/monitor/event-handler.js +68 -36
  289. package/dist/signal/send.js +29 -37
  290. package/dist/slack/monitor/allow-list.js +10 -11
  291. package/dist/slack/monitor/commands.js +14 -3
  292. package/dist/slack/monitor/events/interactions.js +4 -4
  293. package/dist/slack/monitor/media.js +224 -16
  294. package/dist/slack/monitor/message-handler/dispatch.js +247 -13
  295. package/dist/slack/monitor/message-handler/prepare.js +128 -45
  296. package/dist/slack/monitor/slash.js +357 -144
  297. package/dist/slack/streaming.js +77 -0
  298. package/dist/telegram/accounts.js +40 -13
  299. package/dist/telegram/allowed-updates.js +3 -0
  300. package/dist/telegram/bot/delivery.js +129 -66
  301. package/dist/telegram/bot/helpers.js +136 -122
  302. package/dist/telegram/bot-handlers.js +600 -339
  303. package/dist/telegram/bot-message-context.js +115 -73
  304. package/dist/telegram/bot-message-dispatch.js +235 -104
  305. package/dist/telegram/bot-native-command-menu.js +3 -1
  306. package/dist/telegram/bot-native-commands.js +213 -193
  307. package/dist/telegram/bot.js +24 -132
  308. package/dist/telegram/draft-stream.js +84 -75
  309. package/dist/telegram/format.js +150 -6
  310. package/dist/telegram/send.js +415 -255
  311. package/dist/telegram/targets.js +21 -2
  312. package/dist/telegram/update-offset-store.js +19 -3
  313. package/dist/terminal/restore.js +5 -2
  314. package/dist/test-utils/fetch-mock.js +5 -0
  315. package/dist/version.js +18 -5
  316. package/dist/web/auto-reply/monitor/broadcast.js +7 -3
  317. package/dist/web/auto-reply/monitor/on-message.js +6 -3
  318. package/dist/web/inbound/media.js +34 -8
  319. package/dist/web/inbound/monitor.js +34 -17
  320. package/dist/web/inbound/send-api.js +18 -17
  321. package/dist/web/outbound.js +12 -5
  322. package/dist/wizard/clack-prompter.js +40 -7
  323. package/extensions/bluebubbles/package.json +1 -1
  324. package/extensions/copilot-proxy/package.json +1 -1
  325. package/extensions/diagnostics-otel/package.json +1 -1
  326. package/extensions/discord/package.json +1 -1
  327. package/extensions/feishu/package.json +1 -1
  328. package/extensions/google-antigravity-auth/package.json +1 -1
  329. package/extensions/google-gemini-cli-auth/package.json +1 -1
  330. package/extensions/googlechat/package.json +1 -1
  331. package/extensions/imessage/package.json +1 -1
  332. package/extensions/irc/package.json +1 -1
  333. package/extensions/line/package.json +1 -1
  334. package/extensions/llm-task/package.json +1 -1
  335. package/extensions/lobster/package.json +1 -1
  336. package/extensions/matrix/CHANGELOG.md +5 -0
  337. package/extensions/matrix/package.json +1 -1
  338. package/extensions/mattermost/package.json +1 -1
  339. package/extensions/memory-core/package.json +1 -1
  340. package/extensions/memory-lancedb/package.json +1 -1
  341. package/extensions/minimax-portal-auth/package.json +1 -1
  342. package/extensions/msteams/CHANGELOG.md +5 -0
  343. package/extensions/msteams/package.json +1 -1
  344. package/extensions/nextcloud-talk/package.json +1 -1
  345. package/extensions/nostr/CHANGELOG.md +5 -0
  346. package/extensions/nostr/package.json +1 -1
  347. package/extensions/open-prose/package.json +1 -1
  348. package/extensions/openai-codex-auth/package.json +1 -1
  349. package/extensions/signal/package.json +1 -1
  350. package/extensions/slack/package.json +1 -1
  351. package/extensions/telegram/package.json +1 -1
  352. package/extensions/tlon/package.json +1 -1
  353. package/extensions/twitch/CHANGELOG.md +5 -0
  354. package/extensions/twitch/package.json +1 -1
  355. package/extensions/voice-call/CHANGELOG.md +5 -0
  356. package/extensions/voice-call/package.json +1 -1
  357. package/extensions/whatsapp/package.json +1 -1
  358. package/extensions/zalo/CHANGELOG.md +5 -0
  359. package/extensions/zalo/package.json +1 -1
  360. package/extensions/zalouser/CHANGELOG.md +5 -0
  361. package/extensions/zalouser/package.json +1 -1
  362. package/package.json +1 -1
  363. package/skills/apple-reminders/SKILL.md +100 -49
  364. package/skills/coding-agent/SKILL.md +34 -28
  365. package/skills/github/SKILL.md +131 -16
  366. package/skills/imsg/SKILL.md +112 -15
  367. package/skills/openhue/SKILL.md +101 -19
  368. package/skills/tmux/SKILL.md +111 -79
  369. package/skills/weather/SKILL.md +88 -25
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+ const DEFAULT_SAFE_BIN_TRUSTED_DIRS = [
3
+ "/bin",
4
+ "/usr/bin",
5
+ "/usr/local/bin",
6
+ "/opt/homebrew/bin",
7
+ "/opt/local/bin",
8
+ "/snap/bin",
9
+ "/run/current-system/sw/bin",
10
+ ];
11
+ let trustedSafeBinCache = null;
12
+ function normalizeTrustedDir(value) {
13
+ const trimmed = value.trim();
14
+ if (!trimmed) {
15
+ return null;
16
+ }
17
+ return path.resolve(trimmed);
18
+ }
19
+ function buildTrustedSafeBinCacheKey(pathEnv, delimiter) {
20
+ return `${delimiter}\u0000${pathEnv}`;
21
+ }
22
+ export function buildTrustedSafeBinDirs(params = {}) {
23
+ const delimiter = params.delimiter ?? path.delimiter;
24
+ const pathEnv = params.pathEnv ?? "";
25
+ const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS;
26
+ const trusted = new Set();
27
+ for (const entry of baseDirs) {
28
+ const normalized = normalizeTrustedDir(entry);
29
+ if (normalized) {
30
+ trusted.add(normalized);
31
+ }
32
+ }
33
+ const pathEntries = pathEnv
34
+ .split(delimiter)
35
+ .map((entry) => normalizeTrustedDir(entry))
36
+ .filter((entry) => Boolean(entry));
37
+ for (const entry of pathEntries) {
38
+ trusted.add(entry);
39
+ }
40
+ return trusted;
41
+ }
42
+ export function getTrustedSafeBinDirs(params = {}) {
43
+ const delimiter = params.delimiter ?? path.delimiter;
44
+ const pathEnv = params.pathEnv ?? process.env.PATH ?? process.env.Path ?? "";
45
+ const key = buildTrustedSafeBinCacheKey(pathEnv, delimiter);
46
+ if (!params.refresh && trustedSafeBinCache?.key === key) {
47
+ return trustedSafeBinCache.dirs;
48
+ }
49
+ const dirs = buildTrustedSafeBinDirs({
50
+ pathEnv,
51
+ delimiter,
52
+ });
53
+ trustedSafeBinCache = { key, dirs };
54
+ return dirs;
55
+ }
56
+ export function isTrustedSafeBinPath(params) {
57
+ const trustedDirs = params.trustedDirs ??
58
+ getTrustedSafeBinDirs({
59
+ pathEnv: params.pathEnv,
60
+ delimiter: params.delimiter,
61
+ });
62
+ const resolvedDir = path.dirname(path.resolve(params.resolvedPath));
63
+ return trustedDirs.has(resolvedDir);
64
+ }
@@ -1,128 +1,44 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveAgentConfig, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js";
4
- import { resolveUserTimezone } from "../agents/date-time.js";
4
+ import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
5
5
  import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
6
6
  import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
7
+ import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
7
8
  import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_EVERY, isHeartbeatContentEffectivelyEmpty, resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, } from "../auto-reply/heartbeat.js";
8
9
  import { getReplyFromConfig } from "../auto-reply/reply.js";
9
10
  import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
10
11
  import { getChannelPlugin } from "../channels/plugins/index.js";
11
12
  import { parseDurationMs } from "../cli/parse-duration.js";
12
13
  import { loadConfig } from "../config/config.js";
13
- import { canonicalizeMainSessionAlias, loadSessionStore, resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveStorePath, saveSessionStore, updateSessionStore, } from "../config/sessions.js";
14
+ import { canonicalizeMainSessionAlias, loadSessionStore, resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveSessionFilePath, resolveStorePath, saveSessionStore, updateSessionStore, } from "../config/sessions.js";
14
15
  import { createSubsystemLogger } from "../logging/subsystem.js";
15
16
  import { getQueueSize } from "../process/command-queue.js";
16
17
  import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
17
18
  import { defaultRuntime } from "../runtime.js";
19
+ import { escapeRegExp } from "../utils.js";
18
20
  import { formatErrorMessage } from "./errors.js";
21
+ import { isWithinActiveHours } from "./heartbeat-active-hours.js";
22
+ import { buildCronEventPrompt, isCronSystemEvent, isExecCompletionEvent, } from "./heartbeat-events-filter.js";
19
23
  import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
20
24
  import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
21
25
  import { requestHeartbeatNow, setHeartbeatWakeHandler, } from "./heartbeat-wake.js";
22
26
  import { deliverOutboundPayloads } from "./outbound/deliver.js";
23
27
  import { resolveHeartbeatDeliveryTarget, resolveHeartbeatSenderContext, } from "./outbound/targets.js";
24
- import { peekSystemEvents } from "./system-events.js";
28
+ import { peekSystemEventEntries } from "./system-events.js";
25
29
  const log = createSubsystemLogger("gateway/heartbeat");
26
30
  let heartbeatsEnabled = true;
27
31
  export function setHeartbeatsEnabled(enabled) {
28
32
  heartbeatsEnabled = enabled;
29
33
  }
30
34
  const DEFAULT_HEARTBEAT_TARGET = "last";
31
- const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
32
35
  // Prompt used when an async exec has completed and the result should be relayed to the user.
33
36
  // This overrides the standard heartbeat prompt to ensure the model responds with the exec result
34
37
  // instead of just "HEARTBEAT_OK".
35
38
  const EXEC_EVENT_PROMPT = "An async command you ran earlier has completed. The result is shown in the system messages above. " +
36
39
  "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
37
40
  "If it failed, explain what went wrong.";
38
- // Prompt used when a scheduled cron job has fired and injected a system event.
39
- // This overrides the standard heartbeat prompt so the model relays the scheduled
40
- // reminder instead of responding with "HEARTBEAT_OK".
41
- const CRON_EVENT_PROMPT = "A scheduled reminder has been triggered. The reminder message is shown in the system messages above. " +
42
- "Please relay this reminder to the user in a helpful and friendly way.";
43
- function resolveActiveHoursTimezone(cfg, raw) {
44
- const trimmed = raw?.trim();
45
- if (!trimmed || trimmed === "user") {
46
- return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
47
- }
48
- if (trimmed === "local") {
49
- const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
50
- return host?.trim() || "UTC";
51
- }
52
- try {
53
- new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
54
- return trimmed;
55
- }
56
- catch {
57
- return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
58
- }
59
- }
60
- function parseActiveHoursTime(opts, raw) {
61
- if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) {
62
- return null;
63
- }
64
- const [hourStr, minuteStr] = raw.split(":");
65
- const hour = Number(hourStr);
66
- const minute = Number(minuteStr);
67
- if (!Number.isFinite(hour) || !Number.isFinite(minute)) {
68
- return null;
69
- }
70
- if (hour === 24) {
71
- if (!opts.allow24 || minute !== 0) {
72
- return null;
73
- }
74
- return 24 * 60;
75
- }
76
- return hour * 60 + minute;
77
- }
78
- function resolveMinutesInTimeZone(nowMs, timeZone) {
79
- try {
80
- const parts = new Intl.DateTimeFormat("en-US", {
81
- timeZone,
82
- hour: "2-digit",
83
- minute: "2-digit",
84
- hourCycle: "h23",
85
- }).formatToParts(new Date(nowMs));
86
- const map = {};
87
- for (const part of parts) {
88
- if (part.type !== "literal") {
89
- map[part.type] = part.value;
90
- }
91
- }
92
- const hour = Number(map.hour);
93
- const minute = Number(map.minute);
94
- if (!Number.isFinite(hour) || !Number.isFinite(minute)) {
95
- return null;
96
- }
97
- return hour * 60 + minute;
98
- }
99
- catch {
100
- return null;
101
- }
102
- }
103
- function isWithinActiveHours(cfg, heartbeat, nowMs) {
104
- const active = heartbeat?.activeHours;
105
- if (!active) {
106
- return true;
107
- }
108
- const startMin = parseActiveHoursTime({ allow24: false }, active.start);
109
- const endMin = parseActiveHoursTime({ allow24: true }, active.end);
110
- if (startMin === null || endMin === null) {
111
- return true;
112
- }
113
- if (startMin === endMin) {
114
- return true;
115
- }
116
- const timeZone = resolveActiveHoursTimezone(cfg, active.timezone);
117
- const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone);
118
- if (currentMin === null) {
119
- return true;
120
- }
121
- if (endMin > startMin) {
122
- return currentMin >= startMin && currentMin < endMin;
123
- }
124
- return currentMin >= startMin || currentMin < endMin;
125
- }
41
+ export { isCronSystemEvent };
126
42
  function hasExplicitHeartbeatAgents(cfg) {
127
43
  const list = cfg.agents?.list ?? [];
128
44
  return list.some((entry) => Boolean(entry?.heartbeat));
@@ -228,7 +144,7 @@ function resolveHeartbeatAckMaxChars(cfg, heartbeat) {
228
144
  cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
229
145
  DEFAULT_HEARTBEAT_ACK_MAX_CHARS);
230
146
  }
231
- function resolveHeartbeatSession(cfg, agentId, heartbeat) {
147
+ function resolveHeartbeatSession(cfg, agentId, heartbeat, forcedSessionKey) {
232
148
  const sessionCfg = cfg.session;
233
149
  const scope = sessionCfg?.scope ?? "per-sender";
234
150
  const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
@@ -242,6 +158,30 @@ function resolveHeartbeatSession(cfg, agentId, heartbeat) {
242
158
  if (scope === "global") {
243
159
  return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
244
160
  }
161
+ const forced = forcedSessionKey?.trim();
162
+ if (forced) {
163
+ const forcedCandidate = toAgentStoreSessionKey({
164
+ agentId: resolvedAgentId,
165
+ requestKey: forced,
166
+ mainKey: cfg.session?.mainKey,
167
+ });
168
+ const forcedCanonical = canonicalizeMainSessionAlias({
169
+ cfg,
170
+ agentId: resolvedAgentId,
171
+ sessionKey: forcedCandidate,
172
+ });
173
+ if (forcedCanonical !== "global") {
174
+ const sessionAgentId = resolveAgentIdFromSessionKey(forcedCanonical);
175
+ if (sessionAgentId === normalizeAgentId(resolvedAgentId)) {
176
+ return {
177
+ sessionKey: forcedCanonical,
178
+ storePath,
179
+ store,
180
+ entry: store[forcedCanonical],
181
+ };
182
+ }
183
+ }
184
+ }
245
185
  const trimmed = heartbeat?.session?.trim() ?? "";
246
186
  if (!trimmed) {
247
187
  return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
@@ -273,24 +213,6 @@ function resolveHeartbeatSession(cfg, agentId, heartbeat) {
273
213
  }
274
214
  return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
275
215
  }
276
- function resolveHeartbeatReplyPayload(replyResult) {
277
- if (!replyResult) {
278
- return undefined;
279
- }
280
- if (!Array.isArray(replyResult)) {
281
- return replyResult;
282
- }
283
- for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) {
284
- const payload = replyResult[idx];
285
- if (!payload) {
286
- continue;
287
- }
288
- if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) {
289
- return payload;
290
- }
291
- }
292
- return undefined;
293
- }
294
216
  function resolveHeartbeatReasoningPayloads(replyResult) {
295
217
  const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : [];
296
218
  return payloads.filter((payload) => {
@@ -324,8 +246,65 @@ async function restoreHeartbeatUpdatedAt(params) {
324
246
  nextStore[sessionKey] = { ...nextEntry, updatedAt: resolvedUpdatedAt };
325
247
  });
326
248
  }
249
+ /**
250
+ * Prune heartbeat transcript entries by truncating the file back to a previous size.
251
+ * This removes the user+assistant turns that were written during a HEARTBEAT_OK run,
252
+ * preventing context pollution from zero-information exchanges.
253
+ */
254
+ async function pruneHeartbeatTranscript(params) {
255
+ const { transcriptPath, preHeartbeatSize } = params;
256
+ if (!transcriptPath || typeof preHeartbeatSize !== "number" || preHeartbeatSize < 0) {
257
+ return;
258
+ }
259
+ try {
260
+ const stat = await fs.stat(transcriptPath);
261
+ // Only truncate if the file has grown during the heartbeat run
262
+ if (stat.size > preHeartbeatSize) {
263
+ await fs.truncate(transcriptPath, preHeartbeatSize);
264
+ }
265
+ }
266
+ catch {
267
+ // File may not exist or may have been removed - ignore errors
268
+ }
269
+ }
270
+ /**
271
+ * Get the transcript file path and its current size before a heartbeat run.
272
+ * Returns undefined values if the session or transcript doesn't exist yet.
273
+ */
274
+ async function captureTranscriptState(params) {
275
+ const { storePath, sessionKey, agentId } = params;
276
+ try {
277
+ const store = loadSessionStore(storePath);
278
+ const entry = store[sessionKey];
279
+ if (!entry?.sessionId) {
280
+ return {};
281
+ }
282
+ const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, {
283
+ agentId,
284
+ sessionsDir: path.dirname(storePath),
285
+ });
286
+ const stat = await fs.stat(transcriptPath);
287
+ return { transcriptPath, preHeartbeatSize: stat.size };
288
+ }
289
+ catch {
290
+ // Session or transcript doesn't exist yet - nothing to prune
291
+ return {};
292
+ }
293
+ }
294
+ function stripLeadingHeartbeatResponsePrefix(text, responsePrefix) {
295
+ const normalizedPrefix = responsePrefix?.trim();
296
+ if (!normalizedPrefix) {
297
+ return text;
298
+ }
299
+ // Require a boundary after the configured prefix so short prefixes like "Hi"
300
+ // do not strip the beginning of normal words like "History".
301
+ const prefixPattern = new RegExp(`^${escapeRegExp(normalizedPrefix)}(?=$|\\s|[\\p{P}\\p{S}])\\s*`, "iu");
302
+ return text.replace(prefixPattern, "");
303
+ }
327
304
  function normalizeHeartbeatReply(payload, responsePrefix, ackMaxChars) {
328
- const stripped = stripHeartbeatToken(payload.text, {
305
+ const rawText = typeof payload.text === "string" ? payload.text : "";
306
+ const textForStrip = stripLeadingHeartbeatResponsePrefix(rawText, responsePrefix);
307
+ const stripped = stripHeartbeatToken(textForStrip, {
329
308
  mode: "heartbeat",
330
309
  maxAckChars: ackMaxChars,
331
310
  });
@@ -366,17 +345,19 @@ export async function runHeartbeatOnce(opts) {
366
345
  }
367
346
  // Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
368
347
  // This saves API calls/costs when the file is effectively empty (only comments/headers).
369
- // EXCEPTION: Don't skip for exec events or cron events - they have pending system events
370
- // to process regardless of HEARTBEAT.md content.
348
+ // EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests -
349
+ // they have pending system events to process regardless of HEARTBEAT.md content.
371
350
  const isExecEventReason = opts.reason === "exec-event";
372
351
  const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
352
+ const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:"));
373
353
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
374
354
  const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
375
355
  try {
376
356
  const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
377
357
  if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
378
358
  !isExecEventReason &&
379
- !isCronEventReason) {
359
+ !isCronEventReason &&
360
+ !isWakeReason) {
380
361
  emitHeartbeatEvent({
381
362
  status: "skipped",
382
363
  reason: "empty-heartbeat-file",
@@ -389,7 +370,7 @@ export async function runHeartbeatOnce(opts) {
389
370
  // File doesn't exist or can't be read - proceed with heartbeat.
390
371
  // The LLM prompt says "if it exists" so this is expected behavior.
391
372
  }
392
- const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
373
+ const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat, opts.sessionKey);
393
374
  const previousUpdatedAt = entry?.updatedAt;
394
375
  const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
395
376
  const heartbeatAccountId = heartbeat?.accountId?.trim();
@@ -422,17 +403,25 @@ export async function runHeartbeatOnce(opts) {
422
403
  // If so, use a specialized prompt that instructs the model to relay the result
423
404
  // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
424
405
  const isExecEvent = opts.reason === "exec-event";
425
- const isCronEvent = Boolean(opts.reason?.startsWith("cron:"));
426
- const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];
427
- const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished"));
428
- const hasCronEvents = isCronEvent && pendingEvents.length > 0;
406
+ const pendingEventEntries = peekSystemEventEntries(sessionKey);
407
+ const hasTaggedCronEvents = pendingEventEntries.some((event) => event.contextKey?.startsWith("cron:"));
408
+ const shouldInspectPendingEvents = isExecEvent || isCronEventReason || hasTaggedCronEvents;
409
+ const pendingEvents = shouldInspectPendingEvents
410
+ ? pendingEventEntries.map((event) => event.text)
411
+ : [];
412
+ const cronEvents = pendingEventEntries
413
+ .filter((event) => (isCronEventReason || event.contextKey?.startsWith("cron:")) &&
414
+ isCronSystemEvent(event.text))
415
+ .map((event) => event.text);
416
+ const hasExecCompletion = pendingEvents.some(isExecCompletionEvent);
417
+ const hasCronEvents = cronEvents.length > 0;
429
418
  const prompt = hasExecCompletion
430
419
  ? EXEC_EVENT_PROMPT
431
420
  : hasCronEvents
432
- ? CRON_EVENT_PROMPT
421
+ ? buildCronEventPrompt(cronEvents)
433
422
  : resolveHeartbeatPrompt(cfg, heartbeat);
434
423
  const ctx = {
435
- Body: prompt,
424
+ Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
436
425
  From: sender,
437
426
  To: sender,
438
427
  Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat",
@@ -471,12 +460,24 @@ export async function runHeartbeatOnce(opts) {
471
460
  to: delivery.to,
472
461
  accountId: delivery.accountId,
473
462
  payloads: [{ text: heartbeatOkText }],
463
+ agentId,
474
464
  deps: opts.deps,
475
465
  });
476
466
  return true;
477
467
  };
478
468
  try {
479
- const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
469
+ // Capture transcript state before the heartbeat run so we can prune if HEARTBEAT_OK
470
+ const transcriptState = await captureTranscriptState({
471
+ storePath,
472
+ sessionKey,
473
+ agentId,
474
+ });
475
+ const heartbeatModelOverride = heartbeat?.model?.trim() || undefined;
476
+ const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true;
477
+ const replyOpts = heartbeatModelOverride
478
+ ? { isHeartbeat: true, heartbeatModelOverride, suppressToolErrorWarnings }
479
+ : { isHeartbeat: true, suppressToolErrorWarnings };
480
+ const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
480
481
  const replyPayload = resolveHeartbeatReplyPayload(replyResult);
481
482
  const includeReasoning = heartbeat?.includeReasoning === true;
482
483
  const reasoningPayloads = includeReasoning
@@ -489,6 +490,8 @@ export async function runHeartbeatOnce(opts) {
489
490
  sessionKey,
490
491
  updatedAt: previousUpdatedAt,
491
492
  });
493
+ // Prune the transcript to remove HEARTBEAT_OK turns
494
+ await pruneHeartbeatTranscript(transcriptState);
492
495
  const okSent = await maybeSendHeartbeatOk();
493
496
  emitHeartbeatEvent({
494
497
  status: "ok-empty",
@@ -521,6 +524,8 @@ export async function runHeartbeatOnce(opts) {
521
524
  sessionKey,
522
525
  updatedAt: previousUpdatedAt,
523
526
  });
527
+ // Prune the transcript to remove HEARTBEAT_OK turns
528
+ await pruneHeartbeatTranscript(transcriptState);
524
529
  const okSent = await maybeSendHeartbeatOk();
525
530
  emitHeartbeatEvent({
526
531
  status: "ok-token",
@@ -550,6 +555,8 @@ export async function runHeartbeatOnce(opts) {
550
555
  sessionKey,
551
556
  updatedAt: previousUpdatedAt,
552
557
  });
558
+ // Prune the transcript to remove duplicate heartbeat turns
559
+ await pruneHeartbeatTranscript(transcriptState);
553
560
  emitHeartbeatEvent({
554
561
  status: "skipped",
555
562
  reason: "duplicate",
@@ -627,6 +634,7 @@ export async function runHeartbeatOnce(opts) {
627
634
  channel: delivery.channel,
628
635
  to: delivery.to,
629
636
  accountId: deliveryAccountId,
637
+ agentId,
630
638
  payloads: [
631
639
  ...reasoningPayloads,
632
640
  ...(shouldSkipMain
@@ -699,6 +707,10 @@ export function startHeartbeatRunner(opts) {
699
707
  }
700
708
  return now + intervalMs;
701
709
  };
710
+ const advanceAgentSchedule = (agent, now) => {
711
+ agent.lastRunMs = now;
712
+ agent.nextDueMs = now + agent.intervalMs;
713
+ };
702
714
  const scheduleNext = () => {
703
715
  if (state.stopped) {
704
716
  return;
@@ -722,6 +734,7 @@ export function startHeartbeatRunner(opts) {
722
734
  }
723
735
  const delay = Math.max(0, nextDue - now);
724
736
  state.timer = setTimeout(() => {
737
+ state.timer = null;
725
738
  requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
726
739
  }, delay);
727
740
  state.timer.unref?.();
@@ -774,6 +787,12 @@ export function startHeartbeatRunner(opts) {
774
787
  scheduleNext();
775
788
  };
776
789
  const run = async (params) => {
790
+ if (state.stopped) {
791
+ return {
792
+ status: "skipped",
793
+ reason: "disabled",
794
+ };
795
+ }
777
796
  if (!heartbeatsEnabled) {
778
797
  return {
779
798
  status: "skipped",
@@ -787,27 +806,73 @@ export function startHeartbeatRunner(opts) {
787
806
  };
788
807
  }
789
808
  const reason = params?.reason;
809
+ const requestedAgentId = params?.agentId ? normalizeAgentId(params.agentId) : undefined;
810
+ const requestedSessionKey = params?.sessionKey?.trim() || undefined;
790
811
  const isInterval = reason === "interval";
791
812
  const startedAt = Date.now();
792
813
  const now = startedAt;
793
814
  let ran = false;
815
+ if (requestedSessionKey || requestedAgentId) {
816
+ const targetAgentId = requestedAgentId ?? resolveAgentIdFromSessionKey(requestedSessionKey);
817
+ const targetAgent = state.agents.get(targetAgentId);
818
+ if (!targetAgent) {
819
+ scheduleNext();
820
+ return { status: "skipped", reason: "disabled" };
821
+ }
822
+ try {
823
+ const res = await runOnce({
824
+ cfg: state.cfg,
825
+ agentId: targetAgent.agentId,
826
+ heartbeat: targetAgent.heartbeat,
827
+ reason,
828
+ sessionKey: requestedSessionKey,
829
+ deps: { runtime: state.runtime },
830
+ });
831
+ if (res.status !== "skipped" || res.reason !== "disabled") {
832
+ advanceAgentSchedule(targetAgent, now);
833
+ }
834
+ scheduleNext();
835
+ return res.status === "ran" ? { status: "ran", durationMs: Date.now() - startedAt } : res;
836
+ }
837
+ catch (err) {
838
+ const errMsg = formatErrorMessage(err);
839
+ log.error(`heartbeat runner: targeted runOnce threw unexpectedly: ${errMsg}`, {
840
+ error: errMsg,
841
+ });
842
+ advanceAgentSchedule(targetAgent, now);
843
+ scheduleNext();
844
+ return { status: "failed", reason: errMsg };
845
+ }
846
+ }
794
847
  for (const agent of state.agents.values()) {
795
848
  if (isInterval && now < agent.nextDueMs) {
796
849
  continue;
797
850
  }
798
- const res = await runOnce({
799
- cfg: state.cfg,
800
- agentId: agent.agentId,
801
- heartbeat: agent.heartbeat,
802
- reason,
803
- deps: { runtime: state.runtime },
804
- });
851
+ let res;
852
+ try {
853
+ res = await runOnce({
854
+ cfg: state.cfg,
855
+ agentId: agent.agentId,
856
+ heartbeat: agent.heartbeat,
857
+ reason,
858
+ deps: { runtime: state.runtime },
859
+ });
860
+ }
861
+ catch (err) {
862
+ // If runOnce throws (e.g. during session compaction), we must still
863
+ // advance the timer and call scheduleNext so heartbeats keep firing.
864
+ const errMsg = formatErrorMessage(err);
865
+ log.error(`heartbeat runner: runOnce threw unexpectedly: ${errMsg}`, { error: errMsg });
866
+ advanceAgentSchedule(agent, now);
867
+ continue;
868
+ }
805
869
  if (res.status === "skipped" && res.reason === "requests-in-flight") {
870
+ advanceAgentSchedule(agent, now);
871
+ scheduleNext();
806
872
  return res;
807
873
  }
808
874
  if (res.status !== "skipped" || res.reason !== "disabled") {
809
- agent.lastRunMs = now;
810
- agent.nextDueMs = now + agent.intervalMs;
875
+ advanceAgentSchedule(agent, now);
811
876
  }
812
877
  if (res.status === "ran") {
813
878
  ran = true;
@@ -819,11 +884,19 @@ export function startHeartbeatRunner(opts) {
819
884
  }
820
885
  return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
821
886
  };
822
- setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
887
+ const wakeHandler = async (params) => run({
888
+ reason: params.reason,
889
+ agentId: params.agentId,
890
+ sessionKey: params.sessionKey,
891
+ });
892
+ const disposeWakeHandler = setHeartbeatWakeHandler(wakeHandler);
823
893
  updateConfig(state.cfg);
824
894
  const cleanup = () => {
895
+ if (state.stopped) {
896
+ return;
897
+ }
825
898
  state.stopped = true;
826
- setHeartbeatWakeHandler(null);
899
+ disposeWakeHandler();
827
900
  if (state.timer) {
828
901
  clearTimeout(state.timer);
829
902
  }