@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
@@ -31,41 +31,15 @@ function hasIngressConfigured(): boolean {
31
31
  }
32
32
  }
33
33
 
34
- function getAssistantMappedPhoneNumber(
35
- smsConfig: Record<string, unknown>,
36
- assistantId?: string,
37
- ): string | undefined {
38
- if (!assistantId) return undefined;
39
- const mapping = (smsConfig.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
40
- return mapping[assistantId];
41
- }
42
-
43
- function hasAnyAssistantMappedPhoneNumber(smsConfig: Record<string, unknown>): boolean {
44
- const mapping = (smsConfig.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
45
- return Object.keys(mapping).length > 0;
46
- }
47
-
48
- function hasAnyAssistantMappedPhoneNumberSafe(): boolean {
49
- try {
50
- const raw = loadRawConfig();
51
- const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
52
- return hasAnyAssistantMappedPhoneNumber(smsConfig);
53
- } catch {
54
- return false;
55
- }
56
- }
57
-
58
34
  /**
59
35
  * Resolve SMS from-number with canonical precedence:
60
- * assistant mapping -> env override -> config sms.phoneNumber -> secure key fallback.
36
+ * env override -> config sms.phoneNumber -> secure key fallback.
61
37
  */
62
- function resolveSmsPhoneNumber(assistantId?: string): string {
38
+ function resolveSmsPhoneNumber(): string {
63
39
  try {
64
40
  const raw = loadRawConfig();
65
41
  const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
66
- const mapped = getAssistantMappedPhoneNumber(smsConfig, assistantId);
67
- return mapped
68
- || getTwilioPhoneNumberEnv()
42
+ return getTwilioPhoneNumberEnv()
69
43
  || (smsConfig.phoneNumber as string)
70
44
  || getSecureKey('credential:twilio:phone_number')
71
45
  || '';
@@ -78,7 +52,7 @@ function resolveSmsPhoneNumber(assistantId?: string): string {
78
52
 
79
53
  const smsProbe: ChannelProbe = {
80
54
  channel: 'sms',
81
- runLocalChecks(context?: ChannelProbeContext): ReadinessCheckResult[] {
55
+ runLocalChecks(): ReadinessCheckResult[] {
82
56
  const results: ReadinessCheckResult[] = [];
83
57
 
84
58
  const hasCreds = hasTwilioCredentials();
@@ -90,18 +64,14 @@ const smsProbe: ChannelProbe = {
90
64
  : 'Twilio Account SID and Auth Token are not configured',
91
65
  });
92
66
 
93
- const resolvedNumber = resolveSmsPhoneNumber(context?.assistantId);
94
- const hasPhone = !!resolvedNumber || (!context?.assistantId && hasAnyAssistantMappedPhoneNumberSafe());
67
+ const resolvedNumber = resolveSmsPhoneNumber();
68
+ const hasPhone = !!resolvedNumber;
95
69
  results.push({
96
70
  name: 'phone_number',
97
71
  passed: hasPhone,
98
72
  message: hasPhone
99
- ? (context?.assistantId && !resolvedNumber
100
- ? `Assistant ${context.assistantId} has no direct mapping, but SMS phone numbers are assigned`
101
- : 'Phone number is assigned')
102
- : (context?.assistantId
103
- ? `No phone number assigned for assistant ${context.assistantId}`
104
- : 'No phone number assigned'),
73
+ ? 'Phone number is assigned'
74
+ : 'No phone number assigned',
105
75
  });
106
76
 
107
77
  const hasIngress = hasIngressConfigured();
@@ -115,14 +85,14 @@ const smsProbe: ChannelProbe = {
115
85
 
116
86
  return results;
117
87
  },
118
- async runRemoteChecks(context?: ChannelProbeContext): Promise<ReadinessCheckResult[]> {
88
+ async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
119
89
  if (!hasTwilioCredentials()) return [];
120
90
 
121
91
  const accountSid = getSecureKey('credential:twilio:account_sid');
122
92
  const authToken = getSecureKey('credential:twilio:auth_token');
123
93
  if (!accountSid || !authToken) return [];
124
94
 
125
- const phoneNumber = resolveSmsPhoneNumber(context?.assistantId);
95
+ const phoneNumber = resolveSmsPhoneNumber();
126
96
  if (!phoneNumber) return [];
127
97
 
128
98
  // Only toll-free numbers need verification checks
@@ -170,18 +140,16 @@ const smsProbe: ChannelProbe = {
170
140
 
171
141
  /**
172
142
  * Resolve voice from-number with the same precedence as SMS:
173
- * assistant mapping -> env override -> config sms.phoneNumber -> secure key fallback.
143
+ * env override -> config sms.phoneNumber -> secure key fallback.
174
144
  *
175
145
  * Voice and SMS share the same Twilio phone number infrastructure, so the
176
146
  * resolution logic is identical to resolveSmsPhoneNumber.
177
147
  */
178
- function resolveVoicePhoneNumber(assistantId?: string): string {
148
+ function resolveVoicePhoneNumber(): string {
179
149
  try {
180
150
  const raw = loadRawConfig();
181
151
  const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
182
- const mapped = getAssistantMappedPhoneNumber(smsConfig, assistantId);
183
- return mapped
184
- || getTwilioPhoneNumberEnv()
152
+ return getTwilioPhoneNumberEnv()
185
153
  || (smsConfig.phoneNumber as string)
186
154
  || getSecureKey('credential:twilio:phone_number')
187
155
  || '';
@@ -194,7 +162,7 @@ function resolveVoicePhoneNumber(assistantId?: string): string {
194
162
 
195
163
  const voiceProbe: ChannelProbe = {
196
164
  channel: 'voice',
197
- runLocalChecks(context?: ChannelProbeContext): ReadinessCheckResult[] {
165
+ runLocalChecks(): ReadinessCheckResult[] {
198
166
  const results: ReadinessCheckResult[] = [];
199
167
 
200
168
  const hasCreds = hasTwilioCredentials();
@@ -206,18 +174,14 @@ const voiceProbe: ChannelProbe = {
206
174
  : 'Twilio Account SID and Auth Token are not configured',
207
175
  });
208
176
 
209
- const resolvedNumber = resolveVoicePhoneNumber(context?.assistantId);
210
- const hasPhone = !!resolvedNumber || (!context?.assistantId && hasAnyAssistantMappedPhoneNumberSafe());
177
+ const resolvedNumber = resolveVoicePhoneNumber();
178
+ const hasPhone = !!resolvedNumber;
211
179
  results.push({
212
180
  name: 'phone_number',
213
181
  passed: hasPhone,
214
182
  message: hasPhone
215
- ? (context?.assistantId && !resolvedNumber
216
- ? `Assistant ${context.assistantId} has no direct mapping, but phone numbers are assigned`
217
- : 'Phone number is assigned for voice calls')
218
- : (context?.assistantId
219
- ? `No phone number assigned for assistant ${context.assistantId}`
220
- : 'No phone number assigned for voice calls'),
183
+ ? 'Phone number is assigned for voice calls'
184
+ : 'No phone number assigned for voice calls',
221
185
  });
222
186
 
223
187
  const hasIngress = hasIngressConfigured();
@@ -290,7 +254,6 @@ export class ChannelReadinessService {
290
254
  async getReadiness(
291
255
  channel?: ChannelId,
292
256
  includeRemote?: boolean,
293
- assistantId?: string,
294
257
  ): Promise<ChannelReadinessSnapshot[]> {
295
258
  const channels = channel
296
259
  ? [channel]
@@ -304,14 +267,14 @@ export class ChannelReadinessService {
304
267
  continue;
305
268
  }
306
269
 
307
- const probeContext: ChannelProbeContext = { assistantId };
270
+ const probeContext: ChannelProbeContext = {};
308
271
  const localChecks = probe.runLocalChecks(probeContext);
309
272
  let remoteChecks: ReadinessCheckResult[] | undefined;
310
273
  let remoteChecksFreshlyFetched = false;
311
274
  let remoteChecksAffectReadiness = false;
312
275
  let stale = false;
313
276
 
314
- const cacheKey = this.snapshotCacheKey(ch, assistantId);
277
+ const cacheKey = this.snapshotCacheKey(ch);
315
278
  const cached = this.snapshots.get(cacheKey);
316
279
  const now = Date.now();
317
280
 
@@ -372,11 +335,7 @@ export class ChannelReadinessService {
372
335
  }
373
336
 
374
337
  /** Clear cached snapshot for a specific channel, forcing re-evaluation on next call. */
375
- invalidateChannel(channel: ChannelId, assistantId?: string): void {
376
- if (assistantId) {
377
- this.snapshots.delete(this.snapshotCacheKey(channel, assistantId));
378
- return;
379
- }
338
+ invalidateChannel(channel: ChannelId): void {
380
339
  const prefix = `${channel}::`;
381
340
  for (const key of this.snapshots.keys()) {
382
341
  if (key.startsWith(prefix)) {
@@ -401,8 +360,8 @@ export class ChannelReadinessService {
401
360
  };
402
361
  }
403
362
 
404
- private snapshotCacheKey(channel: ChannelId, assistantId?: string): string {
405
- return `${channel}::${assistantId ?? '__default__'}`;
363
+ private snapshotCacheKey(channel: ChannelId): string {
364
+ return `${channel}::__default__`;
406
365
  }
407
366
  }
408
367
 
@@ -22,10 +22,9 @@ export interface ChannelReadinessSnapshot {
22
22
  remoteChecks?: ReadinessCheckResult[];
23
23
  }
24
24
 
25
- /** Optional probe context for assistant-scoped readiness checks. */
26
- export interface ChannelProbeContext {
27
- assistantId?: string;
28
- }
25
+ /** Optional probe context for readiness checks. */
26
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
27
+ export interface ChannelProbeContext {}
29
28
 
30
29
  /** Probe interface that channels implement to provide readiness checks. */
31
30
  export interface ChannelProbe {
@@ -7,6 +7,7 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
7
7
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
8
8
  import { getLogger } from '../util/logger.js';
9
9
  import { deliverReplyViaCallback } from './channel-reply-delivery.js';
10
+ import { resolveRoutingStateFromRuntime } from './guardian-context-resolver.js';
10
11
  import type { MessageProcessor } from './http-types.js';
11
12
 
12
13
  const log = getLogger('runtime-http');
@@ -129,7 +130,9 @@ export async function sweepFailedEvents(
129
130
  },
130
131
  assistantId,
131
132
  guardianContext,
132
- isInteractive: guardianContext?.trustClass === 'guardian',
133
+ isInteractive: guardianContext
134
+ ? resolveRoutingStateFromRuntime(guardianContext).promptWaitingAllowed
135
+ : false,
133
136
  },
134
137
  sourceChannel,
135
138
  sourceInterface,
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Bridge trusted-contact confirmation_request events to guardian.question notifications.
3
+ *
4
+ * When a trusted-contact channel session creates a confirmation_request (tool approval),
5
+ * this helper emits a guardian.question notification signal and persists canonical
6
+ * delivery rows to guardian destinations (Telegram/SMS/Vellum), enabling the guardian
7
+ * to approve via callback/request-code path.
8
+ *
9
+ * Modeled after the tool-grant-request-helper pattern. Designed to be called from
10
+ * both the daemon event registrar (server.ts) and the HTTP hub publisher
11
+ * (conversation-routes.ts) — the two paths that create confirmation_request
12
+ * canonical records.
13
+ */
14
+
15
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
16
+ import {
17
+ type CanonicalGuardianRequest,
18
+ createCanonicalGuardianDelivery,
19
+ } from '../memory/canonical-guardian-store.js';
20
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
21
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
22
+ import { getLogger } from '../util/logger.js';
23
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
24
+ import { getGuardianBinding } from './channel-guardian-service.js';
25
+
26
+ const log = getLogger('confirmation-request-guardian-bridge');
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface BridgeConfirmationRequestParams {
33
+ /** The canonical guardian request already persisted for this confirmation_request. */
34
+ canonicalRequest: CanonicalGuardianRequest;
35
+ /** Guardian runtime context from the session. */
36
+ guardianContext: GuardianRuntimeContext;
37
+ /** Conversation ID where the confirmation_request was emitted. */
38
+ conversationId: string;
39
+ /** Tool name from the confirmation_request. */
40
+ toolName: string;
41
+ /** Logical assistant ID (defaults to 'self'). */
42
+ assistantId?: string;
43
+ }
44
+
45
+ export type BridgeConfirmationRequestResult =
46
+ | { bridged: true; signalId: string }
47
+ | { skipped: true; reason: 'not_trusted_contact' | 'no_guardian_binding' | 'missing_guardian_identity' | 'binding_identity_mismatch' };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Helper
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Bridge a trusted-contact confirmation_request to a guardian.question notification.
55
+ *
56
+ * Only emits when the session belongs to a trusted-contact actor with a
57
+ * resolvable guardian binding. Guardian and unknown actors are skipped — guardians
58
+ * self-approve, and unknown actors are already fail-closed by the routing layer.
59
+ *
60
+ * Fire-and-forget safe: notification emission errors are logged but not propagated.
61
+ */
62
+ export function bridgeConfirmationRequestToGuardian(
63
+ params: BridgeConfirmationRequestParams,
64
+ ): BridgeConfirmationRequestResult {
65
+ const {
66
+ canonicalRequest,
67
+ guardianContext,
68
+ conversationId,
69
+ toolName,
70
+ assistantId = DAEMON_INTERNAL_ASSISTANT_ID,
71
+ } = params;
72
+
73
+ // Only bridge for trusted-contact sessions. Guardians self-approve and
74
+ // unknown actors are fail-closed by the routing layer.
75
+ if (guardianContext.trustClass !== 'trusted_contact') {
76
+ return { skipped: true, reason: 'not_trusted_contact' };
77
+ }
78
+
79
+ if (!guardianContext.guardianExternalUserId) {
80
+ log.debug(
81
+ { conversationId, sourceChannel: guardianContext.sourceChannel },
82
+ 'Skipping guardian bridge: no guardian identity on trusted-contact context',
83
+ );
84
+ return { skipped: true, reason: 'missing_guardian_identity' };
85
+ }
86
+
87
+ const sourceChannel = guardianContext.sourceChannel;
88
+ const binding = getGuardianBinding(assistantId, sourceChannel);
89
+ if (!binding) {
90
+ log.debug(
91
+ { sourceChannel, assistantId },
92
+ 'No guardian binding for confirmation request bridge',
93
+ );
94
+ return { skipped: true, reason: 'no_guardian_binding' };
95
+ }
96
+
97
+ // Validate that the binding's guardian identity matches the canonical request's
98
+ // guardian identity. A mismatch can occur if a guardian rebind happens between
99
+ // message ingress and confirmation emission — sending the notification to the
100
+ // new binding would leak requester/tool metadata to the wrong recipient.
101
+ //
102
+ // Both sides are canonicalized before comparison because the canonical request
103
+ // value was normalized by resolveGuardianContext() while the binding stores the
104
+ // raw identity. On phone channels the same guardian can have format variance
105
+ // (e.g. "+1 555-123-4567" vs "+15551234567") that would cause a false mismatch.
106
+ const canonicalBindingId = canonicalizeInboundIdentity(sourceChannel, binding.guardianExternalUserId);
107
+ const canonicalRequestId = canonicalRequest.guardianExternalUserId
108
+ ? canonicalizeInboundIdentity(sourceChannel, canonicalRequest.guardianExternalUserId)
109
+ : null;
110
+ if (
111
+ canonicalRequestId &&
112
+ canonicalBindingId !== canonicalRequestId
113
+ ) {
114
+ log.warn(
115
+ {
116
+ sourceChannel,
117
+ assistantId,
118
+ bindingGuardianId: binding.guardianExternalUserId,
119
+ expectedGuardianId: canonicalRequest.guardianExternalUserId,
120
+ requestId: canonicalRequest.id,
121
+ },
122
+ 'Guardian binding identity does not match canonical request guardian — skipping notification to prevent misrouting',
123
+ );
124
+ return { skipped: true, reason: 'binding_identity_mismatch' };
125
+ }
126
+
127
+ const senderLabel = guardianContext.requesterIdentifier
128
+ || guardianContext.requesterExternalUserId
129
+ || 'unknown';
130
+
131
+ const questionText = `Tool approval request: ${toolName}`;
132
+
133
+ // Emit guardian.question notification so the guardian is alerted.
134
+ const signalPromise = emitNotificationSignal({
135
+ sourceEventName: 'guardian.question',
136
+ sourceChannel,
137
+ sourceSessionId: conversationId,
138
+ assistantId,
139
+ attentionHints: {
140
+ requiresAction: true,
141
+ urgency: 'high',
142
+ isAsyncBackground: false,
143
+ visibleInSourceNow: false,
144
+ },
145
+ contextPayload: {
146
+ requestKind: 'tool_approval' as const,
147
+ requestId: canonicalRequest.id,
148
+ requestCode: canonicalRequest.requestCode ?? canonicalRequest.id.slice(0, 6).toUpperCase(),
149
+ sourceChannel,
150
+ requesterExternalUserId: guardianContext.requesterExternalUserId,
151
+ requesterChatId: guardianContext.requesterChatId ?? null,
152
+ requesterIdentifier: senderLabel,
153
+ toolName,
154
+ questionText,
155
+ },
156
+ dedupeKey: `tc-confirmation-request:${canonicalRequest.id}`,
157
+ onThreadCreated: (info) => {
158
+ createCanonicalGuardianDelivery({
159
+ requestId: canonicalRequest.id,
160
+ destinationChannel: 'vellum',
161
+ destinationConversationId: info.conversationId,
162
+ });
163
+ },
164
+ });
165
+
166
+ // Record channel deliveries from the notification pipeline (fire-and-forget).
167
+ void signalPromise.then((signalResult) => {
168
+ for (const result of signalResult.deliveryResults) {
169
+ if (result.channel === 'vellum') continue; // handled in onThreadCreated
170
+ if (result.channel !== 'telegram' && result.channel !== 'sms') continue;
171
+ createCanonicalGuardianDelivery({
172
+ requestId: canonicalRequest.id,
173
+ destinationChannel: result.channel,
174
+ destinationChatId: result.destination.length > 0 ? result.destination : undefined,
175
+ });
176
+ }
177
+ }).catch((err) => {
178
+ log.warn({ err, requestId: canonicalRequest.id }, 'Failed to record channel deliveries for guardian bridge');
179
+ });
180
+
181
+ log.info(
182
+ {
183
+ sourceChannel,
184
+ requesterExternalUserId: guardianContext.requesterExternalUserId,
185
+ toolName,
186
+ requestId: canonicalRequest.id,
187
+ requestCode: canonicalRequest.requestCode,
188
+ },
189
+ 'Guardian notified of trusted-contact confirmation request',
190
+ );
191
+
192
+ // Return the signal ID synchronously from the promise-producing call.
193
+ // The actual signal ID is not available until the promise resolves, but
194
+ // callers only need to know it was bridged — the ID is for diagnostics.
195
+ // We use the canonical request ID as a stable correlation key.
196
+ return { bridged: true, signalId: canonicalRequest.id };
197
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Guardian action follow-up executor.
3
3
  *
4
- * After the conversation engine (M5) classifies the guardian's reply as
4
+ * After the conversation engine classifies the guardian's reply as
5
5
  * `call_back` or `message_back` and transitions the follow-up state to
6
6
  * `dispatching`, this module executes the actual action:
7
7
  *
@@ -62,6 +62,88 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
62
62
  };
63
63
  }
64
64
 
65
+ // ---------------------------------------------------------------------------
66
+ // Routing-state helper
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Routing state for a channel actor turn.
71
+ *
72
+ * Determines whether a turn should be treated as interactive (the caller
73
+ * can be kept waiting for a guardian to respond to an approval prompt) by
74
+ * combining trust class with guardian route resolvability.
75
+ *
76
+ * A guardian route is "resolvable" when a verified guardian binding exists
77
+ * for the channel — meaning there is a concrete destination to deliver
78
+ * approval notifications to. Without a resolvable guardian route, entering
79
+ * an interactive wait (up to 300s) is a dead-end: no guardian will ever
80
+ * see the prompt.
81
+ */
82
+ export interface RoutingState {
83
+ /** Whether the actor's trust class alone permits interactive waits. */
84
+ canBeInteractive: boolean;
85
+ /** Whether a verified guardian destination exists for this channel. */
86
+ guardianRouteResolvable: boolean;
87
+ /**
88
+ * Whether the turn should actually enter an interactive prompt wait.
89
+ * True only when the actor can be interactive AND a guardian route is
90
+ * resolvable. This is the canonical decision used by processMessage.
91
+ */
92
+ promptWaitingAllowed: boolean;
93
+ }
94
+
95
+ /**
96
+ * Compute the routing state for a channel actor turn.
97
+ *
98
+ * Guardian actors are always interactive (they self-approve).
99
+ * Trusted contacts are only interactive when a guardian binding exists
100
+ * to receive approval notifications. Unknown actors are never interactive.
101
+ */
102
+ export function resolveRoutingState(ctx: GuardianContext): RoutingState {
103
+ const isGuardian = ctx.trustClass === 'guardian';
104
+ const isTrustedContact = ctx.trustClass === 'trusted_contact';
105
+
106
+ // Guardians self-approve — they are always interactive and route-resolvable.
107
+ if (isGuardian) {
108
+ return {
109
+ canBeInteractive: true,
110
+ guardianRouteResolvable: true,
111
+ promptWaitingAllowed: true,
112
+ };
113
+ }
114
+
115
+ // Trusted contacts can be interactive only if a guardian destination
116
+ // exists. The guardian binding populates guardianExternalUserId during
117
+ // trust resolution; its presence means there is a verified guardian
118
+ // to route approval notifications to.
119
+ const guardianRouteResolvable = !!ctx.guardianExternalUserId;
120
+ if (isTrustedContact) {
121
+ return {
122
+ canBeInteractive: true,
123
+ guardianRouteResolvable,
124
+ promptWaitingAllowed: guardianRouteResolvable,
125
+ };
126
+ }
127
+
128
+ // Unknown actors are never interactive.
129
+ return {
130
+ canBeInteractive: false,
131
+ guardianRouteResolvable: !!ctx.guardianExternalUserId,
132
+ promptWaitingAllowed: false,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Convenience: compute routing state from a GuardianRuntimeContext
138
+ * (the shape persisted in stored payloads and used by the retry sweep).
139
+ */
140
+ export function resolveRoutingStateFromRuntime(ctx: GuardianRuntimeContext): RoutingState {
141
+ return resolveRoutingState({
142
+ trustClass: ctx.trustClass,
143
+ guardianExternalUserId: ctx.guardianExternalUserId,
144
+ });
145
+ }
146
+
65
147
  export function toGuardianRuntimeContext(sourceChannel: ChannelId, ctx: GuardianContext): GuardianRuntimeContext {
66
148
  return {
67
149
  sourceChannel,
@@ -16,7 +16,8 @@ import { sendMessage as sendSms } from '../messaging/providers/sms/client.js';
16
16
  import { getCredentialMetadata } from '../tools/credentials/metadata-store.js';
17
17
  import { getLogger } from '../util/logger.js';
18
18
  import { normalizePhoneNumber } from '../util/phone.js';
19
- import { normalizeAssistantId, readHttpToken } from '../util/platform.js';
19
+ import { readHttpToken } from '../util/platform.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
20
21
  import {
21
22
  countRecentSendsToDestination,
22
23
  createOutboundSession,
@@ -93,7 +94,6 @@ function getTelegramBotUsername(): string | undefined {
93
94
 
94
95
  export interface StartOutboundParams {
95
96
  channel: ChannelId;
96
- assistantId?: string;
97
97
  destination?: string;
98
98
  rebind?: boolean;
99
99
  /** Origin conversation ID so completion/failure pointers can route back. */
@@ -102,14 +102,12 @@ export interface StartOutboundParams {
102
102
 
103
103
  export interface ResendOutboundParams {
104
104
  channel: ChannelId;
105
- assistantId?: string;
106
105
  /** Origin conversation ID so completion/failure pointers can route back on resend. */
107
106
  originConversationId?: string;
108
107
  }
109
108
 
110
109
  export interface CancelOutboundParams {
111
110
  channel: ChannelId;
112
- assistantId?: string;
113
111
  }
114
112
 
115
113
  /**
@@ -243,7 +241,7 @@ function initiateGuardianVoiceCall(
243
241
  // ---------------------------------------------------------------------------
244
242
 
245
243
  export function startOutbound(params: StartOutboundParams): OutboundActionResult {
246
- const assistantId = normalizeAssistantId(params.assistantId ?? 'self');
244
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
247
245
  const channel = params.channel;
248
246
  const originConversationId = params.originConversationId;
249
247
 
@@ -541,7 +539,7 @@ function startOutboundVoice(
541
539
  // ---------------------------------------------------------------------------
542
540
 
543
541
  export function resendOutbound(params: ResendOutboundParams): OutboundActionResult {
544
- const assistantId = normalizeAssistantId(params.assistantId ?? 'self');
542
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
545
543
  const channel = params.channel;
546
544
  const originConversationId = params.originConversationId;
547
545
 
@@ -707,7 +705,7 @@ export function resendOutbound(params: ResendOutboundParams): OutboundActionResu
707
705
  // ---------------------------------------------------------------------------
708
706
 
709
707
  export function cancelOutbound(params: CancelOutboundParams): OutboundActionResult {
710
- const assistantId = normalizeAssistantId(params.assistantId ?? 'self');
708
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
711
709
  const channel = params.channel;
712
710
 
713
711
  const session = findActiveSession(assistantId, channel);