@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -1,22 +1,17 @@
1
1
  import * as net from 'node:net';
2
2
  import * as tls from 'node:tls';
3
- import { randomBytes } from 'node:crypto';
4
- import { existsSync, chmodSync, readFileSync, writeFileSync, readdirSync, watch, type FSWatcher } from 'node:fs';
3
+ import { chmodSync, statSync, readFileSync } from 'node:fs';
5
4
  import { join } from 'node:path';
6
- import { getSocketPath, getSessionTokenPath, getRootDir, getWorkspaceDir, getWorkspaceSkillsDir, getSandboxWorkingDir, removeSocketFile, getTCPPort, getTCPHost, isTCPEnabled, isIOSPairingEnabled } from '../util/platform.js';
5
+ import { getSocketPath, getSandboxWorkingDir, removeSocketFile, getTCPPort, getTCPHost, isTCPEnabled, isIOSPairingEnabled } from '../util/platform.js';
7
6
  import { ensureTlsCert } from './tls-certs.js';
8
7
  import { getLocalIPv4 } from '../util/network-info.js';
9
- import { hasNoAuthOverride } from './connection-policy.js';
10
8
  import { getLogger } from '../util/logger.js';
11
9
  import { getFailoverProvider, initializeProviders } from '../providers/registry.js';
12
10
  import { RateLimitProvider } from '../providers/ratelimit.js';
13
- import { getConfig, invalidateConfigCache } from '../config/loader.js';
11
+ import { getConfig } from '../config/loader.js';
14
12
  import { buildSystemPrompt } from '../config/system-prompt.js';
15
- import { clearCache as clearTrustCache } from '../permissions/trust-store.js';
16
- import { resetAllowlist, validateAllowlistFile } from '../security/secret-allowlist.js';
17
13
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
18
14
  import { IngressBlockedError } from '../util/errors.js';
19
- import { clearEmbeddingBackendCache } from '../memory/embedding-backend.js';
20
15
  import * as conversationStore from '../memory/conversation-store.js';
21
16
  import * as attachmentsStore from '../memory/attachments-store.js';
22
17
  import { Session, DEFAULT_MEMORY_POLICY, type SessionMemoryPolicy } from './session.js';
@@ -26,7 +21,6 @@ import {
26
21
  serialize,
27
22
  createMessageParser,
28
23
  MAX_LINE_SIZE,
29
- type ClientMessage,
30
24
  type ServerMessage,
31
25
  normalizeThreadType,
32
26
  } from './ipc-protocol.js';
@@ -35,15 +29,15 @@ import { handleMessage, type HandlerContext, type SessionCreateOptions } from '.
35
29
  import { RunOrchestrator } from '../runtime/run-orchestrator.js';
36
30
  import { ensureBlobDir, sweepStaleBlobs } from './ipc-blob-store.js';
37
31
  import { bootstrapHomeBaseAppLink } from '../home-base/bootstrap.js';
38
- import { assistantEventHub } from '../runtime/assistant-event-hub.js';
39
- import { buildAssistantEvent } from '../runtime/assistant-event.js';
40
32
  import { SessionEvictor } from './session-evictor.js';
41
33
  import { getSubagentManager } from '../subagent/index.js';
42
34
  import { tryRouteCallMessage } from '../calls/call-bridge.js';
43
35
  import { resolveSlash } from './session-slash.js';
44
36
  import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
45
37
  import { registerDaemonCallbacks } from '../work-items/work-item-runner.js';
46
- import { DebouncerMap } from '../util/debounce.js';
38
+ import { AuthManager } from './auth-manager.js';
39
+ import { ConfigWatcher } from './config-watcher.js';
40
+ import { IpcSender } from './ipc-handler.js';
47
41
 
48
42
  const log = getLogger('server');
49
43
 
@@ -69,41 +63,25 @@ export class DaemonServer {
69
63
  private connectedSockets = new Set<net.Socket>();
70
64
  private socketSandboxOverride = new Map<net.Socket, boolean>();
71
65
  private cuObservationParseSequence = new Map<string, number>();
72
- // Persisted session options (e.g. systemPromptOverride, maxResponseTokens)
73
- // so that evicted sessions can be recreated with the same overrides.
74
66
  private sessionOptions = new Map<string, SessionCreateOptions>();
75
- // Guards against duplicate session creation when multiple clients connect
76
- // with the same conversation ID concurrently. The first caller creates the
77
- // session; subsequent callers await the same promise.
78
67
  private sessionCreating = new Map<string, Promise<Session>>();
79
- // Shared across all sessions so maxRequestsPerMinute is enforced globally.
80
68
  private sharedRequestTimestamps: number[] = [];
81
69
  private socketPath: string;
82
70
  private httpPort: number | undefined;
83
- private watchers: FSWatcher[] = [];
84
- private debounceTimers = new DebouncerMap({
85
- defaultDelayMs: 200,
86
- maxEntries: 1000,
87
- protectedKeyPrefix: '__',
88
- });
89
- private suppressConfigReload = false;
90
- private lastConfigFingerprint = '';
91
- private lastConfigRefreshTime = 0;
92
71
  private blobSweepTimer: ReturnType<typeof setInterval> | null = null;
93
- private static readonly CONFIG_REFRESH_INTERVAL_MS = 30_000;
94
72
  private static readonly MAX_CONNECTIONS = 50;
95
- private static readonly AUTH_TIMEOUT_MS = 5_000;
96
- private sessionToken = '';
97
- private authenticatedSockets = new Set<net.Socket>();
98
- private authTimeouts = new Map<net.Socket, ReturnType<typeof setTimeout>>();
99
73
  private evictor: SessionEvictor;
100
74
 
75
+ // Composed subsystems
76
+ private auth = new AuthManager();
77
+ private configWatcher = new ConfigWatcher();
78
+ private ipc = new IpcSender();
79
+
101
80
  /**
102
- * Derive a SessionMemoryPolicy from the conversation's thread type and
103
- * memory scope. Private conversations get an isolated scope with strict
104
- * side-effect controls and default-fallback recall; standard conversations
105
- * use the shared default scope with no restrictions.
81
+ * Logical assistant identifier used when publishing to the assistant-events hub.
106
82
  */
83
+ assistantId: string = 'default';
84
+
107
85
  private deriveMemoryPolicy(conversationId: string): SessionMemoryPolicy {
108
86
  const threadType = conversationStore.getConversationThreadType(conversationId);
109
87
  if (threadType === 'private') {
@@ -119,35 +97,20 @@ export class DaemonServer {
119
97
  private applyTransportMetadata(_session: Session, options: SessionCreateOptions | undefined): void {
120
98
  const transport = options?.transport;
121
99
  if (!transport) return;
122
-
123
- // Transport metadata is available for future use but onboarding context
124
- // is now handled via BOOTSTRAP.md in the system prompt.
125
100
  log.debug({ channelId: transport.channelId }, 'Transport metadata received');
126
101
  }
127
102
 
128
- /**
129
- * Logical assistant identifier used when publishing to the assistant-events hub.
130
- * Defaults to 'default' for the IPC daemon runtime; override in tests or
131
- * multi-tenant deployments where the daemon is scoped to a specific assistant.
132
- */
133
- assistantId: string = 'default';
134
-
135
103
  constructor() {
136
104
  this.socketPath = getSocketPath();
137
105
  this.evictor = new SessionEvictor(this.sessions);
138
- // Share the global rate-limit timestamps with the subagent manager.
139
106
  getSubagentManager().sharedRequestTimestamps = this.sharedRequestTimestamps;
140
- // Abort subagents when their parent session is evicted.
141
107
  this.evictor.onEvict = (sessionId: string) => {
142
108
  getSubagentManager().abortAllForParent(sessionId);
143
109
  };
144
- // Protect parent sessions that have active subagents from eviction.
145
110
  this.evictor.shouldProtect = (sessionId: string) => {
146
111
  const children = getSubagentManager().getChildrenOf(sessionId);
147
112
  return children.some((c) => c.status === 'running' || c.status === 'pending');
148
113
  };
149
- // When a subagent finishes, inject the result into the parent session
150
- // so the LLM automatically informs the user.
151
114
  getSubagentManager().onSubagentFinished = (parentSessionId, message, sendToClient, notification) => {
152
115
  const parentSession = this.sessions.get(parentSessionId);
153
116
  if (!parentSession) {
@@ -155,7 +118,6 @@ export class DaemonServer {
155
118
  return;
156
119
  }
157
120
  const requestId = `subagent-notify-${Date.now()}`;
158
- // Store structured notification data in the DB for history reconstruction
159
121
  const metadata = { subagentNotification: notification };
160
122
  const enqueueResult = parentSession.enqueueMessage(message, [], sendToClient, requestId, undefined, undefined, metadata);
161
123
  if (enqueueResult.rejected) {
@@ -163,26 +125,38 @@ export class DaemonServer {
163
125
  return;
164
126
  }
165
127
  if (!enqueueResult.queued) {
166
- // Parent is idle — send directly.
167
128
  const messageId = parentSession.persistUserMessage(message, [], undefined, metadata);
168
129
  parentSession.runAgentLoop(message, messageId, sendToClient).catch((err) => {
169
130
  log.error({ parentSessionId, err }, 'Failed to process subagent notification in parent');
170
131
  });
171
132
  }
172
- // If queued, it will be processed when the parent finishes its current turn.
173
133
  };
174
134
  }
175
135
 
136
+ // ── Send / Broadcast wrappers ───────────────────────────────────────
137
+
138
+ private send(socket: net.Socket, msg: ServerMessage): void {
139
+ this.ipc.send(socket, msg, this.socketToSession, this.assistantId);
140
+ }
141
+
142
+ broadcast(msg: ServerMessage, excludeSocket?: net.Socket): void {
143
+ this.ipc.broadcast(
144
+ this.auth.getAuthenticatedSockets(),
145
+ msg,
146
+ this.socketToSession,
147
+ this.assistantId,
148
+ excludeSocket,
149
+ );
150
+ }
151
+
152
+ // ── Server lifecycle ────────────────────────────────────────────────
153
+
176
154
  async start(): Promise<void> {
177
- // Clean up stale socket (only if it's actually a Unix socket)
178
155
  removeSocketFile(this.socketPath);
179
156
 
180
- // Initialize providers from config so they're available before any
181
- // session is created. Without this, getProvider() throws because the
182
- // registry is empty until a config file change triggers a reload.
183
157
  const config = getConfig();
184
158
  initializeProviders(config);
185
- this.lastConfigFingerprint = this.configFingerprint(config);
159
+ this.configWatcher.initFingerprint(config);
186
160
 
187
161
  try {
188
162
  bootstrapHomeBaseAppLink();
@@ -192,7 +166,6 @@ export class DaemonServer {
192
166
 
193
167
  this.evictor.start();
194
168
 
195
- // Register daemon callbacks so tools can trigger work item execution
196
169
  registerDaemonCallbacks({
197
170
  getOrCreateSession: (conversationId) => this.getOrCreateSession(conversationId),
198
171
  broadcast: (msg) => this.broadcast(msg),
@@ -205,30 +178,9 @@ export class DaemonServer {
205
178
  });
206
179
  }, 5 * 60 * 1000);
207
180
 
208
- this.startFileWatchers();
209
-
210
- // Reuse existing session token from disk if present, so pairing
211
- // (e.g. iOS QR code) survives daemon restarts. Only generate a
212
- // new token when none exists on disk.
213
- const tokenPath = getSessionTokenPath();
214
- let existingToken: string | null = null;
215
- try {
216
- const raw = readFileSync(tokenPath, 'utf-8').trim();
217
- if (raw.length >= 32) existingToken = raw;
218
- } catch { /* file doesn't exist yet */ }
181
+ this.configWatcher.start(() => this.evictSessionsForReload());
182
+ this.auth.initToken();
219
183
 
220
- if (existingToken) {
221
- this.sessionToken = existingToken;
222
- log.info({ tokenPath }, 'Reusing existing session token');
223
- } else {
224
- this.sessionToken = randomBytes(32).toString('hex');
225
- writeFileSync(tokenPath, this.sessionToken, { mode: 0o600 });
226
- chmodSync(tokenPath, 0o600);
227
- log.info({ tokenPath }, 'New session token generated');
228
- }
229
-
230
- // Generate TLS certificate before starting listeners so it's
231
- // available synchronously in the listen callback.
232
184
  let tlsCreds: { cert: string; key: string; fingerprint: string } | null = null;
233
185
  if (isTCPEnabled()) {
234
186
  try {
@@ -253,15 +205,23 @@ export class DaemonServer {
253
205
 
254
206
  this.server.listen(this.socketPath, () => {
255
207
  process.umask(oldUmask);
256
- // Replace the one-shot startup handler with a permanent one
257
208
  this.server!.removeAllListeners('error');
258
209
  this.server!.on('error', (err) => {
259
210
  log.error({ err, socketPath: this.socketPath }, 'Server socket error while running');
260
211
  });
261
212
  chmodSync(this.socketPath, 0o600);
213
+ // Validate the chmod actually took effect — some filesystems
214
+ // (e.g. FAT32 mounts, container overlays) silently ignore chmod.
215
+ const socketStat = statSync(this.socketPath);
216
+ if ((socketStat.mode & 0o077) !== 0) {
217
+ const actual = '0o' + (socketStat.mode & 0o777).toString(8);
218
+ log.error(
219
+ { socketPath: this.socketPath, mode: actual },
220
+ 'IPC socket is accessible by other users (expected 0600) — filesystem may not support Unix permissions',
221
+ );
222
+ }
262
223
  log.info({ socketPath: this.socketPath }, 'Daemon server listening');
263
224
 
264
- // Start TLS-encrypted TCP listener for iOS clients (alongside the Unix socket)
265
225
  if (tlsCreds) {
266
226
  const tcpPort = getTCPPort();
267
227
  const tcpHost = getTCPHost();
@@ -301,22 +261,9 @@ export class DaemonServer {
301
261
  clearInterval(this.blobSweepTimer);
302
262
  this.blobSweepTimer = null;
303
263
  }
304
- this.stopFileWatchers();
305
-
306
- // Session token is intentionally kept on disk so pairing
307
- // (e.g. iOS QR code) survives daemon restarts. To regenerate,
308
- // delete ~/.vellum/session-token and restart the daemon.
264
+ this.configWatcher.stop();
265
+ this.auth.cleanupAll();
309
266
 
310
- for (const timer of this.authTimeouts.values()) {
311
- clearTimeout(timer);
312
- }
313
- this.authTimeouts.clear();
314
- this.authenticatedSockets.clear();
315
-
316
- // 1. Stop accepting new connections first. server.close() prevents new
317
- // connections from arriving, so the cleanup below won't race with
318
- // handleConnection() adding sockets that never get destroyed.
319
- // Its callback fires once all existing connections have ended.
320
267
  const serverClosed = new Promise<void>((resolve) => {
321
268
  if (this.server) {
322
269
  this.server.close(() => {
@@ -341,8 +288,6 @@ export class DaemonServer {
341
288
  }
342
289
  });
343
290
 
344
- // 2. Now dispose sessions and destroy sockets. This lets server.close()
345
- // finish promptly since all connections will be ended.
346
291
  for (const session of this.sessions.values()) {
347
292
  session.dispose();
348
293
  }
@@ -366,247 +311,7 @@ export class DaemonServer {
366
311
  log.info('Daemon server stopped');
367
312
  }
368
313
 
369
- private startFileWatchers(): void {
370
- const rootDir = getRootDir();
371
- const workspaceDir = getWorkspaceDir();
372
- const protectedDir = join(rootDir, 'protected');
373
-
374
- // Watch workspace directory for config + prompt files
375
- const workspaceHandlers: Record<string, () => void> = {
376
- 'config.json': () => {
377
- if (this.suppressConfigReload) return;
378
- try {
379
- this.refreshConfigFromSources();
380
- } catch (err) {
381
- log.error({ err, configPath: join(workspaceDir, 'config.json') }, 'Failed to reload config after file change. Previous config remains active.');
382
- return;
383
- }
384
- },
385
- 'SOUL.md': () => this.evictSessionsForReload(),
386
- 'IDENTITY.md': () => this.evictSessionsForReload(),
387
- 'USER.md': () => this.evictSessionsForReload(),
388
- 'LOOKS.md': () => this.evictSessionsForReload(),
389
- };
390
-
391
- // Watch protected/ for trust rules and secret allowlist
392
- const protectedHandlers: Record<string, () => void> = {
393
- 'trust.json': () => {
394
- clearTrustCache();
395
- },
396
- 'secret-allowlist.json': () => {
397
- resetAllowlist();
398
- try {
399
- const errors = validateAllowlistFile();
400
- if (errors && errors.length > 0) {
401
- for (const e of errors) {
402
- log.warn({ index: e.index, pattern: e.pattern }, `Invalid regex in secret-allowlist.json: ${e.message}`);
403
- }
404
- }
405
- } catch (err) {
406
- log.warn({ err }, 'Failed to validate secret-allowlist.json');
407
- }
408
- },
409
- };
410
-
411
- const watchDir = (dir: string, handlers: Record<string, () => void>, label: string): void => {
412
- try {
413
- const watcher = watch(dir, (_eventType, filename) => {
414
- if (!filename) return;
415
- const file = String(filename);
416
- if (!handlers[file]) return;
417
- this.debounceTimers.schedule(`file:${file}`, () => {
418
- log.info({ file }, 'File changed, reloading');
419
- handlers[file]();
420
- });
421
- });
422
- this.watchers.push(watcher);
423
- log.info({ dir }, `Watching ${label}`);
424
- } catch (err) {
425
- log.warn({ err, dir }, `Failed to watch ${label}. Hot-reload will be unavailable.`);
426
- }
427
- };
428
-
429
- watchDir(workspaceDir, workspaceHandlers, 'workspace directory for config/prompt changes');
430
- if (existsSync(protectedDir)) {
431
- watchDir(protectedDir, protectedHandlers, 'protected directory for trust/allowlist changes');
432
- }
433
-
434
- this.startSkillsWatchers(() => this.evictSessionsForReload());
435
- }
436
-
437
- private configFingerprint(config: ReturnType<typeof getConfig>): string {
438
- return JSON.stringify(config);
439
- }
440
-
441
- /**
442
- * Record the runtime HTTP server port and broadcast it to all
443
- * connected clients so they can enable the share UI immediately.
444
- */
445
- setHttpPort(port: number): void {
446
- this.httpPort = port;
447
- // Clients that connected before the HTTP server started received
448
- // daemon_status with no httpPort. Broadcast the updated port so
449
- // they can enable the share UI without reconnecting.
450
- this.broadcast({
451
- type: 'daemon_status',
452
- httpPort: port,
453
- version: daemonVersion,
454
- });
455
- }
456
-
457
- /**
458
- * Dispose and remove all in-memory sessions unconditionally.
459
- * Called after `sessions clear` wipes the database so that stale
460
- * sessions don't reference deleted conversation rows.
461
- */
462
- clearAllSessions(): number {
463
- const count = this.sessions.size;
464
- const subagentManager = getSubagentManager();
465
- for (const id of this.sessions.keys()) {
466
- this.evictor.remove(id);
467
- subagentManager.abortAllForParent(id);
468
- }
469
- for (const session of this.sessions.values()) {
470
- session.dispose();
471
- }
472
- this.sessions.clear();
473
- this.sessionOptions.clear();
474
- return count;
475
- }
476
-
477
- private evictSessionsForReload(): void {
478
- const subagentManager = getSubagentManager();
479
- for (const [id, session] of this.sessions) {
480
- if (!session.isProcessing()) {
481
- subagentManager.abortAllForParent(id);
482
- session.dispose();
483
- this.sessions.delete(id);
484
- this.evictor.remove(id);
485
- } else {
486
- session.markStale();
487
- }
488
- }
489
- }
490
-
491
- /**
492
- * Reload config from disk + secure storage, and refresh providers only
493
- * when effective config values (including API keys) have changed.
494
- */
495
- private refreshConfigFromSources(): boolean {
496
- invalidateConfigCache();
497
- const config = getConfig();
498
- const fingerprint = this.configFingerprint(config);
499
- if (fingerprint === this.lastConfigFingerprint) {
500
- return false;
501
- }
502
- // Default trust rules depend on config (e.g. skills.load.extraDirs),
503
- // so clear the trust cache so rules are regenerated from fresh config.
504
- clearTrustCache();
505
- clearEmbeddingBackendCache();
506
- const isFirstInit = this.lastConfigFingerprint === '';
507
- initializeProviders(config);
508
- this.lastConfigFingerprint = fingerprint;
509
- if (!isFirstInit) {
510
- this.evictSessionsForReload();
511
- }
512
- return true;
513
- }
514
-
515
- private stopFileWatchers(): void {
516
- this.debounceTimers.cancelAll();
517
- for (const watcher of this.watchers) {
518
- watcher.close();
519
- }
520
- this.watchers = [];
521
- }
522
-
523
- private startSkillsWatchers(evictSessions: () => void): void {
524
- const skillsDir = getWorkspaceSkillsDir();
525
- if (!existsSync(skillsDir)) return;
526
-
527
- const scheduleSkillsReload = (file: string): void => {
528
- this.debounceTimers.schedule(`skills:${file}`, () => {
529
- log.info({ file }, 'Skill file changed, reloading');
530
- evictSessions();
531
- });
532
- };
533
-
534
- try {
535
- const recursiveWatcher = watch(skillsDir, { recursive: true }, (_eventType, filename) => {
536
- scheduleSkillsReload(filename ? String(filename) : '(unknown)');
537
- });
538
- this.watchers.push(recursiveWatcher);
539
- log.info({ dir: skillsDir }, 'Watching skills directory recursively');
540
- return;
541
- } catch (err) {
542
- log.info({ err, dir: skillsDir }, 'Recursive skills watch unavailable; using per-directory watchers');
543
- }
544
-
545
- const childWatchers = new Map<string, FSWatcher>();
546
-
547
- const watchDir = (dirPath: string, onChange: (filename: string) => void): FSWatcher | null => {
548
- try {
549
- const watcher = watch(dirPath, (_eventType, filename) => {
550
- onChange(filename ? String(filename) : '(unknown)');
551
- });
552
- this.watchers.push(watcher);
553
- return watcher;
554
- } catch (err) {
555
- log.warn({ err, dirPath }, 'Failed to watch skills directory');
556
- return null;
557
- }
558
- };
559
-
560
- const removeWatcher = (watcher: FSWatcher): void => {
561
- const idx = this.watchers.indexOf(watcher);
562
- if (idx !== -1) {
563
- this.watchers.splice(idx, 1);
564
- }
565
- };
566
-
567
- const refreshChildWatchers = (): void => {
568
- const nextChildDirs = new Set<string>();
569
-
570
- try {
571
- const entries = readdirSync(skillsDir, { withFileTypes: true });
572
- for (const entry of entries) {
573
- if (!entry.isDirectory()) continue;
574
- const childDir = join(skillsDir, entry.name);
575
- nextChildDirs.add(childDir);
576
-
577
- if (childWatchers.has(childDir)) continue;
578
-
579
- const watcher = watchDir(childDir, (filename) => {
580
- const label = filename === '(unknown)' ? entry.name : `${entry.name}/${filename}`;
581
- scheduleSkillsReload(label);
582
- });
583
- if (watcher) {
584
- childWatchers.set(childDir, watcher);
585
- }
586
- }
587
- } catch (err) {
588
- log.warn({ err, skillsDir }, 'Failed to enumerate skill directories');
589
- return;
590
- }
591
-
592
- for (const [childDir, watcher] of childWatchers.entries()) {
593
- if (nextChildDirs.has(childDir)) continue;
594
- watcher.close();
595
- childWatchers.delete(childDir);
596
- removeWatcher(watcher);
597
- }
598
- };
599
-
600
- const rootWatcher = watchDir(skillsDir, (filename) => {
601
- scheduleSkillsReload(filename);
602
- refreshChildWatchers();
603
- });
604
-
605
- if (!rootWatcher) return;
606
-
607
- refreshChildWatchers();
608
- log.info({ dir: skillsDir }, 'Watching skills directory with non-recursive fallback');
609
- }
314
+ // ── Connection handling ─────────────────────────────────────────────
610
315
 
611
316
  private handleConnection(socket: net.Socket): void {
612
317
  if (this.connectedSockets.size >= DaemonServer.MAX_CONNECTIONS) {
@@ -623,14 +328,8 @@ export class DaemonServer {
623
328
  this.connectedSockets.add(socket);
624
329
  const parser = createMessageParser({ maxLineSize: MAX_LINE_SIZE });
625
330
 
626
- // When the operator explicitly opts into unauthenticated connections
627
- // (VELLUM_DAEMON_NOAUTH=1), auto-authenticate so clients that can't
628
- // read the local session token file (e.g. SSH-forwarded sockets)
629
- // aren't disconnected by the auth timeout. This is intentionally
630
- // gated on a separate flag — a custom socket path alone (via
631
- // VELLUM_DAEMON_SOCKET) no longer bypasses token auth.
632
- if (hasNoAuthOverride()) {
633
- this.authenticatedSockets.add(socket);
331
+ if (this.auth.shouldAutoAuth()) {
332
+ this.auth.markAuthenticated(socket);
634
333
  log.warn('Auto-authenticated client (VELLUM_DAEMON_NOAUTH is set — token auth bypassed)');
635
334
  this.send(socket, { type: 'auth_result', success: true });
636
335
  this.sendInitialSession(socket).catch((err) => {
@@ -638,17 +337,10 @@ export class DaemonServer {
638
337
  });
639
338
  }
640
339
 
641
- // Require authentication before sending session info or accepting
642
- // commands. Clients must send { type: 'auth', token } as their
643
- // first message within AUTH_TIMEOUT_MS.
644
- const authTimer = setTimeout(() => {
645
- if (!this.authenticatedSockets.has(socket)) {
646
- log.warn('Client failed to authenticate within timeout, disconnecting');
647
- this.send(socket, { type: 'error', message: 'Authentication timeout' });
648
- socket.destroy();
649
- }
650
- }, DaemonServer.AUTH_TIMEOUT_MS);
651
- this.authTimeouts.set(socket, authTimer);
340
+ this.auth.startTimeout(socket, () => {
341
+ this.send(socket, { type: 'error', message: 'Authentication timeout' });
342
+ socket.destroy();
343
+ });
652
344
 
653
345
  socket.on('data', (data) => {
654
346
  const chunkReceivedAtMs = Date.now();
@@ -689,40 +381,31 @@ export class DaemonServer {
689
381
  return;
690
382
  }
691
383
 
692
- // Auth gate: first message must be 'auth' with a valid token.
693
- if (!this.authenticatedSockets.has(socket)) {
694
- const pendingTimer = this.authTimeouts.get(socket);
695
- if (pendingTimer) {
696
- clearTimeout(pendingTimer);
697
- this.authTimeouts.delete(socket);
698
- }
384
+ // Auth gate
385
+ if (!this.auth.isAuthenticated(socket)) {
386
+ this.auth.clearTimeout(socket);
699
387
 
700
388
  if (result.message.type === 'auth') {
701
389
  const authMsg = result.message as { type: 'auth'; token: string };
702
- if (authMsg.token === this.sessionToken) {
703
- this.authenticatedSockets.add(socket);
390
+ if (this.auth.authenticate(socket, authMsg.token)) {
704
391
  this.send(socket, { type: 'auth_result', success: true });
705
392
  this.sendInitialSession(socket).catch((err) => {
706
393
  log.error({ err }, 'Failed to send initial session info after auth');
707
394
  });
708
395
  } else {
709
- log.warn('Client provided invalid auth token');
710
396
  this.send(socket, { type: 'auth_result', success: false, message: 'Invalid token' });
711
397
  socket.destroy();
712
398
  }
713
399
  continue;
714
400
  }
715
401
 
716
- // Non-auth message from unauthenticated socket
717
402
  log.warn({ type: result.message.type }, 'Unauthenticated client sent non-auth message, disconnecting');
718
403
  this.send(socket, { type: 'error', message: 'Authentication required' });
719
404
  socket.destroy();
720
405
  return;
721
406
  }
722
407
 
723
- // If an already-authenticated socket sends an auth message (e.g.
724
- // auto-auth'd client that also has a local token), respond with
725
- // auth_result so the client doesn't hang waiting for the handshake.
408
+ // Already-authenticated socket sending auth (e.g. auto-auth'd + local token)
726
409
  if (result.message.type === 'auth') {
727
410
  this.send(socket, { type: 'auth_result', success: true });
728
411
  continue;
@@ -733,12 +416,7 @@ export class DaemonServer {
733
416
  });
734
417
 
735
418
  socket.on('close', () => {
736
- const pendingAuthTimer = this.authTimeouts.get(socket);
737
- if (pendingAuthTimer) {
738
- clearTimeout(pendingAuthTimer);
739
- this.authTimeouts.delete(socket);
740
- }
741
- this.authenticatedSockets.delete(socket);
419
+ this.auth.cleanupSocket(socket);
742
420
  this.connectedSockets.delete(socket);
743
421
  this.socketSandboxOverride.delete(socket);
744
422
  const sessionId = this.socketToSession.get(socket);
@@ -770,63 +448,47 @@ export class DaemonServer {
770
448
  });
771
449
  }
772
450
 
773
- /** Low-level wire write — does not publish to the assistant-events hub. */
774
- private writeToSocket(socket: net.Socket, msg: ServerMessage): void {
775
- if (!socket.destroyed && socket.writable) {
776
- socket.write(serialize(msg));
777
- }
778
- }
451
+ // ── Session management ──────────────────────────────────────────────
779
452
 
780
- private send(socket: net.Socket, msg: ServerMessage): void {
781
- this.writeToSocket(socket, msg);
782
- // Best-effort sessionId: prefer message field, fall back to socket binding.
783
- const msgRecord = msg as unknown as Record<string, unknown>;
784
- const sessionId =
785
- ('sessionId' in msg && typeof msgRecord.sessionId === 'string'
786
- ? msgRecord.sessionId as string
787
- : undefined) ?? this.socketToSession.get(socket);
788
- this.publishAssistantEvent(msg, sessionId, this.assistantId);
453
+ setHttpPort(port: number): void {
454
+ this.httpPort = port;
455
+ this.broadcast({
456
+ type: 'daemon_status',
457
+ httpPort: port,
458
+ version: daemonVersion,
459
+ });
789
460
  }
790
461
 
791
- broadcast(msg: ServerMessage, excludeSocket?: net.Socket): void {
792
- for (const socket of this.authenticatedSockets) {
793
- if (socket === excludeSocket) continue;
794
- this.writeToSocket(socket, msg); // bypass per-socket hub publish
462
+ clearAllSessions(): number {
463
+ const count = this.sessions.size;
464
+ const subagentManager = getSubagentManager();
465
+ for (const id of this.sessions.keys()) {
466
+ this.evictor.remove(id);
467
+ subagentManager.abortAllForParent(id);
468
+ }
469
+ for (const session of this.sessions.values()) {
470
+ session.dispose();
795
471
  }
796
- // Publish once for the broadcast. Prefer message-level sessionId; fall back
797
- // to excludeSocket's session binding so session-scoped events (e.g.
798
- // assistant_text_delta emitted without a sessionId field) are correctly tagged.
799
- const msgRecord = msg as unknown as Record<string, unknown>;
800
- const sessionId =
801
- ('sessionId' in msg && typeof msgRecord.sessionId === 'string'
802
- ? msgRecord.sessionId as string
803
- : undefined) ?? (excludeSocket ? this.socketToSession.get(excludeSocket) : undefined);
804
- this.publishAssistantEvent(msg, sessionId, this.assistantId);
472
+ this.sessions.clear();
473
+ this.sessionOptions.clear();
474
+ return count;
805
475
  }
806
476
 
807
- /**
808
- * Publish `msg` as an `AssistantEvent` to the process-level hub.
809
- * Publishes are serialized via a promise chain so that subscribers always
810
- * observe events in the order they were sent (e.g. text deltas before
811
- * message_complete), even when subscriber callbacks are async.
812
- */
813
- private _hubChain: Promise<void> = Promise.resolve();
814
-
815
- private publishAssistantEvent(msg: ServerMessage, sessionId?: string, assistantId?: string): void {
816
- const event = buildAssistantEvent(assistantId ?? this.assistantId, msg, sessionId);
817
- this._hubChain = this._hubChain
818
- .then(() => assistantEventHub.publish(event))
819
- .catch((err: unknown) => {
820
- log.warn({ err }, 'assistant-events hub subscriber threw during IPC send');
821
- });
477
+ private evictSessionsForReload(): void {
478
+ const subagentManager = getSubagentManager();
479
+ for (const [id, session] of this.sessions) {
480
+ if (!session.isProcessing()) {
481
+ subagentManager.abortAllForParent(id);
482
+ session.dispose();
483
+ this.sessions.delete(id);
484
+ this.evictor.remove(id);
485
+ } else {
486
+ session.markStale();
487
+ }
488
+ }
822
489
  }
823
490
 
824
491
  private async sendInitialSession(socket: net.Socket): Promise<void> {
825
- // Only send session info for an existing conversation. Don't create one —
826
- // the client will create its own session via session_create when the user
827
- // sends a message. Creating one here would produce an orphaned session
828
- // that the macOS client rejects (correlation ID mismatch) but that still
829
- // appears in session_list on subsequent launches.
830
492
  const conversation = conversationStore.getLatestConversation();
831
493
  if (!conversation) {
832
494
  this.send(socket, {
@@ -837,8 +499,6 @@ export class DaemonServer {
837
499
  return;
838
500
  }
839
501
 
840
- // Warm session state for commands like undo/usage after reconnect without
841
- // rebinding the active IPC output client to this passive socket.
842
502
  await this.getOrCreateSession(conversation.id, undefined, false);
843
503
 
844
504
  this.send(socket, {
@@ -869,12 +529,9 @@ export class DaemonServer {
869
529
  if (!rebindClient || !socket) return;
870
530
  target.updateClient(sendToClient);
871
531
  target.setSandboxOverride(this.socketSandboxOverride.get(socket));
872
- // Update the sender for any active child subagents so they route
873
- // through the new socket instead of the stale one from spawn time.
874
532
  getSubagentManager().updateParentSender(conversationId, sendToClient);
875
533
  };
876
534
 
877
- // Persist session options so they survive eviction/recreation.
878
535
  if (options && Object.values(options).some(v => v !== undefined)) {
879
536
  this.sessionOptions.set(conversationId, {
880
537
  ...this.sessionOptions.get(conversationId),
@@ -883,16 +540,11 @@ export class DaemonServer {
883
540
  }
884
541
 
885
542
  if (!session || (session.isStale() && !session.isProcessing())) {
886
- // Dispose the outgoing stale session before replacing it.
887
543
  if (session) {
888
544
  getSubagentManager().abortAllForParent(conversationId);
889
545
  session.dispose();
890
546
  }
891
547
 
892
- // Check if another caller is already creating this session.
893
- // Without this guard, two concurrent getOrCreateSession calls for the
894
- // same conversationId would both pass the null/stale check, both create
895
- // a Session + loadFromDb(), and the second set() would orphan the first.
896
548
  const pending = this.sessionCreating.get(conversationId);
897
549
  if (pending) {
898
550
  session = await pending;
@@ -900,7 +552,6 @@ export class DaemonServer {
900
552
  return session;
901
553
  }
902
554
 
903
- // Recover stored options for this conversation (survives eviction).
904
555
  const storedOptions = this.sessionOptions.get(conversationId);
905
556
 
906
557
  const createPromise = (async () => {
@@ -926,9 +577,6 @@ export class DaemonServer {
926
577
  (msg) => this.broadcast(msg, socket),
927
578
  memoryPolicy,
928
579
  );
929
- // When created without a socket (HTTP path), mark the session
930
- // so interactive prompts (e.g. host attachment reads) can fail
931
- // fast instead of waiting for a timeout with no client to respond.
932
580
  if (!socket) {
933
581
  newSession.updateClient(sendToClient, true);
934
582
  }
@@ -949,7 +597,6 @@ export class DaemonServer {
949
597
  }
950
598
  this.evictor.touch(conversationId);
951
599
  } else {
952
- // Rebind to the new socket so IPC goes to the current client.
953
600
  maybeBindClient(session);
954
601
  this.applyTransportMetadata(session, options);
955
602
  this.evictor.touch(conversationId);
@@ -957,6 +604,8 @@ export class DaemonServer {
957
604
  return session;
958
605
  }
959
606
 
607
+ // ── Message dispatch ────────────────────────────────────────────────
608
+
960
609
  private handlerContext(): HandlerContext {
961
610
  return {
962
611
  sessions: this.sessions,
@@ -966,12 +615,11 @@ export class DaemonServer {
966
615
  cuObservationParseSequence: this.cuObservationParseSequence,
967
616
  socketSandboxOverride: this.socketSandboxOverride,
968
617
  sharedRequestTimestamps: this.sharedRequestTimestamps,
969
- debounceTimers: this.debounceTimers,
970
- suppressConfigReload: this.suppressConfigReload,
971
- setSuppressConfigReload: (value: boolean) => { this.suppressConfigReload = value; },
618
+ debounceTimers: this.configWatcher.timers,
619
+ suppressConfigReload: this.configWatcher.suppressConfigReload,
620
+ setSuppressConfigReload: (value: boolean) => { this.configWatcher.suppressConfigReload = value; },
972
621
  updateConfigFingerprint: () => {
973
- this.lastConfigFingerprint = this.configFingerprint(getConfig());
974
- this.lastConfigRefreshTime = Date.now();
622
+ this.configWatcher.updateFingerprint();
975
623
  },
976
624
  send: (socket, msg) => this.send(socket, msg),
977
625
  broadcast: (msg) => this.broadcast(msg),
@@ -982,13 +630,14 @@ export class DaemonServer {
982
630
  };
983
631
  }
984
632
 
985
- private dispatchMessage(msg: ClientMessage, socket: net.Socket): void {
633
+ private dispatchMessage(msg: Parameters<typeof handleMessage>[0], socket: net.Socket): void {
986
634
  if (msg.type !== 'ping') {
987
635
  const now = Date.now();
988
- if (now - this.lastConfigRefreshTime >= DaemonServer.CONFIG_REFRESH_INTERVAL_MS) {
636
+ if (now - this.configWatcher.lastConfigRefreshTime >= ConfigWatcher.REFRESH_INTERVAL_MS) {
989
637
  try {
990
- this.refreshConfigFromSources();
991
- this.lastConfigRefreshTime = now;
638
+ const changed = this.configWatcher.refreshConfigFromSources();
639
+ if (changed) this.evictSessionsForReload();
640
+ this.configWatcher.lastConfigRefreshTime = now;
992
641
  } catch (err) {
993
642
  log.warn({ err }, 'Failed to refresh config from secure sources before handling IPC message');
994
643
  }
@@ -997,12 +646,8 @@ export class DaemonServer {
997
646
  handleMessage(msg, socket, this.handlerContext());
998
647
  }
999
648
 
1000
- /**
1001
- * Persist a user message and start the agent loop in the background.
1002
- * Returns the messageId immediately without waiting for the agent loop
1003
- * to complete. Used by the HTTP sendMessage endpoint so the response
1004
- * is not blocked for the duration of the agent loop.
1005
- */
649
+ // ── HTTP message processing ─────────────────────────────────────────
650
+
1006
651
  async persistAndProcessMessage(
1007
652
  conversationId: string,
1008
653
  content: string,
@@ -1010,7 +655,6 @@ export class DaemonServer {
1010
655
  options?: SessionCreateOptions,
1011
656
  sourceChannel?: string,
1012
657
  ): Promise<{ messageId: string }> {
1013
- // Block inbound content that contains secrets — mirrors the IPC check in sessions.ts
1014
658
  const ingressCheck = checkIngressForSecrets(content);
1015
659
  if (ingressCheck.blocked) {
1016
660
  throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
@@ -1018,15 +662,14 @@ export class DaemonServer {
1018
662
 
1019
663
  const session = await this.getOrCreateSession(conversationId, undefined, true, options);
1020
664
 
1021
- // Reject concurrent requests upfront. The HTTP path should never use
1022
- // the message queue — it returns 409 to the caller instead.
1023
665
  if (session.isProcessing()) {
1024
666
  throw new Error('Session is already processing a message');
1025
667
  }
1026
668
 
669
+ session.setAssistantId(options?.assistantId ?? 'self');
670
+ session.setGuardianContext(options?.guardianContext ?? null);
1027
671
  session.setChannelCapabilities(resolveChannelCapabilities(sourceChannel));
1028
672
 
1029
- // Resolve attachment IDs to full attachment data for the session
1030
673
  const attachments = attachmentIds
1031
674
  ? attachmentsStore.getAttachmentsByIds(attachmentIds).map((a) => ({
1032
675
  id: a.id,
@@ -1036,15 +679,9 @@ export class DaemonServer {
1036
679
  }))
1037
680
  : [];
1038
681
 
1039
- // Persist the user message immediately after the isProcessing() guard.
1040
- // This synchronously sets session.processing = true, closing the race
1041
- // window that previously existed between the guard and the async bridge
1042
- // check (two concurrent requests could both pass isProcessing() and
1043
- // race into message handling).
1044
682
  const requestId = crypto.randomUUID();
1045
683
  const messageId = session.persistUserMessage(content, attachments, requestId);
1046
684
 
1047
- // Now that the processing lock is held, check the call-answer bridge.
1048
685
  let bridgeHandled = false;
1049
686
  try {
1050
687
  const bridgeResult = await tryRouteCallMessage(conversationId, content, messageId);
@@ -1054,16 +691,12 @@ export class DaemonServer {
1054
691
  }
1055
692
 
1056
693
  if (bridgeHandled) {
1057
- // The message was consumed by the call system. Release the session.
1058
694
  resetSessionProcessingState(session);
1059
- // Drain any queued messages that arrived while processing was true.
1060
695
  session.drainQueue('loop_complete');
1061
696
  log.info({ conversationId, messageId }, 'User message consumed by call bridge, skipping agent loop');
1062
697
  return { messageId };
1063
698
  }
1064
699
 
1065
- // Fire-and-forget the agent loop. Errors are logged but do not
1066
- // affect the HTTP response (the client polls GET /messages).
1067
700
  session.runAgentLoop(content, messageId, () => {}).catch((err) => {
1068
701
  log.error({ err, conversationId }, 'Background agent loop failed');
1069
702
  });
@@ -1071,11 +704,6 @@ export class DaemonServer {
1071
704
  return { messageId };
1072
705
  }
1073
706
 
1074
- /**
1075
- * Process a message from the HTTP runtime API (blocking).
1076
- * Gets or creates a session and runs the full agent loop before returning.
1077
- * Used by the channel inbound endpoint which needs the assistant reply.
1078
- */
1079
707
  async processMessage(
1080
708
  conversationId: string,
1081
709
  content: string,
@@ -1083,7 +711,6 @@ export class DaemonServer {
1083
711
  options?: SessionCreateOptions,
1084
712
  sourceChannel?: string,
1085
713
  ): Promise<{ messageId: string }> {
1086
- // Block inbound content that contains secrets — mirrors the IPC check in sessions.ts
1087
714
  const ingressCheck = checkIngressForSecrets(content);
1088
715
  if (ingressCheck.blocked) {
1089
716
  throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
@@ -1095,9 +722,10 @@ export class DaemonServer {
1095
722
  throw new Error('Session is already processing a message');
1096
723
  }
1097
724
 
725
+ session.setAssistantId(options?.assistantId ?? 'self');
726
+ session.setGuardianContext(options?.guardianContext ?? null);
1098
727
  session.setChannelCapabilities(resolveChannelCapabilities(sourceChannel));
1099
728
 
1100
- // Resolve attachment IDs to full attachment data for the session
1101
729
  const attachments = attachmentIds
1102
730
  ? attachmentsStore.getAttachmentsByIds(attachmentIds).map((a) => ({
1103
731
  id: a.id,
@@ -1107,12 +735,8 @@ export class DaemonServer {
1107
735
  }))
1108
736
  : [];
1109
737
 
1110
- // Resolve slash commands before persistence (synchronous — no race window).
1111
738
  const slashResult = resolveSlash(content);
1112
739
 
1113
- // Unknown slash command — persist the exchange (user + assistant) and
1114
- // return immediately. This path doesn't set processing=true since no
1115
- // agent loop runs, so there is no race concern.
1116
740
  if (slashResult.kind === 'unknown') {
1117
741
  const userMsg = createUserMessage(content, attachments);
1118
742
  const persisted = conversationStore.addMessage(
@@ -1134,26 +758,19 @@ export class DaemonServer {
1134
758
 
1135
759
  const resolvedContent = slashResult.content;
1136
760
 
1137
- // Preactivate skill tools when slash resolution identifies a known skill
1138
761
  if (slashResult.kind === 'rewritten') {
1139
762
  (session as unknown as { preactivatedSkillIds?: string[] }).preactivatedSkillIds = [slashResult.skillId];
1140
763
  }
1141
764
 
1142
- // Persist the user message immediately after the isProcessing() guard.
1143
- // This synchronously sets session.processing = true, closing the race
1144
- // window that previously existed between the guard and the async bridge
1145
- // check.
1146
765
  const requestId = crypto.randomUUID();
1147
766
  let messageId: string;
1148
767
  try {
1149
768
  messageId = session.persistUserMessage(resolvedContent, attachments, requestId);
1150
769
  } catch (err) {
1151
- // runAgentLoop never ran, so its finally block won't clear this
1152
770
  (session as unknown as { preactivatedSkillIds?: string[] }).preactivatedSkillIds = undefined;
1153
771
  throw err;
1154
772
  }
1155
773
 
1156
- // Now that the processing lock is held, check the call-answer bridge.
1157
774
  let bridgeHandled = false;
1158
775
  try {
1159
776
  const bridgeResult = await tryRouteCallMessage(conversationId, resolvedContent, messageId);
@@ -1163,28 +780,22 @@ export class DaemonServer {
1163
780
  }
1164
781
 
1165
782
  if (bridgeHandled) {
1166
- // The message was consumed by the call system. Release the session.
1167
783
  (session as unknown as { preactivatedSkillIds?: string[] }).preactivatedSkillIds = undefined;
1168
784
  resetSessionProcessingState(session);
1169
- // Drain any queued messages that arrived while processing was true.
1170
785
  session.drainQueue('loop_complete');
1171
786
  log.info({ conversationId, messageId }, 'User message consumed by call bridge, skipping agent loop');
1172
787
  return { messageId };
1173
788
  }
1174
789
 
1175
- // Run the agent loop (blocking — the channel inbound endpoint needs the reply).
1176
790
  await session.runAgentLoop(resolvedContent, messageId, () => {});
1177
791
 
1178
792
  return { messageId };
1179
793
  }
1180
794
 
1181
- /**
1182
- * Create a RunOrchestrator wired to this server's session management.
1183
- */
1184
795
  createRunOrchestrator(): RunOrchestrator {
1185
796
  return new RunOrchestrator({
1186
- getOrCreateSession: (conversationId) =>
1187
- this.getOrCreateSession(conversationId),
797
+ getOrCreateSession: (conversationId, transport) =>
798
+ this.getOrCreateSession(conversationId, undefined, true, transport ? { transport } : undefined),
1188
799
  resolveAttachments: (attachmentIds) =>
1189
800
  attachmentsStore.getAttachmentsByIds(attachmentIds).map((a) => ({
1190
801
  id: a.id,
@@ -1199,10 +810,6 @@ export class DaemonServer {
1199
810
 
1200
811
  }
1201
812
 
1202
- /**
1203
- * Reset the processing state set by `persistUserMessage` when the agent loop
1204
- * is intentionally skipped (e.g. call-answer bridge consumed the message).
1205
- */
1206
813
  function resetSessionProcessingState(session: Session): void {
1207
814
  const s = session as unknown as {
1208
815
  processing: boolean;