@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
@@ -34,7 +34,6 @@ mock.module('../util/platform.js', () => ({
34
34
  getDbPath: () => join(testDir, 'test.db'),
35
35
  getLogPath: () => join(testDir, 'test.log'),
36
36
  ensureDataDir: () => {},
37
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
38
37
  readHttpToken: () => 'test-bearer-token',
39
38
  }));
40
39
 
@@ -82,6 +81,7 @@ mock.module('../runtime/approval-message-composer.js', () => ({
82
81
  composeApprovalMessageGenerative: async () => 'mock generative message',
83
82
  }));
84
83
 
84
+ import { getResolver } from '../approvals/guardian-request-resolvers.js';
85
85
  import {
86
86
  createApprovalRequest,
87
87
  createBinding,
@@ -489,6 +489,16 @@ describe('trusted contact activated notification signal', () => {
489
489
  expect(activatedSignals.length).toBe(0);
490
490
  });
491
491
 
492
+ test('voice access_request resolver has registered handler for access_request kind', () => {
493
+ // The access_request resolver is registered during module load. When the
494
+ // source channel is 'voice', it should directly activate the member via
495
+ // upsertMember (no verification session). This test validates the resolver
496
+ // is registered and accessible.
497
+ const resolver = getResolver('access_request');
498
+ expect(resolver).toBeDefined();
499
+ expect(resolver!.kind).toBe('access_request');
500
+ });
501
+
492
502
  test('member is persisted BEFORE activated signal is emitted', async () => {
493
503
  // Set up a guardian binding
494
504
  createBinding({
@@ -29,7 +29,6 @@ mock.module('../util/platform.js', () => ({
29
29
  getDbPath: () => join(testDir, 'test.db'),
30
30
  getLogPath: () => join(testDir, 'test.log'),
31
31
  ensureDataDir: () => {},
32
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
33
32
  readHttpToken: () => 'test-bearer-token',
34
33
  }));
35
34
 
@@ -32,7 +32,6 @@ mock.module('../util/platform.js', () => ({
32
32
  getDbPath: () => join(testDir, 'test.db'),
33
33
  getLogPath: () => join(testDir, 'test.log'),
34
34
  ensureDataDir: () => {},
35
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
36
35
  readHttpToken: () => 'test-bearer-token',
37
36
  }));
38
37
 
@@ -86,25 +86,14 @@ describe('twilio-config', () => {
86
86
  expect(config.phoneNumber).toBe('+15558888888');
87
87
  });
88
88
 
89
- test('resolves assistant-scoped phone number when assistantId is provided', () => {
89
+ test('returns global phone number when assistantPhoneNumbers mapping exists', () => {
90
90
  mockLoadConfigResult = {
91
91
  sms: {
92
92
  phoneNumber: '+15551234567',
93
93
  assistantPhoneNumbers: { 'ast-1': '+15557777777' },
94
94
  },
95
95
  };
96
- const config = getTwilioConfig('ast-1');
97
- expect(config.phoneNumber).toBe('+15557777777');
98
- });
99
-
100
- test('falls back to global phone number when assistant has no dedicated number', () => {
101
- mockLoadConfigResult = {
102
- sms: {
103
- phoneNumber: '+15551234567',
104
- assistantPhoneNumbers: { 'ast-1': '+15557777777' },
105
- },
106
- };
107
- const config = getTwilioConfig('ast-unknown');
96
+ const config = getTwilioConfig();
108
97
  expect(config.phoneNumber).toBe('+15551234567');
109
98
  });
110
99
  });
@@ -704,19 +704,20 @@ describe('twilio webhook routes', () => {
704
704
  expect(res.status).toBe(400);
705
705
  });
706
706
 
707
- test('inbound webhook with forwarded assistantId creates session with correct assistantId', async () => {
707
+ test('inbound webhook creates session with internal scope assistantId', async () => {
708
708
  const req = makeInboundVoiceRequest({
709
709
  CallSid: 'CA_inbound_assist_1',
710
710
  From: '+14155551234',
711
711
  To: '+15550001111',
712
712
  });
713
713
 
714
- const res = await handleVoiceWebhook(req, 'my-assistant-id');
714
+ const res = await handleVoiceWebhook(req);
715
715
 
716
716
  expect(res.status).toBe(200);
717
717
  const session = getCallSessionByCallSid('CA_inbound_assist_1');
718
718
  expect(session).not.toBeNull();
719
- expect(session!.assistantId).toBe('my-assistant-id');
719
+ // Daemon always uses internal scope — external assistant IDs are not leaked into session state.
720
+ expect(session!.assistantId).toBe('self');
720
721
  });
721
722
 
722
723
  test('outbound call flow remains non-regressed with callSessionId present', async () => {
@@ -53,7 +53,6 @@ mock.module('../util/platform.js', () => ({
53
53
  getInterfacesDir: () => '',
54
54
  getClipboardCommand: () => null,
55
55
  readLockfile: () => null,
56
- normalizeAssistantId: (id: string) => id,
57
56
  writeLockfile: () => {},
58
57
  readPlatformToken: () => null,
59
58
  readSessionToken: () => null,
package/src/agent/loop.ts CHANGED
@@ -41,7 +41,7 @@ export type AgentEvent =
41
41
 
42
42
  const DEFAULT_CONFIG: AgentLoopConfig = {
43
43
  maxTokens: 16000,
44
- maxToolUseTurns: 60,
44
+ maxToolUseTurns: 0,
45
45
  minTurnIntervalMs: 150,
46
46
  };
47
47
 
@@ -35,6 +35,7 @@ import {
35
35
  type GuardianApprovalRequest,
36
36
  updateApprovalDecision,
37
37
  } from '../memory/channel-guardian-store.js';
38
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
38
39
  import type {
39
40
  ApprovalAction,
40
41
  ApprovalDecisionResult,
@@ -52,6 +53,7 @@ import {
52
53
  type ActorContext,
53
54
  type ChannelDeliveryContext,
54
55
  getResolver,
56
+ type ResolverEmissionContext,
55
57
  } from './guardian-request-resolvers.js';
56
58
 
57
59
  const log = getLogger('guardian-decision-primitive');
@@ -233,7 +235,7 @@ export function mintCanonicalRequestGrant(params: {
233
235
  }
234
236
 
235
237
  const result = mintGrantFromDecision({
236
- assistantId: 'self',
238
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
237
239
  scopeMode: 'tool_signature',
238
240
  toolName: request.toolName,
239
241
  inputDigest: request.inputDigest,
@@ -294,10 +296,12 @@ export interface ApplyCanonicalGuardianDecisionParams {
294
296
  userText?: string;
295
297
  /** Optional channel delivery context — present when the decision arrived via a channel message. */
296
298
  channelDeliveryContext?: ChannelDeliveryContext;
299
+ /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */
300
+ emissionContext?: ResolverEmissionContext;
297
301
  }
298
302
 
299
303
  export type CanonicalDecisionResult =
300
- | { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string }
304
+ | { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string; resolverReplyText?: string }
301
305
  | { applied: false; reason: 'not_found' | 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired'; detail?: string };
302
306
 
303
307
  /**
@@ -318,7 +322,7 @@ export type CanonicalDecisionResult =
318
322
  export async function applyCanonicalGuardianDecision(
319
323
  params: ApplyCanonicalGuardianDecisionParams,
320
324
  ): Promise<CanonicalDecisionResult> {
321
- const { requestId, action, actorContext, userText, channelDeliveryContext } = params;
325
+ const { requestId, action, actorContext, userText, channelDeliveryContext, emissionContext } = params;
322
326
 
323
327
  // 1. Look up the canonical request
324
328
  const request = getCanonicalGuardianRequest(requestId);
@@ -426,6 +430,7 @@ export async function applyCanonicalGuardianDecision(
426
430
  // 5. Dispatch to kind-specific resolver
427
431
  let resolverFailed = false;
428
432
  let resolverFailureReason: string | undefined;
433
+ let resolverReplyText: string | undefined;
429
434
  const resolver = getResolver(request.kind);
430
435
  if (resolver) {
431
436
  const resolverResult = await resolver.resolve({
@@ -433,6 +438,7 @@ export async function applyCanonicalGuardianDecision(
433
438
  decision: { action: effectiveAction, userText },
434
439
  actor: actorContext,
435
440
  channelDeliveryContext,
441
+ emissionContext,
436
442
  });
437
443
 
438
444
  if (!resolverResult.ok) {
@@ -451,6 +457,8 @@ export async function applyCanonicalGuardianDecision(
451
457
  // still being informed that the resolver had an issue.
452
458
  resolverFailed = true;
453
459
  resolverFailureReason = resolverResult.reason;
460
+ } else {
461
+ resolverReplyText = resolverResult.guardianReplyText;
454
462
  }
455
463
  } else {
456
464
  log.info(
@@ -493,5 +501,6 @@ export async function applyCanonicalGuardianDecision(
493
501
  requestId,
494
502
  grantMinted,
495
503
  ...(resolverFailed ? { resolverFailed, resolverFailureReason } : {}),
504
+ ...(resolverReplyText ? { resolverReplyText } : {}),
496
505
  };
497
506
  }
@@ -12,18 +12,27 @@
12
12
  */
13
13
 
14
14
  import { answerCall } from '../calls/call-domain.js';
15
- import type { CanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
15
+ import { getGatewayInternalBaseUrl } from '../config/env.js';
16
+ import { type CanonicalGuardianRequest,getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
17
+ import { upsertMember } from '../memory/ingress-member-store.js';
16
18
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
17
19
  import { addRule } from '../permissions/trust-store.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
18
21
  import type { ApprovalAction } from '../runtime/channel-approval-types.js';
19
22
  import { createOutboundSession } from '../runtime/channel-guardian-service.js';
20
23
  import { deliverChannelReply } from '../runtime/gateway-client.js';
21
24
  import * as pendingInteractions from '../runtime/pending-interactions.js';
22
25
  import { getTool } from '../tools/registry.js';
26
+ import { TC_GRANT_WAIT_MAX_MS } from '../tools/tool-approval-handler.js';
23
27
  import { getLogger } from '../util/logger.js';
28
+ import { readHttpToken } from '../util/platform.js';
24
29
 
25
30
  const log = getLogger('guardian-request-resolvers');
26
31
 
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
27
36
  // ---------------------------------------------------------------------------
28
37
  // Types
29
38
  // ---------------------------------------------------------------------------
@@ -58,6 +67,13 @@ export interface ChannelDeliveryContext {
58
67
  bearerToken?: string;
59
68
  }
60
69
 
70
+ /** Emission context threaded from callers to handleConfirmationResponse. */
71
+ export interface ResolverEmissionContext {
72
+ source?: 'button' | 'inline_nl' | 'auto_deny' | 'timeout' | 'system';
73
+ causedByRequestId?: string;
74
+ decisionText?: string;
75
+ }
76
+
61
77
  /** Context passed to each resolver after CAS resolution succeeds. */
62
78
  export interface ResolverContext {
63
79
  /** The canonical request record (already resolved to its terminal status). */
@@ -68,13 +84,27 @@ export interface ResolverContext {
68
84
  actor: ActorContext;
69
85
  /** Optional channel delivery context — present when the decision arrived via a channel message. */
70
86
  channelDeliveryContext?: ChannelDeliveryContext;
87
+ /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */
88
+ emissionContext?: ResolverEmissionContext;
71
89
  }
72
90
 
73
91
  /** Discriminated result from a resolver. */
74
92
  export type ResolverResult =
75
- | { ok: true; applied: true; grantMinted?: boolean }
93
+ | { ok: true; applied: true; grantMinted?: boolean; guardianReplyText?: string }
76
94
  | { ok: false; reason: string };
77
95
 
96
+ function resolveDeliverCallbackUrlForChannel(channel: string): string | null {
97
+ switch (channel) {
98
+ case 'telegram':
99
+ case 'sms':
100
+ case 'whatsapp':
101
+ case 'slack':
102
+ return `${getGatewayInternalBaseUrl()}/deliver/${channel}`;
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+
78
108
  /** Interface that kind-specific resolvers implement. */
79
109
  export interface GuardianRequestResolver {
80
110
  /** The request kind this resolver handles (matches canonical_guardian_requests.kind). */
@@ -133,11 +163,13 @@ const pendingInteractionResolver: GuardianRequestResolver = {
133
163
  decision.action === 'approve_always' &&
134
164
  details &&
135
165
  details.persistentDecisionsAllowed !== false &&
136
- details.allowlistOptions?.length &&
137
- details.scopeOptions?.length
166
+ details.allowlistOptions?.length
138
167
  ) {
139
168
  const pattern = details.allowlistOptions[0].pattern;
140
- const scope = details.scopeOptions[0].scope;
169
+ // Non-scoped tools (web_fetch, network_request, etc.) have empty
170
+ // scopeOptions — default to 'everywhere' so approve_always still
171
+ // persists a trust rule instead of silently degrading to one-time.
172
+ const scope = details.scopeOptions?.length ? details.scopeOptions[0].scope : 'everywhere';
141
173
  const tool = getTool(details.toolName);
142
174
  const executionTarget = tool?.origin === 'skill' ? details.executionTarget : undefined;
143
175
  addRule(details.toolName, pattern, scope, 'allow', 100, { executionTarget });
@@ -160,7 +192,7 @@ const pendingInteractionResolver: GuardianRequestResolver = {
160
192
 
161
193
  // Map action to the permission system's UserDecision type and notify session.
162
194
  const userDecision = decision.action === 'reject' ? 'deny' as const : 'allow' as const;
163
- resolved.session.handleConfirmationResponse(request.id, userDecision);
195
+ resolved.session.handleConfirmationResponse(request.id, userDecision, undefined, undefined, undefined, ctx.emissionContext);
164
196
 
165
197
  log.info(
166
198
  {
@@ -277,8 +309,11 @@ const accessRequestResolver: GuardianRequestResolver = {
277
309
  const channel = request.sourceChannel ?? 'unknown';
278
310
  const requesterExternalUserId = request.requesterExternalUserId ?? '';
279
311
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
312
+ const requesterLabel = requesterExternalUserId || requesterChatId || 'the requester';
280
313
  const decidedByExternalUserId = ctx.actor.externalUserId ?? '';
281
- const assistantId = channelDeliveryContext?.assistantId ?? 'self';
314
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
315
+ const desktopDeliverUrl = resolveDeliverCallbackUrlForChannel(channel);
316
+ const desktopBearerToken = readHttpToken() ?? undefined;
282
317
 
283
318
  if (decision.action === 'reject') {
284
319
  log.info(
@@ -335,12 +370,59 @@ const accessRequestResolver: GuardianRequestResolver = {
335
370
  contextPayload: deniedPayload,
336
371
  dedupeKey: `trusted-contact:denied:${request.id}`,
337
372
  });
373
+ } else if (desktopDeliverUrl && requesterChatId) {
374
+ try {
375
+ await deliverChannelReply(desktopDeliverUrl, {
376
+ chatId: requesterChatId,
377
+ text: 'Your access request has been denied by the guardian.',
378
+ assistantId,
379
+ }, desktopBearerToken);
380
+ } catch (err) {
381
+ log.error({ err, requesterChatId }, 'Failed to notify requester of access request denial (desktop decision path)');
382
+ }
383
+ }
384
+
385
+ return {
386
+ ok: true,
387
+ applied: true,
388
+ ...(ctx.actor.isTrusted ? { guardianReplyText: `Access denied for ${requesterLabel}.` } : {}),
389
+ };
390
+ }
391
+
392
+ // Voice approvals: directly activate the trusted contact without minting
393
+ // a verification session. The caller is already on the line and the
394
+ // relay server's in-call wait loop will detect the approved status.
395
+ if (channel === 'voice') {
396
+ try {
397
+ upsertMember({
398
+ assistantId,
399
+ sourceChannel: 'voice',
400
+ externalUserId: requesterExternalUserId,
401
+ externalChatId: requesterChatId,
402
+ status: 'active',
403
+ policy: 'allow',
404
+ });
405
+ } catch (err) {
406
+ log.error(
407
+ { err, requesterExternalUserId },
408
+ 'Access request resolver: failed to activate voice caller as trusted contact',
409
+ );
338
410
  }
339
411
 
412
+ log.info(
413
+ {
414
+ event: 'resolver_access_request_voice_approved',
415
+ requestId: request.id,
416
+ channel,
417
+ requesterExternalUserId,
418
+ },
419
+ 'Access request resolver: voice approval — direct trusted-contact activation (no verification session)',
420
+ );
421
+
340
422
  return { ok: true, applied: true };
341
423
  }
342
424
 
343
- // On approve: mint an identity-bound verification session so the
425
+ // Non-voice approvals: mint an identity-bound verification session so the
344
426
  // requester can verify their identity.
345
427
  const session = createOutboundSession({
346
428
  assistantId,
@@ -365,6 +447,7 @@ const accessRequestResolver: GuardianRequestResolver = {
365
447
 
366
448
  // Deliver the verification code to the guardian and notify the requester
367
449
  // when channel delivery context is available (channel message path).
450
+ let requesterNotified = false;
368
451
  if (channelDeliveryContext) {
369
452
  let codeDelivered = true;
370
453
 
@@ -395,6 +478,7 @@ const accessRequestResolver: GuardianRequestResolver = {
395
478
  + 'Please enter the 6-digit verification code you receive from the guardian.',
396
479
  assistantId,
397
480
  }, channelDeliveryContext.bearerToken);
481
+ requesterNotified = true;
398
482
  } catch (err) {
399
483
  log.error({ err, requesterChatId }, 'Failed to notify requester of access request approval');
400
484
  }
@@ -434,9 +518,29 @@ const accessRequestResolver: GuardianRequestResolver = {
434
518
  dedupeKey: `trusted-contact:verification-sent:${session.sessionId}`,
435
519
  });
436
520
  }
521
+ } else if (desktopDeliverUrl && requesterChatId) {
522
+ try {
523
+ await deliverChannelReply(desktopDeliverUrl, {
524
+ chatId: requesterChatId,
525
+ text: 'Your access request has been approved! '
526
+ + 'Please enter the 6-digit verification code you receive from the guardian.',
527
+ assistantId,
528
+ }, desktopBearerToken);
529
+ requesterNotified = true;
530
+ } catch (err) {
531
+ log.error({ err, requesterChatId }, 'Failed to notify requester of access request approval (desktop decision path)');
532
+ }
437
533
  }
438
534
 
439
- return { ok: true, applied: true };
535
+ const verificationReplyText = requesterNotified
536
+ ? `Access approved for ${requesterLabel}. Give them this verification code: ${session.secret}. The code expires in 10 minutes.`
537
+ : `Access approved for ${requesterLabel}. Give them this verification code: ${session.secret}. The code expires in 10 minutes. I could not notify them automatically, so please tell them to send the code manually.`;
538
+
539
+ return {
540
+ ok: true,
541
+ applied: true,
542
+ ...(ctx.actor.isTrusted ? { guardianReplyText: verificationReplyText } : {}),
543
+ };
440
544
  },
441
545
  };
442
546
 
@@ -461,7 +565,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
461
565
  async resolve(ctx: ResolverContext): Promise<ResolverResult> {
462
566
  const { request, decision, channelDeliveryContext } = ctx;
463
567
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
464
- const assistantId = channelDeliveryContext?.assistantId ?? 'self';
568
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
465
569
 
466
570
  if (decision.action === 'reject') {
467
571
  log.info(
@@ -495,7 +599,61 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
495
599
  'Tool grant request resolver: approved (grant minting deferred to canonical primitive)',
496
600
  );
497
601
 
498
- if (channelDeliveryContext && requesterChatId) {
602
+ // Re-read the canonical request to check whether an inline grant waiter
603
+ // has already claimed this request. When followupState is
604
+ // 'inline_wait_active', the requester's original tool call is blocking
605
+ // on the grant and will resume automatically — sending a "please retry"
606
+ // notification would be stale and confusing (and could cause duplicate
607
+ // attempts or one-time-grant denials).
608
+ //
609
+ // Staleness guard: the inline_wait_active marker is persisted in DB and
610
+ // can outlive the actual waiter if the daemon crashes or restarts during
611
+ // the wait. To avoid permanently suppressing the retry notification, we
612
+ // treat the marker as stale if the encoded start timestamp is older than
613
+ // the maximum wait budget plus a 30s buffer.
614
+ const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000;
615
+ const freshRequest = getCanonicalGuardianRequest(request.id);
616
+ const followupState = freshRequest?.followupState ?? '';
617
+ let inlineWaitActive = followupState.startsWith('inline_wait_active');
618
+ if (inlineWaitActive && freshRequest) {
619
+ // The followupState encodes the wall-clock epoch when the inline wait
620
+ // started (e.g. 'inline_wait_active:1700000000000'). We use this
621
+ // instead of updatedAt because resolveCanonicalGuardianRequest sets
622
+ // updatedAt = now during CAS resolution, making updatedAt always fresh
623
+ // by the time this resolver runs.
624
+ const colonIdx = followupState.indexOf(':');
625
+ const waitStartMs = colonIdx !== -1 ? Number(followupState.slice(colonIdx + 1)) : NaN;
626
+ const markerAgeMs = Number.isFinite(waitStartMs)
627
+ ? Date.now() - waitStartMs
628
+ : Infinity; // Treat unparseable timestamps as stale for safety.
629
+ const stalenessThresholdMs = TC_GRANT_WAIT_MAX_MS + INLINE_WAIT_STALENESS_BUFFER_MS;
630
+ if (markerAgeMs > stalenessThresholdMs) {
631
+ log.warn(
632
+ {
633
+ event: 'resolver_tool_grant_request_stale_inline_wait',
634
+ requestId: request.id,
635
+ toolName: request.toolName,
636
+ markerAgeMs,
637
+ stalenessThresholdMs,
638
+ waitStartMs,
639
+ },
640
+ 'inline_wait_active marker is stale (daemon likely crashed during wait) — sending retry notification',
641
+ );
642
+ inlineWaitActive = false;
643
+ }
644
+ }
645
+
646
+ if (inlineWaitActive) {
647
+ log.info(
648
+ {
649
+ event: 'resolver_tool_grant_request_skip_retry_notification',
650
+ requestId: request.id,
651
+ toolName: request.toolName,
652
+ followupState: freshRequest?.followupState,
653
+ },
654
+ 'Skipping requester retry notification — inline grant wait is active and will resume the original invocation',
655
+ );
656
+ } else if (channelDeliveryContext && requesterChatId) {
499
657
  try {
500
658
  await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
501
659
  chatId: requesterChatId,
@@ -41,6 +41,35 @@ export function getUserConsultationTimeoutMs(): number {
41
41
  return getConfig().calls.userConsultTimeoutSeconds * 1000;
42
42
  }
43
43
 
44
+ export function getTtsPlaybackDelayMs(): number {
45
+ return getConfig().calls.ttsPlaybackDelayMs;
46
+ }
47
+
48
+ export function getAccessRequestPollIntervalMs(): number {
49
+ return getConfig().calls.accessRequestPollIntervalMs;
50
+ }
51
+
52
+ export function getGuardianWaitUpdateInitialIntervalMs(): number {
53
+ return getConfig().calls.guardianWaitUpdateInitialIntervalMs;
54
+ }
55
+
56
+ export function getGuardianWaitUpdateInitialWindowMs(): number {
57
+ return getConfig().calls.guardianWaitUpdateInitialWindowMs;
58
+ }
59
+
60
+ export function getGuardianWaitUpdateSteadyMinIntervalMs(): number {
61
+ return getConfig().calls.guardianWaitUpdateSteadyMinIntervalMs;
62
+ }
63
+
64
+ export function getGuardianWaitUpdateSteadyMaxIntervalMs(): number {
65
+ return getConfig().calls.guardianWaitUpdateSteadyMaxIntervalMs;
66
+ }
67
+
68
+ export function getSilenceTimeoutMs(): number {
69
+ return 30 * 1000; // 30 seconds
70
+ }
71
+
72
+ /** @deprecated Use getSilenceTimeoutMs() for mockability in tests. */
44
73
  export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds
45
74
 
46
75
  // Legacy named exports for backward compatibility (use functions above for config-backed values)
@@ -18,10 +18,11 @@ import {
18
18
  listCanonicalGuardianDeliveries,
19
19
  } from '../memory/canonical-guardian-store.js';
20
20
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
21
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
21
22
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
22
23
  import { getLogger } from '../util/logger.js';
23
24
  import { readHttpToken } from '../util/platform.js';
24
- import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
25
+ import { getMaxCallDurationMs, getSilenceTimeoutMs, getUserConsultationTimeoutMs } from './call-constants.js';
25
26
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
26
27
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
27
28
  import { fireCallCompletionNotifier, fireCallQuestionNotifier, fireCallTranscriptNotifier,registerCallController, unregisterCallController } from './call-state.js';
@@ -245,7 +246,7 @@ export class CallController {
245
246
  this.task = task;
246
247
  this.isInbound = !task;
247
248
  this.broadcast = opts?.broadcast;
248
- this.assistantId = opts?.assistantId ?? 'self';
249
+ this.assistantId = opts?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
249
250
  this.guardianContext = opts?.guardianContext ?? null;
250
251
 
251
252
  // Resolve the conversation ID from the call session
@@ -1048,8 +1049,15 @@ export class CallController {
1048
1049
  private resetSilenceTimer(): void {
1049
1050
  if (this.silenceTimer) clearTimeout(this.silenceTimer);
1050
1051
  this.silenceTimer = setTimeout(() => {
1052
+ // During guardian wait states, the relay heartbeat timer handles
1053
+ // periodic updates — suppress the generic "Are you still there?"
1054
+ // which is confusing when the caller is waiting on a decision.
1055
+ if (this.relay.getConnectionState() === 'awaiting_guardian_decision') {
1056
+ log.debug({ callSessionId: this.callSessionId }, 'Silence timeout suppressed during guardian wait');
1057
+ return;
1058
+ }
1051
1059
  log.info({ callSessionId: this.callSessionId }, 'Silence timeout triggered');
1052
1060
  this.relay.sendTextToken('Are you still there?', true);
1053
- }, SILENCE_TIMEOUT_MS);
1061
+ }, getSilenceTimeoutMs());
1054
1062
  }
1055
1063
  }