@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -4,14 +4,18 @@ import { initializeProviders } from '../../providers/registry.js';
4
4
  import { addRule, removeRule, updateRule, getAllRules, acceptStarterBundle } from '../../permissions/trust-store.js';
5
5
  import { classifyRisk, check, generateAllowlistOptions, generateScopeOptions } from '../../permissions/checker.js';
6
6
  import { isSideEffectTool } from '../../tools/executor.js';
7
- import { resolveExecutionTarget } from '../../tools/execution-target.js';
8
- import { getAllTools } from '../../tools/registry.js';
7
+ import { resolveExecutionTarget, type ManifestOverride } from '../../tools/execution-target.js';
8
+ import { getAllTools, getTool } from '../../tools/registry.js';
9
+ import { loadSkillCatalog } from '../../config/skills.js';
10
+ import { parseToolManifestFile } from '../../skills/tool-manifest.js';
11
+ import { join } from 'node:path';
9
12
  import { listSchedules, updateSchedule, deleteSchedule, describeCronExpression } from '../../schedule/schedule-store.js';
10
13
  import { listReminders, cancelReminder } from '../../tools/reminder/reminder-store.js';
11
14
  import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
12
15
  import { upsertCredentialMetadata, deleteCredentialMetadata, getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
13
16
  import { postToSlackWebhook } from '../../slack/slack-webhook.js';
14
17
  import { getApp } from '../../memory/app-store.js';
18
+ import * as externalConversationStore from '../../memory/external-conversation-store.js';
15
19
  import { readHttpToken } from '../../util/platform.js';
16
20
  import type {
17
21
  ModelSetRequest,
@@ -29,6 +33,7 @@ import type {
29
33
  TwitterIntegrationConfigRequest,
30
34
  TelegramConfigRequest,
31
35
  TwilioConfigRequest,
36
+ ChannelReadinessRequest,
32
37
  GuardianVerificationRequest,
33
38
  ToolPermissionSimulateRequest,
34
39
  } from '../ipc-protocol.js';
@@ -38,6 +43,14 @@ import {
38
43
  searchAvailableNumbers,
39
44
  provisionPhoneNumber,
40
45
  updatePhoneNumberWebhooks,
46
+ getTollFreeVerificationStatus,
47
+ submitTollFreeVerification,
48
+ updateTollFreeVerification,
49
+ deleteTollFreeVerification,
50
+ getPhoneNumberSid,
51
+ releasePhoneNumber,
52
+ fetchMessageStatus,
53
+ type TollFreeVerificationSubmitParams,
41
54
  } from '../../calls/twilio-rest.js';
42
55
  import {
43
56
  getTwilioVoiceWebhookUrl,
@@ -46,6 +59,7 @@ import {
46
59
  type IngressConfig,
47
60
  } from '../../inbound/public-ingress-urls.js';
48
61
  import { createVerificationChallenge, getGuardianBinding, revokeBinding as revokeGuardianBinding } from '../../runtime/channel-guardian-service.js';
62
+ import { createReadinessService, type ChannelReadinessService } from '../../runtime/channel-readiness-service.js';
49
63
  import { log, CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext } from './shared.js';
50
64
  import { MODEL_TO_PROVIDER } from '../session-slash.js';
51
65
 
@@ -406,7 +420,7 @@ export async function handleShareToSlack(
406
420
  ctx.send(socket, {
407
421
  type: 'share_to_slack_response',
408
422
  success: false,
409
- error: 'No Slack webhook URL configured. Set one in Settings.',
423
+ error: 'No Slack webhook URL configured. Provide one here in the chat, or set it from the Settings page.',
410
424
  });
411
425
  return;
412
426
  }
@@ -628,20 +642,36 @@ export async function handleIngressConfig(
628
642
  triggerGatewayReconcile(effectiveUrl);
629
643
 
630
644
  // Best-effort Twilio webhook reconciliation: when ingress is being
631
- // enabled/updated and a Twilio number is assigned with valid credentials,
645
+ // enabled/updated and Twilio numbers are assigned with valid credentials,
632
646
  // push the new webhook URLs to Twilio so calls and SMS route correctly.
633
647
  if (isEnabled && hasTwilioCredentials()) {
634
648
  const currentConfig = loadRawConfig();
635
649
  const smsConfig = (currentConfig?.sms ?? {}) as Record<string, unknown>;
636
- const assignedNumber = (smsConfig.phoneNumber as string) ?? '';
637
- if (assignedNumber) {
650
+ const assignedNumbers = new Set<string>();
651
+ const legacyNumber = (smsConfig.phoneNumber as string) ?? '';
652
+ if (legacyNumber) assignedNumbers.add(legacyNumber);
653
+
654
+ const assistantPhoneNumbers = smsConfig.assistantPhoneNumbers;
655
+ if (assistantPhoneNumbers && typeof assistantPhoneNumbers === 'object' && !Array.isArray(assistantPhoneNumbers)) {
656
+ for (const number of Object.values(assistantPhoneNumbers as Record<string, unknown>)) {
657
+ if (typeof number === 'string' && number) {
658
+ assignedNumbers.add(number);
659
+ }
660
+ }
661
+ }
662
+
663
+ if (assignedNumbers.size > 0) {
638
664
  const acctSid = getSecureKey('credential:twilio:account_sid')!;
639
665
  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
- });
666
+ // Fire-and-forget: webhook sync failure must not block the ingress save.
667
+ // Reconcile every assigned number so assistant-scoped mappings do not
668
+ // retain stale Twilio webhook URLs after ingress URL changes.
669
+ for (const assignedNumber of assignedNumbers) {
670
+ syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
671
+ .catch(() => {
672
+ // Already logged inside syncTwilioWebhooks
673
+ });
674
+ }
645
675
  }
646
676
  }
647
677
  } else {
@@ -1159,6 +1189,32 @@ export async function handleTelegramConfig(
1159
1189
  }
1160
1190
  }
1161
1191
 
1192
+ /** In-memory store for the last SMS send test result. Shared between sms_send_test and sms_doctor. */
1193
+ let _lastTestResult: {
1194
+ messageSid: string;
1195
+ to: string;
1196
+ initialStatus: string;
1197
+ finalStatus: string;
1198
+ errorCode?: string;
1199
+ errorMessage?: string;
1200
+ timestamp: number;
1201
+ } | undefined;
1202
+
1203
+ /** Map a Twilio error code to a human-readable remediation suggestion. */
1204
+ function mapTwilioErrorRemediation(errorCode: string | undefined): string | undefined {
1205
+ if (!errorCode) return undefined;
1206
+ const map: Record<string, string> = {
1207
+ '30003': 'Unreachable destination. The handset may be off or out of service.',
1208
+ '30004': 'Message blocked by carrier or recipient.',
1209
+ '30005': 'Unknown destination phone number. Verify the number is valid.',
1210
+ '30006': 'Landline or unreachable carrier. SMS cannot be delivered to this number.',
1211
+ '30007': 'Message flagged as spam by carrier. Adjust content or register for A2P.',
1212
+ '30008': 'Unknown error from the carrier network.',
1213
+ '21610': 'Recipient has opted out (STOP). Cannot send until they opt back in.',
1214
+ };
1215
+ return map[errorCode];
1216
+ }
1217
+
1162
1218
  export async function handleTwilioConfig(
1163
1219
  msg: TwilioConfigRequest,
1164
1220
  socket: net.Socket,
@@ -1459,6 +1515,608 @@ export async function handleTwilioConfig(
1459
1515
  hasCredentials: true,
1460
1516
  numbers,
1461
1517
  });
1518
+ } else if (msg.action === 'sms_compliance_status') {
1519
+ if (!hasTwilioCredentials()) {
1520
+ ctx.send(socket, {
1521
+ type: 'twilio_config_response',
1522
+ success: false,
1523
+ hasCredentials: false,
1524
+ error: 'Twilio credentials not configured. Set credentials first.',
1525
+ });
1526
+ return;
1527
+ }
1528
+
1529
+ const raw = loadRawConfig();
1530
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1531
+ let phoneNumber: string;
1532
+ if (msg.assistantId) {
1533
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1534
+ phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1535
+ } else {
1536
+ phoneNumber = (sms.phoneNumber as string) ?? '';
1537
+ }
1538
+
1539
+ if (!phoneNumber) {
1540
+ ctx.send(socket, {
1541
+ type: 'twilio_config_response',
1542
+ success: false,
1543
+ hasCredentials: true,
1544
+ error: 'No phone number assigned. Assign a number first.',
1545
+ });
1546
+ return;
1547
+ }
1548
+
1549
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
1550
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
1551
+
1552
+ // Determine number type from prefix
1553
+ const tollFreePrefixes = ['+1800', '+1833', '+1844', '+1855', '+1866', '+1877', '+1888'];
1554
+ const isTollFree = tollFreePrefixes.some((prefix) => phoneNumber.startsWith(prefix));
1555
+ const numberType = isTollFree ? 'toll_free' : 'local_10dlc';
1556
+
1557
+ if (!isTollFree) {
1558
+ // Non-toll-free numbers don't need toll-free verification
1559
+ ctx.send(socket, {
1560
+ type: 'twilio_config_response',
1561
+ success: true,
1562
+ hasCredentials: true,
1563
+ phoneNumber,
1564
+ compliance: { numberType },
1565
+ });
1566
+ return;
1567
+ }
1568
+
1569
+ // Look up the phone number SID and check verification status
1570
+ const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
1571
+ if (!phoneSid) {
1572
+ ctx.send(socket, {
1573
+ type: 'twilio_config_response',
1574
+ success: false,
1575
+ hasCredentials: true,
1576
+ phoneNumber,
1577
+ error: `Phone number ${phoneNumber} not found on Twilio account`,
1578
+ });
1579
+ return;
1580
+ }
1581
+
1582
+ const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
1583
+
1584
+ ctx.send(socket, {
1585
+ type: 'twilio_config_response',
1586
+ success: true,
1587
+ hasCredentials: true,
1588
+ phoneNumber,
1589
+ compliance: {
1590
+ numberType,
1591
+ verificationSid: verification?.sid,
1592
+ verificationStatus: verification?.status,
1593
+ rejectionReason: verification?.rejectionReason,
1594
+ rejectionReasons: verification?.rejectionReasons,
1595
+ errorCode: verification?.errorCode,
1596
+ editAllowed: verification?.editAllowed,
1597
+ editExpiration: verification?.editExpiration,
1598
+ },
1599
+ });
1600
+ } else if (msg.action === 'sms_submit_tollfree_verification') {
1601
+ if (!hasTwilioCredentials()) {
1602
+ ctx.send(socket, {
1603
+ type: 'twilio_config_response',
1604
+ success: false,
1605
+ hasCredentials: false,
1606
+ error: 'Twilio credentials not configured. Set credentials first.',
1607
+ });
1608
+ return;
1609
+ }
1610
+
1611
+ const vp = msg.verificationParams;
1612
+ if (!vp) {
1613
+ ctx.send(socket, {
1614
+ type: 'twilio_config_response',
1615
+ success: false,
1616
+ hasCredentials: true,
1617
+ error: 'verificationParams is required for sms_submit_tollfree_verification action',
1618
+ });
1619
+ return;
1620
+ }
1621
+
1622
+ // Validate required fields
1623
+ const requiredFields: Array<[string, unknown]> = [
1624
+ ['tollfreePhoneNumberSid', vp.tollfreePhoneNumberSid],
1625
+ ['businessName', vp.businessName],
1626
+ ['businessWebsite', vp.businessWebsite],
1627
+ ['notificationEmail', vp.notificationEmail],
1628
+ ['useCaseCategories', vp.useCaseCategories],
1629
+ ['useCaseSummary', vp.useCaseSummary],
1630
+ ['productionMessageSample', vp.productionMessageSample],
1631
+ ['optInImageUrls', vp.optInImageUrls],
1632
+ ['optInType', vp.optInType],
1633
+ ['messageVolume', vp.messageVolume],
1634
+ ];
1635
+
1636
+ const missing = requiredFields
1637
+ .filter(([, v]) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0))
1638
+ .map(([name]) => name);
1639
+
1640
+ if (missing.length > 0) {
1641
+ ctx.send(socket, {
1642
+ type: 'twilio_config_response',
1643
+ success: false,
1644
+ hasCredentials: true,
1645
+ error: `Missing required verification fields: ${missing.join(', ')}`,
1646
+ });
1647
+ return;
1648
+ }
1649
+
1650
+ // Validate enum values
1651
+ const validUseCaseCategories = [
1652
+ 'TWO_FACTOR_AUTHENTICATION', 'ACCOUNT_NOTIFICATION', 'CUSTOMER_CARE',
1653
+ 'DELIVERY_NOTIFICATION', 'FRAUD_ALERT', 'HIGHER_EDUCATION', 'MARKETING',
1654
+ 'POLLING_AND_VOTING', 'PUBLIC_SERVICE_ANNOUNCEMENT', 'SECURITY_ALERT',
1655
+ ];
1656
+ const invalidCategories = (vp.useCaseCategories ?? []).filter((c) => !validUseCaseCategories.includes(c));
1657
+ if (invalidCategories.length > 0) {
1658
+ ctx.send(socket, {
1659
+ type: 'twilio_config_response',
1660
+ success: false,
1661
+ hasCredentials: true,
1662
+ error: `Invalid useCaseCategories: ${invalidCategories.join(', ')}. Valid values: ${validUseCaseCategories.join(', ')}`,
1663
+ });
1664
+ return;
1665
+ }
1666
+
1667
+ const validOptInTypes = ['VERBAL', 'WEB_FORM', 'PAPER_FORM', 'VIA_TEXT', 'MOBILE_QR_CODE'];
1668
+ if (!validOptInTypes.includes(vp.optInType!)) {
1669
+ ctx.send(socket, {
1670
+ type: 'twilio_config_response',
1671
+ success: false,
1672
+ hasCredentials: true,
1673
+ error: `Invalid optInType: ${vp.optInType}. Valid values: ${validOptInTypes.join(', ')}`,
1674
+ });
1675
+ return;
1676
+ }
1677
+
1678
+ const validMessageVolumes = [
1679
+ '10', '100', '1,000', '10,000', '100,000', '250,000',
1680
+ '500,000', '750,000', '1,000,000', '5,000,000', '10,000,000+',
1681
+ ];
1682
+ if (!validMessageVolumes.includes(vp.messageVolume!)) {
1683
+ ctx.send(socket, {
1684
+ type: 'twilio_config_response',
1685
+ success: false,
1686
+ hasCredentials: true,
1687
+ error: `Invalid messageVolume: ${vp.messageVolume}. Valid values: ${validMessageVolumes.join(', ')}`,
1688
+ });
1689
+ return;
1690
+ }
1691
+
1692
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
1693
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
1694
+
1695
+ const submitParams: TollFreeVerificationSubmitParams = {
1696
+ tollfreePhoneNumberSid: vp.tollfreePhoneNumberSid!,
1697
+ businessName: vp.businessName!,
1698
+ businessWebsite: vp.businessWebsite!,
1699
+ notificationEmail: vp.notificationEmail!,
1700
+ useCaseCategories: vp.useCaseCategories!,
1701
+ useCaseSummary: vp.useCaseSummary!,
1702
+ productionMessageSample: vp.productionMessageSample!,
1703
+ optInImageUrls: vp.optInImageUrls!,
1704
+ optInType: vp.optInType!,
1705
+ messageVolume: vp.messageVolume!,
1706
+ businessType: vp.businessType ?? 'SOLE_PROPRIETOR',
1707
+ customerProfileSid: vp.customerProfileSid,
1708
+ };
1709
+
1710
+ const verification = await submitTollFreeVerification(accountSid, authToken, submitParams);
1711
+
1712
+ ctx.send(socket, {
1713
+ type: 'twilio_config_response',
1714
+ success: true,
1715
+ hasCredentials: true,
1716
+ compliance: {
1717
+ numberType: 'toll_free',
1718
+ verificationSid: verification.sid,
1719
+ verificationStatus: verification.status,
1720
+ },
1721
+ });
1722
+ } else if (msg.action === 'sms_update_tollfree_verification') {
1723
+ if (!hasTwilioCredentials()) {
1724
+ ctx.send(socket, {
1725
+ type: 'twilio_config_response',
1726
+ success: false,
1727
+ hasCredentials: false,
1728
+ error: 'Twilio credentials not configured. Set credentials first.',
1729
+ });
1730
+ return;
1731
+ }
1732
+
1733
+ if (!msg.verificationSid) {
1734
+ ctx.send(socket, {
1735
+ type: 'twilio_config_response',
1736
+ success: false,
1737
+ hasCredentials: true,
1738
+ error: 'verificationSid is required for sms_update_tollfree_verification action',
1739
+ });
1740
+ return;
1741
+ }
1742
+
1743
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
1744
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
1745
+
1746
+ const verification = await updateTollFreeVerification(
1747
+ accountSid,
1748
+ authToken,
1749
+ msg.verificationSid,
1750
+ msg.verificationParams ?? {},
1751
+ );
1752
+
1753
+ ctx.send(socket, {
1754
+ type: 'twilio_config_response',
1755
+ success: true,
1756
+ hasCredentials: true,
1757
+ compliance: {
1758
+ numberType: 'toll_free',
1759
+ verificationSid: verification.sid,
1760
+ verificationStatus: verification.status,
1761
+ editAllowed: verification.editAllowed,
1762
+ editExpiration: verification.editExpiration,
1763
+ },
1764
+ });
1765
+ } else if (msg.action === 'sms_delete_tollfree_verification') {
1766
+ if (!hasTwilioCredentials()) {
1767
+ ctx.send(socket, {
1768
+ type: 'twilio_config_response',
1769
+ success: false,
1770
+ hasCredentials: false,
1771
+ error: 'Twilio credentials not configured. Set credentials first.',
1772
+ });
1773
+ return;
1774
+ }
1775
+
1776
+ if (!msg.verificationSid) {
1777
+ ctx.send(socket, {
1778
+ type: 'twilio_config_response',
1779
+ success: false,
1780
+ hasCredentials: true,
1781
+ error: 'verificationSid is required for sms_delete_tollfree_verification action',
1782
+ });
1783
+ return;
1784
+ }
1785
+
1786
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
1787
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
1788
+
1789
+ await deleteTollFreeVerification(accountSid, authToken, msg.verificationSid);
1790
+
1791
+ ctx.send(socket, {
1792
+ type: 'twilio_config_response',
1793
+ success: true,
1794
+ hasCredentials: true,
1795
+ warning: 'Toll-free verification deleted. Re-submitting may reset your position in the review queue.',
1796
+ });
1797
+ } else if (msg.action === 'release_number') {
1798
+ if (!hasTwilioCredentials()) {
1799
+ ctx.send(socket, {
1800
+ type: 'twilio_config_response',
1801
+ success: false,
1802
+ hasCredentials: false,
1803
+ error: 'Twilio credentials not configured. Set credentials first.',
1804
+ });
1805
+ return;
1806
+ }
1807
+
1808
+ const raw = loadRawConfig();
1809
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1810
+ let phoneNumber: string;
1811
+ if (msg.phoneNumber) {
1812
+ phoneNumber = msg.phoneNumber;
1813
+ } else if (msg.assistantId) {
1814
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1815
+ phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1816
+ } else {
1817
+ phoneNumber = (sms.phoneNumber as string) ?? '';
1818
+ }
1819
+
1820
+ if (!phoneNumber) {
1821
+ ctx.send(socket, {
1822
+ type: 'twilio_config_response',
1823
+ success: false,
1824
+ hasCredentials: true,
1825
+ error: 'No phone number to release. Specify phoneNumber or ensure one is assigned.',
1826
+ });
1827
+ return;
1828
+ }
1829
+
1830
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
1831
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
1832
+
1833
+ await releasePhoneNumber(accountSid, authToken, phoneNumber);
1834
+
1835
+ // Clear the number from config and secure key store
1836
+ if (sms.phoneNumber === phoneNumber) {
1837
+ delete sms.phoneNumber;
1838
+ }
1839
+ const assistantPhoneNumbers = sms.assistantPhoneNumbers as Record<string, string> | undefined;
1840
+ if (assistantPhoneNumbers) {
1841
+ for (const [id, num] of Object.entries(assistantPhoneNumbers)) {
1842
+ if (num === phoneNumber) {
1843
+ delete assistantPhoneNumbers[id];
1844
+ }
1845
+ }
1846
+ if (Object.keys(assistantPhoneNumbers).length === 0) {
1847
+ delete sms.assistantPhoneNumbers;
1848
+ }
1849
+ }
1850
+
1851
+ const wasSuppressed = ctx.suppressConfigReload;
1852
+ ctx.setSuppressConfigReload(true);
1853
+ try {
1854
+ saveRawConfig({ ...raw, sms });
1855
+ } catch (err) {
1856
+ ctx.setSuppressConfigReload(wasSuppressed);
1857
+ throw err;
1858
+ }
1859
+ ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1860
+
1861
+ // Clear the phone number from secure key store if it matches
1862
+ const storedPhone = getSecureKey('credential:twilio:phone_number');
1863
+ if (storedPhone === phoneNumber) {
1864
+ deleteSecureKey('credential:twilio:phone_number');
1865
+ }
1866
+
1867
+ ctx.send(socket, {
1868
+ type: 'twilio_config_response',
1869
+ success: true,
1870
+ hasCredentials: true,
1871
+ warning: 'Phone number released from Twilio. Any associated toll-free verification context is lost.',
1872
+ });
1873
+ } else if (msg.action === 'sms_send_test') {
1874
+ // ── SMS send test ────────────────────────────────────────────────
1875
+ if (!hasTwilioCredentials()) {
1876
+ ctx.send(socket, {
1877
+ type: 'twilio_config_response',
1878
+ success: false,
1879
+ hasCredentials: false,
1880
+ error: 'Twilio credentials not configured. Set credentials first.',
1881
+ });
1882
+ return;
1883
+ }
1884
+
1885
+ const to = msg.phoneNumber;
1886
+ if (!to) {
1887
+ ctx.send(socket, {
1888
+ type: 'twilio_config_response',
1889
+ success: false,
1890
+ hasCredentials: true,
1891
+ error: 'phoneNumber is required for sms_send_test action.',
1892
+ });
1893
+ return;
1894
+ }
1895
+
1896
+ const raw = loadRawConfig();
1897
+ const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
1898
+ const from = (smsSection.phoneNumber as string | undefined)
1899
+ || getSecureKey('credential:twilio:phone_number')
1900
+ || '';
1901
+ if (!from) {
1902
+ ctx.send(socket, {
1903
+ type: 'twilio_config_response',
1904
+ success: false,
1905
+ hasCredentials: true,
1906
+ error: 'No phone number assigned. Run the twilio-setup skill to assign a number.',
1907
+ });
1908
+ return;
1909
+ }
1910
+
1911
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
1912
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
1913
+ const text = msg.text || 'Test SMS from your Vellum assistant';
1914
+
1915
+ // Send via gateway's /deliver/sms endpoint
1916
+ const bearerToken = readHttpToken();
1917
+ const gatewayPort = Number(process.env.GATEWAY_PORT) || 7830;
1918
+ const gatewayUrl = process.env.GATEWAY_INTERNAL_BASE_URL?.replace(/\/+$/, '') || `http://127.0.0.1:${gatewayPort}`;
1919
+
1920
+ const sendResp = await fetch(`${gatewayUrl}/deliver/sms`, {
1921
+ method: 'POST',
1922
+ headers: {
1923
+ 'Content-Type': 'application/json',
1924
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
1925
+ },
1926
+ body: JSON.stringify({ to, text }),
1927
+ signal: AbortSignal.timeout(30_000),
1928
+ });
1929
+
1930
+ if (!sendResp.ok) {
1931
+ const errBody = await sendResp.text().catch(() => '<unreadable>');
1932
+ ctx.send(socket, {
1933
+ type: 'twilio_config_response',
1934
+ success: false,
1935
+ hasCredentials: true,
1936
+ error: `SMS send failed (${sendResp.status}): ${errBody}`,
1937
+ });
1938
+ return;
1939
+ }
1940
+
1941
+ const sendData = await sendResp.json().catch(() => ({})) as {
1942
+ messageSid?: string;
1943
+ status?: string;
1944
+ };
1945
+ const messageSid = sendData.messageSid || '';
1946
+ const initialStatus = sendData.status || 'unknown';
1947
+
1948
+ // Poll Twilio for final status (up to 3 times, 2s apart)
1949
+ let finalStatus = initialStatus;
1950
+ let errorCode: string | undefined;
1951
+ let errorMessage: string | undefined;
1952
+
1953
+ if (messageSid) {
1954
+ for (let i = 0; i < 3; i++) {
1955
+ await new Promise((r) => setTimeout(r, 2000));
1956
+ try {
1957
+ const pollResult = await fetchMessageStatus(accountSid, authToken, messageSid);
1958
+ finalStatus = pollResult.status;
1959
+ errorCode = pollResult.errorCode;
1960
+ errorMessage = pollResult.errorMessage;
1961
+ // Stop polling if we've reached a terminal status
1962
+ if (['delivered', 'undelivered', 'failed'].includes(finalStatus)) break;
1963
+ } catch {
1964
+ // Polling failure is non-fatal; we'll use the last known status
1965
+ break;
1966
+ }
1967
+ }
1968
+ }
1969
+
1970
+ const testResult = {
1971
+ messageSid,
1972
+ to,
1973
+ initialStatus,
1974
+ finalStatus,
1975
+ ...(errorCode ? { errorCode } : {}),
1976
+ ...(errorMessage ? { errorMessage } : {}),
1977
+ };
1978
+
1979
+ // Store for sms_doctor
1980
+ _lastTestResult = { ...testResult, timestamp: Date.now() };
1981
+
1982
+ ctx.send(socket, {
1983
+ type: 'twilio_config_response',
1984
+ success: true,
1985
+ hasCredentials: true,
1986
+ testResult,
1987
+ });
1988
+
1989
+ } else if (msg.action === 'sms_doctor') {
1990
+ // ── SMS doctor diagnostic ────────────────────────────────────────
1991
+ const hasCredentials = hasTwilioCredentials();
1992
+
1993
+ // 1. Channel readiness check
1994
+ let readinessReady = false;
1995
+ const readinessIssues: string[] = [];
1996
+ try {
1997
+ const readinessService = getReadinessService();
1998
+ const snapshot = await readinessService.getReadiness('sms', { includeRemote: false });
1999
+ readinessReady = snapshot.ready;
2000
+ for (const r of snapshot.reasons) {
2001
+ readinessIssues.push(r.text);
2002
+ }
2003
+ } catch (err) {
2004
+ readinessIssues.push(`Readiness check failed: ${err instanceof Error ? err.message : String(err)}`);
2005
+ }
2006
+
2007
+ // 2. Compliance status
2008
+ let complianceStatus = 'unknown';
2009
+ let complianceDetail: string | undefined;
2010
+ let complianceRemediation: string | undefined;
2011
+ if (hasCredentials) {
2012
+ try {
2013
+ const raw = loadRawConfig();
2014
+ const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
2015
+ const phoneNumber = (smsSection.phoneNumber as string | undefined) || getSecureKey('credential:twilio:phone_number') || '';
2016
+ if (phoneNumber) {
2017
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
2018
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
2019
+ // Determine number type and verification status
2020
+ const isTollFree = phoneNumber.startsWith('+1') && ['800','888','877','866','855','844','833'].some(
2021
+ (p) => phoneNumber.startsWith(`+1${p}`),
2022
+ );
2023
+ if (isTollFree) {
2024
+ try {
2025
+ const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneNumber);
2026
+ if (verification) {
2027
+ const status = verification.status;
2028
+ complianceStatus = status;
2029
+ complianceDetail = `Toll-free verification: ${status}`;
2030
+ if (status === 'TWILIO_APPROVED') {
2031
+ complianceRemediation = undefined;
2032
+ } else if (status === 'PENDING_REVIEW' || status === 'IN_REVIEW') {
2033
+ complianceRemediation = 'Toll-free verification is pending. Messaging may have limited throughput until approved.';
2034
+ } else if (status === 'TWILIO_REJECTED') {
2035
+ complianceRemediation = 'Toll-free verification was rejected. Check rejection reasons and resubmit.';
2036
+ } else {
2037
+ complianceRemediation = 'Submit a toll-free verification to enable full messaging throughput.';
2038
+ }
2039
+ } else {
2040
+ complianceStatus = 'unverified';
2041
+ complianceDetail = 'Toll-free number without verification';
2042
+ complianceRemediation = 'Submit a toll-free verification request to avoid filtering.';
2043
+ }
2044
+ } catch {
2045
+ complianceStatus = 'check_failed';
2046
+ complianceDetail = 'Could not retrieve toll-free verification status';
2047
+ }
2048
+ } else {
2049
+ complianceStatus = 'local_10dlc';
2050
+ complianceDetail = 'Local/10DLC number — carrier registration handled externally';
2051
+ }
2052
+ } else {
2053
+ complianceStatus = 'no_number';
2054
+ complianceDetail = 'No phone number assigned';
2055
+ complianceRemediation = 'Assign a phone number via the twilio-setup skill.';
2056
+ }
2057
+ } catch {
2058
+ complianceStatus = 'check_failed';
2059
+ complianceDetail = 'Could not determine compliance status';
2060
+ }
2061
+ } else {
2062
+ complianceStatus = 'no_credentials';
2063
+ complianceDetail = 'Twilio credentials are not configured';
2064
+ complianceRemediation = 'Set Twilio credentials via the twilio-setup skill.';
2065
+ }
2066
+
2067
+ // 3. Last send test result
2068
+ let lastSend: { status: string; errorCode?: string; remediation?: string } | undefined;
2069
+ if (_lastTestResult) {
2070
+ lastSend = {
2071
+ status: _lastTestResult.finalStatus,
2072
+ ...((_lastTestResult.errorCode) ? { errorCode: _lastTestResult.errorCode } : {}),
2073
+ ...((_lastTestResult.errorCode) ? { remediation: mapTwilioErrorRemediation(_lastTestResult.errorCode) } : {}),
2074
+ };
2075
+ }
2076
+
2077
+ // 4. Determine overall status
2078
+ const actionItems: string[] = [];
2079
+ let overallStatus: 'healthy' | 'degraded' | 'broken' = 'healthy';
2080
+
2081
+ if (!hasCredentials) {
2082
+ overallStatus = 'broken';
2083
+ actionItems.push('Configure Twilio credentials.');
2084
+ }
2085
+ if (!readinessReady) {
2086
+ overallStatus = 'broken';
2087
+ for (const issue of readinessIssues) actionItems.push(issue);
2088
+ }
2089
+ if (complianceStatus === 'unverified' || complianceStatus === 'PENDING_REVIEW' || complianceStatus === 'IN_REVIEW') {
2090
+ if (overallStatus === 'healthy') overallStatus = 'degraded';
2091
+ if (complianceRemediation) actionItems.push(complianceRemediation);
2092
+ }
2093
+ if (complianceStatus === 'TWILIO_REJECTED' || complianceStatus === 'no_number') {
2094
+ overallStatus = 'broken';
2095
+ if (complianceRemediation) actionItems.push(complianceRemediation);
2096
+ }
2097
+ if (_lastTestResult && ['failed', 'undelivered'].includes(_lastTestResult.finalStatus)) {
2098
+ if (overallStatus === 'healthy') overallStatus = 'degraded';
2099
+ const remediation = mapTwilioErrorRemediation(_lastTestResult.errorCode);
2100
+ actionItems.push(remediation || `Last test SMS ${_lastTestResult.finalStatus}. Check Twilio logs for details.`);
2101
+ }
2102
+
2103
+ ctx.send(socket, {
2104
+ type: 'twilio_config_response',
2105
+ success: true,
2106
+ hasCredentials,
2107
+ diagnostics: {
2108
+ readiness: { ready: readinessReady, issues: readinessIssues },
2109
+ compliance: {
2110
+ status: complianceStatus,
2111
+ ...(complianceDetail ? { detail: complianceDetail } : {}),
2112
+ ...(complianceRemediation ? { remediation: complianceRemediation } : {}),
2113
+ },
2114
+ ...(lastSend ? { lastSend } : {}),
2115
+ overallStatus,
2116
+ actionItems,
2117
+ },
2118
+ });
2119
+
1462
2120
  } else {
1463
2121
  ctx.send(socket, {
1464
2122
  type: 'twilio_config_response',
@@ -1484,12 +2142,12 @@ export function handleGuardianVerification(
1484
2142
  socket: net.Socket,
1485
2143
  ctx: HandlerContext,
1486
2144
  ): void {
1487
- try {
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';
1491
- const channel = msg.channel ?? 'telegram';
2145
+ // Use the assistant ID from the request when available; fall back to
2146
+ // 'self' for backward compatibility with single-assistant mode.
2147
+ const assistantId = msg.assistantId ?? 'self';
2148
+ const channel = msg.channel ?? 'telegram';
1492
2149
 
2150
+ try {
1493
2151
  if (msg.action === 'create_challenge') {
1494
2152
  const result = createVerificationChallenge(assistantId, channel, msg.sessionId);
1495
2153
 
@@ -1498,14 +2156,47 @@ export function handleGuardianVerification(
1498
2156
  success: true,
1499
2157
  secret: result.secret,
1500
2158
  instruction: result.instruction,
2159
+ channel,
1501
2160
  });
1502
2161
  } else if (msg.action === 'status') {
1503
2162
  const binding = getGuardianBinding(assistantId, channel);
2163
+ let guardianUsername: string | undefined;
2164
+ let guardianDisplayName: string | undefined;
2165
+ if (binding?.metadataJson) {
2166
+ try {
2167
+ const parsed = JSON.parse(binding.metadataJson) as Record<string, unknown>;
2168
+ if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
2169
+ guardianUsername = parsed.username.trim();
2170
+ }
2171
+ if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
2172
+ guardianDisplayName = parsed.displayName.trim();
2173
+ }
2174
+ } catch {
2175
+ // ignore malformed metadata
2176
+ }
2177
+ }
2178
+ if (binding?.guardianDeliveryChatId && (!guardianUsername || !guardianDisplayName)) {
2179
+ const ext = externalConversationStore.getBindingByChannelChat(
2180
+ channel,
2181
+ binding.guardianDeliveryChatId,
2182
+ );
2183
+ if (!guardianUsername && ext?.username) {
2184
+ guardianUsername = ext.username;
2185
+ }
2186
+ if (!guardianDisplayName && ext?.displayName) {
2187
+ guardianDisplayName = ext.displayName;
2188
+ }
2189
+ }
1504
2190
  ctx.send(socket, {
1505
2191
  type: 'guardian_verification_response',
1506
2192
  success: true,
1507
2193
  bound: binding !== null,
1508
2194
  guardianExternalUserId: binding?.guardianExternalUserId,
2195
+ guardianUsername,
2196
+ guardianDisplayName,
2197
+ channel,
2198
+ assistantId,
2199
+ guardianDeliveryChatId: binding?.guardianDeliveryChatId,
1509
2200
  });
1510
2201
  } else if (msg.action === 'revoke') {
1511
2202
  revokeGuardianBinding(assistantId, channel);
@@ -1513,12 +2204,14 @@ export function handleGuardianVerification(
1513
2204
  type: 'guardian_verification_response',
1514
2205
  success: true,
1515
2206
  bound: false,
2207
+ channel,
1516
2208
  });
1517
2209
  } else {
1518
2210
  ctx.send(socket, {
1519
2211
  type: 'guardian_verification_response',
1520
2212
  success: false,
1521
2213
  error: `Unknown action: ${String(msg.action)}`,
2214
+ channel,
1522
2215
  });
1523
2216
  }
1524
2217
  } catch (err) {
@@ -1528,6 +2221,58 @@ export function handleGuardianVerification(
1528
2221
  type: 'guardian_verification_response',
1529
2222
  success: false,
1530
2223
  error: message,
2224
+ channel,
2225
+ });
2226
+ }
2227
+ }
2228
+
2229
+ // Lazy singleton — created on first use so module-load stays lightweight.
2230
+ let _readinessService: ChannelReadinessService | undefined;
2231
+ function getReadinessService(): ChannelReadinessService {
2232
+ if (!_readinessService) {
2233
+ _readinessService = createReadinessService();
2234
+ }
2235
+ return _readinessService;
2236
+ }
2237
+
2238
+ export async function handleChannelReadiness(
2239
+ msg: ChannelReadinessRequest,
2240
+ socket: net.Socket,
2241
+ ctx: HandlerContext,
2242
+ ): Promise<void> {
2243
+ try {
2244
+ const service = getReadinessService();
2245
+
2246
+ if (msg.action === 'refresh') {
2247
+ if (msg.channel) {
2248
+ service.invalidateChannel(msg.channel);
2249
+ } else {
2250
+ service.invalidateAll();
2251
+ }
2252
+ }
2253
+
2254
+ const snapshots = await service.getReadiness(msg.channel, msg.includeRemote);
2255
+
2256
+ ctx.send(socket, {
2257
+ type: 'channel_readiness_response',
2258
+ success: true,
2259
+ snapshots: snapshots.map((s) => ({
2260
+ channel: s.channel,
2261
+ ready: s.ready,
2262
+ checkedAt: s.checkedAt,
2263
+ stale: s.stale,
2264
+ reasons: s.reasons,
2265
+ localChecks: s.localChecks,
2266
+ remoteChecks: s.remoteChecks,
2267
+ })),
2268
+ });
2269
+ } catch (err) {
2270
+ const message = err instanceof Error ? err.message : String(err);
2271
+ log.error({ err }, 'Failed to handle channel readiness');
2272
+ ctx.send(socket, {
2273
+ type: 'channel_readiness_response',
2274
+ success: false,
2275
+ error: message,
1531
2276
  });
1532
2277
  }
1533
2278
  }
@@ -1540,6 +2285,32 @@ export function handleEnvVarsRequest(socket: net.Socket, ctx: HandlerContext): v
1540
2285
  ctx.send(socket, { type: 'env_vars_response', vars });
1541
2286
  }
1542
2287
 
2288
+ /**
2289
+ * Look up manifest metadata for a tool that isn't in the live registry.
2290
+ * Searches all installed skills' TOOLS.json manifests for a matching tool name.
2291
+ */
2292
+ function resolveManifestOverride(toolName: string): ManifestOverride | undefined {
2293
+ if (getTool(toolName)) return undefined;
2294
+ try {
2295
+ const catalog = loadSkillCatalog();
2296
+ for (const skill of catalog) {
2297
+ if (!skill.toolManifest?.present || !skill.toolManifest.valid) continue;
2298
+ try {
2299
+ const manifest = parseToolManifestFile(join(skill.directoryPath, 'TOOLS.json'));
2300
+ const entry = manifest.tools.find((t) => t.name === toolName);
2301
+ if (entry) {
2302
+ return { risk: entry.risk, execution_target: entry.execution_target };
2303
+ }
2304
+ } catch {
2305
+ // Skip unparseable manifests
2306
+ }
2307
+ }
2308
+ } catch {
2309
+ // Non-fatal
2310
+ }
2311
+ return undefined;
2312
+ }
2313
+
1543
2314
  export async function handleToolPermissionSimulate(
1544
2315
  msg: ToolPermissionSimulateRequest,
1545
2316
  socket: net.Socket,
@@ -1565,13 +2336,15 @@ export async function handleToolPermissionSimulate(
1565
2336
 
1566
2337
  const workingDir = msg.workingDir ?? process.cwd();
1567
2338
 
1568
- // Resolve execution target using manifest metadata or prefix heuristics.
1569
- // resolveExecutionTarget handles unregistered tools via prefix fallback.
1570
- const executionTarget = resolveExecutionTarget(msg.toolName);
2339
+ // For unregistered skill tools, resolve manifest metadata so the simulation
2340
+ // uses accurate risk/execution_target values instead of falling back to defaults.
2341
+ const manifestOverride = resolveManifestOverride(msg.toolName);
2342
+
2343
+ const executionTarget = resolveExecutionTarget(msg.toolName, manifestOverride);
1571
2344
  const policyContext = { executionTarget };
1572
2345
 
1573
- const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir);
1574
- const result = await check(msg.toolName, msg.input, workingDir, policyContext);
2346
+ const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir, undefined, manifestOverride);
2347
+ const result = await check(msg.toolName, msg.input, workingDir, policyContext, manifestOverride);
1575
2348
 
1576
2349
  // Private-thread override: promote allow → prompt for side-effect tools
1577
2350
  if (
@@ -1629,7 +2402,7 @@ export async function handleToolPermissionSimulate(
1629
2402
 
1630
2403
  export function handleToolNamesList(socket: net.Socket, ctx: HandlerContext): void {
1631
2404
  const tools = getAllTools();
1632
- const names = tools.map((t) => t.name).sort((a, b) => a.localeCompare(b));
2405
+ const nameSet = new Set(tools.map((t) => t.name));
1633
2406
  const schemas: Record<string, import('../ipc-contract.js').ToolInputSchema> = {};
1634
2407
  for (const tool of tools) {
1635
2408
  try {
@@ -1639,6 +2412,29 @@ export function handleToolNamesList(socket: net.Socket, ctx: HandlerContext): vo
1639
2412
  // Skip tools whose definitions can't be resolved
1640
2413
  }
1641
2414
  }
2415
+
2416
+ // Include tools from all installed skills, even those not currently
2417
+ // activated in any session.
2418
+ try {
2419
+ const catalog = loadSkillCatalog();
2420
+ for (const skill of catalog) {
2421
+ if (!skill.toolManifest?.present || !skill.toolManifest.valid) continue;
2422
+ try {
2423
+ const manifest = parseToolManifestFile(join(skill.directoryPath, 'TOOLS.json'));
2424
+ for (const entry of manifest.tools) {
2425
+ if (nameSet.has(entry.name)) continue;
2426
+ nameSet.add(entry.name);
2427
+ schemas[entry.name] = entry.input_schema as unknown as import('../ipc-contract.js').ToolInputSchema;
2428
+ }
2429
+ } catch {
2430
+ // Skip skills whose manifests can't be parsed
2431
+ }
2432
+ }
2433
+ } catch {
2434
+ // Non-fatal — fall back to registered tools only
2435
+ }
2436
+
2437
+ const names = Array.from(nameSet).sort((a, b) => a.localeCompare(b));
1642
2438
  ctx.send(socket, { type: 'tool_names_list_response', names, schemas });
1643
2439
  }
1644
2440
 
@@ -1663,6 +2459,7 @@ export const configHandlers = defineHandlers({
1663
2459
  twitter_integration_config: handleTwitterIntegrationConfig,
1664
2460
  telegram_config: handleTelegramConfig,
1665
2461
  twilio_config: handleTwilioConfig,
2462
+ channel_readiness: handleChannelReadiness,
1666
2463
  guardian_verification: handleGuardianVerification,
1667
2464
  env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx),
1668
2465
  tool_permission_simulate: handleToolPermissionSimulate,