@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.
- package/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- package/src/util/retry.ts +4 -4
package/src/daemon/server.ts
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import * as net from 'node:net';
|
|
2
2
|
import * as tls from 'node:tls';
|
|
3
|
-
import {
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
|
693
|
-
if (!this.
|
|
694
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
this.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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 >=
|
|
636
|
+
if (now - this.configWatcher.lastConfigRefreshTime >= ConfigWatcher.REFRESH_INTERVAL_MS) {
|
|
989
637
|
try {
|
|
990
|
-
this.refreshConfigFromSources();
|
|
991
|
-
this.
|
|
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
|
-
|
|
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;
|