@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -12,6 +12,24 @@ Use `send_notification` for user-facing alerts and notifications. This tool rout
12
12
  - `preferred_channels` are **routing hints**, not hard channel forcing. The notification router makes the final delivery decision based on user preferences, channel availability, and urgency.
13
13
  - Channel selection and delivery are handled entirely by the notification router -- do not attempt to control delivery manually.
14
14
 
15
+ ## Deduplication (`dedupe_key`)
16
+
17
+ - `dedupe_key` suppresses duplicate signals. A second notification with the same key is **dropped entirely** within a **1-hour window**. After the window expires, the same key is accepted again.
18
+ - Never reuse a `dedupe_key` across logically distinct notifications, even if they are related. The key means "this exact event already fired," not "these events are in the same category."
19
+ - If you omit `dedupe_key`, the LLM decision engine may generate one automatically based on signal context. This means even keyless signals can be deduplicated if the engine considers them duplicates of a recent event.
20
+
21
+ ## Threading
22
+
23
+ Thread grouping is handled by the LLM-powered decision engine, not by any parameter you pass. There is no explicit "post to thread X" parameter — thread reuse is inferred, not commanded.
24
+
25
+ **How it works:** The engine evaluates recent notification thread candidates and decides whether a new signal is a continuation of an existing thread based on `source_event_name`, provenance metadata, and message content. Use natural, descriptive titles and bodies — the engine groups by semantic relatedness, not string matching.
26
+
27
+ **`source_event_name` is the primary grouping signal.** Use a stable event name for notifications that belong to the same logical stream (e.g. `dog.news.thread.reply` for all replies in a thread). Use a distinct event name when the notification represents a genuinely different kind of event.
28
+
29
+ **Practical constraints:**
30
+ - Thread candidates are scoped to the **last 24 hours** (max 5 per channel). You cannot reuse an old thread from days ago.
31
+ - The engine will only reuse conversations originally created by the notification system (`source === 'notification'`). It will never append to a user-initiated conversation, even if it looks related.
32
+
15
33
  ## Important
16
34
 
17
35
  - Do **NOT** use AppleScript `display notification` or other OS-level notification commands for assistant-managed alerts. Always use `send_notification`.
@@ -117,12 +117,18 @@ export {
117
117
  SandboxConfigSchema,
118
118
  } from './sandbox-schema.js';
119
119
  export type {
120
+ RemotePolicyConfig,
121
+ RemoteProviderConfig,
122
+ RemoteProvidersConfig,
120
123
  SkillEntryConfig,
121
124
  SkillsConfig,
122
125
  SkillsInstallConfig,
123
126
  SkillsLoadConfig,
124
127
  } from './skills-schema.js';
125
128
  export {
129
+ RemotePolicyConfigSchema,
130
+ RemoteProviderConfigSchema,
131
+ RemoteProvidersConfigSchema,
126
132
  SkillEntryConfigSchema,
127
133
  SkillsConfigSchema,
128
134
  SkillsInstallConfigSchema,
@@ -19,14 +19,41 @@ export const SkillsInstallConfigSchema = z.object({
19
19
  }).default('npm'),
20
20
  });
21
21
 
22
+ export const RemoteProviderConfigSchema = z.object({
23
+ enabled: z.boolean({ error: 'skills.remoteProviders.<provider>.enabled must be a boolean' }).default(true),
24
+ });
25
+
26
+ export const RemoteProvidersConfigSchema = z.object({
27
+ skillssh: RemoteProviderConfigSchema.default({} as any),
28
+ clawhub: RemoteProviderConfigSchema.default({} as any),
29
+ });
30
+
31
+ const VALID_SKILLS_SH_RISK_LEVELS = ['safe', 'low', 'medium', 'high', 'critical', 'unknown'] as const;
32
+ // 'unknown' is valid as a risk label on a skill but not as a threshold — setting the threshold
33
+ // to 'unknown' would silently disable fail-closed behavior since nothing can exceed it.
34
+ const VALID_MAX_RISK_LEVELS = ['safe', 'low', 'medium', 'high', 'critical'] as const;
35
+
36
+ export const RemotePolicyConfigSchema = z.object({
37
+ blockSuspicious: z.boolean({ error: 'skills.remotePolicy.blockSuspicious must be a boolean' }).default(true),
38
+ blockMalware: z.boolean({ error: 'skills.remotePolicy.blockMalware must be a boolean' }).default(true),
39
+ maxSkillsShRisk: z.enum(VALID_MAX_RISK_LEVELS, {
40
+ error: `skills.remotePolicy.maxSkillsShRisk must be one of: ${VALID_MAX_RISK_LEVELS.join(', ')}`,
41
+ }).default('medium'),
42
+ });
43
+
22
44
  export const SkillsConfigSchema = z.object({
23
45
  entries: z.record(z.string(), SkillEntryConfigSchema).default({} as any),
24
46
  load: SkillsLoadConfigSchema.default({} as any),
25
47
  install: SkillsInstallConfigSchema.default({} as any),
26
48
  allowBundled: z.array(z.string()).nullable().default(null),
49
+ remoteProviders: RemoteProvidersConfigSchema.default({} as any),
50
+ remotePolicy: RemotePolicyConfigSchema.default({} as any),
27
51
  });
28
52
 
29
53
  export type SkillEntryConfig = z.infer<typeof SkillEntryConfigSchema>;
30
54
  export type SkillsLoadConfig = z.infer<typeof SkillsLoadConfigSchema>;
31
55
  export type SkillsInstallConfig = z.infer<typeof SkillsInstallConfigSchema>;
56
+ export type RemoteProviderConfig = z.infer<typeof RemoteProviderConfigSchema>;
57
+ export type RemoteProvidersConfig = z.infer<typeof RemoteProvidersConfigSchema>;
58
+ export type RemotePolicyConfig = z.infer<typeof RemotePolicyConfigSchema>;
32
59
  export type SkillsConfig = z.infer<typeof SkillsConfigSchema>;
@@ -1,7 +1,5 @@
1
1
  _ Lines starting with _ are comments — they won't appear in the system prompt
2
-
3
- # UPDATES.md
4
-
2
+ _
5
3
  _ This file contains release update notes for the assistant.
6
4
  _ Each release block is wrapped with HTML comment markers:
7
5
  _ <!-- vellum-update-release:<version> -->
@@ -11,6 +9,7 @@ _
11
9
  _ Format is freeform markdown. Write notes that help the assistant
12
10
  _ understand what changed and how it affects behavior, capabilities,
13
11
  _ or available tools. Focus on what matters to the user experience.
14
-
15
- ## What's New
16
-
12
+ _
13
+ _ To add release notes, replace this content with real markdown
14
+ _ describing what changed. The sync will only materialize a bulletin
15
+ _ when non-comment content is present.
@@ -44,7 +44,9 @@ export function appendReleaseBlock(
44
44
  /** Extracts all version strings from release markers found in `content`. */
45
45
  export function extractReleaseIds(content: string): string[] {
46
46
  const ids: string[] = [];
47
+ MARKER_REGEX.lastIndex = 0;
47
48
  let match: RegExpExecArray | null;
49
+ // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
48
50
  while ((match = MARKER_REGEX.exec(content)) !== null) {
49
51
  ids.push(match[1]);
50
52
  }
@@ -4,7 +4,7 @@ const ACTIVE_RELEASES_KEY = 'updates:active_releases';
4
4
  const COMPLETED_RELEASES_KEY = 'updates:completed_releases';
5
5
 
6
6
  function parseReleaseArray(raw: string | null): string[] {
7
- if (raw === null) return [];
7
+ if (!raw) return [];
8
8
  try {
9
9
  const parsed = JSON.parse(raw);
10
10
  if (!Array.isArray(parsed)) return [];
@@ -0,0 +1,6 @@
1
+ import { join } from 'node:path';
2
+
3
+ /** Returns the path to the bundled UPDATES.md template. Extracted for testability. */
4
+ export function getTemplatePath(): string {
5
+ return join(import.meta.dirname ?? __dirname, 'templates', 'UPDATES.md');
6
+ }
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
2
 
3
+ import { getWorkspacePromptPath } from '../util/platform.js';
4
+ import { APP_VERSION } from '../version.js';
4
5
  import { stripCommentLines } from './system-prompt.js';
5
6
  import { appendReleaseBlock, hasReleaseBlock } from './update-bulletin-format.js';
6
7
  import {
@@ -10,8 +11,7 @@ import {
10
11
  markReleasesCompleted,
11
12
  setActiveReleases,
12
13
  } from './update-bulletin-state.js';
13
- import { APP_VERSION } from '../version.js';
14
- import { getWorkspacePromptPath } from '../util/platform.js';
14
+ import { getTemplatePath } from './update-bulletin-template-path.js';
15
15
 
16
16
  /**
17
17
  * Writes content to a file via a temp-file + rename to prevent partial/truncated
@@ -21,7 +21,22 @@ function atomicWriteFileSync(filePath: string, content: string): void {
21
21
  const tmpPath = `${filePath}.tmp.${process.pid}`;
22
22
  try {
23
23
  writeFileSync(tmpPath, content, 'utf-8');
24
- renameSync(tmpPath, filePath);
24
+ // Resolve symlinks so we rename to the real target, preserving the link.
25
+ // If the symlink is dangling (target doesn't exist), fall back to writing
26
+ // through the symlink path directly — realpathSync throws ENOENT for dangling links.
27
+ let targetPath = filePath;
28
+ try {
29
+ if (lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink()) {
30
+ targetPath = realpathSync(filePath);
31
+ }
32
+ } catch (err: unknown) {
33
+ // Dangling symlink — fall back to writing through the symlink path.
34
+ // Only swallow ENOENT (dangling target); re-throw ELOOP, EACCES, I/O faults, etc.
35
+ if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
36
+ throw err;
37
+ }
38
+ }
39
+ renameSync(tmpPath, targetPath);
25
40
  } catch (err) {
26
41
  try {
27
42
  unlinkSync(tmpPath);
@@ -57,7 +72,7 @@ export function syncUpdateBulletinOnStartup(): void {
57
72
  }
58
73
 
59
74
  // --- Template materialization ---
60
- const templatePath = join(import.meta.dirname ?? __dirname, 'templates', 'UPDATES.md');
75
+ const templatePath = getTemplatePath();
61
76
  if (!existsSync(templatePath)) return;
62
77
 
63
78
  const rawTemplate = readFileSync(templatePath, 'utf-8');
@@ -90,8 +90,9 @@ export class ConfigWatcher {
90
90
  /**
91
91
  * Start all file watchers. `onSessionEvict` is called when watched
92
92
  * files change and sessions need to be evicted for reload.
93
+ * `onIdentityChanged` is called when IDENTITY.md changes on disk.
93
94
  */
94
- start(onSessionEvict: () => void): void {
95
+ start(onSessionEvict: () => void, onIdentityChanged?: () => void): void {
95
96
  const workspaceDir = getWorkspaceDir();
96
97
  const protectedDir = join(getRootDir(), 'protected');
97
98
 
@@ -106,7 +107,7 @@ export class ConfigWatcher {
106
107
  }
107
108
  },
108
109
  'SOUL.md': () => onSessionEvict(),
109
- 'IDENTITY.md': () => onSessionEvict(),
110
+ 'IDENTITY.md': () => { onSessionEvict(); onIdentityChanged?.(); },
110
111
  'USER.md': () => onSessionEvict(),
111
112
  'LOOKS.md': () => onSessionEvict(),
112
113
  'UPDATES.md': () => onSessionEvict(),
@@ -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();
@@ -2,6 +2,7 @@ import * as net from 'node:net';
2
2
 
3
3
  import type { ChannelId } from '../../channels/types.js';
4
4
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
5
+ import { findMember, revokeMember } from '../../memory/ingress-member-store.js';
5
6
  import {
6
7
  createVerificationChallenge,
7
8
  findActiveSession,
@@ -173,8 +174,25 @@ export function handleGuardianVerification(
173
174
  const result = getGuardianStatus(channel, assistantId);
174
175
  ctx.send(socket, { type: 'guardian_verification_response', ...result });
175
176
  } else if (msg.action === 'revoke') {
177
+ // Capture binding before revoking so we can revoke the guardian's
178
+ // ingress member record — without this, the guardian would still pass
179
+ // the ACL check after unbinding.
180
+ const bindingBeforeRevoke = getGuardianBinding(assistantId, channel);
176
181
  revokeGuardianBinding(assistantId, channel);
177
182
  revokePendingChallenges(assistantId, channel);
183
+
184
+ if (bindingBeforeRevoke) {
185
+ const member = findMember({
186
+ assistantId,
187
+ sourceChannel: channel,
188
+ externalUserId: bindingBeforeRevoke.guardianExternalUserId,
189
+ externalChatId: bindingBeforeRevoke.guardianDeliveryChatId,
190
+ });
191
+ if (member) {
192
+ revokeMember(member.id, 'guardian_binding_revoked');
193
+ }
194
+ }
195
+
178
196
  ctx.send(socket, {
179
197
  type: 'guardian_verification_response',
180
198
  success: true,
@@ -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 {
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
 
5
5
  import { getConfig, invalidateConfigCache,loadRawConfig, saveRawConfig } from '../../config/loader.js';
6
6
  import { resolveSkillStates } from '../../config/skill-state.js';
7
- import { ensureSkillIcon,loadSkillBySelector, loadSkillCatalog } from '../../config/skills.js';
7
+ import { ensureSkillIcon,loadSkillBySelector, loadSkillCatalog, type SkillSummary } from '../../config/skills.js';
8
8
  import { createTimeout,extractText, getConfiguredProvider, userMessage } from '../../providers/provider-send-message.js';
9
9
  import { clawhubCheckUpdates, clawhubInspect, clawhubInstall, clawhubSearch, type ClawhubSearchResultItem,clawhubUpdate } from '../../skills/clawhub.js';
10
10
  import { createManagedSkill,deleteManagedSkill, removeSkillsIndexEntry, validateManagedSkillId } from '../../skills/managed-store.js';
@@ -26,6 +26,48 @@ import type {
26
26
  } from '../ipc-protocol.js';
27
27
  import { CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, ensureSkillEntry, type HandlerContext,log } from './shared.js';
28
28
 
29
+ // ─── Provenance resolution ──────────────────────────────────────────────────
30
+
31
+ interface SkillProvenance {
32
+ kind: 'first-party' | 'third-party' | 'local';
33
+ provider?: string;
34
+ originId?: string;
35
+ sourceUrl?: string;
36
+ }
37
+
38
+ const CLAWHUB_BASE_URL = 'https://skills.sh';
39
+
40
+ function resolveProvenance(summary: SkillSummary): SkillProvenance {
41
+ // Bundled skills are always first-party (shipped with Vellum)
42
+ if (summary.source === 'bundled') {
43
+ return { kind: 'first-party', provider: 'Vellum' };
44
+ }
45
+
46
+ // Managed skills could be either first-party (installed from Vellum catalog)
47
+ // or third-party (installed from clawhub). The homepage field serves as a
48
+ // heuristic: Vellum catalog skills don't typically have a clawhub homepage.
49
+ if (summary.source === 'managed') {
50
+ if (summary.homepage?.includes('skills.sh') || summary.homepage?.includes('clawhub')) {
51
+ return {
52
+ kind: 'third-party',
53
+ provider: 'skills.sh',
54
+ originId: summary.id,
55
+ sourceUrl: summary.homepage ?? `${CLAWHUB_BASE_URL}/skills/${encodeURIComponent(summary.id)}`,
56
+ };
57
+ }
58
+ // No positive evidence of origin -- could be user-authored or from Vellum catalog.
59
+ // Default to "local" to avoid mislabeling user-created skills as first-party.
60
+ return { kind: 'local' };
61
+ }
62
+
63
+ // Workspace and extra skills are user-provided
64
+ if (summary.source === 'workspace' || summary.source === 'extra') {
65
+ return { kind: 'local' };
66
+ }
67
+
68
+ return { kind: 'local' };
69
+ }
70
+
29
71
  export function handleSkillsList(socket: net.Socket, ctx: HandlerContext): void {
30
72
  const config = getConfig();
31
73
  const catalog = loadSkillCatalog();
@@ -37,12 +79,13 @@ export function handleSkillsList(socket: net.Socket, ctx: HandlerContext): void
37
79
  description: r.summary.description,
38
80
  emoji: r.summary.emoji,
39
81
  homepage: r.summary.homepage,
40
- source: r.summary.source as 'bundled' | 'managed' | 'workspace' | 'clawhub' | 'extra',
82
+ source: r.summary.source,
41
83
  state: (r.state === 'degraded' ? 'enabled' : r.state) as 'enabled' | 'disabled' | 'available',
42
84
  degraded: r.degraded,
43
85
  missingRequirements: r.missingRequirements,
44
86
  updateAvailable: false,
45
87
  userInvocable: r.summary.userInvocable,
88
+ provenance: resolveProvenance(r.summary),
46
89
  }));
47
90
 
48
91
  ctx.send(socket, { type: 'skills_list_response', skills });
@@ -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
  }
@@ -95,6 +95,7 @@ export interface SkillsListResponse {
95
95
  updateAvailable: boolean;
96
96
  userInvocable: boolean;
97
97
  clawhub?: { author: string; stars: number; installs: number; reports: number; publishedAt: string };
98
+ provenance?: { kind: 'first-party' | 'third-party' | 'local'; provider?: string; originId?: string; sourceUrl?: string };
98
99
  }>;
99
100
  }
100
101
 
@@ -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' });