vellum 0.2.13 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
package/src/daemon/server.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import * as net from 'node:net';
|
|
2
|
+
import * as tls from 'node:tls';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { existsSync, chmodSync, readFileSync, writeFileSync,
|
|
4
|
+
import { existsSync, chmodSync, readFileSync, writeFileSync, readdirSync, watch, type FSWatcher } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
|
-
import { getSocketPath, getSessionTokenPath, getRootDir, getWorkspaceDir, getWorkspaceSkillsDir, getSandboxWorkingDir, removeSocketFile, getTCPPort, getTCPHost, isTCPEnabled } from '../util/platform.js';
|
|
6
|
+
import { getSocketPath, getSessionTokenPath, getRootDir, getWorkspaceDir, getWorkspaceSkillsDir, getSandboxWorkingDir, removeSocketFile, getTCPPort, getTCPHost, isTCPEnabled, isIOSPairingEnabled } from '../util/platform.js';
|
|
7
|
+
import { ensureTlsCert } from './tls-certs.js';
|
|
8
|
+
import { getLocalIPv4 } from '../util/network-info.js';
|
|
6
9
|
import { hasNoAuthOverride } from './connection-policy.js';
|
|
7
10
|
import { getLogger } from '../util/logger.js';
|
|
8
11
|
import { getFailoverProvider, initializeProviders } from '../providers/registry.js';
|
|
@@ -36,10 +39,11 @@ import { assistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
|
36
39
|
import { buildAssistantEvent } from '../runtime/assistant-event.js';
|
|
37
40
|
import { SessionEvictor } from './session-evictor.js';
|
|
38
41
|
import { getSubagentManager } from '../subagent/index.js';
|
|
39
|
-
import {
|
|
42
|
+
import { tryRouteCallMessage } from '../calls/call-bridge.js';
|
|
40
43
|
import { resolveSlash } from './session-slash.js';
|
|
41
44
|
import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
|
|
42
45
|
import { registerDaemonCallbacks } from '../work-items/work-item-runner.js';
|
|
46
|
+
import { DebouncerMap } from '../util/debounce.js';
|
|
43
47
|
|
|
44
48
|
const log = getLogger('server');
|
|
45
49
|
|
|
@@ -57,7 +61,7 @@ const daemonVersion = readPackageVersion();
|
|
|
57
61
|
|
|
58
62
|
export class DaemonServer {
|
|
59
63
|
private server: net.Server | null = null;
|
|
60
|
-
private tcpServer:
|
|
64
|
+
private tcpServer: tls.Server | null = null;
|
|
61
65
|
private sessions = new Map<string, Session>();
|
|
62
66
|
private socketToSession = new Map<net.Socket, string>();
|
|
63
67
|
private cuSessions = new Map<string, ComputerUseSession>();
|
|
@@ -77,8 +81,11 @@ export class DaemonServer {
|
|
|
77
81
|
private socketPath: string;
|
|
78
82
|
private httpPort: number | undefined;
|
|
79
83
|
private watchers: FSWatcher[] = [];
|
|
80
|
-
private debounceTimers = new
|
|
81
|
-
|
|
84
|
+
private debounceTimers = new DebouncerMap({
|
|
85
|
+
defaultDelayMs: 200,
|
|
86
|
+
maxEntries: 1000,
|
|
87
|
+
protectedKeyPrefix: '__',
|
|
88
|
+
});
|
|
82
89
|
private suppressConfigReload = false;
|
|
83
90
|
private lastConfigFingerprint = '';
|
|
84
91
|
private lastConfigRefreshTime = 0;
|
|
@@ -200,15 +207,36 @@ export class DaemonServer {
|
|
|
200
207
|
|
|
201
208
|
this.startFileWatchers();
|
|
202
209
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// can connect.
|
|
207
|
-
this.sessionToken = randomBytes(32).toString('hex');
|
|
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.
|
|
208
213
|
const tokenPath = getSessionTokenPath();
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 */ }
|
|
219
|
+
|
|
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
|
+
let tlsCreds: { cert: string; key: string; fingerprint: string } | null = null;
|
|
233
|
+
if (isTCPEnabled()) {
|
|
234
|
+
try {
|
|
235
|
+
tlsCreds = await ensureTlsCert();
|
|
236
|
+
} catch (err) {
|
|
237
|
+
log.error({ err }, 'Failed to generate TLS certificate — TCP listener will not start');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
212
240
|
|
|
213
241
|
return new Promise((resolve, reject) => {
|
|
214
242
|
this.server = net.createServer((socket) => {
|
|
@@ -233,17 +261,31 @@ export class DaemonServer {
|
|
|
233
261
|
chmodSync(this.socketPath, 0o600);
|
|
234
262
|
log.info({ socketPath: this.socketPath }, 'Daemon server listening');
|
|
235
263
|
|
|
236
|
-
// Start TCP listener for iOS clients (alongside the Unix socket)
|
|
237
|
-
if (
|
|
264
|
+
// Start TLS-encrypted TCP listener for iOS clients (alongside the Unix socket)
|
|
265
|
+
if (tlsCreds) {
|
|
238
266
|
const tcpPort = getTCPPort();
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
267
|
+
const tcpHost = getTCPHost();
|
|
268
|
+
this.tcpServer = tls.createServer(
|
|
269
|
+
{ cert: tlsCreds.cert, key: tlsCreds.key },
|
|
270
|
+
(socket) => { this.handleConnection(socket); },
|
|
271
|
+
);
|
|
242
272
|
this.tcpServer.on('error', (err) => {
|
|
243
|
-
log.error({ err, tcpPort }, 'TCP server error');
|
|
273
|
+
log.error({ err, tcpPort }, 'TLS TCP server error');
|
|
244
274
|
});
|
|
245
|
-
|
|
246
|
-
|
|
275
|
+
const fingerprint = tlsCreds.fingerprint;
|
|
276
|
+
this.tcpServer.listen(tcpPort, tcpHost, () => {
|
|
277
|
+
const localIP = getLocalIPv4();
|
|
278
|
+
log.info(
|
|
279
|
+
{ tcpPort, tcpHost, fingerprint, localIP, iosPairing: isIOSPairingEnabled() },
|
|
280
|
+
'TLS TCP listener started',
|
|
281
|
+
);
|
|
282
|
+
if (isIOSPairingEnabled() && localIP) {
|
|
283
|
+
log.warn(
|
|
284
|
+
{ localIP, tcpPort },
|
|
285
|
+
'iOS pairing enabled — daemon is reachable on the local network at %s:%d',
|
|
286
|
+
localIP, tcpPort,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
247
289
|
});
|
|
248
290
|
}
|
|
249
291
|
|
|
@@ -261,10 +303,9 @@ export class DaemonServer {
|
|
|
261
303
|
}
|
|
262
304
|
this.stopFileWatchers();
|
|
263
305
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
} catch { /* ignore if already gone */ }
|
|
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.
|
|
268
309
|
|
|
269
310
|
for (const timer of this.authTimeouts.values()) {
|
|
270
311
|
clearTimeout(timer);
|
|
@@ -373,16 +414,10 @@ export class DaemonServer {
|
|
|
373
414
|
if (!filename) return;
|
|
374
415
|
const file = String(filename);
|
|
375
416
|
if (!handlers[file]) return;
|
|
376
|
-
|
|
377
|
-
const existing = this.debounceTimers.get(key);
|
|
378
|
-
if (existing) clearTimeout(existing);
|
|
379
|
-
const timer = setTimeout(() => {
|
|
380
|
-
this.debounceTimers.delete(key);
|
|
417
|
+
this.debounceTimers.schedule(`file:${file}`, () => {
|
|
381
418
|
log.info({ file }, 'File changed, reloading');
|
|
382
419
|
handlers[file]();
|
|
383
|
-
}
|
|
384
|
-
this.debounceTimers.set(key, timer);
|
|
385
|
-
this.enforceDebounceLimit();
|
|
420
|
+
});
|
|
386
421
|
});
|
|
387
422
|
this.watchers.push(watcher);
|
|
388
423
|
log.info({ dir }, `Watching ${label}`);
|
|
@@ -478,51 +513,22 @@ export class DaemonServer {
|
|
|
478
513
|
}
|
|
479
514
|
|
|
480
515
|
private stopFileWatchers(): void {
|
|
481
|
-
|
|
482
|
-
clearTimeout(timer);
|
|
483
|
-
}
|
|
484
|
-
this.debounceTimers.clear();
|
|
516
|
+
this.debounceTimers.cancelAll();
|
|
485
517
|
for (const watcher of this.watchers) {
|
|
486
518
|
watcher.close();
|
|
487
519
|
}
|
|
488
520
|
this.watchers = [];
|
|
489
521
|
}
|
|
490
522
|
|
|
491
|
-
/**
|
|
492
|
-
* Evict the oldest file-watcher debounce entries when the map exceeds the safety cap.
|
|
493
|
-
* Map iteration order follows insertion order, so the first keys are oldest.
|
|
494
|
-
* Protects system timers (keys starting with '__') from eviction, so critical
|
|
495
|
-
* timers like '__suppress_reset__' are never cleared during bursts of file events.
|
|
496
|
-
*/
|
|
497
|
-
private enforceDebounceLimit(): void {
|
|
498
|
-
if (this.debounceTimers.size <= DaemonServer.MAX_DEBOUNCE_ENTRIES) return;
|
|
499
|
-
const excess = this.debounceTimers.size - DaemonServer.MAX_DEBOUNCE_ENTRIES;
|
|
500
|
-
let removed = 0;
|
|
501
|
-
for (const [key, timer] of this.debounceTimers) {
|
|
502
|
-
if (removed >= excess) break;
|
|
503
|
-
// Skip system timers (those with keys starting with '__')
|
|
504
|
-
if (key.startsWith('__')) continue;
|
|
505
|
-
clearTimeout(timer);
|
|
506
|
-
this.debounceTimers.delete(key);
|
|
507
|
-
removed++;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
523
|
private startSkillsWatchers(evictSessions: () => void): void {
|
|
512
524
|
const skillsDir = getWorkspaceSkillsDir();
|
|
513
525
|
if (!existsSync(skillsDir)) return;
|
|
514
526
|
|
|
515
527
|
const scheduleSkillsReload = (file: string): void => {
|
|
516
|
-
|
|
517
|
-
const existing = this.debounceTimers.get(key);
|
|
518
|
-
if (existing) clearTimeout(existing);
|
|
519
|
-
const timer = setTimeout(() => {
|
|
520
|
-
this.debounceTimers.delete(key);
|
|
528
|
+
this.debounceTimers.schedule(`skills:${file}`, () => {
|
|
521
529
|
log.info({ file }, 'Skill file changed, reloading');
|
|
522
530
|
evictSessions();
|
|
523
|
-
}
|
|
524
|
-
this.debounceTimers.set(key, timer);
|
|
525
|
-
this.enforceDebounceLimit();
|
|
531
|
+
});
|
|
526
532
|
};
|
|
527
533
|
|
|
528
534
|
try {
|
|
@@ -1041,10 +1047,10 @@ export class DaemonServer {
|
|
|
1041
1047
|
// Now that the processing lock is held, check the call-answer bridge.
|
|
1042
1048
|
let bridgeHandled = false;
|
|
1043
1049
|
try {
|
|
1044
|
-
const bridgeResult = await
|
|
1050
|
+
const bridgeResult = await tryRouteCallMessage(conversationId, content, messageId);
|
|
1045
1051
|
bridgeHandled = bridgeResult.handled;
|
|
1046
1052
|
} catch (err) {
|
|
1047
|
-
log.warn({ err, conversationId }, 'Call
|
|
1053
|
+
log.warn({ err, conversationId }, 'Call bridge check failed (non-fatal), proceeding with agent loop');
|
|
1048
1054
|
}
|
|
1049
1055
|
|
|
1050
1056
|
if (bridgeHandled) {
|
|
@@ -1052,7 +1058,7 @@ export class DaemonServer {
|
|
|
1052
1058
|
resetSessionProcessingState(session);
|
|
1053
1059
|
// Drain any queued messages that arrived while processing was true.
|
|
1054
1060
|
session.drainQueue('loop_complete');
|
|
1055
|
-
log.info({ conversationId, messageId }, 'User message consumed by call
|
|
1061
|
+
log.info({ conversationId, messageId }, 'User message consumed by call bridge, skipping agent loop');
|
|
1056
1062
|
return { messageId };
|
|
1057
1063
|
}
|
|
1058
1064
|
|
|
@@ -1150,10 +1156,10 @@ export class DaemonServer {
|
|
|
1150
1156
|
// Now that the processing lock is held, check the call-answer bridge.
|
|
1151
1157
|
let bridgeHandled = false;
|
|
1152
1158
|
try {
|
|
1153
|
-
const bridgeResult = await
|
|
1159
|
+
const bridgeResult = await tryRouteCallMessage(conversationId, resolvedContent, messageId);
|
|
1154
1160
|
bridgeHandled = bridgeResult.handled;
|
|
1155
1161
|
} catch (err) {
|
|
1156
|
-
log.warn({ err, conversationId }, 'Call
|
|
1162
|
+
log.warn({ err, conversationId }, 'Call bridge check failed (non-fatal), proceeding with agent loop');
|
|
1157
1163
|
}
|
|
1158
1164
|
|
|
1159
1165
|
if (bridgeHandled) {
|
|
@@ -1162,7 +1168,7 @@ export class DaemonServer {
|
|
|
1162
1168
|
resetSessionProcessingState(session);
|
|
1163
1169
|
// Drain any queued messages that arrived while processing was true.
|
|
1164
1170
|
session.drainQueue('loop_complete');
|
|
1165
|
-
log.info({ conversationId, messageId }, 'User message consumed by call
|
|
1171
|
+
log.info({ conversationId, messageId }, 'User message consumed by call bridge, skipping agent loop');
|
|
1166
1172
|
return { messageId };
|
|
1167
1173
|
}
|
|
1168
1174
|
|
|
@@ -51,7 +51,7 @@ export async function approveHostAttachmentRead(
|
|
|
51
51
|
toolName,
|
|
52
52
|
input,
|
|
53
53
|
await classifyRisk(toolName, input, workingDir),
|
|
54
|
-
generateAllowlistOptions(toolName, input),
|
|
54
|
+
await generateAllowlistOptions(toolName, input),
|
|
55
55
|
generateScopeOptions(workingDir, toolName),
|
|
56
56
|
undefined,
|
|
57
57
|
undefined,
|
|
@@ -9,14 +9,17 @@ import {
|
|
|
9
9
|
applyConflictResolution,
|
|
10
10
|
listPendingConflictDetails,
|
|
11
11
|
markConflictAsked,
|
|
12
|
+
resolveConflict,
|
|
12
13
|
} from '../memory/conflict-store.js';
|
|
13
14
|
import type { PendingConflictDetail } from '../memory/conflict-store.js';
|
|
14
15
|
import { resolveConflictClarification } from '../memory/clarification-resolver.js';
|
|
15
16
|
import {
|
|
17
|
+
areStatementsCoherent,
|
|
16
18
|
computeConflictRelevance,
|
|
17
19
|
looksLikeClarificationReply,
|
|
18
20
|
shouldAttemptConflictResolution,
|
|
19
21
|
} from '../memory/conflict-intent.js';
|
|
22
|
+
import { isConflictKindPairEligible, isStatementConflictEligible } from '../memory/conflict-policy.js';
|
|
20
23
|
|
|
21
24
|
export interface ConflictGateDecision {
|
|
22
25
|
question: string;
|
|
@@ -35,6 +38,8 @@ export class ConflictGate {
|
|
|
35
38
|
relevanceThreshold: number;
|
|
36
39
|
reaskCooldownTurns: number;
|
|
37
40
|
resolverLlmTimeoutMs: number;
|
|
41
|
+
askOnIrrelevantTurns: boolean;
|
|
42
|
+
conflictableKinds: readonly string[];
|
|
38
43
|
},
|
|
39
44
|
scopeId = 'default',
|
|
40
45
|
): Promise<ConflictGateDecision | null> {
|
|
@@ -44,8 +49,23 @@ export class ConflictGate {
|
|
|
44
49
|
const threshold = conflictConfig.relevanceThreshold;
|
|
45
50
|
const cooldownTurns = Math.max(1, conflictConfig.reaskCooldownTurns);
|
|
46
51
|
const pendingBeforeResolve = listPendingConflictDetails(scopeId, 50);
|
|
52
|
+
|
|
53
|
+
// Dismiss non-actionable conflicts (kind/statement policy or incoherent pair)
|
|
54
|
+
const dismissedIds = new Set<string>();
|
|
55
|
+
for (const conflict of pendingBeforeResolve) {
|
|
56
|
+
const dismissReason = this.getDismissReason(conflict, conflictConfig.conflictableKinds);
|
|
57
|
+
if (dismissReason) {
|
|
58
|
+
resolveConflict(conflict.id, {
|
|
59
|
+
status: 'dismissed',
|
|
60
|
+
resolutionNote: dismissReason,
|
|
61
|
+
});
|
|
62
|
+
dismissedIds.add(conflict.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const actionablePending = pendingBeforeResolve.filter((c) => !dismissedIds.has(c.id));
|
|
47
67
|
const clarificationReply = looksLikeClarificationReply(userMessage);
|
|
48
|
-
const candidatesBeforeResolve =
|
|
68
|
+
const candidatesBeforeResolve = actionablePending.filter((conflict) => {
|
|
49
69
|
const relevance = computeConflictRelevance(userMessage, conflict);
|
|
50
70
|
return shouldAttemptConflictResolution({
|
|
51
71
|
clarificationReply,
|
|
@@ -66,16 +86,29 @@ export class ConflictGate {
|
|
|
66
86
|
conflict,
|
|
67
87
|
relevance: computeConflictRelevance(userMessage, conflict),
|
|
68
88
|
}));
|
|
89
|
+
// Try relevant conflicts first
|
|
69
90
|
const askable = scored
|
|
70
91
|
.filter((entry) => entry.relevance >= threshold)
|
|
71
92
|
.find((entry) => this.shouldAsk(entry.conflict.id, cooldownTurns));
|
|
72
|
-
if (!askable) return null;
|
|
73
93
|
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
// If no relevant conflict to ask and askOnIrrelevantTurns is enabled, try ones
|
|
95
|
+
// below the threshold (including zero-relevance). Zero-relevance conflicts are
|
|
96
|
+
// surfaced but not tracked as asked, preventing wasRecentlyAsked from triggering
|
|
97
|
+
// heuristic resolution on subsequent unrelated turns.
|
|
98
|
+
const candidateToAsk = askable
|
|
99
|
+
?? (conflictConfig.askOnIrrelevantTurns
|
|
100
|
+
? scored.find((entry) => entry.relevance < threshold && this.shouldAsk(entry.conflict.id, cooldownTurns))
|
|
101
|
+
: undefined);
|
|
102
|
+
|
|
103
|
+
if (!candidateToAsk) return null;
|
|
104
|
+
|
|
105
|
+
if (askable || candidateToAsk.relevance > 0) {
|
|
106
|
+
this.lastAskedTurn.set(candidateToAsk.conflict.id, this.turnCounter);
|
|
107
|
+
markConflictAsked(candidateToAsk.conflict.id);
|
|
108
|
+
}
|
|
76
109
|
return {
|
|
77
|
-
question:
|
|
78
|
-
relevant:
|
|
110
|
+
question: candidateToAsk.conflict.clarificationQuestion ?? buildFallbackConflictQuestion(candidateToAsk.conflict),
|
|
111
|
+
relevant: candidateToAsk.relevance >= threshold,
|
|
79
112
|
};
|
|
80
113
|
}
|
|
81
114
|
|
|
@@ -115,6 +148,29 @@ export class ConflictGate {
|
|
|
115
148
|
if (lastAsked === undefined) return false;
|
|
116
149
|
return this.turnCounter - lastAsked <= cooldownTurns;
|
|
117
150
|
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns a dismissal reason if the conflict should be dismissed, or null if actionable.
|
|
154
|
+
*/
|
|
155
|
+
private getDismissReason(
|
|
156
|
+
conflict: PendingConflictDetail,
|
|
157
|
+
conflictableKinds: readonly string[],
|
|
158
|
+
): string | null {
|
|
159
|
+
if (!isConflictKindPairEligible(conflict.existingKind, conflict.candidateKind, { conflictableKinds })) {
|
|
160
|
+
return 'Dismissed by conflict policy (kind not eligible).';
|
|
161
|
+
}
|
|
162
|
+
if (!isStatementConflictEligible(conflict.existingKind, conflict.existingStatement, { conflictableKinds })) {
|
|
163
|
+
return 'Dismissed by conflict policy (transient/non-durable).';
|
|
164
|
+
}
|
|
165
|
+
if (!isStatementConflictEligible(conflict.candidateKind, conflict.candidateStatement, { conflictableKinds })) {
|
|
166
|
+
return 'Dismissed by conflict policy (transient/non-durable).';
|
|
167
|
+
}
|
|
168
|
+
// Dismiss incoherent conflicts where the two statements have zero topical overlap
|
|
169
|
+
if (!areStatementsCoherent(conflict.existingStatement, conflict.candidateStatement)) {
|
|
170
|
+
return 'Dismissed by conflict policy (incoherent — zero statement overlap).';
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
118
174
|
}
|
|
119
175
|
|
|
120
176
|
export function buildFallbackConflictQuestion(conflict: PendingConflictDetail): string {
|
|
@@ -95,7 +95,7 @@ export function registerSessionNotifiers(
|
|
|
95
95
|
registerCallQuestionNotifier(conversationId, (callSessionId: string, question: string) => {
|
|
96
96
|
const callSession = getCallSession(callSessionId);
|
|
97
97
|
const callee = callSession?.toNumber ?? 'the caller';
|
|
98
|
-
const questionText = `**Live call question** (to ${callee}):\n\n${question}\n\n_Reply in this thread to answer._`;
|
|
98
|
+
const questionText = `**Live call question** (to ${callee}):\n\n${question}\n\n_Reply in this thread to answer. Your next message will be treated as the answer to this question. Once answered, you can send messages to steer the conversation._`;
|
|
99
99
|
|
|
100
100
|
conversationStore.addMessage(
|
|
101
101
|
conversationId,
|
|
@@ -16,6 +16,7 @@ import * as conversationStore from '../memory/conversation-store.js';
|
|
|
16
16
|
import { resolveSlash } from './session-slash.js';
|
|
17
17
|
import { getConfig } from '../config/loader.js';
|
|
18
18
|
import { getLogger } from '../util/logger.js';
|
|
19
|
+
import { tryRouteCallMessage } from '../calls/call-bridge.js';
|
|
19
20
|
|
|
20
21
|
const log = getLogger('session-process');
|
|
21
22
|
|
|
@@ -49,6 +50,8 @@ export interface ProcessSessionContext {
|
|
|
49
50
|
readonly conversationId: string;
|
|
50
51
|
messages: Message[];
|
|
51
52
|
processing: boolean;
|
|
53
|
+
abortController: AbortController | null;
|
|
54
|
+
currentRequestId?: string;
|
|
52
55
|
readonly queue: MessageQueue;
|
|
53
56
|
readonly traceEmitter: TraceEmitter;
|
|
54
57
|
currentActiveSurfaceId?: string;
|
|
@@ -177,15 +180,49 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
|
|
|
177
180
|
session.currentPage = next.currentPage;
|
|
178
181
|
|
|
179
182
|
// Fire-and-forget: persistUserMessage set session.processing = true
|
|
180
|
-
// so subsequent messages will still be enqueued.
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
+
// so subsequent messages will still be enqueued. Route through the call
|
|
184
|
+
// bridge first — if consumed, skip agent processing and continue draining.
|
|
185
|
+
// runAgentLoop's finally block will call drainQueue when this run completes.
|
|
186
|
+
routeOrProcess(session, resolvedContent, userMessageId, next).catch((err) => {
|
|
183
187
|
const message = err instanceof Error ? err.message : String(err);
|
|
184
188
|
log.error({ err, conversationId: session.conversationId, requestId: next.requestId }, 'Error processing queued message');
|
|
185
189
|
next.onEvent({ type: 'error', message: `Failed to process queued message: ${message}` });
|
|
186
190
|
});
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Try the call bridge first; if not consumed, run the agent loop.
|
|
195
|
+
* Used by drainQueue to handle the async bridge check in fire-and-forget mode.
|
|
196
|
+
*/
|
|
197
|
+
async function routeOrProcess(
|
|
198
|
+
session: ProcessSessionContext,
|
|
199
|
+
content: string,
|
|
200
|
+
userMessageId: string,
|
|
201
|
+
next: { onEvent: (msg: ServerMessage) => void; requestId: string },
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
try {
|
|
204
|
+
const bridgeResult = await tryRouteCallMessage(session.conversationId, content, userMessageId);
|
|
205
|
+
if (bridgeResult.handled) {
|
|
206
|
+
session.preactivatedSkillIds = undefined;
|
|
207
|
+
session.processing = false;
|
|
208
|
+
session.abortController = null;
|
|
209
|
+
session.currentRequestId = undefined;
|
|
210
|
+
log.info({ conversationId: session.conversationId, userMessageId }, 'Queued message consumed by call bridge, skipping agent loop');
|
|
211
|
+
if (bridgeResult.userFacingText) {
|
|
212
|
+
next.onEvent({ type: 'assistant_text_delta', text: bridgeResult.userFacingText });
|
|
213
|
+
}
|
|
214
|
+
next.onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
215
|
+
// runAgentLoop never ran so its finally block won't drain — continue manually
|
|
216
|
+
drainQueue(session);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
log.warn({ err, conversationId: session.conversationId }, 'Call bridge check failed (non-fatal), proceeding with agent loop');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await session.runAgentLoop(content, userMessageId, next.onEvent);
|
|
224
|
+
}
|
|
225
|
+
|
|
189
226
|
// ── processMessage ───────────────────────────────────────────────────
|
|
190
227
|
|
|
191
228
|
/**
|
|
@@ -259,6 +296,28 @@ export async function processMessage(
|
|
|
259
296
|
return '';
|
|
260
297
|
}
|
|
261
298
|
|
|
299
|
+
// Route through the call bridge before the agent loop. When the bridge
|
|
300
|
+
// consumes the message (answer or instruction), skip agent processing.
|
|
301
|
+
try {
|
|
302
|
+
const bridgeResult = await tryRouteCallMessage(session.conversationId, resolvedContent, userMessageId);
|
|
303
|
+
if (bridgeResult.handled) {
|
|
304
|
+
session.preactivatedSkillIds = undefined;
|
|
305
|
+
session.processing = false;
|
|
306
|
+
session.abortController = null;
|
|
307
|
+
session.currentRequestId = undefined;
|
|
308
|
+
log.info({ conversationId: session.conversationId, userMessageId }, 'IPC message consumed by call bridge, skipping agent loop');
|
|
309
|
+
if (bridgeResult.userFacingText) {
|
|
310
|
+
onEvent({ type: 'assistant_text_delta', text: bridgeResult.userFacingText });
|
|
311
|
+
}
|
|
312
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
313
|
+
// runAgentLoop never ran so its finally block won't drain — continue manually
|
|
314
|
+
drainQueue(session);
|
|
315
|
+
return userMessageId;
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
log.warn({ err, conversationId: session.conversationId }, 'Call bridge check failed (non-fatal), proceeding with agent loop');
|
|
319
|
+
}
|
|
320
|
+
|
|
262
321
|
await session.runAgentLoop(resolvedContent, userMessageId, onEvent);
|
|
263
322
|
return userMessageId;
|
|
264
323
|
}
|
|
@@ -249,7 +249,6 @@ export function createToolExecutor(
|
|
|
249
249
|
undefined, undefined,
|
|
250
250
|
ctx.conversationId,
|
|
251
251
|
req.executionTarget,
|
|
252
|
-
req.principal,
|
|
253
252
|
);
|
|
254
253
|
if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
|
|
255
254
|
log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule');
|
|
@@ -433,7 +432,7 @@ export function createProxyApprovalCallback(
|
|
|
433
432
|
}
|
|
434
433
|
|
|
435
434
|
// Use the checker's built-in allowlist generation for network_request
|
|
436
|
-
const allowlistOptions = generateAllowlistOptions('network_request', { url });
|
|
435
|
+
const allowlistOptions = await generateAllowlistOptions('network_request', { url });
|
|
437
436
|
|
|
438
437
|
const scopeOptions = generateScopeOptions(ctx.workingDir);
|
|
439
438
|
|