@vellumai/assistant 0.3.28 → 0.4.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 (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -145,7 +145,7 @@ const MemoryFreshnessConfigSchema = z.object({
145
145
  .number({ error: 'memory.retrieval.freshness.maxAgeDays.opinion must be a number' })
146
146
  .nonnegative('memory.retrieval.freshness.maxAgeDays.opinion must be non-negative')
147
147
  .default(60),
148
- }).default({} as any),
148
+ }).default({ fact: 0, preference: 0, behavior: 90, event: 30, opinion: 60 }),
149
149
  staleDecay: z
150
150
  .number({ error: 'memory.retrieval.freshness.staleDecay must be a number' })
151
151
  .min(0, 'memory.retrieval.freshness.staleDecay must be >= 0')
@@ -181,15 +181,15 @@ export const MemoryRetrievalConfigSchema = z.object({
181
181
  error: 'memory.retrieval.injectionStrategy must be "prepend_user_block" or "separate_context_message"',
182
182
  })
183
183
  .default('prepend_user_block'),
184
- reranking: MemoryRerankingConfigSchema.default({} as any),
185
- freshness: MemoryFreshnessConfigSchema.default({} as any),
184
+ reranking: MemoryRerankingConfigSchema.default(MemoryRerankingConfigSchema.parse({})),
185
+ freshness: MemoryFreshnessConfigSchema.default(MemoryFreshnessConfigSchema.parse({})),
186
186
  scopePolicy: z
187
187
  .enum(['allow_global_fallback', 'strict'], {
188
188
  error: 'memory.retrieval.scopePolicy must be "allow_global_fallback" or "strict"',
189
189
  })
190
190
  .default('allow_global_fallback'),
191
- dynamicBudget: MemoryDynamicBudgetConfigSchema.default({} as any),
192
- earlyTermination: MemoryEarlyTerminationConfigSchema.default({} as any),
191
+ dynamicBudget: MemoryDynamicBudgetConfigSchema.default(MemoryDynamicBudgetConfigSchema.parse({})),
192
+ earlyTermination: MemoryEarlyTerminationConfigSchema.default(MemoryEarlyTerminationConfigSchema.parse({})),
193
193
  });
194
194
 
195
195
  export const MemorySegmentationConfigSchema = z.object({
@@ -287,7 +287,7 @@ export const MemoryEntityConfigSchema = z.object({
287
287
  .int('memory.entity.extractRelations.backfillBatchSize must be an integer')
288
288
  .positive('memory.entity.extractRelations.backfillBatchSize must be a positive integer')
289
289
  .default(200),
290
- }).default({} as any),
290
+ }).default({ enabled: true, backfillBatchSize: 200 }),
291
291
  relationRetrieval: z.object({
292
292
  enabled: z
293
293
  .boolean({ error: 'memory.entity.relationRetrieval.enabled must be a boolean' })
@@ -320,7 +320,15 @@ export const MemoryEntityConfigSchema = z.object({
320
320
  depthDecay: z
321
321
  .boolean({ error: 'memory.entity.relationRetrieval.depthDecay must be a boolean' })
322
322
  .default(true),
323
- }).default({} as any),
323
+ }).default({
324
+ enabled: true,
325
+ maxSeedEntities: 8,
326
+ maxNeighborEntities: 20,
327
+ maxEdges: 40,
328
+ neighborScoreMultiplier: 0.7,
329
+ maxDepth: 3,
330
+ depthDecay: true,
331
+ }),
324
332
  });
325
333
 
326
334
  export const MemoryConflictsConfigSchema = z.object({
@@ -384,18 +392,18 @@ export const MemoryConfigSchema = z.object({
384
392
  enabled: z
385
393
  .boolean({ error: 'memory.enabled must be a boolean' })
386
394
  .default(true),
387
- embeddings: MemoryEmbeddingsConfigSchema.default({} as any),
388
- qdrant: QdrantConfigSchema.default({} as any),
389
- retrieval: MemoryRetrievalConfigSchema.default({} as any),
390
- segmentation: MemorySegmentationConfigSchema.default({} as any),
391
- jobs: MemoryJobsConfigSchema.default({} as any),
392
- retention: MemoryRetentionConfigSchema.default({} as any),
393
- cleanup: MemoryCleanupConfigSchema.default({} as any),
394
- extraction: MemoryExtractionConfigSchema.default({} as any),
395
- summarization: MemorySummarizationConfigSchema.default({} as any),
396
- entity: MemoryEntityConfigSchema.default({} as any),
397
- conflicts: MemoryConflictsConfigSchema.default({} as any),
398
- profile: MemoryProfileConfigSchema.default({} as any),
395
+ embeddings: MemoryEmbeddingsConfigSchema.default(MemoryEmbeddingsConfigSchema.parse({})),
396
+ qdrant: QdrantConfigSchema.default(QdrantConfigSchema.parse({})),
397
+ retrieval: MemoryRetrievalConfigSchema.default(MemoryRetrievalConfigSchema.parse({})),
398
+ segmentation: MemorySegmentationConfigSchema.default(MemorySegmentationConfigSchema.parse({})),
399
+ jobs: MemoryJobsConfigSchema.default(MemoryJobsConfigSchema.parse({})),
400
+ retention: MemoryRetentionConfigSchema.default(MemoryRetentionConfigSchema.parse({})),
401
+ cleanup: MemoryCleanupConfigSchema.default(MemoryCleanupConfigSchema.parse({})),
402
+ extraction: MemoryExtractionConfigSchema.default(MemoryExtractionConfigSchema.parse({})),
403
+ summarization: MemorySummarizationConfigSchema.default(MemorySummarizationConfigSchema.parse({})),
404
+ entity: MemoryEntityConfigSchema.default(MemoryEntityConfigSchema.parse({})),
405
+ conflicts: MemoryConflictsConfigSchema.default(MemoryConflictsConfigSchema.parse({})),
406
+ profile: MemoryProfileConfigSchema.default(MemoryProfileConfigSchema.parse({})),
399
407
  });
400
408
 
401
409
  export type MemoryEmbeddingsConfig = z.infer<typeof MemoryEmbeddingsConfigSchema>;
@@ -206,34 +206,34 @@ export const AssistantConfigSchema = z.object({
206
206
  .int('maxToolUseTurns must be an integer')
207
207
  .positive('maxToolUseTurns must be a positive integer')
208
208
  .default(60),
209
- thinking: ThinkingConfigSchema.default({} as any),
210
- contextWindow: ContextWindowConfigSchema.default({} as any),
211
- memory: MemoryConfigSchema.default({} as any),
209
+ thinking: ThinkingConfigSchema.default(ThinkingConfigSchema.parse({})),
210
+ contextWindow: ContextWindowConfigSchema.default(ContextWindowConfigSchema.parse({})),
211
+ memory: MemoryConfigSchema.default(MemoryConfigSchema.parse({})),
212
212
  dataDir: z
213
213
  .string({ error: 'dataDir must be a string' })
214
214
  .default(getDataDir()),
215
- timeouts: TimeoutConfigSchema.default({} as any),
216
- sandbox: SandboxConfigSchema.default({} as any),
217
- rateLimit: RateLimitConfigSchema.default({} as any),
218
- secretDetection: SecretDetectionConfigSchema.default({} as any),
219
- permissions: PermissionsConfigSchema.default({} as any),
220
- auditLog: AuditLogConfigSchema.default({} as any),
221
- logFile: LogFileConfigSchema.default({} as any),
215
+ timeouts: TimeoutConfigSchema.default(TimeoutConfigSchema.parse({})),
216
+ sandbox: SandboxConfigSchema.default(SandboxConfigSchema.parse({})),
217
+ rateLimit: RateLimitConfigSchema.default(RateLimitConfigSchema.parse({})),
218
+ secretDetection: SecretDetectionConfigSchema.default(SecretDetectionConfigSchema.parse({})),
219
+ permissions: PermissionsConfigSchema.default(PermissionsConfigSchema.parse({})),
220
+ auditLog: AuditLogConfigSchema.default(AuditLogConfigSchema.parse({})),
221
+ logFile: LogFileConfigSchema.default(LogFileConfigSchema.parse({})),
222
222
  pricingOverrides: z
223
223
  .array(ModelPricingOverrideSchema)
224
224
  .default([]),
225
- heartbeat: HeartbeatConfigSchema.default({} as any),
226
- swarm: SwarmConfigSchema.default({} as any),
227
- mcp: McpConfigSchema.default({} as any),
228
- skills: SkillsConfigSchema.default({} as any),
229
- workspaceGit: WorkspaceGitConfigSchema.default({} as any),
230
- calls: CallsConfigSchema.default({} as any),
231
- sms: SmsConfigSchema.default({} as any),
225
+ heartbeat: HeartbeatConfigSchema.default(HeartbeatConfigSchema.parse({})),
226
+ swarm: SwarmConfigSchema.default(SwarmConfigSchema.parse({})),
227
+ mcp: McpConfigSchema.default(McpConfigSchema.parse({})),
228
+ skills: SkillsConfigSchema.default(SkillsConfigSchema.parse({})),
229
+ workspaceGit: WorkspaceGitConfigSchema.default(WorkspaceGitConfigSchema.parse({})),
230
+ calls: CallsConfigSchema.default(CallsConfigSchema.parse({})),
231
+ sms: SmsConfigSchema.default(SmsConfigSchema.parse({})),
232
232
  ingress: IngressConfigSchema,
233
- platform: PlatformConfigSchema.default({} as any),
234
- daemon: DaemonConfigSchema.default({} as any),
235
- notifications: NotificationsConfigSchema.default({} as any),
236
- ui: UiConfigSchema.default({} as any),
233
+ platform: PlatformConfigSchema.default(PlatformConfigSchema.parse({})),
234
+ daemon: DaemonConfigSchema.default(DaemonConfigSchema.parse({})),
235
+ notifications: NotificationsConfigSchema.default(NotificationsConfigSchema.parse({})),
236
+ ui: UiConfigSchema.default(UiConfigSchema.parse({})),
237
237
  featureFlags: z
238
238
  .record(z.string(), z.boolean({ error: 'featureFlags values must be booleans' }))
239
239
  .default({} as any),
@@ -24,8 +24,8 @@ export const RemoteProviderConfigSchema = z.object({
24
24
  });
25
25
 
26
26
  export const RemoteProvidersConfigSchema = z.object({
27
- skillssh: RemoteProviderConfigSchema.default({} as any),
28
- clawhub: RemoteProviderConfigSchema.default({} as any),
27
+ skillssh: RemoteProviderConfigSchema.default(RemoteProviderConfigSchema.parse({})),
28
+ clawhub: RemoteProviderConfigSchema.default(RemoteProviderConfigSchema.parse({})),
29
29
  });
30
30
 
31
31
  // 'unknown' is valid as a risk label on a skill but not as a threshold — setting the threshold
@@ -41,12 +41,12 @@ export const RemotePolicyConfigSchema = z.object({
41
41
  });
42
42
 
43
43
  export const SkillsConfigSchema = z.object({
44
- entries: z.record(z.string(), SkillEntryConfigSchema).default({} as any),
45
- load: SkillsLoadConfigSchema.default({} as any),
46
- install: SkillsInstallConfigSchema.default({} as any),
44
+ entries: z.record(z.string(), SkillEntryConfigSchema).default({} as Record<string, never>),
45
+ load: SkillsLoadConfigSchema.default(SkillsLoadConfigSchema.parse({})),
46
+ install: SkillsInstallConfigSchema.default(SkillsInstallConfigSchema.parse({})),
47
47
  allowBundled: z.array(z.string()).nullable().default(null),
48
- remoteProviders: RemoteProvidersConfigSchema.default({} as any),
49
- remotePolicy: RemotePolicyConfigSchema.default({} as any),
48
+ remoteProviders: RemoteProvidersConfigSchema.default(RemoteProvidersConfigSchema.parse({})),
49
+ remotePolicy: RemotePolicyConfigSchema.default(RemotePolicyConfigSchema.parse({})),
50
50
  });
51
51
 
52
52
  export type SkillEntryConfig = z.infer<typeof SkillEntryConfigSchema>;
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: "Trusted Contacts"
3
- description: "Manage trusted contacts and Telegram invite links — list, allow, revoke, block users, and create/list/revoke invite links for external channels"
3
+ description: "Manage trusted contacts and invite links — list, allow, revoke, block users, and create/list/revoke invite links for Telegram and voice (phone call) channels"
4
4
  user-invocable: true
5
5
  metadata: {"vellum": {"emoji": "\ud83d\udc65"}}
6
6
  ---
7
7
 
8
- You are helping your user manage trusted contacts and invite links for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. Invite links let the guardian share a Telegram deep link that automatically grants access when opened. All operations go through the gateway HTTP API using `curl` with bearer auth.
8
+ You are helping your user manage trusted contacts and invite links for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram, SMS, and voice (phone calls). Invite links let the guardian share a Telegram deep link that automatically grants access when opened. Voice invites let the guardian authorize a specific phone number to call in — the invitee must call from that phone number AND enter a one-time numeric code. All operations go through the gateway HTTP API using `curl` with bearer auth.
9
9
 
10
10
  ## Prerequisites
11
11
 
@@ -18,8 +18,9 @@ You are helping your user manage trusted contacts and invite links for the Vellu
18
18
  - **Member**: A user identity (external user ID or chat ID) from a specific channel that has been registered with a policy.
19
19
  - **Policy**: Controls what the member can do — `allow` (can message freely) or `deny` (blocked from messaging).
20
20
  - **Status**: The member's lifecycle state — `active` (currently effective), `revoked` (access removed), or `blocked` (explicitly denied).
21
- - **Source channel**: The messaging platform the contact uses (e.g., `telegram`, `sms`).
21
+ - **Source channel**: The messaging platform the contact uses (e.g., `telegram`, `sms`, `voice`).
22
22
  - **Invite link**: A shareable Telegram deep link that, when opened by someone, automatically grants them trusted-contact access. Each invite has a token, usage limits, and optional expiration.
23
+ - **Voice invite**: An invite bound to a specific phone number for phone-call access. The guardian provides the invitee's phone number (E.164 format, e.g., `+15551234567`), and the system generates a numeric code. The invitee must call from that exact phone number AND enter the code when prompted. Both conditions must be met — the call must originate from the bound number, and the correct code must be entered. Voice invites do not have a Telegram-style deep link and do not use `/start` payload tokens. SMS-based invites are not supported.
23
24
 
24
25
  ## Available Actions
25
26
 
@@ -128,14 +129,6 @@ Use this when the guardian wants to invite someone to message the assistant on T
128
129
  ```bash
129
130
  TOKEN=$(cat ~/.vellum/http-token)
130
131
 
131
- BOT_CONFIG_JSON=$(curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
132
- -H "Authorization: Bearer $TOKEN")
133
- BOT_USERNAME=$(printf '%s' "$BOT_CONFIG_JSON" | tr -d '\n' | sed -n 's/.*"botUsername"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
134
- if [ -z "$BOT_USERNAME" ]; then
135
- echo "error:no_bot_username"
136
- exit 1
137
- fi
138
-
139
132
  INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
140
133
  -H "Content-Type: application/json" \
141
134
  -H "Authorization: Bearer $TOKEN" \
@@ -144,14 +137,40 @@ INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
144
137
  "maxUses": 1,
145
138
  "note": "<optional note, e.g. the person it is for>"
146
139
  }')
147
- INVITE_TOKEN=$(printf '%s' "$INVITE_JSON" | tr -d '\n' | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
140
+
141
+ INVITE_TOKEN=$(printf '%s' "$INVITE_JSON" | python3 -c "
142
+ import json, sys
143
+ data = json.load(sys.stdin)
144
+ invite = data.get('invite', {})
145
+ print(invite.get('token', ''), end='')
146
+ ")
147
+ INVITE_URL=$(printf '%s' "$INVITE_JSON" | python3 -c "
148
+ import json, sys
149
+ data = json.load(sys.stdin)
150
+ invite = data.get('invite', {})
151
+ share = invite.get('share') or {}
152
+ print(share.get('url', ''), end='')
153
+ ")
154
+
148
155
  if [ -z "$INVITE_TOKEN" ]; then
149
156
  printf '%s\n' "$INVITE_JSON"
150
157
  exit 1
151
158
  fi
152
159
 
160
+ # Prefer backend-provided canonical link when available.
161
+ if [ -z "$INVITE_URL" ]; then
162
+ BOT_CONFIG_JSON=$(curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
163
+ -H "Authorization: Bearer $TOKEN")
164
+ BOT_USERNAME=$(printf '%s' "$BOT_CONFIG_JSON" | tr -d '\n' | sed -n 's/.*"botUsername"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
165
+ if [ -z "$BOT_USERNAME" ]; then
166
+ echo "error:no_share_url_or_bot_username"
167
+ exit 1
168
+ fi
169
+ INVITE_URL="https://t.me/$BOT_USERNAME?start=iv_$INVITE_TOKEN"
170
+ fi
171
+
153
172
  echo "<vellum-sensitive-output kind=\"invite_code\" value=\"$INVITE_TOKEN\" />"
154
- echo "https://t.me/$BOT_USERNAME?start=iv_$INVITE_TOKEN"
173
+ echo "$INVITE_URL"
155
174
  ```
156
175
 
157
176
  Optional fields:
@@ -159,7 +178,11 @@ Optional fields:
159
178
  - `expiresInMs` — expiration time in milliseconds from now (e.g., `86400000` for 24 hours). Defaults to 7 days (`604800000`) if omitted.
160
179
  - `note` — a human-readable label for the invite (e.g., "For Mom", "Family group").
161
180
 
162
- The create response contains `{ ok: true, invite: { id, token, ... } }`. The `token` field is the raw invite token — it is only returned at creation time and cannot be retrieved later.
181
+ The create response contains `{ ok: true, invite: { id, token, share?, ... } }`.
182
+ - `token` is the raw invite token and is only returned at creation time.
183
+ - `share.url` is the canonical shareable deep link (when channel transport config is available).
184
+
185
+ Always use `invite.share.url` when present. Do not manually construct `?start=` links if the API already provided one.
163
186
 
164
187
  **Presenting to the guardian**: Give the guardian the link with clear copy-paste instructions:
165
188
 
@@ -216,9 +239,99 @@ curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
216
239
 
217
240
  Replace `<invite_id>` with the invite's `id` from the list response.
218
241
 
242
+ ### 8. Create a voice invite
243
+
244
+ Use this when the guardian wants to authorize a specific phone number to call the assistant. Voice invites are identity-bound: the invitee must call from the specified phone number AND enter a one-time numeric code.
245
+
246
+ **Important**: The response includes a `voiceCode` field that is only returned at creation time and cannot be retrieved later. Extract and present it clearly.
247
+
248
+ ```bash
249
+ TOKEN=$(cat ~/.vellum/http-token)
250
+
251
+ INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
252
+ -H "Content-Type: application/json" \
253
+ -H "Authorization: Bearer $TOKEN" \
254
+ -d '{
255
+ "sourceChannel": "voice",
256
+ "expectedExternalUserId": "<phone_number_E164>",
257
+ "maxUses": 1,
258
+ "note": "<optional note, e.g. the person it is for>"
259
+ }')
260
+ printf '%s\n' "$INVITE_JSON"
261
+ ```
262
+
263
+ Required fields:
264
+ - `sourceChannel` — must be `"voice"`
265
+ - `expectedExternalUserId` — the invitee's phone number in E.164 format (e.g., `+15551234567`)
266
+
267
+ Optional fields:
268
+ - `maxUses` — how many times the code can be used (default: 1)
269
+ - `expiresInMs` — expiration time in milliseconds from now (e.g., `86400000` for 24 hours). Defaults to 7 days if omitted.
270
+ - ~~`voiceCodeDigits`~~ — always 6 digits; this parameter is accepted but ignored
271
+ - `note` — a human-readable label for the invite (e.g., "For Mom", "Dr. Smith")
272
+
273
+ The create response contains `{ ok: true, invite: { id, voiceCode, expectedExternalUserId, ... } }`.
274
+ - `voiceCode` is the numeric code the invitee must enter and is only returned at creation time.
275
+ - Voice invite responses do **not** include `token` or `share.url`. Do not try to build or send a deep link for voice invites.
276
+
277
+ **Presenting to the guardian**: Give the guardian clear instructions to relay to the invitee:
278
+
279
+ > Voice invite created for **<phone_number>**:
280
+ >
281
+ > **Invite code: `<voiceCode>`**
282
+ >
283
+ > Share these instructions with the person you are inviting:
284
+ > 1. Call the assistant's phone number from **<phone_number>** (the call must come from this exact number)
285
+ > 2. When prompted, enter the code **<voiceCode>**
286
+ > 3. Once verified, they will be added as a trusted contact and can call the assistant directly in the future
287
+ >
288
+ > This code can be used <maxUses> time(s)<and expires in X hours/days if applicable>.
289
+
290
+ There is no "open link" step for voice invites. The invite is redeemed only during a live phone call from the bound number.
291
+
292
+ If the user provides a phone number without the `+` country code prefix, ask them to confirm the full E.164 number (e.g., US numbers should be `+1XXXXXXXXXX`).
293
+
294
+ **Note**: SMS-based invites are not currently supported. Only voice (phone call) invites are available for phone-based access.
295
+
296
+ ### 9. List voice invites
297
+
298
+ Use this to show the guardian their active voice invites.
299
+
300
+ ```bash
301
+ TOKEN=$(cat ~/.vellum/http-token)
302
+ curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites?sourceChannel=voice" \
303
+ -H "Authorization: Bearer $TOKEN"
304
+ ```
305
+
306
+ Optional query parameters:
307
+ - `status` — filter by status (`active`, `revoked`, `redeemed`, `expired`)
308
+
309
+ The response format is the same as regular invites but voice invites also include:
310
+ - `expectedExternalUserId` — the bound phone number
311
+ - `voiceCodeDigits` — always 6 (the code itself is not retrievable after creation)
312
+ - `token` and `share` are not present for voice invites
313
+
314
+ **Presenting results**: Format as a readable list. Show the note (or "unnamed" as fallback), bound phone number, status, uses remaining, and expiration. Highlight which invites are still active.
315
+
316
+ ### 10. Revoke a voice invite
317
+
318
+ Use this when the guardian wants to cancel an active voice invite. **Always confirm before revoking.**
319
+
320
+ Ask the user: *"I'll revoke the voice invite for [phone number or note]. The code will no longer work. Should I proceed?"*
321
+
322
+ First, list voice invites to find the invite's `id`, then revoke:
323
+
324
+ ```bash
325
+ TOKEN=$(cat ~/.vellum/http-token)
326
+ curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
327
+ -H "Authorization: Bearer $TOKEN"
328
+ ```
329
+
330
+ Replace `<invite_id>` with the invite's `id` from the list response. The same revoke endpoint is used for both Telegram and voice invites.
331
+
219
332
  ## Confirmation Requirements
220
333
 
221
- **All mutating actions (allow, revoke, block, revoke invite) require explicit user confirmation before execution.** This is a safety measure — modifying who can access the assistant should always be a deliberate choice. Creating an invite link does not require confirmation since it does not grant access until someone opens it.
334
+ **All mutating actions (allow, revoke, block, revoke invite) require explicit user confirmation before execution.** This is a safety measure — modifying who can access the assistant should always be a deliberate choice. Creating an invite (Telegram link or voice invite) does not require confirmation since it does not grant access until the invitee redeems it.
222
335
 
223
336
  - Clearly state what action you are about to take and who it affects.
224
337
  - Wait for the user to confirm before running the curl command.
@@ -232,7 +345,9 @@ Replace `<invite_id>` with the invite's `id` from the list response.
232
345
  - `At least one of externalUserId or externalChatId is required` — ask the user for the contact's channel-specific identifier.
233
346
  - `Member not found or cannot be revoked` — the member ID may be invalid or the member is already revoked.
234
347
  - `Member not found or already blocked` — the member ID may be invalid or the member is already blocked.
235
- - `sourceChannel is required for create` — when creating an invite, always pass `"sourceChannel": "telegram"`.
348
+ - `sourceChannel is required for create` — when creating an invite, always pass `"sourceChannel": "telegram"` for Telegram or `"sourceChannel": "voice"` for voice invites.
349
+ - `expectedExternalUserId is required for voice invites` — voice invites must include the invitee's phone number.
350
+ - `expectedExternalUserId must be in E.164 format` — the phone number must start with `+` followed by country code and number (e.g., `+15551234567`).
236
351
  - `Invite not found or already revoked` — the invite ID may be invalid or the invite is already revoked.
237
352
 
238
353
  ## Typical Workflows
@@ -252,3 +367,11 @@ Replace `<invite_id>` with the invite's `id` from the list response.
252
367
  **"Show my invites"** / **"List active invite links"** — List invites filtered by `sourceChannel=telegram`, present active invites with uses remaining and expiration info.
253
368
 
254
369
  **"Revoke invite"** / **"Cancel invite link"** — List invites to identify the target, confirm, then revoke by ID.
370
+
371
+ **"Create a voice invite for +15551234567"** — Create a voice invite with `sourceChannel: "voice"` and the given phone number as `expectedExternalUserId`. Present the invite code and instructions: the person must call from that number and enter the code.
372
+
373
+ **"Let my mom call in"** / **"Invite someone by phone"** — Ask for the phone number in E.164 format, create a voice invite, and present the code + calling instructions.
374
+
375
+ **"Show my voice invites"** / **"List phone invites"** — List invites filtered by `sourceChannel=voice`, present active invites with bound phone number and expiration info.
376
+
377
+ **"Revoke voice invite"** / **"Cancel the phone invite for +15551234567"** — List voice invites, identify the target by phone number or note, confirm, then revoke by ID.
@@ -303,9 +303,9 @@ async function executeApprove(
303
303
  }
304
304
  }
305
305
  session.setAssistantId(assistantId);
306
- // The guardian approved this escalation, so tag as guardian to avoid
307
- // 'unverified_channel' blocking memory extraction.
308
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: sourceChannel ?? 'vellum' });
306
+ // The guardian approved this escalation, so tag as guardian trust to avoid
307
+ // unknown-provenance memory gating.
308
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'vellum' });
309
309
  session.setCommandIntent(null);
310
310
 
311
311
  // Process the message through the agent loop (no IPC event callback
@@ -379,7 +379,7 @@ async function executeDeny(
379
379
  // Store a system note about the denial in the conversation
380
380
  const denialInterface = isInterfaceId(sourceChannel) ? sourceChannel : undefined;
381
381
  await addMessage(conversationId, 'assistant', denialText, {
382
- provenanceActorRole: 'guardian' as const,
382
+ provenanceTrustClass: 'guardian' as const,
383
383
  userMessageChannel: sourceChannel,
384
384
  assistantMessageChannel: sourceChannel,
385
385
  ...(denialInterface ? { userMessageInterface: denialInterface, assistantMessageInterface: denialInterface } : {}),
@@ -2,18 +2,26 @@ import * as net from 'node:net';
2
2
 
3
3
  import { v4 as uuid } from 'uuid';
4
4
 
5
+ import { createAssistantMessage, createUserMessage } from '../../agent/message-types.js';
5
6
  import { type InterfaceId,isChannelId, parseChannelId, parseInterfaceId } from '../../channels/types.js';
6
7
  import { getConfig } from '../../config/loader.js';
7
8
  import { getAttachmentsForMessage, getFilePathForAttachment, setAttachmentThumbnail } from '../../memory/attachments-store.js';
9
+ import {
10
+ listCanonicalGuardianRequests,
11
+ listPendingCanonicalGuardianRequestsByDestinationConversation,
12
+ } from '../../memory/canonical-guardian-store.js';
8
13
  import { getAttentionStateByConversationIds } from '../../memory/conversation-attention-store.js';
9
14
  import * as conversationStore from '../../memory/conversation-store.js';
10
15
  import { GENERATING_TITLE, queueGenerateConversationTitle, UNTITLED_FALLBACK } from '../../memory/conversation-title-service.js';
11
16
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
17
+ import { routeGuardianReply } from '../../runtime/guardian-reply-router.js';
18
+ import * as pendingInteractions from '../../runtime/pending-interactions.js';
12
19
  import { checkIngressForSecrets } from '../../security/secret-ingress.js';
13
20
  import { compileCustomPatterns, redactSecrets } from '../../security/secret-scanner.js';
14
21
  import { getSubagentManager } from '../../subagent/index.js';
15
22
  import { silentlyWithLog } from '../../util/silently.js';
16
23
  import { truncate } from '../../util/truncate.js';
24
+ import { createApprovalConversationGenerator } from '../approval-generators.js';
17
25
  import { getAssistantName } from '../identity-helpers.js';
18
26
  import type { UserMessageAttachment } from '../ipc-contract.js';
19
27
  import type {
@@ -56,6 +64,8 @@ import {
56
64
  wireEscalationHandler,
57
65
  } from './shared.js';
58
66
 
67
+ const desktopApprovalConversationGenerator = createApprovalConversationGenerator();
68
+
59
69
  export async function handleUserMessage(
60
70
  msg: UserMessage,
61
71
  socket: net.Socket,
@@ -172,9 +182,9 @@ export async function handleUserMessage(
172
182
  assistantMessageInterface: ipcInterface,
173
183
  });
174
184
  session.setAssistantId('self');
175
- // IPC/desktop user IS the guardian — default to guardian role so messages
176
- // are not tagged 'unverified_channel' (which blocks memory extraction).
177
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: ipcChannel });
185
+ // IPC/desktop user IS the guardian — default to guardian trust so
186
+ // messages are not tagged as unknown provenance.
187
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: ipcChannel });
178
188
  session.setCommandIntent(null);
179
189
  // Fire-and-forget: don't block the IPC handler so the connection can
180
190
  // continue receiving messages (e.g. cancel, confirmations, or
@@ -451,12 +461,146 @@ export async function handleUserMessage(
451
461
  }
452
462
  }
453
463
 
464
+ // If exactly one live turn is waiting on confirmation (no queued turns),
465
+ // try to consume this text as an inline approval decision first.
466
+ if (
467
+ session.hasAnyPendingConfirmation()
468
+ && session.getQueueDepth() === 0
469
+ && messageText.trim().length > 0
470
+ ) {
471
+ try {
472
+ const pendingInteractionRequestIdsForConversation = pendingInteractions
473
+ .getByConversation(msg.sessionId)
474
+ .filter(
475
+ (interaction) =>
476
+ interaction.kind === 'confirmation'
477
+ && interaction.session === session
478
+ && session.hasPendingConfirmation(interaction.requestId),
479
+ )
480
+ .map((interaction) => interaction.requestId);
481
+
482
+ const pendingCanonicalRequestIdsForConversation = [
483
+ ...listPendingCanonicalGuardianRequestsByDestinationConversation(msg.sessionId, ipcChannel)
484
+ .filter((request) => request.kind === 'tool_approval')
485
+ .map((request) => request.id),
486
+ ...listCanonicalGuardianRequests({
487
+ status: 'pending',
488
+ conversationId: msg.sessionId,
489
+ kind: 'tool_approval',
490
+ }).map((request) => request.id),
491
+ ].filter((pendingRequestId) => session.hasPendingConfirmation(pendingRequestId));
492
+
493
+ const pendingRequestIdsForConversation = Array.from(new Set([
494
+ ...pendingInteractionRequestIdsForConversation,
495
+ ...pendingCanonicalRequestIdsForConversation,
496
+ ]));
497
+
498
+ if (pendingRequestIdsForConversation.length > 0) {
499
+ const routerResult = await routeGuardianReply({
500
+ messageText: messageText.trim(),
501
+ channel: ipcChannel,
502
+ actor: {
503
+ externalUserId: undefined,
504
+ channel: ipcChannel,
505
+ isTrusted: true,
506
+ },
507
+ conversationId: msg.sessionId,
508
+ pendingRequestIds: pendingRequestIdsForConversation,
509
+ approvalConversationGenerator: desktopApprovalConversationGenerator,
510
+ });
511
+
512
+ if (routerResult.consumed && routerResult.type !== 'nl_keep_pending') {
513
+ const consumedChannelMeta = {
514
+ userMessageChannel: ipcChannel,
515
+ assistantMessageChannel: ipcChannel,
516
+ userMessageInterface: ipcInterface,
517
+ assistantMessageInterface: ipcInterface,
518
+ provenanceActorRole: 'guardian' as const,
519
+ };
520
+
521
+ const consumedUserMessage = createUserMessage(messageText, msg.attachments ?? []);
522
+ await conversationStore.addMessage(
523
+ msg.sessionId,
524
+ 'user',
525
+ JSON.stringify(consumedUserMessage.content),
526
+ consumedChannelMeta,
527
+ );
528
+
529
+ const replyText = (routerResult.replyText?.trim())
530
+ || (routerResult.decisionApplied ? 'Decision applied.' : 'Request already resolved.');
531
+ const consumedAssistantMessage = createAssistantMessage(replyText);
532
+ await conversationStore.addMessage(
533
+ msg.sessionId,
534
+ 'assistant',
535
+ JSON.stringify(consumedAssistantMessage.content),
536
+ consumedChannelMeta,
537
+ );
538
+ // Avoid mutating in-memory history while an agent loop is active;
539
+ // the loop owns history reconstruction for the in-flight turn.
540
+ if (!session.isProcessing()) {
541
+ // Keep in-memory history aligned with persisted transcript so
542
+ // session-history operations (undo/regenerate) target the same turn.
543
+ session.messages.push(consumedUserMessage, consumedAssistantMessage);
544
+ }
545
+
546
+ // Mirror the normal queued/dequeued lifecycle so desktop clients can
547
+ // reconcile queued bubble state for this just-sent user message.
548
+ ctx.send(socket, {
549
+ type: 'message_queued',
550
+ sessionId: msg.sessionId,
551
+ requestId,
552
+ position: 0,
553
+ });
554
+ ctx.send(socket, {
555
+ type: 'message_dequeued',
556
+ sessionId: msg.sessionId,
557
+ requestId,
558
+ });
559
+
560
+ // Only emit the reply delta when no agent turn is in-flight.
561
+ // When the agent is active, currentAssistantMessageId on the client
562
+ // points to the agent's streaming message and this delta would
563
+ // contaminate it. The reply is already persisted to the DB, so the
564
+ // client will see it on the next transcript reload / session switch.
565
+ if (!session.isProcessing()) {
566
+ ctx.send(socket, {
567
+ type: 'assistant_text_delta',
568
+ text: replyText,
569
+ sessionId: msg.sessionId,
570
+ });
571
+ }
572
+ ctx.send(socket, {
573
+ type: 'message_request_complete',
574
+ sessionId: msg.sessionId,
575
+ requestId,
576
+ runStillActive: session.isProcessing(),
577
+ });
578
+
579
+ rlog.info(
580
+ { routerType: routerResult.type, decisionApplied: routerResult.decisionApplied, routerRequestId: routerResult.requestId },
581
+ 'Consumed pending-confirmation reply before auto-deny',
582
+ );
583
+ return;
584
+ }
585
+ }
586
+ } catch (err) {
587
+ rlog.warn({ err }, 'Failed to process pending-confirmation reply; falling back to auto-deny behavior');
588
+ }
589
+ }
590
+
454
591
  // If the session has a pending tool confirmation, auto-deny it so the
455
- // agent can process the user's follow-up message instead. The agent
592
+ // agent can process the user's follow-up message instead. The agent
456
593
  // will see the denial and can re-request the tool if still needed.
457
594
  if (session.hasAnyPendingConfirmation()) {
458
595
  rlog.info('Auto-denying pending confirmation(s) due to new user message');
459
596
  session.denyAllPendingConfirmations();
597
+ // Keep the pending-interaction tracker aligned with the prompter so
598
+ // stale request IDs are not reused as routing candidates.
599
+ for (const interaction of pendingInteractions.getByConversation(msg.sessionId)) {
600
+ if (interaction.session === session && interaction.kind === 'confirmation') {
601
+ pendingInteractions.resolve(interaction.requestId);
602
+ }
603
+ }
460
604
  }
461
605
 
462
606
  dispatchUserMessage(
@@ -173,6 +173,21 @@ export interface MessageDequeued {
173
173
  requestId: string;
174
174
  }
175
175
 
176
+ /**
177
+ * Request-level terminal signal for a user message lifecycle.
178
+ *
179
+ * Unlike `message_complete`, this does not imply the active assistant turn
180
+ * has completed. It is used for paths that consume a request inline while a
181
+ * separate in-flight turn may still be running.
182
+ */
183
+ export interface MessageRequestComplete {
184
+ type: 'message_request_complete';
185
+ sessionId: string;
186
+ requestId: string;
187
+ /** True when an existing turn is still running after this request is finalized. */
188
+ runStillActive?: boolean;
189
+ }
190
+
176
191
  export interface MessageQueuedDeleted {
177
192
  type: 'message_queued_deleted';
178
193
  sessionId: string;
@@ -241,6 +256,7 @@ export type _MessagesServerMessages =
241
256
  | SecretDetected
242
257
  | MessageQueued
243
258
  | MessageDequeued
259
+ | MessageRequestComplete
244
260
  | MessageQueuedDeleted
245
261
  | SuggestionResponse
246
262
  | TraceEvent;
@@ -267,6 +267,7 @@
267
267
  "message_dequeued",
268
268
  "message_queued",
269
269
  "message_queued_deleted",
270
+ "message_request_complete",
270
271
  "model_info",
271
272
  "navigate_settings",
272
273
  "notification_intent",