@vellumai/assistant 0.4.32 → 0.4.33

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 (87) hide show
  1. package/docs/architecture/memory.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/access-request-decision.test.ts +83 -1
  4. package/src/__tests__/actor-token-service.test.ts +0 -1
  5. package/src/__tests__/approval-routes-http.test.ts +0 -1
  6. package/src/__tests__/call-controller.test.ts +0 -1
  7. package/src/__tests__/call-routes-http.test.ts +0 -1
  8. package/src/__tests__/channel-guardian.test.ts +0 -1
  9. package/src/__tests__/channel-invite-transport.test.ts +52 -40
  10. package/src/__tests__/commit-message-enrichment-service.test.ts +4 -38
  11. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -1
  12. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  13. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -1
  15. package/src/__tests__/guardian-dispatch.test.ts +0 -1
  16. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  17. package/src/__tests__/handlers-telegram-config.test.ts +0 -1
  18. package/src/__tests__/inbound-invite-redemption.test.ts +1 -4
  19. package/src/__tests__/ingress-reconcile.test.ts +3 -36
  20. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  21. package/src/__tests__/migration-export-http.test.ts +0 -1
  22. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  23. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  24. package/src/__tests__/migration-validate-http.test.ts +0 -1
  25. package/src/__tests__/non-member-access-request.test.ts +0 -1
  26. package/src/__tests__/notification-guardian-path.test.ts +0 -1
  27. package/src/__tests__/notification-telegram-adapter.test.ts +0 -4
  28. package/src/__tests__/relay-server.test.ts +145 -2
  29. package/src/__tests__/sandbox-host-parity.test.ts +5 -2
  30. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  31. package/src/__tests__/slack-channel-config.test.ts +0 -1
  32. package/src/__tests__/slack-inbound-verification.test.ts +0 -1
  33. package/src/__tests__/sms-messaging-provider.test.ts +0 -4
  34. package/src/__tests__/terminal-tools.test.ts +5 -2
  35. package/src/__tests__/trusted-contact-approval-notifier.test.ts +66 -74
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -1
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  39. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  40. package/src/__tests__/twilio-routes.test.ts +0 -1
  41. package/src/__tests__/update-bulletin.test.ts +0 -2
  42. package/src/__tests__/user-reference.test.ts +47 -1
  43. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  44. package/src/__tests__/workspace-git-service.test.ts +2 -2
  45. package/src/calls/relay-server.ts +5 -55
  46. package/src/channels/config.ts +41 -2
  47. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  48. package/src/config/bundled-skills/slack-digest-setup/SKILL.md +164 -0
  49. package/src/config/env.ts +0 -4
  50. package/src/config/feature-flag-registry.json +4 -4
  51. package/src/config/user-reference.ts +47 -9
  52. package/src/daemon/handlers/config-channels.ts +11 -10
  53. package/src/daemon/handlers/contacts.ts +5 -1
  54. package/src/daemon/lifecycle.ts +18 -26
  55. package/src/memory/channel-delivery-store.ts +1 -0
  56. package/src/memory/db-init.ts +4 -0
  57. package/src/memory/delivery-crud.ts +13 -0
  58. package/src/memory/invite-store.ts +71 -1
  59. package/src/memory/migrations/040-invite-code-hash-column.ts +16 -0
  60. package/src/memory/migrations/index.ts +1 -0
  61. package/src/memory/schema/contacts.ts +2 -0
  62. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -3
  63. package/src/runtime/auth/token-service.ts +50 -0
  64. package/src/runtime/channel-guardian-service.ts +1 -3
  65. package/src/runtime/channel-invite-transport.ts +121 -34
  66. package/src/runtime/channel-invite-transports/email.ts +50 -0
  67. package/src/runtime/channel-invite-transports/slack.ts +81 -0
  68. package/src/runtime/channel-invite-transports/sms.ts +70 -0
  69. package/src/runtime/channel-invite-transports/telegram.ts +29 -11
  70. package/src/runtime/channel-invite-transports/voice.ts +12 -12
  71. package/src/runtime/invite-redemption-service.ts +193 -0
  72. package/src/runtime/invite-redemption-templates.ts +6 -6
  73. package/src/runtime/invite-service.ts +81 -11
  74. package/src/runtime/routes/access-request-decision.ts +52 -6
  75. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -0
  76. package/src/runtime/routes/contact-routes.ts +33 -6
  77. package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -6
  78. package/src/runtime/routes/inbound-message-handler.ts +1 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +289 -4
  80. package/src/runtime/routes/inbound-stages/background-dispatch.ts +9 -42
  81. package/src/runtime/routes/inbound-stages/edit-intercept.ts +10 -0
  82. package/src/runtime/routes/invite-routes.ts +1 -0
  83. package/src/tools/browser/browser-manager.ts +10 -1
  84. package/src/tools/browser/runtime-check.ts +3 -1
  85. package/src/tools/shared/shell-output.ts +7 -2
  86. package/src/util/platform.ts +0 -4
  87. package/src/workspace/git-service.ts +10 -4
@@ -26,7 +26,6 @@ mock.module("../util/platform.js", () => ({
26
26
  getPidPath: () => join(testDir, "test.pid"),
27
27
  getDbPath: () => join(testDir, "test.db"),
28
28
  getLogPath: () => join(testDir, "test.log"),
29
- readHttpToken: () => "test-token",
30
29
  ensureDataDir: () => {},
31
30
  migrateToDataLayout: () => {},
32
31
  migrateToWorkspaceLayout: () => {},
@@ -80,10 +79,15 @@ mock.module("../runtime/gateway-client.js", () => ({
80
79
  }));
81
80
 
82
81
  // ── Guardian binding mock ──
83
- let mockGuardianBinding: Record<string, unknown> | null = null;
82
+ // mockGuardianContact controls what findGuardianForChannel returns.
83
+ // When non-null, it should look like { contact: { displayName: "..." }, channel: { ... } }.
84
+ let mockGuardianContact: {
85
+ contact: { displayName: string };
86
+ channel: Record<string, unknown>;
87
+ } | null = null;
84
88
 
85
89
  mock.module("../runtime/channel-guardian-service.js", () => ({
86
- getGuardianBinding: () => mockGuardianBinding,
90
+ getGuardianBinding: () => null,
87
91
  // Re-export stubs for other functions to prevent import errors
88
92
  bindSessionIdentity: () => {},
89
93
  createOutboundSession: () => ({}),
@@ -100,6 +104,11 @@ mock.module("../runtime/channel-guardian-service.js", () => ({
100
104
  }),
101
105
  }));
102
106
 
107
+ // ── Contact store mock ──
108
+ mock.module("../contacts/contact-store.js", () => ({
109
+ findGuardianForChannel: () => mockGuardianContact,
110
+ }));
111
+
103
112
  // ── Pending interactions mock ──
104
113
  let mockPendingApprovals: Array<{
105
114
  requestId: string;
@@ -123,11 +132,22 @@ mock.module("../config/env.js", () => ({
123
132
  // ── User reference mock ──
124
133
  mock.module("../config/user-reference.js", () => ({
125
134
  resolveUserReference: () => "my human",
135
+ DEFAULT_USER_REFERENCE: "my human",
136
+ resolveGuardianName: (guardianDisplayName?: string | null): string => {
137
+ // Mirror the real implementation: USER.md name > guardianDisplayName > default
138
+ const userRef = "my human"; // In tests, resolveUserReference() returns this
139
+ if (userRef !== "my human") return userRef;
140
+ if (guardianDisplayName && guardianDisplayName.trim().length > 0) {
141
+ return guardianDisplayName.trim();
142
+ }
143
+ return "my human";
144
+ },
126
145
  }));
127
146
 
128
147
  // Import module under test AFTER mocks are set up
129
148
  import type { ChannelId } from "../channels/types.js";
130
- import { resolveUserReference } from "../config/user-reference.js";
149
+ import { resolveGuardianName } from "../config/user-reference.js";
150
+ import { findGuardianForChannel } from "../contacts/contact-store.js";
131
151
  import type { TrustContext } from "../daemon/session-runtime-assembly.js";
132
152
 
133
153
  // We need to test the private functions by importing the module.
@@ -178,8 +198,6 @@ async function simulateNotifierPoll(params: {
178
198
  const { getApprovalInfoByConversation } =
179
199
  await import("../runtime/channel-approvals.js");
180
200
  const { deliverChannelReply } = await import("../runtime/gateway-client.js");
181
- const { getGuardianBinding } =
182
- await import("../runtime/channel-guardian-service.js");
183
201
 
184
202
  const pending = getApprovalInfoByConversation(params.conversationId);
185
203
  const info = pending[0];
@@ -198,37 +216,14 @@ async function simulateNotifierPoll(params: {
198
216
 
199
217
  notifiedRequestIds.set(info.requestId, conversationId);
200
218
 
201
- // Resolve guardian name
202
- let guardianName: string | undefined;
203
- const binding = getGuardianBinding(
204
- params.assistantId ?? "self",
219
+ // Resolve guardian name via the contacts-based approach
220
+ const guardian = findGuardianForChannel(
205
221
  params.sourceChannel,
222
+ params.assistantId ?? "self",
206
223
  );
207
- if (binding?.metadataJson) {
208
- try {
209
- const parsed = JSON.parse(binding.metadataJson as string) as Record<
210
- string,
211
- unknown
212
- >;
213
- if (
214
- typeof parsed.displayName === "string" &&
215
- parsed.displayName.trim().length > 0
216
- ) {
217
- guardianName = parsed.displayName.trim();
218
- } else if (
219
- typeof parsed.username === "string" &&
220
- parsed.username.trim().length > 0
221
- ) {
222
- guardianName = `@${parsed.username.trim()}`;
223
- }
224
- } catch {
225
- // ignore
226
- }
227
- }
224
+ const guardianName = resolveGuardianName(guardian?.contact.displayName);
228
225
 
229
- const waitingText = `Waiting for ${
230
- guardianName ?? resolveUserReference()
231
- }'s approval...`;
226
+ const waitingText = `Waiting for ${guardianName}'s approval...`;
232
227
 
233
228
  try {
234
229
  await deliverChannelReply(
@@ -256,7 +251,7 @@ describe("trusted-contact pending-approval notifier", () => {
256
251
  deliveredReplies.length = 0;
257
252
  deliverShouldFail = false;
258
253
  mockPendingApprovals = [];
259
- mockGuardianBinding = null;
254
+ mockGuardianContact = null;
260
255
  });
261
256
 
262
257
  afterAll(() => {
@@ -277,9 +272,9 @@ describe("trusted-contact pending-approval notifier", () => {
277
272
  },
278
273
  ];
279
274
 
280
- mockGuardianBinding = {
281
- id: "binding-1",
282
- metadataJson: JSON.stringify({ displayName: "Mom" }),
275
+ mockGuardianContact = {
276
+ contact: { displayName: "Mom" },
277
+ channel: {},
283
278
  };
284
279
 
285
280
  const notified = new Map<string, string>();
@@ -304,7 +299,7 @@ describe("trusted-contact pending-approval notifier", () => {
304
299
  expect(notified.has("req-1")).toBe(true);
305
300
  });
306
301
 
307
- test("uses username with @ prefix when display name is not available", async () => {
302
+ test("uses contact displayName from contact store", async () => {
308
303
  mockPendingApprovals = [
309
304
  {
310
305
  requestId: "req-2",
@@ -314,9 +309,9 @@ describe("trusted-contact pending-approval notifier", () => {
314
309
  },
315
310
  ];
316
311
 
317
- mockGuardianBinding = {
318
- id: "binding-1",
319
- metadataJson: JSON.stringify({ username: "guardian_user" }),
312
+ mockGuardianContact = {
313
+ contact: { displayName: "Guardian User" },
314
+ channel: {},
320
315
  };
321
316
 
322
317
  const notified = new Map<string, string>();
@@ -332,11 +327,11 @@ describe("trusted-contact pending-approval notifier", () => {
332
327
 
333
328
  expect(deliveredReplies).toHaveLength(1);
334
329
  expect(deliveredReplies[0].payload.text).toBe(
335
- "Waiting for @guardian_user's approval...",
330
+ "Waiting for Guardian User's approval...",
336
331
  );
337
332
  });
338
333
 
339
- test("falls back to user reference when no guardian name is available", async () => {
334
+ test("falls back to user reference when guardian has empty display name", async () => {
340
335
  mockPendingApprovals = [
341
336
  {
342
337
  requestId: "req-3",
@@ -346,10 +341,10 @@ describe("trusted-contact pending-approval notifier", () => {
346
341
  },
347
342
  ];
348
343
 
349
- // No binding metadata
350
- mockGuardianBinding = {
351
- id: "binding-1",
352
- metadataJson: null,
344
+ // Guardian contact exists but has an empty displayName
345
+ mockGuardianContact = {
346
+ contact: { displayName: "" },
347
+ channel: {},
353
348
  };
354
349
 
355
350
  const notified = new Map<string, string>();
@@ -369,7 +364,7 @@ describe("trusted-contact pending-approval notifier", () => {
369
364
  );
370
365
  });
371
366
 
372
- test("falls back to user reference when no guardian binding exists", async () => {
367
+ test("falls back to user reference when no guardian contact exists", async () => {
373
368
  mockPendingApprovals = [
374
369
  {
375
370
  requestId: "req-4",
@@ -379,7 +374,7 @@ describe("trusted-contact pending-approval notifier", () => {
379
374
  },
380
375
  ];
381
376
 
382
- mockGuardianBinding = null;
377
+ mockGuardianContact = null;
383
378
 
384
379
  const notified = new Map<string, string>();
385
380
  await simulateNotifierPoll({
@@ -408,9 +403,9 @@ describe("trusted-contact pending-approval notifier", () => {
408
403
  },
409
404
  ];
410
405
 
411
- mockGuardianBinding = {
412
- id: "binding-1",
413
- metadataJson: JSON.stringify({ displayName: "Guardian" }),
406
+ mockGuardianContact = {
407
+ contact: { displayName: "Guardian" },
408
+ channel: {},
414
409
  };
415
410
 
416
411
  const notified = new Map<string, string>();
@@ -436,9 +431,9 @@ describe("trusted-contact pending-approval notifier", () => {
436
431
  });
437
432
 
438
433
  test("sends separate messages for different requestIds", async () => {
439
- mockGuardianBinding = {
440
- id: "binding-1",
441
- metadataJson: JSON.stringify({ displayName: "Guardian" }),
434
+ mockGuardianContact = {
435
+ contact: { displayName: "Guardian" },
436
+ channel: {},
442
437
  };
443
438
 
444
439
  const notified = new Map<string, string>();
@@ -478,9 +473,9 @@ describe("trusted-contact pending-approval notifier", () => {
478
473
  });
479
474
 
480
475
  test("concurrent pollers for different conversations do not evict each other", async () => {
481
- mockGuardianBinding = {
482
- id: "binding-1",
483
- metadataJson: JSON.stringify({ displayName: "Guardian" }),
476
+ mockGuardianContact = {
477
+ contact: { displayName: "Guardian" },
478
+ channel: {},
484
479
  };
485
480
 
486
481
  // Shared dedupe map simulating the module-level global
@@ -630,9 +625,9 @@ describe("trusted-contact pending-approval notifier", () => {
630
625
  },
631
626
  ];
632
627
 
633
- mockGuardianBinding = {
634
- id: "binding-1",
635
- metadataJson: JSON.stringify({ displayName: "Guardian" }),
628
+ mockGuardianContact = {
629
+ contact: { displayName: "Guardian" },
630
+ channel: {},
636
631
  };
637
632
 
638
633
  const notified = new Map<string, string>();
@@ -678,7 +673,7 @@ describe("trusted-contact pending-approval notifier", () => {
678
673
  expect(deliveredReplies).toHaveLength(0);
679
674
  });
680
675
 
681
- test("prefers displayName over username when both are present", async () => {
676
+ test("uses contact displayName from guardian contact record", async () => {
682
677
  mockPendingApprovals = [
683
678
  {
684
679
  requestId: "req-10",
@@ -688,12 +683,9 @@ describe("trusted-contact pending-approval notifier", () => {
688
683
  },
689
684
  ];
690
685
 
691
- mockGuardianBinding = {
692
- id: "binding-1",
693
- metadataJson: JSON.stringify({
694
- displayName: "Sarah",
695
- username: "sarah_bot",
696
- }),
686
+ mockGuardianContact = {
687
+ contact: { displayName: "Sarah" },
688
+ channel: {},
697
689
  };
698
690
 
699
691
  const notified = new Map<string, string>();
@@ -713,7 +705,7 @@ describe("trusted-contact pending-approval notifier", () => {
713
705
  );
714
706
  });
715
707
 
716
- test("handles malformed metadataJson gracefully", async () => {
708
+ test("falls back to default when guardian contact has whitespace-only displayName", async () => {
717
709
  mockPendingApprovals = [
718
710
  {
719
711
  requestId: "req-11",
@@ -723,9 +715,9 @@ describe("trusted-contact pending-approval notifier", () => {
723
715
  },
724
716
  ];
725
717
 
726
- mockGuardianBinding = {
727
- id: "binding-1",
728
- metadataJson: "not-valid-json{{{",
718
+ mockGuardianContact = {
719
+ contact: { displayName: " " },
720
+ channel: {},
729
721
  };
730
722
 
731
723
  const notified = new Map<string, string>();
@@ -740,7 +732,7 @@ describe("trusted-contact pending-approval notifier", () => {
740
732
  });
741
733
 
742
734
  expect(deliveredReplies).toHaveLength(1);
743
- // Falls back to generic phrasing
735
+ // Falls back to default user reference
744
736
  expect(deliveredReplies[0].payload.text).toBe(
745
737
  "Waiting for my human's approval...",
746
738
  );
@@ -38,7 +38,6 @@ mock.module("../util/platform.js", () => ({
38
38
  getPidPath: () => join(testDir, "test.pid"),
39
39
  getDbPath: () => join(testDir, "test.db"),
40
40
  getLogPath: () => join(testDir, "test.log"),
41
- readHttpToken: () => "test-token",
42
41
  ensureDataDir: () => {},
43
42
  migrateToDataLayout: () => {},
44
43
  migrateToWorkspaceLayout: () => {},
@@ -33,7 +33,6 @@ mock.module("../util/platform.js", () => ({
33
33
  getDbPath: () => join(testDir, "test.db"),
34
34
  getLogPath: () => join(testDir, "test.log"),
35
35
  ensureDataDir: () => {},
36
- readHttpToken: () => "test-bearer-token",
37
36
  }));
38
37
 
39
38
  mock.module("../util/logger.js", () => ({
@@ -28,7 +28,6 @@ mock.module("../util/platform.js", () => ({
28
28
  getDbPath: () => join(testDir, "test.db"),
29
29
  getLogPath: () => join(testDir, "test.log"),
30
30
  ensureDataDir: () => {},
31
- readHttpToken: () => "test-bearer-token",
32
31
  }));
33
32
 
34
33
  mock.module("../util/logger.js", () => ({
@@ -31,7 +31,6 @@ mock.module("../util/platform.js", () => ({
31
31
  getDbPath: () => join(testDir, "test.db"),
32
32
  getLogPath: () => join(testDir, "test.log"),
33
33
  ensureDataDir: () => {},
34
- readHttpToken: () => "test-bearer-token",
35
34
  }));
36
35
 
37
36
  mock.module("../util/logger.js", () => ({
@@ -43,7 +43,6 @@ mock.module("../util/platform.js", () => ({
43
43
  getDbPath: () => join(testDir, "test.db"),
44
44
  getLogPath: () => join(testDir, "test.log"),
45
45
  ensureDataDir: () => {},
46
- readHttpToken: () => null,
47
46
  }));
48
47
 
49
48
  mock.module("../util/logger.js", () => ({
@@ -57,7 +57,6 @@ mock.module("../util/platform.js", () => ({
57
57
  getHooksDir: () => "",
58
58
  getSocketPath: () => "",
59
59
  getSessionTokenPath: () => "",
60
- getHttpTokenPath: () => "",
61
60
  getPlatformTokenPath: () => "",
62
61
  getPidPath: () => "",
63
62
  getWorkspaceConfigPath: () => "",
@@ -72,7 +71,6 @@ mock.module("../util/platform.js", () => ({
72
71
  writeLockfile: () => {},
73
72
  readPlatformToken: () => null,
74
73
  readSessionToken: () => null,
75
- readHttpToken: () => null,
76
74
  removeSocketFile: () => {},
77
75
  getTCPPort: () => 8765,
78
76
  isTCPEnabled: () => false,
@@ -26,7 +26,8 @@ mock.module("node:fs", () => ({
26
26
  }));
27
27
 
28
28
  // Import after mocks are in place
29
- const { resolveUserReference } = await import("../config/user-reference.js");
29
+ const { resolveUserReference, resolveGuardianName, DEFAULT_USER_REFERENCE } =
30
+ await import("../config/user-reference.js");
30
31
 
31
32
  describe("resolveUserReference", () => {
32
33
  beforeEach(() => {
@@ -69,3 +70,48 @@ describe("resolveUserReference", () => {
69
70
  expect(resolveUserReference()).toBe("Alice");
70
71
  });
71
72
  });
73
+
74
+ describe("resolveGuardianName", () => {
75
+ beforeEach(() => {
76
+ mockFileExists = false;
77
+ mockFileContent = "";
78
+ });
79
+
80
+ test("returns USER.md name when present, ignoring guardianDisplayName", () => {
81
+ mockFileExists = true;
82
+ mockFileContent = [
83
+ "## Onboarding Snapshot",
84
+ "",
85
+ "- Preferred name/reference: John",
86
+ ].join("\n");
87
+ expect(resolveGuardianName("Jane")).toBe("John");
88
+ });
89
+
90
+ test('returns "my human" when USER.md explicitly sets name to default value', () => {
91
+ mockFileExists = true;
92
+ mockFileContent = [
93
+ "## Onboarding Snapshot",
94
+ "",
95
+ "- Preferred name/reference: my human",
96
+ ].join("\n");
97
+ // The user's explicit choice must be respected even though it matches the default sentinel
98
+ expect(resolveGuardianName("Jane")).toBe("my human");
99
+ });
100
+
101
+ test("falls back to guardianDisplayName when USER.md is empty", () => {
102
+ mockFileExists = false;
103
+ expect(resolveGuardianName("Jane")).toBe("Jane");
104
+ });
105
+
106
+ test("falls back to DEFAULT_USER_REFERENCE when both are empty", () => {
107
+ mockFileExists = false;
108
+ expect(resolveGuardianName()).toBe(DEFAULT_USER_REFERENCE);
109
+ expect(resolveGuardianName(null)).toBe(DEFAULT_USER_REFERENCE);
110
+ expect(resolveGuardianName("")).toBe(DEFAULT_USER_REFERENCE);
111
+ });
112
+
113
+ test("trims whitespace on guardianDisplayName fallback", () => {
114
+ mockFileExists = false;
115
+ expect(resolveGuardianName(" Jane ")).toBe("Jane");
116
+ });
117
+ });
@@ -38,7 +38,6 @@ mock.module("../util/platform.js", () => ({
38
38
  getDbPath: () => join(testDir, "test.db"),
39
39
  getLogPath: () => join(testDir, "test.log"),
40
40
  ensureDataDir: () => {},
41
- readHttpToken: () => null,
42
41
  }));
43
42
 
44
43
  mock.module("../util/logger.js", () => ({
@@ -655,7 +655,7 @@ describe("WorkspaceGitService", () => {
655
655
  cwd: testDir,
656
656
  });
657
657
  const oldGitignore =
658
- "# Runtime state - excluded from git tracking\ndata/\nlogs/\n*.log\n*.sock\n*.pid\n*.sqlite\n*.sqlite-journal\n*.sqlite-wal\n*.sqlite-shm\n*.db\n*.db-journal\n*.db-wal\n*.db-shm\nvellum.sock\nvellum.pid\nsession-token\nhttp-token\n";
658
+ "# Runtime state - excluded from git tracking\ndata/\nlogs/\n*.log\n*.sock\n*.pid\n*.sqlite\n*.sqlite-journal\n*.sqlite-wal\n*.sqlite-shm\n*.db\n*.db-journal\n*.db-wal\n*.db-shm\nvellum.sock\nvellum.pid\nsession-token\n";
659
659
  writeFileSync(join(testDir, ".gitignore"), oldGitignore);
660
660
  writeFileSync(join(testDir, "file.txt"), "content");
661
661
  execFileSync("git", ["add", "-A"], { cwd: testDir });
@@ -725,7 +725,7 @@ describe("WorkspaceGitService", () => {
725
725
  cwd: testDir,
726
726
  });
727
727
  const gitignoreContent =
728
- "# Runtime state - excluded from git tracking\ndata/db/\ndata/qdrant/\ndata/ipc-blobs/\nlogs/\n*.log\n*.sock\n*.pid\n*.sqlite\n*.sqlite-journal\n*.sqlite-wal\n*.sqlite-shm\n*.db\n*.db-journal\n*.db-wal\n*.db-shm\nvellum.sock\nvellum.pid\nsession-token\nhttp-token\n";
728
+ "# Runtime state - excluded from git tracking\ndata/db/\ndata/qdrant/\ndata/ipc-blobs/\nlogs/\n*.log\n*.sock\n*.pid\n*.sqlite\n*.sqlite-journal\n*.sqlite-wal\n*.sqlite-shm\n*.db\n*.db-journal\n*.db-wal\n*.db-shm\nvellum.sock\nvellum.pid\nsession-token\n";
729
729
  writeFileSync(join(testDir, ".gitignore"), gitignoreContent);
730
730
  writeFileSync(join(testDir, "file.txt"), "content");
731
731
  execFileSync("git", ["add", "-A"], { cwd: testDir });
@@ -10,7 +10,7 @@ import { randomInt } from "node:crypto";
10
10
 
11
11
  import type { ServerWebSocket } from "bun";
12
12
 
13
- import { resolveUserReference } from "../config/user-reference.js";
13
+ import { resolveGuardianName } from "../config/user-reference.js";
14
14
  import {
15
15
  findGuardianForChannel,
16
16
  listGuardianChannels,
@@ -31,7 +31,6 @@ import {
31
31
  toTrustContext,
32
32
  } from "../runtime/actor-trust-resolver.js";
33
33
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
34
- import { getGuardianBinding } from "../runtime/channel-guardian-service.js";
35
34
  import {
36
35
  composeVerificationVoice,
37
36
  GUARDIAN_VERIFY_TEMPLATE_KEYS,
@@ -1597,70 +1596,21 @@ export class RelayConnection {
1597
1596
 
1598
1597
  /**
1599
1598
  * Resolve a human-readable guardian label for voice wait copy.
1600
- * Prefers displayName from the guardian binding metadata, falls back
1601
- * to @username, then the user's preferred name from USER.md.
1599
+ * Delegates to the shared resolveGuardianName() which checks USER.md
1600
+ * first, then falls back to Contact.displayName, then DEFAULT_USER_REFERENCE.
1602
1601
  */
1603
1602
  private resolveGuardianLabel(): string {
1604
1603
  const assistantId =
1605
1604
  this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1606
1605
 
1607
- // Try the voice-channel binding first, then fall back to any active
1608
- // binding for the assistant (mirrors the cross-channel fallback pattern
1609
- // in access-request-helper.ts).
1610
- let metadataJson: string | null = null;
1611
- // Contacts-first: prefer the voice-bound guardian, then fall back to
1612
- // any guardian channel (mirrors the voice-first pattern in the legacy path).
1606
+ // Look up the guardian contact for a displayName fallback
1613
1607
  const voiceGuardian = findGuardianForChannel("voice", assistantId);
1614
1608
  const guardianChannels = voiceGuardian
1615
1609
  ? null
1616
1610
  : listGuardianChannels(assistantId);
1617
1611
  const guardianContact = voiceGuardian?.contact ?? guardianChannels?.contact;
1618
- if (guardianContact) {
1619
- const meta: Record<string, string> = {};
1620
- if (guardianContact.displayName) {
1621
- meta.displayName = guardianContact.displayName;
1622
- }
1623
- // Preserve the username fallback: use the voice channel's externalUserId
1624
- // so downstream parsing can fall back to @username when displayName is a
1625
- // raw external ID (e.g., phone number from contact-sync).
1626
- const voiceChannel =
1627
- voiceGuardian?.channel ??
1628
- guardianChannels?.channels.find((ch) => ch.type === "voice");
1629
- if (voiceChannel?.externalUserId) {
1630
- meta.username = voiceChannel.externalUserId;
1631
- }
1632
- if (Object.keys(meta).length > 0) {
1633
- metadataJson = JSON.stringify(meta);
1634
- }
1635
- }
1636
- if (!metadataJson) {
1637
- const voiceBinding = getGuardianBinding(assistantId, "voice");
1638
- if (voiceBinding?.metadataJson) {
1639
- metadataJson = voiceBinding.metadataJson;
1640
- }
1641
- }
1642
-
1643
- if (metadataJson) {
1644
- try {
1645
- const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
1646
- if (
1647
- typeof parsed.displayName === "string" &&
1648
- parsed.displayName.trim().length > 0
1649
- ) {
1650
- return parsed.displayName.trim();
1651
- }
1652
- if (
1653
- typeof parsed.username === "string" &&
1654
- parsed.username.trim().length > 0
1655
- ) {
1656
- return `@${parsed.username.trim()}`;
1657
- }
1658
- } catch {
1659
- // ignore malformed metadata
1660
- }
1661
- }
1662
1612
 
1663
- return resolveUserReference();
1613
+ return resolveGuardianName(guardianContact?.displayName);
1664
1614
  }
1665
1615
 
1666
1616
  /**
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Canonical per-channel notification policy registry.
2
+ * Canonical per-channel policy registry.
3
3
  *
4
4
  * Every ChannelId must have an entry here. The `satisfies` constraint
5
5
  * ensures that adding a new ChannelId to channels/types.ts will fail
@@ -13,11 +13,17 @@ export type ConversationStrategy =
13
13
  | "continue_existing_conversation"
14
14
  | "not_deliverable";
15
15
 
16
+ export interface ChannelInvitePolicy {
17
+ /** Whether inbound invite code redemption is supported on this channel. */
18
+ codeRedemptionEnabled: boolean;
19
+ }
20
+
16
21
  export interface ChannelNotificationPolicy {
17
22
  notification: {
18
23
  deliveryEnabled: boolean;
19
24
  conversationStrategy: ConversationStrategy;
20
25
  };
26
+ invite: ChannelInvitePolicy;
21
27
  }
22
28
 
23
29
  const CHANNEL_POLICIES = {
@@ -26,48 +32,69 @@ const CHANNEL_POLICIES = {
26
32
  deliveryEnabled: true,
27
33
  conversationStrategy: "start_new_conversation",
28
34
  },
35
+ invite: {
36
+ codeRedemptionEnabled: false,
37
+ },
29
38
  },
30
39
  telegram: {
31
40
  notification: {
32
41
  deliveryEnabled: true,
33
42
  conversationStrategy: "continue_existing_conversation",
34
43
  },
44
+ invite: {
45
+ codeRedemptionEnabled: true,
46
+ },
35
47
  },
36
48
  sms: {
37
49
  notification: {
38
50
  deliveryEnabled: true,
39
51
  conversationStrategy: "continue_existing_conversation",
40
52
  },
53
+ invite: {
54
+ codeRedemptionEnabled: true,
55
+ },
41
56
  },
42
57
  whatsapp: {
43
58
  notification: {
44
59
  deliveryEnabled: false,
45
60
  conversationStrategy: "continue_existing_conversation",
46
61
  },
62
+ invite: {
63
+ codeRedemptionEnabled: false,
64
+ },
47
65
  },
48
66
  slack: {
49
67
  notification: {
50
68
  deliveryEnabled: true,
51
69
  conversationStrategy: "continue_existing_conversation",
52
70
  },
71
+ invite: {
72
+ codeRedemptionEnabled: true,
73
+ },
53
74
  },
54
75
  email: {
55
76
  notification: {
56
77
  deliveryEnabled: false,
57
78
  conversationStrategy: "continue_existing_conversation",
58
79
  },
80
+ invite: {
81
+ codeRedemptionEnabled: true,
82
+ },
59
83
  },
60
84
  voice: {
61
85
  notification: {
62
86
  deliveryEnabled: false,
63
87
  conversationStrategy: "not_deliverable",
64
88
  },
89
+ invite: {
90
+ codeRedemptionEnabled: false,
91
+ },
65
92
  },
66
93
  } as const satisfies Record<ChannelId, ChannelNotificationPolicy>;
67
94
 
68
95
  export type ChannelPolicies = typeof CHANNEL_POLICIES;
69
96
 
70
- /** Returns the full notification policy for a channel. */
97
+ /** Returns the full policy for a channel. */
71
98
  export function getChannelPolicy(
72
99
  channelId: ChannelId,
73
100
  ): ChannelNotificationPolicy {
@@ -97,3 +124,15 @@ export function getConversationStrategy(
97
124
  ): ConversationStrategy {
98
125
  return CHANNEL_POLICIES[channelId].notification.conversationStrategy;
99
126
  }
127
+
128
+ /** Returns the invite policy for the given channel. */
129
+ export function getChannelInvitePolicy(
130
+ channelId: ChannelId,
131
+ ): ChannelInvitePolicy {
132
+ return CHANNEL_POLICIES[channelId].invite;
133
+ }
134
+
135
+ /** Whether invite code redemption is enabled for the given channel. */
136
+ export function isInviteCodeRedemptionEnabled(channelId: ChannelId): boolean {
137
+ return CHANNEL_POLICIES[channelId].invite.codeRedemptionEnabled;
138
+ }
@@ -69,6 +69,8 @@ When you need to **send** content to Slack proactively (e.g. a scheduled digest,
69
69
  - `send_notification` is appropriate for short alerts and status updates where you want the router to pick the best channel. `messaging_send` is appropriate when you have specific content to deliver to a specific Slack destination.
70
70
  - For scheduled tasks (cron/RRULE), always end with a `messaging_send` call so the results actually reach the user. Without it, the output only lives in the conversation log.
71
71
 
72
+ For setting up recurring digests, load the `slack-digest-setup` skill which covers the full configuration, scheduling, and delivery protocol.
73
+
72
74
  ## Watcher Integration
73
75
 
74
76
  For real-time monitoring (not just on-demand scanning), the user can set up a Slack watcher using the watcher skill with the same channel IDs. Mention this if the user wants ongoing monitoring.