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.
Files changed (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -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, unlinkSync, readdirSync, watch, type FSWatcher } from 'node:fs';
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 { tryHandlePendingCallAnswer } from '../calls/call-bridge.js';
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: net.Server | null = null;
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 Map<string, ReturnType<typeof setTimeout>>();
81
- private static readonly MAX_DEBOUNCE_ENTRIES = 1000;
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
- // Generate a session token and write it to disk so clients can
204
- // authenticate when connecting. Written before the socket starts
205
- // listening to ensure the token is available by the time a client
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
- writeFileSync(tokenPath, this.sessionToken, { mode: 0o600 });
210
- chmodSync(tokenPath, 0o600);
211
- log.info({ tokenPath }, 'Session token written');
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 (isTCPEnabled()) {
264
+ // Start TLS-encrypted TCP listener for iOS clients (alongside the Unix socket)
265
+ if (tlsCreds) {
238
266
  const tcpPort = getTCPPort();
239
- this.tcpServer = net.createServer((socket) => {
240
- this.handleConnection(socket);
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
- this.tcpServer.listen(tcpPort, getTCPHost(), () => {
246
- log.info({ tcpPort, tcpHost: getTCPHost() }, 'TCP listener started');
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
- // Clean up session token
265
- try {
266
- unlinkSync(getSessionTokenPath());
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
- const key = `file:${file}`;
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
- }, 200);
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
- for (const timer of this.debounceTimers.values()) {
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
- const key = `skills:${file}`;
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
- }, 200);
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 tryHandlePendingCallAnswer(conversationId, content, messageId);
1050
+ const bridgeResult = await tryRouteCallMessage(conversationId, content, messageId);
1045
1051
  bridgeHandled = bridgeResult.handled;
1046
1052
  } catch (err) {
1047
- log.warn({ err, conversationId }, 'Call-answer bridge check failed (non-fatal), proceeding with agent loop');
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-answer bridge, skipping agent loop');
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 tryHandlePendingCallAnswer(conversationId, content, messageId);
1159
+ const bridgeResult = await tryRouteCallMessage(conversationId, resolvedContent, messageId);
1154
1160
  bridgeHandled = bridgeResult.handled;
1155
1161
  } catch (err) {
1156
- log.warn({ err, conversationId }, 'Call-answer bridge check failed (non-fatal), proceeding with agent loop');
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-answer bridge, skipping agent loop');
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 = pendingBeforeResolve.filter((conflict) => {
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
- this.lastAskedTurn.set(askable.conflict.id, this.turnCounter);
75
- markConflictAsked(askable.conflict.id);
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: askable.conflict.clarificationQuestion ?? buildFallbackConflictQuestion(askable.conflict),
78
- relevant: true,
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. runAgentLoop's
181
- // finally block will call drainQueue when this run completes.
182
- session.runAgentLoop(resolvedContent, userMessageId, next.onEvent).catch((err) => {
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