@vellumai/assistant 0.3.16 → 0.3.18

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 (90) hide show
  1. package/ARCHITECTURE.md +70 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/access-request-decision.test.ts +4 -7
  6. package/src/__tests__/channel-guardian.test.ts +3 -1
  7. package/src/__tests__/checker.test.ts +79 -48
  8. package/src/__tests__/config-watcher.test.ts +11 -13
  9. package/src/__tests__/conversation-pairing.test.ts +103 -3
  10. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  11. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  12. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  13. package/src/__tests__/guardian-action-store.test.ts +182 -0
  14. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  15. package/src/__tests__/ipc-snapshot.test.ts +21 -0
  16. package/src/__tests__/non-member-access-request.test.ts +1 -2
  17. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  18. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  19. package/src/__tests__/notification-deep-link.test.ts +44 -1
  20. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  21. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  22. package/src/__tests__/slack-channel-config.test.ts +3 -3
  23. package/src/__tests__/trust-store.test.ts +21 -21
  24. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  25. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  26. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  27. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  28. package/src/__tests__/update-bulletin.test.ts +66 -3
  29. package/src/__tests__/update-template-contract.test.ts +6 -11
  30. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  31. package/src/calls/call-controller.ts +129 -8
  32. package/src/calls/guardian-action-sweep.ts +1 -1
  33. package/src/calls/guardian-dispatch.ts +8 -0
  34. package/src/calls/voice-session-bridge.ts +4 -2
  35. package/src/cli/core-commands.ts +41 -1
  36. package/src/config/templates/UPDATES.md +5 -6
  37. package/src/config/update-bulletin-format.ts +2 -0
  38. package/src/config/update-bulletin-state.ts +1 -1
  39. package/src/config/update-bulletin-template-path.ts +6 -0
  40. package/src/config/update-bulletin.ts +21 -6
  41. package/src/daemon/config-watcher.ts +3 -2
  42. package/src/daemon/daemon-control.ts +64 -10
  43. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  44. package/src/daemon/handlers/identity.ts +45 -25
  45. package/src/daemon/handlers/sessions.ts +1 -1
  46. package/src/daemon/ipc-contract/sessions.ts +1 -1
  47. package/src/daemon/ipc-contract/workspace.ts +12 -1
  48. package/src/daemon/ipc-contract-inventory.json +1 -0
  49. package/src/daemon/lifecycle.ts +8 -0
  50. package/src/daemon/server.ts +25 -3
  51. package/src/daemon/session-process.ts +438 -184
  52. package/src/daemon/tls-certs.ts +17 -12
  53. package/src/daemon/tool-side-effects.ts +1 -1
  54. package/src/memory/channel-delivery-store.ts +18 -20
  55. package/src/memory/channel-guardian-store.ts +39 -42
  56. package/src/memory/conversation-crud.ts +2 -2
  57. package/src/memory/conversation-queries.ts +2 -2
  58. package/src/memory/conversation-store.ts +24 -25
  59. package/src/memory/db-init.ts +9 -1
  60. package/src/memory/fts-reconciler.ts +41 -26
  61. package/src/memory/guardian-action-store.ts +57 -7
  62. package/src/memory/guardian-verification.ts +1 -0
  63. package/src/memory/jobs-worker.ts +2 -2
  64. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  65. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  66. package/src/memory/migrations/index.ts +4 -2
  67. package/src/memory/schema-migration.ts +1 -0
  68. package/src/memory/schema.ts +6 -1
  69. package/src/memory/search/semantic.ts +3 -3
  70. package/src/notifications/README.md +158 -17
  71. package/src/notifications/broadcaster.ts +68 -50
  72. package/src/notifications/conversation-pairing.ts +96 -18
  73. package/src/notifications/decision-engine.ts +6 -3
  74. package/src/notifications/deliveries-store.ts +12 -0
  75. package/src/notifications/emit-signal.ts +1 -0
  76. package/src/notifications/thread-candidates.ts +60 -25
  77. package/src/notifications/types.ts +2 -1
  78. package/src/permissions/checker.ts +1 -16
  79. package/src/permissions/defaults.ts +14 -4
  80. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  81. package/src/runtime/http-server.ts +11 -11
  82. package/src/runtime/routes/access-request-decision.ts +1 -1
  83. package/src/runtime/routes/debug-routes.ts +4 -4
  84. package/src/runtime/routes/guardian-approval-interception.ts +4 -4
  85. package/src/runtime/routes/inbound-message-handler.ts +6 -6
  86. package/src/runtime/routes/integration-routes.ts +2 -2
  87. package/src/tools/permission-checker.ts +1 -2
  88. package/src/tools/secret-detection-handler.ts +1 -1
  89. package/src/tools/system/voice-config.ts +1 -1
  90. package/src/version.ts +29 -2
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { execSync, spawn } from 'node:child_process';
2
2
  import { closeSync,existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { createConnection } from 'node:net';
4
4
  import { join, resolve } from 'node:path';
@@ -70,9 +70,17 @@ function killStaleDaemon(): void {
70
70
  return;
71
71
  }
72
72
 
73
- // The PID file references a live process, but getDaemonStatus() (called
74
- // earlier in startDaemon) already returns early when the daemon is healthy.
75
- // If we reach here, the recorded process is alive but non-responsive.
73
+ // Guard against stale PID reuse: if the PID has been recycled by the OS
74
+ // and now belongs to an unrelated process, we must not signal it.
75
+ if (!isVellumDaemonProcess(pid)) {
76
+ log.info({ pid }, 'PID file references a non-vellum process (stale PID reuse) — cleaning up PID file only');
77
+ cleanupPidFile();
78
+ return;
79
+ }
80
+
81
+ // The PID file references a live vellum daemon process, but getDaemonStatus()
82
+ // (called earlier in startDaemon) already returns early when the daemon is
83
+ // healthy. If we reach here, the recorded process is alive but non-responsive.
76
84
  try {
77
85
  log.info({ pid }, 'Killing stale daemon process from PID file');
78
86
  process.kill(pid, 'SIGKILL');
@@ -91,6 +99,27 @@ function isProcessRunning(pid: number): boolean {
91
99
  }
92
100
  }
93
101
 
102
+ /**
103
+ * Check whether a PID belongs to a vellum daemon process (a bun process
104
+ * running the daemon's main.ts). Prevents signaling an unrelated process
105
+ * that reused a stale PID.
106
+ */
107
+ function isVellumDaemonProcess(pid: number): boolean {
108
+ try {
109
+ const cmd = execSync(`ps -p ${pid} -o command=`, {
110
+ encoding: 'utf-8',
111
+ timeout: 3000,
112
+ stdio: ['ignore', 'pipe', 'ignore'],
113
+ }).trim();
114
+ // The daemon is spawned as `bun run <path>/main.ts` — look for bun
115
+ // running our daemon entry point.
116
+ return cmd.includes('bun') && cmd.includes('daemon/main.ts');
117
+ } catch {
118
+ // Process exited or ps failed — treat as not ours.
119
+ return false;
120
+ }
121
+ }
122
+
94
123
  /** Try a TCP connect to the Unix socket. Returns true if the connection
95
124
  * handshake completes within the timeout — false on connection refused,
96
125
  * timeout, or missing socket file. */
@@ -166,10 +195,17 @@ export async function getDaemonStatus(): Promise<{ running: boolean; pid?: numbe
166
195
  cleanupPidFile();
167
196
  return { running: false };
168
197
  }
169
- // Process is alive verify the socket is responsive. A deadlocked or
170
- // wedged daemon will pass the PID liveness check but fail to accept
171
- // connections, and should be treated as not running so killStaleDaemon()
172
- // can clean it up.
198
+ // Guard against stale PID reuse: if the OS recycled the PID and it now
199
+ // belongs to an unrelated process, discard the stale PID file.
200
+ if (!isVellumDaemonProcess(pid)) {
201
+ log.info({ pid }, 'PID file references a non-vellum process (stale PID reuse) — cleaning up');
202
+ cleanupPidFile();
203
+ return { running: false };
204
+ }
205
+ // Process is alive and is ours — verify the socket is responsive. A
206
+ // deadlocked or wedged daemon will pass the PID liveness check but fail
207
+ // to accept connections, and should be treated as not running so
208
+ // killStaleDaemon() can clean it up.
173
209
  const responsive = await isSocketResponsive();
174
210
  if (!responsive) {
175
211
  log.warn({ pid }, 'Daemon process alive but socket unresponsive');
@@ -188,6 +224,11 @@ function getStartupLockPath(): string {
188
224
  function acquireStartupLock(): boolean {
189
225
  const lockPath = getStartupLockPath();
190
226
  try {
227
+ // Ensure the root directory exists before attempting the lock file write.
228
+ // On a first-time run, getRootDir() may not exist yet, and writeFileSync
229
+ // with 'wx' would throw ENOENT — which the catch block misinterprets as
230
+ // "lock already held."
231
+ mkdirSync(getRootDir(), { recursive: true });
191
232
  // O_CREAT | O_EXCL — fails atomically if the file already exists.
192
233
  writeFileSync(lockPath, String(Date.now()), { flag: 'wx' });
193
234
  return true;
@@ -226,12 +267,16 @@ export async function startDaemon(): Promise<{
226
267
  const lockWaitMs = 10_000;
227
268
  const lockInterval = 200;
228
269
  let lockWaited = 0;
270
+ let lockAcquired = false;
229
271
  while (lockWaited < lockWaitMs) {
230
272
  await new Promise((r) => setTimeout(r, lockInterval));
231
273
  lockWaited += lockInterval;
232
- if (acquireStartupLock()) break;
274
+ if (acquireStartupLock()) {
275
+ lockAcquired = true;
276
+ break;
277
+ }
233
278
  }
234
- if (lockWaited >= lockWaitMs) {
279
+ if (!lockAcquired) {
235
280
  // Timed out waiting for the lock — re-check status in case the
236
281
  // other caller succeeded.
237
282
  const recheck = await getDaemonStatus();
@@ -358,6 +403,15 @@ export async function stopDaemon(): Promise<StopResult> {
358
403
  return { stopped: false, reason: 'not_running' };
359
404
  }
360
405
 
406
+ // Guard against stale PID reuse: if the PID has been recycled by the OS
407
+ // and now belongs to an unrelated process, clean up the PID file but
408
+ // never signal it.
409
+ if (!isVellumDaemonProcess(pid)) {
410
+ log.info({ pid }, 'PID file references a non-vellum process (stale PID reuse) — cleaning up PID file only');
411
+ cleanupPidFile();
412
+ return { stopped: false, reason: 'not_running' };
413
+ }
414
+
361
415
  process.kill(pid, 'SIGTERM');
362
416
 
363
417
  const timeouts = readDaemonTimeouts();
@@ -1,6 +1,6 @@
1
1
  import { deleteSecureKey, getSecureKey, setSecureKey } from '../../security/secure-keys.js';
2
2
  import { deleteCredentialMetadata, getCredentialMetadata, upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
3
- import { log } from './shared.js';
3
+ import { log as _log } from './shared.js';
4
4
 
5
5
  // -- Result type --
6
6
 
@@ -6,6 +6,45 @@ import { fileURLToPath } from 'node:url';
6
6
  import { getWorkspacePromptPath, readLockfile } from '../../util/platform.js';
7
7
  import { defineHandlers, type HandlerContext,log } from './shared.js';
8
8
 
9
+ export interface IdentityFields {
10
+ name: string;
11
+ role: string;
12
+ personality: string;
13
+ emoji: string;
14
+ home: string;
15
+ }
16
+
17
+ /** Parse the core identity fields from IDENTITY.md content. */
18
+ export function parseIdentityFields(content: string): IdentityFields {
19
+ const fields: Record<string, string> = {};
20
+ for (const line of content.split('\n')) {
21
+ const trimmed = line.trim();
22
+ const lower = trimmed.toLowerCase();
23
+ const extract = (prefix: string): string | null => {
24
+ if (!lower.startsWith(prefix)) return null;
25
+ return trimmed.split(':**').pop()?.trim() ?? null;
26
+ };
27
+
28
+ const name = extract('- **name:**');
29
+ if (name) { fields.name = name; continue; }
30
+ const role = extract('- **role:**');
31
+ if (role) { fields.role = role; continue; }
32
+ const personality = extract('- **personality:**') ?? extract('- **vibe:**');
33
+ if (personality) { fields.personality = personality; continue; }
34
+ const emoji = extract('- **emoji:**');
35
+ if (emoji) { fields.emoji = emoji; continue; }
36
+ const home = extract('- **home:**');
37
+ if (home) { fields.home = home; continue; }
38
+ }
39
+ return {
40
+ name: fields.name ?? '',
41
+ role: fields.role ?? '',
42
+ personality: fields.personality ?? '',
43
+ emoji: fields.emoji ?? '',
44
+ home: fields.home ?? '',
45
+ };
46
+ }
47
+
9
48
  function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
10
49
  const identityPath = getWorkspacePromptPath('IDENTITY.md');
11
50
 
@@ -24,26 +63,7 @@ function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
24
63
 
25
64
  try {
26
65
  const content = readFileSync(identityPath, 'utf-8');
27
- const fields: Record<string, string> = {};
28
- for (const line of content.split('\n')) {
29
- const trimmed = line.trim();
30
- const lower = trimmed.toLowerCase();
31
- const extract = (prefix: string): string | null => {
32
- if (!lower.startsWith(prefix)) return null;
33
- return trimmed.split(':**').pop()?.trim() ?? null;
34
- };
35
-
36
- const name = extract('- **name:**');
37
- if (name) { fields.name = name; continue; }
38
- const role = extract('- **role:**');
39
- if (role) { fields.role = role; continue; }
40
- const personality = extract('- **personality:**') ?? extract('- **vibe:**');
41
- if (personality) { fields.personality = personality; continue; }
42
- const emoji = extract('- **emoji:**');
43
- if (emoji) { fields.emoji = emoji; continue; }
44
- const home = extract('- **home:**');
45
- if (home) { fields.home = home; continue; }
46
- }
66
+ const fields = parseIdentityFields(content);
47
67
 
48
68
  // Read version from package.json
49
69
  let version: string | undefined;
@@ -90,11 +110,11 @@ function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
90
110
  ctx.send(socket, {
91
111
  type: 'identity_get_response',
92
112
  found: true,
93
- name: fields.name ?? '',
94
- role: fields.role ?? '',
95
- personality: fields.personality ?? '',
96
- emoji: fields.emoji ?? '',
97
- home: fields.home ?? '',
113
+ name: fields.name,
114
+ role: fields.role,
115
+ personality: fields.personality,
116
+ emoji: fields.emoji,
117
+ home: fields.home,
98
118
  version,
99
119
  assistantId,
100
120
  createdAt,
@@ -38,8 +38,8 @@ import { normalizeThreadType } from '../ipc-protocol.js';
38
38
  import { executeRecordingIntent } from '../recording-executor.js';
39
39
  import { resolveRecordingIntent } from '../recording-intent.js';
40
40
  import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
41
- import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
42
41
  import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
42
+ import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
43
43
  import { generateVideoThumbnail } from '../video-thumbnail.js';
44
44
  import { handleRecordingPause, handleRecordingRestart, handleRecordingResume, handleRecordingStart, handleRecordingStop } from './recording.js';
45
45
  import {
@@ -213,7 +213,7 @@ export interface AssistantAttention {
213
213
 
214
214
  export interface SessionListResponse {
215
215
  type: 'session_list_response';
216
- sessions: Array<{ id: string; title: string; createdAt: number; updatedAt: number; threadType?: ThreadType; source?: string; channelBinding?: ChannelBinding; conversationOriginChannel?: ChannelId; conversationOriginInterface?: InterfaceId; assistantAttention?: AssistantAttention }>;
216
+ sessions: Array<{ id: string; title: string; createdAt?: number; updatedAt: number; threadType?: ThreadType; source?: string; channelBinding?: ChannelBinding; conversationOriginChannel?: ChannelId; conversationOriginInterface?: InterfaceId; assistantAttention?: AssistantAttention }>;
217
217
  /** Whether more sessions exist beyond the returned page. */
218
218
  hasMore?: boolean;
219
219
  }
@@ -112,6 +112,16 @@ export interface ToolNamesListResponse {
112
112
  schemas?: Record<string, ToolInputSchema>;
113
113
  }
114
114
 
115
+ /** Server push — broadcast when IDENTITY.md changes on disk. */
116
+ export interface IdentityChanged {
117
+ type: 'identity_changed';
118
+ name: string;
119
+ role: string;
120
+ personality: string;
121
+ emoji: string;
122
+ home: string;
123
+ }
124
+
115
125
  // --- Domain-level union aliases (consumed by the barrel file) ---
116
126
 
117
127
  export type _WorkspaceClientMessages =
@@ -126,4 +136,5 @@ export type _WorkspaceServerMessages =
126
136
  | WorkspaceFileReadResponse
127
137
  | IdentityGetResponse
128
138
  | ToolPermissionSimulateResponse
129
- | ToolNamesListResponse;
139
+ | ToolNamesListResponse
140
+ | IdentityChanged;
@@ -248,6 +248,7 @@
248
248
  "heartbeat_runs_list_response",
249
249
  "history_response",
250
250
  "home_base_get_response",
251
+ "identity_changed",
251
252
  "identity_get_response",
252
253
  "ingress_config_response",
253
254
  "ingress_invite_response",
@@ -17,6 +17,7 @@ import {
17
17
  } from '../config/env.js';
18
18
  import { loadConfig } from '../config/loader.js';
19
19
  import { ensurePromptFiles } from '../config/system-prompt.js';
20
+ import { syncUpdateBulletinOnStartup } from '../config/update-bulletin.js';
20
21
  import { HeartbeatService } from '../heartbeat/heartbeat-service.js';
21
22
  import { getHookManager } from '../hooks/manager.js';
22
23
  import { installTemplates } from '../hooks/templates.js';
@@ -63,6 +64,7 @@ import { installShutdownHandlers } from './shutdown-handlers.js';
63
64
  // Re-export public API so existing consumers don't need to change imports
64
65
  export type { StopResult } from './daemon-control.js';
65
66
  export {
67
+ cleanupPidFile,
66
68
  ensureDaemonRunning,
67
69
  getDaemonStatus,
68
70
  isDaemonRunning,
@@ -139,6 +141,12 @@ export async function runDaemon(): Promise<void> {
139
141
  initializeDb();
140
142
  log.info('Daemon startup: DB initialized');
141
143
 
144
+ try {
145
+ syncUpdateBulletinOnStartup();
146
+ } catch (err) {
147
+ log.warn({ err }, 'Bulletin sync failed — continuing startup');
148
+ }
149
+
142
150
  // Recover orphaned work items that were left in 'running' state when the
143
151
  // daemon previously crashed or was killed mid-task.
144
152
  const orphanedRunning = listWorkItems({ status: 'running' });
@@ -1,4 +1,4 @@
1
- import { chmodSync, readFileSync,statSync } from 'node:fs';
1
+ import { chmodSync, existsSync, readFileSync,statSync } from 'node:fs';
2
2
  import * as net from 'node:net';
3
3
  import { join } from 'node:path';
4
4
  import * as tls from 'node:tls';
@@ -20,12 +20,13 @@ import { getSubagentManager } from '../subagent/index.js';
20
20
  import { IngressBlockedError } from '../util/errors.js';
21
21
  import { getLogger } from '../util/logger.js';
22
22
  import { getLocalIPv4 } from '../util/network-info.js';
23
- import { getSandboxWorkingDir, getSocketPath, getTCPHost, getTCPPort, isIOSPairingEnabled,isTCPEnabled, removeSocketFile } from '../util/platform.js';
23
+ import { getSandboxWorkingDir, getSocketPath, getTCPHost, getTCPPort, getWorkspacePromptPath, isIOSPairingEnabled,isTCPEnabled, removeSocketFile } from '../util/platform.js';
24
24
  import { registerDaemonCallbacks } from '../work-items/work-item-runner.js';
25
25
  import { AuthManager } from './auth-manager.js';
26
26
  import { ComputerUseSession } from './computer-use-session.js';
27
27
  import { ConfigWatcher } from './config-watcher.js';
28
28
  import { handleMessage, type HandlerContext, type SessionCreateOptions } from './handlers.js';
29
+ import { parseIdentityFields } from './handlers/identity.js';
29
30
  import { cleanupRecordingsOnDisconnect } from './handlers/recording.js';
30
31
  import { ensureBlobDir, sweepStaleBlobs } from './ipc-blob-store.js';
31
32
  import { IpcSender } from './ipc-handler.js';
@@ -226,6 +227,24 @@ export class DaemonServer {
226
227
  );
227
228
  }
228
229
 
230
+ private broadcastIdentityChanged(): void {
231
+ try {
232
+ const identityPath = getWorkspacePromptPath('IDENTITY.md');
233
+ const content = existsSync(identityPath) ? readFileSync(identityPath, 'utf-8') : '';
234
+ const fields = parseIdentityFields(content);
235
+ this.broadcast({
236
+ type: 'identity_changed',
237
+ name: fields.name,
238
+ role: fields.role,
239
+ personality: fields.personality,
240
+ emoji: fields.emoji,
241
+ home: fields.home,
242
+ });
243
+ } catch (err) {
244
+ log.error({ err }, 'Failed to broadcast identity change');
245
+ }
246
+ }
247
+
229
248
  // ── Server lifecycle ────────────────────────────────────────────────
230
249
 
231
250
  async start(): Promise<void> {
@@ -255,7 +274,10 @@ export class DaemonServer {
255
274
  });
256
275
  }, 5 * 60 * 1000);
257
276
 
258
- this.configWatcher.start(() => this.evictSessionsForReload());
277
+ this.configWatcher.start(
278
+ () => this.evictSessionsForReload(),
279
+ () => this.broadcastIdentityChanged(),
280
+ );
259
281
  this.auth.initToken();
260
282
 
261
283
  let tlsCreds: { cert: string; key: string; fingerprint: string } | null = null;