@vellumai/assistant 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Shared request-kind and instruction-mode resolver for guardian.question signals.
3
+ *
4
+ * Explicit request kinds provide a stable contract between producers and
5
+ * notification rendering logic, avoiding implicit inference from incidental
6
+ * fields like `toolName`.
7
+ */
8
+
9
+ export const GUARDIAN_QUESTION_REQUEST_KINDS = {
10
+ pending_question: 'pending_question',
11
+ tool_approval: 'tool_approval',
12
+ tool_grant_request: 'tool_grant_request',
13
+ access_request: 'access_request',
14
+ } as const;
15
+
16
+ export type GuardianQuestionRequestKind = keyof typeof GUARDIAN_QUESTION_REQUEST_KINDS;
17
+ export type GuardianQuestionInstructionMode = 'approval' | 'answer';
18
+
19
+ interface GuardianRequestKindModeConfig {
20
+ defaultMode: GuardianQuestionInstructionMode;
21
+ modeWhenToolNamePresent?: GuardianQuestionInstructionMode;
22
+ }
23
+
24
+ const REQUEST_KIND_MODE_CONFIG: Record<GuardianQuestionRequestKind, GuardianRequestKindModeConfig> = {
25
+ pending_question: {
26
+ defaultMode: 'answer',
27
+ modeWhenToolNamePresent: 'approval',
28
+ },
29
+ tool_approval: {
30
+ defaultMode: 'approval',
31
+ },
32
+ tool_grant_request: {
33
+ defaultMode: 'approval',
34
+ },
35
+ access_request: {
36
+ defaultMode: 'approval',
37
+ },
38
+ };
39
+
40
+ interface GuardianQuestionPayloadBase {
41
+ requestId: string;
42
+ requestCode: string;
43
+ questionText: string;
44
+ }
45
+
46
+ interface GuardianQuestionPayloadBaseWithDiscriminator extends GuardianQuestionPayloadBase {
47
+ requestKind: GuardianQuestionRequestKind;
48
+ [key: string]: unknown;
49
+ }
50
+
51
+ export interface GuardianRequestModeInput {
52
+ kind: unknown;
53
+ toolName?: unknown;
54
+ }
55
+
56
+ export interface GuardianRequestTextInput {
57
+ requestCode: string;
58
+ questionText?: string | null;
59
+ toolName?: string | null;
60
+ }
61
+
62
+ type GuardianDisambiguationCategory = 'questions' | 'approvals';
63
+
64
+ interface GuardianModeTextConfig {
65
+ invalidActionWithCode: (requestCode: string) => string;
66
+ invalidActionWithoutCode: string;
67
+ buildCodeOnlyHeader: (request: GuardianRequestTextInput) => string;
68
+ buildCodeOnlyDetailLine: (request: GuardianRequestTextInput) => string | null;
69
+ buildDisambiguationLabel: (request: Pick<GuardianRequestTextInput, 'questionText' | 'toolName'>) => string;
70
+ disambiguationCategory: GuardianDisambiguationCategory;
71
+ }
72
+
73
+ const MODE_TEXT_CONFIG: Record<GuardianQuestionInstructionMode, GuardianModeTextConfig> = {
74
+ answer: {
75
+ invalidActionWithCode: (requestCode) =>
76
+ `I found request ${requestCode}, but I still need your answer. Reply "${requestCode} <your answer>".`,
77
+ invalidActionWithoutCode:
78
+ "I couldn't determine your answer. Reply with the request code followed by your answer (e.g., \"ABC123 3pm works\").",
79
+ buildCodeOnlyHeader: (request) => `I found question ${request.requestCode}.`,
80
+ buildCodeOnlyDetailLine: (request) => request.questionText ? `Question: ${request.questionText}` : null,
81
+ buildDisambiguationLabel: (request) => request.questionText ?? 'question',
82
+ disambiguationCategory: 'questions',
83
+ },
84
+ approval: {
85
+ invalidActionWithCode: (requestCode) =>
86
+ `I found request ${requestCode}, but I need to know your decision. Reply "${requestCode} approve" or "${requestCode} reject".`,
87
+ invalidActionWithoutCode:
88
+ "I couldn't determine your intended action. Reply with the request code followed by 'approve' or 'reject' (e.g., \"ABC123 approve\").",
89
+ buildCodeOnlyHeader: (request) => `I found request ${request.requestCode} for ${request.toolName ?? 'an action'}.`,
90
+ buildCodeOnlyDetailLine: (request) => request.questionText ? `Details: ${request.questionText}` : null,
91
+ buildDisambiguationLabel: (request) => request.toolName ?? request.questionText ?? 'action',
92
+ disambiguationCategory: 'approvals',
93
+ },
94
+ };
95
+
96
+ export interface PendingQuestionGuardianPayload extends GuardianQuestionPayloadBaseWithDiscriminator {
97
+ requestKind: 'pending_question';
98
+ callSessionId: string;
99
+ activeGuardianRequestCount: number;
100
+ /**
101
+ * Voice tool-approval requests are persisted as pending_question with tool
102
+ * metadata so they still route through pending-question resolution.
103
+ */
104
+ toolName?: string;
105
+ }
106
+
107
+ export interface ToolApprovalGuardianPayload extends GuardianQuestionPayloadBaseWithDiscriminator {
108
+ requestKind: 'tool_approval';
109
+ toolName: string;
110
+ }
111
+
112
+ export interface ToolGrantGuardianPayload extends GuardianQuestionPayloadBaseWithDiscriminator {
113
+ requestKind: 'tool_grant_request';
114
+ toolName: string;
115
+ }
116
+
117
+ export interface AccessRequestGuardianPayload extends GuardianQuestionPayloadBaseWithDiscriminator {
118
+ requestKind: 'access_request';
119
+ }
120
+
121
+ export type GuardianQuestionPayload =
122
+ | PendingQuestionGuardianPayload
123
+ | ToolApprovalGuardianPayload
124
+ | ToolGrantGuardianPayload
125
+ | AccessRequestGuardianPayload;
126
+
127
+ export interface GuardianQuestionModeResolution {
128
+ mode: GuardianQuestionInstructionMode;
129
+ requestKind: GuardianQuestionRequestKind | null;
130
+ legacyFallbackUsed: boolean;
131
+ }
132
+
133
+ function nonEmptyString(value: unknown): string | null {
134
+ if (typeof value !== 'string') return null;
135
+ const trimmed = value.trim();
136
+ return trimmed.length > 0 ? trimmed : null;
137
+ }
138
+
139
+ export function parseGuardianQuestionRequestKind(
140
+ payload: Record<string, unknown>,
141
+ ): GuardianQuestionRequestKind | null {
142
+ const raw = nonEmptyString(payload.requestKind);
143
+ if (!raw) return null;
144
+
145
+ switch (raw) {
146
+ case 'pending_question':
147
+ case 'tool_approval':
148
+ case 'tool_grant_request':
149
+ case 'access_request':
150
+ return raw;
151
+ default:
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function parseBasePayload(payload: Record<string, unknown>): GuardianQuestionPayloadBase | null {
157
+ const requestId = nonEmptyString(payload.requestId);
158
+ const requestCode = nonEmptyString(payload.requestCode);
159
+ const questionText = nonEmptyString(payload.questionText);
160
+ if (!requestId || !requestCode || !questionText) return null;
161
+ return { requestId, requestCode, questionText };
162
+ }
163
+
164
+ /**
165
+ * Parse a guardian.question context payload into a strict discriminated union.
166
+ *
167
+ * Returns null when required fields for the declared requestKind are missing,
168
+ * or when requestKind is absent/unknown.
169
+ */
170
+ export function parseGuardianQuestionPayload(
171
+ payload: Record<string, unknown>,
172
+ ): GuardianQuestionPayload | null {
173
+ const requestKind = parseGuardianQuestionRequestKind(payload);
174
+ if (!requestKind) return null;
175
+
176
+ const base = parseBasePayload(payload);
177
+ if (!base) return null;
178
+
179
+ switch (requestKind) {
180
+ case 'pending_question': {
181
+ const callSessionId = nonEmptyString(payload.callSessionId);
182
+ const activeGuardianRequestCount = typeof payload.activeGuardianRequestCount === 'number'
183
+ ? payload.activeGuardianRequestCount
184
+ : undefined;
185
+ const toolName = nonEmptyString(payload.toolName);
186
+ if (!callSessionId || activeGuardianRequestCount === undefined || Number.isNaN(activeGuardianRequestCount)) {
187
+ return null;
188
+ }
189
+ const pendingQuestionPayload: PendingQuestionGuardianPayload = {
190
+ requestKind,
191
+ ...base,
192
+ callSessionId,
193
+ activeGuardianRequestCount,
194
+ };
195
+ if (toolName) {
196
+ pendingQuestionPayload.toolName = toolName;
197
+ }
198
+ return {
199
+ ...pendingQuestionPayload,
200
+ };
201
+ }
202
+ case 'tool_approval':
203
+ case 'tool_grant_request': {
204
+ const toolName = nonEmptyString(payload.toolName);
205
+ if (!toolName) return null;
206
+ return {
207
+ requestKind,
208
+ ...base,
209
+ toolName,
210
+ };
211
+ }
212
+ case 'access_request':
213
+ return {
214
+ requestKind,
215
+ ...base,
216
+ };
217
+ default:
218
+ return null;
219
+ }
220
+ }
221
+
222
+ export function resolveGuardianInstructionModeForRequestKind(
223
+ requestKind: GuardianQuestionRequestKind,
224
+ toolName?: string | null,
225
+ ): GuardianQuestionInstructionMode {
226
+ const config = REQUEST_KIND_MODE_CONFIG[requestKind];
227
+ const normalizedToolName = nonEmptyString(toolName);
228
+ if (normalizedToolName && config.modeWhenToolNamePresent) {
229
+ return config.modeWhenToolNamePresent;
230
+ }
231
+
232
+ return config.defaultMode;
233
+ }
234
+
235
+ export function resolveGuardianInstructionModeFromFields(
236
+ requestKindValue: unknown,
237
+ toolNameValue: unknown,
238
+ ): { requestKind: GuardianQuestionRequestKind; mode: GuardianQuestionInstructionMode } | null {
239
+ const requestKind = parseGuardianQuestionRequestKind({ requestKind: requestKindValue });
240
+ if (!requestKind) return null;
241
+
242
+ return {
243
+ requestKind,
244
+ mode: resolveGuardianInstructionModeForRequestKind(requestKind, nonEmptyString(toolNameValue)),
245
+ };
246
+ }
247
+
248
+ export function resolveGuardianInstructionModeForRequest(
249
+ request?: GuardianRequestModeInput | null,
250
+ ): GuardianQuestionInstructionMode {
251
+ if (!request) return 'approval';
252
+ const modeResolution = resolveGuardianInstructionModeFromFields(request.kind, request.toolName);
253
+ if (!modeResolution) return 'approval';
254
+ return modeResolution.mode;
255
+ }
256
+
257
+ function getModeTextConfig(mode: GuardianQuestionInstructionMode): GuardianModeTextConfig {
258
+ return MODE_TEXT_CONFIG[mode];
259
+ }
260
+
261
+ export function buildGuardianReplyDirective(
262
+ requestCode: string,
263
+ mode: GuardianQuestionInstructionMode,
264
+ ): string {
265
+ switch (mode) {
266
+ case 'approval':
267
+ return `Reply "${requestCode} approve" or "${requestCode} reject".`;
268
+ case 'answer':
269
+ return `Reply "${requestCode} <your answer>".`;
270
+ default: {
271
+ const _never: never = mode;
272
+ return _never;
273
+ }
274
+ }
275
+ }
276
+
277
+ export function buildGuardianRequestCodeInstruction(
278
+ requestCode: string,
279
+ mode: GuardianQuestionInstructionMode,
280
+ ): string {
281
+ return `Reference code: ${requestCode}. ${buildGuardianReplyDirective(requestCode, mode)}`;
282
+ }
283
+
284
+ export function buildGuardianInvalidActionReply(
285
+ mode: GuardianQuestionInstructionMode,
286
+ requestCode?: string,
287
+ ): string {
288
+ const config = getModeTextConfig(mode);
289
+ if (requestCode) return config.invalidActionWithCode(requestCode);
290
+ return config.invalidActionWithoutCode;
291
+ }
292
+
293
+ export function buildGuardianCodeOnlyClarification(
294
+ mode: GuardianQuestionInstructionMode,
295
+ request: GuardianRequestTextInput,
296
+ ): string {
297
+ const config = getModeTextConfig(mode);
298
+ const lines = [
299
+ config.buildCodeOnlyHeader(request),
300
+ ];
301
+ const detailLine = config.buildCodeOnlyDetailLine(request);
302
+ if (detailLine) {
303
+ lines.push(detailLine);
304
+ }
305
+ lines.push(buildGuardianReplyDirective(request.requestCode, mode));
306
+ return lines.join('\n');
307
+ }
308
+
309
+ export function buildGuardianDisambiguationLabel(
310
+ mode: GuardianQuestionInstructionMode,
311
+ request: Pick<GuardianRequestTextInput, 'questionText' | 'toolName'>,
312
+ ): string {
313
+ return getModeTextConfig(mode).buildDisambiguationLabel(request);
314
+ }
315
+
316
+ export function buildGuardianDisambiguationExample(
317
+ mode: GuardianQuestionInstructionMode,
318
+ requestCode: string,
319
+ ): string {
320
+ const category = getModeTextConfig(mode).disambiguationCategory;
321
+ const replyDirective = buildGuardianReplyDirective(requestCode, mode);
322
+ return `For ${category}: ${replyDirective.replace(/^Reply/, 'reply')}`;
323
+ }
324
+
325
+ export function hasGuardianRequestCodeInstruction(
326
+ text: string | undefined,
327
+ requestCode: string,
328
+ mode: GuardianQuestionInstructionMode,
329
+ ): boolean {
330
+ if (typeof text !== 'string') return false;
331
+ const upper = text.toUpperCase();
332
+ const normalizedCode = requestCode.toUpperCase();
333
+
334
+ switch (mode) {
335
+ case 'approval':
336
+ return upper.includes(`${normalizedCode} APPROVE`) && upper.includes(`${normalizedCode} REJECT`);
337
+ case 'answer': {
338
+ const hasAnswerInstruction = upper.includes(`${normalizedCode} <YOUR ANSWER>`);
339
+ const hasApprovalInstruction = upper.includes(`${normalizedCode} APPROVE`) || upper.includes(`${normalizedCode} REJECT`);
340
+ return hasAnswerInstruction && !hasApprovalInstruction;
341
+ }
342
+ default: {
343
+ const _never: never = mode;
344
+ return _never;
345
+ }
346
+ }
347
+ }
348
+
349
+ function escapeRegExp(value: string): string {
350
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
351
+ }
352
+
353
+ function normalizeInstructionText(value: string): string {
354
+ return value
355
+ .replace(/[ \t]+\n/g, '\n')
356
+ .replace(/\n{3,}/g, '\n\n')
357
+ .trim();
358
+ }
359
+
360
+ export function stripConflictingGuardianRequestInstructions(
361
+ text: string,
362
+ requestCode: string,
363
+ mode: GuardianQuestionInstructionMode,
364
+ ): string {
365
+ const escapedCode = escapeRegExp(requestCode);
366
+ const approvalInstructionPattern = new RegExp(
367
+ `(?:Reference\\s+code:\\s*${escapedCode}\\.?\\s*)?Reply\\s+"${escapedCode}\\s+approve"\\s+or\\s+"${escapedCode}\\s+reject"\\.?`,
368
+ 'ig',
369
+ );
370
+ const answerInstructionPattern = new RegExp(
371
+ `(?:Reference\\s+code:\\s*${escapedCode}\\.?\\s*)?Reply\\s+"${escapedCode}\\s+<your\\s+answer>"\\.?`,
372
+ 'ig',
373
+ );
374
+
375
+ const next = mode === 'answer'
376
+ ? text.replace(approvalInstructionPattern, '')
377
+ : text.replace(answerInstructionPattern, '');
378
+
379
+ return normalizeInstructionText(next);
380
+ }
381
+
382
+ /**
383
+ * Resolve guardian reply instruction mode from request kind.
384
+ *
385
+ * Backward compatibility: if requestKind is missing/unknown, fall back to
386
+ * toolName presence so previously persisted payloads keep working.
387
+ */
388
+ export function resolveGuardianQuestionInstructionMode(
389
+ payload: Record<string, unknown>,
390
+ ): GuardianQuestionModeResolution {
391
+ const parsed = parseGuardianQuestionPayload(payload);
392
+ if (parsed) {
393
+ const parsedToolName = nonEmptyString('toolName' in parsed ? parsed.toolName : null);
394
+ return {
395
+ mode: resolveGuardianInstructionModeForRequestKind(parsed.requestKind, parsedToolName),
396
+ requestKind: parsed.requestKind,
397
+ legacyFallbackUsed: false,
398
+ };
399
+ }
400
+
401
+ const requestKindResolution = resolveGuardianInstructionModeFromFields(
402
+ payload.requestKind,
403
+ payload.toolName,
404
+ );
405
+ if (requestKindResolution) {
406
+ return {
407
+ mode: requestKindResolution.mode,
408
+ requestKind: requestKindResolution.requestKind,
409
+ legacyFallbackUsed: true,
410
+ };
411
+ }
412
+
413
+ const toolName = nonEmptyString(payload.toolName);
414
+ return {
415
+ mode: toolName ? 'approval' : 'answer',
416
+ requestKind: null,
417
+ legacyFallbackUsed: true,
418
+ };
419
+ }
@@ -4,6 +4,8 @@
4
4
  * decision engine route contextually.
5
5
  */
6
6
 
7
+ import type { GuardianQuestionPayload } from './guardian-question-mode.js';
8
+
7
9
  export interface AttentionHints {
8
10
  requiresAction: boolean;
9
11
  urgency: 'low' | 'medium' | 'high';
@@ -14,14 +16,23 @@ export interface AttentionHints {
14
16
 
15
17
  export type RoutingIntent = 'single_channel' | 'multi_channel' | 'all_channels';
16
18
 
17
- export interface NotificationSignal {
19
+ export interface NotificationEventContextPayloadMap {
20
+ 'guardian.question': GuardianQuestionPayload;
21
+ }
22
+
23
+ export type NotificationContextPayload<TEventName extends string = string> =
24
+ TEventName extends keyof NotificationEventContextPayloadMap
25
+ ? NotificationEventContextPayloadMap[TEventName]
26
+ : Record<string, unknown>;
27
+
28
+ export interface NotificationSignal<TEventName extends string = string> {
18
29
  signalId: string;
19
30
  assistantId: string;
20
31
  createdAt: number; // epoch ms
21
32
  sourceChannel: string; // free-form: 'vellum', 'telegram', 'voice', 'scheduler', etc.
22
33
  sourceSessionId: string;
23
- sourceEventName: string; // free-form: 'reminder_fired', 'schedule_complete', 'guardian_question', etc.
24
- contextPayload: Record<string, unknown>;
34
+ sourceEventName: TEventName; // free-form: 'reminder_fired', 'schedule_complete', 'guardian_question', etc.
35
+ contextPayload: NotificationContextPayload<TEventName>;
25
36
  attentionHints: AttentionHints;
26
37
  /** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
27
38
  routingIntent?: RoutingIntent;
@@ -776,7 +776,19 @@ export async function generateAllowlistOptions(toolName: string, input: Record<s
776
776
  return [{ label: '*', description: 'Everything', pattern: '*' }];
777
777
  }
778
778
 
779
- export function generateScopeOptions(workingDir: string, _toolName?: string): ScopeOption[] {
779
+ // Directory-based scope only applies to filesystem and shell tools.
780
+ // All other tools auto-use "everywhere" (the client handles this).
781
+ export const SCOPE_AWARE_TOOLS = new Set([
782
+ 'bash', 'host_bash',
783
+ 'file_read', 'file_write', 'file_edit',
784
+ 'host_file_read', 'host_file_write', 'host_file_edit',
785
+ ]);
786
+
787
+ export function generateScopeOptions(workingDir: string, toolName?: string): ScopeOption[] {
788
+ if (toolName && !SCOPE_AWARE_TOOLS.has(toolName)) {
789
+ return [];
790
+ }
791
+
780
792
  const home = homedir();
781
793
  const options: ScopeOption[] = [];
782
794
 
@@ -21,14 +21,25 @@ interface PendingPrompt {
21
21
  timer: ReturnType<typeof setTimeout>;
22
22
  }
23
23
 
24
+ export type ConfirmationStateCallback = (
25
+ requestId: string,
26
+ state: 'pending' | 'approved' | 'denied' | 'timed_out' | 'resolved_stale',
27
+ source: 'button' | 'inline_nl' | 'auto_deny' | 'timeout' | 'system',
28
+ ) => void;
29
+
24
30
  export class PermissionPrompter {
25
31
  private pending = new Map<string, PendingPrompt>();
26
32
  private sendToClient: (msg: ServerMessage) => void;
33
+ private onStateChanged?: ConfirmationStateCallback;
27
34
 
28
35
  constructor(sendToClient: (msg: ServerMessage) => void) {
29
36
  this.sendToClient = sendToClient;
30
37
  }
31
38
 
39
+ setOnStateChanged(cb: ConfirmationStateCallback): void {
40
+ this.onStateChanged = cb;
41
+ }
42
+
32
43
  updateSender(sendToClient: (msg: ServerMessage) => void): void {
33
44
  this.sendToClient = sendToClient;
34
45
  }
@@ -60,6 +71,7 @@ export class PermissionPrompter {
60
71
  const timer = setTimeout(() => {
61
72
  this.pending.delete(requestId);
62
73
  log.warn({ requestId, toolName }, 'Permission prompt timed out, defaulting to deny');
74
+ this.onStateChanged?.(requestId, 'timed_out', 'timeout');
63
75
  resolve({ decision: 'deny' });
64
76
  }, timeoutMs);
65
77
 
@@ -90,6 +102,8 @@ export class PermissionPrompter {
90
102
  executionTarget,
91
103
  persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
92
104
  });
105
+
106
+ this.onStateChanged?.(requestId, 'pending', 'system');
93
107
  });
94
108
  }
95
109
 
@@ -512,6 +512,10 @@ export class AnthropicProvider implements Provider {
512
512
  )
513
513
  : this.client.messages.stream(params, { signal: timeoutSignal });
514
514
 
515
+ // Track whether we've seen a text content block so we can insert a
516
+ // separator between consecutive text blocks in the same response.
517
+ let hasSeenTextBlock = false;
518
+
515
519
  stream.on("text", (text) => {
516
520
  onEvent?.({ type: "text_delta", text });
517
521
  });
@@ -527,6 +531,22 @@ export class AnthropicProvider implements Provider {
527
531
  let pendingInputJsonFlush: ReturnType<typeof setTimeout> | undefined;
528
532
 
529
533
  stream.on("streamEvent", (event) => {
534
+ // Insert a space separator when a new text content block starts
535
+ // after a previous one, so consecutive text blocks don't get
536
+ // concatenated without whitespace (e.g. "sentence.NextSentence").
537
+ // Uses a space instead of \n because the client's MarkdownRenderer
538
+ // can collapse soft line breaks (\n) within a paragraph.
539
+ if (event.type === 'content_block_start' && event.content_block.type === 'text') {
540
+ if (hasSeenTextBlock) {
541
+ onEvent?.({ type: "text_delta", text: " " });
542
+ }
543
+ hasSeenTextBlock = true;
544
+ } else if (event.type === 'content_block_start') {
545
+ // Reset on non-text blocks so that text separated by tool_use
546
+ // (text -> tool_use -> text) doesn't get a spurious leading space
547
+ // in the second text segment.
548
+ hasSeenTextBlock = false;
549
+ }
530
550
  if (event.type === 'content_block_start' && event.content_block.type === 'tool_use') {
531
551
  currentStreamingToolName = event.content_block.name;
532
552
  accumulatedInputJson = '';
@@ -107,10 +107,22 @@ export function extractText(response: ProviderResponse): string {
107
107
  * Extract all text blocks from a ProviderResponse and join them.
108
108
  */
109
109
  export function extractAllText(response: ProviderResponse): string {
110
- return response.content
110
+ const parts = response.content
111
111
  .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
112
- .map((b) => b.text)
113
- .join('');
112
+ .map((b) => b.text);
113
+ // Join consecutive text blocks with a space, but skip the separator when
114
+ // either side already has whitespace (avoids double-spacing).
115
+ let result = parts[0] ?? '';
116
+ for (let i = 1; i < parts.length; i++) {
117
+ const prev = result[result.length - 1];
118
+ const next = parts[i][0];
119
+ if (prev && next && prev !== ' ' && prev !== '\n' && prev !== '\t' &&
120
+ next !== ' ' && next !== '\n' && next !== '\t') {
121
+ result += ' ';
122
+ }
123
+ result += parts[i];
124
+ }
125
+ return result;
114
126
  }
115
127
 
116
128
  /**