@vellumai/assistant 0.11.10 → 0.11.11-staging.1

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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -9,6 +9,7 @@ import {
9
9
  ambiguousSameUserError,
10
10
  enforceSameActorOrErrorResult,
11
11
  pickSameUserAutoResolve,
12
+ snapshotHostProxyActorPrincipalId,
12
13
  } from "../runtime/auth/same-actor.js";
13
14
  import * as pendingInteractions from "../runtime/pending-interactions.js";
14
15
  import { readAudioBase64 } from "../tools/shared/filesystem/audio-read.js";
@@ -183,12 +184,11 @@ export class HostFileProxy {
183
184
  conversationId,
184
185
  kind: "host_file",
185
186
  targetClientId: resolvedTargetClientId,
186
- targetActorPrincipalId:
187
- resolvedTargetClientId != null
188
- ? assistantEventHub.getActorPrincipalIdForClient(
189
- resolvedTargetClientId,
190
- )
191
- : undefined,
187
+ targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
188
+ hub: assistantEventHub,
189
+ targetClientId: resolvedTargetClientId,
190
+ sourceActorPrincipalId,
191
+ }),
192
192
  rpcResolve: resolve as (v: unknown) => void,
193
193
  rpcReject: reject,
194
194
  timer,
@@ -21,6 +21,7 @@ import {
21
21
  assistantEventHub,
22
22
  broadcastMessage,
23
23
  } from "../runtime/assistant-event-hub.js";
24
+ import { snapshotHostProxyActorPrincipalId } from "../runtime/auth/same-actor.js";
24
25
  import type { PendingInteraction } from "../runtime/pending-interactions.js";
25
26
  import * as pendingInteractions from "../runtime/pending-interactions.js";
26
27
  import { AssistantError, ErrorCode } from "../util/errors.js";
@@ -142,6 +143,7 @@ export abstract class HostProxyBase<TRequest, TResultPayload> {
142
143
  extraFields?: Record<string, unknown>,
143
144
  targetClientId?: string,
144
145
  timeoutMsOverride?: number,
146
+ sourceActorPrincipalId?: string,
145
147
  ): Promise<TResultPayload> {
146
148
  const requestId = uuid();
147
149
  const effectiveTimeoutMs = timeoutMsOverride ?? this.timeoutMs;
@@ -206,15 +208,14 @@ export abstract class HostProxyBase<TRequest, TResultPayload> {
206
208
  // (HostCuProxy bypasses dispatchRequest entirely with its own inline
207
209
  // request method that registers directly, which is why CU works
208
210
  // without this base-level fix.)
209
- // Snapshot the target's actorPrincipalId at registration time so the
210
- // result-route same-actor check has a stable value to compare against —
211
- // the target client's SSE subscription may briefly disconnect between
212
- // dispatch and result submission, which would make a live hub lookup
213
- // falsely 403 a legitimate result.
214
- const targetActorPrincipalId =
215
- targetClientId != null
216
- ? assistantEventHub.getActorPrincipalIdForClient(targetClientId)
217
- : undefined;
211
+ // Snapshot the actor principal at registration so the result-route
212
+ // same-actor check has a stable value. Targeted dispatch uses the
213
+ // target client; untargeted dispatch uses the turn's source actor.
214
+ const targetActorPrincipalId = snapshotHostProxyActorPrincipalId({
215
+ hub: assistantEventHub,
216
+ targetClientId,
217
+ sourceActorPrincipalId,
218
+ });
218
219
  pendingInteractions.register(requestId, {
219
220
  conversationId,
220
221
  kind: this.resultPendingKind,
@@ -13,6 +13,7 @@ import {
13
13
  ambiguousSameUserError,
14
14
  enforceSameActorOrErrorResult,
15
15
  pickSameUserAutoResolve,
16
+ snapshotHostProxyActorPrincipalId,
16
17
  } from "../runtime/auth/same-actor.js";
17
18
  import * as pendingInteractions from "../runtime/pending-interactions.js";
18
19
  import type { ToolExecutionResult } from "../tools/types.js";
@@ -259,24 +260,22 @@ export class HostTransferProxy {
259
260
  sha256,
260
261
  fileBuffer,
261
262
  targetClientId: resolvedTargetClientId,
262
- targetActorPrincipalId:
263
- resolvedTargetClientId != null
264
- ? assistantEventHub.getActorPrincipalIdForClient(
265
- resolvedTargetClientId,
266
- )
267
- : undefined,
263
+ targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
264
+ hub: assistantEventHub,
265
+ targetClientId: resolvedTargetClientId,
266
+ sourceActorPrincipalId,
267
+ }),
268
268
  });
269
269
 
270
270
  pendingInteractions.register(requestId, {
271
271
  conversationId: input.conversationId,
272
272
  kind: "host_transfer",
273
273
  targetClientId: resolvedTargetClientId,
274
- targetActorPrincipalId:
275
- resolvedTargetClientId != null
276
- ? assistantEventHub.getActorPrincipalIdForClient(
277
- resolvedTargetClientId,
278
- )
279
- : undefined,
274
+ targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
275
+ hub: assistantEventHub,
276
+ targetClientId: resolvedTargetClientId,
277
+ sourceActorPrincipalId,
278
+ }),
280
279
  rpcResolve: resolve as (v: unknown) => void,
281
280
  rpcReject: reject,
282
281
  timer,
@@ -450,24 +449,22 @@ export class HostTransferProxy {
450
449
  filePath: input.destPath,
451
450
  overwrite: input.overwrite,
452
451
  targetClientId: resolvedTargetClientId,
453
- targetActorPrincipalId:
454
- resolvedTargetClientId != null
455
- ? assistantEventHub.getActorPrincipalIdForClient(
456
- resolvedTargetClientId,
457
- )
458
- : undefined,
452
+ targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
453
+ hub: assistantEventHub,
454
+ targetClientId: resolvedTargetClientId,
455
+ sourceActorPrincipalId,
456
+ }),
459
457
  });
460
458
 
461
459
  pendingInteractions.register(requestId, {
462
460
  conversationId: input.conversationId,
463
461
  kind: "host_transfer",
464
462
  targetClientId: resolvedTargetClientId,
465
- targetActorPrincipalId:
466
- resolvedTargetClientId != null
467
- ? assistantEventHub.getActorPrincipalIdForClient(
468
- resolvedTargetClientId,
469
- )
470
- : undefined,
463
+ targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
464
+ hub: assistantEventHub,
465
+ targetClientId: resolvedTargetClientId,
466
+ sourceActorPrincipalId,
467
+ }),
471
468
  rpcResolve: resolve as (v: unknown) => void,
472
469
  rpcReject: reject,
473
470
  timer,
@@ -12,6 +12,7 @@ import {
12
12
  } from "../mcp/effective-config.js";
13
13
  import { getMcpServerManager } from "../mcp/manager.js";
14
14
  import { migrateLegacyMcpHeaders } from "../mcp/mcp-header-store.js";
15
+ import { signalMcpReloaded } from "../mcp/reload-signal.js";
15
16
  import { createMcpToolsFromServer } from "../tools/mcp/mcp-tool-factory.js";
16
17
  import { registerMcpTools, unregisterAllMcpTools } from "../tools/registry.js";
17
18
  import { getLogger } from "../util/logger.js";
@@ -154,6 +155,11 @@ async function doReload(): Promise<McpReloadResult> {
154
155
  // to evict sessions.
155
156
 
156
157
  log.info({ serverCount, toolCount }, "MCP servers reloaded");
158
+ // Other processes hold their own connections to the same servers and have
159
+ // no watcher of their own; this is how they learn the set moved. Only on
160
+ // success: a reload that failed before the teardown left this process on
161
+ // its existing servers, so there is nothing for anyone to mirror.
162
+ signalMcpReloaded();
157
163
  return { success: true, serverCount, toolCount, servers };
158
164
  } catch (err) {
159
165
  const error = err instanceof Error ? err.message : String(err);
@@ -1,8 +1,7 @@
1
1
  import { maybeDefaultSpeechToManaged } from "../config/managed-speech-defaults.js";
2
2
  import { rehydratePlatformCredentials } from "../config/platform-rehydration.js";
3
3
  import type { AssistantConfig } from "../config/types.js";
4
- import { buildEffectiveMcpConfig } from "../mcp/effective-config.js";
5
- import { getMcpServerManager } from "../mcp/manager.js";
4
+ import { startConfiguredMcpServers } from "../mcp/startup.js";
6
5
  import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
7
6
  import { outlookMessagingProvider } from "../messaging/providers/outlook/adapter.js";
8
7
  import { slackProvider as slackMessagingProvider } from "../messaging/providers/slack/adapter.js";
@@ -11,8 +10,7 @@ import { whatsappMessagingProvider } from "../messaging/providers/whatsapp/adapt
11
10
  import { registerMessagingProvider } from "../messaging/registry.js";
12
11
  import { initializeProviders } from "../providers/registry.js";
13
12
  import { validateSubagentRoleAllowlists } from "../subagent/validate-allowlists.js";
14
- import { createMcpToolsFromServer } from "../tools/mcp/mcp-tool-factory.js";
15
- import { initializeTools, registerMcpTools } from "../tools/registry.js";
13
+ import { initializeTools } from "../tools/registry.js";
16
14
  import { getLogger } from "../util/logger.js";
17
15
  import { initWatcherEngine } from "../watcher/engine.js";
18
16
  import { registerWatcherProvider } from "../watcher/provider-registry.js";
@@ -63,28 +61,9 @@ export async function initializeProvidersAndTools(
63
61
  }
64
62
 
65
63
  // Start MCP servers — workspace-configured and plugin-declared alike —
66
- // and register their tools.
67
- const mcpConfig = buildEffectiveMcpConfig(config.mcp);
68
- if (Object.keys(mcpConfig.servers).length > 0) {
69
- const manager = getMcpServerManager();
70
- try {
71
- const serverToolInfos = await manager.start(mcpConfig);
72
- for (const { serverId, serverConfig, tools } of serverToolInfos) {
73
- const mcpTools = createMcpToolsFromServer(
74
- tools,
75
- serverId,
76
- serverConfig,
77
- manager,
78
- );
79
- registerMcpTools(serverId, mcpTools);
80
- }
81
- } catch (err) {
82
- log.error(
83
- { err },
84
- "MCP server initialization failed — continuing without MCP tools",
85
- );
86
- }
87
- }
64
+ // and register their tools. Shared with the schedule worker, which hosts
65
+ // agent turns in its own process and so needs its own connections.
66
+ await startConfiguredMcpServers(config.mcp);
88
67
 
89
68
  log.info("Daemon startup: providers and tools initialized");
90
69
  }
@@ -21,6 +21,10 @@ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
21
21
  import { runBackgroundJob } from "../runtime/background-job-runner.js";
22
22
  import { hasReceivedUserMessage } from "../runtime/pre-first-message-gate.js";
23
23
  import { computeNextRunAt } from "../schedule/recurrence-engine.js";
24
+ import {
25
+ hourInTimeZone,
26
+ resolveScheduleTimezone,
27
+ } from "../schedule/schedule-timezone.js";
24
28
  import { readTextFileSync } from "../util/fs.js";
25
29
  import { getLogger } from "../util/logger.js";
26
30
  import { getWorkspaceDir, getWorkspacePromptPath } from "../util/platform.js";
@@ -117,8 +121,10 @@ function refreshBackgroundWakeIntentSoon(reason: string): void {
117
121
  }
118
122
 
119
123
  export interface HeartbeatDeps {
120
- /** Override for current hour (0-23), for testing. */
124
+ /** Override for current hour (0-23), for testing. Used when no timezone is resolved. */
121
125
  getCurrentHour?: () => number;
126
+ /** Override for "now", for testing timezone-aware active hours. */
127
+ now?: () => Date;
122
128
  }
123
129
 
124
130
  export interface ManagedWakeHeartbeatRunOptions {
@@ -351,7 +357,7 @@ export class HeartbeatService {
351
357
  const nextRunAt = computeNextRunAt({
352
358
  syntax: "cron",
353
359
  expression: config.cronExpression!,
354
- timezone: config.timezone,
360
+ timezone: resolveScheduleTimezone(config.timezone),
355
361
  });
356
362
  this._nextRunAt = nextRunAt;
357
363
  if (this.timer) {
@@ -543,22 +549,15 @@ export class HeartbeatService {
543
549
 
544
550
  // Active hours guard — only applied when both bounds are set.
545
551
  // The schema rejects configs where only one bound is provided.
552
+ // Hours are wall-clock in the heartbeat timezone (explicit, then the
553
+ // user's configured/detected zone). Host-local is the last resort so a
554
+ // managed container whose clock is UTC does not treat 8:00-22:00 as UTC.
546
555
  if (
547
556
  !force &&
548
557
  config.activeHoursStart != null &&
549
558
  config.activeHoursEnd != null
550
559
  ) {
551
- let hour: number;
552
- if (this.cronMode && config.timezone) {
553
- const parts = new Intl.DateTimeFormat("en-US", {
554
- timeZone: config.timezone,
555
- hourCycle: "h23",
556
- hour: "numeric",
557
- }).formatToParts(new Date());
558
- hour = Number(parts.find((p) => p.type === "hour")!.value);
559
- } else {
560
- hour = this.deps.getCurrentHour?.() ?? new Date().getHours();
561
- }
560
+ const hour = currentHourForHeartbeat(config, this.deps);
562
561
  if (
563
562
  !isWithinActiveHours(
564
563
  hour,
@@ -569,6 +568,7 @@ export class HeartbeatService {
569
568
  log.debug(
570
569
  {
571
570
  hour,
571
+ timezone: resolveScheduleTimezone(config.timezone),
572
572
  activeHoursStart: config.activeHoursStart,
573
573
  activeHoursEnd: config.activeHoursEnd,
574
574
  },
@@ -1011,6 +1011,18 @@ function isDiskPressureBackgroundLocked(logKey: string): boolean {
1011
1011
  return true;
1012
1012
  }
1013
1013
 
1014
+ function currentHourForHeartbeat(
1015
+ config: HeartbeatConfig,
1016
+ deps: HeartbeatDeps,
1017
+ ): number {
1018
+ const now = deps.now?.() ?? new Date();
1019
+ const timezone = resolveScheduleTimezone(config.timezone);
1020
+ if (timezone) {
1021
+ return hourInTimeZone(timezone, now);
1022
+ }
1023
+ return deps.getCurrentHour?.() ?? now.getHours();
1024
+ }
1025
+
1014
1026
  /**
1015
1027
  * Check if the given hour falls within the active window.
1016
1028
  * Handles overnight windows (e.g. start=22, end=6).
@@ -1026,3 +1038,50 @@ function isWithinActiveHours(
1026
1038
  // Overnight window: e.g. 22-6 means 22,23,0,1,2,3,4,5
1027
1039
  return hour >= start || hour < end;
1028
1040
  }
1041
+
1042
+ function stringOrNull(value: unknown): string | null {
1043
+ return typeof value === "string" && value.length > 0 ? value : null;
1044
+ }
1045
+
1046
+ function rawObject(value: unknown): Record<string, unknown> | null {
1047
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1048
+ return null;
1049
+ }
1050
+ return value as Record<string, unknown>;
1051
+ }
1052
+
1053
+ /**
1054
+ * Cron next-run is computed in the resolved timezone at schedule time. Interval
1055
+ * active hours are evaluated live, so only cron needs a reschedule when the
1056
+ * fallback zone changes and heartbeat.timezone is unset.
1057
+ */
1058
+ export function shouldRescheduleHeartbeatForTimezoneChange(
1059
+ previousRaw: Record<string, unknown>,
1060
+ nextRaw: Record<string, unknown>,
1061
+ ): boolean {
1062
+ const nextHeartbeat = rawObject(nextRaw.heartbeat);
1063
+ if (stringOrNull(nextHeartbeat?.timezone)) {
1064
+ return false;
1065
+ }
1066
+ if (!stringOrNull(nextHeartbeat?.cronExpression)) {
1067
+ return false;
1068
+ }
1069
+ const previousUi = rawObject(previousRaw.ui);
1070
+ const nextUi = rawObject(nextRaw.ui);
1071
+ return (
1072
+ stringOrNull(previousUi?.userTimezone) !==
1073
+ stringOrNull(nextUi?.userTimezone) ||
1074
+ stringOrNull(previousUi?.detectedTimezone) !==
1075
+ stringOrNull(nextUi?.detectedTimezone)
1076
+ );
1077
+ }
1078
+
1079
+ export function rescheduleHeartbeatIfTimezoneChanged(
1080
+ previousRaw: Record<string, unknown>,
1081
+ nextRaw: Record<string, unknown>,
1082
+ ): void {
1083
+ if (!shouldRescheduleHeartbeatForTimezoneChange(previousRaw, nextRaw)) {
1084
+ return;
1085
+ }
1086
+ HeartbeatService.getInstance()?.reconfigure();
1087
+ }
@@ -53,6 +53,7 @@ import {
53
53
  _pendingFrameReclaimCountForTests,
54
54
  _setProcessingWaitMsForTests,
55
55
  _standaloneImageQueueSizeForTests,
56
+ newestPersistedSightFrame,
56
57
  pendingStandaloneImagePersist,
57
58
  persistAmbientSightFrame,
58
59
  persistLiveVoicePhoto,
@@ -462,6 +463,39 @@ describe("persistLiveVoicePhoto", () => {
462
463
  });
463
464
 
464
465
  describe("persistAmbientSightFrame", () => {
466
+ test("reports where the daemon's half of the persist went", async () => {
467
+ const live = liveConversation("Live voice sight frame timing");
468
+ try {
469
+ const attachment = await uploadAttachment(
470
+ "frame.png",
471
+ "image/png",
472
+ IMAGE_BASE64,
473
+ );
474
+
475
+ const result = await persistAmbientSightFrame(
476
+ live.id,
477
+ attachment.id,
478
+ "voice",
479
+ );
480
+ expect(result.ok).toBe(true);
481
+ // Three legs, each a whole non-negative number of milliseconds: nothing
482
+ // was queued ahead of it and no turn held the flag, so the first two are
483
+ // near zero, and the write is whatever the store took.
484
+ expect(result.timing).toBeDefined();
485
+ for (const leg of Object.values(result.timing!)) {
486
+ expect(Number.isInteger(leg)).toBe(true);
487
+ expect(leg).toBeGreaterThanOrEqual(0);
488
+ }
489
+ expect(Object.keys(result.timing!).sort()).toEqual([
490
+ "flagWaitMs",
491
+ "queueWaitMs",
492
+ "writeMs",
493
+ ]);
494
+ } finally {
495
+ live.dispose();
496
+ }
497
+ });
498
+
465
499
  test("tags the row with the attachment it carries", async () => {
466
500
  const live = liveConversation("Live voice sight frame");
467
501
  try {
@@ -1596,6 +1630,44 @@ describe("standalone image persists are serialized per conversation", () => {
1596
1630
  });
1597
1631
  });
1598
1632
 
1633
+ describe("newestPersistedSightFrame", () => {
1634
+ test("is null for a conversation that carries no frame", () => {
1635
+ const live = liveConversation("Sight newest none");
1636
+ try {
1637
+ expect(newestPersistedSightFrame(live.id)).toBeNull();
1638
+ } finally {
1639
+ live.dispose();
1640
+ }
1641
+ });
1642
+
1643
+ test("names the last frame written, by the id the row carries", async () => {
1644
+ const live = liveConversation("Sight newest");
1645
+ try {
1646
+ const first = await uploadFrame("first.png");
1647
+ const second = await uploadFrame("second.png");
1648
+ expect((await persistAmbientSightFrame(live.id, first, "voice")).ok).toBe(
1649
+ true,
1650
+ );
1651
+ expect(
1652
+ (await persistAmbientSightFrame(live.id, second, "voice")).ok,
1653
+ ).toBe(true);
1654
+
1655
+ // A frame already linked elsewhere is stored under a fresh id, so the
1656
+ // answer is read from the rows rather than assumed from the argument.
1657
+ const rows = getMessages(live.id);
1658
+ const storedSecond = sightFrameAttachmentIdsFromMetadata(
1659
+ metadataOf(rows[1]),
1660
+ )[0];
1661
+ expect(newestPersistedSightFrame(live.id)).toEqual({
1662
+ attachmentId: storedSecond,
1663
+ capturedAt: rows[1].createdAt,
1664
+ });
1665
+ } finally {
1666
+ live.dispose();
1667
+ }
1668
+ });
1669
+ });
1670
+
1599
1671
  describe("pendingStandaloneImagePersist", () => {
1600
1672
  test("reports nothing for a conversation with no image queued", async () => {
1601
1673
  const live = liveConversation("Sight pending idle");
@@ -212,6 +212,87 @@ describe("live-voice sight_frame frame", () => {
212
212
  expect(result.error.frameType).toBe("sight_frame");
213
213
  }
214
214
  });
215
+
216
+ test("carries the client's timing through as sent", () => {
217
+ const timing = {
218
+ reason: "forced",
219
+ armToKeepMs: 40,
220
+ keepToEncodedMs: 30,
221
+ encodedToUploadedMs: 200,
222
+ uploadedToSentMs: 0,
223
+ bytes: 12345,
224
+ };
225
+ const result = validateLiveVoiceClientFrame({
226
+ type: "sight_frame",
227
+ attachmentId: "att-1",
228
+ timing,
229
+ });
230
+ expect(result).toEqual({
231
+ ok: true,
232
+ frame: { type: "sight_frame", attachmentId: "att-1", timing },
233
+ });
234
+ });
235
+
236
+ test("an ambient keep's timing has no arm", () => {
237
+ const timing = {
238
+ reason: "novel",
239
+ keepToEncodedMs: 30,
240
+ encodedToUploadedMs: 200,
241
+ uploadedToSentMs: 12,
242
+ bytes: 12345,
243
+ };
244
+ const result = validateLiveVoiceClientFrame({
245
+ type: "sight_frame",
246
+ attachmentId: "att-1",
247
+ timing,
248
+ });
249
+ expect(result).toEqual({
250
+ ok: true,
251
+ frame: { type: "sight_frame", attachmentId: "att-1", timing },
252
+ });
253
+ });
254
+
255
+ test("rejects a timing with a negative or fractional duration, naming the field", () => {
256
+ for (const bad of [
257
+ { keepToEncodedMs: -1 },
258
+ { encodedToUploadedMs: 1.5 },
259
+ { uploadedToSentMs: "0" },
260
+ { armToKeepMs: -5 },
261
+ { bytes: null },
262
+ { reason: "" },
263
+ ]) {
264
+ const result = validateLiveVoiceClientFrame({
265
+ type: "sight_frame",
266
+ attachmentId: "att-1",
267
+ timing: {
268
+ reason: "novel",
269
+ keepToEncodedMs: 30,
270
+ encodedToUploadedMs: 200,
271
+ uploadedToSentMs: 0,
272
+ bytes: 1,
273
+ ...bad,
274
+ },
275
+ });
276
+ expect(result.ok).toBe(false);
277
+ if (!result.ok) {
278
+ expect(result.error.code).toBe("invalid_field");
279
+ expect(result.error.field).toBe("timing");
280
+ expect(result.error.frameType).toBe("sight_frame");
281
+ }
282
+ }
283
+ });
284
+
285
+ test("rejects a timing that is not an object", () => {
286
+ const result = validateLiveVoiceClientFrame({
287
+ type: "sight_frame",
288
+ attachmentId: "att-1",
289
+ timing: "fast",
290
+ });
291
+ expect(result.ok).toBe(false);
292
+ if (!result.ok) {
293
+ expect(result.error.field).toBe("timing");
294
+ }
295
+ });
215
296
  });
216
297
 
217
298
  describe("live-voice camera frames kept mid-call", () => {
@@ -90,6 +90,7 @@ import {
90
90
  getMessageById,
91
91
  MessageInsertPreconditionError,
92
92
  recordConversationPersistedSeq,
93
+ selectNewestSightFrameCapture,
93
94
  } from "../persistence/conversation-crud.js";
94
95
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
95
96
  import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
@@ -134,6 +135,29 @@ const SIGHT_FRAME_MESSAGE_CONTENT = "(camera frame)";
134
135
  export interface LiveVoicePhotoResult {
135
136
  readonly ok: boolean;
136
137
  readonly messageId?: string;
138
+ /**
139
+ * Where the daemon's half of a persist went, on the ordinary success path.
140
+ * Absent on failure and on the recovery path that reports a row the write
141
+ * threw after landing, where the marks say nothing about the row.
142
+ */
143
+ readonly timing?: LiveVoicePhotoPersistTiming;
144
+ }
145
+
146
+ /**
147
+ * The daemon leg of one standalone image, as durations between its marks.
148
+ *
149
+ * Together with the client leg a `sight_frame` can carry (see
150
+ * `LiveVoiceSightFrameTiming` in `protocol.ts`) and the session's own
151
+ * distance-from-speech-onset, this is what says which leg a frame that missed
152
+ * its turn was lost in.
153
+ */
154
+ export interface LiveVoicePhotoPersistTiming {
155
+ /** Behind this conversation's earlier standalone images. */
156
+ readonly queueWaitMs: number;
157
+ /** Polling for the processing flag, which a running turn holds. */
158
+ readonly flagWaitMs: number;
159
+ /** Materializing the attachment and inserting the row. */
160
+ readonly writeMs: number;
137
161
  }
138
162
 
139
163
  /**
@@ -175,8 +199,9 @@ async function enqueueStandaloneImagePersist(
175
199
  attachmentId: string,
176
200
  kind: "photo" | "sight_frame",
177
201
  content: string,
178
- job: () => Promise<LiveVoicePhotoResult>,
202
+ job: (queueWaitMs: number) => Promise<LiveVoicePhotoResult>,
179
203
  ): Promise<LiveVoicePhotoResult> {
204
+ const queuedAtMs = Date.now();
180
205
  let queue = standaloneImageQueues.get(conversationId);
181
206
  if (!queue) {
182
207
  queue = {
@@ -215,7 +240,7 @@ async function enqueueStandaloneImagePersist(
215
240
  );
216
241
  return { ok: false };
217
242
  }
218
- return job();
243
+ return job(Date.now() - queuedAtMs);
219
244
  });
220
245
  // The tail must survive a failed job, or one rejection would strand every
221
246
  // image queued behind it.
@@ -265,6 +290,33 @@ export const SIGHT_FRAME_TURN_HOLD_MS = 800;
265
290
  * Settles, never rejects: the caller is choosing how long to wait, not whether
266
291
  * the image landed.
267
292
  */
293
+ /** The newest camera frame a conversation's rows carry. */
294
+ export interface NewestSightFrame {
295
+ readonly attachmentId: string;
296
+ /** When the row carrying it was written, in wall-clock milliseconds. */
297
+ readonly capturedAt: number;
298
+ }
299
+
300
+ /**
301
+ * The camera frame a turn launching now would read as the current view, or
302
+ * null when the conversation carries none.
303
+ *
304
+ * For the turn's own log: which frame the answer is about is otherwise
305
+ * invisible, and the failure this instruments is exactly a turn reading the
306
+ * frame before the one the question was asked about. Read from the rows, the
307
+ * same source retention ranks frames by, so a frame that landed is counted
308
+ * and a frame still in flight is not. One indexed row, since it runs on every
309
+ * voice turn.
310
+ */
311
+ export function newestPersistedSightFrame(
312
+ conversationId: string,
313
+ ): NewestSightFrame | null {
314
+ const newest = selectNewestSightFrameCapture(conversationId);
315
+ return newest === null
316
+ ? null
317
+ : { attachmentId: newest.attachmentId, capturedAt: newest.createdAt };
318
+ }
319
+
268
320
  export function pendingStandaloneImagePersist(
269
321
  conversationId: string,
270
322
  ): Promise<void> | null {
@@ -672,13 +724,14 @@ function persistStandaloneImage(
672
724
  attachmentId,
673
725
  kind,
674
726
  persistOptions.content,
675
- () =>
727
+ (queueWaitMs) =>
676
728
  writeStandaloneImage(
677
729
  conversationId,
678
730
  attachmentId,
679
731
  kind,
680
732
  incarnation,
681
733
  persistOptions,
734
+ queueWaitMs,
682
735
  ),
683
736
  );
684
737
  }
@@ -718,6 +771,7 @@ async function writeStandaloneImage(
718
771
  | "insertPrecondition"
719
772
  | "onUndiscardedAttachments"
720
773
  >,
774
+ queueWaitMs: number,
721
775
  ): Promise<LiveVoicePhotoResult> {
722
776
  const { content } = persistOptions;
723
777
  // The id the row is inserted under, so a failure can ask whether the insert
@@ -768,7 +822,9 @@ async function writeStandaloneImage(
768
822
  // A turn holds the lock for its whole run. Waiting rather than queueing:
769
823
  // the conversation's queue drains into a turn, which is the one thing this
770
824
  // must not cause.
825
+ const flagWaitStartedAtMs = Date.now();
771
826
  const owner = await acquireProcessingFlag(conversation);
827
+ const flagAcquiredAtMs = Date.now();
772
828
  if (owner === null) {
773
829
  log.warn(
774
830
  { conversationId, attachmentId, kind },
@@ -828,7 +884,15 @@ async function writeStandaloneImage(
828
884
 
829
885
  announcePersistedImage(conversationId, content, persisted.id);
830
886
 
831
- return { ok: true, messageId: persisted.id };
887
+ return {
888
+ ok: true,
889
+ messageId: persisted.id,
890
+ timing: {
891
+ queueWaitMs,
892
+ flagWaitMs: flagAcquiredAtMs - flagWaitStartedAtMs,
893
+ writeMs: Date.now() - flagAcquiredAtMs,
894
+ },
895
+ };
832
896
  } finally {
833
897
  // Only this job's own hold is released. A turn that claimed the flag
834
898
  // away mid-write owns it now, and clearing there would free a turn that