@vellumai/assistant 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +82 -21
  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__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -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,40 @@ 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 Twilio numbers are 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 assignedNumbers = new Set<string>();
637
+ const legacyNumber = (smsConfig.phoneNumber as string) ?? '';
638
+ if (legacyNumber) assignedNumbers.add(legacyNumber);
639
+
640
+ const assistantPhoneNumbers = smsConfig.assistantPhoneNumbers;
641
+ if (assistantPhoneNumbers && typeof assistantPhoneNumbers === 'object' && !Array.isArray(assistantPhoneNumbers)) {
642
+ for (const number of Object.values(assistantPhoneNumbers as Record<string, unknown>)) {
643
+ if (typeof number === 'string' && number) {
644
+ assignedNumbers.add(number);
645
+ }
646
+ }
647
+ }
648
+
649
+ if (assignedNumbers.size > 0) {
650
+ const acctSid = getSecureKey('credential:twilio:account_sid')!;
651
+ const acctToken = getSecureKey('credential:twilio:auth_token')!;
652
+ // Fire-and-forget: webhook sync failure must not block the ingress save.
653
+ // Reconcile every assigned number so assistant-scoped mappings do not
654
+ // retain stale Twilio webhook URLs after ingress URL changes.
655
+ for (const assignedNumber of assignedNumbers) {
656
+ syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
657
+ .catch(() => {
658
+ // Already logged inside syncTwilioWebhooks
659
+ });
660
+ }
661
+ }
662
+ }
587
663
  } else {
588
664
  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
665
  }
@@ -1109,7 +1185,15 @@ export async function handleTwilioConfig(
1109
1185
  const hasCredentials = hasTwilioCredentials();
1110
1186
  const raw = loadRawConfig();
1111
1187
  const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1112
- const phoneNumber = (sms.phoneNumber as string) ?? '';
1188
+ // When assistantId is provided, look up in assistantPhoneNumbers first,
1189
+ // fall back to the legacy phoneNumber field
1190
+ let phoneNumber: string;
1191
+ if (msg.assistantId) {
1192
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1193
+ phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1194
+ } else {
1195
+ phoneNumber = (sms.phoneNumber as string) ?? '';
1196
+ }
1113
1197
  ctx.send(socket, {
1114
1198
  type: 'twilio_config_response',
1115
1199
  success: true,
@@ -1192,9 +1276,12 @@ export async function handleTwilioConfig(
1192
1276
  hasCredentials: true,
1193
1277
  });
1194
1278
  } else if (msg.action === 'clear_credentials') {
1279
+ // Only clear authentication credentials (Account SID and Auth Token).
1280
+ // Preserve the phone number in both config (sms.phoneNumber) and secure
1281
+ // key (credential:twilio:phone_number) so that re-entering credentials
1282
+ // resumes working without needing to reassign the number.
1195
1283
  deleteSecureKey('credential:twilio:account_sid');
1196
1284
  deleteSecureKey('credential:twilio:auth_token');
1197
- deleteSecureKey('credential:twilio:phone_number');
1198
1285
  deleteCredentialMetadata('twilio', 'account_sid');
1199
1286
  deleteCredentialMetadata('twilio', 'auth_token');
1200
1287
 
@@ -1233,11 +1320,64 @@ export async function handleTwilioConfig(
1233
1320
  // Purchase the first available number
1234
1321
  const purchased = await provisionPhoneNumber(accountSid, authToken, available[0].phoneNumber);
1235
1322
 
1323
+ // Auto-assign: persist the purchased number in secure storage and config
1324
+ // (same persistence as assign_number for consistency)
1325
+ const phoneStored = setSecureKey('credential:twilio:phone_number', purchased.phoneNumber);
1326
+ if (!phoneStored) {
1327
+ ctx.send(socket, {
1328
+ type: 'twilio_config_response',
1329
+ success: false,
1330
+ hasCredentials: hasTwilioCredentials(),
1331
+ phoneNumber: purchased.phoneNumber,
1332
+ error: `Phone number ${purchased.phoneNumber} was purchased but could not be saved. Use assign_number to assign it manually.`,
1333
+ });
1334
+ return;
1335
+ }
1336
+
1337
+ const raw = loadRawConfig();
1338
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1339
+ // When assistantId is provided, only set the legacy global phoneNumber
1340
+ // if it's not already set — this prevents multi-assistant assignments
1341
+ // from clobbering each other's outbound SMS number.
1342
+ if (msg.assistantId) {
1343
+ if (!sms.phoneNumber) {
1344
+ sms.phoneNumber = purchased.phoneNumber;
1345
+ }
1346
+ } else {
1347
+ sms.phoneNumber = purchased.phoneNumber;
1348
+ }
1349
+ // When assistantId is provided, also persist into the per-assistant mapping
1350
+ if (msg.assistantId) {
1351
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1352
+ mapping[msg.assistantId] = purchased.phoneNumber;
1353
+ sms.assistantPhoneNumbers = mapping;
1354
+ }
1355
+
1356
+ const wasSuppressed = ctx.suppressConfigReload;
1357
+ ctx.setSuppressConfigReload(true);
1358
+ try {
1359
+ saveRawConfig({ ...raw, sms });
1360
+ } catch (err) {
1361
+ ctx.setSuppressConfigReload(wasSuppressed);
1362
+ throw err;
1363
+ }
1364
+ ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1365
+
1366
+ // Best-effort webhook configuration — non-fatal so the number is
1367
+ // still usable even if ingress isn't configured yet.
1368
+ const webhookResult = await syncTwilioWebhooks(
1369
+ purchased.phoneNumber,
1370
+ accountSid,
1371
+ authToken,
1372
+ loadRawConfig() as IngressConfig,
1373
+ );
1374
+
1236
1375
  ctx.send(socket, {
1237
1376
  type: 'twilio_config_response',
1238
1377
  success: true,
1239
1378
  hasCredentials: true,
1240
1379
  phoneNumber: purchased.phoneNumber,
1380
+ warning: webhookResult.warning,
1241
1381
  });
1242
1382
  } else if (msg.action === 'assign_number') {
1243
1383
  if (!msg.phoneNumber) {
@@ -1266,7 +1406,22 @@ export async function handleTwilioConfig(
1266
1406
  // Also persist in assistant config (non-secret) for the UI
1267
1407
  const raw = loadRawConfig();
1268
1408
  const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1269
- sms.phoneNumber = msg.phoneNumber;
1409
+ // When assistantId is provided, only set the legacy global phoneNumber
1410
+ // if it's not already set — this prevents multi-assistant assignments
1411
+ // from clobbering each other's outbound SMS number.
1412
+ if (msg.assistantId) {
1413
+ if (!sms.phoneNumber) {
1414
+ sms.phoneNumber = msg.phoneNumber;
1415
+ }
1416
+ } else {
1417
+ sms.phoneNumber = msg.phoneNumber;
1418
+ }
1419
+ // When assistantId is provided, also persist into the per-assistant mapping
1420
+ if (msg.assistantId) {
1421
+ const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1422
+ mapping[msg.assistantId] = msg.phoneNumber;
1423
+ sms.assistantPhoneNumbers = mapping;
1424
+ }
1270
1425
 
1271
1426
  const wasSuppressed = ctx.suppressConfigReload;
1272
1427
  ctx.setSuppressConfigReload(true);
@@ -1278,11 +1433,26 @@ export async function handleTwilioConfig(
1278
1433
  }
1279
1434
  ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1280
1435
 
1436
+ // Best-effort webhook configuration when credentials are available
1437
+ let webhookWarning: string | undefined;
1438
+ if (hasTwilioCredentials()) {
1439
+ const acctSid = getSecureKey('credential:twilio:account_sid')!;
1440
+ const acctToken = getSecureKey('credential:twilio:auth_token')!;
1441
+ const webhookResult = await syncTwilioWebhooks(
1442
+ msg.phoneNumber,
1443
+ acctSid,
1444
+ acctToken,
1445
+ loadRawConfig() as IngressConfig,
1446
+ );
1447
+ webhookWarning = webhookResult.warning;
1448
+ }
1449
+
1281
1450
  ctx.send(socket, {
1282
1451
  type: 'twilio_config_response',
1283
1452
  success: true,
1284
1453
  hasCredentials: hasTwilioCredentials(),
1285
1454
  phoneNumber: msg.phoneNumber,
1455
+ warning: webhookWarning,
1286
1456
  });
1287
1457
  } else if (msg.action === 'list_numbers') {
1288
1458
  if (!hasTwilioCredentials()) {
@@ -1330,12 +1500,12 @@ export function handleGuardianVerification(
1330
1500
  socket: net.Socket,
1331
1501
  ctx: HandlerContext,
1332
1502
  ): void {
1333
- 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';
1337
- const channel = msg.channel ?? 'telegram';
1503
+ // Use the assistant ID from the request when available; fall back to
1504
+ // 'self' for backward compatibility with single-assistant mode.
1505
+ const assistantId = msg.assistantId ?? 'self';
1506
+ const channel = msg.channel ?? 'telegram';
1338
1507
 
1508
+ try {
1339
1509
  if (msg.action === 'create_challenge') {
1340
1510
  const result = createVerificationChallenge(assistantId, channel, msg.sessionId);
1341
1511
 
@@ -1344,6 +1514,7 @@ export function handleGuardianVerification(
1344
1514
  success: true,
1345
1515
  secret: result.secret,
1346
1516
  instruction: result.instruction,
1517
+ channel,
1347
1518
  });
1348
1519
  } else if (msg.action === 'status') {
1349
1520
  const binding = getGuardianBinding(assistantId, channel);
@@ -1352,19 +1523,24 @@ export function handleGuardianVerification(
1352
1523
  success: true,
1353
1524
  bound: binding !== null,
1354
1525
  guardianExternalUserId: binding?.guardianExternalUserId,
1526
+ channel,
1527
+ assistantId,
1528
+ guardianDeliveryChatId: binding?.guardianDeliveryChatId,
1355
1529
  });
1356
1530
  } else if (msg.action === 'revoke') {
1357
- const revoked = revokeGuardianBinding(assistantId, channel);
1531
+ revokeGuardianBinding(assistantId, channel);
1358
1532
  ctx.send(socket, {
1359
1533
  type: 'guardian_verification_response',
1360
1534
  success: true,
1361
1535
  bound: false,
1536
+ channel,
1362
1537
  });
1363
1538
  } else {
1364
1539
  ctx.send(socket, {
1365
1540
  type: 'guardian_verification_response',
1366
1541
  success: false,
1367
1542
  error: `Unknown action: ${String(msg.action)}`,
1543
+ channel,
1368
1544
  });
1369
1545
  }
1370
1546
  } catch (err) {
@@ -1374,6 +1550,7 @@ export function handleGuardianVerification(
1374
1550
  type: 'guardian_verification_response',
1375
1551
  success: false,
1376
1552
  error: message,
1553
+ channel,
1377
1554
  });
1378
1555
  }
1379
1556
  }
@@ -1411,11 +1588,10 @@ export async function handleToolPermissionSimulate(
1411
1588
 
1412
1589
  const workingDir = msg.workingDir ?? process.cwd();
1413
1590
 
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;
1591
+ // Resolve execution target using manifest metadata or prefix heuristics.
1592
+ // resolveExecutionTarget handles unregistered tools via prefix fallback.
1593
+ const executionTarget = resolveExecutionTarget(msg.toolName);
1594
+ const policyContext = { executionTarget };
1419
1595
 
1420
1596
  const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir);
1421
1597
  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,
@@ -5,8 +5,9 @@ import { getConfig, loadRawConfig, saveRawConfig, invalidateConfigCache } from '
5
5
  import { loadSkillCatalog, loadSkillBySelector, ensureSkillIcon } from '../../config/skills.js';
6
6
  import { resolveSkillStates } from '../../config/skill-state.js';
7
7
  import { getWorkspaceSkillsDir } from '../../util/platform.js';
8
- import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect } from '../../skills/clawhub.js';
8
+ import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect, type ClawhubSearchResultItem } from '../../skills/clawhub.js';
9
9
  import { removeSkillsIndexEntry, deleteManagedSkill, validateManagedSkillId } from '../../skills/managed-store.js';
10
+ import { listCatalogEntries, installFromVellumCatalog, checkVellumSkill } from '../../tools/skills/vellum-catalog.js';
10
11
  import type {
11
12
  SkillDetailRequest,
12
13
  SkillsEnableRequest,
@@ -186,26 +187,44 @@ export async function handleSkillsInstall(
186
187
  ctx: HandlerContext,
187
188
  ): Promise<void> {
188
189
  try {
189
- const result = await clawhubInstall(msg.slug, { version: msg.version });
190
- if (!result.success) {
191
- ctx.send(socket, {
192
- type: 'skills_operation_response',
193
- operation: 'install',
194
- success: false,
195
- error: result.error ?? 'Unknown error',
196
- });
197
- return;
190
+ // Check if the slug matches a vellum-skills catalog entry first
191
+ const isVellumSkill = await checkVellumSkill(msg.slug);
192
+
193
+ let skillId: string;
194
+
195
+ if (isVellumSkill) {
196
+ // Install from vellum-skills catalog (remote with bundled fallback)
197
+ const result = await installFromVellumCatalog(msg.slug);
198
+ if (!result.success) {
199
+ ctx.send(socket, {
200
+ type: 'skills_operation_response',
201
+ operation: 'install',
202
+ success: false,
203
+ error: result.error ?? 'Unknown error',
204
+ });
205
+ return;
206
+ }
207
+ skillId = result.skillName ?? msg.slug;
208
+ } else {
209
+ // Install from clawhub (community)
210
+ const result = await clawhubInstall(msg.slug, { version: msg.version });
211
+ if (!result.success) {
212
+ ctx.send(socket, {
213
+ type: 'skills_operation_response',
214
+ operation: 'install',
215
+ success: false,
216
+ error: result.error ?? 'Unknown error',
217
+ });
218
+ return;
219
+ }
220
+ const rawId = result.skillName ?? msg.slug;
221
+ skillId = rawId.includes('/') ? rawId.split('/').pop()! : rawId;
198
222
  }
199
223
 
200
224
  // Reload skill catalog so the newly installed skill is picked up
201
225
  loadSkillCatalog();
202
226
 
203
227
  // Auto-enable the newly installed skill so it's immediately usable.
204
- // Use basename of slug to match the catalog ID (directory basename), since
205
- // install slugs can be namespaced (e.g. "org/name") but skill state keys use
206
- // the bare directory name.
207
- const rawId = result.skillName ?? msg.slug;
208
- const skillId = rawId.includes('/') ? rawId.split('/').pop()! : rawId;
209
228
  try {
210
229
  const raw = loadRawConfig();
211
230
  ensureSkillEntry(raw, skillId).enabled = true;
@@ -404,12 +423,36 @@ export async function handleSkillsSearch(
404
423
  ctx: HandlerContext,
405
424
  ): Promise<void> {
406
425
  try {
407
- const result = await clawhubSearch(msg.query);
426
+ // Search vellum-skills catalog (remote with bundled fallback)
427
+ const catalogEntries = await listCatalogEntries();
428
+ const query = (msg.query ?? '').toLowerCase();
429
+ const matchingCatalog = catalogEntries.filter((e) => {
430
+ if (!query) return true;
431
+ return e.name.toLowerCase().includes(query) || e.description.toLowerCase().includes(query) || e.id.toLowerCase().includes(query);
432
+ });
433
+ const vellumSkills: ClawhubSearchResultItem[] = matchingCatalog.map((e) => ({
434
+ name: e.name,
435
+ slug: e.id,
436
+ description: e.description,
437
+ author: 'Vellum',
438
+ stars: 0,
439
+ installs: 0,
440
+ version: '',
441
+ createdAt: 0,
442
+ source: 'vellum' as const,
443
+ }));
444
+
445
+ // Search clawhub concurrently
446
+ const clawhubResult = await clawhubSearch(msg.query);
447
+
448
+ // Merge: vellum first, then clawhub
449
+ const merged = { skills: [...vellumSkills, ...clawhubResult.skills] };
450
+
408
451
  ctx.send(socket, {
409
452
  type: 'skills_operation_response',
410
453
  operation: 'search',
411
454
  success: true,
412
- data: result,
455
+ data: merged,
413
456
  });
414
457
  } catch (err) {
415
458
  const message = err instanceof Error ? err.message : String(err);
@@ -91,6 +91,7 @@
91
91
  "ToolNamesListRequest",
92
92
  "ToolPermissionSimulateRequest",
93
93
  "TrustRulesList",
94
+ "TwilioConfigRequest",
94
95
  "TwitterAuthStartRequest",
95
96
  "TwitterAuthStatusRequest",
96
97
  "TwitterIntegrationConfigRequest",
@@ -215,6 +216,7 @@
215
216
  "ToolUseStart",
216
217
  "TraceEvent",
217
218
  "TrustRulesListResponse",
219
+ "TwilioConfigResponse",
218
220
  "TwitterAuthResult",
219
221
  "TwitterAuthStatusResponse",
220
222
  "TwitterIntegrationConfigResponse",
@@ -338,6 +340,7 @@
338
340
  "tool_names_list",
339
341
  "tool_permission_simulate",
340
342
  "trust_rules_list",
343
+ "twilio_config",
341
344
  "twitter_auth_start",
342
345
  "twitter_auth_status",
343
346
  "twitter_integration_config",
@@ -462,6 +465,7 @@
462
465
  "tool_use_start",
463
466
  "trace_event",
464
467
  "trust_rules_list_response",
468
+ "twilio_config_response",
465
469
  "twitter_auth_result",
466
470
  "twitter_auth_status_response",
467
471
  "twitter_integration_config_response",
@@ -67,6 +67,10 @@ export interface SecretResponse {
67
67
 
68
68
  export interface SessionListRequest {
69
69
  type: 'session_list';
70
+ /** Number of sessions to skip (for pagination). Defaults to 0. */
71
+ offset?: number;
72
+ /** Maximum number of sessions to return. Defaults to 50. */
73
+ limit?: number;
70
74
  }
71
75
 
72
76
  /** Lightweight session transport metadata for channel identity and natural-language guidance. */
@@ -557,6 +561,7 @@ export interface TwilioConfigRequest {
557
561
  phoneNumber?: string; // Only for action: 'assign_number'
558
562
  areaCode?: string; // Only for action: 'provision_number'
559
563
  country?: string; // Only for action: 'provision_number' (ISO 3166-1 alpha-2, default 'US')
564
+ assistantId?: string; // Scope number assignment/lookup to a specific assistant
560
565
  }
561
566
 
562
567
  export interface TwilioConfigResponse {
@@ -566,6 +571,8 @@ export interface TwilioConfigResponse {
566
571
  phoneNumber?: string;
567
572
  numbers?: Array<{ phoneNumber: string; friendlyName: string; capabilities: { voice: boolean; sms: boolean } }>;
568
573
  error?: string;
574
+ /** Non-fatal warning message (e.g. webhook sync failure that did not prevent the primary operation). */
575
+ warning?: string;
569
576
  }
570
577
 
571
578
  export interface GuardianVerificationRequest {
@@ -573,6 +580,7 @@ export interface GuardianVerificationRequest {
573
580
  action: 'create_challenge' | 'status' | 'revoke';
574
581
  channel?: string; // Defaults to 'telegram'
575
582
  sessionId?: string;
583
+ assistantId?: string; // Defaults to 'self'
576
584
  }
577
585
 
578
586
  export interface GuardianVerificationResponse {
@@ -583,6 +591,12 @@ export interface GuardianVerificationResponse {
583
591
  /** Present when action is 'status'. */
584
592
  bound?: boolean;
585
593
  guardianExternalUserId?: string;
594
+ /** The channel this status pertains to (e.g. "telegram", "sms"). Present when action is 'status'. */
595
+ channel?: string;
596
+ /** The assistant ID scoped to this status. Present when action is 'status'. */
597
+ assistantId?: string;
598
+ /** The delivery chat ID for the guardian (e.g. Telegram chat ID). Present when action is 'status' and bound is true. */
599
+ guardianDeliveryChatId?: string;
586
600
  error?: string;
587
601
  }
588
602
 
@@ -1271,6 +1285,8 @@ export interface ChannelBinding {
1271
1285
  export interface SessionListResponse {
1272
1286
  type: 'session_list_response';
1273
1287
  sessions: Array<{ id: string; title: string; updatedAt: number; threadType?: ThreadType; channelBinding?: ChannelBinding }>;
1288
+ /** Whether more sessions exist beyond the returned page. */
1289
+ hasMore?: boolean;
1274
1290
  }
1275
1291
 
1276
1292
  export interface SessionsClearResponse {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * IPC wire-level helpers: socket writing, broadcast, and assistant-event
3
+ * hub publishing. Extracted from DaemonServer to separate transport
4
+ * concerns from session management and business logic.
5
+ */
6
+ import * as net from 'node:net';
7
+ import { serialize, type ServerMessage } from './ipc-protocol.js';
8
+ import { assistantEventHub } from '../runtime/assistant-event-hub.js';
9
+ import { buildAssistantEvent } from '../runtime/assistant-event.js';
10
+ import { getLogger } from '../util/logger.js';
11
+
12
+ const log = getLogger('ipc-handler');
13
+
14
+ /**
15
+ * Manages IPC message delivery: writing to individual sockets,
16
+ * broadcasting to all authenticated sockets, and publishing events
17
+ * to the assistant-events hub in order.
18
+ */
19
+ export class IpcSender {
20
+ private _hubChain: Promise<void> = Promise.resolve();
21
+
22
+ /** Write to a single socket without publishing to the event hub. */
23
+ writeToSocket(socket: net.Socket, msg: ServerMessage): void {
24
+ if (!socket.destroyed && socket.writable) {
25
+ socket.write(serialize(msg));
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Send a message to a single socket and publish to the event hub.
31
+ * `sessionId` is resolved from the message itself or the socket binding.
32
+ */
33
+ send(
34
+ socket: net.Socket,
35
+ msg: ServerMessage,
36
+ socketToSession: Map<net.Socket, string>,
37
+ assistantId: string,
38
+ ): void {
39
+ this.writeToSocket(socket, msg);
40
+ const sessionId = extractSessionId(msg) ?? socketToSession.get(socket);
41
+ this.publishAssistantEvent(msg, sessionId, assistantId);
42
+ }
43
+
44
+ /**
45
+ * Broadcast a message to all authenticated sockets, then publish
46
+ * a single event to the hub.
47
+ */
48
+ broadcast(
49
+ authenticatedSockets: Set<net.Socket>,
50
+ msg: ServerMessage,
51
+ socketToSession: Map<net.Socket, string>,
52
+ assistantId: string,
53
+ excludeSocket?: net.Socket,
54
+ ): void {
55
+ for (const socket of authenticatedSockets) {
56
+ if (socket === excludeSocket) continue;
57
+ this.writeToSocket(socket, msg);
58
+ }
59
+ const sessionId = extractSessionId(msg)
60
+ ?? (excludeSocket ? socketToSession.get(excludeSocket) : undefined);
61
+ this.publishAssistantEvent(msg, sessionId, assistantId);
62
+ }
63
+
64
+ /**
65
+ * Publish `msg` as an `AssistantEvent` to the process-level hub.
66
+ * Publications are serialized via a promise chain so subscribers
67
+ * always observe events in send order.
68
+ */
69
+ private publishAssistantEvent(msg: ServerMessage, sessionId?: string, assistantId?: string): void {
70
+ const id = assistantId ?? 'default';
71
+ const event = buildAssistantEvent(id, msg, sessionId);
72
+ this._hubChain = this._hubChain
73
+ .then(() => assistantEventHub.publish(event))
74
+ .catch((err: unknown) => {
75
+ log.warn({ err }, 'assistant-events hub subscriber threw during IPC send');
76
+ });
77
+ }
78
+ }
79
+
80
+ /** Extract sessionId from a ServerMessage if present. */
81
+ function extractSessionId(msg: ServerMessage): string | undefined {
82
+ const record = msg as unknown as Record<string, unknown>;
83
+ if ('sessionId' in msg && typeof record.sessionId === 'string') {
84
+ return record.sessionId as string;
85
+ }
86
+ return undefined;
87
+ }
@@ -429,11 +429,23 @@ export async function runDaemon(): Promise<void> {
429
429
  if (httpPortEnv) {
430
430
  const port = parseInt(httpPortEnv, 10);
431
431
  if (!isNaN(port) && port > 0) {
432
- // Use an explicit env var if provided; otherwise generate a fresh
433
- // random token. Either way, write it to disk so HTTP clients and
434
- // the gateway can authenticate.
435
- const bearerToken = process.env.RUNTIME_PROXY_BEARER_TOKEN || randomBytes(32).toString('hex');
432
+ // Resolve the bearer token in priority order:
433
+ // 1. Explicit env var (e.g. cloud deploys)
434
+ // 2. Existing token file on disk (preserves QR-paired iOS devices across restarts)
435
+ // 3. Fresh random token (first-time startup)
436
436
  const httpTokenPath = getHttpTokenPath();
437
+ let bearerToken = process.env.RUNTIME_PROXY_BEARER_TOKEN;
438
+ if (!bearerToken) {
439
+ try {
440
+ const existing = readFileSync(httpTokenPath, 'utf-8').trim();
441
+ if (existing) bearerToken = existing;
442
+ } catch {
443
+ // File doesn't exist or can't be read — will generate below
444
+ }
445
+ }
446
+ if (!bearerToken) {
447
+ bearerToken = randomBytes(32).toString('hex');
448
+ }
437
449
  writeFileSync(httpTokenPath, bearerToken, { mode: 0o600 });
438
450
  chmodSync(httpTokenPath, 0o600);
439
451