@vellumai/assistant 0.7.1 → 0.7.2

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 (535) hide show
  1. package/ARCHITECTURE.md +32 -49
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -2
  4. package/__tests__/permissions/gateway-threshold-reader.test.ts +9 -3
  5. package/bun.lock +26 -26
  6. package/docs/architecture/security.md +20 -0
  7. package/docs/plugins.md +7 -9
  8. package/knip.json +1 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  10. package/node_modules/@vellumai/gateway-client/src/ipc-client.ts +39 -1
  11. package/node_modules/@vellumai/gateway-client/src/types.ts +11 -0
  12. package/node_modules/@vellumai/service-contracts/package.json +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +4 -0
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +107 -0
  15. package/node_modules/@vellumai/service-contracts/src/index.ts +5 -1
  16. package/node_modules/@vellumai/service-contracts/src/ingress.ts +24 -0
  17. package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +84 -0
  18. package/node_modules/@vellumai/skill-host-contracts/src/assistant-event.ts +9 -0
  19. package/node_modules/@vellumai/twilio-client/bun.lock +24 -0
  20. package/node_modules/@vellumai/twilio-client/package.json +18 -0
  21. package/node_modules/@vellumai/twilio-client/src/__tests__/twilio-client.test.ts +128 -0
  22. package/node_modules/@vellumai/twilio-client/src/index.ts +179 -0
  23. package/node_modules/@vellumai/twilio-client/tsconfig.json +20 -0
  24. package/openapi.yaml +565 -12
  25. package/package.json +6 -3
  26. package/src/__tests__/app-builder-tool-scripts.test.ts +3 -3
  27. package/src/__tests__/app-bundler.test.ts +170 -1
  28. package/src/__tests__/app-control-flow.test.ts +374 -0
  29. package/src/__tests__/app-control-no-global-cgevent.test.ts +98 -0
  30. package/src/__tests__/app-control-tool-schemas.test.ts +621 -0
  31. package/src/__tests__/app-executors.test.ts +30 -43
  32. package/src/__tests__/approval-routes-http.test.ts +23 -6
  33. package/src/__tests__/assistant-event-hub-machine-name.test.ts +146 -0
  34. package/src/__tests__/assistant-event-hub-targeted.test.ts +257 -0
  35. package/src/__tests__/assistant-event-hub.test.ts +109 -2
  36. package/src/__tests__/assistant-event.test.ts +10 -0
  37. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -2
  38. package/src/__tests__/assistant-feature-flags-integration.test.ts +11 -7
  39. package/src/__tests__/background-shell-host-bash.test.ts +14 -15
  40. package/src/__tests__/bootstrap-turn-cleanup.test.ts +44 -0
  41. package/src/__tests__/btw-routes.test.ts +13 -4
  42. package/src/__tests__/call-controller.test.ts +49 -1
  43. package/src/__tests__/call-domain.test.ts +0 -2
  44. package/src/__tests__/call-routes-http.test.ts +0 -2
  45. package/src/__tests__/channel-readiness-service.test.ts +59 -1
  46. package/src/__tests__/checker.test.ts +3 -4
  47. package/src/__tests__/config-loader-backfill.test.ts +90 -155
  48. package/src/__tests__/config-loader-platform-defaults.test.ts +196 -0
  49. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  50. package/src/__tests__/config-set-platform-guard.test.ts +48 -4
  51. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +2 -2
  52. package/src/__tests__/config-watcher.test.ts +2 -2
  53. package/src/__tests__/conversation-app-control-instantiation.test.ts +392 -0
  54. package/src/__tests__/conversation-app-control-lifecycle.test.ts +237 -0
  55. package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
  56. package/src/__tests__/conversation-lifecycle.test.ts +36 -0
  57. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +283 -0
  58. package/src/__tests__/conversation-routes-disk-view.test.ts +6 -0
  59. package/src/__tests__/conversation-routes-guardian-reply.test.ts +120 -72
  60. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  61. package/src/__tests__/conversation-slash-commands.test.ts +0 -4
  62. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +202 -0
  63. package/src/__tests__/conversation-surfaces-app-control.test.ts +317 -0
  64. package/src/__tests__/credential-execution-feature-gates.test.ts +5 -12
  65. package/src/__tests__/credential-execution-managed-contract.test.ts +3 -131
  66. package/src/__tests__/credentials-cli.test.ts +5 -12
  67. package/src/__tests__/cu-unified-flow.test.ts +185 -23
  68. package/src/__tests__/daemon-credential-client.test.ts +101 -19
  69. package/src/__tests__/db-schedule-syntax-migration.test.ts +2 -0
  70. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  71. package/src/__tests__/gateway-only-enforcement.test.ts +0 -1
  72. package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -2
  73. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +0 -2
  74. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -1
  75. package/src/__tests__/heartbeat-service.test.ts +718 -1
  76. package/src/__tests__/helpers/call-route-handler.ts +7 -1
  77. package/src/__tests__/host-app-control-proxy.test.ts +602 -0
  78. package/src/__tests__/host-app-control-routes.test.ts +263 -0
  79. package/src/__tests__/host-bash-proxy.test.ts +246 -47
  80. package/src/__tests__/host-bash-routes.test.ts +294 -0
  81. package/src/__tests__/host-browser-proxy.test.ts +24 -22
  82. package/src/__tests__/host-browser-routes.test.ts +39 -13
  83. package/src/__tests__/host-cu-proxy.test.ts +41 -52
  84. package/src/__tests__/host-cu-routes-targeted.test.ts +300 -0
  85. package/src/__tests__/host-file-edit-tool.test.ts +47 -1
  86. package/src/__tests__/host-file-proxy-targeted.test.ts +339 -0
  87. package/src/__tests__/host-file-proxy.test.ts +37 -43
  88. package/src/__tests__/host-file-read-tool.test.ts +17 -0
  89. package/src/__tests__/host-file-routes-targeted.test.ts +262 -0
  90. package/src/__tests__/host-file-write-tool.test.ts +42 -1
  91. package/src/__tests__/host-proxy-base.test.ts +312 -0
  92. package/src/__tests__/host-shell-tool.test.ts +22 -4
  93. package/src/__tests__/host-transfer-proxy-targeted.test.ts +583 -0
  94. package/src/__tests__/host-transfer-proxy.test.ts +121 -22
  95. package/src/__tests__/host-transfer-routes-targeted.test.ts +447 -0
  96. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  97. package/src/__tests__/identity-intro-cache.test.ts +29 -0
  98. package/src/__tests__/identity-routes.test.ts +103 -1
  99. package/src/__tests__/init-feature-flag-overrides.test.ts +26 -3
  100. package/src/__tests__/inline-command-runner.test.ts +0 -1
  101. package/src/__tests__/inline-skill-load-permissions.test.ts +5 -11
  102. package/src/__tests__/integration-status.test.ts +85 -5
  103. package/src/__tests__/intent-routing.test.ts +0 -1
  104. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +95 -5
  105. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +17 -0
  106. package/src/__tests__/managed-skill-lifecycle.test.ts +0 -1
  107. package/src/__tests__/mcp-auth-routes.test.ts +197 -0
  108. package/src/__tests__/mcp-cli.test.ts +338 -2
  109. package/src/__tests__/memory-jobs-worker-lanes.test.ts +188 -0
  110. package/src/__tests__/migration-import-commit-http.test.ts +108 -2
  111. package/src/__tests__/mock-gateway-ipc.ts +1 -0
  112. package/src/__tests__/oauth-cli.test.ts +0 -2
  113. package/src/__tests__/oauth2-gateway-transport.test.ts +0 -1
  114. package/src/__tests__/persistence-secret-redaction.test.ts +299 -0
  115. package/src/__tests__/platform-bash-auto-approve.test.ts +5 -9
  116. package/src/__tests__/prechat-onboarding-contract.test.ts +3 -1
  117. package/src/__tests__/process-message-background-slack.test.ts +2 -0
  118. package/src/__tests__/provider-commit-message-generator.test.ts +0 -1
  119. package/src/__tests__/public-ingress-urls.test.ts +97 -0
  120. package/src/__tests__/require-fresh-approval.test.ts +0 -1
  121. package/src/__tests__/retry-backoff.test.ts +87 -0
  122. package/src/__tests__/runtime-events-sse.test.ts +10 -6
  123. package/src/__tests__/sanitize-config-for-transfer.test.ts +24 -2
  124. package/src/__tests__/schedule-retry.test.ts +715 -0
  125. package/src/__tests__/script-proxy-mitm-handler.test.ts +1 -1
  126. package/src/__tests__/secret-ingress-http.test.ts +1 -0
  127. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  128. package/src/__tests__/shell-tool-proxy-mode.test.ts +0 -1
  129. package/src/__tests__/skill-feature-flags.test.ts +43 -41
  130. package/src/__tests__/skill-load-feature-flag.test.ts +13 -14
  131. package/src/__tests__/skill-load-inline-command.test.ts +0 -51
  132. package/src/__tests__/skill-load-inline-includes.test.ts +0 -43
  133. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  134. package/src/__tests__/skill-script-runner-sandbox.test.ts +0 -1
  135. package/src/__tests__/slack-channel-config.test.ts +9 -14
  136. package/src/__tests__/system-prompt-ask-mode.test.ts +0 -1
  137. package/src/__tests__/system-prompt.test.ts +0 -1
  138. package/src/__tests__/telegram-config.test.ts +0 -1
  139. package/src/__tests__/test-preload.ts +8 -0
  140. package/src/__tests__/tool-approval-handler.test.ts +3 -4
  141. package/src/__tests__/tool-audit-listener.test.ts +48 -0
  142. package/src/__tests__/tool-execute-pipeline.test.ts +0 -1
  143. package/src/__tests__/tool-execution-abort-cleanup.test.ts +0 -1
  144. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  145. package/src/__tests__/tool-executor.test.ts +0 -1
  146. package/src/__tests__/twilio-config.test.ts +3 -16
  147. package/src/__tests__/twilio-routes.test.ts +3 -5
  148. package/src/__tests__/twilio-validation.test.ts +93 -0
  149. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +1 -4
  150. package/src/__tests__/verification-control-plane-policy.test.ts +2 -4
  151. package/src/__tests__/voice-ingress-preflight.test.ts +19 -0
  152. package/src/__tests__/workspace-migration-006-services-config.test.ts +3 -2
  153. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +1 -5
  154. package/src/__tests__/workspace-migration-down-functions.test.ts +8 -8
  155. package/src/__tests__/workspace-migration-unify-llm-callsite-configs.test.ts +10 -6
  156. package/src/backup/__tests__/paths.test.ts +0 -22
  157. package/src/backup/__tests__/restore.test.ts +51 -151
  158. package/src/backup/paths.ts +2 -18
  159. package/src/backup/restore.ts +107 -231
  160. package/src/bundler/app-bundler.ts +51 -3
  161. package/src/calls/relay-server.ts +4 -44
  162. package/src/calls/twilio-config.ts +2 -17
  163. package/src/calls/twilio-rest.ts +33 -105
  164. package/src/calls/twilio-routes.ts +11 -12
  165. package/src/channels/types.ts +8 -7
  166. package/src/cli/commands/__tests__/backup.test.ts +6 -277
  167. package/src/cli/commands/__tests__/gateway.test.ts +288 -0
  168. package/src/cli/commands/__tests__/memory-v2.test.ts +4 -0
  169. package/src/cli/commands/__tests__/webhooks.test.ts +0 -1
  170. package/src/cli/commands/backup.ts +6 -331
  171. package/src/cli/commands/clients.ts +36 -37
  172. package/src/cli/commands/contacts.ts +73 -0
  173. package/src/cli/commands/conversations.ts +2 -5
  174. package/src/cli/commands/credentials.ts +15 -7
  175. package/src/cli/commands/domain.ts +66 -15
  176. package/src/cli/commands/gateway.ts +183 -0
  177. package/src/cli/commands/keys.ts +9 -6
  178. package/src/cli/commands/mcp.ts +116 -156
  179. package/src/cli/commands/memory-v2.ts +296 -1
  180. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -1
  181. package/src/cli/commands/platform/__tests__/connect.test.ts +0 -2
  182. package/src/cli/commands/platform/__tests__/disconnect.test.ts +0 -2
  183. package/src/cli/commands/platform/__tests__/status.test.ts +13 -15
  184. package/src/cli/commands/platform/disconnect.ts +5 -4
  185. package/src/cli/commands/platform/index.ts +0 -18
  186. package/src/cli/lib/daemon-credential-client.ts +110 -28
  187. package/src/cli/program.ts +2 -0
  188. package/src/config/assistant-feature-flags.ts +67 -10
  189. package/src/config/bundled-skills/acp/SKILL.md +6 -0
  190. package/src/config/bundled-skills/acp/TOOLS.json +1 -22
  191. package/src/config/bundled-skills/app-builder/SKILL.md +14 -109
  192. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -28
  193. package/src/config/bundled-skills/app-builder/tools/app-create.ts +1 -10
  194. package/src/config/bundled-skills/app-control/SKILL.md +75 -0
  195. package/src/config/bundled-skills/app-control/TOOLS.json +299 -0
  196. package/src/config/bundled-skills/app-control/tools/app-control-click.ts +12 -0
  197. package/src/config/bundled-skills/app-control/tools/app-control-combo.ts +12 -0
  198. package/src/config/bundled-skills/app-control/tools/app-control-drag.ts +12 -0
  199. package/src/config/bundled-skills/app-control/tools/app-control-observe.ts +12 -0
  200. package/src/config/bundled-skills/app-control/tools/app-control-press.ts +12 -0
  201. package/src/config/bundled-skills/app-control/tools/app-control-sequence.ts +12 -0
  202. package/src/config/bundled-skills/app-control/tools/app-control-start.ts +12 -0
  203. package/src/config/bundled-skills/app-control/tools/app-control-stop.ts +12 -0
  204. package/src/config/bundled-skills/app-control/tools/app-control-type.ts +12 -0
  205. package/src/config/bundled-skills/computer-use/SKILL.md +6 -0
  206. package/src/config/bundled-skills/computer-use/TOOLS.json +67 -43
  207. package/src/config/bundled-skills/contacts/TOOLS.json +0 -16
  208. package/src/config/bundled-skills/document/TOOLS.json +0 -8
  209. package/src/config/bundled-skills/followups/TOOLS.json +0 -12
  210. package/src/config/bundled-skills/image-studio/SKILL.md +4 -0
  211. package/src/config/bundled-skills/image-studio/TOOLS.json +0 -4
  212. package/src/config/bundled-skills/media-processing/TOOLS.json +0 -24
  213. package/src/config/bundled-skills/messaging/TOOLS.json +0 -40
  214. package/src/config/bundled-skills/phone-calls/TOOLS.json +0 -12
  215. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +19 -4
  216. package/src/config/bundled-skills/playbooks/TOOLS.json +0 -16
  217. package/src/config/bundled-skills/schedule/TOOLS.json +14 -14
  218. package/src/config/bundled-skills/sequences/TOOLS.json +0 -36
  219. package/src/config/bundled-skills/settings/SKILL.md +4 -0
  220. package/src/config/bundled-skills/settings/TOOLS.json +0 -12
  221. package/src/config/bundled-skills/skill-management/SKILL.md +6 -0
  222. package/src/config/bundled-skills/skill-management/TOOLS.json +0 -8
  223. package/src/config/bundled-skills/subagent/SKILL.md +6 -2
  224. package/src/config/bundled-skills/subagent/TOOLS.json +0 -20
  225. package/src/config/bundled-skills/transcribe/SKILL.md +4 -0
  226. package/src/config/bundled-skills/transcribe/TOOLS.json +0 -4
  227. package/src/config/bundled-tool-registry.ts +21 -0
  228. package/src/config/env-registry.ts +0 -2
  229. package/src/config/env.ts +19 -12
  230. package/src/config/feature-flag-registry.json +21 -133
  231. package/src/config/loader.ts +73 -99
  232. package/src/config/sanitize-for-transfer.ts +2 -0
  233. package/src/config/schemas/__tests__/memory-lifecycle.test.ts +80 -0
  234. package/src/config/schemas/__tests__/memory-v2.test.ts +7 -4
  235. package/src/config/schemas/calls.ts +0 -9
  236. package/src/config/schemas/heartbeat.ts +63 -0
  237. package/src/config/schemas/ingress.ts +10 -6
  238. package/src/config/schemas/llm.ts +5 -10
  239. package/src/config/schemas/memory-lifecycle.ts +77 -24
  240. package/src/config/schemas/memory-v2.ts +48 -4
  241. package/src/config/schemas/platform.ts +6 -0
  242. package/src/config/schemas/services.ts +1 -15
  243. package/src/config/schemas/skills.ts +0 -6
  244. package/src/config/seed-inference-profiles.ts +1 -1
  245. package/src/contacts/contact-store.ts +0 -30
  246. package/src/contacts/contacts-write.ts +0 -27
  247. package/src/context/window-manager.ts +1 -2
  248. package/src/credential-execution/feature-gates.ts +10 -10
  249. package/src/credential-execution/process-manager.ts +12 -41
  250. package/src/daemon/__tests__/conversation-tool-setup.test.ts +126 -5
  251. package/src/daemon/bootstrap-turn-cleanup.ts +45 -0
  252. package/src/daemon/config-watcher.ts +4 -3
  253. package/src/daemon/conversation-agent-loop-handlers.ts +21 -3
  254. package/src/daemon/conversation-agent-loop.ts +32 -28
  255. package/src/daemon/conversation-lifecycle.ts +8 -1
  256. package/src/daemon/conversation-process.ts +16 -11
  257. package/src/daemon/conversation-runtime-assembly.ts +2 -2
  258. package/src/daemon/conversation-surfaces.ts +125 -4
  259. package/src/daemon/conversation-tool-setup.ts +16 -55
  260. package/src/daemon/conversation.ts +21 -2
  261. package/src/daemon/doordash-steps.ts +1 -1
  262. package/src/daemon/handlers/shared.ts +4 -1
  263. package/src/daemon/host-app-control-proxy.ts +293 -0
  264. package/src/daemon/host-bash-proxy.ts +84 -74
  265. package/src/daemon/host-browser-proxy.ts +67 -82
  266. package/src/daemon/host-cu-proxy.ts +81 -86
  267. package/src/daemon/host-file-proxy.ts +93 -69
  268. package/src/daemon/host-proxy-base.ts +294 -0
  269. package/src/daemon/host-proxy-preactivation.ts +82 -0
  270. package/src/daemon/host-transfer-proxy.ts +247 -129
  271. package/src/daemon/lifecycle.ts +115 -117
  272. package/src/daemon/message-protocol.ts +3 -8
  273. package/src/daemon/message-types/contacts.ts +23 -1
  274. package/src/daemon/message-types/conversations.ts +11 -8
  275. package/src/daemon/message-types/host-app-control.ts +150 -0
  276. package/src/daemon/message-types/host-bash.ts +4 -0
  277. package/src/daemon/message-types/host-cu.ts +2 -0
  278. package/src/daemon/message-types/host-file.ts +4 -0
  279. package/src/daemon/message-types/host-transfer.ts +3 -0
  280. package/src/daemon/message-types/schedules.ts +8 -3
  281. package/src/daemon/message-types/skills.ts +2 -2
  282. package/src/daemon/process-message.ts +18 -1
  283. package/src/daemon/shutdown-handlers.ts +0 -3
  284. package/src/daemon/tool-setup-types.ts +51 -0
  285. package/src/daemon/tool-side-effects.ts +1 -1
  286. package/src/events/tool-audit-listener.ts +2 -1
  287. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +15 -7
  288. package/src/heartbeat/__tests__/heartbeat-run-store.test.ts +216 -0
  289. package/src/heartbeat/heartbeat-run-store.ts +236 -0
  290. package/src/heartbeat/heartbeat-service.ts +280 -49
  291. package/src/home/__tests__/post-connect-feed.test.ts +99 -0
  292. package/src/home/__tests__/relationship-state-writer.test.ts +11 -9
  293. package/src/home/__tests__/suggested-prompts.test.ts +89 -0
  294. package/src/home/post-connect-feed.ts +68 -0
  295. package/src/home/relationship-state-writer.ts +17 -92
  296. package/src/home/suggested-prompts.ts +46 -10
  297. package/src/inbound/public-ingress-urls.ts +32 -34
  298. package/src/ipc/__tests__/route-error-envelope.test.ts +80 -0
  299. package/src/ipc/assistant-server.ts +14 -1
  300. package/src/ipc/cli-client.ts +32 -1
  301. package/src/live-voice/live-voice-metrics.ts +10 -10
  302. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +304 -0
  303. package/src/mcp/mcp-auth-orchestrator.ts +213 -0
  304. package/src/mcp/mcp-auth-state.ts +133 -0
  305. package/src/mcp/mcp-oauth-provider.ts +19 -0
  306. package/src/memory/__tests__/jobs-store-job-classes.test.ts +24 -0
  307. package/src/memory/__tests__/qdrant-client-sentinel.test.ts +49 -0
  308. package/src/memory/__tests__/sparse-tokenize.test.ts +66 -0
  309. package/src/memory/anisotropy.test.ts +247 -0
  310. package/src/memory/anisotropy.ts +443 -0
  311. package/src/memory/auto-analysis-constants.ts +17 -0
  312. package/src/memory/auto-analysis-guard.ts +5 -15
  313. package/src/memory/canonical-guardian-store.ts +7 -7
  314. package/src/memory/context-search/__tests__/agent-runner-redaction.test.ts +122 -0
  315. package/src/memory/context-search/agent-protocol.ts +6 -6
  316. package/src/memory/context-search/agent-runner.ts +32 -7
  317. package/src/memory/context-search/sources/memory-v2.ts +17 -5
  318. package/src/memory/conversation-crud.ts +1 -1
  319. package/src/memory/conversation-key-store.ts +2 -15
  320. package/src/memory/db-init.ts +4 -0
  321. package/src/memory/embedding-backend.ts +9 -21
  322. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +49 -4
  323. package/src/memory/graph/conversation-graph-memory.ts +1 -24
  324. package/src/memory/graph/graph-search.ts +8 -0
  325. package/src/memory/graph/retriever.ts +28 -0
  326. package/src/memory/graph/tools.ts +1 -1
  327. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +8 -2
  328. package/src/memory/jobs/embed-concept-page.ts +28 -2
  329. package/src/memory/jobs/embed-pkb-file.test.ts +2 -2
  330. package/src/memory/jobs-store.ts +66 -22
  331. package/src/memory/jobs-worker.ts +112 -63
  332. package/src/memory/memory-v2-activation-log-store.ts +1 -1
  333. package/src/memory/migrations/237-heartbeat-runs.ts +45 -0
  334. package/src/memory/migrations/238-schedule-retry-policy.ts +20 -0
  335. package/src/memory/migrations/index.ts +5 -0
  336. package/src/memory/migrations/registry.ts +8 -0
  337. package/src/memory/pkb/pkb-search.ts +7 -0
  338. package/src/memory/qdrant-client.ts +50 -20
  339. package/src/memory/schema/infrastructure.ts +15 -0
  340. package/src/memory/search/semantic.ts +7 -0
  341. package/src/memory/sparse-tokenize.ts +49 -0
  342. package/src/memory/v2/__tests__/activation.test.ts +77 -95
  343. package/src/memory/v2/__tests__/injection.test.ts +43 -21
  344. package/src/memory/v2/__tests__/sim.test.ts +166 -6
  345. package/src/memory/v2/__tests__/sparse-bm25.test.ts +292 -0
  346. package/src/memory/v2/__tests__/static-context.test.ts +0 -1
  347. package/src/memory/v2/activation.ts +69 -88
  348. package/src/memory/v2/consolidation-job.ts +3 -5
  349. package/src/memory/v2/constants.ts +7 -0
  350. package/src/memory/v2/injection.ts +86 -53
  351. package/src/memory/v2/prompts/consolidation.ts +312 -91
  352. package/src/memory/v2/qdrant.ts +99 -1
  353. package/src/memory/v2/sim.ts +126 -16
  354. package/src/memory/v2/skill-qdrant.ts +12 -3
  355. package/src/memory/v2/skill-store.ts +16 -1
  356. package/src/memory/v2/sparse-bm25.ts +245 -0
  357. package/src/memory/v2/static-context.ts +6 -5
  358. package/src/messaging/providers/gmail/types.ts +0 -49
  359. package/src/messaging/providers/slack/adapter.ts +1 -31
  360. package/src/messaging/providers/slack/types.ts +0 -32
  361. package/src/notifications/README.md +10 -10
  362. package/src/notifications/broadcaster.ts +1 -1
  363. package/src/notifications/guardian-question-mode.ts +5 -5
  364. package/src/oauth/connect-orchestrator.ts +4 -0
  365. package/src/oauth/credential-token-resolver.ts +1 -3
  366. package/src/oauth/manual-token-connection.ts +0 -4
  367. package/src/outbound-proxy/index.ts +1 -37
  368. package/src/outbound-proxy/logging.ts +1 -1
  369. package/src/outbound-proxy/policy.ts +6 -5
  370. package/src/outbound-proxy/router.ts +2 -1
  371. package/src/permissions/approval-policy.test.ts +6 -275
  372. package/src/permissions/approval-policy.ts +0 -51
  373. package/src/permissions/checker.test.ts +0 -1
  374. package/src/permissions/checker.ts +3 -17
  375. package/src/permissions/gateway-threshold-reader.ts +2 -0
  376. package/src/permissions/prompter.ts +34 -1
  377. package/src/permissions/secret-prompter.ts +6 -2
  378. package/src/prompts/bootstrap-cleanup.ts +27 -0
  379. package/src/prompts/system-prompt.ts +3 -18
  380. package/src/prompts/templates/SOUL.md +13 -1
  381. package/src/providers/speech-to-text/provider-catalog.ts +7 -8
  382. package/src/runtime/assistant-event-hub.ts +118 -96
  383. package/src/runtime/assistant-event.ts +1 -0
  384. package/src/runtime/auth/__tests__/middleware.test.ts +11 -56
  385. package/src/runtime/auth/middleware.ts +0 -96
  386. package/src/runtime/auth/route-policy.ts +19 -0
  387. package/src/runtime/btw-sidechain.ts +2 -3
  388. package/src/runtime/channel-invite-transport.ts +2 -48
  389. package/src/runtime/channel-invite-transports/email.ts +1 -1
  390. package/src/runtime/channel-invite-transports/slack.ts +1 -1
  391. package/src/runtime/channel-invite-transports/telegram.ts +1 -1
  392. package/src/runtime/channel-invite-transports/voice.ts +1 -1
  393. package/src/runtime/channel-invite-transports/whatsapp.ts +1 -1
  394. package/src/runtime/channel-invite-types.ts +54 -0
  395. package/src/runtime/channel-readiness-service.ts +32 -13
  396. package/src/runtime/http-server.ts +3 -329
  397. package/src/runtime/http-types.ts +0 -5
  398. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +413 -0
  399. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +260 -0
  400. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +189 -0
  401. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +153 -1
  402. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +451 -0
  403. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -0
  404. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +515 -0
  405. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +437 -0
  406. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +319 -0
  407. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +51 -1
  408. package/src/runtime/migrations/migration-transport.ts +7 -7
  409. package/src/runtime/migrations/vbundle-builder.ts +327 -60
  410. package/src/runtime/migrations/vbundle-import-analyzer.ts +4 -4
  411. package/src/runtime/migrations/vbundle-import-policy.ts +172 -0
  412. package/src/runtime/migrations/vbundle-importer.ts +245 -68
  413. package/src/runtime/migrations/vbundle-streaming-importer.ts +326 -35
  414. package/src/runtime/migrations/vbundle-streaming-validator.ts +157 -4
  415. package/src/runtime/migrations/vbundle-tar-stream.ts +15 -6
  416. package/src/runtime/migrations/vbundle-validator.ts +114 -0
  417. package/src/runtime/pending-interactions.ts +35 -9
  418. package/src/runtime/routes/__tests__/backup-routes.test.ts +22 -150
  419. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +98 -0
  420. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +242 -0
  421. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +112 -0
  422. package/src/runtime/routes/approval-interception-types.ts +13 -0
  423. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +1 -1
  424. package/src/runtime/routes/backup-routes.ts +15 -38
  425. package/src/runtime/routes/btw-routes.ts +14 -37
  426. package/src/runtime/routes/client-routes.ts +1 -0
  427. package/src/runtime/routes/contact-prompt-routes.ts +183 -0
  428. package/src/runtime/routes/conversation-query-routes.ts +36 -1
  429. package/src/runtime/routes/conversation-routes.ts +30 -13
  430. package/src/runtime/routes/document-pdf-renderer.ts +165 -0
  431. package/src/runtime/routes/documents-routes.ts +30 -0
  432. package/src/runtime/routes/errors.ts +19 -4
  433. package/src/runtime/routes/events-routes.ts +12 -6
  434. package/src/runtime/routes/gateway-log-routes.ts +79 -0
  435. package/src/runtime/routes/guardian-approval-interception.ts +2 -8
  436. package/src/runtime/routes/heartbeat-routes.ts +103 -38
  437. package/src/runtime/routes/host-app-control-routes.ts +134 -0
  438. package/src/runtime/routes/host-bash-routes.ts +36 -6
  439. package/src/runtime/routes/host-browser-routes.ts +108 -13
  440. package/src/runtime/routes/host-cu-routes.ts +44 -14
  441. package/src/runtime/routes/host-file-routes.ts +33 -10
  442. package/src/runtime/routes/host-transfer-routes.ts +64 -24
  443. package/src/runtime/routes/http-adapter.ts +1 -0
  444. package/src/runtime/routes/identity-intro-cache.ts +30 -0
  445. package/src/runtime/routes/identity-routes.ts +15 -43
  446. package/src/runtime/routes/inbound-message-handler.ts +1 -9
  447. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -7
  448. package/src/runtime/routes/inbound-stages/edit-intercept.ts +0 -8
  449. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +0 -20
  450. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +5 -13
  451. package/src/runtime/routes/index.ts +8 -0
  452. package/src/runtime/routes/mcp-auth-routes.ts +132 -0
  453. package/src/runtime/routes/memory-item-routes.ts +10 -12
  454. package/src/runtime/routes/memory-v2-routes.ts +441 -1
  455. package/src/runtime/routes/migration-routes.ts +96 -0
  456. package/src/runtime/routes/schedule-routes.ts +7 -0
  457. package/src/runtime/verification-templates.ts +4 -7
  458. package/src/schedule/integration-status.ts +66 -2
  459. package/src/schedule/recurrence-engine.ts +4 -1
  460. package/src/schedule/retry-backoff.ts +18 -0
  461. package/src/schedule/retry-policy.ts +82 -0
  462. package/src/schedule/schedule-recovery.ts +64 -0
  463. package/src/schedule/schedule-store.ts +106 -2
  464. package/src/schedule/scheduler-types.ts +25 -0
  465. package/src/schedule/scheduler.ts +63 -38
  466. package/src/security/oauth-callback-registry.ts +8 -0
  467. package/src/sequence/analytics.ts +5 -5
  468. package/src/sequence/engine.ts +1 -1
  469. package/src/skills/catalog-files.ts +2 -8
  470. package/src/skills/include-graph.ts +5 -5
  471. package/src/skills/remote-skill-policy.ts +5 -5
  472. package/src/skills/skill-file-provider.ts +1 -1
  473. package/src/skills/skill-file-types.ts +13 -0
  474. package/src/skills/skillssh-audit-types.ts +28 -0
  475. package/src/skills/skillssh-registry.ts +8 -21
  476. package/src/telemetry/types.ts +2 -0
  477. package/src/telemetry/usage-telemetry-reporter.test.ts +21 -0
  478. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  479. package/src/tools/app-control/skill-proxy-bridge.ts +28 -0
  480. package/src/tools/apps/executors.ts +56 -69
  481. package/src/tools/browser/__tests__/browser-status.test.ts +21 -18
  482. package/src/tools/browser/browser-execution.ts +2 -2
  483. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +55 -4
  484. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +12 -6
  485. package/src/tools/browser/cdp-client/factory.ts +23 -24
  486. package/src/tools/browser/cdp-client/index.ts +1 -14
  487. package/src/tools/computer-use/definitions.ts +42 -20
  488. package/src/tools/executor.ts +2 -0
  489. package/src/tools/host-filesystem/edit.ts +26 -0
  490. package/src/tools/host-filesystem/read.ts +26 -0
  491. package/src/tools/host-filesystem/transfer.ts +31 -1
  492. package/src/tools/host-filesystem/write.ts +26 -0
  493. package/src/tools/host-terminal/host-shell.ts +58 -0
  494. package/src/tools/schedule/create.ts +6 -0
  495. package/src/tools/schedule/list.ts +2 -0
  496. package/src/tools/schedule/update.ts +10 -0
  497. package/src/tools/shared/filesystem/file-ops-service.ts +2 -0
  498. package/src/tools/shared/filesystem/path-policy.ts +25 -1
  499. package/src/tools/skills/load.ts +0 -32
  500. package/src/tools/tool-approval-handler.ts +1 -5
  501. package/src/tools/types.ts +4 -0
  502. package/src/usage/pricing.ts +1 -1
  503. package/src/workspace/hatched-date.ts +86 -0
  504. package/src/workspace/migrations/003-seed-device-id.ts +1 -1
  505. package/src/workspace/migrations/006-services-config.ts +8 -5
  506. package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +3 -9
  507. package/src/workspace/migrations/021-move-signals-to-workspace.ts +4 -10
  508. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +4 -10
  509. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +4 -11
  510. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +3 -10
  511. package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +3 -2
  512. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +2 -1
  513. package/src/workspace/migrations/059-move-pid-to-workspace.ts +3 -8
  514. package/src/workspace/migrations/061-move-backup-key-to-workspace.ts +3 -8
  515. package/src/workspace/migrations/AGENTS.md +1 -1
  516. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -10
  517. package/src/workspace/migrations/utils.ts +21 -0
  518. package/src/__tests__/host-browser-e2e-cloud.test.ts +0 -443
  519. package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +0 -226
  520. package/src/__tests__/host-browser-ws-events-e2e.test.ts +0 -427
  521. package/src/__tests__/twilio-rest.test.ts +0 -34
  522. package/src/backup/__tests__/backup-key.test.ts +0 -152
  523. package/src/backup/__tests__/backup-worker.test.ts +0 -782
  524. package/src/backup/__tests__/offsite-writer.test.ts +0 -641
  525. package/src/backup/__tests__/stream-crypt.test.ts +0 -228
  526. package/src/backup/backup-key.ts +0 -137
  527. package/src/backup/backup-worker.ts +0 -472
  528. package/src/backup/offsite-writer.ts +0 -222
  529. package/src/backup/stream-crypt.ts +0 -263
  530. package/src/daemon/message-types/pairing.ts +0 -58
  531. package/src/outbound-proxy/config.ts +0 -20
  532. package/src/outbound-proxy/health.ts +0 -18
  533. package/src/outbound-proxy/types.ts +0 -150
  534. package/src/runtime/capability-tokens.ts +0 -190
  535. package/src/signals/mcp-reload.ts +0 -18
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { getConfig } from "../config/loader.js";
5
+ import type { HeartbeatConfig } from "../config/schemas/heartbeat.js";
5
6
  import type { HeartbeatAlert } from "../daemon/message-protocol.js";
6
7
  import { processMessage } from "../daemon/process-message.js";
7
8
  import { emitFeedEvent } from "../home/emit-feed-event.js";
@@ -13,10 +14,20 @@ import {
13
14
  resolveGuardianPersona,
14
15
  } from "../prompts/persona-resolver.js";
15
16
  import { isTemplateContent } from "../prompts/system-prompt.js";
17
+ import { computeNextRunAt } from "../schedule/recurrence-engine.js";
16
18
  import { readTextFileSync } from "../util/fs.js";
17
19
  import { getLogger } from "../util/logger.js";
18
20
  import { getWorkspaceDir, getWorkspacePromptPath } from "../util/platform.js";
19
21
  import { stripCommentLines } from "../util/strip-comment-lines.js";
22
+ import {
23
+ completeHeartbeatRun,
24
+ insertPendingHeartbeatRun,
25
+ markStaleRunningAsError,
26
+ markStaleRunsAsMissed,
27
+ skipHeartbeatRun,
28
+ startHeartbeatRun,
29
+ supersedePendingRun,
30
+ } from "./heartbeat-run-store.js";
20
31
 
21
32
  const log = getLogger("heartbeat-check");
22
33
 
@@ -100,10 +111,20 @@ export class HeartbeatService {
100
111
  }
101
112
 
102
113
  private readonly deps: HeartbeatDeps;
103
- private timer: ReturnType<typeof setInterval> | null = null;
114
+ private timer:
115
+ | ReturnType<typeof setInterval>
116
+ | ReturnType<typeof setTimeout>
117
+ | null = null;
104
118
  private activeRun: Promise<void> | null = null;
105
119
  private _lastRunAt: number | null = null;
106
120
  private _nextRunAt: number | null = null;
121
+ private cronMode = false;
122
+ private stopped = false;
123
+ private configEpoch = 0;
124
+ private _pendingRunId: string | null = null;
125
+ private _startupMissedCount = 0;
126
+ private _startupCrashedCount = 0;
127
+ private _hasRunStartupRecovery = false;
107
128
 
108
129
  constructor(deps: HeartbeatDeps) {
109
130
  this.deps = deps;
@@ -121,6 +142,7 @@ export class HeartbeatService {
121
142
  }
122
143
 
123
144
  start(): void {
145
+ this.stopped = false;
124
146
  const config = getConfig().heartbeat;
125
147
  if (!config.enabled) {
126
148
  log.info("Heartbeat disabled by config");
@@ -129,7 +151,57 @@ export class HeartbeatService {
129
151
  }
130
152
  if (this.timer) return;
131
153
 
132
- log.info({ intervalMs: config.intervalMs }, "Heartbeat service started");
154
+ if (!this._hasRunStartupRecovery) {
155
+ this._hasRunStartupRecovery = true;
156
+ try {
157
+ this._startupMissedCount = markStaleRunsAsMissed();
158
+ this._startupCrashedCount = markStaleRunningAsError();
159
+ } catch (err) {
160
+ log.error({ err }, "Failed to recover stale heartbeat runs on startup");
161
+ }
162
+ if (this._startupMissedCount > 0 || this._startupCrashedCount > 0) {
163
+ log.info(
164
+ {
165
+ missedCount: this._startupMissedCount,
166
+ crashedCount: this._startupCrashedCount,
167
+ },
168
+ "Recovered stale heartbeat runs on startup",
169
+ );
170
+
171
+ const total = this._startupMissedCount + this._startupCrashedCount;
172
+ const today = new Date().toISOString().split("T")[0];
173
+ void emitFeedEvent({
174
+ source: "assistant",
175
+ title: "Heartbeat Runs Missed",
176
+ summary: `${total} heartbeat run${total > 1 ? "s were" : " was"} missed while the assistant was offline.`,
177
+ dedupKey: `heartbeat:missed:${today}`,
178
+ priority: 55,
179
+ urgency: "high",
180
+ }).catch((err) => {
181
+ log.warn({ err }, "Failed to emit missed heartbeat feed event");
182
+ });
183
+ }
184
+ }
185
+
186
+ if (config.cronExpression != null) {
187
+ this.cronMode = true;
188
+ this.scheduleNextCronRun(config);
189
+ } else {
190
+ this.startIntervalMode(config);
191
+ }
192
+ }
193
+
194
+ private startIntervalMode(config: HeartbeatConfig): void {
195
+ this.cronMode = false;
196
+ if (this.timer) {
197
+ clearTimeout(this.timer as ReturnType<typeof setTimeout>);
198
+ clearInterval(this.timer as ReturnType<typeof setInterval>);
199
+ this.timer = null;
200
+ }
201
+ log.info(
202
+ { intervalMs: config.intervalMs },
203
+ "Heartbeat service started (interval mode)",
204
+ );
133
205
  this.scheduleNextRun(config.intervalMs);
134
206
  this.timer = setInterval(() => {
135
207
  this.runOnce().catch((err) => {
@@ -138,13 +210,69 @@ export class HeartbeatService {
138
210
  }, config.intervalMs);
139
211
  }
140
212
 
213
+ private scheduleNextCronRun(config: HeartbeatConfig): void {
214
+ if (this.stopped) return;
215
+ try {
216
+ const nextRunAt = computeNextRunAt({
217
+ syntax: "cron",
218
+ expression: config.cronExpression!,
219
+ timezone: config.timezone,
220
+ });
221
+ this._nextRunAt = nextRunAt;
222
+ if (this.timer) {
223
+ clearTimeout(this.timer as ReturnType<typeof setTimeout>);
224
+ clearInterval(this.timer as ReturnType<typeof setInterval>);
225
+ this.timer = null;
226
+ }
227
+ const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
228
+ const delayMs = Math.max(0, nextRunAt - Date.now());
229
+ const epoch = this.configEpoch;
230
+ if (delayMs > MAX_TIMEOUT_MS) {
231
+ // Re-evaluate after 24h — the actual cron time is still far away
232
+ this.timer = setTimeout(() => {
233
+ if (this.configEpoch === epoch) {
234
+ this.scheduleNextCronRun(getConfig().heartbeat);
235
+ }
236
+ }, MAX_TIMEOUT_MS);
237
+ } else {
238
+ this.timer = setTimeout(() => {
239
+ this.runOnce()
240
+ .catch((err) => log.error({ err }, "Cron heartbeat failed"))
241
+ .finally(() => {
242
+ if (this.configEpoch === epoch) {
243
+ this.scheduleNextCronRun(getConfig().heartbeat);
244
+ }
245
+ });
246
+ }, delayMs);
247
+ }
248
+ (this.timer as ReturnType<typeof setTimeout>).unref();
249
+ log.info(
250
+ { nextRunAt: new Date(nextRunAt).toISOString(), delayMs },
251
+ "Heartbeat cron run scheduled",
252
+ );
253
+ } catch (err) {
254
+ log.warn(
255
+ { err },
256
+ "Failed to compute next cron run, falling back to interval mode",
257
+ );
258
+ this.startIntervalMode(config);
259
+ }
260
+ }
261
+
141
262
  /** Restart the timer with the latest config (e.g. after settings change). */
142
263
  reconfigure(): void {
264
+ this.configEpoch++;
265
+ if (this._pendingRunId) {
266
+ supersedePendingRun(this._pendingRunId);
267
+ this._pendingRunId = null;
268
+ }
143
269
  if (this.timer) {
144
- clearInterval(this.timer);
270
+ clearTimeout(this.timer as ReturnType<typeof setTimeout>);
271
+ clearInterval(this.timer as ReturnType<typeof setInterval>);
145
272
  this.timer = null;
146
273
  }
147
274
  this._nextRunAt = null;
275
+ this.cronMode = false;
148
276
  this.start();
149
277
  }
150
278
 
@@ -155,8 +283,15 @@ export class HeartbeatService {
155
283
  */
156
284
  resetTimer(): void {
157
285
  if (!this.timer) return;
286
+ if (this.cronMode) {
287
+ clearTimeout(this.timer as ReturnType<typeof setTimeout>);
288
+ clearInterval(this.timer as ReturnType<typeof setInterval>);
289
+ this.timer = null;
290
+ this.scheduleNextCronRun(getConfig().heartbeat);
291
+ return;
292
+ }
158
293
  const config = getConfig().heartbeat;
159
- clearInterval(this.timer);
294
+ clearInterval(this.timer as ReturnType<typeof setInterval>);
160
295
  this.scheduleNextRun(config.intervalMs);
161
296
  this.timer = setInterval(() => {
162
297
  this.runOnce().catch((err) => {
@@ -166,10 +301,16 @@ export class HeartbeatService {
166
301
  }
167
302
 
168
303
  async stop(): Promise<void> {
304
+ this.stopped = true;
169
305
  if (this.timer) {
170
- clearInterval(this.timer);
306
+ clearTimeout(this.timer as ReturnType<typeof setTimeout>);
307
+ clearInterval(this.timer as ReturnType<typeof setInterval>);
171
308
  this.timer = null;
172
309
  }
310
+ if (this._pendingRunId) {
311
+ supersedePendingRun(this._pendingRunId);
312
+ this._pendingRunId = null;
313
+ }
173
314
  this._nextRunAt = null;
174
315
  if (this.activeRun) {
175
316
  let timerId: ReturnType<typeof setTimeout>;
@@ -186,7 +327,22 @@ export class HeartbeatService {
186
327
  * When `force` is true (e.g. manual "Run Now"), skip enabled & active-hours guards. */
187
328
  async runOnce({ force = false }: { force?: boolean } = {}): Promise<boolean> {
188
329
  const config = getConfig().heartbeat;
189
- if (!force && !config.enabled) return false;
330
+
331
+ let runId: string | null;
332
+ let scheduledFor: number;
333
+ if (force) {
334
+ scheduledFor = Date.now();
335
+ runId = insertPendingHeartbeatRun(scheduledFor);
336
+ } else {
337
+ runId = this._pendingRunId;
338
+ scheduledFor = this._nextRunAt ?? Date.now();
339
+ this._pendingRunId = null;
340
+ }
341
+
342
+ if (!force && !config.enabled) {
343
+ if (runId) skipHeartbeatRun(runId, "disabled");
344
+ return false;
345
+ }
190
346
 
191
347
  // Active hours guard — only applied when both bounds are set.
192
348
  // The schema rejects configs where only one bound is provided.
@@ -195,7 +351,17 @@ export class HeartbeatService {
195
351
  config.activeHoursStart != null &&
196
352
  config.activeHoursEnd != null
197
353
  ) {
198
- const hour = this.deps.getCurrentHour?.() ?? new Date().getHours();
354
+ let hour: number;
355
+ if (this.cronMode && config.timezone) {
356
+ const parts = new Intl.DateTimeFormat("en-US", {
357
+ timeZone: config.timezone,
358
+ hourCycle: "h23",
359
+ hour: "numeric",
360
+ }).formatToParts(new Date());
361
+ hour = Number(parts.find((p) => p.type === "hour")!.value);
362
+ } else {
363
+ hour = this.deps.getCurrentHour?.() ?? new Date().getHours();
364
+ }
199
365
  if (
200
366
  !isWithinActiveHours(
201
367
  hour,
@@ -211,7 +377,10 @@ export class HeartbeatService {
211
377
  },
212
378
  "Outside active hours, skipping",
213
379
  );
214
- this.scheduleNextRun(config.intervalMs);
380
+ if (runId) skipHeartbeatRun(runId, "outside_active_hours");
381
+ if (!this.cronMode) {
382
+ this.scheduleNextRun(config.intervalMs);
383
+ }
215
384
  return false;
216
385
  }
217
386
  }
@@ -219,10 +388,14 @@ export class HeartbeatService {
219
388
  // Overlap prevention
220
389
  if (this.activeRun) {
221
390
  log.debug("Previous heartbeat run still active, skipping");
391
+ if (runId) skipHeartbeatRun(runId, "overlap");
222
392
  return false;
223
393
  }
224
394
 
225
- const run = this.executeRun();
395
+ if (!runId) {
396
+ runId = insertPendingHeartbeatRun(scheduledFor);
397
+ }
398
+ const run = this.executeRun(runId, scheduledFor);
226
399
  this.activeRun = run;
227
400
  // Clear activeRun once executeRun finishes. On timeout, runOnce releases
228
401
  // activeRun separately (see catch block below) so future runs aren't
@@ -252,16 +425,39 @@ export class HeartbeatService {
252
425
  // Release activeRun so the overlap guard doesn't permanently block
253
426
  // future heartbeat runs when executeRun hangs past the timeout.
254
427
  this.activeRun = null;
428
+ const transitioned = runId
429
+ ? completeHeartbeatRun(runId, {
430
+ status: "timeout",
431
+ error: "Heartbeat execution exceeded the 30-minute timeout",
432
+ })
433
+ : false;
434
+ if (transitioned) {
435
+ const today = new Date().toISOString().split("T")[0];
436
+ void emitFeedEvent({
437
+ source: "assistant",
438
+ title: "Heartbeat Timed Out",
439
+ summary: "Heartbeat execution exceeded the 30-minute timeout.",
440
+ dedupKey: `heartbeat:timeout:${today}`,
441
+ priority: 55,
442
+ urgency: "high",
443
+ }).catch(() => {});
444
+ }
255
445
  } finally {
256
446
  clearTimeout(timerId);
257
447
  this._lastRunAt = Date.now();
258
- this.scheduleNextRun(getConfig().heartbeat.intervalMs);
448
+ if (!this.cronMode) {
449
+ this.scheduleNextRun(getConfig().heartbeat.intervalMs);
450
+ }
259
451
  }
260
452
  return true;
261
453
  }
262
454
 
263
455
  private scheduleNextRun(intervalMs: number): void {
456
+ if (this._pendingRunId) {
457
+ supersedePendingRun(this._pendingRunId);
458
+ }
264
459
  this._nextRunAt = Date.now() + intervalMs;
460
+ this._pendingRunId = insertPendingHeartbeatRun(this._nextRunAt);
265
461
  }
266
462
 
267
463
  /**
@@ -383,14 +579,20 @@ export class HeartbeatService {
383
579
  }
384
580
  }
385
581
 
386
- private async executeRun(): Promise<void> {
582
+ private async executeRun(runId: string, scheduledFor: number): Promise<void> {
387
583
  log.info("Running heartbeat");
388
584
 
585
+ startHeartbeatRun(runId);
586
+
587
+ const latenessMs = Date.now() - scheduledFor;
588
+ const LATE_THRESHOLD_MS = 5 * 60 * 1000;
589
+
389
590
  // Credential health check — surface broken credentials proactively
390
591
  // before the LLM heartbeat prompt runs. Returns unhealthy provider
391
592
  // names so the prompt can instruct the LLM to skip those providers.
392
593
  const unhealthyProviders = await this.runCredentialHealthCheck();
393
594
 
595
+ let conversationId: string | undefined;
394
596
  try {
395
597
  const checklist = this.readChecklist();
396
598
  const { prompt, includedReengagement } = this.buildPrompt(
@@ -405,6 +607,7 @@ export class HeartbeatService {
405
607
  origin: "heartbeat",
406
608
  systemHint: "Heartbeat",
407
609
  });
610
+ conversationId = conversation.id;
408
611
 
409
612
  this.deps.onConversationCreated?.({
410
613
  conversationId: conversation.id,
@@ -425,50 +628,78 @@ export class HeartbeatService {
425
628
 
426
629
  log.info({ conversationId: conversation.id }, "Heartbeat completed");
427
630
 
428
- let title = "Heartbeat";
429
- try {
430
- const row = getConversation(conversation.id);
431
- if (row?.title && row.title !== GENERATING_TITLE) {
432
- title = row.title;
631
+ const transitioned = completeHeartbeatRun(runId, {
632
+ status: "ok",
633
+ conversationId,
634
+ });
635
+
636
+ if (transitioned) {
637
+ let title = "Heartbeat";
638
+ try {
639
+ const row = getConversation(conversation.id);
640
+ if (row?.title && row.title !== GENERATING_TITLE) {
641
+ title = row.title;
642
+ }
643
+ } catch {
644
+ // Best-effort; fall back to generic title.
433
645
  }
434
- } catch {
435
- // Best-effort; fall back to generic title.
436
- }
437
646
 
438
- const today = new Date().toISOString().split("T")[0];
439
- void emitFeedEvent({
440
- source: "assistant",
441
- title,
442
- summary: "Periodic check completed. Tap to see details.",
443
- dedupKey: `heartbeat:ok:${today}`,
444
- priority: 30,
445
- }).catch((err) => {
446
- log.warn(
447
- { err, conversationId: conversation.id },
448
- "Failed to emit heartbeat feed event",
449
- );
450
- });
647
+ const today = new Date().toISOString().split("T")[0];
648
+ void emitFeedEvent({
649
+ source: "assistant",
650
+ title,
651
+ summary: "Periodic check completed. Tap to see details.",
652
+ dedupKey: `heartbeat:ok:${today}`,
653
+ priority: 30,
654
+ }).catch((err) => {
655
+ log.warn(
656
+ { err, conversationId: conversation.id },
657
+ "Failed to emit heartbeat feed event",
658
+ );
659
+ });
660
+
661
+ if (latenessMs > LATE_THRESHOLD_MS) {
662
+ const lateMinutes = Math.round(latenessMs / 60_000);
663
+ void emitFeedEvent({
664
+ source: "assistant",
665
+ title: "Heartbeat Ran Late",
666
+ summary: `Heartbeat ran ${lateMinutes} minutes late (scheduled for ${new Date(scheduledFor).toLocaleTimeString()}).`,
667
+ dedupKey: `heartbeat:late:${today}`,
668
+ priority: 45,
669
+ urgency: "medium",
670
+ }).catch(() => {});
671
+ }
672
+ }
451
673
  } catch (err) {
452
674
  log.error({ err }, "Heartbeat failed");
453
- try {
454
- this.deps.alerter({
455
- type: "heartbeat_alert",
675
+
676
+ const transitioned = completeHeartbeatRun(runId, {
677
+ status: "error",
678
+ conversationId,
679
+ error: err instanceof Error ? err.message : String(err),
680
+ });
681
+
682
+ if (transitioned) {
683
+ try {
684
+ this.deps.alerter({
685
+ type: "heartbeat_alert",
686
+ title: "Heartbeat Failed",
687
+ body: err instanceof Error ? err.message : String(err),
688
+ });
689
+ } catch (alertErr) {
690
+ log.error({ alertErr }, "Failed to broadcast heartbeat alert");
691
+ }
692
+
693
+ const today = new Date().toISOString().split("T")[0];
694
+ void emitFeedEvent({
695
+ source: "assistant",
456
696
  title: "Heartbeat Failed",
457
- body: err instanceof Error ? err.message : String(err),
458
- });
459
- } catch (alertErr) {
460
- log.error({ alertErr }, "Failed to broadcast heartbeat alert");
697
+ summary: `Heartbeat check failed: ${(err instanceof Error ? err.message : String(err)).slice(0, 200)}`,
698
+ dedupKey: `heartbeat:fail:${today}`,
699
+ priority: 55,
700
+ urgency: "high",
701
+ }).catch(() => {});
461
702
  }
462
-
463
- const today = new Date().toISOString().split("T")[0];
464
- void emitFeedEvent({
465
- source: "assistant",
466
- title: "Heartbeat",
467
- summary: "Heartbeat check failed. Check logs for details.",
468
- dedupKey: `heartbeat:fail:${today}`,
469
- priority: 55,
470
- urgency: "medium",
471
- }).catch(() => {});
472
703
  }
473
704
  }
474
705
 
@@ -0,0 +1,99 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ // ─── assistantEventHub mock ────────────────────────────────────────────
7
+ const publishSpy = mock<(event: unknown) => Promise<void>>(async () => {});
8
+
9
+ mock.module("../../runtime/assistant-event-hub.js", () => ({
10
+ assistantEventHub: {
11
+ publish: publishSpy,
12
+ subscribe: () => () => {},
13
+ },
14
+ }));
15
+
16
+ const { emitPostConnectNudge } = await import("../post-connect-feed.js");
17
+ const { readHomeFeed } = await import("../feed-writer.js");
18
+
19
+ // ─── tmpdir workspace lifecycle ────────────────────────────────────────
20
+
21
+ let workspaceDir: string;
22
+ let origWorkspaceDir: string | undefined;
23
+
24
+ beforeEach(() => {
25
+ workspaceDir = mkdtempSync(join(tmpdir(), "vellum-pcf-"));
26
+ origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
27
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
28
+ publishSpy.mockClear();
29
+ });
30
+
31
+ afterEach(() => {
32
+ if (origWorkspaceDir === undefined) {
33
+ delete process.env.VELLUM_WORKSPACE_DIR;
34
+ } else {
35
+ process.env.VELLUM_WORKSPACE_DIR = origWorkspaceDir;
36
+ }
37
+ try {
38
+ rmSync(workspaceDir, { recursive: true, force: true });
39
+ } catch {
40
+ // best-effort
41
+ }
42
+ });
43
+
44
+ // ─── Tests ─────────────────────────────────────────────────────────────
45
+
46
+ describe("emitPostConnectNudge", () => {
47
+ test("emits a nudge feed item for google", async () => {
48
+ await emitPostConnectNudge("google");
49
+
50
+ const feed = readHomeFeed();
51
+ expect(feed.items).toHaveLength(1);
52
+
53
+ const item = feed.items[0]!;
54
+ expect(item.id).toBe("connect-nudge:google");
55
+ expect(item.type).toBe("nudge");
56
+ expect(item.source).toBe("gmail");
57
+ expect(item.title).toContain("Gmail connected");
58
+ expect(item.actions).toHaveLength(2);
59
+ expect(item.actions![0]!.label).toBe("Triage my inbox");
60
+ expect(item.actions![1]!.label).toBe("Set up daily digest");
61
+ expect(item.expiresAt).toBeDefined();
62
+ expect(item.author).toBe("platform");
63
+ });
64
+
65
+ test("no-ops for non-email services", async () => {
66
+ await emitPostConnectNudge("slack");
67
+ await emitPostConnectNudge("notion");
68
+ await emitPostConnectNudge("linear");
69
+
70
+ const feed = readHomeFeed();
71
+ expect(feed.items).toHaveLength(0);
72
+ });
73
+
74
+ test("reconnecting appends a second nudge (same-author nudges don't deduplicate)", async () => {
75
+ await emitPostConnectNudge("google");
76
+ await emitPostConnectNudge("google");
77
+
78
+ const feed = readHomeFeed();
79
+ // Same-author (platform) same-source nudges both persist —
80
+ // the feed writer's author-resolution only handles cross-author
81
+ // replacement. In practice, reconnects are rare and the 7-day
82
+ // expiry prevents buildup.
83
+ expect(feed.items).toHaveLength(2);
84
+ expect(feed.items.every((i) => i.id === "connect-nudge:google")).toBe(true);
85
+ });
86
+
87
+ test("nudge expires after 7 days", async () => {
88
+ await emitPostConnectNudge("google");
89
+
90
+ const feed = readHomeFeed();
91
+ const item = feed.items[0]!;
92
+ const created = new Date(item.createdAt).getTime();
93
+ const expires = new Date(item.expiresAt!).getTime();
94
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
95
+
96
+ // Allow 1 second tolerance for test execution time
97
+ expect(Math.abs(expires - created - sevenDaysMs)).toBeLessThan(1000);
98
+ });
99
+ });
@@ -432,6 +432,12 @@ describe("relationship-state-writer", () => {
432
432
  // Also sanity: it must be a real, recent date (not the epoch
433
433
  // sentinel we emit when stat fails).
434
434
  expect(Date.parse(first.hatchedDate)).toBeGreaterThan(0);
435
+ const sidecarPath = join(workspaceDir, "data", "hatched.json");
436
+ expect(existsSync(sidecarPath)).toBe(true);
437
+ const sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8")) as {
438
+ hatchedAt: string;
439
+ };
440
+ expect(sidecar.hatchedAt).toBe(first.hatchedDate);
435
441
  });
436
442
 
437
443
  test("honors an explicit Hatched bullet in IDENTITY.md over file birthtime", async () => {
@@ -488,9 +494,10 @@ describe("relationship-state-writer", () => {
488
494
  expect(state.hatchedDate).toBe("2025-01-15T00:00:00.000Z");
489
495
  });
490
496
 
491
- test("IDENTITY.md birthtime takes precedence over the sidecar", async () => {
492
- // Seed a stale sidecar and then an IDENTITY.md without an
493
- // explicit Hatched bullet — birthtime wins over the sidecar.
497
+ test("sidecar takes precedence over IDENTITY.md metadata", async () => {
498
+ // Seed an existing sidecar and then an IDENTITY.md without an
499
+ // explicit Hatched bullet — the persisted sidecar wins so the
500
+ // date remains stable across later identity edits.
494
501
  mkdirSync(join(workspaceDir, "data"), { recursive: true });
495
502
  writeFileSync(
496
503
  join(workspaceDir, "data", "hatched.json"),
@@ -500,12 +507,7 @@ describe("relationship-state-writer", () => {
500
507
  writeFile("IDENTITY.md", "- **Name:** Sage\n- **Role:** Assistant\n");
501
508
 
502
509
  const state = (await computeRelationshipState()) as RelationshipStateLike;
503
- // The sidecar value is 2020-06-01 but IDENTITY.md was just
504
- // written, so birthtime will be a much more recent date.
505
- expect(state.hatchedDate).not.toBe("2020-06-01T00:00:00.000Z");
506
- expect(Date.parse(state.hatchedDate)).toBeGreaterThan(
507
- Date.parse("2020-06-01T00:00:00.000Z"),
508
- );
510
+ expect(state.hatchedDate).toBe("2020-06-01T00:00:00.000Z");
509
511
  });
510
512
  });
511
513