@vellumai/assistant 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +82 -21
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -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
 
@@ -82,7 +82,14 @@ If the user wants to buy a new number through Twilio, send:
82
82
  - `areaCode` is optional — ask the user if they have a preferred area code
83
83
  - `country` defaults to `"US"` — ask if they want a different country (ISO 3166-1 alpha-2)
84
84
 
85
- The daemon provisions the number via the Twilio API and automatically assigns it to the assistant. The response includes the new `phoneNumber`.
85
+ The daemon provisions the number via the Twilio API, automatically assigns it to the assistant (persisting to both secure storage and config), and configures Twilio webhooks (voice, status callback, SMS) if a public ingress URL is available. The response includes the new `phoneNumber`. No separate `assign_number` call is needed.
86
+
87
+ **Webhook auto-configuration:** When `ingress.publicBaseUrl` is configured, the daemon automatically sets the following webhooks on the Twilio phone number:
88
+ - Voice webhook: `{publicBaseUrl}/webhooks/twilio/voice`
89
+ - Voice status callback: `{publicBaseUrl}/webhooks/twilio/status`
90
+ - SMS webhook: `{publicBaseUrl}/webhooks/twilio/sms`
91
+
92
+ If ingress is not yet configured, webhook setup is skipped gracefully — the number is still assigned and usable once ingress is set up later.
86
93
 
87
94
  **Trial account note:** Twilio trial accounts come with one free phone number. Check "Active Numbers" in the Twilio Console first before provisioning.
88
95
 
@@ -109,7 +116,7 @@ Then assign the chosen number:
109
116
  }
110
117
  ```
111
118
 
112
- The phone number must be in E.164 format.
119
+ The phone number must be in E.164 format. Like `provision_number`, `assign_number` also auto-configures Twilio webhooks when a public ingress URL is available.
113
120
 
114
121
  ### Option C: Manual Entry
115
122
 
@@ -146,13 +153,13 @@ If not configured, load and run the public-ingress skill:
146
153
  skill_load skill=public-ingress
147
154
  ```
148
155
 
149
- **Twilio webhook endpoints (handled automatically by the gateway):**
156
+ **Twilio webhook endpoints (auto-configured on provision/assign):**
150
157
  - Voice webhook: `{publicBaseUrl}/webhooks/twilio/voice`
151
158
  - Voice status callback: `{publicBaseUrl}/webhooks/twilio/status`
152
159
  - ConversationRelay WebSocket: `{publicBaseUrl}/webhooks/twilio/relay` (wss://)
153
160
  - SMS webhook: `{publicBaseUrl}/webhooks/twilio/sms`
154
161
 
155
- No manual Twilio webhook configuration is needed webhook URLs are registered dynamically.
162
+ Webhook URLs are automatically configured on the Twilio phone number when `provision_number` or `assign_number` is called with a valid ingress URL. No manual Twilio Console webhook configuration is needed.
156
163
 
157
164
  ## Step 5: Verify Setup
158
165
 
@@ -164,6 +171,44 @@ Confirm:
164
171
 
165
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."**
166
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
+
167
212
  ## Step 6: Enable Features
168
213
 
169
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
+ }