@vellumai/assistant 0.3.3 → 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 (75) hide show
  1. package/README.md +8 -16
  2. package/package.json +1 -1
  3. package/src/__tests__/call-orchestrator.test.ts +321 -0
  4. package/src/__tests__/channel-approval-routes.test.ts +382 -124
  5. package/src/__tests__/channel-approvals.test.ts +51 -2
  6. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  7. package/src/__tests__/channel-guardian.test.ts +187 -0
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  10. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  11. package/src/__tests__/handlers-twilio-config.test.ts +73 -0
  12. package/src/__tests__/secret-scanner.test.ts +223 -0
  13. package/src/__tests__/shell-parser-property.test.ts +357 -2
  14. package/src/__tests__/system-prompt.test.ts +25 -1
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  16. package/src/__tests__/user-reference.test.ts +68 -0
  17. package/src/calls/call-orchestrator.ts +63 -11
  18. package/src/cli/map.ts +6 -0
  19. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  20. package/src/commands/cc-command-registry.ts +14 -1
  21. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  22. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  23. package/src/config/defaults.ts +1 -1
  24. package/src/config/schema.ts +3 -3
  25. package/src/config/skills.ts +5 -32
  26. package/src/config/system-prompt.ts +16 -0
  27. package/src/config/user-reference.ts +29 -0
  28. package/src/config/vellum-skills/catalog.json +52 -0
  29. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  30. package/src/config/vellum-skills/twilio-setup/SKILL.md +38 -0
  31. package/src/daemon/auth-manager.ts +103 -0
  32. package/src/daemon/computer-use-session.ts +8 -1
  33. package/src/daemon/config-watcher.ts +253 -0
  34. package/src/daemon/handlers/config.ts +36 -13
  35. package/src/daemon/handlers/skills.ts +6 -7
  36. package/src/daemon/ipc-contract.ts +6 -0
  37. package/src/daemon/ipc-handler.ts +87 -0
  38. package/src/daemon/lifecycle.ts +16 -4
  39. package/src/daemon/ride-shotgun-handler.ts +11 -1
  40. package/src/daemon/server.ts +105 -502
  41. package/src/daemon/session-agent-loop.ts +5 -14
  42. package/src/daemon/session-runtime-assembly.ts +60 -44
  43. package/src/daemon/session.ts +8 -1
  44. package/src/memory/db-connection.ts +28 -0
  45. package/src/memory/db-init.ts +1019 -0
  46. package/src/memory/db.ts +2 -2007
  47. package/src/memory/embedding-backend.ts +79 -11
  48. package/src/memory/indexer.ts +2 -0
  49. package/src/memory/job-utils.ts +64 -4
  50. package/src/memory/jobs-worker.ts +7 -1
  51. package/src/memory/recall-cache.ts +107 -0
  52. package/src/memory/retriever.ts +30 -1
  53. package/src/memory/schema-migration.ts +984 -0
  54. package/src/memory/schema.ts +1 -0
  55. package/src/memory/search/types.ts +2 -0
  56. package/src/permissions/prompter.ts +14 -3
  57. package/src/permissions/trust-store.ts +7 -0
  58. package/src/runtime/channel-approvals.ts +17 -3
  59. package/src/runtime/gateway-client.ts +2 -1
  60. package/src/runtime/http-server.ts +15 -4
  61. package/src/runtime/routes/channel-routes.ts +172 -84
  62. package/src/runtime/routes/run-routes.ts +7 -1
  63. package/src/runtime/run-orchestrator.ts +8 -1
  64. package/src/security/secret-scanner.ts +218 -0
  65. package/src/skills/frontmatter.ts +63 -0
  66. package/src/skills/slash-commands.ts +23 -0
  67. package/src/skills/vellum-catalog-remote.ts +107 -0
  68. package/src/tools/browser/auto-navigate.ts +132 -24
  69. package/src/tools/browser/browser-manager.ts +67 -61
  70. package/src/tools/claude-code/claude-code.ts +55 -3
  71. package/src/tools/executor.ts +10 -2
  72. package/src/tools/skills/vellum-catalog.ts +61 -156
  73. package/src/tools/terminal/parser.ts +21 -5
  74. package/src/util/platform.ts +8 -1
  75. package/src/util/retry.ts +4 -4
@@ -0,0 +1,29 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { getWorkspacePromptPath } from '../util/platform.js';
3
+
4
+ const DEFAULT_USER_REFERENCE = 'my human';
5
+
6
+ /**
7
+ * Resolve the name/reference the assistant uses when referring to
8
+ * the human it represents in external communications.
9
+ *
10
+ * Reads the "Preferred name/reference:" field from the Onboarding
11
+ * Snapshot section of USER.md. Falls back to "my human" when the
12
+ * file is missing, unreadable, or the field is empty.
13
+ */
14
+ export function resolveUserReference(): string {
15
+ const userPath = getWorkspacePromptPath('USER.md');
16
+ if (!existsSync(userPath)) return DEFAULT_USER_REFERENCE;
17
+
18
+ try {
19
+ const content = readFileSync(userPath, 'utf-8');
20
+ const match = content.match(/Preferred name\/reference:\s*(.+)/);
21
+ if (match && match[1].trim()) {
22
+ return match[1].trim();
23
+ }
24
+ } catch {
25
+ // Fallback on any read error
26
+ }
27
+
28
+ return DEFAULT_USER_REFERENCE;
29
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "description": "Manifest of first-party Vellum skills. Fetched from GitHub at runtime so the assistant can discover and install new skills maintained by Vellum.",
3
+ "version": 1,
4
+ "skills": [
5
+ {
6
+ "id": "chatgpt-import",
7
+ "name": "ChatGPT Import",
8
+ "description": "Import conversation history from ChatGPT into Vellum",
9
+ "emoji": "\ud83d\udce5"
10
+ },
11
+ {
12
+ "id": "deploy-fullstack-vercel",
13
+ "name": "Deploy Fullstack to Vercel",
14
+ "description": "Build and deploy a full-stack app (React frontend + Python/FastAPI backend) to Vercel as a serverless demo with seeded data",
15
+ "emoji": "\ud83d\ude80"
16
+ },
17
+ {
18
+ "id": "document-writer",
19
+ "name": "Document Writer",
20
+ "description": "Create and edit long-form documents like blog posts, articles, essays, and reports using the built-in rich text editor",
21
+ "emoji": "\ud83d\udcdd"
22
+ },
23
+ {
24
+ "id": "google-oauth-setup",
25
+ "name": "Google OAuth Setup",
26
+ "description": "Create Google Cloud OAuth credentials for Gmail integration using browser automation",
27
+ "emoji": "\ud83d\udd11",
28
+ "includes": ["browser", "public-ingress"]
29
+ },
30
+ {
31
+ "id": "slack-oauth-setup",
32
+ "name": "Slack OAuth Setup",
33
+ "description": "Create Slack App and OAuth credentials for Slack integration using browser automation",
34
+ "emoji": "\ud83d\udd11",
35
+ "includes": ["browser", "public-ingress"]
36
+ },
37
+ {
38
+ "id": "telegram-setup",
39
+ "name": "Telegram Setup",
40
+ "description": "Connect a Telegram bot to the Vellum Assistant gateway with automated webhook registration and credential storage",
41
+ "emoji": "\ud83e\udd16",
42
+ "includes": ["public-ingress"]
43
+ },
44
+ {
45
+ "id": "twilio-setup",
46
+ "name": "Twilio Setup",
47
+ "description": "Configure Twilio credentials and phone numbers for voice calls and SMS messaging",
48
+ "emoji": "\ud83d\udcf1",
49
+ "includes": ["public-ingress"]
50
+ }
51
+ ]
52
+ }
@@ -103,12 +103,17 @@ Before reporting success, confirm the guardian binding was actually created. Sen
103
103
 
104
104
  ### Step 8: Report Success
105
105
 
106
+ First, retrieve the bot identity by sending a `telegram_config` IPC message with `action: "get"` and reading the `botUsername` field from the response.
107
+
106
108
  Summarize what was done:
109
+ - Bot identity: @{botUsername}
107
110
  - Bot verified and credentials stored securely via daemon
108
111
  - Webhook registration: handled automatically by the gateway
109
112
  - Bot commands registered: /new, /guardian_verify
110
- - Guardian identity verified (if completed and binding confirmed)
113
+ - Guardian identity: {verified | not configured}
114
+ - Guardian verification status: {verified via challenge | skipped}
111
115
  - Routing configuration validated
116
+ - To re-check guardian status later, send `guardian_verification` with `action: "status"` and `channel: "telegram"`
112
117
 
113
118
  The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the webhook secret changes later, the gateway's credential watcher will automatically re-register the webhook. If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
114
119
 
@@ -171,6 +171,44 @@ Confirm:
171
171
 
172
172
  Tell the user: **"Twilio is configured. Your assistant's phone number is {phoneNumber}. This number is used for both voice calls and SMS messaging."**
173
173
 
174
+ ## Step 5.5: Guardian Verification (SMS)
175
+
176
+ Now link the user's phone number as the trusted SMS guardian for this assistant. Tell the user: "Now let's verify your guardian identity for SMS. This links your phone number as the trusted guardian for SMS messaging."
177
+
178
+ 1. Send the `guardian_verification` IPC message with `action: "create_challenge"` and `channel: "sms"`:
179
+
180
+ ```json
181
+ {
182
+ "type": "guardian_verification",
183
+ "action": "create_challenge",
184
+ "channel": "sms"
185
+ }
186
+ ```
187
+
188
+ 2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian_verify <secret>` to your bot via SMS within 10 minutes."
189
+
190
+ 3. Wait for the user to confirm they have sent the verification code via SMS to the assistant's phone number.
191
+
192
+ 4. Check verification status by sending `guardian_verification` with `action: "status"` and `channel: "sms"`:
193
+
194
+ ```json
195
+ {
196
+ "type": "guardian_verification",
197
+ "action": "status",
198
+ "channel": "sms"
199
+ }
200
+ ```
201
+
202
+ 5. If `bound` is `true`: "Guardian verified! Your phone number is now the trusted SMS guardian."
203
+
204
+ 6. If `bound` is `false` and the user claims they sent the code: "The verification doesn't appear to have succeeded. Let's generate a new challenge." Repeat from substep 1.
205
+
206
+ **Note:** Guardian verification is optional but recommended. If the user declines or wants to skip, proceed to Step 6 without blocking.
207
+
208
+ To re-check guardian status later, send `guardian_verification` with `action: "status"` and `channel: "sms"`.
209
+
210
+ Report the guardian verification result: **"Guardian identity: {verified | not configured}."**
211
+
174
212
  ## Step 6: Enable Features
175
213
 
176
214
  Now that Twilio is configured, the user can enable the features that depend on it:
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Manages daemon-level authentication: session token lifecycle,
3
+ * per-socket auth state, and auth timeouts.
4
+ */
5
+ import * as net from 'node:net';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
8
+ import { getSessionTokenPath } from '../util/platform.js';
9
+ import { hasNoAuthOverride } from './connection-policy.js';
10
+ import { getLogger } from '../util/logger.js';
11
+
12
+ const log = getLogger('auth-manager');
13
+
14
+ export const AUTH_TIMEOUT_MS = 5_000;
15
+
16
+ export class AuthManager {
17
+ private sessionToken = '';
18
+ private authenticatedSockets = new Set<net.Socket>();
19
+ private authTimeouts = new Map<net.Socket, ReturnType<typeof setTimeout>>();
20
+
21
+ /** Initialize the session token — reuse from disk or generate a new one. */
22
+ initToken(): void {
23
+ const tokenPath = getSessionTokenPath();
24
+ let existingToken: string | null = null;
25
+ try {
26
+ const raw = readFileSync(tokenPath, 'utf-8').trim();
27
+ if (raw.length >= 32) existingToken = raw;
28
+ } catch { /* file doesn't exist yet */ }
29
+
30
+ if (existingToken) {
31
+ this.sessionToken = existingToken;
32
+ log.info({ tokenPath }, 'Reusing existing session token');
33
+ } else {
34
+ this.sessionToken = randomBytes(32).toString('hex');
35
+ writeFileSync(tokenPath, this.sessionToken, { mode: 0o600 });
36
+ chmodSync(tokenPath, 0o600);
37
+ log.info({ tokenPath }, 'New session token generated');
38
+ }
39
+ }
40
+
41
+ isAuthenticated(socket: net.Socket): boolean {
42
+ return this.authenticatedSockets.has(socket);
43
+ }
44
+
45
+ /** Returns true if VELLUM_DAEMON_NOAUTH bypass is active. */
46
+ shouldAutoAuth(): boolean {
47
+ return hasNoAuthOverride();
48
+ }
49
+
50
+ markAuthenticated(socket: net.Socket): void {
51
+ this.authenticatedSockets.add(socket);
52
+ }
53
+
54
+ /** Validate a token and authenticate the socket. Returns true on success. */
55
+ authenticate(socket: net.Socket, token: string): boolean {
56
+ if (token === this.sessionToken) {
57
+ this.authenticatedSockets.add(socket);
58
+ return true;
59
+ }
60
+ log.warn('Client provided invalid auth token');
61
+ return false;
62
+ }
63
+
64
+ /** Start the auth timeout for a newly connected socket. */
65
+ startTimeout(socket: net.Socket, onTimeout: () => void): void {
66
+ const timer = setTimeout(() => {
67
+ if (!this.authenticatedSockets.has(socket)) {
68
+ log.warn('Client failed to authenticate within timeout, disconnecting');
69
+ onTimeout();
70
+ }
71
+ }, AUTH_TIMEOUT_MS);
72
+ this.authTimeouts.set(socket, timer);
73
+ }
74
+
75
+ /** Clear the auth timeout (called when the first message arrives). */
76
+ clearTimeout(socket: net.Socket): void {
77
+ const timer = this.authTimeouts.get(socket);
78
+ if (timer) {
79
+ clearTimeout(timer);
80
+ this.authTimeouts.delete(socket);
81
+ }
82
+ }
83
+
84
+ /** Remove all auth state for a disconnected socket. */
85
+ cleanupSocket(socket: net.Socket): void {
86
+ this.clearTimeout(socket);
87
+ this.authenticatedSockets.delete(socket);
88
+ }
89
+
90
+ /** Tear down all auth state on server stop. */
91
+ cleanupAll(): void {
92
+ for (const timer of this.authTimeouts.values()) {
93
+ clearTimeout(timer);
94
+ }
95
+ this.authTimeouts.clear();
96
+ this.authenticatedSockets.clear();
97
+ }
98
+
99
+ /** Iterate over authenticated sockets (for broadcasting). */
100
+ getAuthenticatedSockets(): Set<net.Socket> {
101
+ return this.authenticatedSockets;
102
+ }
103
+ }
@@ -897,7 +897,14 @@ export class ComputerUseSession {
897
897
  decision: UserDecision,
898
898
  selectedPattern?: string,
899
899
  selectedScope?: string,
900
+ decisionContext?: string,
900
901
  ): void {
901
- this.prompter?.resolveConfirmation(requestId, decision, selectedPattern, selectedScope);
902
+ this.prompter?.resolveConfirmation(
903
+ requestId,
904
+ decision,
905
+ selectedPattern,
906
+ selectedScope,
907
+ decisionContext,
908
+ );
902
909
  }
903
910
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * File watchers and config reload logic extracted from DaemonServer.
3
+ * Watches workspace files (config, prompts), protected directory
4
+ * (trust rules, secret allowlist), and skills directories for changes.
5
+ */
6
+ import { existsSync, readdirSync, watch, type FSWatcher } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { getRootDir, getWorkspaceDir, getWorkspaceSkillsDir } from '../util/platform.js';
9
+ import { getConfig, invalidateConfigCache } from '../config/loader.js';
10
+ import { initializeProviders } from '../providers/registry.js';
11
+ import { clearCache as clearTrustCache } from '../permissions/trust-store.js';
12
+ import { resetAllowlist, validateAllowlistFile } from '../security/secret-allowlist.js';
13
+ import { clearEmbeddingBackendCache } from '../memory/embedding-backend.js';
14
+ import { DebouncerMap } from '../util/debounce.js';
15
+ import { getLogger } from '../util/logger.js';
16
+
17
+ const log = getLogger('config-watcher');
18
+
19
+ export class ConfigWatcher {
20
+ private watchers: FSWatcher[] = [];
21
+ private debounceTimers = new DebouncerMap({
22
+ defaultDelayMs: 200,
23
+ maxEntries: 1000,
24
+ protectedKeyPrefix: '__',
25
+ });
26
+ private suppressReload = false;
27
+ private lastFingerprint = '';
28
+ private lastRefreshTime = 0;
29
+
30
+ static readonly REFRESH_INTERVAL_MS = 30_000;
31
+
32
+ /** Expose the debounce timers so handlers can schedule debounced work. */
33
+ get timers(): DebouncerMap {
34
+ return this.debounceTimers;
35
+ }
36
+
37
+ get suppressConfigReload(): boolean {
38
+ return this.suppressReload;
39
+ }
40
+
41
+ set suppressConfigReload(value: boolean) {
42
+ this.suppressReload = value;
43
+ }
44
+
45
+ get lastConfigRefreshTime(): number {
46
+ return this.lastRefreshTime;
47
+ }
48
+
49
+ set lastConfigRefreshTime(value: number) {
50
+ this.lastRefreshTime = value;
51
+ }
52
+
53
+ /** Compute a fingerprint of the current config for change detection. */
54
+ configFingerprint(config: ReturnType<typeof getConfig>): string {
55
+ return JSON.stringify(config);
56
+ }
57
+
58
+ /** Initialize the config fingerprint (call after first config load). */
59
+ initFingerprint(config: ReturnType<typeof getConfig>): void {
60
+ this.lastFingerprint = this.configFingerprint(config);
61
+ }
62
+
63
+ /** Update the fingerprint to match the current config. */
64
+ updateFingerprint(): void {
65
+ this.lastFingerprint = this.configFingerprint(getConfig());
66
+ this.lastRefreshTime = Date.now();
67
+ }
68
+
69
+ /**
70
+ * Reload config from disk + secure storage, and refresh providers only
71
+ * when effective config values (including API keys) have changed.
72
+ * Returns true if config actually changed.
73
+ */
74
+ refreshConfigFromSources(): boolean {
75
+ invalidateConfigCache();
76
+ const config = getConfig();
77
+ const fingerprint = this.configFingerprint(config);
78
+ if (fingerprint === this.lastFingerprint) {
79
+ return false;
80
+ }
81
+ clearTrustCache();
82
+ clearEmbeddingBackendCache();
83
+ const isFirstInit = this.lastFingerprint === '';
84
+ initializeProviders(config);
85
+ this.lastFingerprint = fingerprint;
86
+ return !isFirstInit;
87
+ }
88
+
89
+ /**
90
+ * Start all file watchers. `onSessionEvict` is called when watched
91
+ * files change and sessions need to be evicted for reload.
92
+ */
93
+ start(onSessionEvict: () => void): void {
94
+ const workspaceDir = getWorkspaceDir();
95
+ const protectedDir = join(getRootDir(), 'protected');
96
+
97
+ const workspaceHandlers: Record<string, () => void> = {
98
+ 'config.json': () => {
99
+ if (this.suppressReload) return;
100
+ try {
101
+ const changed = this.refreshConfigFromSources();
102
+ if (changed) onSessionEvict();
103
+ } catch (err) {
104
+ log.error({ err, configPath: join(workspaceDir, 'config.json') }, 'Failed to reload config after file change. Previous config remains active.');
105
+ }
106
+ },
107
+ 'SOUL.md': () => onSessionEvict(),
108
+ 'IDENTITY.md': () => onSessionEvict(),
109
+ 'USER.md': () => onSessionEvict(),
110
+ 'LOOKS.md': () => onSessionEvict(),
111
+ };
112
+
113
+ const protectedHandlers: Record<string, () => void> = {
114
+ 'trust.json': () => {
115
+ clearTrustCache();
116
+ },
117
+ 'secret-allowlist.json': () => {
118
+ resetAllowlist();
119
+ try {
120
+ const errors = validateAllowlistFile();
121
+ if (errors && errors.length > 0) {
122
+ for (const e of errors) {
123
+ log.warn({ index: e.index, pattern: e.pattern }, `Invalid regex in secret-allowlist.json: ${e.message}`);
124
+ }
125
+ }
126
+ } catch (err) {
127
+ log.warn({ err }, 'Failed to validate secret-allowlist.json');
128
+ }
129
+ },
130
+ };
131
+
132
+ const watchDir = (dir: string, handlers: Record<string, () => void>, label: string): void => {
133
+ try {
134
+ const watcher = watch(dir, (_eventType, filename) => {
135
+ if (!filename) return;
136
+ const file = String(filename);
137
+ if (!handlers[file]) return;
138
+ this.debounceTimers.schedule(`file:${file}`, () => {
139
+ log.info({ file }, 'File changed, reloading');
140
+ handlers[file]();
141
+ });
142
+ });
143
+ this.watchers.push(watcher);
144
+ log.info({ dir }, `Watching ${label}`);
145
+ } catch (err) {
146
+ log.warn({ err, dir }, `Failed to watch ${label}. Hot-reload will be unavailable.`);
147
+ }
148
+ };
149
+
150
+ watchDir(workspaceDir, workspaceHandlers, 'workspace directory for config/prompt changes');
151
+ if (existsSync(protectedDir)) {
152
+ watchDir(protectedDir, protectedHandlers, 'protected directory for trust/allowlist changes');
153
+ }
154
+
155
+ this.startSkillsWatchers(onSessionEvict);
156
+ }
157
+
158
+ stop(): void {
159
+ this.debounceTimers.cancelAll();
160
+ for (const watcher of this.watchers) {
161
+ watcher.close();
162
+ }
163
+ this.watchers = [];
164
+ }
165
+
166
+ private startSkillsWatchers(onSessionEvict: () => void): void {
167
+ const skillsDir = getWorkspaceSkillsDir();
168
+ if (!existsSync(skillsDir)) return;
169
+
170
+ const scheduleSkillsReload = (file: string): void => {
171
+ this.debounceTimers.schedule(`skills:${file}`, () => {
172
+ log.info({ file }, 'Skill file changed, reloading');
173
+ onSessionEvict();
174
+ });
175
+ };
176
+
177
+ try {
178
+ const recursiveWatcher = watch(skillsDir, { recursive: true }, (_eventType, filename) => {
179
+ scheduleSkillsReload(filename ? String(filename) : '(unknown)');
180
+ });
181
+ this.watchers.push(recursiveWatcher);
182
+ log.info({ dir: skillsDir }, 'Watching skills directory recursively');
183
+ return;
184
+ } catch (err) {
185
+ log.info({ err, dir: skillsDir }, 'Recursive skills watch unavailable; using per-directory watchers');
186
+ }
187
+
188
+ const childWatchers = new Map<string, FSWatcher>();
189
+
190
+ const watchDir = (dirPath: string, onChange: (filename: string) => void): FSWatcher | null => {
191
+ try {
192
+ const watcher = watch(dirPath, (_eventType, filename) => {
193
+ onChange(filename ? String(filename) : '(unknown)');
194
+ });
195
+ this.watchers.push(watcher);
196
+ return watcher;
197
+ } catch (err) {
198
+ log.warn({ err, dirPath }, 'Failed to watch skills directory');
199
+ return null;
200
+ }
201
+ };
202
+
203
+ const removeWatcher = (watcher: FSWatcher): void => {
204
+ const idx = this.watchers.indexOf(watcher);
205
+ if (idx !== -1) {
206
+ this.watchers.splice(idx, 1);
207
+ }
208
+ };
209
+
210
+ const refreshChildWatchers = (): void => {
211
+ const nextChildDirs = new Set<string>();
212
+
213
+ try {
214
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
215
+ for (const entry of entries) {
216
+ if (!entry.isDirectory()) continue;
217
+ const childDir = join(skillsDir, entry.name);
218
+ nextChildDirs.add(childDir);
219
+
220
+ if (childWatchers.has(childDir)) continue;
221
+
222
+ const watcher = watchDir(childDir, (filename) => {
223
+ const label = filename === '(unknown)' ? entry.name : `${entry.name}/${filename}`;
224
+ scheduleSkillsReload(label);
225
+ });
226
+ if (watcher) {
227
+ childWatchers.set(childDir, watcher);
228
+ }
229
+ }
230
+ } catch (err) {
231
+ log.warn({ err, skillsDir }, 'Failed to enumerate skill directories');
232
+ return;
233
+ }
234
+
235
+ for (const [childDir, watcher] of childWatchers.entries()) {
236
+ if (nextChildDirs.has(childDir)) continue;
237
+ watcher.close();
238
+ childWatchers.delete(childDir);
239
+ removeWatcher(watcher);
240
+ }
241
+ };
242
+
243
+ const rootWatcher = watchDir(skillsDir, (filename) => {
244
+ scheduleSkillsReload(filename);
245
+ refreshChildWatchers();
246
+ });
247
+
248
+ if (!rootWatcher) return;
249
+
250
+ refreshChildWatchers();
251
+ log.info({ dir: skillsDir }, 'Watching skills directory with non-recursive fallback');
252
+ }
253
+ }
@@ -628,20 +628,36 @@ export async function handleIngressConfig(
628
628
  triggerGatewayReconcile(effectiveUrl);
629
629
 
630
630
  // Best-effort Twilio webhook reconciliation: when ingress is being
631
- // enabled/updated and a Twilio number is assigned with valid credentials,
631
+ // enabled/updated and Twilio numbers are assigned with valid credentials,
632
632
  // push the new webhook URLs to Twilio so calls and SMS route correctly.
633
633
  if (isEnabled && hasTwilioCredentials()) {
634
634
  const currentConfig = loadRawConfig();
635
635
  const smsConfig = (currentConfig?.sms ?? {}) as Record<string, unknown>;
636
- const assignedNumber = (smsConfig.phoneNumber as string) ?? '';
637
- if (assignedNumber) {
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) {
638
650
  const acctSid = getSecureKey('credential:twilio:account_sid')!;
639
651
  const acctToken = getSecureKey('credential:twilio:auth_token')!;
640
- // Fire-and-forget: webhook sync failure must not block the ingress save
641
- syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
642
- .catch(() => {
643
- // Already logged inside syncTwilioWebhooks
644
- });
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
+ }
645
661
  }
646
662
  }
647
663
  } else {
@@ -1484,12 +1500,12 @@ export function handleGuardianVerification(
1484
1500
  socket: net.Socket,
1485
1501
  ctx: HandlerContext,
1486
1502
  ): void {
1487
- try {
1488
- // Use the assistant ID from the request when available; fall back to
1489
- // 'self' for backward compatibility with single-assistant mode.
1490
- const assistantId = msg.assistantId ?? 'self';
1491
- const channel = msg.channel ?? 'telegram';
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';
1492
1507
 
1508
+ try {
1493
1509
  if (msg.action === 'create_challenge') {
1494
1510
  const result = createVerificationChallenge(assistantId, channel, msg.sessionId);
1495
1511
 
@@ -1498,6 +1514,7 @@ export function handleGuardianVerification(
1498
1514
  success: true,
1499
1515
  secret: result.secret,
1500
1516
  instruction: result.instruction,
1517
+ channel,
1501
1518
  });
1502
1519
  } else if (msg.action === 'status') {
1503
1520
  const binding = getGuardianBinding(assistantId, channel);
@@ -1506,6 +1523,9 @@ export function handleGuardianVerification(
1506
1523
  success: true,
1507
1524
  bound: binding !== null,
1508
1525
  guardianExternalUserId: binding?.guardianExternalUserId,
1526
+ channel,
1527
+ assistantId,
1528
+ guardianDeliveryChatId: binding?.guardianDeliveryChatId,
1509
1529
  });
1510
1530
  } else if (msg.action === 'revoke') {
1511
1531
  revokeGuardianBinding(assistantId, channel);
@@ -1513,12 +1533,14 @@ export function handleGuardianVerification(
1513
1533
  type: 'guardian_verification_response',
1514
1534
  success: true,
1515
1535
  bound: false,
1536
+ channel,
1516
1537
  });
1517
1538
  } else {
1518
1539
  ctx.send(socket, {
1519
1540
  type: 'guardian_verification_response',
1520
1541
  success: false,
1521
1542
  error: `Unknown action: ${String(msg.action)}`,
1543
+ channel,
1522
1544
  });
1523
1545
  }
1524
1546
  } catch (err) {
@@ -1528,6 +1550,7 @@ export function handleGuardianVerification(
1528
1550
  type: 'guardian_verification_response',
1529
1551
  success: false,
1530
1552
  error: message,
1553
+ channel,
1531
1554
  });
1532
1555
  }
1533
1556
  }
@@ -7,7 +7,7 @@ import { resolveSkillStates } from '../../config/skill-state.js';
7
7
  import { getWorkspaceSkillsDir } from '../../util/platform.js';
8
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 } from '../../tools/skills/vellum-catalog.js';
10
+ import { listCatalogEntries, installFromVellumCatalog, checkVellumSkill } from '../../tools/skills/vellum-catalog.js';
11
11
  import type {
12
12
  SkillDetailRequest,
13
13
  SkillsEnableRequest,
@@ -188,14 +188,13 @@ export async function handleSkillsInstall(
188
188
  ): Promise<void> {
189
189
  try {
190
190
  // Check if the slug matches a vellum-skills catalog entry first
191
- const catalogEntries = listCatalogEntries();
192
- const isVellumSkill = catalogEntries.some((e) => e.id === msg.slug);
191
+ const isVellumSkill = await checkVellumSkill(msg.slug);
193
192
 
194
193
  let skillId: string;
195
194
 
196
195
  if (isVellumSkill) {
197
- // Install from vellum-skills catalog (local copy)
198
- const result = installFromVellumCatalog(msg.slug);
196
+ // Install from vellum-skills catalog (remote with bundled fallback)
197
+ const result = await installFromVellumCatalog(msg.slug);
199
198
  if (!result.success) {
200
199
  ctx.send(socket, {
201
200
  type: 'skills_operation_response',
@@ -424,8 +423,8 @@ export async function handleSkillsSearch(
424
423
  ctx: HandlerContext,
425
424
  ): Promise<void> {
426
425
  try {
427
- // Search vellum-skills catalog locally
428
- const catalogEntries = listCatalogEntries();
426
+ // Search vellum-skills catalog (remote with bundled fallback)
427
+ const catalogEntries = await listCatalogEntries();
429
428
  const query = (msg.query ?? '').toLowerCase();
430
429
  const matchingCatalog = catalogEntries.filter((e) => {
431
430
  if (!query) return true;
@@ -591,6 +591,12 @@ export interface GuardianVerificationResponse {
591
591
  /** Present when action is 'status'. */
592
592
  bound?: boolean;
593
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;
594
600
  error?: string;
595
601
  }
596
602