@vellumai/assistant 0.3.2 → 0.3.3

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 (52) hide show
  1. package/README.md +82 -13
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/channel-approval-routes.test.ts +930 -14
  7. package/src/__tests__/channel-approval.test.ts +2 -0
  8. package/src/__tests__/channel-delivery-store.test.ts +104 -1
  9. package/src/__tests__/channel-guardian.test.ts +184 -1
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  12. package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
  13. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  14. package/src/__tests__/handlers-twilio-config.test.ts +665 -5
  15. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  16. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  17. package/src/__tests__/run-orchestrator.test.ts +1 -1
  18. package/src/__tests__/session-process-bridge.test.ts +2 -0
  19. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  20. package/src/calls/twilio-config.ts +10 -1
  21. package/src/calls/twilio-rest.ts +70 -0
  22. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  23. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  24. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  25. package/src/config/schema.ts +3 -0
  26. package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
  27. package/src/daemon/handlers/config.ts +168 -15
  28. package/src/daemon/handlers/sessions.ts +5 -3
  29. package/src/daemon/handlers/skills.ts +61 -17
  30. package/src/daemon/ipc-contract-inventory.json +4 -0
  31. package/src/daemon/ipc-contract.ts +10 -0
  32. package/src/daemon/session-agent-loop.ts +4 -0
  33. package/src/daemon/session-process.ts +20 -3
  34. package/src/daemon/session-slash.ts +50 -2
  35. package/src/daemon/session-surfaces.ts +17 -1
  36. package/src/inbound/public-ingress-urls.ts +20 -3
  37. package/src/index.ts +1 -23
  38. package/src/memory/app-git-service.ts +24 -0
  39. package/src/memory/app-store.ts +0 -21
  40. package/src/memory/channel-delivery-store.ts +74 -3
  41. package/src/memory/channel-guardian-store.ts +54 -26
  42. package/src/memory/conversation-key-store.ts +20 -0
  43. package/src/memory/conversation-store.ts +14 -2
  44. package/src/memory/db.ts +12 -0
  45. package/src/memory/schema.ts +5 -0
  46. package/src/runtime/http-server.ts +13 -5
  47. package/src/runtime/routes/channel-routes.ts +134 -43
  48. package/src/skills/clawhub.ts +6 -2
  49. package/src/subagent/manager.ts +4 -1
  50. package/src/subagent/types.ts +2 -0
  51. package/src/tools/skills/vellum-catalog.ts +45 -2
  52. package/src/tools/subagent/spawn.ts +2 -0
@@ -211,4 +211,68 @@ describe('Ingress URL consistency between assistant and gateway', () => {
211
211
 
212
212
  expect(gatewayCanonical).toBe(callbackUrl);
213
213
  });
214
+
215
+ // ── SMS-specific URL consistency ──────────────────────────────────
216
+
217
+ test('SMS webhook URL consistency: gateway signature URL matches configured ingress', () => {
218
+ const publicBase = 'https://sms-gateway.example.com';
219
+ const authToken = 'test-sms-auth-token';
220
+
221
+ // The gateway registers /webhooks/twilio/sms with Twilio. Twilio signs
222
+ // inbound SMS requests against the full public URL.
223
+ const smsWebhookUrl = `${publicBase}/webhooks/twilio/sms`;
224
+
225
+ const params = {
226
+ Body: 'hello',
227
+ From: '+15551234567',
228
+ To: '+15559876543',
229
+ MessageSid: 'SM123',
230
+ };
231
+ const twilioSignature = computeTwilioSignature(smsWebhookUrl, params, authToken);
232
+
233
+ // Gateway receives the request on its local address and reconstructs
234
+ // the canonical URL using the configured ingress base.
235
+ const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/sms';
236
+ const canonicalUrl = reconstructGatewayCanonicalUrl(publicBase, localUrl);
237
+
238
+ expect(canonicalUrl).toBe(smsWebhookUrl);
239
+
240
+ const recomputed = computeTwilioSignature(canonicalUrl, params, authToken);
241
+ expect(recomputed).toBe(twilioSignature);
242
+ });
243
+
244
+ test('SMS webhook signature fails when ingress URL is not configured (fail-visible)', () => {
245
+ const publicBase = 'https://sms-gateway.example.com';
246
+ const authToken = 'test-sms-auth-token';
247
+
248
+ const smsWebhookUrl = `${publicBase}/webhooks/twilio/sms`;
249
+ const params = { Body: 'test', From: '+15550001111', MessageSid: 'SM456' };
250
+ const twilioSignature = computeTwilioSignature(smsWebhookUrl, params, authToken);
251
+
252
+ // Without ingress config, the gateway uses the local URL — signature mismatch.
253
+ const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/sms';
254
+ const canonicalWithout = reconstructGatewayCanonicalUrl(undefined, localUrl);
255
+ const recomputedWithout = computeTwilioSignature(canonicalWithout, params, authToken);
256
+ expect(recomputedWithout).not.toBe(twilioSignature);
257
+ });
258
+
259
+ test('all Twilio webhook paths share the /webhooks/twilio/ prefix consistently', () => {
260
+ const config: IngressConfig = {
261
+ ingress: { publicBaseUrl: 'https://consistent.example.com' },
262
+ };
263
+ const base = getPublicBaseUrl(config);
264
+
265
+ // Document the path contract: all Twilio webhooks live under /webhooks/twilio/
266
+ const voiceUrl = getTwilioVoiceWebhookUrl(config, 'sess');
267
+ const statusUrl = getTwilioStatusCallbackUrl(config);
268
+
269
+ // Verify they all share the same base and prefix
270
+ expect(voiceUrl).toStartWith(`${base}/webhooks/twilio/`);
271
+ expect(statusUrl).toStartWith(`${base}/webhooks/twilio/`);
272
+
273
+ // SMS is currently handled at the gateway level (/webhooks/twilio/sms)
274
+ // but the path pattern is the same
275
+ const smsUrl = `${base}/webhooks/twilio/sms`;
276
+ expect(smsUrl).toStartWith(`${base}/webhooks/twilio/`);
277
+ });
214
278
  });
@@ -378,6 +378,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
378
378
  type: 'telegram_config',
379
379
  action: 'get',
380
380
  },
381
+ twilio_config: {
382
+ type: 'twilio_config',
383
+ action: 'get',
384
+ },
381
385
  guardian_verification: {
382
386
  type: 'guardian_verification',
383
387
  action: 'create_challenge',
@@ -1218,6 +1222,12 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1218
1222
  connected: true,
1219
1223
  hasWebhookSecret: true,
1220
1224
  },
1225
+ twilio_config_response: {
1226
+ type: 'twilio_config_response',
1227
+ success: true,
1228
+ hasCredentials: true,
1229
+ phoneNumber: '+15551234567',
1230
+ },
1221
1231
  guardian_verification_response: {
1222
1232
  type: 'guardian_verification_response',
1223
1233
  success: true,
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
@@ -33,6 +33,7 @@ mock.module('../config/loader.js', () => ({
33
33
  provider: 'anthropic',
34
34
  memory: { enabled: false },
35
35
  calls: { enabled: false },
36
+ contextWindow: { maxInputTokens: 200000 },
36
37
  }),
37
38
  }));
38
39
 
@@ -75,6 +76,7 @@ function createMockSession(overrides?: Partial<ProcessSessionContext>): ProcessS
75
76
  traceEmitter: {
76
77
  emit: () => {},
77
78
  } as unknown as ProcessSessionContext['traceEmitter'],
79
+ usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
78
80
  persistUserMessage: mock((_content: string, _attachments: unknown[], _requestId?: string) => 'mock-msg-id'),
79
81
  runAgentLoop: mock(async () => {}),
80
82
  ...overrides,
@@ -314,9 +314,9 @@ describe('tool_permission_simulate handler', () => {
314
314
 
315
315
  const res = getResponse(sent);
316
316
  expect(res.success).toBe(true);
317
- // The sandbox-scoped rule should not match a host tool
317
+ // The sandbox-scoped allow rule should not match a host tool — falls
318
+ // through to the default ask rule instead.
318
319
  expect(res.decision).toBe('prompt');
319
- expect(res.matchedRuleId).toBeUndefined();
320
320
  expect(res.executionTarget).toBe('host');
321
321
  });
322
322
 
@@ -16,8 +16,17 @@ export interface TwilioConfig {
16
16
  export function getTwilioConfig(): TwilioConfig {
17
17
  const accountSid = getSecureKey('credential:twilio:account_sid');
18
18
  const authToken = getSecureKey('credential:twilio:auth_token');
19
- const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
20
19
  const config = loadConfig();
20
+
21
+ // Phone number resolution priority:
22
+ // 1. TWILIO_PHONE_NUMBER env var (explicit override)
23
+ // 2. config file sms.phoneNumber (primary storage)
24
+ // 3. credential:twilio:phone_number secure key (backward-compat fallback)
25
+ const phoneNumber =
26
+ process.env.TWILIO_PHONE_NUMBER ||
27
+ config.sms?.phoneNumber ||
28
+ getSecureKey('credential:twilio:phone_number') ||
29
+ '';
21
30
  const webhookBaseUrl = getPublicBaseUrl(config);
22
31
 
23
32
  // Always use the centralized relay URL derived from the public ingress base URL.
@@ -154,3 +154,73 @@ export async function provisionPhoneNumber(
154
154
  capabilities: { voice: data.capabilities.voice, sms: data.capabilities.sms },
155
155
  };
156
156
  }
157
+
158
+ export interface WebhookUrls {
159
+ voiceUrl: string;
160
+ statusCallbackUrl: string;
161
+ smsUrl: string;
162
+ }
163
+
164
+ /**
165
+ * Update the webhook URLs on a Twilio IncomingPhoneNumber.
166
+ *
167
+ * Configures voice webhook, voice status callback, and SMS webhook so
168
+ * that Twilio routes inbound calls and messages to the assistant's
169
+ * gateway endpoints.
170
+ */
171
+ export async function updatePhoneNumberWebhooks(
172
+ accountSid: string,
173
+ authToken: string,
174
+ phoneNumber: string,
175
+ webhooks: WebhookUrls,
176
+ ): Promise<void> {
177
+ // First, find the SID for this phone number
178
+ const listRes = await fetch(
179
+ `${twilioBaseUrl(accountSid)}/IncomingPhoneNumbers.json?PhoneNumber=${encodeURIComponent(phoneNumber)}`,
180
+ {
181
+ method: 'GET',
182
+ headers: { Authorization: twilioAuthHeader(accountSid, authToken) },
183
+ },
184
+ );
185
+
186
+ if (!listRes.ok) {
187
+ const text = await listRes.text();
188
+ throw new Error(`Twilio API error ${listRes.status} looking up phone number: ${text}`);
189
+ }
190
+
191
+ const listData = (await listRes.json()) as {
192
+ incoming_phone_numbers: Array<{ sid: string; phone_number: string }>;
193
+ };
194
+
195
+ const match = listData.incoming_phone_numbers.find((n) => n.phone_number === phoneNumber);
196
+ if (!match) {
197
+ throw new Error(`Phone number ${phoneNumber} not found on Twilio account ${accountSid}`);
198
+ }
199
+
200
+ // Update the phone number's webhook configuration
201
+ const body = new URLSearchParams({
202
+ VoiceUrl: webhooks.voiceUrl,
203
+ VoiceMethod: 'POST',
204
+ StatusCallback: webhooks.statusCallbackUrl,
205
+ StatusCallbackMethod: 'POST',
206
+ SmsUrl: webhooks.smsUrl,
207
+ SmsMethod: 'POST',
208
+ });
209
+
210
+ const updateRes = await fetch(
211
+ `${twilioBaseUrl(accountSid)}/IncomingPhoneNumbers/${match.sid}.json`,
212
+ {
213
+ method: 'POST',
214
+ headers: {
215
+ Authorization: twilioAuthHeader(accountSid, authToken),
216
+ 'Content-Type': 'application/x-www-form-urlencoded',
217
+ },
218
+ body: body.toString(),
219
+ },
220
+ );
221
+
222
+ if (!updateRes.ok) {
223
+ const text = await updateRes.text();
224
+ throw new Error(`Twilio API error ${updateRes.status} updating webhooks: ${text}`);
225
+ }
226
+ }
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: "Email Setup"
3
+ description: "Create the assistant's own email address via the Vellum hosted API (one-time setup)"
4
+ user-invocable: true
5
+ metadata: {"vellum": {"emoji": "📧"}}
6
+ ---
7
+
8
+ You are setting up your own personal email address. This is a one-time operation — once you have an email, you do not need to run this again.
9
+
10
+ ## Prerequisites
11
+
12
+ Only proceed if the user explicitly asks you to create or set up your email address. Do NOT proactively run this skill.
13
+
14
+ ## Step 1: Check if Email Already Exists
15
+
16
+ Before doing anything, check whether you already have an email address configured:
17
+
18
+ ```bash
19
+ vellum email status
20
+ ```
21
+
22
+ This will return your email address, the callback URL that inbound email will hit, and the path to the inbox locally. If you already have an email address, tell the user your existing email address and stop — do NOT create another one.
23
+
24
+ ## Step 2: Create Your Email
25
+
26
+ Call the Vellum hosted API to provision your email. Use `host_bash` to make the request:
27
+
28
+ ```bash
29
+ vellum email create <your-username>
30
+ ```
31
+
32
+ For `<your-username>`, use your assistant name (lowercased, alphanumeric only). Check your identity from `IDENTITY.md` or `USER.md` to determine your name. If you don't have a name yet, ask the user what username they'd like for your email.
33
+
34
+ ## Step 3: Confirm Setup
35
+
36
+ After the inbox is created successfully:
37
+
38
+ 1. Parse the command output to extract your new email address.
39
+ 2. Tell the user your new email address.
40
+ 3. Store a note in your memory or `USER.md` that your email has been provisioned so you remember it in future conversations.
41
+
42
+ ## Rules
43
+
44
+ - **One-time only.** If an inbox already exists (Step 1), do not create another. Inform the user of the existing address.
45
+ - **User-initiated only.** Never run this skill unless the user asks you to set up or create an email.
46
+ - **No custom domains.** Use the default provider domain. Do not attempt domain setup.
47
+ - **No API key prompting.** The email API key should already be configured. If the `vellum email` command fails with an API key error, tell the user the email integration is not yet configured and ask them to set it up.
48
+
49
+ ## Troubleshooting
50
+
51
+ ### API key not configured
52
+ If you get an error about a missing API key, the email provider has not been set up. Tell the user:
53
+ > "Email isn't configured yet. Please set up the email integration first."
54
+
55
+ ### Inbox creation failed
56
+ If inbox creation returns an error (e.g. username taken), try a variation of the name (append a number or use a nickname) and retry once. If it still fails, report the error to the user.
@@ -18,6 +18,10 @@ Subagents follow this status flow: `pending` -> `running` -> `completed` / `fail
18
18
 
19
19
  Only the parent session that spawned a subagent can interact with it (check status, send messages, abort, or read output).
20
20
 
21
+ ## Silent Mode
22
+
23
+ Set `send_result_to_user: false` when spawning a subagent whose result is for internal processing only. The parent will still be notified on completion, but the notification will instruct it to read the result without presenting it to the user.
24
+
21
25
  ## Tips
22
26
 
23
27
  - Do NOT poll `subagent_status` in a loop. You will be notified automatically when a subagent completes.
@@ -20,6 +20,10 @@
20
20
  "context": {
21
21
  "type": "string",
22
22
  "description": "Optional additional context to pass to the subagent"
23
+ },
24
+ "send_result_to_user": {
25
+ "type": "boolean",
26
+ "description": "Whether to present the subagent's result to the user when it completes. Defaults to true. Set to false for internal/silent processing."
23
27
  }
24
28
  },
25
29
  "required": ["label", "objective"]
@@ -1066,6 +1066,9 @@ export const SmsConfigSchema = z.object({
1066
1066
  phoneNumber: z
1067
1067
  .string({ error: 'sms.phoneNumber must be a string' })
1068
1068
  .default(''),
1069
+ assistantPhoneNumbers: z
1070
+ .record(z.string(), z.string({ error: 'sms.assistantPhoneNumbers values must be strings' }))
1071
+ .optional(),
1069
1072
  });
1070
1073
 
1071
1074
  const IngressBaseSchema = z.object({
@@ -82,7 +82,14 @@ If the user wants to buy a new number through Twilio, send:
82
82
  - `areaCode` is optional — ask the user if they have a preferred area code
83
83
  - `country` defaults to `"US"` — ask if they want a different country (ISO 3166-1 alpha-2)
84
84
 
85
- The daemon provisions the number via the Twilio API and automatically assigns it to the assistant. The response includes the new `phoneNumber`.
85
+ The daemon provisions the number via the Twilio API, automatically assigns it to the assistant (persisting to both secure storage and config), and configures Twilio webhooks (voice, status callback, SMS) if a public ingress URL is available. The response includes the new `phoneNumber`. No separate `assign_number` call is needed.
86
+
87
+ **Webhook auto-configuration:** When `ingress.publicBaseUrl` is configured, the daemon automatically sets the following webhooks on the Twilio phone number:
88
+ - Voice webhook: `{publicBaseUrl}/webhooks/twilio/voice`
89
+ - Voice status callback: `{publicBaseUrl}/webhooks/twilio/status`
90
+ - SMS webhook: `{publicBaseUrl}/webhooks/twilio/sms`
91
+
92
+ If ingress is not yet configured, webhook setup is skipped gracefully — the number is still assigned and usable once ingress is set up later.
86
93
 
87
94
  **Trial account note:** Twilio trial accounts come with one free phone number. Check "Active Numbers" in the Twilio Console first before provisioning.
88
95
 
@@ -109,7 +116,7 @@ Then assign the chosen number:
109
116
  }
110
117
  ```
111
118
 
112
- The phone number must be in E.164 format.
119
+ The phone number must be in E.164 format. Like `provision_number`, `assign_number` also auto-configures Twilio webhooks when a public ingress URL is available.
113
120
 
114
121
  ### Option C: Manual Entry
115
122
 
@@ -146,13 +153,13 @@ If not configured, load and run the public-ingress skill:
146
153
  skill_load skill=public-ingress
147
154
  ```
148
155
 
149
- **Twilio webhook endpoints (handled automatically by the gateway):**
156
+ **Twilio webhook endpoints (auto-configured on provision/assign):**
150
157
  - Voice webhook: `{publicBaseUrl}/webhooks/twilio/voice`
151
158
  - Voice status callback: `{publicBaseUrl}/webhooks/twilio/status`
152
159
  - ConversationRelay WebSocket: `{publicBaseUrl}/webhooks/twilio/relay` (wss://)
153
160
  - SMS webhook: `{publicBaseUrl}/webhooks/twilio/sms`
154
161
 
155
- No manual Twilio webhook configuration is needed webhook URLs are registered dynamically.
162
+ Webhook URLs are automatically configured on the Twilio phone number when `provision_number` or `assign_number` is called with a valid ingress URL. No manual Twilio Console webhook configuration is needed.
156
163
 
157
164
  ## Step 5: Verify Setup
158
165
 
@@ -5,7 +5,7 @@ import { addRule, removeRule, updateRule, getAllRules, acceptStarterBundle } fro
5
5
  import { classifyRisk, check, generateAllowlistOptions, generateScopeOptions } from '../../permissions/checker.js';
6
6
  import { isSideEffectTool } from '../../tools/executor.js';
7
7
  import { resolveExecutionTarget } from '../../tools/execution-target.js';
8
- import { getAllTools, getTool } from '../../tools/registry.js';
8
+ import { getAllTools } from '../../tools/registry.js';
9
9
  import { listSchedules, updateSchedule, deleteSchedule, describeCronExpression } from '../../schedule/schedule-store.js';
10
10
  import { listReminders, cancelReminder } from '../../tools/reminder/reminder-store.js';
11
11
  import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
@@ -37,7 +37,14 @@ import {
37
37
  listIncomingPhoneNumbers,
38
38
  searchAvailableNumbers,
39
39
  provisionPhoneNumber,
40
+ updatePhoneNumberWebhooks,
40
41
  } from '../../calls/twilio-rest.js';
42
+ import {
43
+ getTwilioVoiceWebhookUrl,
44
+ getTwilioStatusCallbackUrl,
45
+ getTwilioSmsWebhookUrl,
46
+ type IngressConfig,
47
+ } from '../../inbound/public-ingress-urls.js';
41
48
  import { createVerificationChallenge, getGuardianBinding, revokeBinding as revokeGuardianBinding } from '../../runtime/channel-guardian-service.js';
42
49
  import { log, CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext } from './shared.js';
43
50
  import { MODEL_TO_PROVIDER } from '../session-slash.js';
@@ -509,11 +516,46 @@ function triggerGatewayReconcile(ingressPublicBaseUrl: string | undefined): void
509
516
  });
510
517
  }
511
518
 
512
- export function handleIngressConfig(
519
+ /**
520
+ * Best-effort Twilio webhook sync helper.
521
+ *
522
+ * Computes the voice, status-callback, and SMS webhook URLs from the current
523
+ * ingress config and pushes them to the Twilio IncomingPhoneNumber API.
524
+ *
525
+ * Returns `{ success, warning }`. When the update fails, `success` is false
526
+ * and `warning` contains a human-readable message. Callers should treat
527
+ * failure as non-fatal so that the primary operation (provision, assign,
528
+ * ingress save) still succeeds.
529
+ */
530
+ async function syncTwilioWebhooks(
531
+ phoneNumber: string,
532
+ accountSid: string,
533
+ authToken: string,
534
+ ingressConfig: IngressConfig,
535
+ ): Promise<{ success: boolean; warning?: string }> {
536
+ try {
537
+ const voiceUrl = getTwilioVoiceWebhookUrl(ingressConfig);
538
+ const statusCallbackUrl = getTwilioStatusCallbackUrl(ingressConfig);
539
+ const smsUrl = getTwilioSmsWebhookUrl(ingressConfig);
540
+ await updatePhoneNumberWebhooks(accountSid, authToken, phoneNumber, {
541
+ voiceUrl,
542
+ statusCallbackUrl,
543
+ smsUrl,
544
+ });
545
+ log.info({ phoneNumber }, 'Twilio webhooks configured successfully');
546
+ return { success: true };
547
+ } catch (err) {
548
+ const message = err instanceof Error ? err.message : String(err);
549
+ log.warn({ err, phoneNumber }, `Webhook configuration skipped: ${message}`);
550
+ return { success: false, warning: `Webhook configuration skipped: ${message}` };
551
+ }
552
+ }
553
+
554
+ export async function handleIngressConfig(
513
555
  msg: IngressConfigRequest,
514
556
  socket: net.Socket,
515
557
  ctx: HandlerContext,
516
- ): void {
558
+ ): Promise<void> {
517
559
  const localGatewayTarget = computeGatewayTarget();
518
560
  try {
519
561
  if (msg.action === 'get') {
@@ -584,6 +626,24 @@ export function handleIngressConfig(
584
626
  // fallback branch above) rather than the raw `value` from the UI.
585
627
  const effectiveUrl = isEnabled ? process.env.INGRESS_PUBLIC_BASE_URL : undefined;
586
628
  triggerGatewayReconcile(effectiveUrl);
629
+
630
+ // Best-effort Twilio webhook reconciliation: when ingress is being
631
+ // enabled/updated and a Twilio number is assigned with valid credentials,
632
+ // push the new webhook URLs to Twilio so calls and SMS route correctly.
633
+ if (isEnabled && hasTwilioCredentials()) {
634
+ const currentConfig = loadRawConfig();
635
+ const smsConfig = (currentConfig?.sms ?? {}) as Record<string, unknown>;
636
+ const assignedNumber = (smsConfig.phoneNumber as string) ?? '';
637
+ if (assignedNumber) {
638
+ const acctSid = getSecureKey('credential:twilio:account_sid')!;
639
+ const acctToken = getSecureKey('credential:twilio:auth_token')!;
640
+ // Fire-and-forget: webhook sync failure must not block the ingress save
641
+ syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
642
+ .catch(() => {
643
+ // Already logged inside syncTwilioWebhooks
644
+ });
645
+ }
646
+ }
587
647
  } else {
588
648
  ctx.send(socket, { type: 'ingress_config_response', enabled: false, publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
589
649
  }
@@ -1109,7 +1169,15 @@ export async function handleTwilioConfig(
1109
1169
  const hasCredentials = hasTwilioCredentials();
1110
1170
  const raw = loadRawConfig();
1111
1171
  const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1112
- const phoneNumber = (sms.phoneNumber as string) ?? '';
1172
+ // When assistantId is provided, look up in assistantPhoneNumbers first,
1173
+ // fall back to the legacy phoneNumber field
1174
+ let phoneNumber: string;
1175
+ if (msg.assistantId) {
1176
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1177
+ phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1178
+ } else {
1179
+ phoneNumber = (sms.phoneNumber as string) ?? '';
1180
+ }
1113
1181
  ctx.send(socket, {
1114
1182
  type: 'twilio_config_response',
1115
1183
  success: true,
@@ -1192,9 +1260,12 @@ export async function handleTwilioConfig(
1192
1260
  hasCredentials: true,
1193
1261
  });
1194
1262
  } else if (msg.action === 'clear_credentials') {
1263
+ // Only clear authentication credentials (Account SID and Auth Token).
1264
+ // Preserve the phone number in both config (sms.phoneNumber) and secure
1265
+ // key (credential:twilio:phone_number) so that re-entering credentials
1266
+ // resumes working without needing to reassign the number.
1195
1267
  deleteSecureKey('credential:twilio:account_sid');
1196
1268
  deleteSecureKey('credential:twilio:auth_token');
1197
- deleteSecureKey('credential:twilio:phone_number');
1198
1269
  deleteCredentialMetadata('twilio', 'account_sid');
1199
1270
  deleteCredentialMetadata('twilio', 'auth_token');
1200
1271
 
@@ -1233,11 +1304,64 @@ export async function handleTwilioConfig(
1233
1304
  // Purchase the first available number
1234
1305
  const purchased = await provisionPhoneNumber(accountSid, authToken, available[0].phoneNumber);
1235
1306
 
1307
+ // Auto-assign: persist the purchased number in secure storage and config
1308
+ // (same persistence as assign_number for consistency)
1309
+ const phoneStored = setSecureKey('credential:twilio:phone_number', purchased.phoneNumber);
1310
+ if (!phoneStored) {
1311
+ ctx.send(socket, {
1312
+ type: 'twilio_config_response',
1313
+ success: false,
1314
+ hasCredentials: hasTwilioCredentials(),
1315
+ phoneNumber: purchased.phoneNumber,
1316
+ error: `Phone number ${purchased.phoneNumber} was purchased but could not be saved. Use assign_number to assign it manually.`,
1317
+ });
1318
+ return;
1319
+ }
1320
+
1321
+ const raw = loadRawConfig();
1322
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1323
+ // When assistantId is provided, only set the legacy global phoneNumber
1324
+ // if it's not already set — this prevents multi-assistant assignments
1325
+ // from clobbering each other's outbound SMS number.
1326
+ if (msg.assistantId) {
1327
+ if (!sms.phoneNumber) {
1328
+ sms.phoneNumber = purchased.phoneNumber;
1329
+ }
1330
+ } else {
1331
+ sms.phoneNumber = purchased.phoneNumber;
1332
+ }
1333
+ // When assistantId is provided, also persist into the per-assistant mapping
1334
+ if (msg.assistantId) {
1335
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1336
+ mapping[msg.assistantId] = purchased.phoneNumber;
1337
+ sms.assistantPhoneNumbers = mapping;
1338
+ }
1339
+
1340
+ const wasSuppressed = ctx.suppressConfigReload;
1341
+ ctx.setSuppressConfigReload(true);
1342
+ try {
1343
+ saveRawConfig({ ...raw, sms });
1344
+ } catch (err) {
1345
+ ctx.setSuppressConfigReload(wasSuppressed);
1346
+ throw err;
1347
+ }
1348
+ ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1349
+
1350
+ // Best-effort webhook configuration — non-fatal so the number is
1351
+ // still usable even if ingress isn't configured yet.
1352
+ const webhookResult = await syncTwilioWebhooks(
1353
+ purchased.phoneNumber,
1354
+ accountSid,
1355
+ authToken,
1356
+ loadRawConfig() as IngressConfig,
1357
+ );
1358
+
1236
1359
  ctx.send(socket, {
1237
1360
  type: 'twilio_config_response',
1238
1361
  success: true,
1239
1362
  hasCredentials: true,
1240
1363
  phoneNumber: purchased.phoneNumber,
1364
+ warning: webhookResult.warning,
1241
1365
  });
1242
1366
  } else if (msg.action === 'assign_number') {
1243
1367
  if (!msg.phoneNumber) {
@@ -1266,7 +1390,22 @@ export async function handleTwilioConfig(
1266
1390
  // Also persist in assistant config (non-secret) for the UI
1267
1391
  const raw = loadRawConfig();
1268
1392
  const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1269
- sms.phoneNumber = msg.phoneNumber;
1393
+ // When assistantId is provided, only set the legacy global phoneNumber
1394
+ // if it's not already set — this prevents multi-assistant assignments
1395
+ // from clobbering each other's outbound SMS number.
1396
+ if (msg.assistantId) {
1397
+ if (!sms.phoneNumber) {
1398
+ sms.phoneNumber = msg.phoneNumber;
1399
+ }
1400
+ } else {
1401
+ sms.phoneNumber = msg.phoneNumber;
1402
+ }
1403
+ // When assistantId is provided, also persist into the per-assistant mapping
1404
+ if (msg.assistantId) {
1405
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1406
+ mapping[msg.assistantId] = msg.phoneNumber;
1407
+ sms.assistantPhoneNumbers = mapping;
1408
+ }
1270
1409
 
1271
1410
  const wasSuppressed = ctx.suppressConfigReload;
1272
1411
  ctx.setSuppressConfigReload(true);
@@ -1278,11 +1417,26 @@ export async function handleTwilioConfig(
1278
1417
  }
1279
1418
  ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1280
1419
 
1420
+ // Best-effort webhook configuration when credentials are available
1421
+ let webhookWarning: string | undefined;
1422
+ if (hasTwilioCredentials()) {
1423
+ const acctSid = getSecureKey('credential:twilio:account_sid')!;
1424
+ const acctToken = getSecureKey('credential:twilio:auth_token')!;
1425
+ const webhookResult = await syncTwilioWebhooks(
1426
+ msg.phoneNumber,
1427
+ acctSid,
1428
+ acctToken,
1429
+ loadRawConfig() as IngressConfig,
1430
+ );
1431
+ webhookWarning = webhookResult.warning;
1432
+ }
1433
+
1281
1434
  ctx.send(socket, {
1282
1435
  type: 'twilio_config_response',
1283
1436
  success: true,
1284
1437
  hasCredentials: hasTwilioCredentials(),
1285
1438
  phoneNumber: msg.phoneNumber,
1439
+ warning: webhookWarning,
1286
1440
  });
1287
1441
  } else if (msg.action === 'list_numbers') {
1288
1442
  if (!hasTwilioCredentials()) {
@@ -1331,9 +1485,9 @@ export function handleGuardianVerification(
1331
1485
  ctx: HandlerContext,
1332
1486
  ): void {
1333
1487
  try {
1334
- // In single-assistant mode, 'self' is the canonical assistant ID used
1335
- // by channel routes when validating challenges on the inbound path.
1336
- const assistantId = 'self';
1488
+ // Use the assistant ID from the request when available; fall back to
1489
+ // 'self' for backward compatibility with single-assistant mode.
1490
+ const assistantId = msg.assistantId ?? 'self';
1337
1491
  const channel = msg.channel ?? 'telegram';
1338
1492
 
1339
1493
  if (msg.action === 'create_challenge') {
@@ -1354,7 +1508,7 @@ export function handleGuardianVerification(
1354
1508
  guardianExternalUserId: binding?.guardianExternalUserId,
1355
1509
  });
1356
1510
  } else if (msg.action === 'revoke') {
1357
- const revoked = revokeGuardianBinding(assistantId, channel);
1511
+ revokeGuardianBinding(assistantId, channel);
1358
1512
  ctx.send(socket, {
1359
1513
  type: 'guardian_verification_response',
1360
1514
  success: true,
@@ -1411,11 +1565,10 @@ export async function handleToolPermissionSimulate(
1411
1565
 
1412
1566
  const workingDir = msg.workingDir ?? process.cwd();
1413
1567
 
1414
- // Only infer execution target when the tool is actually registered;
1415
- // for unresolved tools, leave it undefined so trust rules are unscoped.
1416
- const isRegistered = getTool(msg.toolName) !== undefined;
1417
- const executionTarget = isRegistered ? resolveExecutionTarget(msg.toolName) : undefined;
1418
- const policyContext = executionTarget ? { executionTarget } : undefined;
1568
+ // Resolve execution target using manifest metadata or prefix heuristics.
1569
+ // resolveExecutionTarget handles unregistered tools via prefix fallback.
1570
+ const executionTarget = resolveExecutionTarget(msg.toolName);
1571
+ const policyContext = { executionTarget };
1419
1572
 
1420
1573
  const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir);
1421
1574
  const result = await check(msg.toolName, msg.input, workingDir, policyContext);
@@ -198,8 +198,9 @@ export function handleSecretResponse(
198
198
  log.warn({ requestId: msg.requestId }, 'No session found with pending secret prompt for requestId');
199
199
  }
200
200
 
201
- export function handleSessionList(socket: net.Socket, ctx: HandlerContext): void {
202
- const conversations = conversationStore.listConversations(50);
201
+ export function handleSessionList(socket: net.Socket, ctx: HandlerContext, offset = 0, limit = 50): void {
202
+ const conversations = conversationStore.listConversations(limit, false, offset);
203
+ const totalCount = conversationStore.countConversations();
203
204
  const bindings = externalConversationStore.getBindingsForConversations(
204
205
  conversations.map((c) => c.id),
205
206
  );
@@ -223,6 +224,7 @@ export function handleSessionList(socket: net.Socket, ctx: HandlerContext): void
223
224
  } : {}),
224
225
  };
225
226
  }),
227
+ hasMore: offset + conversations.length < totalCount,
226
228
  });
227
229
  }
228
230
 
@@ -541,7 +543,7 @@ export const sessionHandlers = defineHandlers({
541
543
  user_message: handleUserMessage,
542
544
  confirmation_response: handleConfirmationResponse,
543
545
  secret_response: handleSecretResponse,
544
- session_list: (_msg, socket, ctx) => handleSessionList(socket, ctx),
546
+ session_list: (msg, socket, ctx) => handleSessionList(socket, ctx, msg.offset ?? 0, msg.limit ?? 50),
545
547
  session_create: handleSessionCreate,
546
548
  sessions_clear: (_msg, socket, ctx) => handleSessionsClear(socket, ctx),
547
549
  session_switch: handleSessionSwitch,