@vellumai/assistant 0.5.15 → 0.5.16

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 (175) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/architecture/integrations.md +15 -14
  3. package/knip.json +3 -1
  4. package/openapi.yaml +11 -43
  5. package/package.json +1 -1
  6. package/src/__tests__/assistant-feature-flags-integration.test.ts +3 -375
  7. package/src/__tests__/ces-rpc-credential-backend.test.ts +4 -1
  8. package/src/__tests__/checker.test.ts +59 -0
  9. package/src/__tests__/cli-command-risk-guard.test.ts +98 -10
  10. package/src/__tests__/cli-memory.test.ts +372 -0
  11. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +12 -2
  12. package/src/__tests__/config-schema.test.ts +0 -2
  13. package/src/__tests__/config-watcher-feature-flags.test.ts +211 -0
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +7 -4
  15. package/src/__tests__/conversation-slash-commands.test.ts +2 -6
  16. package/src/__tests__/conversation-usage.test.ts +1 -0
  17. package/src/__tests__/credential-security-e2e.test.ts +4 -1
  18. package/src/__tests__/docker-signing-key-bootstrap.test.ts +7 -73
  19. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -7
  20. package/src/__tests__/guardian-routing-invariants.test.ts +151 -0
  21. package/src/__tests__/heartbeat-service.test.ts +1 -3
  22. package/src/__tests__/intent-routing.test.ts +6 -18
  23. package/src/__tests__/log-export-workspace.test.ts +2 -28
  24. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -37
  25. package/src/__tests__/managed-store.test.ts +2 -10
  26. package/src/__tests__/messaging-send-tool.test.ts +6 -6
  27. package/src/__tests__/migration-cross-version-compatibility.test.ts +1 -29
  28. package/src/__tests__/migration-export-http.test.ts +3 -34
  29. package/src/__tests__/migration-import-commit-http.test.ts +1 -29
  30. package/src/__tests__/migration-import-preflight-http.test.ts +3 -34
  31. package/src/__tests__/no-domain-routing-in-prompt-guard.test.ts +2 -1
  32. package/src/__tests__/oauth-apps-routes.test.ts +120 -10
  33. package/src/__tests__/oauth-connect-orchestrator.test.ts +709 -0
  34. package/src/__tests__/oauth-provider-serializer.test.ts +2 -1
  35. package/src/__tests__/oauth-provider-visibility.test.ts +149 -0
  36. package/src/__tests__/oauth-providers-routes.test.ts +5 -2
  37. package/src/__tests__/oauth-store.test.ts +0 -5
  38. package/src/__tests__/outlook-messaging-provider.test.ts +576 -0
  39. package/src/__tests__/path-policy.test.ts +2 -17
  40. package/src/__tests__/permission-types.test.ts +0 -1
  41. package/src/__tests__/platform-callback-registration.test.ts +3 -7
  42. package/src/__tests__/provider-commit-message-generator.test.ts +0 -1
  43. package/src/__tests__/provider-error-scenarios.test.ts +0 -2
  44. package/src/__tests__/qdrant-manager.test.ts +68 -21
  45. package/src/__tests__/require-fresh-approval.test.ts +0 -1
  46. package/src/__tests__/sandbox-diagnostics.test.ts +20 -29
  47. package/src/__tests__/scaffold-managed-skill-tool.test.ts +2 -10
  48. package/src/__tests__/secret-allowlist.test.ts +20 -35
  49. package/src/__tests__/shell-credential-ref.test.ts +0 -5
  50. package/src/__tests__/skill-load-feature-flag.test.ts +2 -43
  51. package/src/__tests__/skill-load-inline-command.test.ts +3 -65
  52. package/src/__tests__/skill-load-inline-includes.test.ts +3 -65
  53. package/src/__tests__/skill-load-tool.test.ts +3 -67
  54. package/src/__tests__/skill-memory.test.ts +362 -119
  55. package/src/__tests__/skills.test.ts +22 -49
  56. package/src/__tests__/slack-channel-config.test.ts +2 -21
  57. package/src/__tests__/starter-bundle.test.ts +2 -8
  58. package/src/__tests__/stt-hints.test.ts +7 -2
  59. package/src/__tests__/system-prompt.test.ts +25 -45
  60. package/src/__tests__/task-compiler.test.ts +0 -21
  61. package/src/__tests__/task-management-tools.test.ts +0 -21
  62. package/src/__tests__/task-memory-cleanup.test.ts +0 -21
  63. package/src/__tests__/task-runner.test.ts +0 -21
  64. package/src/__tests__/task-scheduler.test.ts +0 -21
  65. package/src/__tests__/terminal-tools.test.ts +1 -17
  66. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +0 -79
  67. package/src/__tests__/tool-approval-handler.test.ts +1 -20
  68. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -11
  69. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -25
  70. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  71. package/src/__tests__/tool-executor.test.ts +0 -1
  72. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -20
  73. package/src/__tests__/tool-preview-lifecycle.test.ts +0 -20
  74. package/src/__tests__/trust-store.test.ts +9 -41
  75. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -30
  76. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1 -21
  77. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -22
  78. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -22
  79. package/src/__tests__/trusted-contact-verification.test.ts +0 -22
  80. package/src/__tests__/turn-boundary-resolution.test.ts +0 -28
  81. package/src/__tests__/twilio-provider.test.ts +0 -16
  82. package/src/__tests__/twilio-routes-twiml.test.ts +7 -12
  83. package/src/__tests__/twilio-routes.test.ts +0 -24
  84. package/src/__tests__/update-bulletin.test.ts +17 -89
  85. package/src/__tests__/usage-cache-backfill-migration.test.ts +0 -20
  86. package/src/__tests__/usage-routes.test.ts +0 -21
  87. package/src/__tests__/user-reference.test.ts +1 -5
  88. package/src/__tests__/vbundle-pax-and-symlink.test.ts +4 -34
  89. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +2 -53
  90. package/src/__tests__/voice-invite-redemption.test.ts +0 -21
  91. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -24
  92. package/src/__tests__/voice-session-bridge.test.ts +0 -21
  93. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -23
  94. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +2 -2
  95. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -23
  96. package/src/__tests__/workspace-migration-down-functions.test.ts +0 -6
  97. package/src/acp/client-handler.ts +1 -2
  98. package/src/cli/__tests__/notifications.test.ts +0 -22
  99. package/src/cli/cli-memory.ts +176 -0
  100. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  101. package/src/cli/commands/oauth/connect.ts +15 -0
  102. package/src/cli/commands/oauth/providers.ts +49 -42
  103. package/src/cli/commands/platform/__tests__/connect.test.ts +2 -48
  104. package/src/cli/commands/platform/__tests__/disconnect.test.ts +2 -48
  105. package/src/cli/commands/platform/__tests__/status.test.ts +0 -50
  106. package/src/config/bundled-skills/computer-use/TOOLS.json +7 -7
  107. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  108. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  109. package/src/config/feature-flag-registry.json +16 -0
  110. package/src/config/loader.ts +4 -0
  111. package/src/config/schemas/security.ts +0 -6
  112. package/src/config/schemas/services.ts +8 -0
  113. package/src/context/window-manager.ts +28 -9
  114. package/src/credential-execution/approval-bridge.ts +0 -1
  115. package/src/daemon/config-watcher.ts +51 -0
  116. package/src/daemon/conversation-agent-loop.ts +3 -2
  117. package/src/daemon/conversation-process.ts +1 -0
  118. package/src/daemon/conversation-usage.ts +1 -0
  119. package/src/daemon/handlers/skills.ts +9 -1
  120. package/src/daemon/lifecycle.ts +13 -4
  121. package/src/daemon/message-types/conversations.ts +1 -0
  122. package/src/daemon/providers-setup.ts +2 -0
  123. package/src/daemon/server.ts +26 -22
  124. package/src/events/domain-events.ts +1 -2
  125. package/src/memory/db-init.ts +9 -0
  126. package/src/memory/job-handlers/batch-extraction.ts +16 -4
  127. package/src/memory/job-handlers/embedding.test.ts +3 -27
  128. package/src/memory/job-handlers/journal-carry-forward.test.ts +1 -29
  129. package/src/memory/llm-usage-store.ts +35 -2
  130. package/src/memory/migrations/201-oauth-providers-feature-flag.ts +11 -0
  131. package/src/memory/migrations/202-drop-callback-transport-column.ts +13 -0
  132. package/src/memory/migrations/index.ts +2 -0
  133. package/src/memory/qdrant-manager.ts +26 -5
  134. package/src/memory/query-expansion.ts +1 -1
  135. package/src/memory/retriever.test.ts +22 -20
  136. package/src/memory/retriever.ts +10 -2
  137. package/src/memory/schema/oauth.ts +1 -1
  138. package/src/memory/search/mmr.ts +8 -5
  139. package/src/memory/slack-thread-store.ts +17 -0
  140. package/src/messaging/providers/outlook/adapter.ts +193 -0
  141. package/src/messaging/providers/outlook/client.ts +311 -0
  142. package/src/messaging/providers/outlook/types.ts +83 -0
  143. package/src/notifications/adapters/slack.ts +1 -1
  144. package/src/oauth/__tests__/identity-verifier.test.ts +1 -1
  145. package/src/oauth/connect-orchestrator.ts +10 -3
  146. package/src/oauth/oauth-store.ts +10 -11
  147. package/src/oauth/provider-serializer.ts +3 -0
  148. package/src/oauth/provider-visibility.ts +16 -0
  149. package/src/oauth/seed-providers.ts +49 -17
  150. package/src/permissions/checker.ts +39 -7
  151. package/src/permissions/types.ts +2 -4
  152. package/src/prompts/journal-context.ts +9 -11
  153. package/src/prompts/system-prompt.ts +3 -64
  154. package/src/prompts/templates/UPDATES.md +6 -0
  155. package/src/runtime/auth/__tests__/credential-service.test.ts +1 -27
  156. package/src/runtime/auth/__tests__/token-service.test.ts +1 -25
  157. package/src/runtime/auth/route-policy.ts +0 -4
  158. package/src/runtime/guardian-reply-router.ts +6 -2
  159. package/src/runtime/routes/conversation-query-routes.ts +2 -58
  160. package/src/runtime/routes/inbound-stages/background-dispatch.ts +43 -2
  161. package/src/runtime/routes/memory-item-routes.test.ts +0 -17
  162. package/src/runtime/routes/memory-item-routes.ts +103 -12
  163. package/src/runtime/routes/oauth-apps.ts +18 -1
  164. package/src/runtime/routes/oauth-providers.ts +13 -1
  165. package/src/runtime/routes/settings-routes.ts +1 -0
  166. package/src/runtime/routes/usage-routes.ts +19 -2
  167. package/src/runtime/routes/work-items-routes.test.ts +0 -21
  168. package/src/runtime/routes/workspace-routes.test.ts +3 -27
  169. package/src/security/secret-allowlist.ts +4 -4
  170. package/src/skills/skill-memory.ts +62 -23
  171. package/src/tools/memory/handlers.test.ts +1 -29
  172. package/src/tools/permission-checker.ts +0 -18
  173. package/src/tools/skills/skill-script-runner.ts +1 -1
  174. package/src/util/device-id.ts +3 -65
  175. package/src/workspace/git-service.ts +27 -6
@@ -1,29 +1,58 @@
1
1
  import { and, eq } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
- import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
5
4
  import { getConfig } from "../config/loader.js";
5
+ import { resolveSkillStates } from "../config/skill-state.js";
6
+ import { loadSkillCatalog, type SkillSummary } from "../config/skills.js";
6
7
  import { getDb } from "../memory/db.js";
7
8
  import { computeMemoryFingerprint } from "../memory/fingerprint.js";
8
9
  import { enqueueMemoryJob } from "../memory/jobs-store.js";
9
10
  import { memoryItems } from "../memory/schema.js";
10
11
  import { getLogger } from "../util/logger.js";
11
- import { type CatalogSkill, resolveCatalog } from "./catalog-install.js";
12
12
 
13
13
  const log = getLogger("skill-memory");
14
14
 
15
15
  /**
16
- * Build a semantically rich capability statement from a catalog skill entry.
16
+ * Generic input for building capability statements.
17
+ * Decoupled from CatalogSkill so other skill sources (e.g. bundled skills) can
18
+ * produce capability memories without being shoehorned into the catalog type.
19
+ */
20
+ export interface SkillCapabilityInput {
21
+ id: string;
22
+ displayName: string;
23
+ description: string;
24
+ activationHints?: string[];
25
+ avoidWhen?: string[];
26
+ }
27
+
28
+ /**
29
+ * Convert a SkillSummary to a SkillCapabilityInput.
30
+ * SkillSummary already has flat properties, so this is a straightforward mapping.
31
+ */
32
+ export function fromSkillSummary(entry: SkillSummary): SkillCapabilityInput {
33
+ return {
34
+ id: entry.id,
35
+ displayName: entry.displayName,
36
+ description: entry.description,
37
+ activationHints: entry.activationHints,
38
+ avoidWhen: entry.avoidWhen,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Build a semantically rich capability statement from a skill capability input.
17
44
  * Truncated to 500 chars max (matching the limit used by memory item extraction).
18
45
  */
19
- export function buildCapabilityStatement(entry: CatalogSkill): string {
20
- const displayName = entry.metadata?.vellum?.["display-name"] ?? entry.name;
21
- const activationHints = entry.metadata?.vellum?.["activation-hints"];
46
+ export function buildCapabilityStatement(input: SkillCapabilityInput): string {
47
+ const { displayName, activationHints, avoidWhen } = input;
22
48
 
23
- let statement = `The "${displayName}" skill (${entry.id}) is available. ${entry.description}.`;
49
+ let statement = `The "${displayName}" skill (${input.id}) is available. ${input.description}.`;
24
50
  if (activationHints && activationHints.length > 0) {
25
51
  statement += ` Use when: ${activationHints.join("; ")}.`;
26
52
  }
53
+ if (avoidWhen && avoidWhen.length > 0) {
54
+ statement += ` Avoid when: ${avoidWhen.join("; ")}.`;
55
+ }
27
56
 
28
57
  // Truncate to 500 chars max
29
58
  if (statement.length > 500) {
@@ -34,17 +63,17 @@ export function buildCapabilityStatement(entry: CatalogSkill): string {
34
63
  }
35
64
 
36
65
  /**
37
- * Upsert a capability memory item for a catalog skill.
66
+ * Upsert a capability memory item for a skill.
38
67
  * Best-effort: errors are logged but never thrown.
39
68
  */
40
69
  export function upsertSkillCapabilityMemory(
41
70
  skillId: string,
42
- entry: CatalogSkill,
71
+ input: SkillCapabilityInput,
43
72
  ): void {
44
73
  try {
45
74
  const db = getDb();
46
75
  const subject = `skill:${skillId}`;
47
- const statement = buildCapabilityStatement(entry);
76
+ const statement = buildCapabilityStatement(input);
48
77
  const kind = "capability";
49
78
  const scopeId = "default";
50
79
  const confidence = 1.0;
@@ -169,30 +198,39 @@ export function deleteSkillCapabilityMemory(skillId: string): void {
169
198
  }
170
199
 
171
200
  /**
172
- * Seed capability memory items for all catalog skills.
173
- * Prunes stale entries whose skills are no longer in the catalog.
201
+ * Seed capability memory items for all enabled skills (bundled, managed, workspace, extra).
202
+ * Prunes stale entries whose skills are no longer in the enabled set.
174
203
  * Best-effort: errors are logged but never thrown.
175
204
  */
176
- export async function seedCatalogSkillMemories(): Promise<void> {
205
+ export function seedCatalogSkillMemories(): void {
177
206
  try {
178
- const catalog = await resolveCatalog();
207
+ const catalog = loadSkillCatalog();
179
208
  const config = getConfig();
209
+ const resolved = resolveSkillStates(catalog, config);
210
+ const enabled = resolved.filter((r) => r.state === "enabled");
211
+
180
212
  const catalogIds = new Set<string>();
213
+ for (const { summary } of enabled) {
214
+ catalogIds.add(summary.id);
215
+ const input = fromSkillSummary(summary);
181
216
 
182
- for (const entry of catalog) {
183
- // Skip skills whose feature flag is disabled
184
- const flagId = entry.metadata?.vellum?.["feature-flag"];
185
- if (flagId) {
186
- if (!isAssistantFeatureFlagEnabled(flagId, config)) {
187
- continue;
217
+ // Enrich mcp-setup description with configured server names
218
+ if (summary.id === "mcp-setup") {
219
+ const servers = config.mcp?.servers;
220
+ if (servers) {
221
+ const names = Object.keys(servers).filter(
222
+ (name) => servers[name]?.enabled !== false,
223
+ );
224
+ if (names.length > 0) {
225
+ input.description += ` Configured: ${names.join(", ")}`;
226
+ }
188
227
  }
189
228
  }
190
229
 
191
- catalogIds.add(entry.id);
192
- upsertSkillCapabilityMemory(entry.id, entry);
230
+ upsertSkillCapabilityMemory(summary.id, input);
193
231
  }
194
232
 
195
- // Prune stale capability memories for skills no longer in catalog
233
+ // Prune stale capability memories for skills no longer in the enabled set
196
234
  const db = getDb();
197
235
  const allCapabilities = db
198
236
  .select()
@@ -208,6 +246,7 @@ export async function seedCatalogSkillMemories(): Promise<void> {
208
246
 
209
247
  const now = Date.now();
210
248
  for (const item of allCapabilities) {
249
+ if (!item.subject.startsWith("skill:")) continue;
211
250
  const itemSkillId = item.subject.replace("skill:", "");
212
251
  if (!catalogIds.has(itemSkillId)) {
213
252
  db.update(memoryItems)
@@ -4,34 +4,10 @@
4
4
  * Covers happy path (multi-source results), empty results, degraded mode
5
5
  * (embeddings/Qdrant unavailable), scope filtering, and error handling.
6
6
  */
7
- import { mkdtempSync, rmSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
- import {
11
- afterAll,
12
- beforeAll,
13
- beforeEach,
14
- describe,
15
- expect,
16
- mock,
17
- test,
18
- } from "bun:test";
19
-
20
- const testDir = mkdtempSync(join(tmpdir(), "memory-recall-handler-"));
7
+ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
21
8
 
22
9
  // ── Module mocks (must precede production imports) ───────────────────
23
10
 
24
- mock.module("../../util/platform.js", () => ({
25
- getDataDir: () => testDir,
26
- isMacOS: () => process.platform === "darwin",
27
- isLinux: () => process.platform === "linux",
28
- isWindows: () => process.platform === "win32",
29
- getPidPath: () => join(testDir, "test.pid"),
30
- getDbPath: () => join(testDir, "test.db"),
31
- getLogPath: () => join(testDir, "test.log"),
32
- ensureDataDir: () => {},
33
- }));
34
-
35
11
  mock.module("../../util/logger.js", () => ({
36
12
  getLogger: () =>
37
13
  new Proxy({} as Record<string, unknown>, {
@@ -259,10 +235,6 @@ describe("handleMemoryRecall", () => {
259
235
  clearEmbeddingBackendCache();
260
236
  });
261
237
 
262
- afterAll(() => {
263
- rmSync(testDir, { recursive: true, force: true });
264
- });
265
-
266
238
  // ── Input validation ──────────────────────────────────────────────
267
239
 
268
240
  test("returns error when query is missing", async () => {
@@ -137,24 +137,6 @@ export class PermissionChecker {
137
137
  }
138
138
 
139
139
  if (result.decision === "prompt") {
140
- // dangerouslySkipPermissions: when enabled, auto-approve all prompts
141
- // without user interaction. Deny rules are still respected (they
142
- // return before reaching this block).
143
- //
144
- // Note: unlike guardian auto-approve and temporary overrides below,
145
- // this intentionally does NOT check `context.requireFreshApproval`.
146
- // The setting is designed to skip ALL interactive prompts
147
- // unconditionally — it is an explicit operator opt-out from the
148
- // permission system, so requireFreshApproval does not apply.
149
- const cfg = getConfig();
150
- if (cfg.permissions.dangerouslySkipPermissions) {
151
- log.info(
152
- { toolName: name, riskLevel },
153
- "dangerouslySkipPermissions active — auto-approving without prompt",
154
- );
155
- return { allowed: true, decision: "dangerously_skip_permissions", riskLevel };
156
- }
157
-
158
140
  // Guardian-trust sessions (e.g. scheduled jobs, reminders) should be
159
141
  // able to use bundled tools without interactive approval. The guardian
160
142
  // is the owner - prompting makes no sense when there is no client.
@@ -40,7 +40,7 @@ export async function runSkillToolScript(
40
40
  context: ToolContext,
41
41
  options?: RunSkillToolScriptOptions,
42
42
  ): Promise<ToolExecutionResult> {
43
- if (options?.target === "sandbox") {
43
+ if (options?.target === "sandbox" && !options?.bundled) {
44
44
  return runSkillToolScriptSandbox(skillDir, executorPath, input, context, {
45
45
  timeoutMs: options.timeoutMs,
46
46
  expectedSkillVersionHash: options.expectedSkillVersionHash,
@@ -8,8 +8,7 @@
8
8
  * Path resolution:
9
9
  * - Containerized (IS_CONTAINERIZED=true): uses /home/assistant (the assistant
10
10
  * user's persistent home dir) so device.json lives on the assistant's own
11
- * filesystem rather than the shared data volume. Falls back to the legacy
12
- * BASE_DATA_DIR location for migration.
11
+ * filesystem rather than the shared data volume.
13
12
  * - Local (single or multi-instance): uses homedir() so all instances on the
14
13
  * same machine share a single device ID.
15
14
  *
@@ -45,22 +44,6 @@ export function getDeviceIdBaseDir(): string {
45
44
  return homedir();
46
45
  }
47
46
 
48
- /**
49
- * Resolve the legacy base directory for device.json migration.
50
- *
51
- * Returns the old containerized path (via BASE_DATA_DIR env var) so we can
52
- * fall back to reading device.json from the shared volume if it hasn't been
53
- * migrated yet. Returns undefined when not containerized or when no legacy
54
- * path exists.
55
- */
56
- function getLegacyDeviceIdBaseDir(): string | undefined {
57
- if (!getIsContainerized()) {
58
- return undefined;
59
- }
60
- const baseDataDir = process.env.BASE_DATA_DIR?.trim() || undefined;
61
- return baseDataDir;
62
- }
63
-
64
47
  /**
65
48
  * Get the stable device ID for this machine.
66
49
  *
@@ -96,55 +79,10 @@ export function getDeviceId(): string {
96
79
  }
97
80
  }
98
81
  } catch (err) {
99
- log.warn({ err }, "Failed to read device.json — checking legacy path");
100
- }
101
-
102
- // Migration fallback: check the legacy location (shared volume) if the new
103
- // location doesn't have a valid device.json yet.
104
- const legacyBase = getLegacyDeviceIdBaseDir();
105
- if (legacyBase) {
106
- const legacyPath = join(legacyBase, ".vellum", "device.json");
107
- try {
108
- if (existsSync(legacyPath)) {
109
- const raw = JSON.parse(readFileSync(legacyPath, "utf-8"));
110
- if (
111
- raw &&
112
- typeof raw === "object" &&
113
- typeof raw.deviceId === "string" &&
114
- raw.deviceId.length > 0
115
- ) {
116
- cached = raw.deviceId as string;
117
- log.info(
118
- { deviceId: cached },
119
- "Resolved device ID from legacy device.json — will persist to new location",
120
- );
121
- // Persist to the new location so future reads don't need the fallback
122
- try {
123
- mkdirSync(vellumDir, { recursive: true });
124
- writeFileSync(
125
- filePath,
126
- JSON.stringify({ deviceId: cached }, null, 2) + "\n",
127
- { mode: 0o644 },
128
- );
129
- log.info("Migrated device.json to new location");
130
- } catch (writeErr) {
131
- log.warn(
132
- { err: writeErr },
133
- "Failed to migrate device.json to new location",
134
- );
135
- }
136
- return cached;
137
- }
138
- }
139
- } catch (err) {
140
- log.warn(
141
- { err },
142
- "Failed to read legacy device.json — generating new device ID",
143
- );
144
- }
82
+ log.warn({ err }, "Failed to read device.json — generating new device ID");
145
83
  }
146
84
 
147
- // Either the file doesn't exist at either location, or deviceId was missing/empty.
85
+ // Either the file doesn't exist or deviceId was missing/empty.
148
86
  // Generate a new UUID and persist it.
149
87
  try {
150
88
  mkdirSync(vellumDir, { recursive: true });
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawnSync } from "node:child_process";
2
2
  import {
3
3
  existsSync,
4
4
  readFileSync,
@@ -258,20 +258,41 @@ export class WorkspaceGitService {
258
258
  }
259
259
 
260
260
  /**
261
- * Remove `.git/index.lock` if it exists.
261
+ * Remove `.git/index.lock` if it exists and no external process holds it.
262
262
  *
263
263
  * This method is always called inside the mutex, so no git operation from
264
- * our code can be concurrently holding the lock. Any lock file present is
265
- * stale left behind by a crashed process or an external command that
266
- * has already exited.
264
+ * our code can be concurrently holding the lock. However, an external git
265
+ * process (user running `git add`, IDE tooling, etc.) could legitimately
266
+ * hold the lock. We use `lsof` to check — if any process has the file
267
+ * open, we leave it alone. If no process holds it, it's stale (crashed
268
+ * process) and safe to remove.
267
269
  */
268
270
  private cleanStaleLockFile(): void {
269
271
  const lockPath = join(this.workspaceDir, ".git", "index.lock");
272
+ if (!existsSync(lockPath)) {
273
+ return;
274
+ }
275
+
276
+ try {
277
+ const result = spawnSync("lsof", ["-t", lockPath], {
278
+ timeout: 3000,
279
+ stdio: ["ignore", "pipe", "ignore"],
280
+ });
281
+ if (result.status === 0 && result.stdout?.length > 0) {
282
+ log.debug("index.lock held by an active process, skipping removal");
283
+ return;
284
+ }
285
+ } catch {
286
+ // lsof unavailable or errored — fall through to remove.
287
+ // On platforms without lsof this degrades to unconditional removal,
288
+ // which is the same as the previous behavior.
289
+ }
290
+
270
291
  try {
271
292
  unlinkSync(lockPath);
272
293
  log.debug("Removed stale index.lock");
273
294
  } catch {
274
- // File doesn't exist or can't be removed — move on.
295
+ // File was removed between check and unlink, or can't be removed — move on.
275
296
  }
276
297
  }
277
298