@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
@@ -53,27 +53,34 @@ const log = getLogger('runtime-http');
53
53
 
54
54
  /**
55
55
  * Header name used by the gateway to prove a request originated from it.
56
- * The gateway sets this to the shared bearer token; the runtime validates
57
- * it using constant-time comparison. Requests to `/channels/inbound`
58
- * that lack a valid gateway-origin proof are rejected with 403.
56
+ * The gateway sends a dedicated gateway-origin secret (or the bearer token
57
+ * as fallback). The runtime validates it using constant-time comparison.
58
+ * Requests to `/channels/inbound` that lack a valid proof are rejected with 403.
59
59
  */
60
60
  export const GATEWAY_ORIGIN_HEADER = 'X-Gateway-Origin';
61
61
 
62
62
  /**
63
63
  * Validate that the request carries a valid gateway-origin proof.
64
- * Returns true when the header value matches the expected bearer token
65
- * using constant-time comparison to prevent timing attacks.
64
+ * Uses constant-time comparison to prevent timing attacks.
66
65
  *
67
- * When no bearer token is configured (e.g., local dev without auth),
68
- * gateway-origin validation is skipped the server is already
69
- * unauthenticated, so there is no shared secret to verify against.
66
+ * The `gatewayOriginSecret` parameter is the dedicated secret configured
67
+ * via `RUNTIME_GATEWAY_ORIGIN_SECRET`. When set, only this value is
68
+ * accepted. When not set, the function falls back to `bearerToken` for
69
+ * backward compatibility. When neither is configured (local dev), validation
70
+ * is skipped entirely.
70
71
  */
71
- export function verifyGatewayOrigin(req: Request, bearerToken?: string): boolean {
72
- if (!bearerToken) return true; // No shared secret configured — skip validation
72
+ export function verifyGatewayOrigin(
73
+ req: Request,
74
+ bearerToken?: string,
75
+ gatewayOriginSecret?: string,
76
+ ): boolean {
77
+ // Determine the expected secret: prefer dedicated secret, fall back to bearer token
78
+ const expectedSecret = gatewayOriginSecret ?? bearerToken;
79
+ if (!expectedSecret) return true; // No shared secret configured — skip validation
73
80
  const provided = req.headers.get(GATEWAY_ORIGIN_HEADER);
74
81
  if (!provided) return false;
75
82
  const a = Buffer.from(provided);
76
- const b = Buffer.from(bearerToken);
83
+ const b = Buffer.from(expectedSecret);
77
84
  if (a.length !== b.length) return false;
78
85
  return timingSafeEqual(a, b);
79
86
  }
@@ -84,6 +91,9 @@ export function verifyGatewayOrigin(req: Request, bearerToken?: string): boolean
84
91
 
85
92
  export type ActorRole = 'guardian' | 'non-guardian' | 'unverified_channel';
86
93
 
94
+ /** Sub-reason for `unverified_channel` denials. */
95
+ export type DenialReason = 'no_binding' | 'no_identity';
96
+
87
97
  export interface GuardianContext {
88
98
  actorRole: ActorRole;
89
99
  /** The guardian's delivery chat ID (from the guardian binding). */
@@ -96,6 +106,8 @@ export interface GuardianContext {
96
106
  requesterExternalUserId?: string;
97
107
  /** The requester's chat ID. */
98
108
  requesterChatId?: string;
109
+ /** Sub-reason when actorRole is 'unverified_channel'. */
110
+ denialReason?: DenialReason;
99
111
  }
100
112
 
101
113
  /** Guardian approval request expiry (30 minutes). */
@@ -142,7 +154,7 @@ function parseCallbackData(data: string): ApprovalDecisionResult | null {
142
154
  return { action: action as ApprovalAction, source: 'telegram_button', runId };
143
155
  }
144
156
 
145
- export async function handleDeleteConversation(req: Request): Promise<Response> {
157
+ export async function handleDeleteConversation(req: Request, assistantId: string = 'self'): Promise<Response> {
146
158
  const body = await req.json() as {
147
159
  sourceChannel?: string;
148
160
  externalChatId?: string;
@@ -157,8 +169,12 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
157
169
  return Response.json({ error: 'externalChatId is required' }, { status: 400 });
158
170
  }
159
171
 
160
- const conversationKey = `${sourceChannel}:${externalChatId}`;
161
- deleteConversationKey(conversationKey);
172
+ // Delete both legacy and scoped conversation key aliases to handle
173
+ // migration scenarios where either or both keys may exist.
174
+ const legacyKey = `${sourceChannel}:${externalChatId}`;
175
+ const scopedKey = `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
176
+ deleteConversationKey(legacyKey);
177
+ deleteConversationKey(scopedKey);
162
178
  externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
163
179
 
164
180
  return Response.json({ ok: true });
@@ -169,11 +185,13 @@ export async function handleChannelInbound(
169
185
  processMessage?: MessageProcessor,
170
186
  bearerToken?: string,
171
187
  runOrchestrator?: RunOrchestrator,
188
+ assistantId: string = 'self',
189
+ gatewayOriginSecret?: string,
172
190
  ): Promise<Response> {
173
191
  // Reject requests that lack valid gateway-origin proof. This ensures
174
192
  // channel inbound messages can only arrive via the gateway (which
175
193
  // performs webhook-level verification) and not via direct HTTP calls.
176
- if (!verifyGatewayOrigin(req, bearerToken)) {
194
+ if (!verifyGatewayOrigin(req, bearerToken, gatewayOriginSecret)) {
177
195
  log.warn('Rejected channel inbound request: missing or invalid gateway-origin proof');
178
196
  return Response.json(
179
197
  { error: 'Forbidden: missing gateway-origin proof', code: 'GATEWAY_ORIGIN_REQUIRED' },
@@ -258,7 +276,7 @@ export async function handleChannelInbound(
258
276
  sourceChannel,
259
277
  externalChatId,
260
278
  externalMessageId,
261
- { sourceMessageId },
279
+ { sourceMessageId, assistantId },
262
280
  );
263
281
 
264
282
  if (editResult.duplicate) {
@@ -285,7 +303,7 @@ export async function handleChannelInbound(
285
303
  if (original) break;
286
304
  if (attempt < EDIT_LOOKUP_RETRIES) {
287
305
  log.info(
288
- { assistantId: "self", sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES },
306
+ { assistantId, sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES },
289
307
  'Original message not linked yet, retrying edit lookup',
290
308
  );
291
309
  await new Promise((resolve) => setTimeout(resolve, EDIT_LOOKUP_DELAY_MS));
@@ -295,12 +313,12 @@ export async function handleChannelInbound(
295
313
  if (original) {
296
314
  conversationStore.updateMessageContent(original.messageId, content ?? '');
297
315
  log.info(
298
- { assistantId: "self", sourceMessageId, messageId: original.messageId },
316
+ { assistantId, sourceMessageId, messageId: original.messageId },
299
317
  'Updated message content from edited_message',
300
318
  );
301
319
  } else {
302
320
  log.warn(
303
- { assistantId: "self", sourceChannel, externalChatId, sourceMessageId },
321
+ { assistantId, sourceChannel, externalChatId, sourceMessageId },
304
322
  'Could not find original message for edit after retries, ignoring',
305
323
  );
306
324
  }
@@ -317,7 +335,7 @@ export async function handleChannelInbound(
317
335
  sourceChannel,
318
336
  externalChatId,
319
337
  externalMessageId,
320
- { sourceMessageId },
338
+ { sourceMessageId, assistantId },
321
339
  );
322
340
 
323
341
  // Upsert external conversation binding with sender metadata
@@ -351,7 +369,7 @@ export async function handleChannelInbound(
351
369
  const token = trimmedContent.slice('/guardian_verify '.length).trim();
352
370
  if (token.length > 0) {
353
371
  const verifyResult = validateAndConsumeChallenge(
354
- 'self',
372
+ assistantId,
355
373
  sourceChannel,
356
374
  token,
357
375
  body.senderExternalUserId,
@@ -384,11 +402,15 @@ export async function handleChannelInbound(
384
402
  // Determine whether the sender is the guardian for this channel.
385
403
  // When a guardian binding exists, non-guardian actors get stricter
386
404
  // side-effect controls and their approvals route to the guardian's chat.
405
+ //
406
+ // Guardian enforcement runs independently of CHANNEL_APPROVALS_ENABLED.
407
+ // The approval flag only gates the interactive approval prompting UX;
408
+ // actor-role resolution and fail-closed denial are always active.
387
409
  let guardianCtx: GuardianContext = { actorRole: 'guardian' };
388
- if (isChannelApprovalsEnabled() && body.senderExternalUserId) {
389
- const senderIsGuardian = isGuardian('self', sourceChannel, body.senderExternalUserId);
410
+ if (body.senderExternalUserId) {
411
+ const senderIsGuardian = isGuardian(assistantId, sourceChannel, body.senderExternalUserId);
390
412
  if (!senderIsGuardian) {
391
- const binding = getGuardianBinding('self', sourceChannel);
413
+ const binding = getGuardianBinding(assistantId, sourceChannel);
392
414
  if (binding) {
393
415
  const requesterLabel = body.senderUsername
394
416
  ? `@${body.senderUsername}`
@@ -406,11 +428,25 @@ export async function handleChannelInbound(
406
428
  // unverified. Sensitive actions will be auto-denied (fail-closed).
407
429
  guardianCtx = {
408
430
  actorRole: 'unverified_channel',
431
+ denialReason: 'no_binding',
409
432
  requesterExternalUserId: body.senderExternalUserId,
410
433
  requesterChatId: externalChatId,
411
434
  };
412
435
  }
413
436
  }
437
+ } else {
438
+ // No sender identity available — fail-closed when guardian enforcement
439
+ // is active for this channel. If a binding exists, unknown actors must
440
+ // not be granted default guardian permissions.
441
+ const binding = getGuardianBinding(assistantId, sourceChannel);
442
+ if (binding) {
443
+ guardianCtx = {
444
+ actorRole: 'unverified_channel',
445
+ denialReason: 'no_identity',
446
+ requesterExternalUserId: undefined,
447
+ requesterChatId: externalChatId,
448
+ };
449
+ }
414
450
  }
415
451
 
416
452
  // ── Approval interception (gated behind feature flag) ──
@@ -431,6 +467,7 @@ export async function handleChannelInbound(
431
467
  bearerToken,
432
468
  orchestrator: runOrchestrator,
433
469
  guardianCtx,
470
+ assistantId,
434
471
  });
435
472
 
436
473
  if (approvalResult.handled) {
@@ -503,6 +540,7 @@ export async function handleChannelInbound(
503
540
  replyCallbackUrl,
504
541
  bearerToken,
505
542
  guardianCtx,
543
+ assistantId,
506
544
  });
507
545
  } else {
508
546
  // Fire-and-forget: process the message and deliver the reply in the background.
@@ -599,6 +637,22 @@ const RUN_POLL_MAX_WAIT_MS = 300_000; // 5 minutes
599
637
  const POST_DECISION_POLL_INTERVAL_MS = 500;
600
638
  const POST_DECISION_POLL_MAX_WAIT_MS = RUN_POLL_MAX_WAIT_MS;
601
639
 
640
+ /**
641
+ * Override the poll max-wait for tests. When set, used in place of
642
+ * RUN_POLL_MAX_WAIT_MS so tests can exercise timeout paths without
643
+ * waiting 5 minutes.
644
+ */
645
+ let testPollMaxWaitOverride: number | null = null;
646
+
647
+ /** @internal — test-only: set an override for the poll max-wait. */
648
+ export function _setTestPollMaxWait(ms: number | null): void {
649
+ testPollMaxWaitOverride = ms;
650
+ }
651
+
652
+ function getEffectivePollMaxWait(): number {
653
+ return testPollMaxWaitOverride ?? RUN_POLL_MAX_WAIT_MS;
654
+ }
655
+
602
656
  interface ApprovalProcessingParams {
603
657
  orchestrator: RunOrchestrator;
604
658
  conversationId: string;
@@ -610,6 +664,7 @@ interface ApprovalProcessingParams {
610
664
  replyCallbackUrl: string;
611
665
  bearerToken?: string;
612
666
  guardianCtx: GuardianContext;
667
+ assistantId: string;
613
668
  }
614
669
 
615
670
  /**
@@ -636,6 +691,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
636
691
  replyCallbackUrl,
637
692
  bearerToken,
638
693
  guardianCtx,
694
+ assistantId,
639
695
  } = params;
640
696
 
641
697
  const isNonGuardian = guardianCtx.actorRole === 'non-guardian';
@@ -656,9 +712,10 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
656
712
  // Poll the run until it reaches a terminal state, delivering approval
657
713
  // prompts when it transitions to needs_confirmation.
658
714
  const startTime = Date.now();
715
+ const pollMaxWait = getEffectivePollMaxWait();
659
716
  let lastStatus = run.status;
660
717
 
661
- while (Date.now() - startTime < RUN_POLL_MAX_WAIT_MS) {
718
+ while (Date.now() - startTime < pollMaxWait) {
662
719
  await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
663
720
 
664
721
  const current = orchestrator.getRun(run.id);
@@ -668,12 +725,15 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
668
725
  const pending = getPendingConfirmationsByConversation(conversationId);
669
726
 
670
727
  if (isUnverifiedChannel && pending.length > 0) {
671
- // No guardian binding — auto-deny the sensitive action (fail-closed).
728
+ // Unverified channel — auto-deny the sensitive action (fail-closed).
672
729
  handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
730
+ const denialText = guardianCtx.denialReason === 'no_identity'
731
+ ? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied. Please ensure your messaging client sends user identity information.`
732
+ : `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied. Please ask an administrator to configure a guardian.`;
673
733
  try {
674
734
  await deliverChannelReply(replyCallbackUrl, {
675
735
  chatId: externalChatId,
676
- text: `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied. Please ask an administrator to configure a guardian.`,
736
+ text: denialText,
677
737
  }, bearerToken);
678
738
  } catch (err) {
679
739
  log.error({ err, runId: run.id }, 'Failed to deliver unverified-channel denial notice');
@@ -691,6 +751,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
691
751
  const approvalReqRecord = createApprovalRequest({
692
752
  runId: run.id,
693
753
  conversationId,
754
+ assistantId,
694
755
  channel: sourceChannel,
695
756
  requesterExternalUserId: guardianCtx.requesterExternalUserId ?? '',
696
757
  requesterChatId: guardianCtx.requesterChatId ?? externalChatId,
@@ -765,7 +826,14 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
765
826
  bearerToken,
766
827
  );
767
828
  } catch (err) {
768
- log.error({ err, runId: run.id }, 'Failed to deliver approval prompt for channel run');
829
+ // Fail-closed: if we cannot deliver the approval prompt, the
830
+ // user will never see it and the run would hang indefinitely
831
+ // in needs_confirmation. Auto-deny to avoid silent wait states.
832
+ log.error(
833
+ { err, runId: run.id, conversationId },
834
+ 'Failed to deliver standard approval prompt; auto-denying (fail-closed)',
835
+ );
836
+ handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
769
837
  }
770
838
  }
771
839
  }
@@ -792,8 +860,19 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
792
860
 
793
861
  channelDeliveryStore.markProcessed(eventId);
794
862
 
795
- // Deliver the final assistant reply to the requester's chat
796
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
863
+ // Deliver the final assistant reply exactly once. The post-decision
864
+ // poll in schedulePostDecisionDelivery races with this path; the
865
+ // claimRunDelivery guard ensures only the winner sends the reply.
866
+ // If delivery fails, release the claim so the other poller can retry
867
+ // rather than permanently losing the reply.
868
+ if (channelDeliveryStore.claimRunDelivery(run.id)) {
869
+ try {
870
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
871
+ } catch (deliveryErr) {
872
+ channelDeliveryStore.resetRunDeliveryClaim(run.id);
873
+ throw deliveryErr;
874
+ }
875
+ }
797
876
 
798
877
  // If this was a non-guardian run that went through guardian approval,
799
878
  // also notify the guardian's chat about the outcome.
@@ -823,7 +902,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
823
902
  // needs_secret, or disappeared). Record a processing failure so the
824
903
  // retry/dead-letter machinery can handle it.
825
904
  const timeoutErr = new Error(
826
- `Approval poll timeout: run did not reach terminal state within ${RUN_POLL_MAX_WAIT_MS}ms (status: ${finalRun?.status ?? 'null'})`,
905
+ `Approval poll timeout: run did not reach terminal state within ${pollMaxWait}ms (status: ${finalRun?.status ?? 'null'})`,
827
906
  );
828
907
  log.warn(
829
908
  { runId: run.id, status: finalRun?.status, conversationId },
@@ -853,6 +932,7 @@ interface ApprovalInterceptionParams {
853
932
  bearerToken?: string;
854
933
  orchestrator: RunOrchestrator;
855
934
  guardianCtx: GuardianContext;
935
+ assistantId: string;
856
936
  }
857
937
 
858
938
  interface ApprovalInterceptionResult {
@@ -884,6 +964,7 @@ async function handleApprovalInterception(
884
964
  bearerToken,
885
965
  orchestrator,
886
966
  guardianCtx,
967
+ assistantId,
887
968
  } = params;
888
969
 
889
970
  // ── Guardian approval decision path ──
@@ -909,14 +990,14 @@ async function handleApprovalInterception(
909
990
  // the decision resolves to exactly the right approval even when
910
991
  // multiple approvals target the same guardian chat.
911
992
  let guardianApproval = decision?.runId
912
- ? getPendingApprovalByRunAndGuardianChat(decision.runId, sourceChannel, externalChatId)
993
+ ? getPendingApprovalByRunAndGuardianChat(decision.runId, sourceChannel, externalChatId, assistantId)
913
994
  : null;
914
995
 
915
996
  // For plain-text decisions (no run ID), check how many pending
916
997
  // approvals exist for this guardian chat. If there are multiple,
917
998
  // the guardian must use buttons to disambiguate.
918
999
  if (!guardianApproval && decision && !decision.runId) {
919
- const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId);
1000
+ const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId, assistantId);
920
1001
  if (allPending.length > 1) {
921
1002
  try {
922
1003
  await deliverChannelReply(replyCallbackUrl, {
@@ -936,7 +1017,7 @@ async function handleApprovalInterception(
936
1017
  // Fall back to the single-result lookup for non-decision messages
937
1018
  // (reminder path) or when the scoped lookup found nothing.
938
1019
  if (!guardianApproval && !decision) {
939
- guardianApproval = getPendingApprovalByGuardianChat(sourceChannel, externalChatId);
1020
+ guardianApproval = getPendingApprovalByGuardianChat(sourceChannel, externalChatId, assistantId);
940
1021
  }
941
1022
 
942
1023
  if (guardianApproval) {
@@ -1056,16 +1137,19 @@ async function handleApprovalInterception(
1056
1137
  const pendingPrompt = getChannelApprovalPrompt(conversationId);
1057
1138
  if (!pendingPrompt) return { handled: false };
1058
1139
 
1059
- // When the sender is from an unverified channel (no guardian binding),
1060
- // auto-deny any pending confirmation and block self-approval.
1140
+ // When the sender is from an unverified channel, auto-deny any pending
1141
+ // confirmation and block self-approval.
1061
1142
  if (guardianCtx.actorRole === 'unverified_channel') {
1062
1143
  const pending = getPendingConfirmationsByConversation(conversationId);
1063
1144
  if (pending.length > 0) {
1064
1145
  handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
1146
+ const denialText = guardianCtx.denialReason === 'no_identity'
1147
+ ? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied.`
1148
+ : `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied.`;
1065
1149
  try {
1066
1150
  await deliverChannelReply(replyCallbackUrl, {
1067
1151
  chatId: externalChatId,
1068
- text: `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied.`,
1152
+ text: denialText,
1069
1153
  }, bearerToken);
1070
1154
  } catch (err) {
1071
1155
  log.error({ err, conversationId }, 'Failed to deliver unverified-channel denial notice during interception');
@@ -1153,8 +1237,8 @@ async function handleApprovalInterception(
1153
1237
  // Schedule a background poll for run terminal state and deliver the reply.
1154
1238
  // This handles the case where the original poll in
1155
1239
  // processChannelMessageWithApprovals has already exited due to timeout.
1156
- // If the original poll is still running and delivers first, the duplicate
1157
- // delivery is acceptable (gateway deduplicates or user sees a repeat).
1240
+ // The claimRunDelivery guard ensures at-most-once delivery when both
1241
+ // pollers race to terminal state.
1158
1242
  if (result.applied && result.runId) {
1159
1243
  schedulePostDecisionDelivery(
1160
1244
  orchestrator,
@@ -1201,9 +1285,9 @@ async function handleApprovalInterception(
1201
1285
  * handles the case where the original poll in `processChannelMessageWithApprovals`
1202
1286
  * has already exited due to the 5-minute timeout.
1203
1287
  *
1204
- * If the original poll already delivered the reply, delivering it again is
1205
- * acceptable the gateway will deduplicate or the user sees a duplicate
1206
- * (better than seeing nothing).
1288
+ * Uses the same `claimRunDelivery` guard as the main poll to guarantee
1289
+ * at-most-once delivery: whichever poller reaches terminal state first
1290
+ * claims the delivery, and the other silently skips it.
1207
1291
  */
1208
1292
  function schedulePostDecisionDelivery(
1209
1293
  orchestrator: RunOrchestrator,
@@ -1221,7 +1305,14 @@ function schedulePostDecisionDelivery(
1221
1305
  const current = orchestrator.getRun(runId);
1222
1306
  if (!current) break;
1223
1307
  if (current.status === 'completed' || current.status === 'failed') {
1224
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
1308
+ if (channelDeliveryStore.claimRunDelivery(runId)) {
1309
+ try {
1310
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
1311
+ } catch (deliveryErr) {
1312
+ channelDeliveryStore.resetRunDeliveryClaim(runId);
1313
+ throw deliveryErr;
1314
+ }
1315
+ }
1225
1316
  return;
1226
1317
  }
1227
1318
  }
@@ -163,6 +163,8 @@ export interface ClawhubSearchResultItem {
163
163
  installs: number;
164
164
  version: string;
165
165
  createdAt: number;
166
+ /** Where this skill comes from: "vellum" (first-party) or "clawhub" (community). */
167
+ source: 'vellum' | 'clawhub';
166
168
  }
167
169
 
168
170
  export interface ClawhubSearchResult {
@@ -273,10 +275,10 @@ export async function clawhubSearch(query: string): Promise<ClawhubSearchResult>
273
275
  try {
274
276
  const parsed = JSON.parse(result.stdout);
275
277
  if (Array.isArray(parsed)) {
276
- return { skills: parsed };
278
+ return { skills: parsed.map((s: ClawhubSearchResultItem) => ({ ...s, source: s.source ?? 'clawhub' as const })) };
277
279
  }
278
280
  if (parsed.skills && Array.isArray(parsed.skills)) {
279
- return parsed as ClawhubSearchResult;
281
+ return { skills: parsed.skills.map((s: ClawhubSearchResultItem) => ({ ...s, source: s.source ?? 'clawhub' as const })) };
280
282
  }
281
283
  } catch {
282
284
  // CLI outputs text: "slug vVersion DisplayName (score)"
@@ -296,6 +298,7 @@ export async function clawhubSearch(query: string): Promise<ClawhubSearchResult>
296
298
  stars: 0,
297
299
  installs: 0,
298
300
  createdAt: 0,
301
+ source: 'clawhub',
299
302
  });
300
303
  }
301
304
  }
@@ -330,6 +333,7 @@ export async function clawhubExplore(opts?: { limit?: number; sort?: string }):
330
333
  installs: (item.stats as Record<string, number>)?.installsAllTime ?? 0,
331
334
  version: (item.tags as Record<string, string>)?.latest ?? '',
332
335
  createdAt: (item.createdAt as number) ?? 0,
336
+ source: 'clawhub',
333
337
  }));
334
338
  return { skills };
335
339
  } catch {
@@ -482,10 +482,13 @@ export class SubagentManager {
482
482
  let message: string;
483
483
 
484
484
  if (outcome === 'completed') {
485
+ const silent = config.sendResultToUser === false;
485
486
  message =
486
487
  `[Subagent "${config.label}" completed]\n\n` +
487
488
  `Use subagent_read with subagent_id "${config.id}" to retrieve the full output.\n` +
488
- `Do NOT re-spawn this subagent — just read and share the results.`;
489
+ (silent
490
+ ? `This subagent was spawned for internal processing. Read the result for your own use but do NOT share it with the user.\nDo NOT re-spawn this subagent.`
491
+ : `Do NOT re-spawn this subagent — just read and share the results.`);
489
492
  } else {
490
493
  const error = managed.state.error ?? 'Unknown error';
491
494
  message =
@@ -42,6 +42,8 @@ export interface SubagentConfig {
42
42
  systemPromptOverride?: string;
43
43
  /** Optional skill IDs to pre-activate on the subagent session. */
44
44
  preactivatedSkillIds?: string[];
45
+ /** Whether the parent should present the result to the user. Defaults to true. */
46
+ sendResultToUser?: boolean;
45
47
  }
46
48
 
47
49
  // ── State (runtime) ─────────────────────────────────────────────────────
@@ -15,7 +15,7 @@ function getVellumSkillsDir(): string {
15
15
  return join(import.meta.dir, '..', '..', 'config', 'vellum-skills');
16
16
  }
17
17
 
18
- interface CatalogEntry {
18
+ export interface CatalogEntry {
19
19
  id: string;
20
20
  name: string;
21
21
  description: string;
@@ -88,7 +88,7 @@ function parseCatalogEntry(directory: string): CatalogEntry | null {
88
88
  }
89
89
  }
90
90
 
91
- function listCatalogEntries(): CatalogEntry[] {
91
+ export function listCatalogEntries(): CatalogEntry[] {
92
92
  const catalogDir = getVellumSkillsDir();
93
93
  if (!existsSync(catalogDir)) return [];
94
94
 
@@ -107,6 +107,49 @@ function listCatalogEntries(): CatalogEntry[] {
107
107
  return entries.sort((a, b) => a.id.localeCompare(b.id));
108
108
  }
109
109
 
110
+ /**
111
+ * Install a skill from the vellum-skills catalog by ID.
112
+ * Returns { success, skillName, error }.
113
+ */
114
+ export function installFromVellumCatalog(skillId: string): { success: boolean; skillName?: string; error?: string } {
115
+ const catalogDir = getVellumSkillsDir();
116
+ const skillDir = join(catalogDir, skillId.trim());
117
+ const skillFilePath = join(skillDir, 'SKILL.md');
118
+
119
+ if (!existsSync(skillFilePath)) {
120
+ return { success: false, error: `Skill "${skillId}" not found in the Vellum catalog` };
121
+ }
122
+
123
+ const content = readFileSync(skillFilePath, 'utf-8');
124
+ const match = content.match(FRONTMATTER_REGEX);
125
+ if (!match) {
126
+ return { success: false, error: `Skill "${skillId}" has invalid SKILL.md` };
127
+ }
128
+
129
+ const entry = parseCatalogEntry(skillDir);
130
+ if (!entry) {
131
+ return { success: false, error: `Skill "${skillId}" has invalid SKILL.md` };
132
+ }
133
+
134
+ const bodyMarkdown = content.slice(match[0].length);
135
+ const result = createManagedSkill({
136
+ id: entry.id,
137
+ name: entry.name,
138
+ description: entry.description,
139
+ bodyMarkdown,
140
+ emoji: entry.emoji,
141
+ includes: entry.includes,
142
+ overwrite: true,
143
+ addToIndex: true,
144
+ });
145
+
146
+ if (!result.created) {
147
+ return { success: false, error: result.error };
148
+ }
149
+
150
+ return { success: true, skillName: entry.id };
151
+ }
152
+
110
153
  class VellumSkillsCatalogTool implements Tool {
111
154
  name = 'vellum_skills_catalog';
112
155
  description = 'List and install Vellum-provided skills from the first-party catalog';
@@ -8,6 +8,7 @@ export async function executeSubagentSpawn(
8
8
  const label = input.label as string;
9
9
  const objective = input.objective as string;
10
10
  const extraContext = input.context as string | undefined;
11
+ const sendResultToUser = input.send_result_to_user !== false;
11
12
 
12
13
  if (!label || !objective) {
13
14
  return { content: 'Both "label" and "objective" are required.', isError: true };
@@ -26,6 +27,7 @@ export async function executeSubagentSpawn(
26
27
  label,
27
28
  objective,
28
29
  context: extraContext,
30
+ sendResultToUser,
29
31
  },
30
32
  sendToClient as (msg: unknown) => void,
31
33
  );