@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.4

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 (209) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/docs/guardian-request-flow.md +35 -0
  7. package/knip.json +3 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  10. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  11. package/openapi.yaml +136 -76
  12. package/package.json +1 -1
  13. package/scripts/write-plugin-api-shim.ts +10 -0
  14. package/src/__tests__/app-control-flow.test.ts +1 -0
  15. package/src/__tests__/approval-routes-http.test.ts +2 -2
  16. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  17. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  18. package/src/__tests__/compaction-events.test.ts +8 -10
  19. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  21. package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
  22. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  23. package/src/__tests__/conversation-queue.test.ts +39 -62
  24. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  25. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  27. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  28. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  29. package/src/__tests__/conversation-runtime-assembly.test.ts +53 -0
  30. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  31. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  37. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  38. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  39. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  40. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  41. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  42. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  43. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  44. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  45. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  46. package/src/__tests__/document-sync-tags.test.ts +0 -75
  47. package/src/__tests__/file-ops-service.test.ts +163 -30
  48. package/src/__tests__/filesystem-tools.test.ts +23 -24
  49. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  50. package/src/__tests__/host-file-read-tool.test.ts +16 -19
  51. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  52. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  53. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  54. package/src/__tests__/media-generate-image.test.ts +131 -21
  55. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  56. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  57. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  58. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  59. package/src/__tests__/qdrant-manager.test.ts +14 -1
  60. package/src/__tests__/run-due-schedules.test.ts +21 -0
  61. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  62. package/src/__tests__/schedule-routes.test.ts +23 -0
  63. package/src/__tests__/schedule-store.test.ts +17 -0
  64. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  65. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  66. package/src/__tests__/starter-task-flow.test.ts +5 -4
  67. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  68. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  69. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  70. package/src/__tests__/subagent-tools.test.ts +81 -101
  71. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  72. package/src/__tests__/tool-executor.test.ts +5 -1
  73. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  74. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  75. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  76. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  77. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  78. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  79. package/src/acp/session-manager.ts +8 -1
  80. package/src/api/events/host-file.ts +2 -2
  81. package/src/api/surfaces.ts +5 -0
  82. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  83. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  84. package/src/calls/voice-session-bridge.ts +44 -25
  85. package/src/calls/voice-triage-escalate.ts +1 -0
  86. package/src/cli/bundled-modules.ts +29 -0
  87. package/src/cli/commands/db/repair.ts +4 -8
  88. package/src/cli/commands/domain.ts +6 -3
  89. package/src/cli/commands/email.ts +6 -3
  90. package/src/cli/commands/keys.ts +8 -3
  91. package/src/cli/commands/plugins.ts +85 -36
  92. package/src/cli/commands/schedules.ts +35 -1
  93. package/src/cli/lib/bundled-marketplace.json +1 -1
  94. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  95. package/src/config/assistant-feature-flags.ts +36 -15
  96. package/src/config/balanced-model-experiment.ts +35 -0
  97. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  98. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  99. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  100. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  101. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  102. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  103. package/src/config/call-site-defaults.ts +7 -0
  104. package/src/config/default-profile-catalog.ts +96 -4
  105. package/src/config/feature-flag-registry.json +11 -11
  106. package/src/config/skills.ts +9 -2
  107. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  108. package/src/daemon/conversation-agent-loop.ts +14 -13
  109. package/src/daemon/conversation-notifiers.ts +11 -9
  110. package/src/daemon/conversation-process.ts +0 -27
  111. package/src/daemon/conversation-runtime-assembly.ts +9 -2
  112. package/src/daemon/conversation-store.ts +4 -4
  113. package/src/daemon/conversation-surfaces.ts +27 -13
  114. package/src/daemon/conversation-tool-setup.ts +2 -4
  115. package/src/daemon/conversation.ts +104 -51
  116. package/src/daemon/doordash-steps.ts +2 -2
  117. package/src/daemon/lifecycle.ts +14 -1
  118. package/src/daemon/process-message.ts +0 -13
  119. package/src/daemon/windows-compiled-entry.ts +4 -0
  120. package/src/documents/document-store.ts +5 -235
  121. package/src/hooks/types.ts +5 -0
  122. package/src/ipc/gateway-flag-listener.ts +17 -3
  123. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  124. package/src/live-voice/live-voice-manager.ts +16 -3
  125. package/src/live-voice/live-voice-session.ts +63 -9
  126. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  127. package/src/monitoring/control.ts +1 -0
  128. package/src/monitoring/db-integrity-sample.ts +4 -5
  129. package/src/notifications/AGENTS.md +2 -0
  130. package/src/notifications/approval-card-data.ts +33 -0
  131. package/src/permissions/prompter.ts +1 -5
  132. package/src/persistence/conversation-queries.ts +66 -16
  133. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  134. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  135. package/src/persistence/schema/documents.ts +4 -4
  136. package/src/plugin-api/constants.ts +8 -0
  137. package/src/plugin-api/index.ts +5 -1
  138. package/src/plugin-api/webhook-url.ts +13 -11
  139. package/src/plugins/defaults/main.ts +6 -7
  140. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  141. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  142. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  143. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  144. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  145. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  146. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  147. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  148. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  149. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  150. package/src/plugins/mtime-cache.ts +17 -0
  151. package/src/prompts/templates/system-sections.ts +0 -7
  152. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  153. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  154. package/src/providers/openai/chat-completions-provider.ts +11 -1
  155. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  156. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  157. package/src/routes/control.ts +1 -0
  158. package/src/routes/route-host-client.ts +1 -0
  159. package/src/runtime/AGENTS.md +16 -17
  160. package/src/runtime/agent-wake.ts +15 -12
  161. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  162. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  163. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  164. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  165. package/src/runtime/routes/conversation-routes.ts +11 -13
  166. package/src/runtime/routes/documents-routes.ts +3 -222
  167. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  168. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  169. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  170. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  171. package/src/runtime/routes/schedule-routes.ts +94 -7
  172. package/src/runtime/routes/workspace-routes.ts +0 -9
  173. package/src/runtime/routes/workspace-utils.ts +3 -13
  174. package/src/runtime/services/conversation-serializer.ts +7 -2
  175. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  176. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  177. package/src/schedule/plugin-schedule-availability.ts +58 -0
  178. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  179. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  180. package/src/schedule/schedule-store.ts +5 -1
  181. package/src/schedule/scheduler.ts +9 -4
  182. package/src/schedule/worker-control.ts +1 -0
  183. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  184. package/src/subagent/consult-context.ts +11 -11
  185. package/src/subagent/consult-prompt.ts +26 -35
  186. package/src/subagent/manager.ts +20 -37
  187. package/src/subagent/notify.ts +7 -1
  188. package/src/subagent/types.ts +15 -13
  189. package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
  190. package/src/tools/acp/spawn.ts +6 -4
  191. package/src/tools/filesystem/read.ts +27 -10
  192. package/src/tools/host-filesystem/read.ts +27 -15
  193. package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
  194. package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
  195. package/src/tools/shared/filesystem/types.ts +5 -5
  196. package/src/tools/skills/scaffold-managed.ts +25 -7
  197. package/src/tools/subagent/spawn.ts +28 -88
  198. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  199. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  200. package/src/util/logger.ts +16 -0
  201. package/src/util/worker-process.ts +37 -4
  202. package/src/windows-compiled-cli.ts +32 -0
  203. package/src/windows-compiled-entry.ts +4 -0
  204. package/src/windows-compiled-logger.ts +6 -0
  205. package/src/windows-compiled-worker-entry.ts +29 -0
  206. package/src/__tests__/document-workspace-file.test.ts +0 -467
  207. package/src/daemon/interactive-turn-sender.ts +0 -59
  208. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  209. package/src/subagent/consult-transcript.ts +0 -90
@@ -72,7 +72,7 @@ export const ROUTES: RouteDefinition[] = [
72
72
  if (circuitOpenForMs === 0) {
73
73
  if (circuit.compactionCircuitOpenUntil !== null) {
74
74
  circuit.compactionCircuitOpenUntil = null;
75
- conversation.sendToClient({
75
+ conversation.emit({
76
76
  type: "compaction_circuit_closed",
77
77
  conversationId: conversation.conversationId,
78
78
  });
@@ -80,7 +80,7 @@ export const ROUTES: RouteDefinition[] = [
80
80
  } else {
81
81
  const openUntil = Date.now() + circuitOpenForMs;
82
82
  circuit.compactionCircuitOpenUntil = openUntil;
83
- conversation.sendToClient({
83
+ conversation.emit({
84
84
  type: "compaction_circuit_open",
85
85
  conversationId: conversation.conversationId,
86
86
  reason: "3_consecutive_failures",
@@ -52,7 +52,7 @@ export const ROUTES: RouteDefinition[] = [
52
52
  circuit.consecutiveCompactionFailures = 0;
53
53
  if (circuit.compactionCircuitOpenUntil !== null) {
54
54
  circuit.compactionCircuitOpenUntil = null;
55
- conversation.sendToClient({
55
+ conversation.emit({
56
56
  type: "compaction_circuit_closed",
57
57
  conversationId: conversation.conversationId,
58
58
  });
@@ -5,6 +5,9 @@
5
5
  * the shared ROUTES array.
6
6
  */
7
7
 
8
+ import { statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
8
11
  import { z } from "zod";
9
12
 
10
13
  import { getOrCreateConversation } from "../../daemon/conversation-store.js";
@@ -15,9 +18,10 @@ import {
15
18
  getUsageCostForRun,
16
19
  listRunConversationIds,
17
20
  } from "../../persistence/llm-usage-store.js";
21
+ import { isPluginDisabled } from "../../plugins/disabled-state.js";
18
22
  import { isDeferSchedule } from "../../schedule/defer-provenance.js";
19
23
  import { validateScheduleInferenceProfile } from "../../schedule/inference-profile.js";
20
- import { declarationExistsOnDisk } from "../../schedule/plugin-schedule-declarations.js";
24
+ import { pluginScheduleSourceAvailable } from "../../schedule/plugin-schedule-availability.js";
21
25
  import { isPluginSchedulesEnabled } from "../../schedule/plugin-schedules-gate.js";
22
26
  import {
23
27
  describeRRuleExpression,
@@ -38,6 +42,7 @@ import {
38
42
  getLastScheduleConversationId,
39
43
  getSchedule,
40
44
  getScheduleRuns,
45
+ isEngineLatched,
41
46
  listSchedules,
42
47
  resolveScheduleConversationGroupId,
43
48
  type ScheduleJob,
@@ -49,6 +54,7 @@ import { buildWakeScheduleOptions } from "../../schedule/wake-schedule-options.j
49
54
  import { initializeTools } from "../../tools/registry.js";
50
55
  import { UserError } from "../../util/errors.js";
51
56
  import { getLogger } from "../../util/logger.js";
57
+ import { getWorkspacePluginsDir } from "../../util/platform.js";
52
58
  import { normalizeCapabilityManifest } from "../../workflows/capabilities.js";
53
59
  import { getWorkflowRunManager } from "../../workflows/run-manager.js";
54
60
  import { isOwnerCaller } from "../auth/owner-caller.js";
@@ -108,6 +114,76 @@ async function assertWakeMutationAllowed(
108
114
  // Response schemas (shared by all schedule routes)
109
115
  // ---------------------------------------------------------------------------
110
116
 
117
+ const DISARM_REASONS = [
118
+ "user_disabled",
119
+ "plugin_removed",
120
+ "plugin_disabled",
121
+ "declaration_removed",
122
+ "declaration_disabled",
123
+ ] as const;
124
+
125
+ type DisarmReason = (typeof DISARM_REASONS)[number];
126
+
127
+ /**
128
+ * Why a plugin-sourced schedule sits disarmed, so a client can say so instead
129
+ * of showing an off row with no explanation.
130
+ *
131
+ * Only a sourced row that is off can have one, which also bounds the disk
132
+ * probes below to those rows. The first matching cause wins, ordered so the
133
+ * most specific answer comes first: the user's own override outranks anything
134
+ * the plugin's files say, a plugin that is gone outranks one that is merely
135
+ * disabled, and a declaration that is gone outranks one that is still there.
136
+ * `declaration_disabled` is the fallback the plugin's own files account for:
137
+ * a declared `enabled: false`, a declaration too broken to arm, or a manifest
138
+ * that no longer parses.
139
+ *
140
+ * Two off states never get a reason, because in neither did the plugin's files
141
+ * turn the schedule off. A row the engine latched (a fired or cancelled
142
+ * one-shot, a bounded recurrence the claim path exhausted) is a schedule that
143
+ * finished, not one that is paused. And when the `plugin-schedules` kill switch
144
+ * is off the reconciler disarms every sourced row regardless of what its
145
+ * declaration says, so no per-row cause is the true one. Both return null, and
146
+ * clients show the row like any other schedule that is off.
147
+ */
148
+ function deriveDisarmReason(
149
+ job: Pick<
150
+ ScheduleJob,
151
+ | "sourceKey"
152
+ | "enabled"
153
+ | "userEnabled"
154
+ | "status"
155
+ | "nextRunAt"
156
+ | "lastRunAt"
157
+ >,
158
+ ): DisarmReason | null {
159
+ if (job.sourceKey === null || job.enabled) {
160
+ return null;
161
+ }
162
+ if (job.userEnabled === false) {
163
+ return "user_disabled";
164
+ }
165
+ if (isEngineLatched(job) || !isPluginSchedulesEnabled()) {
166
+ return null;
167
+ }
168
+ const match = /^plugin:([^/]+)\/(.+)$/.exec(job.sourceKey);
169
+ if (!match) {
170
+ return null;
171
+ }
172
+ const [, pluginName, scheduleName] = match;
173
+ const pluginDir = join(getWorkspacePluginsDir(), pluginName!);
174
+ if (!statSync(pluginDir, { throwIfNoEntry: false })?.isDirectory()) {
175
+ return "plugin_removed";
176
+ }
177
+ if (isPluginDisabled(pluginName!)) {
178
+ return "plugin_disabled";
179
+ }
180
+ const declarationDir = join(pluginDir, "schedules", scheduleName!);
181
+ if (!statSync(declarationDir, { throwIfNoEntry: false })?.isDirectory()) {
182
+ return "declaration_removed";
183
+ }
184
+ return "declaration_disabled";
185
+ }
186
+
111
187
  const scheduleSchema = z.object({
112
188
  id: z.string(),
113
189
  name: z.string(),
@@ -150,6 +226,12 @@ const scheduleSchema = z.object({
150
226
  .describe(
151
227
  "User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value applies. Always null for user-created schedules.",
152
228
  ),
229
+ disarmReason: z
230
+ .enum(DISARM_REASONS)
231
+ .nullable()
232
+ .describe(
233
+ "Why a plugin-sourced schedule is off: the user turned it off, the plugin is gone, the plugin is disabled, the declaration is gone, or the plugin's own files turned it off. Null whenever the schedule is on, and always null for user-created schedules.",
234
+ ),
153
235
  isOneShot: z.boolean(),
154
236
  // A deferred wake ("remind me about this tomorrow") is an ordinary schedule
155
237
  // row created by the defer path, distinguishable only by `createdBy`, which
@@ -302,6 +384,7 @@ function serializeSchedule(
302
384
  workflowName: j.workflowName,
303
385
  sourceKey: j.sourceKey,
304
386
  userEnabled: j.userEnabled,
387
+ disarmReason: deriveDisarmReason(j),
305
388
  isOneShot: isOneShotForDisplay(j),
306
389
  isDeferred: isDeferSchedule(j.createdBy),
307
390
  };
@@ -1240,15 +1323,19 @@ async function handleRunScheduleNow(
1240
1323
 
1241
1324
  // A plugin-sourced row runs the plugin's own script or prompt, so run-now is
1242
1325
  // only offered while that plugin is something the runtime would activate.
1243
- // `declarationExistsOnDisk` is the same probe the enable path uses: it covers
1244
- // a disabled plugin, an unreadable or invalid manifest, and a declaration
1245
- // that is simply gone. Turning the feature flag off retires the surface
1246
- // wholesale and takes the same path. The row can still be armed at this
1247
- // point, because the reconciler that disarms it runs on its own schedule.
1326
+ // `pluginScheduleSourceAvailable` covers a plugin the daemon never
1327
+ // activated, a disabled plugin, an unreadable or invalid manifest, and a
1328
+ // declaration that is simply gone. Turning the feature flag off retires the
1329
+ // surface wholesale and takes the same path. The row can still be armed at
1330
+ // this point, because the reconciler that disarms it runs on its own
1331
+ // schedule.
1332
+ // Routes are served by the daemon, so the activation half of the probe reads
1333
+ // a live ledger here. Paths that run in worker processes cannot ask that
1334
+ // question and use the on-disk probe alone.
1248
1335
  if (
1249
1336
  schedule.sourceKey !== null &&
1250
1337
  (!isPluginSchedulesEnabled() ||
1251
- !(await declarationExistsOnDisk(schedule.sourceKey)))
1338
+ !(await pluginScheduleSourceAvailable(schedule.sourceKey)))
1252
1339
  ) {
1253
1340
  throw new BadRequestError(
1254
1341
  "This schedule's plugin is disabled or no longer declares it, so it cannot be run.",
@@ -19,7 +19,6 @@ import { basename, dirname, join } from "node:path";
19
19
 
20
20
  import { z } from "zod";
21
21
 
22
- import { rebindDocumentsToRenamedPath } from "../../documents/document-store.js";
23
22
  import { getWorkspaceDir } from "../../util/platform.js";
24
23
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
25
24
  import { publishSoundsConfigUpdated } from "../sync/resource-sync-events.js";
@@ -35,7 +34,6 @@ import {
35
34
  isTextMimeType,
36
35
  MAX_INLINE_TEXT_SIZE,
37
36
  resolveWorkspacePath,
38
- toWorkspaceRelativePath,
39
37
  writeWorkspaceFile,
40
38
  } from "./workspace-utils.js";
41
39
 
@@ -505,13 +503,6 @@ function handleWorkspaceRename({ body, headers }: RouteHandlerArgs) {
505
503
 
506
504
  mkdirSync(dirname(resolvedNew), { recursive: true });
507
505
  renameSync(resolvedOld, resolvedNew);
508
- // File-backed documents key off the canonical relative path, so they follow
509
- // the file to its new name. The request strings are not canonical, hence the
510
- // resolved paths.
511
- rebindDocumentsToRenamedPath({
512
- oldPath: toWorkspaceRelativePath(resolvedOld),
513
- newPath: toWorkspaceRelativePath(resolvedNew),
514
- });
515
506
  publishSoundsConfigUpdatedForPaths(
516
507
  [oldPath, newPath],
517
508
  headers?.["x-vellum-client-id"]?.trim() || undefined,
@@ -6,7 +6,7 @@ import {
6
6
  statSync,
7
7
  writeFileSync,
8
8
  } from "node:fs";
9
- import { dirname, relative, resolve, sep } from "node:path";
9
+ import { dirname, resolve, sep } from "node:path";
10
10
 
11
11
  import { getWorkspaceDir } from "../../util/platform.js";
12
12
  import { BadRequestError, ConflictError } from "./errors.js";
@@ -85,22 +85,12 @@ export function resolveWorkspacePath(
85
85
  return resolved;
86
86
  }
87
87
 
88
- /**
89
- * Normalise a workspace path to the canonical relative form used as a stable
90
- * key for the file — forward slashes, no `.`/`..` segments, no leading slash.
91
- * `absolutePath` must already have passed {@link resolveWorkspacePath}.
92
- */
93
- export function toWorkspaceRelativePath(absolutePath: string): string {
94
- return relative(getWorkspaceDir(), absolutePath).split(sep).join("/");
95
- }
96
-
97
88
  /**
98
89
  * Write bytes to a workspace-relative path, enforcing the workspace boundary.
99
90
  *
100
91
  * This is the one write primitive behind every daemon-side workspace file
101
- * write: the workspace write route and the document store's write-through for
102
- * file-backed documents both go through it, so the traversal and
103
- * directory-conflict rules cannot drift apart.
92
+ * write, so the traversal and directory-conflict rules cannot drift apart
93
+ * between callers.
104
94
  *
105
95
  * Returns the number of bytes written. Throws {@link BadRequestError} when the
106
96
  * path escapes the workspace and {@link ConflictError} when it names a
@@ -244,13 +244,18 @@ export function serializeConversationSummary(params: {
244
244
 
245
245
  /**
246
246
  * Build a full conversation detail response from a conversation ID.
247
- * Returns null if the conversation doesn't exist.
247
+ * Returns null if the conversation doesn't exist, or if it is a legacy
248
+ * `private` row: every listing hides those by type (see
249
+ * `standardListingVisibilitySql`) and migration cleanup deletes them, and the
250
+ * wire type collapses the value to `standard`, so serving one by id would let
251
+ * a client that only holds a stale id (a persisted last-viewed conversation)
252
+ * resurrect a row it can never see listed.
248
253
  */
249
254
  export function buildConversationDetailResponse(
250
255
  conversationId: string,
251
256
  ): { conversation: ReturnType<typeof serializeConversationSummary> } | null {
252
257
  const conversation = getConversation(conversationId);
253
- if (!conversation) {
258
+ if (!conversation || conversation.conversationType === "private") {
254
259
  return null;
255
260
  }
256
261
 
@@ -487,11 +487,39 @@ describe("parsePluginScheduleDeclarations", () => {
487
487
  expect(after).not.toBe(before);
488
488
  });
489
489
 
490
- test("changes when a helper file inside a directory declaration is edited or renamed", () => {
490
+ test("changes when config.json is edited", () => {
491
+ const pluginDir = makePlugin(FULL_DIGEST);
492
+ const before = parsePluginScheduleDeclarations(pluginDir, "p")
493
+ .declarations[0]!.definitionHash;
494
+ writeFileSync(
495
+ join(pluginDir, "schedules", "digest", "config.json"),
496
+ JSON.stringify({ expression: "0 10 * * *" }),
497
+ );
498
+ const after = parsePluginScheduleDeclarations(pluginDir, "p")
499
+ .declarations[0]!.definitionHash;
500
+ expect(after).not.toBe(before);
501
+ });
502
+
503
+ test("changes when the index.sh entrypoint is edited", () => {
504
+ const pluginDir = makePlugin({
505
+ "backup/config.json": JSON.stringify({ expression: "0 3 * * *" }),
506
+ "backup/index.sh": "echo v1\n",
507
+ });
508
+ const parse = () =>
509
+ parsePluginScheduleDeclarations(pluginDir, "p").declarations[0]!
510
+ .definitionHash;
511
+ const before = parse();
512
+ writeFileSync(
513
+ join(pluginDir, "schedules", "backup", "index.sh"),
514
+ "echo v2\n",
515
+ );
516
+ expect(parse()).not.toBe(before);
517
+ });
518
+
519
+ test("ignores every other file under the declaration directory", () => {
491
520
  const pluginDir = makePlugin({
492
521
  "backup/config.json": JSON.stringify({ expression: "0 3 * * *" }),
493
522
  "backup/index.sh": "sh lib/helper.sh\n",
494
- "backup/lib/helper.sh": "echo v1\n",
495
523
  });
496
524
  const parse = () =>
497
525
  parsePluginScheduleDeclarations(pluginDir, "p").declarations[0]!
@@ -505,15 +533,50 @@ describe("parsePluginScheduleDeclarations", () => {
505
533
  "lib",
506
534
  "helper.sh",
507
535
  );
536
+ mkdirSync(dirname(helperPath), { recursive: true });
537
+ writeFileSync(helperPath, "echo v1\n");
538
+ const statePath = join(
539
+ pluginDir,
540
+ "schedules",
541
+ "backup",
542
+ "data",
543
+ "state.json",
544
+ );
545
+ mkdirSync(dirname(statePath), { recursive: true });
546
+ writeFileSync(statePath, JSON.stringify({ lastRun: 1 }));
547
+ expect(parse()).toBe(original);
548
+
508
549
  writeFileSync(helperPath, "echo v2\n");
509
- const afterEdit = parse();
510
- expect(afterEdit).not.toBe(original);
550
+ writeFileSync(statePath, JSON.stringify({ lastRun: 2 }));
551
+ expect(parse()).toBe(original);
511
552
 
512
553
  renameSync(
513
554
  helperPath,
514
555
  join(pluginDir, "schedules", "backup", "lib", "helper2.sh"),
515
556
  );
516
- expect(parse()).not.toBe(afterEdit);
557
+ expect(parse()).toBe(original);
558
+
559
+ rmSync(join(pluginDir, "schedules", "backup", "data"), {
560
+ recursive: true,
561
+ force: true,
562
+ });
563
+ expect(parse()).toBe(original);
564
+ });
565
+
566
+ test("hashes a two-file declaration to its known value", () => {
567
+ const pluginDir = makePlugin({
568
+ "backup/config.json": JSON.stringify({ expression: "0 3 * * *" }),
569
+ "backup/index.sh": "echo backup\n",
570
+ });
571
+ // Pinned so stored rows keep their hash across upgrades. It holds only
572
+ // while the hashed paths stay `<name>/config.json` and
573
+ // `<name>/<entrypoint>`, relative to schedules/.
574
+ expect(
575
+ parsePluginScheduleDeclarations(pluginDir, "p").declarations[0]!
576
+ .definitionHash,
577
+ ).toBe(
578
+ "0b5722fc45abdd2c46672f275e3bcf548016ee5c0722da5a0588ef45fa7bbcab",
579
+ );
517
580
  });
518
581
 
519
582
  test("differs between two schedules with different names but same content", () => {
@@ -45,9 +45,29 @@ mock.module("../../notifications/emit-signal.js", () => ({
45
45
  },
46
46
  }));
47
47
 
48
+ /**
49
+ * Plugin directories the daemon has not activated. The reconciler arms only
50
+ * plugins this process brought up, and no test here runs the plugin loader, so
51
+ * the double answers "activated" unless a case says otherwise.
52
+ */
53
+ const notActivatedDirs = new Set<string>();
54
+
55
+ /** Every directory the reconciler put through the activation predicate. */
56
+ const activationProbes: string[] = [];
57
+
58
+ const realMtimeCache = await import("../../plugins/mtime-cache.js");
59
+ mock.module("../../plugins/mtime-cache.js", () => ({
60
+ ...realMtimeCache,
61
+ isPluginDirActivated: (dir: string) => {
62
+ activationProbes.push(dir);
63
+ return !notActivatedDirs.has(dir);
64
+ },
65
+ }));
66
+
48
67
  import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
49
68
  import { getDb } from "../../persistence/db-connection.js";
50
69
  import { initializeDb } from "../../persistence/db-init.js";
70
+ import { listInstalledPluginDirs } from "../../plugins/installed-plugin-dirs.js";
51
71
  import { getWorkspacePluginsDir } from "../../util/platform.js";
52
72
  import {
53
73
  reconcilePluginSchedules,
@@ -175,6 +195,8 @@ function resetReconcilerFixtures(): void {
175
195
  emittedSignals.length = 0;
176
196
  emitResultOverride = null;
177
197
  emitGate = null;
198
+ notActivatedDirs.clear();
199
+ activationProbes.length = 0;
178
200
  resetDefinitionErrorEmitGuardForTests();
179
201
  }
180
202
 
@@ -970,6 +992,65 @@ describe("reconcilePluginSchedules", () => {
970
992
  expect(payload.reason).toContain("no upcoming occurrences");
971
993
  });
972
994
 
995
+ test("a plugin the daemon never activated arms nothing", async () => {
996
+ // Everything the parser needs is on disk; only the activation is missing,
997
+ // which is what a directory dropped into the plugins root looks like.
998
+ const dir = writePlugin("news", digest());
999
+ notActivatedDirs.add(dir);
1000
+
1001
+ await reconcilePluginSchedules();
1002
+
1003
+ expect(listDeclaredSchedules()).toHaveLength(0);
1004
+ expect(emittedSignals).toHaveLength(0);
1005
+ });
1006
+
1007
+ test("a row disarms once its plugin is no longer activated", async () => {
1008
+ const dir = writePlugin("news", digest());
1009
+ await reconcilePluginSchedules();
1010
+ const created = listDeclaredSchedules()[0]!;
1011
+ expect(created.enabled).toBe(true);
1012
+
1013
+ notActivatedDirs.add(dir);
1014
+ await reconcilePluginSchedules();
1015
+
1016
+ const row = listDeclaredSchedules()[0]!;
1017
+ expect(row.id).toBe(created.id);
1018
+ expect(row.enabled).toBe(false);
1019
+ });
1020
+
1021
+ test("a plugin activated between passes arms on the second one", async () => {
1022
+ const dir = writePlugin("news", digest());
1023
+ notActivatedDirs.add(dir);
1024
+ await reconcilePluginSchedules();
1025
+ expect(listDeclaredSchedules()).toHaveLength(0);
1026
+
1027
+ notActivatedDirs.delete(dir);
1028
+ await reconcilePluginSchedules();
1029
+
1030
+ const rows = listDeclaredSchedules();
1031
+ expect(rows).toHaveLength(1);
1032
+ expect(rows[0]!.sourceKey).toBe(DIGEST_KEY);
1033
+ expect(rows[0]!.enabled).toBe(true);
1034
+ // The row arms for the first time here, so it announces its arrival.
1035
+ const declared = emittedSignals.filter(
1036
+ (s) => s.sourceEventName === "schedule.declared",
1037
+ );
1038
+ expect(declared).toHaveLength(1);
1039
+ expect(declared[0]!.sourceContextId).toBe(DIGEST_KEY);
1040
+ });
1041
+
1042
+ test("the activation probe sees the directory string the walk yields", async () => {
1043
+ // The predicate is keyed by directory path, so the enumeration the
1044
+ // reconciler walks and the map the daemon activates into have to agree on
1045
+ // the exact string. Both come from `listInstalledPluginDirs`.
1046
+ const dir = writePlugin("news", digest());
1047
+
1048
+ await reconcilePluginSchedules();
1049
+
1050
+ expect(listInstalledPluginDirs().map((p) => p.dir)).toEqual([dir]);
1051
+ expect(activationProbes).toEqual([dir]);
1052
+ });
1053
+
973
1054
  test("a suppressed error emit still latches the day guard", async () => {
974
1055
  writePlugin("news", brokenDigest());
975
1056
  emitResultOverride = {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Availability probe for a plugin-declared schedule's source.
3
+ *
4
+ * Composes the daemon's activation state with the on-disk declaration probe:
5
+ * did this process bring the plugin up, and is its declaration still
6
+ * sourceable.
7
+ *
8
+ * **This probe is only meaningful in the main daemon process.** Activation is
9
+ * tracked in an in-memory map that `bringUpPlugin` writes, so only the process
10
+ * that ran the plugin loader can answer for it. HTTP routes and the plugin
11
+ * schedule reconciler run there, so they may use this. Worker processes (the
12
+ * schedule worker in `schedule/worker.ts`, sidecar turn workers) activate no
13
+ * plugins, so their ledger is empty and every plugin would read as
14
+ * unactivated. Those paths call {@link declarationExistsOnDisk} directly.
15
+ *
16
+ * That split is safe because the reconciler owns arming and runs in the
17
+ * daemon: a plugin it has not activated never arms, and its sweep disarms the
18
+ * rows of a plugin that stops being activated within one pass. The window
19
+ * between those passes is the whole exposure of the worker-side disk-only
20
+ * probes.
21
+ *
22
+ * The composition lives here rather than in `./plugin-schedule-declarations.ts`
23
+ * because that parser is reachable from the plugin loader's own static graph
24
+ * and must stay free of daemon runtime state. `plugins/mtime-cache.ts` reaches
25
+ * the schedule side only through a dynamic import, so importing it here does
26
+ * not close a static cycle.
27
+ */
28
+
29
+ import { join } from "node:path";
30
+
31
+ import { isPluginDirActivated } from "../plugins/mtime-cache.js";
32
+ import { getWorkspacePluginsDir } from "../util/platform.js";
33
+ import { declarationExistsOnDisk } from "./plugin-schedule-declarations.js";
34
+
35
+ /**
36
+ * True when the schedule behind `sourceKey`
37
+ * (`plugin:<pluginName>/<scheduleName>`) may run: the daemon activated the
38
+ * plugin, and {@link declarationExistsOnDisk} still finds the declaration.
39
+ *
40
+ * The activation half is what keeps a plugin directory dropped into the
41
+ * plugins root out of the scheduler. Such a directory has files the disk probe
42
+ * accepts, but no `init` has run for it, so its hooks and tools are not live
43
+ * and its schedules must not be either.
44
+ */
45
+ export async function pluginScheduleSourceAvailable(
46
+ sourceKey: string,
47
+ ): Promise<boolean> {
48
+ const match = /^plugin:([^/]+)\/(.+)$/.exec(sourceKey);
49
+ if (!match) {
50
+ return false;
51
+ }
52
+ const [, pluginName] = match;
53
+ const pluginDir = join(getWorkspacePluginsDir(), pluginName!);
54
+ return (
55
+ isPluginDirActivated(pluginDir) &&
56
+ (await declarationExistsOnDisk(sourceKey))
57
+ );
58
+ }
@@ -30,7 +30,6 @@ import { z } from "zod";
30
30
  import { isPluginDisabled } from "../plugins/disabled-state.js";
31
31
  import { parsePluginManifest } from "../plugins/external-plugin-loader.js";
32
32
  import { isInsidePluginRoot } from "../plugins/installed-plugin-dirs.js";
33
- import { walkPluginTree } from "../plugins/plugin-tree-walk.js";
34
33
  import { FRONTMATTER_REGEX } from "../skills/frontmatter.js";
35
34
  import { getWorkspacePluginsDir } from "../util/platform.js";
36
35
  import {
@@ -77,9 +76,13 @@ export interface ScheduleDeclaration {
77
76
  scriptInvocation: string | null;
78
77
  config: PluginScheduleConfig;
79
78
  /**
80
- * Stable sha256 over the declaration's file contents and their paths
81
- * relative to `schedules/`. Any edit, addition, removal, or rename of a
82
- * declaration file changes the hash.
79
+ * Stable sha256 over the declaration's `config.json` and its entrypoint,
80
+ * each hashed together with its path relative to `schedules/`. Nothing else
81
+ * under the declaration directory takes part. Helper scripts, and any state
82
+ * a script writes beside itself, do not change the stored row definition, so
83
+ * hashing them would report a definition change every time a stateful
84
+ * schedule runs. Scripts execute by path, so an edit to a helper takes
85
+ * effect without a definition change.
83
86
  */
84
87
  definitionHash: string;
85
88
  }
@@ -292,21 +295,6 @@ function hashDeclarationFiles(files: DeclarationFile[]): string {
292
295
  return hash.digest("hex");
293
296
  }
294
297
 
295
- /**
296
- * Collect every regular file under `dir` (recursively, dot entries and
297
- * symlinks skipped) with paths relative to the schedules/ root.
298
- */
299
- function collectDeclarationFiles(
300
- dir: string,
301
- relPrefix: string,
302
- ): DeclarationFile[] {
303
- const files: DeclarationFile[] = [];
304
- walkPluginTree(dir, { excludeDotEntries: true }, (rel, abs) => {
305
- files.push({ relPath: `${relPrefix}${rel}`, content: readFileSync(abs) });
306
- });
307
- return files;
308
- }
309
-
310
298
  function shellQuote(value: string): string {
311
299
  return `'${value.replace(/'/g, `'\\''`)}'`;
312
300
  }
@@ -395,25 +383,24 @@ function parseDirectoryDeclaration(
395
383
  let message: string | null = null;
396
384
  let scriptInvocation: string | null = null;
397
385
  let mode: PluginScheduleMode;
386
+ let entrypointText: string;
398
387
  if (entrypoint === "index.md") {
399
- const content = readFileSync(join(dirPath, "index.md"), "utf8");
400
- if (FRONTMATTER_REGEX.test(content)) {
388
+ entrypointText = readFileSync(join(dirPath, "index.md"), "utf8");
389
+ if (FRONTMATTER_REGEX.test(entrypointText)) {
401
390
  return {
402
391
  error:
403
392
  "index.md must not contain frontmatter: directory-form config belongs in config.json",
404
393
  };
405
394
  }
406
- message = content.trim();
395
+ message = entrypointText.trim();
407
396
  if (!message) {
408
397
  return { error: "prompt body is empty" };
409
398
  }
410
399
  mode = "execute";
411
400
  } else {
412
401
  const scriptPath = join(dirPath, "index.sh");
413
- scriptInvocation = buildScriptInvocation(
414
- scriptPath,
415
- readFileSync(scriptPath, "utf8"),
416
- );
402
+ entrypointText = readFileSync(scriptPath, "utf8");
403
+ scriptInvocation = buildScriptInvocation(scriptPath, entrypointText);
417
404
  mode = "script";
418
405
  }
419
406
 
@@ -422,7 +409,16 @@ function parseDirectoryDeclaration(
422
409
  message,
423
410
  scriptInvocation,
424
411
  config: configResult.config,
425
- files: collectDeclarationFiles(dirPath, `${dirName}/`),
412
+ files: [
413
+ {
414
+ relPath: `${dirName}/config.json`,
415
+ content: Buffer.from(rawConfigText),
416
+ },
417
+ {
418
+ relPath: `${dirName}/${entrypoint}`,
419
+ content: Buffer.from(entrypointText),
420
+ },
421
+ ],
426
422
  };
427
423
  }
428
424
 
@@ -33,6 +33,7 @@ import type { AttentionHints } from "../notifications/signal.js";
33
33
  import { isPluginDisabled } from "../plugins/disabled-state.js";
34
34
  import { parsePluginManifest } from "../plugins/external-plugin-loader.js";
35
35
  import { listInstalledPluginDirs } from "../plugins/installed-plugin-dirs.js";
36
+ import { isPluginDirActivated } from "../plugins/mtime-cache.js";
36
37
  import { getLogger } from "../util/logger.js";
37
38
  import {
38
39
  type DeclarationError,
@@ -264,9 +265,14 @@ function isCompletedRecurrence(
264
265
  * as the plugin source collector) and gather their schedule declarations.
265
266
  * Identity = the directory basename, mirroring `parsePluginManifest`.
266
267
  *
267
- * Disabled plugins and plugins without a `schedules/` directory are skipped
268
- * entirely; in particular, only schedule-declaring plugins pay the manifest
269
- * parse each pass. A schedule-declaring plugin whose manifest fails
268
+ * Disabled plugins, plugins this daemon has not activated
269
+ * ({@link isPluginDirActivated}), and plugins without a `schedules/` directory
270
+ * are skipped entirely; in particular, only schedule-declaring plugins pay the
271
+ * manifest parse each pass. The activation gate is what keeps a directory
272
+ * dropped into the plugins root out of the desired set: nothing has run its
273
+ * `init`, so its hooks and tools are not live and its schedules must not arm
274
+ * either. It joins the set on the pass after the boot scan or an imperative
275
+ * reconcile brings it up. A schedule-declaring plugin whose manifest fails
270
276
  * `parsePluginManifest` (unreadable or schema-invalid `package.json`) also
271
277
  * contributes nothing to the desired set: the runtime loader refuses to
272
278
  * bring such a plugin up, so its schedules must not stay armed either. Each
@@ -282,6 +288,9 @@ async function collectDesiredDeclarations(): Promise<CollectedDeclarations> {
282
288
  if (isPluginDisabled(name)) {
283
289
  continue;
284
290
  }
291
+ if (!isPluginDirActivated(dir)) {
292
+ continue;
293
+ }
285
294
  if (!existsSync(join(dir, "schedules"))) {
286
295
  continue;
287
296
  }