@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
@@ -16,6 +16,7 @@ import {
16
16
  getVercelConfig,
17
17
  setVercelConfig,
18
18
  } from "../../../daemon/handlers/config-vercel.js";
19
+ import { ACTOR_PRINCIPALS } from "../../auth/route-policy.js";
19
20
  import { BadRequestError } from "../errors.js";
20
21
  import type { RouteDefinition, RouteHandlerArgs } from "../types.js";
21
22
 
@@ -70,7 +71,10 @@ export const ROUTES: RouteDefinition[] = [
70
71
  operationId: "integrations_vercel_config_get",
71
72
  endpoint: "integrations/vercel/config",
72
73
  method: "GET",
73
- policy: null,
74
+ policy: {
75
+ requiredScopes: ["settings.read"],
76
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
77
+ },
74
78
  summary: "Get Vercel config",
75
79
  description: "Check if a Vercel API token is stored.",
76
80
  tags: ["integrations"],
@@ -81,7 +85,10 @@ export const ROUTES: RouteDefinition[] = [
81
85
  operationId: "integrations_vercel_config_post",
82
86
  endpoint: "integrations/vercel/config",
83
87
  method: "POST",
84
- policy: null,
88
+ policy: {
89
+ requiredScopes: ["settings.write"],
90
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
91
+ },
85
92
  summary: "Set or delete Vercel config",
86
93
  description:
87
94
  "Set or delete the Vercel API token. Action is determined by the body action field.",
@@ -97,7 +104,10 @@ export const ROUTES: RouteDefinition[] = [
97
104
  operationId: "integrations_vercel_config_delete",
98
105
  endpoint: "integrations/vercel/config",
99
106
  method: "DELETE",
100
- policy: null,
107
+ policy: {
108
+ requiredScopes: ["settings.write"],
109
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
110
+ },
101
111
  summary: "Delete Vercel config",
102
112
  description: "Delete the stored Vercel API token.",
103
113
  tags: ["integrations"],
@@ -15,8 +15,9 @@
15
15
  * - platform_callback_routes_list (GET platform/callback-routes): lists
16
16
  * registered callback routes for this assistant.
17
17
  * - platform_credits (GET platform/credits): fetches the org's remaining
18
- * credit balance, today's spend, and daily-limit / low-balance state from
19
- * the platform billing summary.
18
+ * credit balance, plan-included credit usage, extra credit, upcoming
19
+ * expiry, today's spend, and daily-limit / low-balance state from the
20
+ * platform billing summary.
20
21
  * - platform_subscription (GET platform/subscription): fetches the org's
21
22
  * current plan, subscription status, and entitlements.
22
23
  * - platform_plans (GET platform/plans): fetches the plan catalog with pricing.
@@ -26,6 +27,11 @@
26
27
  * invoice list and returns a single invoice by Stripe invoice ID.
27
28
  */
28
29
 
30
+ import {
31
+ type BillingPlanId,
32
+ extraCreditUsd,
33
+ planCreditUsedFraction,
34
+ } from "@vellumai/service-contracts/plan-credit";
29
35
  import {
30
36
  type PlatformCredentialVerificationStatus,
31
37
  type PlatformVerifyCredentialResponse,
@@ -172,6 +178,48 @@ const PlatformCreditsResponseSchema = z.object({
172
178
  .describe(
173
179
  "True when the balance is above zero but below the low-balance threshold and auto top-up is not enabled.",
174
180
  ),
181
+ plan_credit_remaining: z
182
+ .number()
183
+ .nullable()
184
+ .describe(
185
+ "Unused credit (USD) remaining on the unexpired plan-included grants (initial credit, Pro credit bundle). Null when the platform did not report grant figures.",
186
+ ),
187
+ plan_credit_total: z
188
+ .number()
189
+ .nullable()
190
+ .describe(
191
+ "Total credit (USD) those plan-included grants were worth, net of refunds. Null when the platform did not report grant figures.",
192
+ ),
193
+ plan_credit_used_fraction: z
194
+ .number()
195
+ .nullable()
196
+ .describe(
197
+ "Share of the plan-included credit already used, from 0 to 1: the same reading as the in-app usage meter. A Pro subscription whose unexpired grants total nothing reads as 1 (everything granted is used or expired). Null when there is no usable reading: grant figures missing, or a zero total on a base or unknown plan.",
198
+ ),
199
+ plan_credits_spent: z
200
+ .boolean()
201
+ .nullable()
202
+ .describe(
203
+ "True when the plan-included credit is used up or expired. Whether further managed usage is funded is extra_credit_remaining (zero means the organization is out of credit). Null when there is no plan-credit reading.",
204
+ ),
205
+ extra_credit_remaining: z
206
+ .number()
207
+ .nullable()
208
+ .describe(
209
+ "Credit (USD) bought or earned on top of the plan-included grants: remaining minus plan_credit_remaining, floored at zero. Null when grant figures are missing.",
210
+ ),
211
+ credits_expiring_soon: z
212
+ .number()
213
+ .nullable()
214
+ .describe(
215
+ "Unexpired credit (USD) on grants that expire within the next 30 days. Null when the platform did not report it.",
216
+ ),
217
+ next_credit_expiry_at: z
218
+ .string()
219
+ .nullable()
220
+ .describe(
221
+ "Earliest upcoming expiry (ISO 8601) across grants that still hold credit, or null when none is scheduled.",
222
+ ),
175
223
  });
176
224
  type PlatformCreditsResponse = z.infer<typeof PlatformCreditsResponseSchema>;
177
225
 
@@ -513,10 +561,40 @@ async function handlePlatformCredits(
513
561
  daily_limit_snoozed?: boolean;
514
562
  low_balance_threshold_usd?: string;
515
563
  low_balance_warning?: boolean;
564
+ available_usage_balance?: string;
565
+ total_usage_balance?: string;
566
+ credits_expiring_soon_usd?: string;
567
+ next_credit_expiry_at?: string | null;
516
568
  };
517
569
 
570
+ const remaining = Number(summary.effective_balance_usd);
571
+ const planCreditRemaining = parseUsd(summary.available_usage_balance);
572
+ const planCreditTotal = parseUsd(summary.total_usage_balance);
573
+ let usedFraction = planCreditUsedFraction(
574
+ planCreditTotal,
575
+ planCreditRemaining,
576
+ null,
577
+ );
578
+ if (
579
+ usedFraction === null &&
580
+ planCreditTotal !== null &&
581
+ planCreditTotal <= 0
582
+ ) {
583
+ // Only the zero-grant reading depends on the plan, so the subscription is
584
+ // read lazily and best-effort: a slow or failed read degrades that one
585
+ // reading to null instead of delaying or failing the balance.
586
+ const planId = await fetchBillingSubscription()
587
+ .then((subscription) => subscription.plan_id)
588
+ .catch((): BillingPlanId | null => null);
589
+ usedFraction = planCreditUsedFraction(
590
+ planCreditTotal,
591
+ planCreditRemaining,
592
+ planId,
593
+ );
594
+ }
595
+
518
596
  return {
519
- remaining: Number(summary.effective_balance_usd),
597
+ remaining,
520
598
  settled: Number(summary.settled_balance_usd),
521
599
  pending: Number(summary.pending_compute_usd),
522
600
  unit: "USD",
@@ -530,6 +608,19 @@ async function handlePlatformCredits(
530
608
  daily_limit_snoozed: summary.daily_limit_snoozed === true,
531
609
  low_balance_threshold: parseUsd(summary.low_balance_threshold_usd),
532
610
  low_balance_warning: summary.low_balance_warning === true,
611
+ plan_credit_remaining: planCreditRemaining,
612
+ plan_credit_total: planCreditTotal,
613
+ plan_credit_used_fraction: usedFraction,
614
+ plan_credits_spent: usedFraction === null ? null : usedFraction >= 1,
615
+ extra_credit_remaining:
616
+ planCreditRemaining === null
617
+ ? null
618
+ : extraCreditUsd(remaining, planCreditRemaining),
619
+ credits_expiring_soon: parseUsd(summary.credits_expiring_soon_usd),
620
+ next_credit_expiry_at:
621
+ typeof summary.next_credit_expiry_at === "string"
622
+ ? summary.next_credit_expiry_at
623
+ : null,
533
624
  };
534
625
  }
535
626
 
@@ -624,28 +715,34 @@ async function fetchPlatformJson(
624
715
  return response.json();
625
716
  }
626
717
 
718
+ interface BillingSubscriptionData {
719
+ plan_id: BillingPlanId;
720
+ status: string | null;
721
+ renewal_date: string | null;
722
+ current_period_end: string | null;
723
+ cancel_at_period_end: boolean;
724
+ cancel_at: string | null;
725
+ selected_credit_tier: string | null;
726
+ package: {
727
+ key: string;
728
+ name: string;
729
+ version: number;
730
+ customized: boolean;
731
+ } | null;
732
+ entitlements: { managed_email: boolean; phone_number: boolean };
733
+ }
734
+
735
+ function fetchBillingSubscription(): Promise<BillingSubscriptionData> {
736
+ return fetchPlatformJson(
737
+ "/v1/organizations/billing/subscription/",
738
+ "subscription",
739
+ ) as Promise<BillingSubscriptionData>;
740
+ }
741
+
627
742
  async function handlePlatformSubscription(
628
743
  _args: RouteHandlerArgs,
629
744
  ): Promise<PlatformSubscriptionResponse> {
630
- const data = (await fetchPlatformJson(
631
- "/v1/organizations/billing/subscription/",
632
- "subscription",
633
- )) as {
634
- plan_id: "base" | "pro";
635
- status: string | null;
636
- renewal_date: string | null;
637
- current_period_end: string | null;
638
- cancel_at_period_end: boolean;
639
- cancel_at: string | null;
640
- selected_credit_tier: string | null;
641
- package: {
642
- key: string;
643
- name: string;
644
- version: number;
645
- customized: boolean;
646
- } | null;
647
- entitlements: { managed_email: boolean; phone_number: boolean };
648
- };
745
+ const data = await fetchBillingSubscription();
649
746
 
650
747
  return {
651
748
  planId: data.plan_id,
@@ -914,9 +1011,9 @@ export const ROUTES: RouteDefinition[] = [
914
1011
  allowedPrincipalTypes: ACTOR_PRINCIPALS,
915
1012
  },
916
1013
  summary:
917
- "Get the organization's remaining credit balance and daily-limit state",
1014
+ "Get the organization's credit balance, plan-credit usage, and daily-limit state",
918
1015
  description:
919
- "Fetches the org's settled, pending, and effective (remaining) credit balance in USD from the platform billing summary, plus the daily-limit state: today's spend counted against the daily credit limit (which excludes spend covered by plan-included credits, so it is not total spend), the limit itself, and the low-balance warning state.",
1016
+ "Fetches the org's settled, pending, and effective (remaining) credit balance in USD from the platform billing summary, plus plan-included credit usage (remaining, total, and the used fraction shown by the in-app usage meter), extra credit bought or earned on top, credit expiring within 30 days, and the daily-limit state: today's spend counted against the daily credit limit (which excludes spend covered by plan-included credits, so it is not total spend), the limit itself, and the low-balance warning state.",
920
1017
  tags: ["platform"],
921
1018
  handler: handlePlatformCredits,
922
1019
  responseBody: PlatformCreditsResponseSchema,
@@ -14,6 +14,7 @@ import { getOrCreateConversation } from "../../daemon/conversation-store.js";
14
14
  import { INTERNAL_GUARDIAN_TRUST_CONTEXT } from "../../daemon/trust-context.js";
15
15
  import { bootstrapConversation } from "../../persistence/conversation-bootstrap.js";
16
16
  import { getConversation } from "../../persistence/conversation-crud.js";
17
+ import { isLifecycleQuiesced } from "../../persistence/lifecycle-quiesce.js";
17
18
  import {
18
19
  getUsageCostForRun,
19
20
  listRunConversationIds,
@@ -62,6 +63,7 @@ import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
62
63
  import { parseEpochMillisRange } from "./epoch-millis-range.js";
63
64
  import {
64
65
  BadRequestError,
66
+ ConflictError,
65
67
  ForbiddenError,
66
68
  InternalError,
67
69
  NotFoundError,
@@ -1302,11 +1304,17 @@ export const ROUTES: RouteDefinition[] = [
1302
1304
  },
1303
1305
  summary: "Run schedule now",
1304
1306
  description:
1305
- "Trigger an immediate execution of a schedule. A plugin-sourced schedule is rejected with a 400 when its plugin is disabled or no longer declares it.",
1307
+ "Trigger an immediate execution of a schedule. A plugin-sourced schedule is rejected with a 400 when its plugin is disabled or no longer declares it. Returns 409 while a drain quiesce lease is active so run-now cannot start work the shutdown snapshot would miss.",
1306
1308
  tags: ["schedules"],
1307
1309
  responseBody: z.object({
1308
1310
  schedules: z.array(scheduleSchema).describe("Updated schedule list"),
1309
1311
  }),
1312
+ additionalResponses: {
1313
+ "409": {
1314
+ description:
1315
+ "A drain quiesce lease is active, so run-now is not starting new work.",
1316
+ },
1317
+ },
1310
1318
  handler: ({ pathParams, headers }: RouteHandlerArgs) =>
1311
1319
  handleRunScheduleNow(pathParams!.id, headers),
1312
1320
  },
@@ -1321,6 +1329,12 @@ async function handleRunScheduleNow(
1321
1329
  throw new NotFoundError("Schedule not found");
1322
1330
  }
1323
1331
 
1332
+ if (isLifecycleQuiesced()) {
1333
+ throw new ConflictError(
1334
+ "The assistant is shutting down and is not starting new schedule runs.",
1335
+ );
1336
+ }
1337
+
1324
1338
  // A plugin-sourced row runs the plugin's own script or prompt, so run-now is
1325
1339
  // only offered while that plugin is something the runtime would activate.
1326
1340
  // `pluginScheduleSourceAvailable` covers a plugin the daemon never
@@ -9,8 +9,11 @@ mock.module("../../config/loader.js", () => ({
9
9
  getConfigReadOnly: () => ({ ui: mockUi }),
10
10
  }));
11
11
 
12
- const { resolveScheduleTimezone, expressionCarriesOwnTimezone } =
13
- await import("../schedule-timezone.js");
12
+ const {
13
+ resolveScheduleTimezone,
14
+ expressionCarriesOwnTimezone,
15
+ hourInTimeZone,
16
+ } = await import("../schedule-timezone.js");
14
17
 
15
18
  afterEach(() => {
16
19
  mockUi = {};
@@ -42,12 +45,27 @@ describe("resolveScheduleTimezone", () => {
42
45
  expect(resolveScheduleTimezone(null)).toBeNull();
43
46
  });
44
47
 
45
- test("ignores an invalid explicit value and falls back", () => {
48
+ test("an invalid explicit value is ignored and falls back", () => {
46
49
  mockUi = { userTimezone: "America/New_York" };
47
50
  expect(resolveScheduleTimezone("Not/AZone")).toBe("America/New_York");
48
51
  });
49
52
  });
50
53
 
54
+ describe("hourInTimeZone", () => {
55
+ test("returns the wall-clock hour in the given zone", () => {
56
+ // 23:00 UTC on a Pacific Daylight date is 16:00 in America/Los_Angeles.
57
+ const now = new Date("2026-09-08T23:00:00Z");
58
+ expect(hourInTimeZone("UTC", now)).toBe(23);
59
+ expect(hourInTimeZone("America/Los_Angeles", now)).toBe(16);
60
+ expect(hourInTimeZone("America/New_York", now)).toBe(19);
61
+ });
62
+
63
+ test("normalizes a 24-hour midnight encoding to 0", () => {
64
+ const midnightUtc = new Date("2026-09-08T00:00:00Z");
65
+ expect(hourInTimeZone("UTC", midnightUtc)).toBe(0);
66
+ });
67
+ });
68
+
51
69
  describe("expressionCarriesOwnTimezone", () => {
52
70
  test("cron never carries its own zone", () => {
53
71
  expect(expressionCarriesOwnTimezone("cron", "30 8 * * *")).toBe(false);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Guard over the schedule worker entrypoint's MCP bootstrap.
3
+ *
4
+ * The tool registry is process-local and MCP tools reach it only by connecting
5
+ * to each server and listing what it offers. `initializeTools()` does not do
6
+ * that: it loads core built-ins and workspace tools from disk. So a worker
7
+ * that stops connecting fails every `mcp__*` call an execute-mode schedule
8
+ * makes as "Unknown tool", while `assistant tools list` reads the daemon's
9
+ * registry over IPC and reports the same tool as registered. The mismatch is
10
+ * what makes the failure hard to place, so it is worth a guard.
11
+ *
12
+ * The worker is also long-lived, so the set it connects at startup goes stale
13
+ * as soon as the config moves. It watches for the daemon's reload signal to
14
+ * rebuild, which is covered here too.
15
+ *
16
+ * The entrypoint installs signal handlers and calls `process.exit`, so it
17
+ * cannot be imported into a test process; these assertions read its source the
18
+ * way `worker-feature-flags.test.ts` does. The behavior of the steps
19
+ * themselves is covered in `src/mcp/__tests__/startup.test.ts` and
20
+ * `src/mcp/__tests__/reload-signal.test.ts`.
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { describe, expect, test } from "bun:test";
26
+
27
+ const MCP_CALL = "startConfiguredMcpServers(";
28
+
29
+ /** The bounded wait that keeps unreachable servers off the critical path. */
30
+ const MCP_GRACE = "MCP_STARTUP_GRACE_MS";
31
+
32
+ /** The reload watcher that keeps the connected set current. */
33
+ const MCP_WATCH = "watchForMcpReload(";
34
+
35
+ /** The worker's first call that can execute a schedule. */
36
+ const WORK_START = "void tick()";
37
+
38
+ function readWorkerSource(): string {
39
+ return readFileSync(join(process.cwd(), "src/schedule/worker.ts"), "utf8");
40
+ }
41
+
42
+ describe("schedule worker MCP tools", () => {
43
+ test("connects its own MCP servers before the first schedule tick", () => {
44
+ const source = readWorkerSource();
45
+ const mcpAt = source.indexOf(MCP_CALL);
46
+ const workAt = source.indexOf(WORK_START);
47
+
48
+ expect(
49
+ mcpAt,
50
+ `The schedule worker must call \`${MCP_CALL})\` at startup. This ` +
51
+ "process hosts execute-mode schedule turns, and its tool registry is " +
52
+ "its own: without the call it has no mcp__* tools at all.",
53
+ ).toBeGreaterThanOrEqual(0);
54
+ expect(workAt).toBeGreaterThanOrEqual(0);
55
+ expect(
56
+ mcpAt < workAt,
57
+ "The connect starts and is waited on before the first tick, so a " +
58
+ "schedule that fires immediately runs with the tools it was written " +
59
+ "against. The wait is bounded (see below), so a server that never " +
60
+ "answers delays it rather than blocking it.",
61
+ ).toBe(true);
62
+ });
63
+
64
+ test("bounds the startup wait so unrelated schedules still fire", () => {
65
+ const source = readWorkerSource();
66
+
67
+ expect(
68
+ source.includes(MCP_GRACE),
69
+ "`McpServerManager.start()` walks servers one at a time and allows " +
70
+ "each 30s to connect and 30s more to list its tools. An unbounded " +
71
+ "wait lets a few unreachable servers hold every due schedule, " +
72
+ "including the notify and script ones that never touch MCP, while " +
73
+ "the PID file already reports this worker as running.",
74
+ ).toBe(true);
75
+ });
76
+
77
+ test("rebuilds its MCP set when the daemon reports a reload", () => {
78
+ const source = readWorkerSource();
79
+
80
+ expect(
81
+ source.includes(MCP_WATCH),
82
+ "This process is long-lived and holds its own connections. Without " +
83
+ "the watcher, a server disabled or removed in the config stays " +
84
+ "registered and callable by background schedules until the worker " +
85
+ "restarts.",
86
+ ).toBe(true);
87
+ });
88
+ });
@@ -61,3 +61,28 @@ export function resolveScheduleTimezone(
61
61
  null
62
62
  );
63
63
  }
64
+
65
+ /**
66
+ * Wall-clock hour (0-23) of `now` in `timeZone`.
67
+ *
68
+ * Heartbeat active hours are stored as 0-23 integers. Evaluating them against
69
+ * the host clock (UTC on a managed container) shifts the window by the user's
70
+ * offset, so 8:00-22:00 becomes 1am-3pm Pacific. This reads the hour in the
71
+ * same zone `resolveScheduleTimezone` selected.
72
+ */
73
+ export function hourInTimeZone(
74
+ timeZone: string,
75
+ now: Date = new Date(),
76
+ ): number {
77
+ const parts = new Intl.DateTimeFormat("en-US", {
78
+ timeZone,
79
+ hourCycle: "h23",
80
+ hour: "numeric",
81
+ }).formatToParts(now);
82
+ const raw = Number(parts.find((part) => part.type === "hour")?.value);
83
+ if (!Number.isFinite(raw)) {
84
+ return now.getHours();
85
+ }
86
+ // Some runtimes emit "24" for midnight under hourCycle h23.
87
+ return raw === 24 ? 0 : raw;
88
+ }
@@ -10,6 +10,7 @@ import { processMessage } from "../daemon/process-message.js";
10
10
  import { INTERNAL_GUARDIAN_TRUST_CONTEXT } from "../daemon/trust-context.js";
11
11
  import { emitBackgroundFailureSignal } from "../notifications/background-failure-signal.js";
12
12
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
13
+ import { emitScheduleResultNotification } from "../notifications/schedule-result-producer.js";
13
14
  import { getConversation } from "../persistence/conversation-crud.js";
14
15
  import { isLifecycleQuiesced } from "../persistence/lifecycle-quiesce.js";
15
16
  import { invalidateAssistantInferredItemsForConversation } from "../plugins/defaults/memory/task-memory-cleanup.js";
@@ -963,6 +964,11 @@ export async function runDueSchedulesOnce(
963
964
  let failedTurn: TurnFailure | undefined;
964
965
  const conversationReused = reusedConversationId != null;
965
966
  let runConversationId = reusedConversationId;
967
+ // Captured before the run so the post-run "did this run notify?" probe has
968
+ // a lower bound. A reused conversation carries every prior run's
969
+ // notifications, and without this bound the first run to notify would
970
+ // silence the fallback for every run after it.
971
+ const runStartedAt = Date.now();
966
972
  const runId = await createScheduleRun(job.id, reusedConversationId);
967
973
 
968
974
  if (reusedConversationId) {
@@ -1067,6 +1073,19 @@ export async function runDueSchedulesOnce(
1067
1073
 
1068
1074
  if (ok) {
1069
1075
  await completeScheduleRun(runId, { status: "ok" });
1076
+ // The run succeeded; make sure it was not invisible. No-ops when the run
1077
+ // already notified or produced nothing user-facing, so a schedule whose
1078
+ // prompt ends in an explicit `assistant notifications send` is unaffected.
1079
+ // Awaited rather than fired and forgotten: the schedule worker can exit
1080
+ // once the tick's loop drains, and a detached emit would race that exit.
1081
+ await emitScheduleResultNotification({
1082
+ scheduleId: job.id,
1083
+ scheduleName: job.name,
1084
+ conversationId,
1085
+ runId,
1086
+ runStartedAt,
1087
+ rlog: log,
1088
+ });
1070
1089
  if (isOneShot) {
1071
1090
  await completeOneShot(job.id);
1072
1091
  }
@@ -11,7 +11,7 @@
11
11
  * user-facing traffic, and keep running during a main-thread freeze in the
12
12
  * daemon.
13
13
  */
14
- import { writeFileSync } from "node:fs";
14
+ import { watch, writeFileSync } from "node:fs";
15
15
 
16
16
  import {
17
17
  initFeatureFlagOverrides,
@@ -20,12 +20,18 @@ import {
20
20
  import { getConfig } from "../config/loader.js";
21
21
  import { rehydratePlatformCredentials } from "../config/platform-rehydration.js";
22
22
  import { startConversationEvictor } from "../daemon/conversation-evictor.js";
23
+ import { stopMcpServerManager } from "../mcp/manager.js";
24
+ import { MCP_RELOAD_SIGNAL_FILE } from "../mcp/reload-signal.js";
25
+ import {
26
+ restartConfiguredMcpServers,
27
+ startConfiguredMcpServers,
28
+ } from "../mcp/startup.js";
23
29
  import { resetDb } from "../persistence/db-connection.js";
24
30
  import { registerWorkerPluginSurface } from "../plugins/worker-plugin-surface.js";
25
31
  import { disableStreamSeqStamping } from "../runtime/assistant-stream-state.js";
26
32
  import { initializeTools } from "../tools/registry.js";
27
33
  import { getLogger } from "../util/logger.js";
28
- import { getScheduleWorkerPidPath } from "../util/platform.js";
34
+ import { getScheduleWorkerPidPath, getSignalsDir } from "../util/platform.js";
29
35
  import {
30
36
  cleanupWorkerPidFile,
31
37
  startWorkerPidFileGuard,
@@ -40,6 +46,80 @@ const TICK_INTERVAL_MS = 15_000;
40
46
  /** How often this process re-reads flag overrides from the gateway. */
41
47
  const FLAG_REFRESH_INTERVAL_MS = 60_000;
42
48
 
49
+ /**
50
+ * How long startup waits for MCP servers before arming the tick.
51
+ *
52
+ * Connecting is worth waiting for: a schedule that fires first would fail its
53
+ * `mcp__*` calls as unknown tools. It is not worth waiting for without limit.
54
+ * `McpServerManager.start()` walks servers one at a time and allows each 30s to
55
+ * connect and 30s more to list its tools, so a handful of unreachable ones can
56
+ * hold the first tick for minutes, including the notify and script schedules
57
+ * that never touch MCP. Past this deadline the connect continues in the
58
+ * background and the tick starts without it.
59
+ */
60
+ const MCP_STARTUP_GRACE_MS = 20_000;
61
+
62
+ /**
63
+ * Rebuild this process's MCP connections whenever the daemon reports a reload.
64
+ *
65
+ * Watches the signals directory rather than config.json: a reload also follows
66
+ * an OAuth reauthentication and a plugin install, neither of which edits that
67
+ * file, and the daemon already funnels all of them through one reload that
68
+ * writes this signal. Restarts are serialized and coalesced, so a burst of
69
+ * writes (an editor saving config.json, several servers reauthenticating)
70
+ * rebuilds once rather than once per event.
71
+ *
72
+ * Never throws: a signals directory that cannot be watched leaves this process
73
+ * on the servers it connected at startup, which is where it already was.
74
+ */
75
+ function watchForMcpReload(): void {
76
+ const signalsDir = getSignalsDir();
77
+ let restarting: Promise<unknown> | null = null;
78
+ let restartQueued = false;
79
+
80
+ const restart = (): void => {
81
+ if (restarting) {
82
+ // A reload landed mid-rebuild, so the rebuild in flight may have read
83
+ // the config as it stood before. Run once more after it, not once per
84
+ // event that arrived while it ran.
85
+ restartQueued = true;
86
+ return;
87
+ }
88
+ restarting = restartConfiguredMcpServers()
89
+ .then((toolCount) => {
90
+ log.info({ toolCount }, "MCP servers reloaded in schedule worker");
91
+ })
92
+ .catch((err: unknown) => {
93
+ log.warn({ err }, "MCP reload failed in schedule worker");
94
+ })
95
+ .finally(() => {
96
+ restarting = null;
97
+ if (restartQueued) {
98
+ restartQueued = false;
99
+ restart();
100
+ }
101
+ });
102
+ };
103
+
104
+ try {
105
+ const watcher = watch(signalsDir, (_eventType, filename) => {
106
+ if (String(filename ?? "") === MCP_RELOAD_SIGNAL_FILE) {
107
+ restart();
108
+ }
109
+ });
110
+ watcher.on("error", (err) => {
111
+ log.warn({ err, signalsDir }, "MCP reload watcher failed");
112
+ });
113
+ watcher.unref();
114
+ log.info({ dir: signalsDir }, "Watching for MCP reload signals");
115
+ } catch (err) {
116
+ log.warn(
117
+ { err, signalsDir },
118
+ "Failed to watch for MCP reload signals; MCP servers stay as connected at startup",
119
+ );
120
+ }
121
+ }
122
+
43
123
  async function main(): Promise<void> {
44
124
  // Only the daemon stamps SSE seqs and writes the shared reservation file.
45
125
  disableStreamSeqStamping();
@@ -102,6 +182,36 @@ async function main(): Promise<void> {
102
182
  );
103
183
  }
104
184
 
185
+ // Connect this process's own MCP servers. The tool registry is per-process
186
+ // and MCP tools reach it only by connecting to each server and listing what
187
+ // it offers, so `initializeTools()` above leaves them out: it loads core
188
+ // built-ins and workspace tools from disk and nothing else. Without this an
189
+ // execute-mode schedule fails every `mcp__*` call as "Unknown tool" while the
190
+ // daemon, which does connect at boot, lists the same tool as registered.
191
+ //
192
+ // Connecting here rather than borrowing the daemon's connections is what
193
+ // keeps this process independent of the daemon's event loop, which is the
194
+ // reason it is a separate process at all.
195
+ //
196
+ // Bounded by MCP_STARTUP_GRACE_MS: unreachable servers must not hold the
197
+ // schedules that do not use them. The connect runs on past the deadline.
198
+ const mcpStartup = startConfiguredMcpServers(getConfig().mcp);
199
+ await Promise.race([
200
+ mcpStartup,
201
+ // Unref'd so a connect that beats the deadline leaves nothing pending.
202
+ new Promise((resolve) => {
203
+ setTimeout(resolve, MCP_STARTUP_GRACE_MS).unref();
204
+ }),
205
+ ]);
206
+
207
+ // React to an MCP reload in the daemon. The server set belongs to the config
208
+ // and the daemon owns the watcher that notices it change; this process holds
209
+ // its own connections to the same servers, so without this it keeps serving
210
+ // whatever was configured when it started, including servers since disabled
211
+ // or removed. Best-effort: a signals directory that cannot be watched leaves
212
+ // this process on its startup connections until it restarts.
213
+ watchForMcpReload();
214
+
105
215
  // Sweep idle conversations out of the in-memory pool. The daemon starts
106
216
  // this at startup; this process hosts conversations too, so without it
107
217
  // every scheduled run's conversation is retained for the process lifetime.
@@ -122,6 +232,13 @@ async function main(): Promise<void> {
122
232
  clearInterval(flagRefreshTimer);
123
233
  }
124
234
  disposePidGuard?.();
235
+ // Best-effort, deliberately not awaited: this process exits immediately on
236
+ // a signal by design, and the exit is what everything else here relies on.
237
+ // Starting the close still lets a stdio server see its stdin shut rather
238
+ // than only noticing when the pipe breaks.
239
+ void stopMcpServerManager().catch((err) => {
240
+ log.warn({ err }, "MCP server shutdown failed (non-fatal)");
241
+ });
125
242
  cleanupWorkerPidFile(pidPath);
126
243
  process.exit(0);
127
244
  };