@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: "Trusted Contacts"
3
- description: "Manage trusted contacts and Telegram invite links — list, allow, revoke, block users, and create/list/revoke invite links for external channels"
3
+ description: "Manage trusted contacts and invite links — list, allow, revoke, block users, and create/list/revoke invite links for Telegram and voice (phone call) channels"
4
4
  user-invocable: true
5
5
  metadata: {"vellum": {"emoji": "\ud83d\udc65"}}
6
6
  ---
7
7
 
8
- You are helping your user manage trusted contacts and invite links for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. Invite links let the guardian share a Telegram deep link that automatically grants access when opened. All operations go through the gateway HTTP API using `curl` with bearer auth.
8
+ You are helping your user manage trusted contacts and invite links for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram, SMS, and voice (phone calls). Invite links let the guardian share a Telegram deep link that automatically grants access when opened. Voice invites let the guardian authorize a specific phone number to call in — the invitee must call from that phone number AND enter a one-time numeric code. All operations go through the gateway HTTP API using `curl` with bearer auth.
9
9
 
10
10
  ## Prerequisites
11
11
 
@@ -18,8 +18,9 @@ You are helping your user manage trusted contacts and invite links for the Vellu
18
18
  - **Member**: A user identity (external user ID or chat ID) from a specific channel that has been registered with a policy.
19
19
  - **Policy**: Controls what the member can do — `allow` (can message freely) or `deny` (blocked from messaging).
20
20
  - **Status**: The member's lifecycle state — `active` (currently effective), `revoked` (access removed), or `blocked` (explicitly denied).
21
- - **Source channel**: The messaging platform the contact uses (e.g., `telegram`, `sms`).
21
+ - **Source channel**: The messaging platform the contact uses (e.g., `telegram`, `sms`, `voice`).
22
22
  - **Invite link**: A shareable Telegram deep link that, when opened by someone, automatically grants them trusted-contact access. Each invite has a token, usage limits, and optional expiration.
23
+ - **Voice invite**: An invite bound to a specific phone number for phone-call access. The guardian provides the invitee's phone number (E.164 format, e.g., `+15551234567`), and the system generates a numeric code. The invitee must call from that exact phone number AND enter the code when prompted. Both conditions must be met — the call must originate from the bound number, and the correct code must be entered. Voice invites do not have a Telegram-style deep link and do not use `/start` payload tokens. SMS-based invites are not supported.
23
24
 
24
25
  ## Available Actions
25
26
 
@@ -123,16 +124,53 @@ curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members/<member_id>/block
123
124
 
124
125
  Use this when the guardian wants to invite someone to message the assistant on Telegram without needing their user ID upfront. The invite link is a shareable Telegram deep link — when someone opens it, they automatically get trusted-contact access.
125
126
 
127
+ **Important**: The shell snippet below emits a `<vellum-sensitive-output>` directive containing the raw invite token. The tool executor automatically strips this directive and replaces the raw token with a placeholder so the LLM never sees it. The placeholder is resolved back to the real token in the final assistant reply.
128
+
126
129
  ```bash
127
130
  TOKEN=$(cat ~/.vellum/http-token)
128
- curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
131
+
132
+ INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
129
133
  -H "Content-Type: application/json" \
130
134
  -H "Authorization: Bearer $TOKEN" \
131
135
  -d '{
132
136
  "sourceChannel": "telegram",
133
137
  "maxUses": 1,
134
138
  "note": "<optional note, e.g. the person it is for>"
135
- }'
139
+ }')
140
+
141
+ INVITE_TOKEN=$(printf '%s' "$INVITE_JSON" | python3 -c "
142
+ import json, sys
143
+ data = json.load(sys.stdin)
144
+ invite = data.get('invite', {})
145
+ print(invite.get('token', ''), end='')
146
+ ")
147
+ INVITE_URL=$(printf '%s' "$INVITE_JSON" | python3 -c "
148
+ import json, sys
149
+ data = json.load(sys.stdin)
150
+ invite = data.get('invite', {})
151
+ share = invite.get('share') or {}
152
+ print(share.get('url', ''), end='')
153
+ ")
154
+
155
+ if [ -z "$INVITE_TOKEN" ]; then
156
+ printf '%s\n' "$INVITE_JSON"
157
+ exit 1
158
+ fi
159
+
160
+ # Prefer backend-provided canonical link when available.
161
+ if [ -z "$INVITE_URL" ]; then
162
+ BOT_CONFIG_JSON=$(curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
163
+ -H "Authorization: Bearer $TOKEN")
164
+ BOT_USERNAME=$(printf '%s' "$BOT_CONFIG_JSON" | tr -d '\n' | sed -n 's/.*"botUsername"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
165
+ if [ -z "$BOT_USERNAME" ]; then
166
+ echo "error:no_share_url_or_bot_username"
167
+ exit 1
168
+ fi
169
+ INVITE_URL="https://t.me/$BOT_USERNAME?start=iv_$INVITE_TOKEN"
170
+ fi
171
+
172
+ echo "<vellum-sensitive-output kind=\"invite_code\" value=\"$INVITE_TOKEN\" />"
173
+ echo "$INVITE_URL"
136
174
  ```
137
175
 
138
176
  Optional fields:
@@ -140,21 +178,11 @@ Optional fields:
140
178
  - `expiresInMs` — expiration time in milliseconds from now (e.g., `86400000` for 24 hours). Defaults to 7 days (`604800000`) if omitted.
141
179
  - `note` — a human-readable label for the invite (e.g., "For Mom", "Family group").
142
180
 
143
- The response contains `{ ok: true, invite: { id, token, ... } }`. The `token` field is the raw invite token — it is only returned at creation time and cannot be retrieved later.
144
-
145
- **Building the shareable link**: After creating the invite, look up the Telegram bot username so you can build the deep link. Query the Telegram integration config:
146
-
147
- ```bash
148
- TOKEN=$(cat ~/.vellum/http-token)
149
- curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
150
- -H "Authorization: Bearer $TOKEN"
151
- ```
181
+ The create response contains `{ ok: true, invite: { id, token, share?, ... } }`.
182
+ - `token` is the raw invite token and is only returned at creation time.
183
+ - `share.url` is the canonical shareable deep link (when channel transport config is available).
152
184
 
153
- The response includes `botUsername`. Use it to construct the deep link:
154
-
155
- ```
156
- https://t.me/<botUsername>?start=iv_<token>
157
- ```
185
+ Always use `invite.share.url` when present. Do not manually construct `?start=` links if the API already provided one.
158
186
 
159
187
  **Presenting to the guardian**: Give the guardian the link with clear copy-paste instructions:
160
188
 
@@ -211,9 +239,99 @@ curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
211
239
 
212
240
  Replace `<invite_id>` with the invite's `id` from the list response.
213
241
 
242
+ ### 8. Create a voice invite
243
+
244
+ Use this when the guardian wants to authorize a specific phone number to call the assistant. Voice invites are identity-bound: the invitee must call from the specified phone number AND enter a one-time numeric code.
245
+
246
+ **Important**: The response includes a `voiceCode` field that is only returned at creation time and cannot be retrieved later. Extract and present it clearly.
247
+
248
+ ```bash
249
+ TOKEN=$(cat ~/.vellum/http-token)
250
+
251
+ INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
252
+ -H "Content-Type: application/json" \
253
+ -H "Authorization: Bearer $TOKEN" \
254
+ -d '{
255
+ "sourceChannel": "voice",
256
+ "expectedExternalUserId": "<phone_number_E164>",
257
+ "maxUses": 1,
258
+ "note": "<optional note, e.g. the person it is for>"
259
+ }')
260
+ printf '%s\n' "$INVITE_JSON"
261
+ ```
262
+
263
+ Required fields:
264
+ - `sourceChannel` — must be `"voice"`
265
+ - `expectedExternalUserId` — the invitee's phone number in E.164 format (e.g., `+15551234567`)
266
+
267
+ Optional fields:
268
+ - `maxUses` — how many times the code can be used (default: 1)
269
+ - `expiresInMs` — expiration time in milliseconds from now (e.g., `86400000` for 24 hours). Defaults to 7 days if omitted.
270
+ - ~~`voiceCodeDigits`~~ — always 6 digits; this parameter is accepted but ignored
271
+ - `note` — a human-readable label for the invite (e.g., "For Mom", "Dr. Smith")
272
+
273
+ The create response contains `{ ok: true, invite: { id, voiceCode, expectedExternalUserId, ... } }`.
274
+ - `voiceCode` is the numeric code the invitee must enter and is only returned at creation time.
275
+ - Voice invite responses do **not** include `token` or `share.url`. Do not try to build or send a deep link for voice invites.
276
+
277
+ **Presenting to the guardian**: Give the guardian clear instructions to relay to the invitee:
278
+
279
+ > Voice invite created for **<phone_number>**:
280
+ >
281
+ > **Invite code: `<voiceCode>`**
282
+ >
283
+ > Share these instructions with the person you are inviting:
284
+ > 1. Call the assistant's phone number from **<phone_number>** (the call must come from this exact number)
285
+ > 2. When prompted, enter the code **<voiceCode>**
286
+ > 3. Once verified, they will be added as a trusted contact and can call the assistant directly in the future
287
+ >
288
+ > This code can be used <maxUses> time(s)<and expires in X hours/days if applicable>.
289
+
290
+ There is no "open link" step for voice invites. The invite is redeemed only during a live phone call from the bound number.
291
+
292
+ If the user provides a phone number without the `+` country code prefix, ask them to confirm the full E.164 number (e.g., US numbers should be `+1XXXXXXXXXX`).
293
+
294
+ **Note**: SMS-based invites are not currently supported. Only voice (phone call) invites are available for phone-based access.
295
+
296
+ ### 9. List voice invites
297
+
298
+ Use this to show the guardian their active voice invites.
299
+
300
+ ```bash
301
+ TOKEN=$(cat ~/.vellum/http-token)
302
+ curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites?sourceChannel=voice" \
303
+ -H "Authorization: Bearer $TOKEN"
304
+ ```
305
+
306
+ Optional query parameters:
307
+ - `status` — filter by status (`active`, `revoked`, `redeemed`, `expired`)
308
+
309
+ The response format is the same as regular invites but voice invites also include:
310
+ - `expectedExternalUserId` — the bound phone number
311
+ - `voiceCodeDigits` — always 6 (the code itself is not retrievable after creation)
312
+ - `token` and `share` are not present for voice invites
313
+
314
+ **Presenting results**: Format as a readable list. Show the note (or "unnamed" as fallback), bound phone number, status, uses remaining, and expiration. Highlight which invites are still active.
315
+
316
+ ### 10. Revoke a voice invite
317
+
318
+ Use this when the guardian wants to cancel an active voice invite. **Always confirm before revoking.**
319
+
320
+ Ask the user: *"I'll revoke the voice invite for [phone number or note]. The code will no longer work. Should I proceed?"*
321
+
322
+ First, list voice invites to find the invite's `id`, then revoke:
323
+
324
+ ```bash
325
+ TOKEN=$(cat ~/.vellum/http-token)
326
+ curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
327
+ -H "Authorization: Bearer $TOKEN"
328
+ ```
329
+
330
+ Replace `<invite_id>` with the invite's `id` from the list response. The same revoke endpoint is used for both Telegram and voice invites.
331
+
214
332
  ## Confirmation Requirements
215
333
 
216
- **All mutating actions (allow, revoke, block, revoke invite) require explicit user confirmation before execution.** This is a safety measure — modifying who can access the assistant should always be a deliberate choice. Creating an invite link does not require confirmation since it does not grant access until someone opens it.
334
+ **All mutating actions (allow, revoke, block, revoke invite) require explicit user confirmation before execution.** This is a safety measure — modifying who can access the assistant should always be a deliberate choice. Creating an invite (Telegram link or voice invite) does not require confirmation since it does not grant access until the invitee redeems it.
217
335
 
218
336
  - Clearly state what action you are about to take and who it affects.
219
337
  - Wait for the user to confirm before running the curl command.
@@ -227,7 +345,9 @@ Replace `<invite_id>` with the invite's `id` from the list response.
227
345
  - `At least one of externalUserId or externalChatId is required` — ask the user for the contact's channel-specific identifier.
228
346
  - `Member not found or cannot be revoked` — the member ID may be invalid or the member is already revoked.
229
347
  - `Member not found or already blocked` — the member ID may be invalid or the member is already blocked.
230
- - `sourceChannel is required for create` — when creating an invite, always pass `"sourceChannel": "telegram"`.
348
+ - `sourceChannel is required for create` — when creating an invite, always pass `"sourceChannel": "telegram"` for Telegram or `"sourceChannel": "voice"` for voice invites.
349
+ - `expectedExternalUserId is required for voice invites` — voice invites must include the invitee's phone number.
350
+ - `expectedExternalUserId must be in E.164 format` — the phone number must start with `+` followed by country code and number (e.g., `+15551234567`).
231
351
  - `Invite not found or already revoked` — the invite ID may be invalid or the invite is already revoked.
232
352
 
233
353
  ## Typical Workflows
@@ -247,3 +367,11 @@ Replace `<invite_id>` with the invite's `id` from the list response.
247
367
  **"Show my invites"** / **"List active invite links"** — List invites filtered by `sourceChannel=telegram`, present active invites with uses remaining and expiration info.
248
368
 
249
369
  **"Revoke invite"** / **"Cancel invite link"** — List invites to identify the target, confirm, then revoke by ID.
370
+
371
+ **"Create a voice invite for +15551234567"** — Create a voice invite with `sourceChannel: "voice"` and the given phone number as `expectedExternalUserId`. Present the invite code and instructions: the person must call from that number and enter the code.
372
+
373
+ **"Let my mom call in"** / **"Invite someone by phone"** — Ask for the phone number in E.164 format, create a voice invite, and present the code + calling instructions.
374
+
375
+ **"Show my voice invites"** / **"List phone invites"** — List invites filtered by `sourceChannel=voice`, present active invites with bound phone number and expiration info.
376
+
377
+ **"Revoke voice invite"** / **"Cancel the phone invite for +15551234567"** — List voice invites, identify the target by phone number or note, confirm, then revoke by ID.
@@ -303,9 +303,9 @@ async function executeApprove(
303
303
  }
304
304
  }
305
305
  session.setAssistantId(assistantId);
306
- // The guardian approved this escalation, so tag as guardian to avoid
307
- // 'unverified_channel' blocking memory extraction.
308
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: sourceChannel ?? 'vellum' });
306
+ // The guardian approved this escalation, so tag as guardian trust to avoid
307
+ // unknown-provenance memory gating.
308
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'vellum' });
309
309
  session.setCommandIntent(null);
310
310
 
311
311
  // Process the message through the agent loop (no IPC event callback
@@ -379,7 +379,7 @@ async function executeDeny(
379
379
  // Store a system note about the denial in the conversation
380
380
  const denialInterface = isInterfaceId(sourceChannel) ? sourceChannel : undefined;
381
381
  await addMessage(conversationId, 'assistant', denialText, {
382
- provenanceActorRole: 'guardian' as const,
382
+ provenanceTrustClass: 'guardian' as const,
383
383
  userMessageChannel: sourceChannel,
384
384
  assistantMessageChannel: sourceChannel,
385
385
  ...(denialInterface ? { userMessageInterface: denialInterface, assistantMessageInterface: denialInterface } : {}),
@@ -1,9 +1,8 @@
1
- import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
2
- import { getPendingApprovalForRequest } from '../../memory/channel-guardian-store.js';
1
+ import {
2
+ applyCanonicalGuardianDecision,
3
+ } from '../../approvals/guardian-decision-primitive.js';
4
+ import { getCanonicalGuardianRequest } from '../../memory/canonical-guardian-store.js';
3
5
  import type { ApprovalAction } from '../../runtime/channel-approval-types.js';
4
- import { handleChannelDecision } from '../../runtime/channel-approvals.js';
5
- import * as pendingInteractions from '../../runtime/pending-interactions.js';
6
- import { handleAccessRequestDecision } from '../../runtime/routes/access-request-decision.js';
7
6
  import { listGuardianDecisionPrompts } from '../../runtime/routes/guardian-action-routes.js';
8
7
  import type { GuardianActionDecision, GuardianActionsPendingRequest } from '../ipc-protocol.js';
9
8
  import { defineHandlers, log } from './shared.js';
@@ -16,7 +15,8 @@ export const guardianActionsHandlers = defineHandlers({
16
15
  ctx.send(socket, { type: 'guardian_actions_pending_response', conversationId: msg.conversationId, prompts });
17
16
  },
18
17
 
19
- guardian_action_decision: (msg: GuardianActionDecision, socket, ctx) => {
18
+ guardian_action_decision: async (msg: GuardianActionDecision, socket, ctx) => {
19
+ try {
20
20
  // Validate the action is one of the known actions
21
21
  if (!VALID_ACTIONS.has(msg.action)) {
22
22
  log.warn({ requestId: msg.requestId, action: msg.action }, 'Invalid guardian action');
@@ -29,92 +29,71 @@ export const guardianActionsHandlers = defineHandlers({
29
29
  return;
30
30
  }
31
31
 
32
- // Try the channel guardian approval store first (tool approval prompts)
33
- const approval = getPendingApprovalForRequest(msg.requestId);
34
- if (approval) {
35
- // Enforce conversationId scoping when provided.
36
- if (msg.conversationId && msg.conversationId !== approval.conversationId) {
37
- log.warn({ requestId: msg.requestId, expected: approval.conversationId, got: msg.conversationId }, 'conversationId mismatch');
32
+ // Verify conversationId scoping before applying the canonical decision.
33
+ // A caller must not be able to cross-resolve requests from a different conversation.
34
+ if (msg.conversationId) {
35
+ const canonicalRequest = getCanonicalGuardianRequest(msg.requestId);
36
+ if (canonicalRequest && canonicalRequest.conversationId && canonicalRequest.conversationId !== msg.conversationId) {
37
+ log.warn({ requestId: msg.requestId, expected: canonicalRequest.conversationId, got: msg.conversationId }, 'conversationId mismatch');
38
38
  ctx.send(socket, {
39
39
  type: 'guardian_action_decision_response',
40
40
  applied: false,
41
- reason: 'conversation_mismatch',
41
+ reason: 'not_found',
42
42
  requestId: msg.requestId,
43
43
  });
44
44
  return;
45
45
  }
46
-
47
- // Access request approvals need a separate decision path — they don't have
48
- // pending interactions and use verification sessions instead.
49
- if (approval.toolName === 'ingress_access_request') {
50
- const mappedAction = msg.action === 'reject' ? 'deny' as const : 'approve' as const;
51
- // Use 'desktop' as the actor identity because this endpoint is
52
- // unauthenticated — we cannot verify the caller is the assigned
53
- // guardian, so we record a generic desktop origin instead of
54
- // falsely attributing the decision to guardianExternalUserId.
55
- const decisionResult = handleAccessRequestDecision(
56
- approval,
57
- mappedAction,
58
- 'desktop',
59
- );
60
- ctx.send(socket, {
61
- type: 'guardian_action_decision_response',
62
- applied: decisionResult.type !== 'stale',
63
- requestId: msg.requestId,
64
- reason: decisionResult.type === 'stale' ? 'stale' : undefined,
65
- });
66
- return;
67
- }
68
-
69
- const result = applyGuardianDecision({
70
- approval,
71
- decision: { action: msg.action as 'approve_once' | 'approve_always' | 'reject', source: 'plain_text', requestId: msg.requestId },
72
- actorExternalUserId: undefined,
73
- actorChannel: 'vellum',
74
- });
75
- ctx.send(socket, {
76
- type: 'guardian_action_decision_response',
77
- applied: result.applied,
78
- reason: result.reason,
79
- requestId: result.requestId ?? msg.requestId,
80
- });
81
- return;
82
46
  }
83
47
 
84
- // Fall back to the pending interactions tracker (direct confirmation requests).
85
- // Route through handleChannelDecision so approve_always properly persists trust rules.
86
- const interaction = pendingInteractions.get(msg.requestId);
87
- if (interaction) {
88
- // Enforce conversationId scoping when provided.
89
- if (msg.conversationId && msg.conversationId !== interaction.conversationId) {
90
- log.warn({ requestId: msg.requestId, expected: interaction.conversationId, got: msg.conversationId }, 'conversationId mismatch');
48
+ const canonicalResult = await applyCanonicalGuardianDecision({
49
+ requestId: msg.requestId,
50
+ action: msg.action as ApprovalAction,
51
+ actorContext: {
52
+ externalUserId: undefined,
53
+ channel: 'vellum',
54
+ isTrusted: true,
55
+ },
56
+ userText: undefined,
57
+ });
58
+
59
+ if (canonicalResult.applied) {
60
+ // When the CAS committed but the resolver failed, the side effect
61
+ // (e.g. minting a verification session) did not happen. From the
62
+ // caller's perspective the decision was not truly applied.
63
+ if (canonicalResult.resolverFailed) {
91
64
  ctx.send(socket, {
92
65
  type: 'guardian_action_decision_response',
93
66
  applied: false,
94
- reason: 'conversation_mismatch',
95
- requestId: msg.requestId,
67
+ reason: 'resolver_failed',
68
+ resolverFailureReason: canonicalResult.resolverFailureReason,
69
+ requestId: canonicalResult.requestId,
96
70
  });
97
71
  return;
98
72
  }
99
73
 
100
- const result = handleChannelDecision(
101
- interaction.conversationId,
102
- { action: msg.action as ApprovalAction, source: 'plain_text', requestId: msg.requestId },
103
- );
104
74
  ctx.send(socket, {
105
75
  type: 'guardian_action_decision_response',
106
- applied: result.applied,
107
- requestId: result.requestId ?? msg.requestId,
76
+ applied: true,
77
+ requestId: canonicalResult.requestId,
108
78
  });
109
79
  return;
110
80
  }
111
81
 
112
- log.warn({ requestId: msg.requestId }, 'No pending guardian action found for requestId');
82
+ // Return the reason for failure (stale, expired, not_found, etc.)
113
83
  ctx.send(socket, {
114
84
  type: 'guardian_action_decision_response',
115
85
  applied: false,
116
- reason: 'not_found',
86
+ reason: canonicalResult.reason,
117
87
  requestId: msg.requestId,
118
88
  });
89
+ } catch (err) {
90
+ log.error({ err, requestId: msg.requestId }, 'guardian_action_decision: unhandled error');
91
+ ctx.send(socket, {
92
+ type: 'guardian_action_decision_response',
93
+ applied: false,
94
+ reason: 'internal_error',
95
+ requestId: msg.requestId,
96
+ });
97
+ }
119
98
  },
120
99
  });
@@ -2,18 +2,26 @@ import * as net from 'node:net';
2
2
 
3
3
  import { v4 as uuid } from 'uuid';
4
4
 
5
+ import { createAssistantMessage, createUserMessage } from '../../agent/message-types.js';
5
6
  import { type InterfaceId,isChannelId, parseChannelId, parseInterfaceId } from '../../channels/types.js';
6
7
  import { getConfig } from '../../config/loader.js';
7
8
  import { getAttachmentsForMessage, getFilePathForAttachment, setAttachmentThumbnail } from '../../memory/attachments-store.js';
9
+ import {
10
+ listCanonicalGuardianRequests,
11
+ listPendingCanonicalGuardianRequestsByDestinationConversation,
12
+ } from '../../memory/canonical-guardian-store.js';
8
13
  import { getAttentionStateByConversationIds } from '../../memory/conversation-attention-store.js';
9
14
  import * as conversationStore from '../../memory/conversation-store.js';
10
15
  import { GENERATING_TITLE, queueGenerateConversationTitle, UNTITLED_FALLBACK } from '../../memory/conversation-title-service.js';
11
16
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
17
+ import { routeGuardianReply } from '../../runtime/guardian-reply-router.js';
18
+ import * as pendingInteractions from '../../runtime/pending-interactions.js';
12
19
  import { checkIngressForSecrets } from '../../security/secret-ingress.js';
13
20
  import { compileCustomPatterns, redactSecrets } from '../../security/secret-scanner.js';
14
21
  import { getSubagentManager } from '../../subagent/index.js';
15
22
  import { silentlyWithLog } from '../../util/silently.js';
16
23
  import { truncate } from '../../util/truncate.js';
24
+ import { createApprovalConversationGenerator } from '../approval-generators.js';
17
25
  import { getAssistantName } from '../identity-helpers.js';
18
26
  import type { UserMessageAttachment } from '../ipc-contract.js';
19
27
  import type {
@@ -56,6 +64,8 @@ import {
56
64
  wireEscalationHandler,
57
65
  } from './shared.js';
58
66
 
67
+ const desktopApprovalConversationGenerator = createApprovalConversationGenerator();
68
+
59
69
  export async function handleUserMessage(
60
70
  msg: UserMessage,
61
71
  socket: net.Socket,
@@ -172,9 +182,9 @@ export async function handleUserMessage(
172
182
  assistantMessageInterface: ipcInterface,
173
183
  });
174
184
  session.setAssistantId('self');
175
- // IPC/desktop user IS the guardian — default to guardian role so messages
176
- // are not tagged 'unverified_channel' (which blocks memory extraction).
177
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: ipcChannel });
185
+ // IPC/desktop user IS the guardian — default to guardian trust so
186
+ // messages are not tagged as unknown provenance.
187
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: ipcChannel });
178
188
  session.setCommandIntent(null);
179
189
  // Fire-and-forget: don't block the IPC handler so the connection can
180
190
  // continue receiving messages (e.g. cancel, confirmations, or
@@ -451,12 +461,146 @@ export async function handleUserMessage(
451
461
  }
452
462
  }
453
463
 
464
+ // If exactly one live turn is waiting on confirmation (no queued turns),
465
+ // try to consume this text as an inline approval decision first.
466
+ if (
467
+ session.hasAnyPendingConfirmation()
468
+ && session.getQueueDepth() === 0
469
+ && messageText.trim().length > 0
470
+ ) {
471
+ try {
472
+ const pendingInteractionRequestIdsForConversation = pendingInteractions
473
+ .getByConversation(msg.sessionId)
474
+ .filter(
475
+ (interaction) =>
476
+ interaction.kind === 'confirmation'
477
+ && interaction.session === session
478
+ && session.hasPendingConfirmation(interaction.requestId),
479
+ )
480
+ .map((interaction) => interaction.requestId);
481
+
482
+ const pendingCanonicalRequestIdsForConversation = [
483
+ ...listPendingCanonicalGuardianRequestsByDestinationConversation(msg.sessionId, ipcChannel)
484
+ .filter((request) => request.kind === 'tool_approval')
485
+ .map((request) => request.id),
486
+ ...listCanonicalGuardianRequests({
487
+ status: 'pending',
488
+ conversationId: msg.sessionId,
489
+ kind: 'tool_approval',
490
+ }).map((request) => request.id),
491
+ ].filter((pendingRequestId) => session.hasPendingConfirmation(pendingRequestId));
492
+
493
+ const pendingRequestIdsForConversation = Array.from(new Set([
494
+ ...pendingInteractionRequestIdsForConversation,
495
+ ...pendingCanonicalRequestIdsForConversation,
496
+ ]));
497
+
498
+ if (pendingRequestIdsForConversation.length > 0) {
499
+ const routerResult = await routeGuardianReply({
500
+ messageText: messageText.trim(),
501
+ channel: ipcChannel,
502
+ actor: {
503
+ externalUserId: undefined,
504
+ channel: ipcChannel,
505
+ isTrusted: true,
506
+ },
507
+ conversationId: msg.sessionId,
508
+ pendingRequestIds: pendingRequestIdsForConversation,
509
+ approvalConversationGenerator: desktopApprovalConversationGenerator,
510
+ });
511
+
512
+ if (routerResult.consumed && routerResult.type !== 'nl_keep_pending') {
513
+ const consumedChannelMeta = {
514
+ userMessageChannel: ipcChannel,
515
+ assistantMessageChannel: ipcChannel,
516
+ userMessageInterface: ipcInterface,
517
+ assistantMessageInterface: ipcInterface,
518
+ provenanceActorRole: 'guardian' as const,
519
+ };
520
+
521
+ const consumedUserMessage = createUserMessage(messageText, msg.attachments ?? []);
522
+ await conversationStore.addMessage(
523
+ msg.sessionId,
524
+ 'user',
525
+ JSON.stringify(consumedUserMessage.content),
526
+ consumedChannelMeta,
527
+ );
528
+
529
+ const replyText = (routerResult.replyText?.trim())
530
+ || (routerResult.decisionApplied ? 'Decision applied.' : 'Request already resolved.');
531
+ const consumedAssistantMessage = createAssistantMessage(replyText);
532
+ await conversationStore.addMessage(
533
+ msg.sessionId,
534
+ 'assistant',
535
+ JSON.stringify(consumedAssistantMessage.content),
536
+ consumedChannelMeta,
537
+ );
538
+ // Avoid mutating in-memory history while an agent loop is active;
539
+ // the loop owns history reconstruction for the in-flight turn.
540
+ if (!session.isProcessing()) {
541
+ // Keep in-memory history aligned with persisted transcript so
542
+ // session-history operations (undo/regenerate) target the same turn.
543
+ session.messages.push(consumedUserMessage, consumedAssistantMessage);
544
+ }
545
+
546
+ // Mirror the normal queued/dequeued lifecycle so desktop clients can
547
+ // reconcile queued bubble state for this just-sent user message.
548
+ ctx.send(socket, {
549
+ type: 'message_queued',
550
+ sessionId: msg.sessionId,
551
+ requestId,
552
+ position: 0,
553
+ });
554
+ ctx.send(socket, {
555
+ type: 'message_dequeued',
556
+ sessionId: msg.sessionId,
557
+ requestId,
558
+ });
559
+
560
+ // Only emit the reply delta when no agent turn is in-flight.
561
+ // When the agent is active, currentAssistantMessageId on the client
562
+ // points to the agent's streaming message and this delta would
563
+ // contaminate it. The reply is already persisted to the DB, so the
564
+ // client will see it on the next transcript reload / session switch.
565
+ if (!session.isProcessing()) {
566
+ ctx.send(socket, {
567
+ type: 'assistant_text_delta',
568
+ text: replyText,
569
+ sessionId: msg.sessionId,
570
+ });
571
+ }
572
+ ctx.send(socket, {
573
+ type: 'message_request_complete',
574
+ sessionId: msg.sessionId,
575
+ requestId,
576
+ runStillActive: session.isProcessing(),
577
+ });
578
+
579
+ rlog.info(
580
+ { routerType: routerResult.type, decisionApplied: routerResult.decisionApplied, routerRequestId: routerResult.requestId },
581
+ 'Consumed pending-confirmation reply before auto-deny',
582
+ );
583
+ return;
584
+ }
585
+ }
586
+ } catch (err) {
587
+ rlog.warn({ err }, 'Failed to process pending-confirmation reply; falling back to auto-deny behavior');
588
+ }
589
+ }
590
+
454
591
  // If the session has a pending tool confirmation, auto-deny it so the
455
- // agent can process the user's follow-up message instead. The agent
592
+ // agent can process the user's follow-up message instead. The agent
456
593
  // will see the denial and can re-request the tool if still needed.
457
594
  if (session.hasAnyPendingConfirmation()) {
458
595
  rlog.info('Auto-denying pending confirmation(s) due to new user message');
459
596
  session.denyAllPendingConfirmations();
597
+ // Keep the pending-interaction tracker aligned with the prompter so
598
+ // stale request IDs are not reused as routing candidates.
599
+ for (const interaction of pendingInteractions.getByConversation(msg.sessionId)) {
600
+ if (interaction.session === session && interaction.kind === 'confirmation') {
601
+ pendingInteractions.resolve(interaction.requestId);
602
+ }
603
+ }
460
604
  }
461
605
 
462
606
  dispatchUserMessage(
@@ -31,6 +31,12 @@ export interface GuardianActionsPendingResponse {
31
31
  expiresAt: number;
32
32
  conversationId: string;
33
33
  callSessionId: string | null;
34
+ /**
35
+ * Canonical request kind (e.g. 'tool_approval', 'pending_question').
36
+ * Present when the prompt originates from the canonical guardian request
37
+ * store. Absent for legacy-only prompts.
38
+ */
39
+ kind?: string;
34
40
  }>;
35
41
  }
36
42
 
@@ -38,6 +44,7 @@ export interface GuardianActionDecisionResponse {
38
44
  type: 'guardian_action_decision_response';
39
45
  applied: boolean;
40
46
  reason?: string;
47
+ resolverFailureReason?: string;
41
48
  requestId?: string;
42
49
  userText?: string;
43
50
  }
@@ -173,6 +173,21 @@ export interface MessageDequeued {
173
173
  requestId: string;
174
174
  }
175
175
 
176
+ /**
177
+ * Request-level terminal signal for a user message lifecycle.
178
+ *
179
+ * Unlike `message_complete`, this does not imply the active assistant turn
180
+ * has completed. It is used for paths that consume a request inline while a
181
+ * separate in-flight turn may still be running.
182
+ */
183
+ export interface MessageRequestComplete {
184
+ type: 'message_request_complete';
185
+ sessionId: string;
186
+ requestId: string;
187
+ /** True when an existing turn is still running after this request is finalized. */
188
+ runStillActive?: boolean;
189
+ }
190
+
176
191
  export interface MessageQueuedDeleted {
177
192
  type: 'message_queued_deleted';
178
193
  sessionId: string;
@@ -241,6 +256,7 @@ export type _MessagesServerMessages =
241
256
  | SecretDetected
242
257
  | MessageQueued
243
258
  | MessageDequeued
259
+ | MessageRequestComplete
244
260
  | MessageQueuedDeleted
245
261
  | SuggestionResponse
246
262
  | TraceEvent;