@vellumai/assistant 0.3.14 → 0.3.16

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 (295) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +2 -2
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +331 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-control-plane-policy.test.ts +1 -3
  40. package/src/__tests__/guardian-outbound-http.test.ts +202 -10
  41. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  42. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  43. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  44. package/src/__tests__/hooks-runner.test.ts +13 -4
  45. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  46. package/src/__tests__/intent-routing.test.ts +14 -0
  47. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  48. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  49. package/src/__tests__/memory-regressions.test.ts +16 -12
  50. package/src/__tests__/non-member-access-request.test.ts +282 -0
  51. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  52. package/src/__tests__/notification-routing-intent.test.ts +11 -2
  53. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  54. package/src/__tests__/recording-intent-fallback.test.ts +0 -1
  55. package/src/__tests__/recording-intent-handler.test.ts +6 -3
  56. package/src/__tests__/recording-intent.test.ts +3 -2
  57. package/src/__tests__/recording-state-machine.test.ts +337 -26
  58. package/src/__tests__/registry.test.ts +17 -8
  59. package/src/__tests__/relay-server.test.ts +105 -0
  60. package/src/__tests__/reminder.test.ts +13 -0
  61. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  63. package/src/__tests__/server-history-render.test.ts +8 -8
  64. package/src/__tests__/session-agent-loop.test.ts +1 -0
  65. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  66. package/src/__tests__/session-skill-tools.test.ts +1 -0
  67. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  68. package/src/__tests__/slack-channel-config.test.ts +230 -0
  69. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  70. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  71. package/src/__tests__/system-prompt.test.ts +43 -0
  72. package/src/__tests__/task-management-tools.test.ts +3 -3
  73. package/src/__tests__/task-tools.test.ts +3 -3
  74. package/src/__tests__/trust-store.test.ts +17 -1
  75. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  76. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  77. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  78. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  79. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  80. package/src/__tests__/update-bulletin.test.ts +260 -0
  81. package/src/__tests__/update-template-contract.test.ts +29 -0
  82. package/src/agent/loop.ts +2 -2
  83. package/src/amazon/client.ts +2 -3
  84. package/src/calls/call-controller.ts +115 -34
  85. package/src/calls/call-conversation-messages.ts +2 -2
  86. package/src/calls/call-domain.ts +10 -3
  87. package/src/calls/call-pointer-messages.ts +17 -5
  88. package/src/calls/guardian-action-sweep.ts +77 -36
  89. package/src/calls/relay-server.ts +51 -12
  90. package/src/calls/twilio-routes.ts +3 -1
  91. package/src/calls/types.ts +1 -1
  92. package/src/calls/voice-session-bridge.ts +4 -4
  93. package/src/cli/core-commands.ts +3 -3
  94. package/src/cli/map.ts +8 -5
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  96. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  97. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  98. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  99. package/src/config/computer-use-prompt.ts +1 -0
  100. package/src/config/core-schema.ts +16 -0
  101. package/src/config/env-registry.ts +1 -0
  102. package/src/config/env.ts +16 -1
  103. package/src/config/memory-schema.ts +5 -0
  104. package/src/config/schema.ts +4 -0
  105. package/src/config/system-prompt.ts +69 -2
  106. package/src/config/templates/BOOTSTRAP.md +1 -1
  107. package/src/config/templates/IDENTITY.md +8 -4
  108. package/src/config/templates/SOUL.md +14 -0
  109. package/src/config/templates/UPDATES.md +16 -0
  110. package/src/config/templates/USER.md +5 -1
  111. package/src/config/types.ts +1 -0
  112. package/src/config/update-bulletin-format.ts +52 -0
  113. package/src/config/update-bulletin-state.ts +49 -0
  114. package/src/config/update-bulletin.ts +82 -0
  115. package/src/config/vellum-skills/catalog.json +6 -0
  116. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  117. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  119. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  120. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  121. package/src/context/window-manager.ts +43 -3
  122. package/src/daemon/config-watcher.ts +1 -0
  123. package/src/daemon/connection-policy.ts +21 -1
  124. package/src/daemon/daemon-control.ts +164 -7
  125. package/src/daemon/date-context.ts +174 -1
  126. package/src/daemon/guardian-action-generators.ts +175 -0
  127. package/src/daemon/guardian-verification-intent.ts +120 -0
  128. package/src/daemon/handlers/apps.ts +1 -3
  129. package/src/daemon/handlers/config-channels.ts +8 -8
  130. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  131. package/src/daemon/handlers/config-inbox.ts +55 -159
  132. package/src/daemon/handlers/config-ingress.ts +1 -1
  133. package/src/daemon/handlers/config-integrations.ts +1 -1
  134. package/src/daemon/handlers/config-platform.ts +1 -1
  135. package/src/daemon/handlers/config-scheduling.ts +2 -2
  136. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  137. package/src/daemon/handlers/config-telegram.ts +1 -1
  138. package/src/daemon/handlers/config-twilio.ts +1 -1
  139. package/src/daemon/handlers/config-voice.ts +100 -0
  140. package/src/daemon/handlers/config.ts +3 -0
  141. package/src/daemon/handlers/index.ts +1 -1
  142. package/src/daemon/handlers/misc.ts +84 -6
  143. package/src/daemon/handlers/navigate-settings.ts +27 -0
  144. package/src/daemon/handlers/recording.ts +270 -144
  145. package/src/daemon/handlers/sessions.ts +107 -24
  146. package/src/daemon/handlers/subagents.ts +3 -3
  147. package/src/daemon/handlers/work-items.ts +10 -7
  148. package/src/daemon/ipc-contract/integrations.ts +9 -1
  149. package/src/daemon/ipc-contract/messages.ts +4 -0
  150. package/src/daemon/ipc-contract/sessions.ts +1 -1
  151. package/src/daemon/ipc-contract/settings.ts +26 -0
  152. package/src/daemon/ipc-contract/shared.ts +2 -0
  153. package/src/daemon/ipc-contract/work-items.ts +1 -7
  154. package/src/daemon/ipc-contract-inventory.json +5 -1
  155. package/src/daemon/ipc-contract.ts +5 -1
  156. package/src/daemon/lifecycle.ts +306 -266
  157. package/src/daemon/recording-executor.ts +1 -1
  158. package/src/daemon/recording-intent.ts +0 -41
  159. package/src/daemon/response-tier.ts +2 -2
  160. package/src/daemon/server.ts +6 -6
  161. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  162. package/src/daemon/session-agent-loop.ts +15 -8
  163. package/src/daemon/session-history.ts +3 -2
  164. package/src/daemon/session-media-retry.ts +3 -0
  165. package/src/daemon/session-messaging.ts +38 -4
  166. package/src/daemon/session-notifiers.ts +2 -2
  167. package/src/daemon/session-process.ts +256 -23
  168. package/src/daemon/session-queue-manager.ts +2 -0
  169. package/src/daemon/session-runtime-assembly.ts +39 -0
  170. package/src/daemon/session-skill-tools.ts +13 -4
  171. package/src/daemon/session-tool-setup.ts +6 -7
  172. package/src/daemon/session.ts +19 -8
  173. package/src/daemon/tls-certs.ts +55 -13
  174. package/src/daemon/tool-side-effects.ts +13 -5
  175. package/src/gallery/default-gallery.ts +32 -9
  176. package/src/influencer/client.ts +2 -1
  177. package/src/memory/channel-delivery-store.ts +37 -567
  178. package/src/memory/channel-guardian-store.ts +66 -1317
  179. package/src/memory/conflict-store.ts +4 -4
  180. package/src/memory/conversation-attention-store.ts +4 -7
  181. package/src/memory/conversation-crud.ts +668 -0
  182. package/src/memory/conversation-queries.ts +361 -0
  183. package/src/memory/conversation-store.ts +45 -983
  184. package/src/memory/db-connection.ts +3 -0
  185. package/src/memory/db-init.ts +25 -0
  186. package/src/memory/delivery-channels.ts +175 -0
  187. package/src/memory/delivery-crud.ts +211 -0
  188. package/src/memory/delivery-status.ts +199 -0
  189. package/src/memory/embedding-backend.ts +70 -4
  190. package/src/memory/embedding-local.ts +12 -2
  191. package/src/memory/entity-extractor.ts +3 -8
  192. package/src/memory/fts-reconciler.ts +121 -0
  193. package/src/memory/guardian-action-store.ts +366 -3
  194. package/src/memory/guardian-approvals.ts +569 -0
  195. package/src/memory/guardian-bindings.ts +130 -0
  196. package/src/memory/guardian-rate-limits.ts +196 -0
  197. package/src/memory/guardian-verification.ts +520 -0
  198. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  199. package/src/memory/job-utils.ts +8 -5
  200. package/src/memory/jobs-store.ts +66 -6
  201. package/src/memory/jobs-worker.ts +23 -1
  202. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  203. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  204. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  205. package/src/memory/migrations/100-core-tables.ts +1 -1
  206. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  207. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  208. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  209. package/src/memory/migrations/113-late-migrations.ts +1 -1
  210. package/src/memory/migrations/116-messages-fts.ts +13 -0
  211. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  212. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  213. package/src/memory/migrations/index.ts +8 -3
  214. package/src/memory/migrations/validate-migration-state.ts +114 -15
  215. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  216. package/src/memory/retriever.ts +46 -13
  217. package/src/memory/schema-migration.ts +3 -0
  218. package/src/memory/schema.ts +25 -7
  219. package/src/memory/search/semantic.ts +8 -90
  220. package/src/notifications/README.md +1 -1
  221. package/src/notifications/broadcaster.ts +20 -2
  222. package/src/notifications/conversation-pairing.ts +3 -3
  223. package/src/notifications/decision-engine.ts +173 -8
  224. package/src/notifications/deliveries-store.ts +27 -8
  225. package/src/notifications/preferences-store.ts +7 -7
  226. package/src/notifications/thread-candidates.ts +234 -0
  227. package/src/notifications/types.ts +18 -0
  228. package/src/permissions/defaults.ts +11 -1
  229. package/src/permissions/prompter.ts +17 -0
  230. package/src/permissions/trust-store.ts +2 -0
  231. package/src/providers/failover.ts +19 -0
  232. package/src/providers/registry.ts +46 -1
  233. package/src/runtime/approval-message-composer.ts +1 -1
  234. package/src/runtime/channel-guardian-service.ts +15 -3
  235. package/src/runtime/channel-retry-sweep.ts +7 -2
  236. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  237. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  238. package/src/runtime/guardian-action-message-composer.ts +245 -0
  239. package/src/runtime/guardian-outbound-actions.ts +35 -15
  240. package/src/runtime/guardian-verification-templates.ts +15 -9
  241. package/src/runtime/http-errors.ts +93 -0
  242. package/src/runtime/http-server.ts +140 -51
  243. package/src/runtime/http-types.ts +53 -0
  244. package/src/runtime/ingress-service.ts +237 -0
  245. package/src/runtime/middleware/error-handler.ts +4 -3
  246. package/src/runtime/middleware/rate-limiter.ts +160 -0
  247. package/src/runtime/middleware/request-logger.ts +71 -0
  248. package/src/runtime/middleware/twilio-validation.ts +7 -6
  249. package/src/runtime/pending-interactions.ts +12 -0
  250. package/src/runtime/routes/access-request-decision.ts +215 -0
  251. package/src/runtime/routes/app-routes.ts +25 -18
  252. package/src/runtime/routes/approval-routes.ts +18 -47
  253. package/src/runtime/routes/attachment-routes.ts +15 -41
  254. package/src/runtime/routes/call-routes.ts +20 -20
  255. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  256. package/src/runtime/routes/contact-routes.ts +4 -9
  257. package/src/runtime/routes/conversation-attention-routes.ts +5 -4
  258. package/src/runtime/routes/conversation-routes.ts +26 -57
  259. package/src/runtime/routes/debug-routes.ts +71 -0
  260. package/src/runtime/routes/events-routes.ts +3 -2
  261. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  262. package/src/runtime/routes/identity-routes.ts +14 -10
  263. package/src/runtime/routes/inbound-conversation.ts +3 -2
  264. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  265. package/src/runtime/routes/ingress-routes.ts +174 -0
  266. package/src/runtime/routes/integration-routes.ts +82 -20
  267. package/src/runtime/routes/pairing-routes.ts +11 -10
  268. package/src/runtime/routes/secret-routes.ts +10 -18
  269. package/src/runtime/verification-rate-limiter.ts +83 -0
  270. package/src/schedule/schedule-store.ts +13 -1
  271. package/src/schedule/scheduler.ts +2 -2
  272. package/src/security/secret-ingress.ts +5 -2
  273. package/src/security/secret-scanner.ts +72 -6
  274. package/src/subagent/manager.ts +6 -4
  275. package/src/swarm/plan-validator.ts +4 -1
  276. package/src/tasks/task-runner.ts +3 -1
  277. package/src/tools/browser/api-map.ts +9 -6
  278. package/src/tools/calls/call-start.ts +20 -0
  279. package/src/tools/executor.ts +50 -568
  280. package/src/tools/permission-checker.ts +272 -0
  281. package/src/tools/registry.ts +14 -6
  282. package/src/tools/reminder/reminder-store.ts +7 -7
  283. package/src/tools/reminder/reminder.ts +6 -3
  284. package/src/tools/secret-detection-handler.ts +301 -0
  285. package/src/tools/subagent/message.ts +1 -1
  286. package/src/tools/system/voice-config.ts +62 -0
  287. package/src/tools/tasks/index.ts +3 -3
  288. package/src/tools/tasks/work-item-list.ts +3 -3
  289. package/src/tools/tasks/work-item-update.ts +4 -5
  290. package/src/tools/tool-approval-handler.ts +192 -0
  291. package/src/tools/tool-manifest.ts +2 -0
  292. package/src/watcher/watcher-store.ts +9 -9
  293. package/src/work-items/work-item-runner.ts +9 -6
  294. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  295. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -14,14 +14,24 @@ import { getConfig } from '../config/loader.js';
14
14
  import * as conversationStore from '../memory/conversation-store.js';
15
15
  import { provenanceFromGuardianContext } from '../memory/conversation-store.js';
16
16
  import {
17
+ finalizeFollowup,
18
+ getExpiredDeliveryByConversation,
19
+ getFollowupDeliveryByConversation,
17
20
  getGuardianActionRequest,
18
21
  getPendingDeliveryByConversation,
22
+ progressFollowupState,
19
23
  resolveGuardianActionRequest,
24
+ startFollowupFromExpiredRequest,
20
25
  } from '../memory/guardian-action-store.js';
26
+ import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversation-turn.js';
27
+ import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
28
+ import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
29
+ import type { GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
21
30
  import { extractPreferences } from '../notifications/preference-extractor.js';
22
31
  import { createPreference } from '../notifications/preferences-store.js';
23
32
  import type { Message } from '../providers/types.js';
24
33
  import { getLogger } from '../util/logger.js';
34
+ import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
25
35
  import type { UsageStats } from './ipc-contract.js';
26
36
  import type { ServerMessage, UserMessageAttachment } from './ipc-protocol.js';
27
37
  import type { MessageQueue } from './session-queue-manager.js';
@@ -32,6 +42,25 @@ import type { TraceEmitter } from './trace-emitter.js';
32
42
 
33
43
  const log = getLogger('session-process');
34
44
 
45
+ // ---------------------------------------------------------------------------
46
+ // Module-level generator injection
47
+ // ---------------------------------------------------------------------------
48
+ // The daemon lifecycle creates the generator once and injects it here so the
49
+ // mac/IPC channel path can classify follow-up replies without threading the
50
+ // generator through Session / DaemonServer constructors.
51
+ let _guardianFollowUpGenerator: GuardianFollowUpConversationGenerator | undefined;
52
+ let _guardianActionCopyGenerator: GuardianActionCopyGenerator | undefined;
53
+
54
+ /** Inject the guardian follow-up conversation generator (called from lifecycle.ts). */
55
+ export function setGuardianFollowUpConversationGenerator(gen: GuardianFollowUpConversationGenerator): void {
56
+ _guardianFollowUpGenerator = gen;
57
+ }
58
+
59
+ /** Inject the guardian action copy generator (called from lifecycle.ts). */
60
+ export function setGuardianActionCopyGenerator(gen: GuardianActionCopyGenerator): void {
61
+ _guardianActionCopyGenerator = gen;
62
+ }
63
+
35
64
  /** Build a model_info event with fresh config data. */
36
65
  function buildModelInfoEvent(): ServerMessage {
37
66
  const config = getConfig();
@@ -75,12 +104,12 @@ export interface ProcessSessionContext {
75
104
  /** Assistant identity — used for scoping notification preferences. */
76
105
  readonly assistantId?: string;
77
106
  guardianContext?: GuardianRuntimeContext;
78
- persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>): string;
107
+ persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>, displayContent?: string): Promise<string>;
79
108
  runAgentLoop(
80
109
  content: string,
81
110
  userMessageId: string,
82
111
  onEvent: (msg: ServerMessage) => void,
83
- options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean },
112
+ options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; titleText?: string },
84
113
  ): Promise<void>;
85
114
  getTurnChannelContext(): TurnChannelContext | null;
86
115
  setTurnChannelContext(ctx: TurnChannelContext): void;
@@ -146,7 +175,7 @@ function buildSlashContext(session: ProcessSessionContext): SlashContext {
146
175
  * block, we must explicitly continue draining on failure — otherwise
147
176
  * remaining queued messages would be stranded.
148
177
  */
149
- export function drainQueue(session: ProcessSessionContext, reason: QueueDrainReason = 'loop_complete'): void {
178
+ export async function drainQueue(session: ProcessSessionContext, reason: QueueDrainReason = 'loop_complete'): Promise<void> {
150
179
  const next = session.queue.shift();
151
180
  if (!next) return;
152
181
 
@@ -191,16 +220,22 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
191
220
  : {}),
192
221
  };
193
222
  const userMsg = createUserMessage(next.content, next.attachments);
194
- conversationStore.addMessage(
223
+ // When displayContent is provided (e.g. original text before recording
224
+ // intent stripping), persist that to DB so users see the full message.
225
+ // The in-memory userMessage (sent to the LLM) still uses the stripped content.
226
+ const contentToPersist = next.displayContent
227
+ ? JSON.stringify(createUserMessage(next.displayContent, next.attachments).content)
228
+ : JSON.stringify(userMsg.content);
229
+ await conversationStore.addMessage(
195
230
  session.conversationId,
196
231
  'user',
197
- JSON.stringify(userMsg.content),
232
+ contentToPersist,
198
233
  drainChannelMeta,
199
234
  );
200
235
  session.messages.push(userMsg);
201
236
 
202
237
  const assistantMsg = createAssistantMessage(slashResult.message);
203
- conversationStore.addMessage(
238
+ await conversationStore.addMessage(
204
239
  session.conversationId,
205
240
  'assistant',
206
241
  JSON.stringify(assistantMsg.content),
@@ -237,7 +272,7 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
237
272
  next.onEvent({ type: 'error', message });
238
273
  }
239
274
  // Continue draining regardless of success/failure
240
- drainQueue(session);
275
+ await drainQueue(session);
241
276
  return;
242
277
  }
243
278
 
@@ -248,13 +283,26 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
248
283
  session.preactivatedSkillIds = [slashResult.skillId];
249
284
  }
250
285
 
286
+ // Guardian verification intent interception for queued messages.
287
+ // Preserve the original user content for persistence; only the agent
288
+ // loop receives the rewritten instruction.
289
+ let agentLoopContent = resolvedContent;
290
+ if (slashResult.kind === 'passthrough') {
291
+ const guardianIntent = resolveGuardianVerificationIntent(resolvedContent);
292
+ if (guardianIntent.kind === 'direct_setup') {
293
+ log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted in queue — forcing skill flow');
294
+ agentLoopContent = guardianIntent.rewrittenContent;
295
+ session.preactivatedSkillIds = ['guardian-verify-setup'];
296
+ }
297
+ }
298
+
251
299
  // Try to persist and run the dequeued message. If persistUserMessage
252
300
  // succeeds, runAgentLoop is called and its finally block will drain
253
301
  // the next message. If persistUserMessage fails, processMessage
254
302
  // resolves early (no runAgentLoop call), so we must continue draining.
255
303
  let userMessageId: string;
256
304
  try {
257
- userMessageId = session.persistUserMessage(resolvedContent, next.attachments, next.requestId, next.metadata);
305
+ userMessageId = await session.persistUserMessage(resolvedContent, next.attachments, next.requestId, next.metadata, next.displayContent);
258
306
  } catch (err) {
259
307
  const message = err instanceof Error ? err.message : String(err);
260
308
  log.error({ err, conversationId: session.conversationId, requestId: next.requestId }, 'Failed to persist queued message');
@@ -267,7 +315,7 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
267
315
  // runAgentLoop never ran, so its finally block won't clear this
268
316
  session.preactivatedSkillIds = undefined;
269
317
  // Continue draining — don't strand remaining messages
270
- drainQueue(session);
318
+ await drainQueue(session);
271
319
  return;
272
320
  }
273
321
 
@@ -301,8 +349,12 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
301
349
  // Fire-and-forget: persistUserMessage set session.processing = true
302
350
  // so subsequent messages will still be enqueued.
303
351
  // runAgentLoop's finally block will call drainQueue when this run completes.
304
- session.runAgentLoop(resolvedContent, userMessageId, next.onEvent,
305
- next.isInteractive !== undefined ? { isInteractive: next.isInteractive } : undefined,
352
+ const drainLoopOptions: { isInteractive?: boolean; titleText?: string } = {};
353
+ if (next.isInteractive !== undefined) drainLoopOptions.isInteractive = next.isInteractive;
354
+ if (agentLoopContent !== resolvedContent) drainLoopOptions.titleText = resolvedContent;
355
+
356
+ session.runAgentLoop(agentLoopContent, userMessageId, next.onEvent,
357
+ Object.keys(drainLoopOptions).length > 0 ? drainLoopOptions : undefined,
306
358
  ).catch((err) => {
307
359
  const message = err instanceof Error ? err.message : String(err);
308
360
  log.error({ err, conversationId: session.conversationId, requestId: next.requestId }, 'Error processing queued message');
@@ -325,6 +377,7 @@ export async function processMessage(
325
377
  activeSurfaceId?: string,
326
378
  currentPage?: string,
327
379
  options?: { isInteractive?: boolean },
380
+ displayContent?: string,
328
381
  ): Promise<string> {
329
382
  session.currentActiveSurfaceId = activeSurfaceId;
330
383
  session.currentPage = currentPage;
@@ -339,7 +392,7 @@ export async function processMessage(
339
392
  const guardianIfCtx = session.getTurnInterfaceContext();
340
393
  const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
341
394
  const userMsg = createUserMessage(content, attachments);
342
- const persisted = conversationStore.addMessage(
395
+ const persisted = await conversationStore.addMessage(
343
396
  session.conversationId,
344
397
  'user',
345
398
  JSON.stringify(userMsg.content),
@@ -357,9 +410,9 @@ export async function processMessage(
357
410
  const resolved = resolveGuardianActionRequest(guardianRequest.id, content, 'vellum');
358
411
  const replyText = resolved
359
412
  ? 'Your answer has been relayed to the call.'
360
- : 'This question has already been answered from another channel.';
413
+ : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
361
414
  const replyMsg = createAssistantMessage(replyText);
362
- conversationStore.addMessage(
415
+ await conversationStore.addMessage(
363
416
  session.conversationId,
364
417
  'assistant',
365
418
  JSON.stringify(replyMsg.content),
@@ -370,21 +423,176 @@ export async function processMessage(
370
423
  } else {
371
424
  const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
372
425
  log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
373
- const failMsg = createAssistantMessage('Failed to deliver your answer to the call. Please try again.');
374
- conversationStore.addMessage(
426
+ const failureText = await composeGuardianActionMessageGenerative(
427
+ { scenario: 'guardian_answer_delivery_failed' },
428
+ {},
429
+ _guardianActionCopyGenerator,
430
+ );
431
+ const failMsg = createAssistantMessage(failureText);
432
+ await conversationStore.addMessage(
375
433
  session.conversationId,
376
434
  'assistant',
377
435
  JSON.stringify(failMsg.content),
378
436
  guardianChannelMeta,
379
437
  );
380
438
  session.messages.push(failMsg);
381
- onEvent({ type: 'assistant_text_delta', text: 'Failed to deliver your answer to the call. Please try again.' });
439
+ onEvent({ type: 'assistant_text_delta', text: failureText });
382
440
  }
383
441
  onEvent({ type: 'message_complete', sessionId: session.conversationId });
384
442
  return persisted.id;
385
443
  }
386
444
  }
387
445
 
446
+ // ── Expired guardian action late answer interception (mac channel) ──
447
+ // If no pending delivery was found, check for expired requests eligible
448
+ // for follow-up (status='expired', followup_state='none').
449
+ const expiredDelivery = getExpiredDeliveryByConversation(session.conversationId);
450
+ if (expiredDelivery) {
451
+ const expiredRequest = getGuardianActionRequest(expiredDelivery.requestId);
452
+ if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
453
+ const guardianIfCtx = session.getTurnInterfaceContext();
454
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
455
+ const userMsg = createUserMessage(content, attachments);
456
+ const persisted = await conversationStore.addMessage(
457
+ session.conversationId,
458
+ 'user',
459
+ JSON.stringify(userMsg.content),
460
+ guardianChannelMeta,
461
+ );
462
+ session.messages.push(userMsg);
463
+
464
+ const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, content);
465
+ if (followupResult) {
466
+ const followupText = await composeGuardianActionMessageGenerative(
467
+ {
468
+ scenario: 'guardian_late_answer_followup',
469
+ questionText: expiredRequest.questionText,
470
+ lateAnswerText: content,
471
+ },
472
+ {},
473
+ _guardianActionCopyGenerator,
474
+ );
475
+ const replyMsg = createAssistantMessage(followupText);
476
+ await conversationStore.addMessage(
477
+ session.conversationId,
478
+ 'assistant',
479
+ JSON.stringify(replyMsg.content),
480
+ guardianChannelMeta,
481
+ );
482
+ session.messages.push(replyMsg);
483
+ onEvent({ type: 'assistant_text_delta', text: followupText });
484
+ } else {
485
+ // Follow-up already started or conflict — send stale message
486
+ const staleText = await composeGuardianActionMessageGenerative(
487
+ { scenario: 'guardian_stale_expired' },
488
+ {},
489
+ _guardianActionCopyGenerator,
490
+ );
491
+ const staleMsg = createAssistantMessage(staleText);
492
+ await conversationStore.addMessage(
493
+ session.conversationId,
494
+ 'assistant',
495
+ JSON.stringify(staleMsg.content),
496
+ guardianChannelMeta,
497
+ );
498
+ session.messages.push(staleMsg);
499
+ onEvent({ type: 'assistant_text_delta', text: staleText });
500
+ }
501
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
502
+ return persisted.id;
503
+ }
504
+ }
505
+
506
+ // ── Guardian follow-up conversation interception (mac channel) ──
507
+ // When a request is in `awaiting_guardian_choice` state, the guardian has
508
+ // already been asked "call back or send a message?". Their next message
509
+ // is the reply — route it through the conversation engine.
510
+ const followupDelivery = getFollowupDeliveryByConversation(session.conversationId);
511
+ if (followupDelivery) {
512
+ const followupRequest = getGuardianActionRequest(followupDelivery.requestId);
513
+ if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
514
+ const guardianIfCtx = session.getTurnInterfaceContext();
515
+ const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
516
+ const userMsg = createUserMessage(content, attachments);
517
+ const persisted = await conversationStore.addMessage(
518
+ session.conversationId,
519
+ 'user',
520
+ JSON.stringify(userMsg.content),
521
+ guardianChannelMeta,
522
+ );
523
+ session.messages.push(userMsg);
524
+
525
+ const turnResult = await processGuardianFollowUpTurn(
526
+ {
527
+ questionText: followupRequest.questionText,
528
+ lateAnswerText: followupRequest.lateAnswerText ?? '',
529
+ guardianReply: content,
530
+ },
531
+ _guardianFollowUpGenerator,
532
+ );
533
+
534
+ // Apply the disposition to the follow-up state machine.
535
+ // Both progressFollowupState and finalizeFollowup are compare-and-set:
536
+ // they return null when the transition was not applied (e.g. a concurrent
537
+ // reply already advanced the state). In that case we notify the guardian
538
+ // that the request was already resolved and skip action execution.
539
+ let stateApplied = true;
540
+ if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
541
+ stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== null;
542
+ } else if (turnResult.disposition === 'decline') {
543
+ stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== null;
544
+ }
545
+ // keep_pending: no state change — guardian can reply again
546
+
547
+ if (!stateApplied) {
548
+ log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
549
+ }
550
+
551
+ const replyText = stateApplied
552
+ ? turnResult.replyText
553
+ : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
554
+ const replyMsg = createAssistantMessage(replyText);
555
+ await conversationStore.addMessage(
556
+ session.conversationId,
557
+ 'assistant',
558
+ JSON.stringify(replyMsg.content),
559
+ guardianChannelMeta,
560
+ );
561
+ session.messages.push(replyMsg);
562
+ onEvent({ type: 'assistant_text_delta', text: replyText });
563
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
564
+
565
+ // Execute the action and send a completion/failure message (fire-and-forget).
566
+ // The initial reply above acknowledges the guardian's choice; the executor
567
+ // carries out the actual call_back or message_back and posts a second message.
568
+ if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
569
+ void (async () => {
570
+ try {
571
+ const execResult = await executeFollowupAction(
572
+ followupRequest.id,
573
+ turnResult.disposition as 'call_back' | 'message_back',
574
+ _guardianActionCopyGenerator,
575
+ );
576
+ const completionMsg = createAssistantMessage(execResult.guardianReplyText);
577
+ await conversationStore.addMessage(
578
+ session.conversationId,
579
+ 'assistant',
580
+ JSON.stringify(completionMsg.content),
581
+ guardianChannelMeta,
582
+ );
583
+ session.messages.push(completionMsg);
584
+ onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
585
+ onEvent({ type: 'message_complete', sessionId: session.conversationId });
586
+ } catch (execErr) {
587
+ log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion message failed');
588
+ }
589
+ })();
590
+ }
591
+
592
+ return persisted.id;
593
+ }
594
+ }
595
+
388
596
  // Resolve slash commands before persistence
389
597
  const slashResult = resolveSlash(content, buildSlashContext(session));
390
598
 
@@ -405,16 +613,22 @@ export async function processMessage(
405
613
  : {}),
406
614
  };
407
615
  const userMsg = createUserMessage(content, attachments);
408
- const persisted = conversationStore.addMessage(
616
+ // When displayContent is provided (e.g. original text before recording
617
+ // intent stripping), persist that to DB so users see the full message.
618
+ // The in-memory userMessage (sent to the LLM) still uses the stripped content.
619
+ const contentToPersist = displayContent
620
+ ? JSON.stringify(createUserMessage(displayContent, attachments).content)
621
+ : JSON.stringify(userMsg.content);
622
+ const persisted = await conversationStore.addMessage(
409
623
  session.conversationId,
410
624
  'user',
411
- JSON.stringify(userMsg.content),
625
+ contentToPersist,
412
626
  pmChannelMeta,
413
627
  );
414
628
  session.messages.push(userMsg);
415
629
 
416
630
  const assistantMsg = createAssistantMessage(slashResult.message);
417
- conversationStore.addMessage(
631
+ await conversationStore.addMessage(
418
632
  session.conversationId,
419
633
  'assistant',
420
634
  JSON.stringify(assistantMsg.content),
@@ -450,9 +664,24 @@ export async function processMessage(
450
664
  session.preactivatedSkillIds = [slashResult.skillId];
451
665
  }
452
666
 
667
+ // Guardian verification intent interception — force direct guardian
668
+ // verification requests into the guardian-verify-setup skill flow on
669
+ // the first turn, avoiding conceptual preambles from the agent.
670
+ // We keep the original user content for persistence and use the
671
+ // rewritten content only for the agent loop instruction.
672
+ let agentLoopContent = resolvedContent;
673
+ if (slashResult.kind === 'passthrough') {
674
+ const guardianIntent = resolveGuardianVerificationIntent(resolvedContent);
675
+ if (guardianIntent.kind === 'direct_setup') {
676
+ log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted — forcing skill flow');
677
+ agentLoopContent = guardianIntent.rewrittenContent;
678
+ session.preactivatedSkillIds = ['guardian-verify-setup'];
679
+ }
680
+ }
681
+
453
682
  let userMessageId: string;
454
683
  try {
455
- userMessageId = session.persistUserMessage(resolvedContent, attachments, requestId);
684
+ userMessageId = await session.persistUserMessage(resolvedContent, attachments, requestId, undefined, displayContent);
456
685
  } catch (err) {
457
686
  const message = err instanceof Error ? err.message : String(err);
458
687
  onEvent({ type: 'error', message });
@@ -485,8 +714,12 @@ export async function processMessage(
485
714
  });
486
715
  }
487
716
 
488
- await session.runAgentLoop(resolvedContent, userMessageId, onEvent,
489
- options?.isInteractive !== undefined ? { isInteractive: options.isInteractive } : undefined,
717
+ const loopOptions: { isInteractive?: boolean; titleText?: string } = {};
718
+ if (options?.isInteractive !== undefined) loopOptions.isInteractive = options.isInteractive;
719
+ if (agentLoopContent !== resolvedContent) loopOptions.titleText = resolvedContent;
720
+
721
+ await session.runAgentLoop(agentLoopContent, userMessageId, onEvent,
722
+ Object.keys(loopOptions).length > 0 ? loopOptions : undefined,
490
723
  );
491
724
  return userMessageId;
492
725
  }
@@ -25,6 +25,8 @@ export interface QueuedMessage {
25
25
  isInteractive?: boolean;
26
26
  /** Timestamp (ms) when the message was enqueued. */
27
27
  queuedAt: number;
28
+ /** Original user message text to persist to DB when recording intent stripping produced a different `content`. */
29
+ displayContent?: string;
28
30
  }
29
31
 
30
32
  export const MAX_QUEUE_DEPTH = 10;
@@ -25,6 +25,10 @@ export interface ChannelCapabilities {
25
25
  supportsDynamicUi: boolean;
26
26
  /** Whether the channel supports voice/microphone input. */
27
27
  supportsVoiceInput: boolean;
28
+ /** Push-to-talk activation key (e.g. 'fn', 'ctrl', 'fn_shift', 'none'). Only present on desktop clients. */
29
+ pttActivationKey?: string;
30
+ /** Whether the client has been granted microphone permission by the OS. */
31
+ microphonePermissionGranted?: boolean;
28
32
  }
29
33
 
30
34
  /** Guardian identity/trust context for external chat channels. */
@@ -39,10 +43,26 @@ export interface GuardianRuntimeContext {
39
43
  denialReason?: 'no_binding' | 'no_identity';
40
44
  }
41
45
 
46
+ /** Allowed push-to-talk activation key values. Used to validate client-provided keys before system-prompt injection. */
47
+ const PTT_KEY_ALLOWLIST = new Set(['fn', 'ctrl', 'fn_shift', 'none']);
48
+
49
+ /** Validate a PTT activation key against the allowlist. Returns the key if valid, 'unknown' otherwise. */
50
+ export function sanitizePttActivationKey(key: string | undefined | null): string | undefined {
51
+ if (key == null) return undefined;
52
+ return PTT_KEY_ALLOWLIST.has(key) ? key : 'unknown';
53
+ }
54
+
55
+ /** Optional PTT metadata provided by the client alongside each message. */
56
+ export interface PttMetadata {
57
+ pttActivationKey?: string;
58
+ microphonePermissionGranted?: boolean;
59
+ }
60
+
42
61
  /** Derive channel capabilities from source channel + interface identifiers. */
43
62
  export function resolveChannelCapabilities(
44
63
  sourceChannel?: string | null,
45
64
  sourceInterface?: string | null,
65
+ pttMetadata?: PttMetadata | null,
46
66
  ): ChannelCapabilities {
47
67
  // Normalise legacy pseudo-channel IDs to canonical ChannelId values.
48
68
  let channel: string;
@@ -85,6 +105,8 @@ export function resolveChannelCapabilities(
85
105
  dashboardCapable: supportsDesktopUi,
86
106
  supportsDynamicUi: supportsDesktopUi,
87
107
  supportsVoiceInput: supportsDesktopUi,
108
+ pttActivationKey: sanitizePttActivationKey(pttMetadata?.pttActivationKey),
109
+ microphonePermissionGranted: pttMetadata?.microphonePermissionGranted,
88
110
  };
89
111
  }
90
112
  case 'telegram':
@@ -334,6 +356,23 @@ export function injectChannelCapabilityContext(message: Message, caps: ChannelCa
334
356
  lines.push('- Do NOT ask the user to use voice or microphone input.');
335
357
  }
336
358
 
359
+ // PTT state — only relevant on channels that support voice input
360
+ if (caps.supportsVoiceInput) {
361
+ if (caps.pttActivationKey && caps.pttActivationKey !== 'none') {
362
+ const keyLabel = caps.pttActivationKey === 'fn_shift' ? 'Fn+Shift' : caps.pttActivationKey === 'fn' ? 'Fn (Globe)' : caps.pttActivationKey;
363
+ lines.push(`ptt_activation_key: ${caps.pttActivationKey}`);
364
+ lines.push(`ptt_enabled: true`);
365
+ lines.push(`Push-to-talk is configured with the ${keyLabel} key. The user can hold ${keyLabel} to dictate text or start a voice conversation.`);
366
+ } else if (caps.pttActivationKey === 'none') {
367
+ lines.push(`ptt_activation_key: none`);
368
+ lines.push(`ptt_enabled: false`);
369
+ lines.push('Push-to-talk is disabled. You can offer to enable it for the user.');
370
+ }
371
+ if (caps.microphonePermissionGranted !== undefined) {
372
+ lines.push(`microphone_permission_granted: ${caps.microphonePermissionGranted}`);
373
+ }
374
+ }
375
+
337
376
  lines.push('</channel_capabilities>');
338
377
 
339
378
  const block = lines.join('\n');
@@ -282,17 +282,18 @@ export function projectSkillTools(
282
282
  );
283
283
 
284
284
  if (tools.length > 0) {
285
+ let accepted = tools;
285
286
  const prevHash = prevActive.get(skillId);
286
287
  if (prevHash === undefined) {
287
288
  // Newly active skill — register for the first time
288
- registerSkillTools(tools);
289
+ accepted = registerSkillTools(tools);
289
290
  } else if (prevHash !== currentHash) {
290
291
  // Hash changed — unregister stale tools, then re-register with new definitions
291
292
  log.info({ skillId, prevHash, currentHash }, 'Skill version changed, re-registering tools');
292
293
  unregisterSkillTools(skillId);
293
294
  alreadyUnregistered.add(skillId);
294
295
  try {
295
- registerSkillTools(tools);
296
+ accepted = registerSkillTools(tools);
296
297
  } catch (err) {
297
298
  log.error({ err, skillId }, 'Failed to re-register skill tools after version change');
298
299
  // Don't add to successfulEntries — will be cleaned up as transiently-failed
@@ -306,12 +307,20 @@ export function projectSkillTools(
306
307
  if (existing && existing.ownerSkillBundled !== (skill.bundled ?? undefined)) {
307
308
  log.info({ skillId, bundled: skill.bundled }, 'Skill bundled status changed, re-registering tools');
308
309
  unregisterSkillTools(skillId);
309
- registerSkillTools(tools);
310
+ accepted = registerSkillTools(tools);
311
+ } else {
312
+ // Filter to only tools that are actually registered for this skill.
313
+ // Some tools may have been skipped during initial registration due
314
+ // to core-name collisions — don't let them leak back in.
315
+ accepted = tools.filter((t) => {
316
+ const reg = getTool(t.name);
317
+ return reg !== undefined && reg.origin === 'skill' && reg.ownerSkillId === skillId;
318
+ });
310
319
  }
311
320
  }
312
321
 
313
322
  successfulEntries.set(skillId, currentHash);
314
- for (const tool of tools) {
323
+ for (const tool of accepted) {
315
324
  allToolDefinitions.push(tool.getDefinition());
316
325
  allToolNames.add(tool.name);
317
326
  }
@@ -25,8 +25,8 @@ import { requestComputerControlTool } from '../tools/computer-use/request-comput
25
25
  import type { ProxyApprovalCallback, ProxyApprovalRequest } from '../tools/network/script-proxy/index.js';
26
26
  import { getAllToolDefinitions } from '../tools/registry.js';
27
27
  import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
28
- import { projectSkillTools, type SkillProjectionCache } from './session-skill-tools.js';
29
28
  import type { GuardianRuntimeContext } from './session-runtime-assembly.js';
29
+ import { projectSkillTools, type SkillProjectionCache } from './session-skill-tools.js';
30
30
  import type { SurfaceSessionContext } from './session-surfaces.js';
31
31
  import {
32
32
  surfaceProxyResolver,
@@ -117,12 +117,11 @@ export function createToolExecutor(
117
117
  forcePromptSideEffects: ctx.memoryPolicy.strictSideEffects,
118
118
  onToolLifecycleEvent: handleToolLifecycleEvent,
119
119
  sendToClient: (msg) => {
120
- const serverMsg = msg as unknown as ServerMessage;
121
- ctx.sendToClient(serverMsg);
122
- // Auto-track ui_surface_show for history persistence, mirroring what
123
- // session-surfaces.ts does when sending surfaces through its own path.
124
- if (serverMsg.type === 'ui_surface_show') {
125
- const s = serverMsg as unknown as UiSurfaceShow;
120
+ // Tool context's sendToClient uses a loose { type: string; [key: string]: unknown }
121
+ // signature, but at runtime these are always ServerMessage instances.
122
+ ctx.sendToClient(msg as ServerMessage);
123
+ if (msg.type === 'ui_surface_show') {
124
+ const s = msg as unknown as UiSurfaceShow;
126
125
  ctx.currentTurnSurfaces.push({
127
126
  surfaceId: s.surfaceId,
128
127
  surfaceType: s.surfaceType,
@@ -397,8 +397,9 @@ export class Session {
397
397
  currentPage?: string,
398
398
  metadata?: Record<string, unknown>,
399
399
  options?: { isInteractive?: boolean },
400
+ displayContent?: string,
400
401
  ): { queued: boolean; rejected?: boolean; requestId: string } {
401
- return enqueueMessageImpl(this, content, attachments, onEvent, requestId, activeSurfaceId, currentPage, metadata, options);
402
+ return enqueueMessageImpl(this, content, attachments, onEvent, requestId, activeSurfaceId, currentPage, metadata, options, displayContent);
402
403
  }
403
404
 
404
405
  getQueueDepth(): number {
@@ -425,6 +426,14 @@ export class Session {
425
426
  return this.prompter.hasPendingRequest(requestId);
426
427
  }
427
428
 
429
+ hasAnyPendingConfirmation(): boolean {
430
+ return this.prompter.hasPending;
431
+ }
432
+
433
+ denyAllPendingConfirmations(): void {
434
+ this.prompter.denyAllPending();
435
+ }
436
+
428
437
  hasPendingSecret(requestId: string): boolean {
429
438
  return this.secretPrompter.hasPendingRequest(requestId);
430
439
  }
@@ -489,13 +498,14 @@ export class Session {
489
498
  return this.currentTurnInterfaceContext;
490
499
  }
491
500
 
492
- persistUserMessage(
501
+ async persistUserMessage(
493
502
  content: string,
494
503
  attachments: UserMessageAttachment[],
495
504
  requestId?: string,
496
505
  metadata?: Record<string, unknown>,
497
- ): string {
498
- return persistUserMessageImpl(this, content, attachments, requestId, metadata);
506
+ displayContent?: string,
507
+ ): Promise<string> {
508
+ return persistUserMessageImpl(this, content, attachments, requestId, metadata, displayContent);
499
509
  }
500
510
 
501
511
  // ── Agent Loop ───────────────────────────────────────────────────
@@ -504,14 +514,14 @@ export class Session {
504
514
  content: string,
505
515
  userMessageId: string,
506
516
  onEvent: (msg: ServerMessage) => void,
507
- options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean },
517
+ options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; titleText?: string },
508
518
  ): Promise<void> {
509
519
  return runAgentLoopImpl(this, content, userMessageId, onEvent, options);
510
520
  }
511
521
 
512
522
 
513
- drainQueue(reason: QueueDrainReason = 'loop_complete'): void {
514
- drainQueueImpl(this as ProcessSessionContext, reason);
523
+ drainQueue(reason: QueueDrainReason = 'loop_complete'): Promise<void> {
524
+ return drainQueueImpl(this as ProcessSessionContext, reason);
515
525
  }
516
526
 
517
527
  async processMessage(
@@ -522,8 +532,9 @@ export class Session {
522
532
  activeSurfaceId?: string,
523
533
  currentPage?: string,
524
534
  options?: { isInteractive?: boolean },
535
+ displayContent?: string,
525
536
  ): Promise<string> {
526
- return processMessageImpl(this as ProcessSessionContext, content, attachments, onEvent, requestId, activeSurfaceId, currentPage, options);
537
+ return processMessageImpl(this as ProcessSessionContext, content, attachments, onEvent, requestId, activeSurfaceId, currentPage, options, displayContent);
527
538
  }
528
539
 
529
540
  // ── History ──────────────────────────────────────────────────────