@vellumai/assistant 0.4.31 → 0.4.33

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 (193) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/docs/architecture/memory.md +1 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
  5. package/src/__tests__/access-request-decision.test.ts +83 -1
  6. package/src/__tests__/actor-token-service.test.ts +0 -1
  7. package/src/__tests__/anthropic-provider.test.ts +86 -1
  8. package/src/__tests__/approval-routes-http.test.ts +0 -1
  9. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  10. package/src/__tests__/call-controller.test.ts +0 -1
  11. package/src/__tests__/call-routes-http.test.ts +0 -1
  12. package/src/__tests__/channel-guardian.test.ts +0 -1
  13. package/src/__tests__/channel-invite-transport.test.ts +52 -40
  14. package/src/__tests__/checker.test.ts +37 -98
  15. package/src/__tests__/commit-message-enrichment-service.test.ts +4 -23
  16. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -1
  17. package/src/__tests__/config-schema.test.ts +6 -5
  18. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +1 -19
  20. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  21. package/src/__tests__/followup-tools.test.ts +0 -30
  22. package/src/__tests__/gemini-provider.test.ts +79 -1
  23. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -1
  24. package/src/__tests__/guardian-dispatch.test.ts +0 -1
  25. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  26. package/src/__tests__/handlers-telegram-config.test.ts +0 -1
  27. package/src/__tests__/inbound-invite-redemption.test.ts +1 -4
  28. package/src/__tests__/ingress-reconcile.test.ts +3 -36
  29. package/src/__tests__/ipc-snapshot.test.ts +0 -4
  30. package/src/__tests__/managed-proxy-context.test.ts +163 -0
  31. package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
  32. package/src/__tests__/memory-regressions.test.ts +6 -6
  33. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  34. package/src/__tests__/migration-export-http.test.ts +0 -1
  35. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  36. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  37. package/src/__tests__/migration-validate-http.test.ts +0 -1
  38. package/src/__tests__/non-member-access-request.test.ts +0 -1
  39. package/src/__tests__/notification-guardian-path.test.ts +0 -1
  40. package/src/__tests__/notification-telegram-adapter.test.ts +0 -4
  41. package/src/__tests__/openai-provider.test.ts +82 -0
  42. package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
  43. package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
  44. package/src/__tests__/recurrence-types.test.ts +0 -15
  45. package/src/__tests__/relay-server.test.ts +145 -2
  46. package/src/__tests__/sandbox-host-parity.test.ts +5 -2
  47. package/src/__tests__/schedule-tools.test.ts +28 -44
  48. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  49. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  50. package/src/__tests__/slack-channel-config.test.ts +0 -1
  51. package/src/__tests__/slack-inbound-verification.test.ts +0 -1
  52. package/src/__tests__/sms-messaging-provider.test.ts +0 -4
  53. package/src/__tests__/task-management-tools.test.ts +111 -0
  54. package/src/__tests__/terminal-tools.test.ts +5 -2
  55. package/src/__tests__/trusted-contact-approval-notifier.test.ts +66 -74
  56. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  57. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -1
  58. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  59. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  60. package/src/__tests__/twilio-config.test.ts +0 -3
  61. package/src/__tests__/twilio-routes.test.ts +0 -1
  62. package/src/__tests__/update-bulletin.test.ts +0 -2
  63. package/src/__tests__/user-reference.test.ts +47 -1
  64. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  65. package/src/__tests__/workspace-git-service.test.ts +2 -2
  66. package/src/amazon/session.ts +30 -91
  67. package/src/calls/call-controller.ts +423 -571
  68. package/src/calls/finalize-call.ts +20 -0
  69. package/src/calls/relay-access-wait.ts +340 -0
  70. package/src/calls/relay-server.ts +271 -956
  71. package/src/calls/relay-setup-router.ts +307 -0
  72. package/src/calls/relay-verification.ts +280 -0
  73. package/src/calls/twilio-config.ts +1 -8
  74. package/src/calls/voice-control-protocol.ts +184 -0
  75. package/src/calls/voice-session-bridge.ts +1 -8
  76. package/src/channels/config.ts +41 -2
  77. package/src/config/agent-schema.ts +1 -1
  78. package/src/config/bundled-skills/followups/TOOLS.json +0 -4
  79. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  80. package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
  81. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  82. package/src/config/bundled-skills/slack-digest-setup/SKILL.md +164 -0
  83. package/src/config/core-schema.ts +1 -1
  84. package/src/config/env.ts +0 -14
  85. package/src/config/feature-flag-registry.json +5 -5
  86. package/src/config/loader.ts +19 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/user-reference.ts +47 -9
  89. package/src/daemon/handlers/config-channels.ts +11 -10
  90. package/src/daemon/handlers/contacts.ts +5 -1
  91. package/src/daemon/handlers/session-history.ts +398 -0
  92. package/src/daemon/handlers/session-user-message.ts +982 -0
  93. package/src/daemon/handlers/sessions.ts +9 -1338
  94. package/src/daemon/ipc-contract/sessions.ts +0 -6
  95. package/src/daemon/ipc-contract-inventory.json +0 -1
  96. package/src/daemon/lifecycle.ts +18 -55
  97. package/src/home-base/app-link-store.ts +0 -7
  98. package/src/memory/channel-delivery-store.ts +1 -0
  99. package/src/memory/conversation-attention-store.ts +1 -1
  100. package/src/memory/conversation-store.ts +0 -51
  101. package/src/memory/db-init.ts +9 -1
  102. package/src/memory/delivery-crud.ts +13 -0
  103. package/src/memory/invite-store.ts +71 -1
  104. package/src/memory/job-handlers/conflict.ts +24 -0
  105. package/src/memory/migrations/040-invite-code-hash-column.ts +16 -0
  106. package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
  107. package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
  108. package/src/memory/migrations/index.ts +1 -0
  109. package/src/memory/migrations/registry.ts +6 -0
  110. package/src/memory/recall-cache.ts +0 -5
  111. package/src/memory/schema/calls.ts +274 -0
  112. package/src/memory/schema/contacts.ts +127 -0
  113. package/src/memory/schema/conversations.ts +129 -0
  114. package/src/memory/schema/guardian.ts +172 -0
  115. package/src/memory/schema/index.ts +8 -0
  116. package/src/memory/schema/infrastructure.ts +205 -0
  117. package/src/memory/schema/memory-core.ts +196 -0
  118. package/src/memory/schema/notifications.ts +191 -0
  119. package/src/memory/schema/tasks.ts +78 -0
  120. package/src/memory/schema.ts +1 -1385
  121. package/src/memory/slack-thread-store.ts +0 -69
  122. package/src/notifications/decisions-store.ts +2 -105
  123. package/src/notifications/deliveries-store.ts +0 -11
  124. package/src/notifications/preferences-store.ts +1 -58
  125. package/src/permissions/checker.ts +6 -17
  126. package/src/providers/anthropic/client.ts +6 -2
  127. package/src/providers/gemini/client.ts +13 -2
  128. package/src/providers/managed-proxy/constants.ts +55 -0
  129. package/src/providers/managed-proxy/context.ts +77 -0
  130. package/src/providers/registry.ts +112 -0
  131. package/src/runtime/auth/__tests__/guard-tests.test.ts +52 -26
  132. package/src/runtime/auth/token-service.ts +50 -0
  133. package/src/runtime/channel-guardian-service.ts +1 -3
  134. package/src/runtime/channel-invite-transport.ts +121 -34
  135. package/src/runtime/channel-invite-transports/email.ts +50 -0
  136. package/src/runtime/channel-invite-transports/slack.ts +81 -0
  137. package/src/runtime/channel-invite-transports/sms.ts +70 -0
  138. package/src/runtime/channel-invite-transports/telegram.ts +29 -11
  139. package/src/runtime/channel-invite-transports/voice.ts +12 -12
  140. package/src/runtime/http-server.ts +83 -722
  141. package/src/runtime/http-types.ts +0 -16
  142. package/src/runtime/invite-redemption-service.ts +193 -0
  143. package/src/runtime/invite-redemption-templates.ts +6 -6
  144. package/src/runtime/invite-service.ts +81 -11
  145. package/src/runtime/middleware/auth.ts +0 -12
  146. package/src/runtime/routes/access-request-decision.ts +52 -6
  147. package/src/runtime/routes/app-routes.ts +33 -0
  148. package/src/runtime/routes/approval-routes.ts +32 -0
  149. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -0
  150. package/src/runtime/routes/attachment-routes.ts +32 -0
  151. package/src/runtime/routes/brain-graph-routes.ts +27 -0
  152. package/src/runtime/routes/call-routes.ts +41 -0
  153. package/src/runtime/routes/channel-readiness-routes.ts +20 -0
  154. package/src/runtime/routes/channel-routes.ts +70 -0
  155. package/src/runtime/routes/contact-routes.ts +96 -6
  156. package/src/runtime/routes/conversation-attention-routes.ts +15 -0
  157. package/src/runtime/routes/conversation-routes.ts +190 -193
  158. package/src/runtime/routes/debug-routes.ts +15 -0
  159. package/src/runtime/routes/events-routes.ts +16 -0
  160. package/src/runtime/routes/global-search-routes.ts +15 -0
  161. package/src/runtime/routes/guardian-action-routes.ts +22 -0
  162. package/src/runtime/routes/guardian-bootstrap-routes.ts +21 -6
  163. package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
  164. package/src/runtime/routes/identity-routes.ts +20 -0
  165. package/src/runtime/routes/inbound-message-handler.ts +9 -3
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +295 -10
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +9 -42
  168. package/src/runtime/routes/inbound-stages/edit-intercept.ts +10 -0
  169. package/src/runtime/routes/integration-routes.ts +83 -0
  170. package/src/runtime/routes/invite-routes.ts +32 -0
  171. package/src/runtime/routes/migration-routes.ts +30 -0
  172. package/src/runtime/routes/pairing-routes.ts +18 -0
  173. package/src/runtime/routes/secret-routes.ts +20 -0
  174. package/src/runtime/routes/surface-action-routes.ts +26 -0
  175. package/src/runtime/routes/trust-rules-routes.ts +31 -0
  176. package/src/runtime/routes/twilio-routes.ts +79 -0
  177. package/src/schedule/recurrence-types.ts +1 -11
  178. package/src/tools/browser/browser-manager.ts +10 -1
  179. package/src/tools/browser/runtime-check.ts +3 -1
  180. package/src/tools/followups/followup_create.ts +9 -3
  181. package/src/tools/mcp/mcp-tool-factory.ts +0 -17
  182. package/src/tools/memory/definitions.ts +0 -6
  183. package/src/tools/network/script-proxy/session-manager.ts +38 -3
  184. package/src/tools/schedule/create.ts +1 -3
  185. package/src/tools/schedule/update.ts +9 -6
  186. package/src/tools/shared/shell-output.ts +7 -2
  187. package/src/twitter/session.ts +29 -77
  188. package/src/util/cookie-session.ts +114 -0
  189. package/src/util/platform.ts +0 -4
  190. package/src/workspace/git-service.ts +10 -4
  191. package/src/__tests__/conversation-routes.test.ts +0 -99
  192. package/src/__tests__/task-tools.test.ts +0 -685
  193. package/src/contacts/startup-migration.ts +0 -21
@@ -0,0 +1,982 @@
1
+ import * as net from "node:net";
2
+
3
+ import { v4 as uuid } from "uuid";
4
+
5
+ import {
6
+ createAssistantMessage,
7
+ createUserMessage,
8
+ } from "../../agent/message-types.js";
9
+ import {
10
+ type ChannelId,
11
+ type InterfaceId,
12
+ parseChannelId,
13
+ parseInterfaceId,
14
+ } from "../../channels/types.js";
15
+ import { getConfig } from "../../config/loader.js";
16
+ import {
17
+ listCanonicalGuardianRequests,
18
+ listPendingCanonicalGuardianRequestsByDestinationConversation,
19
+ } from "../../memory/canonical-guardian-store.js";
20
+ import * as conversationStore from "../../memory/conversation-store.js";
21
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../runtime/assistant-scope.js";
22
+ import { routeGuardianReply } from "../../runtime/guardian-reply-router.js";
23
+ import {
24
+ resolveLocalIpcAuthContext,
25
+ resolveLocalIpcTrustContext,
26
+ } from "../../runtime/local-actor-identity.js";
27
+ import * as pendingInteractions from "../../runtime/pending-interactions.js";
28
+ import { checkIngressForSecrets } from "../../security/secret-ingress.js";
29
+ import {
30
+ compileCustomPatterns,
31
+ redactSecrets,
32
+ } from "../../security/secret-scanner.js";
33
+ import { createApprovalConversationGenerator } from "../approval-generators.js";
34
+ import { getAssistantName } from "../identity-helpers.js";
35
+ import type { UserMessageAttachment } from "../ipc-contract.js";
36
+ import type { ServerMessage, UserMessage } from "../ipc-protocol.js";
37
+ import { executeRecordingIntent } from "../recording-executor.js";
38
+ import { resolveRecordingIntent } from "../recording-intent.js";
39
+ import {
40
+ classifyRecordingIntentFallback,
41
+ containsRecordingKeywords,
42
+ } from "../recording-intent-fallback.js";
43
+ import type { Session } from "../session.js";
44
+ import {
45
+ buildSessionErrorMessage,
46
+ classifySessionError,
47
+ } from "../session-error.js";
48
+ import { resolveChannelCapabilities } from "../session-runtime-assembly.js";
49
+ import {
50
+ handleRecordingPause,
51
+ handleRecordingRestart,
52
+ handleRecordingResume,
53
+ handleRecordingStart,
54
+ handleRecordingStop,
55
+ } from "./recording.js";
56
+ import {
57
+ makeIpcEventSender,
58
+ syncCanonicalStatusFromIpcConfirmationDecision,
59
+ } from "./sessions.js";
60
+ import { type HandlerContext, log, wireEscalationHandler } from "./shared.js";
61
+
62
+ const desktopApprovalConversationGenerator =
63
+ createApprovalConversationGenerator();
64
+
65
+ // ── Recording command persistence helper ─────────────────────────────
66
+ // Several recording command actions share identical logic: send a response
67
+ // delta + complete, persist user/assistant messages, and sync in-memory
68
+ // session history. This helper consolidates that pattern.
69
+ async function persistRecordingExchange(
70
+ sessionId: string,
71
+ messageText: string,
72
+ responseText: string,
73
+ session: Session,
74
+ socket: net.Socket,
75
+ ctx: HandlerContext,
76
+ ): Promise<void> {
77
+ ctx.send(socket, {
78
+ type: "assistant_text_delta",
79
+ text: responseText,
80
+ sessionId,
81
+ });
82
+ ctx.send(socket, {
83
+ type: "message_complete",
84
+ sessionId,
85
+ });
86
+ await conversationStore.addMessage(
87
+ sessionId,
88
+ "user",
89
+ JSON.stringify([{ type: "text", text: messageText }]),
90
+ );
91
+ await conversationStore.addMessage(
92
+ sessionId,
93
+ "assistant",
94
+ JSON.stringify([{ type: "text", text: responseText }]),
95
+ );
96
+ if (!session.isProcessing()) {
97
+ session.messages.push({
98
+ role: "user",
99
+ content: [{ type: "text", text: messageText }],
100
+ });
101
+ session.messages.push({
102
+ role: "assistant",
103
+ content: [{ type: "text", text: responseText }],
104
+ });
105
+ }
106
+ }
107
+
108
+ // ── Secret ingress check and redirect ────────────────────────────────
109
+ // Returns true if the message was blocked (caller should return early).
110
+ function handleSecretIngress(
111
+ msg: UserMessage,
112
+ messageText: string,
113
+ socket: net.Socket,
114
+ ctx: HandlerContext,
115
+ session: Session,
116
+ rlog: typeof log,
117
+ dispatchUserMessage: DispatchUserMessageFn,
118
+ ): boolean {
119
+ if (msg.bypassSecretCheck) return false;
120
+
121
+ const ingressCheck = checkIngressForSecrets(messageText);
122
+ if (!ingressCheck.blocked) return false;
123
+
124
+ rlog.warn(
125
+ { detectedTypes: ingressCheck.detectedTypes },
126
+ "Blocked user message containing secrets",
127
+ );
128
+ ctx.send(socket, {
129
+ type: "error",
130
+ message: ingressCheck.userNotice!,
131
+ category: "secret_blocked",
132
+ });
133
+
134
+ const config = getConfig();
135
+ const compiledCustom = config.secretDetection.customPatterns?.length
136
+ ? compileCustomPatterns(config.secretDetection.customPatterns)
137
+ : undefined;
138
+ const redactedMessageText = redactSecrets(
139
+ messageText,
140
+ {
141
+ enabled: true,
142
+ base64Threshold: config.secretDetection.entropyThreshold,
143
+ },
144
+ compiledCustom,
145
+ ).trim();
146
+
147
+ session.redirectToSecurePrompt(ingressCheck.detectedTypes, {
148
+ onStored: (record) => {
149
+ ctx.send(socket, {
150
+ type: "assistant_text_delta",
151
+ sessionId: msg.sessionId,
152
+ text: "Saved your secret securely. Continuing with your request.",
153
+ });
154
+ ctx.send(socket, {
155
+ type: "message_complete",
156
+ sessionId: msg.sessionId,
157
+ });
158
+
159
+ const continuationParts: string[] = [];
160
+ if (redactedMessageText.length > 0)
161
+ continuationParts.push(redactedMessageText);
162
+ continuationParts.push(
163
+ `I entered the redacted secret via the Secure Credential UI and saved it as credential ${record.service}/${record.field}. ` +
164
+ "Continue with my request using that stored credential and do not ask me to paste the secret again.",
165
+ );
166
+ const continuationMessage = continuationParts.join("\n\n");
167
+ const continuationRequestId = uuid();
168
+ dispatchUserMessage(
169
+ continuationMessage,
170
+ msg.attachments ?? [],
171
+ continuationRequestId,
172
+ "secure_redirect_resume",
173
+ msg.activeSurfaceId,
174
+ msg.currentPage,
175
+ );
176
+ },
177
+ });
178
+
179
+ return true;
180
+ }
181
+
182
+ // ── Structured recording command intent ──────────────────────────────
183
+ // Returns true if the command was fully handled (caller should return early).
184
+ async function handleStructuredRecordingIntent(
185
+ msg: UserMessage,
186
+ messageText: string,
187
+ session: Session,
188
+ socket: net.Socket,
189
+ ctx: HandlerContext,
190
+ rlog: typeof log,
191
+ ): Promise<boolean> {
192
+ const config = getConfig();
193
+ if (
194
+ !config.daemon.standaloneRecording ||
195
+ msg.commandIntent?.domain !== "screen_recording"
196
+ ) {
197
+ return false;
198
+ }
199
+
200
+ const action = msg.commandIntent.action;
201
+ rlog.info(
202
+ { action, source: "commandIntent" },
203
+ "Recording command intent received in user_message",
204
+ );
205
+
206
+ if (action === "start") {
207
+ const recordingId = handleRecordingStart(
208
+ msg.sessionId,
209
+ { promptForSource: true },
210
+ socket,
211
+ ctx,
212
+ );
213
+ const responseText = recordingId
214
+ ? "Starting screen recording."
215
+ : "A recording is already active.";
216
+ await persistRecordingExchange(
217
+ msg.sessionId,
218
+ messageText,
219
+ responseText,
220
+ session,
221
+ socket,
222
+ ctx,
223
+ );
224
+ return true;
225
+ } else if (action === "stop") {
226
+ const stopped = handleRecordingStop(msg.sessionId, ctx) !== undefined;
227
+ const responseText = stopped
228
+ ? "Stopping the recording."
229
+ : "No active recording to stop.";
230
+ await persistRecordingExchange(
231
+ msg.sessionId,
232
+ messageText,
233
+ responseText,
234
+ session,
235
+ socket,
236
+ ctx,
237
+ );
238
+ return true;
239
+ } else if (action === "restart") {
240
+ const restartResult = handleRecordingRestart(msg.sessionId, socket, ctx);
241
+ await persistRecordingExchange(
242
+ msg.sessionId,
243
+ messageText,
244
+ restartResult.responseText,
245
+ session,
246
+ socket,
247
+ ctx,
248
+ );
249
+ return true;
250
+ } else if (action === "pause") {
251
+ const paused = handleRecordingPause(msg.sessionId, ctx) !== undefined;
252
+ const responseText = paused
253
+ ? "Pausing the recording."
254
+ : "No active recording to pause.";
255
+ await persistRecordingExchange(
256
+ msg.sessionId,
257
+ messageText,
258
+ responseText,
259
+ session,
260
+ socket,
261
+ ctx,
262
+ );
263
+ return true;
264
+ } else if (action === "resume") {
265
+ const resumed = handleRecordingResume(msg.sessionId, ctx) !== undefined;
266
+ const responseText = resumed
267
+ ? "Resuming the recording."
268
+ : "No active recording to resume.";
269
+ await persistRecordingExchange(
270
+ msg.sessionId,
271
+ messageText,
272
+ responseText,
273
+ session,
274
+ socket,
275
+ ctx,
276
+ );
277
+ return true;
278
+ }
279
+
280
+ // Unrecognized action — fall through to normal text handling
281
+ rlog.warn(
282
+ { action, source: "commandIntent" },
283
+ "Unrecognized screen_recording action, falling through to text handling",
284
+ );
285
+ return false;
286
+ }
287
+
288
+ // ── Standalone recording intent interception ─────────────────────────
289
+ // Returns the original content before strip (if recording keywords were
290
+ // stripped from the message), or undefined if the message was fully handled
291
+ // or no recording intent was detected.
292
+ async function handleStandaloneRecordingIntent(
293
+ msg: UserMessage,
294
+ messageText: string,
295
+ session: Session,
296
+ socket: net.Socket,
297
+ ctx: HandlerContext,
298
+ rlog: typeof log,
299
+ ): Promise<{
300
+ handled: boolean;
301
+ originalContentBeforeStrip?: string;
302
+ updatedMessageText: string;
303
+ }> {
304
+ const config = getConfig();
305
+ if (!config.daemon.standaloneRecording || !messageText) {
306
+ return { handled: false, updatedMessageText: messageText };
307
+ }
308
+
309
+ const name = getAssistantName();
310
+ const dynamicNames = [name].filter(Boolean) as string[];
311
+ const intentResult = resolveRecordingIntent(messageText, dynamicNames);
312
+
313
+ // Pure recording-only intents
314
+ if (
315
+ intentResult.kind === "start_only" ||
316
+ intentResult.kind === "stop_only" ||
317
+ intentResult.kind === "start_and_stop_only" ||
318
+ intentResult.kind === "restart_only" ||
319
+ intentResult.kind === "pause_only" ||
320
+ intentResult.kind === "resume_only"
321
+ ) {
322
+ const execResult = executeRecordingIntent(intentResult, {
323
+ conversationId: msg.sessionId,
324
+ socket,
325
+ ctx,
326
+ });
327
+
328
+ if (execResult.handled) {
329
+ rlog.info(
330
+ { kind: intentResult.kind },
331
+ "Recording intent intercepted in user_message",
332
+ );
333
+ await persistRecordingExchange(
334
+ msg.sessionId,
335
+ messageText,
336
+ execResult.responseText!,
337
+ session,
338
+ socket,
339
+ ctx,
340
+ );
341
+ return { handled: true, updatedMessageText: messageText };
342
+ }
343
+ }
344
+
345
+ // Recording intent with remainder text
346
+ if (
347
+ intentResult.kind === "start_with_remainder" ||
348
+ intentResult.kind === "stop_with_remainder" ||
349
+ intentResult.kind === "start_and_stop_with_remainder" ||
350
+ intentResult.kind === "restart_with_remainder"
351
+ ) {
352
+ const execResult = executeRecordingIntent(intentResult, {
353
+ conversationId: msg.sessionId,
354
+ socket,
355
+ ctx,
356
+ });
357
+
358
+ const originalContentBeforeStrip = messageText;
359
+ const updatedText = execResult.remainderText ?? messageText;
360
+ msg.content = updatedText;
361
+
362
+ if (intentResult.kind === "stop_with_remainder") {
363
+ handleRecordingStop(msg.sessionId, ctx);
364
+ }
365
+ if (intentResult.kind === "start_with_remainder") {
366
+ handleRecordingStart(
367
+ msg.sessionId,
368
+ { promptForSource: true },
369
+ socket,
370
+ ctx,
371
+ );
372
+ }
373
+ if (
374
+ intentResult.kind === "restart_with_remainder" ||
375
+ intentResult.kind === "start_and_stop_with_remainder"
376
+ ) {
377
+ const restartResult = handleRecordingRestart(msg.sessionId, socket, ctx);
378
+ if (
379
+ !restartResult.initiated &&
380
+ restartResult.reason === "no_active_recording" &&
381
+ intentResult.kind === "start_and_stop_with_remainder"
382
+ ) {
383
+ handleRecordingStart(
384
+ msg.sessionId,
385
+ { promptForSource: true },
386
+ socket,
387
+ ctx,
388
+ );
389
+ }
390
+ }
391
+
392
+ rlog.info(
393
+ { remaining: updatedText, kind: intentResult.kind },
394
+ "Recording intent with remainder — continuing with remaining text",
395
+ );
396
+
397
+ return {
398
+ handled: false,
399
+ originalContentBeforeStrip,
400
+ updatedMessageText: updatedText,
401
+ };
402
+ }
403
+
404
+ // 'none' — deterministic resolver found nothing; try LLM fallback
405
+ // if the text contains recording-related keywords.
406
+ if (intentResult.kind === "none" && containsRecordingKeywords(messageText)) {
407
+ const fallback = await classifyRecordingIntentFallback(messageText);
408
+ rlog.info(
409
+ {
410
+ fallbackAction: fallback.action,
411
+ fallbackConfidence: fallback.confidence,
412
+ },
413
+ "Recording intent LLM fallback result",
414
+ );
415
+
416
+ if (fallback.action !== "none" && fallback.confidence === "high") {
417
+ const kindMap: Record<
418
+ string,
419
+ import("../recording-intent.js").RecordingIntentResult
420
+ > = {
421
+ start: { kind: "start_only" },
422
+ stop: { kind: "stop_only" },
423
+ restart: { kind: "restart_only" },
424
+ pause: { kind: "pause_only" },
425
+ resume: { kind: "resume_only" },
426
+ };
427
+ const mapped = kindMap[fallback.action];
428
+ if (mapped) {
429
+ const execResult = executeRecordingIntent(mapped, {
430
+ conversationId: msg.sessionId,
431
+ socket,
432
+ ctx,
433
+ });
434
+
435
+ if (execResult.handled) {
436
+ rlog.info(
437
+ { kind: mapped.kind, source: "llm_fallback" },
438
+ "Recording intent intercepted via LLM fallback",
439
+ );
440
+ await persistRecordingExchange(
441
+ msg.sessionId,
442
+ messageText,
443
+ execResult.responseText!,
444
+ session,
445
+ socket,
446
+ ctx,
447
+ );
448
+ return { handled: true, updatedMessageText: messageText };
449
+ }
450
+ }
451
+ }
452
+ }
453
+
454
+ return { handled: false, updatedMessageText: messageText };
455
+ }
456
+
457
+ // ── Pending confirmation reply interception ──────────────────────────
458
+ // Returns true if the message was consumed as an inline approval reply.
459
+ async function handlePendingConfirmationReply(
460
+ msg: UserMessage,
461
+ messageText: string,
462
+ requestId: string,
463
+ session: Session,
464
+ ipcChannel: ChannelId,
465
+ ipcInterface: InterfaceId,
466
+ socket: net.Socket,
467
+ ctx: HandlerContext,
468
+ rlog: typeof log,
469
+ ): Promise<boolean> {
470
+ if (!session.hasAnyPendingConfirmation() || messageText.trim().length === 0) {
471
+ return false;
472
+ }
473
+
474
+ try {
475
+ const pendingInteractionRequestIdsForConversation = pendingInteractions
476
+ .getByConversation(msg.sessionId)
477
+ .filter(
478
+ (interaction) =>
479
+ interaction.kind === "confirmation" &&
480
+ interaction.session === session &&
481
+ session.hasPendingConfirmation(interaction.requestId),
482
+ )
483
+ .map((interaction) => interaction.requestId);
484
+
485
+ const pendingCanonicalRequestIdsForConversation = [
486
+ ...listPendingCanonicalGuardianRequestsByDestinationConversation(
487
+ msg.sessionId,
488
+ ipcChannel,
489
+ )
490
+ .filter((request) => request.kind === "tool_approval")
491
+ .map((request) => request.id),
492
+ ...listCanonicalGuardianRequests({
493
+ status: "pending",
494
+ conversationId: msg.sessionId,
495
+ kind: "tool_approval",
496
+ }).map((request) => request.id),
497
+ ].filter((pendingRequestId) =>
498
+ session.hasPendingConfirmation(pendingRequestId),
499
+ );
500
+
501
+ const pendingRequestIdsForConversation = Array.from(
502
+ new Set([
503
+ ...pendingInteractionRequestIdsForConversation,
504
+ ...pendingCanonicalRequestIdsForConversation,
505
+ ]),
506
+ );
507
+
508
+ if (pendingRequestIdsForConversation.length === 0) {
509
+ return false;
510
+ }
511
+
512
+ const localCtx = resolveLocalIpcTrustContext(ipcChannel);
513
+ const routerResult = await routeGuardianReply({
514
+ messageText: messageText.trim(),
515
+ channel: ipcChannel,
516
+ actor: {
517
+ actorPrincipalId: localCtx.guardianPrincipalId ?? undefined,
518
+ actorExternalUserId: localCtx.guardianExternalUserId,
519
+ channel: ipcChannel,
520
+ guardianPrincipalId: localCtx.guardianPrincipalId ?? undefined,
521
+ },
522
+ conversationId: msg.sessionId,
523
+ pendingRequestIds: pendingRequestIdsForConversation,
524
+ approvalConversationGenerator: desktopApprovalConversationGenerator,
525
+ emissionContext: {
526
+ source: "inline_nl",
527
+ causedByRequestId: requestId,
528
+ decisionText: messageText.trim(),
529
+ },
530
+ });
531
+
532
+ if (routerResult.consumed && routerResult.type !== "nl_keep_pending") {
533
+ if (routerResult.requestId && !routerResult.decisionApplied) {
534
+ session.emitConfirmationStateChanged({
535
+ sessionId: msg.sessionId,
536
+ requestId: routerResult.requestId,
537
+ state: "resolved_stale",
538
+ source: "inline_nl",
539
+ causedByRequestId: requestId,
540
+ decisionText: messageText.trim(),
541
+ });
542
+ }
543
+
544
+ const consumedChannelMeta = {
545
+ userMessageChannel: ipcChannel,
546
+ assistantMessageChannel: ipcChannel,
547
+ userMessageInterface: ipcInterface,
548
+ assistantMessageInterface: ipcInterface,
549
+ provenanceTrustClass: "guardian" as const,
550
+ };
551
+
552
+ const consumedUserMessage = createUserMessage(
553
+ messageText,
554
+ msg.attachments ?? [],
555
+ );
556
+ await conversationStore.addMessage(
557
+ msg.sessionId,
558
+ "user",
559
+ JSON.stringify(consumedUserMessage.content),
560
+ consumedChannelMeta,
561
+ );
562
+
563
+ const replyText =
564
+ routerResult.replyText?.trim() ||
565
+ (routerResult.decisionApplied
566
+ ? "Decision applied."
567
+ : "Request already resolved.");
568
+ const consumedAssistantMessage = createAssistantMessage(replyText);
569
+ await conversationStore.addMessage(
570
+ msg.sessionId,
571
+ "assistant",
572
+ JSON.stringify(consumedAssistantMessage.content),
573
+ consumedChannelMeta,
574
+ );
575
+ if (!session.isProcessing()) {
576
+ session.messages.push(consumedUserMessage, consumedAssistantMessage);
577
+ }
578
+
579
+ ctx.send(socket, {
580
+ type: "message_queued",
581
+ sessionId: msg.sessionId,
582
+ requestId,
583
+ position: 0,
584
+ });
585
+ ctx.send(socket, {
586
+ type: "message_dequeued",
587
+ sessionId: msg.sessionId,
588
+ requestId,
589
+ });
590
+
591
+ if (!session.isProcessing()) {
592
+ ctx.send(socket, {
593
+ type: "assistant_text_delta",
594
+ text: replyText,
595
+ sessionId: msg.sessionId,
596
+ });
597
+ }
598
+ ctx.send(socket, {
599
+ type: "message_request_complete",
600
+ sessionId: msg.sessionId,
601
+ requestId,
602
+ runStillActive: session.isProcessing(),
603
+ });
604
+
605
+ rlog.info(
606
+ {
607
+ routerType: routerResult.type,
608
+ decisionApplied: routerResult.decisionApplied,
609
+ routerRequestId: routerResult.requestId,
610
+ },
611
+ "Consumed pending-confirmation reply before auto-deny",
612
+ );
613
+ return true;
614
+ }
615
+ } catch (err) {
616
+ rlog.warn(
617
+ { err },
618
+ "Failed to process pending-confirmation reply; falling back to auto-deny behavior",
619
+ );
620
+ }
621
+
622
+ return false;
623
+ }
624
+
625
+ // ── Auto-deny pending confirmations ──────────────────────────────────
626
+ function autoDenyPendingConfirmations(
627
+ msg: UserMessage,
628
+ requestId: string,
629
+ session: Session,
630
+ rlog: typeof log,
631
+ ): void {
632
+ if (!session.hasAnyPendingConfirmation()) return;
633
+
634
+ rlog.info("Auto-denying pending confirmation(s) due to new user message");
635
+ for (const interaction of pendingInteractions.getByConversation(
636
+ msg.sessionId,
637
+ )) {
638
+ if (
639
+ interaction.session === session &&
640
+ interaction.kind === "confirmation"
641
+ ) {
642
+ session.emitConfirmationStateChanged({
643
+ sessionId: msg.sessionId,
644
+ requestId: interaction.requestId,
645
+ state: "denied",
646
+ source: "auto_deny",
647
+ causedByRequestId: requestId,
648
+ });
649
+ }
650
+ }
651
+ session.denyAllPendingConfirmations();
652
+ for (const interaction of pendingInteractions.getByConversation(
653
+ msg.sessionId,
654
+ )) {
655
+ if (
656
+ interaction.session === session &&
657
+ interaction.kind === "confirmation"
658
+ ) {
659
+ syncCanonicalStatusFromIpcConfirmationDecision(
660
+ interaction.requestId,
661
+ "deny",
662
+ );
663
+ pendingInteractions.resolve(interaction.requestId);
664
+ }
665
+ }
666
+ }
667
+
668
+ // ── Dispatch user message function type ──────────────────────────────
669
+ type DispatchUserMessageFn = (
670
+ content: string,
671
+ attachments: UserMessageAttachment[],
672
+ dispatchRequestId: string,
673
+ source: "user_message" | "secure_redirect_resume",
674
+ activeSurfaceId?: string,
675
+ currentPage?: string,
676
+ displayContent?: string,
677
+ ) => void;
678
+
679
+ // ── Build dispatch function ──────────────────────────────────────────
680
+ // Creates the dispatchUserMessage closure used to enqueue or immediately
681
+ // process a user message through the session.
682
+ function buildDispatchUserMessage(params: {
683
+ msg: UserMessage;
684
+ session: Session;
685
+ sendEvent: (event: ServerMessage) => void;
686
+ ipcChannel: ChannelId;
687
+ ipcInterface: InterfaceId;
688
+ socket: net.Socket;
689
+ ctx: HandlerContext;
690
+ rlog: typeof log;
691
+ }): DispatchUserMessageFn {
692
+ const {
693
+ msg,
694
+ session,
695
+ sendEvent,
696
+ ipcChannel,
697
+ ipcInterface,
698
+ socket,
699
+ ctx,
700
+ rlog,
701
+ } = params;
702
+
703
+ const queuedChannelMetadata = {
704
+ userMessageChannel: ipcChannel,
705
+ assistantMessageChannel: ipcChannel,
706
+ userMessageInterface: ipcInterface,
707
+ assistantMessageInterface: ipcInterface,
708
+ };
709
+
710
+ return (
711
+ content: string,
712
+ attachments: UserMessageAttachment[],
713
+ dispatchRequestId: string,
714
+ source: "user_message" | "secure_redirect_resume",
715
+ activeSurfaceId?: string,
716
+ currentPage?: string,
717
+ displayContent?: string,
718
+ ): void => {
719
+ const receivedDescription =
720
+ source === "user_message"
721
+ ? "User message received"
722
+ : "Resuming message after secure credential save";
723
+ const queuedDescription =
724
+ source === "user_message"
725
+ ? "Message queued (session busy)"
726
+ : "Resumed message queued (session busy)";
727
+
728
+ session.traceEmitter.emit("request_received", receivedDescription, {
729
+ requestId: dispatchRequestId,
730
+ status: "info",
731
+ attributes: { source },
732
+ });
733
+
734
+ const result = session.enqueueMessage(
735
+ content,
736
+ attachments,
737
+ sendEvent,
738
+ dispatchRequestId,
739
+ activeSurfaceId,
740
+ currentPage,
741
+ queuedChannelMetadata,
742
+ undefined,
743
+ displayContent,
744
+ );
745
+ if (result.rejected) {
746
+ rlog.warn({ source }, "Message rejected — queue is full");
747
+ session.traceEmitter.emit(
748
+ "request_error",
749
+ "Message rejected — queue is full",
750
+ {
751
+ requestId: dispatchRequestId,
752
+ status: "error",
753
+ attributes: {
754
+ reason: "queue_full",
755
+ queueDepth: session.getQueueDepth(),
756
+ source,
757
+ },
758
+ },
759
+ );
760
+ ctx.send(
761
+ socket,
762
+ buildSessionErrorMessage(msg.sessionId, {
763
+ code: "QUEUE_FULL",
764
+ userMessage:
765
+ "Message queue is full (max depth: 10). Please wait for current messages to be processed.",
766
+ retryable: true,
767
+ debugDetails: "Message rejected — session queue is full",
768
+ }),
769
+ );
770
+ return;
771
+ }
772
+ if (result.queued) {
773
+ const position = session.getQueueDepth();
774
+ rlog.info({ source, position }, queuedDescription);
775
+ session.traceEmitter.emit(
776
+ "request_queued",
777
+ `Message queued at position ${position}`,
778
+ {
779
+ requestId: dispatchRequestId,
780
+ status: "info",
781
+ attributes: { position, source },
782
+ },
783
+ );
784
+ ctx.send(socket, {
785
+ type: "message_queued",
786
+ sessionId: msg.sessionId,
787
+ requestId: dispatchRequestId,
788
+ position,
789
+ });
790
+ return;
791
+ }
792
+
793
+ rlog.info({ source }, "Processing user message");
794
+ session.emitActivityState(
795
+ "thinking",
796
+ "message_dequeued",
797
+ "assistant_turn",
798
+ dispatchRequestId,
799
+ );
800
+ session.setTurnChannelContext({
801
+ userMessageChannel: ipcChannel,
802
+ assistantMessageChannel: ipcChannel,
803
+ });
804
+ session.setTurnInterfaceContext({
805
+ userMessageInterface: ipcInterface,
806
+ assistantMessageInterface: ipcInterface,
807
+ });
808
+ session.setAssistantId(DAEMON_INTERNAL_ASSISTANT_ID);
809
+ session.setTrustContext(resolveLocalIpcTrustContext(ipcChannel));
810
+ session.setAuthContext(resolveLocalIpcAuthContext(msg.sessionId));
811
+ session.setCommandIntent(null);
812
+ session
813
+ .processMessage(
814
+ content,
815
+ attachments,
816
+ sendEvent,
817
+ dispatchRequestId,
818
+ activeSurfaceId,
819
+ currentPage,
820
+ undefined,
821
+ displayContent,
822
+ )
823
+ .catch((err) => {
824
+ const message = err instanceof Error ? err.message : String(err);
825
+ rlog.error(
826
+ { err, source },
827
+ "Error processing user message (session or provider failure)",
828
+ );
829
+ ctx.send(socket, {
830
+ type: "error",
831
+ message: `Failed to process message: ${message}`,
832
+ });
833
+ const classified = classifySessionError(err, { phase: "agent_loop" });
834
+ ctx.send(socket, buildSessionErrorMessage(msg.sessionId, classified));
835
+ });
836
+ };
837
+ }
838
+
839
+ export async function handleUserMessage(
840
+ msg: UserMessage,
841
+ socket: net.Socket,
842
+ ctx: HandlerContext,
843
+ ): Promise<void> {
844
+ const requestId = uuid();
845
+ const rlog = log.child({ sessionId: msg.sessionId, requestId });
846
+ try {
847
+ ctx.socketToSession.set(socket, msg.sessionId);
848
+ const session = await ctx.getOrCreateSession(msg.sessionId, socket, true);
849
+ // Only wire the escalation handler if one isn't already set — handleTaskSubmit
850
+ // sets a handler with the client's actual screen dimensions, and overwriting it
851
+ // here would replace those dimensions with the daemon's defaults.
852
+ if (!session.hasEscalationHandler()) {
853
+ wireEscalationHandler(session, socket, ctx);
854
+ }
855
+
856
+ const ipcChannel = parseChannelId(msg.channel) ?? "vellum";
857
+ const sendEvent = makeIpcEventSender({
858
+ ctx,
859
+ socket,
860
+ session,
861
+ conversationId: msg.sessionId,
862
+ sourceChannel: ipcChannel,
863
+ });
864
+ // Route prompter-originated events (confirmation_request/secret_request)
865
+ // through the IPC wrapper so pending-interactions + canonical tracking
866
+ // are updated before the message is sent to the client.
867
+ session.updateClient(sendEvent, false);
868
+ const ipcInterface = parseInterfaceId(msg.interface);
869
+ if (!ipcInterface) {
870
+ ctx.send(socket, {
871
+ type: "error",
872
+ message:
873
+ "Invalid user_message: interface is required and must be valid",
874
+ });
875
+ return;
876
+ }
877
+
878
+ // Update channel capabilities eagerly so both immediate and queued paths
879
+ // reflect the latest PTT / microphone state from the client.
880
+ session.setChannelCapabilities(
881
+ resolveChannelCapabilities(ipcChannel, ipcInterface, {
882
+ pttActivationKey: msg.pttActivationKey,
883
+ microphonePermissionGranted: msg.microphonePermissionGranted,
884
+ }),
885
+ );
886
+
887
+ const dispatchUserMessage = buildDispatchUserMessage({
888
+ msg,
889
+ session,
890
+ sendEvent,
891
+ ipcChannel,
892
+ ipcInterface,
893
+ socket,
894
+ ctx,
895
+ rlog,
896
+ });
897
+
898
+ let messageText = msg.content ?? "";
899
+
900
+ // Block inbound messages that contain secrets and redirect to secure prompt
901
+ if (
902
+ handleSecretIngress(
903
+ msg,
904
+ messageText,
905
+ socket,
906
+ ctx,
907
+ session,
908
+ rlog,
909
+ dispatchUserMessage,
910
+ )
911
+ ) {
912
+ return;
913
+ }
914
+
915
+ // ── Structured command intent (bypasses text parsing) ──────────────────
916
+ if (
917
+ await handleStructuredRecordingIntent(
918
+ msg,
919
+ messageText,
920
+ session,
921
+ socket,
922
+ ctx,
923
+ rlog,
924
+ )
925
+ ) {
926
+ return;
927
+ }
928
+
929
+ // ── Standalone recording intent interception ──────────────────────────
930
+ const recordingResult = await handleStandaloneRecordingIntent(
931
+ msg,
932
+ messageText,
933
+ session,
934
+ socket,
935
+ ctx,
936
+ rlog,
937
+ );
938
+ if (recordingResult.handled) return;
939
+ messageText = recordingResult.updatedMessageText;
940
+ const originalContentBeforeStrip =
941
+ recordingResult.originalContentBeforeStrip;
942
+
943
+ // ── Pending confirmation reply interception ───────────────────────────
944
+ if (
945
+ await handlePendingConfirmationReply(
946
+ msg,
947
+ messageText,
948
+ requestId,
949
+ session,
950
+ ipcChannel,
951
+ ipcInterface,
952
+ socket,
953
+ ctx,
954
+ rlog,
955
+ )
956
+ ) {
957
+ return;
958
+ }
959
+
960
+ // ── Auto-deny pending confirmations ───────────────────────────────────
961
+ autoDenyPendingConfirmations(msg, requestId, session, rlog);
962
+
963
+ dispatchUserMessage(
964
+ messageText,
965
+ msg.attachments ?? [],
966
+ requestId,
967
+ "user_message",
968
+ msg.activeSurfaceId,
969
+ msg.currentPage,
970
+ originalContentBeforeStrip,
971
+ );
972
+ } catch (err) {
973
+ const message = err instanceof Error ? err.message : String(err);
974
+ rlog.error({ err }, "Error setting up user message processing");
975
+ ctx.send(socket, {
976
+ type: "error",
977
+ message: `Failed to process message: ${message}`,
978
+ });
979
+ const classified = classifySessionError(err, { phase: "handler" });
980
+ ctx.send(socket, buildSessionErrorMessage(msg.sessionId, classified));
981
+ }
982
+ }