@vellumai/assistant 0.3.3 → 0.3.4

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 (75) hide show
  1. package/README.md +8 -16
  2. package/package.json +1 -1
  3. package/src/__tests__/call-orchestrator.test.ts +321 -0
  4. package/src/__tests__/channel-approval-routes.test.ts +382 -124
  5. package/src/__tests__/channel-approvals.test.ts +51 -2
  6. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  7. package/src/__tests__/channel-guardian.test.ts +187 -0
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  10. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  11. package/src/__tests__/handlers-twilio-config.test.ts +73 -0
  12. package/src/__tests__/secret-scanner.test.ts +223 -0
  13. package/src/__tests__/shell-parser-property.test.ts +357 -2
  14. package/src/__tests__/system-prompt.test.ts +25 -1
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  16. package/src/__tests__/user-reference.test.ts +68 -0
  17. package/src/calls/call-orchestrator.ts +63 -11
  18. package/src/cli/map.ts +6 -0
  19. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  20. package/src/commands/cc-command-registry.ts +14 -1
  21. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  22. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  23. package/src/config/defaults.ts +1 -1
  24. package/src/config/schema.ts +3 -3
  25. package/src/config/skills.ts +5 -32
  26. package/src/config/system-prompt.ts +16 -0
  27. package/src/config/user-reference.ts +29 -0
  28. package/src/config/vellum-skills/catalog.json +52 -0
  29. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  30. package/src/config/vellum-skills/twilio-setup/SKILL.md +38 -0
  31. package/src/daemon/auth-manager.ts +103 -0
  32. package/src/daemon/computer-use-session.ts +8 -1
  33. package/src/daemon/config-watcher.ts +253 -0
  34. package/src/daemon/handlers/config.ts +36 -13
  35. package/src/daemon/handlers/skills.ts +6 -7
  36. package/src/daemon/ipc-contract.ts +6 -0
  37. package/src/daemon/ipc-handler.ts +87 -0
  38. package/src/daemon/lifecycle.ts +16 -4
  39. package/src/daemon/ride-shotgun-handler.ts +11 -1
  40. package/src/daemon/server.ts +105 -502
  41. package/src/daemon/session-agent-loop.ts +5 -14
  42. package/src/daemon/session-runtime-assembly.ts +60 -44
  43. package/src/daemon/session.ts +8 -1
  44. package/src/memory/db-connection.ts +28 -0
  45. package/src/memory/db-init.ts +1019 -0
  46. package/src/memory/db.ts +2 -2007
  47. package/src/memory/embedding-backend.ts +79 -11
  48. package/src/memory/indexer.ts +2 -0
  49. package/src/memory/job-utils.ts +64 -4
  50. package/src/memory/jobs-worker.ts +7 -1
  51. package/src/memory/recall-cache.ts +107 -0
  52. package/src/memory/retriever.ts +30 -1
  53. package/src/memory/schema-migration.ts +984 -0
  54. package/src/memory/schema.ts +1 -0
  55. package/src/memory/search/types.ts +2 -0
  56. package/src/permissions/prompter.ts +14 -3
  57. package/src/permissions/trust-store.ts +7 -0
  58. package/src/runtime/channel-approvals.ts +17 -3
  59. package/src/runtime/gateway-client.ts +2 -1
  60. package/src/runtime/http-server.ts +15 -4
  61. package/src/runtime/routes/channel-routes.ts +172 -84
  62. package/src/runtime/routes/run-routes.ts +7 -1
  63. package/src/runtime/run-orchestrator.ts +8 -1
  64. package/src/security/secret-scanner.ts +218 -0
  65. package/src/skills/frontmatter.ts +63 -0
  66. package/src/skills/slash-commands.ts +23 -0
  67. package/src/skills/vellum-catalog-remote.ts +107 -0
  68. package/src/tools/browser/auto-navigate.ts +132 -24
  69. package/src/tools/browser/browser-manager.ts +67 -61
  70. package/src/tools/claude-code/claude-code.ts +55 -3
  71. package/src/tools/executor.ts +10 -2
  72. package/src/tools/skills/vellum-catalog.ts +61 -156
  73. package/src/tools/terminal/parser.ts +21 -5
  74. package/src/util/platform.ts +8 -1
  75. package/src/util/retry.ts +4 -4
@@ -191,74 +191,80 @@ class BrowserManager {
191
191
  if (this.contextCreating) return this.contextCreating;
192
192
 
193
193
  this.contextCreating = (async () => {
194
- // Try to detect or negotiate CDP before falling back to headless.
195
- // This auto-detects an existing Chrome with --remote-debugging-port,
196
- // or asks the client to restart Chrome with CDP enabled.
197
- let useCdp = this._browserMode === 'cdp';
198
- const sender = invokingSessionId ? this.sessionSenders.get(invokingSessionId) : undefined;
199
- if (!useCdp) {
200
- const cdpAvailable = await this.detectCDP();
201
- if (cdpAvailable) {
202
- useCdp = true;
203
- } else if (invokingSessionId && sender) {
204
- log.info({ sessionId: invokingSessionId }, 'Requesting CDP from client');
205
- const accepted = await this.requestCDPFromClient(invokingSessionId, sender);
206
- if (accepted) {
207
- const nowAvailable = await this.detectCDP();
208
- if (nowAvailable) {
209
- useCdp = true;
194
+ // Deterministic test mode: when launch is injected via setLaunchFn,
195
+ // bypass ambient CDP probing/negotiation and use the injected launcher.
196
+ const hasInjectedLaunchFn = launchPersistentContext !== null;
197
+
198
+ if (!hasInjectedLaunchFn) {
199
+ // Try to detect or negotiate CDP before falling back to headless.
200
+ // This auto-detects an existing Chrome with --remote-debugging-port,
201
+ // or asks the client to restart Chrome with CDP enabled.
202
+ let useCdp = this._browserMode === 'cdp';
203
+ const sender = invokingSessionId ? this.sessionSenders.get(invokingSessionId) : undefined;
204
+ if (!useCdp) {
205
+ const cdpAvailable = await this.detectCDP();
206
+ if (cdpAvailable) {
207
+ useCdp = true;
208
+ } else if (invokingSessionId && sender) {
209
+ log.info({ sessionId: invokingSessionId }, 'Requesting CDP from client');
210
+ const accepted = await this.requestCDPFromClient(invokingSessionId, sender);
211
+ if (accepted) {
212
+ const nowAvailable = await this.detectCDP();
213
+ if (nowAvailable) {
214
+ useCdp = true;
215
+ } else {
216
+ log.warn('Client accepted CDP request but CDP not detected');
217
+ }
210
218
  } else {
211
- log.warn('Client accepted CDP request but CDP not detected');
219
+ log.info('Client declined CDP request');
212
220
  }
213
- } else {
214
- log.info('Client declined CDP request');
215
221
  }
216
222
  }
217
- }
218
223
 
219
- if (useCdp) {
220
- try {
221
- const pw = await import('playwright');
222
- const browser = await pw.chromium.connectOverCDP(this.cdpUrl, { timeout: 10_000 });
223
- this.cdpBrowser = browser;
224
- this._browserLaunched = false;
225
- const contexts = browser.contexts();
226
- const ctx = contexts[0] || await browser.newContext();
227
- this.setBrowserMode('cdp');
228
- await this.initBrowserCdpSession();
229
- log.info({ cdpUrl: this.cdpUrl }, 'Connected to Chrome via CDP');
230
- return ctx as unknown as BrowserContext;
231
- } catch (err) {
232
- log.warn({ err }, 'CDP connectOverCDP failed');
233
- this._browserMode = 'headless';
224
+ if (useCdp) {
225
+ try {
226
+ const pw = await import('playwright');
227
+ const browser = await pw.chromium.connectOverCDP(this.cdpUrl, { timeout: 10_000 });
228
+ this.cdpBrowser = browser;
229
+ this._browserLaunched = false;
230
+ const contexts = browser.contexts();
231
+ const ctx = contexts[0] || await browser.newContext();
232
+ this.setBrowserMode('cdp');
233
+ await this.initBrowserCdpSession();
234
+ log.info({ cdpUrl: this.cdpUrl }, 'Connected to Chrome via CDP');
235
+ return ctx as unknown as BrowserContext;
236
+ } catch (err) {
237
+ log.warn({ err }, 'CDP connectOverCDP failed');
238
+ this._browserMode = 'headless';
239
+ }
234
240
  }
235
- }
236
241
 
237
- // If a client is connected, launch headed Chromium (minimized) so the user
238
- // can interact directly when handoff triggers (e.g. CAPTCHAs).
239
- // The window stays offscreen until bringToFront() is called during handoff.
240
- const hasSender = !!(invokingSessionId && this.sessionSenders.get(invokingSessionId));
241
- if (hasSender && this._browserMode === 'headless') {
242
- try {
243
- const pw2 = await import('playwright');
244
- const headedBrowser = await pw2.chromium.launch({
245
- channel: 'chrome',
246
- headless: false,
247
- args: [
248
- '--window-position=-32000,-32000',
249
- '--window-size=1,1',
250
- '--disable-blink-features=AutomationControlled',
251
- ],
252
- });
253
- const ctx = headedBrowser.contexts()[0] || await headedBrowser.newContext();
254
- this.cdpBrowser = headedBrowser as unknown as typeof this.cdpBrowser;
255
- this._browserLaunched = true;
256
- this.setBrowserMode('cdp');
257
- await this.initBrowserCdpSession();
258
- log.info('Launched headed Chromium (minimized) for interactive handoff support');
259
- return ctx as unknown as BrowserContext;
260
- } catch (err2) {
261
- log.warn({ err: err2 }, 'Headed Chromium launch failed, falling back to headless');
242
+ // If a client is connected, launch headed Chromium (minimized) so the user
243
+ // can interact directly when handoff triggers (e.g. CAPTCHAs).
244
+ // The window stays offscreen until bringToFront() is called during handoff.
245
+ const hasSender = !!(invokingSessionId && this.sessionSenders.get(invokingSessionId));
246
+ if (hasSender && this._browserMode === 'headless') {
247
+ try {
248
+ const pw2 = await import('playwright');
249
+ const headedBrowser = await pw2.chromium.launch({
250
+ channel: 'chrome',
251
+ headless: false,
252
+ args: [
253
+ '--window-position=-32000,-32000',
254
+ '--window-size=1,1',
255
+ '--disable-blink-features=AutomationControlled',
256
+ ],
257
+ });
258
+ const ctx = headedBrowser.contexts()[0] || await headedBrowser.newContext();
259
+ this.cdpBrowser = headedBrowser as unknown as typeof this.cdpBrowser;
260
+ this._browserLaunched = true;
261
+ this.setBrowserMode('cdp');
262
+ await this.initBrowserCdpSession();
263
+ log.info('Launched headed Chromium (minimized) for interactive handoff support');
264
+ return ctx as unknown as BrowserContext;
265
+ } catch (err2) {
266
+ log.warn({ err: err2 }, 'Headed Chromium launch failed, falling back to headless');
267
+ }
262
268
  }
263
269
  }
264
270
 
@@ -6,6 +6,7 @@ import { getLogger } from '../../util/logger.js';
6
6
  import { truncate } from '../../util/truncate.js';
7
7
  import { getProfilePolicy } from '../../swarm/worker-backend.js';
8
8
  import type { WorkerProfile } from '../../swarm/worker-backend.js';
9
+ import { getCCCommand, loadCCCommandTemplate } from '../../commands/cc-command-registry.js';
9
10
 
10
11
  const log = getLogger('claude-code-tool');
11
12
 
@@ -62,7 +63,15 @@ export const claudeCodeTool: Tool = {
62
63
  properties: {
63
64
  prompt: {
64
65
  type: 'string',
65
- description: 'The coding task or question for Claude Code to work on',
66
+ description: 'The coding task or question for Claude Code to work on. Use this for free-form tasks. Mutually exclusive with command.',
67
+ },
68
+ command: {
69
+ type: 'string',
70
+ description: 'Name of a .claude/commands/*.md command template to execute. The template will be loaded and $ARGUMENTS substituted before execution. Use this instead of prompt when invoking a named CC command.',
71
+ },
72
+ arguments: {
73
+ type: 'string',
74
+ description: 'Arguments to substitute into the command template ($ARGUMENTS placeholder). Only used with the command input.',
66
75
  },
67
76
  working_dir: {
68
77
  type: 'string',
@@ -82,7 +91,6 @@ export const claudeCodeTool: Tool = {
82
91
  description: 'Worker profile that scopes tool access. Defaults to general (backward compatible).',
83
92
  },
84
93
  },
85
- required: ['prompt'],
86
94
  },
87
95
  };
88
96
  },
@@ -92,8 +100,52 @@ export const claudeCodeTool: Tool = {
92
100
  return { content: 'Cancelled', isError: true };
93
101
  }
94
102
 
95
- const prompt = input.prompt as string;
96
103
  const workingDir = (input.working_dir as string) || context.workingDir;
104
+
105
+ // Resolve prompt: either from direct prompt input or by loading a CC command template
106
+ let prompt: string;
107
+ if (input.command != null && typeof input.command !== 'string') {
108
+ return {
109
+ content: `Error: "command" must be a string, got ${typeof input.command}`,
110
+ isError: true,
111
+ };
112
+ }
113
+ const commandName = input.command as string | undefined;
114
+ if (commandName) {
115
+ // Command-template execution path: load .claude/commands/<command>.md,
116
+ // apply $ARGUMENTS substitution, and use the result as the prompt.
117
+ const entry = getCCCommand(workingDir, commandName);
118
+ if (!entry) {
119
+ return {
120
+ content: `Error: CC command "${commandName}" not found. Looked for .claude/commands/${commandName}.md in ${workingDir} and parent directories.`,
121
+ isError: true,
122
+ };
123
+ }
124
+
125
+ let template: string;
126
+ try {
127
+ template = loadCCCommandTemplate(entry);
128
+ } catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ return {
131
+ content: `Error: Failed to load CC command template "${commandName}": ${message}`,
132
+ isError: true,
133
+ };
134
+ }
135
+
136
+ // Substitute $ARGUMENTS placeholder with the provided arguments
137
+ const args = (input.arguments as string) ?? '';
138
+ prompt = template.replace(/\$ARGUMENTS/g, args);
139
+
140
+ log.info({ command: commandName, templatePath: entry.filePath, hasArgs: !!args }, 'Loaded CC command template');
141
+ } else if (typeof input.prompt === 'string') {
142
+ prompt = input.prompt;
143
+ } else {
144
+ return {
145
+ content: 'Error: Either "prompt" or "command" must be provided.',
146
+ isError: true,
147
+ };
148
+ }
97
149
  const resumeSessionId = input.resume as string | undefined;
98
150
  const model = (input.model as string) || 'claude-sonnet-4-6';
99
151
  const profileName = (input.profile as WorkerProfile | undefined) ?? 'general';
@@ -268,7 +268,15 @@ export class ToolExecutor {
268
268
  });
269
269
 
270
270
  if (response.decision === 'deny') {
271
- const denialMessage = `Permission denied by user. The user chose not to allow the "${name}" tool. Do NOT retry this tool call immediately. Instead, tell the user that the action was not performed because they denied permission, and ask if they would like you to try again or take a different approach. Wait for the user to explicitly respond before retrying.`;
271
+ const contextualDenial = typeof response.decisionContext === 'string'
272
+ ? response.decisionContext.trim()
273
+ : '';
274
+ const denialMessage = contextualDenial.length > 0
275
+ ? contextualDenial
276
+ : `Permission denied by user. The user chose not to allow the "${name}" tool. Do NOT retry this tool call immediately. Instead, tell the user that the action was not performed because they denied permission, and ask if they would like you to try again or take a different approach. Wait for the user to explicitly respond before retrying.`;
277
+ const denialReason = contextualDenial.length > 0
278
+ ? `Permission denied (${name}): contextual policy`
279
+ : 'Permission denied by user';
272
280
  const durationMs = Date.now() - startTime;
273
281
  emitLifecycleEvent(context, {
274
282
  type: 'permission_denied',
@@ -281,7 +289,7 @@ export class ToolExecutor {
281
289
  requestId: context.requestId,
282
290
  riskLevel,
283
291
  decision: 'deny',
284
- reason: 'Permission denied by user',
292
+ reason: denialReason,
285
293
  durationMs,
286
294
  });
287
295
  return { content: denialMessage, isError: true };
@@ -1,20 +1,10 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
3
-
4
1
  import { RiskLevel } from '../../permissions/types.js';
5
2
  import type { ToolDefinition } from '../../providers/types.js';
3
+ import { parseFrontmatterFields } from '../../skills/frontmatter.js';
6
4
  import { createManagedSkill } from '../../skills/managed-store.js';
7
- import { getLogger } from '../../util/logger.js';
5
+ import { fetchCatalogEntries, fetchSkillContent, checkVellumSkill } from '../../skills/vellum-catalog-remote.js';
8
6
  import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
9
7
 
10
- const log = getLogger('vellum-skills-catalog');
11
-
12
- const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
13
-
14
- function getVellumSkillsDir(): string {
15
- return join(import.meta.dir, '..', '..', 'config', 'vellum-skills');
16
- }
17
-
18
8
  export interface CatalogEntry {
19
9
  id: string;
20
10
  name: string;
@@ -23,123 +13,75 @@ export interface CatalogEntry {
23
13
  includes?: string[];
24
14
  }
25
15
 
26
- function parseCatalogEntry(directory: string): CatalogEntry | null {
27
- const skillFilePath = join(directory, 'SKILL.md');
28
- if (!existsSync(skillFilePath)) return null;
29
-
30
- try {
31
- const stat = statSync(skillFilePath);
32
- if (!stat.isFile()) return null;
33
-
34
- const content = readFileSync(skillFilePath, 'utf-8');
35
- const match = content.match(FRONTMATTER_REGEX);
36
- if (!match) return null;
37
-
38
- const fields: Record<string, string> = {};
39
- for (const line of match[1].split(/\r?\n/)) {
40
- const trimmed = line.trim();
41
- if (!trimmed || trimmed.startsWith('#')) continue;
42
- const separatorIndex = trimmed.indexOf(':');
43
- if (separatorIndex === -1) continue;
44
-
45
- const key = trimmed.slice(0, separatorIndex).trim();
46
- let value = trimmed.slice(separatorIndex + 1).trim();
47
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
48
- value = value.slice(1, -1);
49
- }
50
- fields[key] = value;
51
- }
52
-
53
- const name = fields.name?.trim();
54
- const description = fields.description?.trim();
55
- if (!name || !description) return null;
16
+ export { fetchCatalogEntries as listCatalogEntries, checkVellumSkill };
56
17
 
57
- let emoji: string | undefined;
58
- const metadataRaw = fields.metadata?.trim();
59
- if (metadataRaw) {
60
- try {
61
- const parsed = JSON.parse(metadataRaw);
62
- if (parsed?.vellum?.emoji) {
63
- emoji = parsed.vellum.emoji as string;
64
- }
65
- } catch {
66
- // ignore malformed metadata
67
- }
68
- }
69
-
70
- let includes: string[] | undefined;
71
- const includesRaw = fields.includes?.trim();
72
- if (includesRaw) {
73
- try {
74
- const parsed = JSON.parse(includesRaw);
75
- if (Array.isArray(parsed) && parsed.every((item: unknown) => typeof item === 'string')) {
76
- const filtered = (parsed as string[]).map((s) => s.trim()).filter((s) => s.length > 0);
77
- if (filtered.length > 0) includes = filtered;
78
- }
79
- } catch {
80
- // ignore malformed includes
81
- }
82
- }
18
+ /**
19
+ * Install a skill from the vellum-skills catalog by ID.
20
+ * Fetches SKILL.md from GitHub (with bundled fallback) and creates a managed skill.
21
+ * Returns { success, skillName, error }.
22
+ */
23
+ export async function installFromVellumCatalog(skillId: string, options?: { overwrite?: boolean }): Promise<{ success: boolean; skillName?: string; error?: string }> {
24
+ const trimmedId = skillId.trim();
83
25
 
84
- return { id: basename(directory), name, description, emoji, includes };
85
- } catch (err) {
86
- log.warn({ err, directory }, 'Failed to read catalog entry');
87
- return null;
26
+ // Verify skill exists in catalog
27
+ const exists = await checkVellumSkill(trimmedId);
28
+ if (!exists) {
29
+ return { success: false, error: `Skill "${trimmedId}" not found in the Vellum catalog` };
88
30
  }
89
- }
90
-
91
- export function listCatalogEntries(): CatalogEntry[] {
92
- const catalogDir = getVellumSkillsDir();
93
- if (!existsSync(catalogDir)) return [];
94
31
 
95
- const entries: CatalogEntry[] = [];
96
- try {
97
- const dirEntries = readdirSync(catalogDir, { withFileTypes: true });
98
- for (const entry of dirEntries) {
99
- if (!entry.isDirectory()) continue;
100
- const parsed = parseCatalogEntry(join(catalogDir, entry.name));
101
- if (parsed) entries.push(parsed);
102
- }
103
- } catch (err) {
104
- log.warn({ err, catalogDir }, 'Failed to list catalog entries');
32
+ // Fetch SKILL.md content (remote with bundled fallback)
33
+ const content = await fetchSkillContent(trimmedId);
34
+ if (!content) {
35
+ return { success: false, error: `Skill "${trimmedId}" SKILL.md not found` };
105
36
  }
106
37
 
107
- return entries.sort((a, b) => a.id.localeCompare(b.id));
108
- }
38
+ const parsed = parseFrontmatterFields(content);
39
+ if (!parsed) {
40
+ return { success: false, error: `Skill "${trimmedId}" has invalid SKILL.md` };
41
+ }
109
42
 
110
- /**
111
- * Install a skill from the vellum-skills catalog by ID.
112
- * Returns { success, skillName, error }.
113
- */
114
- export function installFromVellumCatalog(skillId: string): { success: boolean; skillName?: string; error?: string } {
115
- const catalogDir = getVellumSkillsDir();
116
- const skillDir = join(catalogDir, skillId.trim());
117
- const skillFilePath = join(skillDir, 'SKILL.md');
43
+ const { fields, body: bodyMarkdown } = parsed;
118
44
 
119
- if (!existsSync(skillFilePath)) {
120
- return { success: false, error: `Skill "${skillId}" not found in the Vellum catalog` };
45
+ const name = fields.name?.trim();
46
+ const description = fields.description?.trim();
47
+ if (!name || !description) {
48
+ return { success: false, error: `Skill "${trimmedId}" has invalid SKILL.md (missing name or description)` };
121
49
  }
122
50
 
123
- const content = readFileSync(skillFilePath, 'utf-8');
124
- const match = content.match(FRONTMATTER_REGEX);
125
- if (!match) {
126
- return { success: false, error: `Skill "${skillId}" has invalid SKILL.md` };
51
+ let emoji: string | undefined;
52
+ const metadataRaw = fields.metadata?.trim();
53
+ if (metadataRaw) {
54
+ try {
55
+ const metaObj = JSON.parse(metadataRaw);
56
+ if (metaObj?.vellum?.emoji) {
57
+ emoji = metaObj.vellum.emoji as string;
58
+ }
59
+ } catch {
60
+ // ignore malformed metadata
61
+ }
127
62
  }
128
63
 
129
- const entry = parseCatalogEntry(skillDir);
130
- if (!entry) {
131
- return { success: false, error: `Skill "${skillId}" has invalid SKILL.md` };
64
+ let includes: string[] | undefined;
65
+ const includesRaw = fields.includes?.trim();
66
+ if (includesRaw) {
67
+ try {
68
+ const includesObj = JSON.parse(includesRaw);
69
+ if (Array.isArray(includesObj) && includesObj.every((item: unknown) => typeof item === 'string')) {
70
+ const filtered = (includesObj as string[]).map((s) => s.trim()).filter((s) => s.length > 0);
71
+ if (filtered.length > 0) includes = filtered;
72
+ }
73
+ } catch {
74
+ // ignore malformed includes
75
+ }
132
76
  }
133
-
134
- const bodyMarkdown = content.slice(match[0].length);
135
77
  const result = createManagedSkill({
136
- id: entry.id,
137
- name: entry.name,
138
- description: entry.description,
78
+ id: trimmedId,
79
+ name,
80
+ description,
139
81
  bodyMarkdown,
140
- emoji: entry.emoji,
141
- includes: entry.includes,
142
- overwrite: true,
82
+ emoji,
83
+ includes,
84
+ overwrite: options?.overwrite ?? true,
143
85
  addToIndex: true,
144
86
  });
145
87
 
@@ -147,7 +89,7 @@ export function installFromVellumCatalog(skillId: string): { success: boolean; s
147
89
  return { success: false, error: result.error };
148
90
  }
149
91
 
150
- return { success: true, skillName: entry.id };
92
+ return { success: true, skillName: trimmedId };
151
93
  }
152
94
 
153
95
  class VellumSkillsCatalogTool implements Tool {
@@ -187,7 +129,7 @@ class VellumSkillsCatalogTool implements Tool {
187
129
 
188
130
  switch (action) {
189
131
  case 'list': {
190
- const entries = listCatalogEntries();
132
+ const entries = await fetchCatalogEntries();
191
133
  if (entries.length === 0) {
192
134
  return { content: 'No Vellum-provided skills available in the catalog.', isError: false };
193
135
  }
@@ -200,52 +142,15 @@ class VellumSkillsCatalogTool implements Tool {
200
142
  return { content: 'Error: skill_id is required for install action', isError: true };
201
143
  }
202
144
 
203
- const catalogDir = getVellumSkillsDir();
204
- const skillDir = join(catalogDir, skillId.trim());
205
- const skillFilePath = join(skillDir, 'SKILL.md');
206
-
207
- if (!existsSync(skillFilePath)) {
208
- const available = listCatalogEntries().map((e) => e.id);
209
- return {
210
- content: `Error: skill "${skillId}" not found in the Vellum catalog. Available: ${available.join(', ') || 'none'}`,
211
- isError: true,
212
- };
213
- }
214
-
215
- const content = readFileSync(skillFilePath, 'utf-8');
216
- const match = content.match(FRONTMATTER_REGEX);
217
- if (!match) {
218
- return { content: `Error: skill "${skillId}" has invalid SKILL.md (missing frontmatter)`, isError: true };
219
- }
220
-
221
- const entry = parseCatalogEntry(skillDir);
222
- if (!entry) {
223
- return { content: `Error: skill "${skillId}" has invalid SKILL.md`, isError: true };
224
- }
225
-
226
- const bodyMarkdown = content.slice(match[0].length);
227
-
228
- const result = createManagedSkill({
229
- id: entry.id,
230
- name: entry.name,
231
- description: entry.description,
232
- bodyMarkdown,
233
- emoji: entry.emoji,
234
- includes: entry.includes,
235
- overwrite: input.overwrite === true,
236
- addToIndex: true,
237
- });
238
-
239
- if (!result.created) {
145
+ const result = await installFromVellumCatalog(skillId, { overwrite: input.overwrite === true });
146
+ if (!result.success) {
240
147
  return { content: `Error: ${result.error}`, isError: true };
241
148
  }
242
149
 
243
150
  return {
244
151
  content: JSON.stringify({
245
152
  installed: true,
246
- skill_id: entry.id,
247
- name: entry.name,
248
- path: result.path,
153
+ skill_id: result.skillName,
249
154
  }),
250
155
  isError: false,
251
156
  };
@@ -36,7 +36,7 @@ export interface ParsedCommand {
36
36
  }
37
37
 
38
38
  const SHELL_PROGRAMS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish']);
39
- const OPAQUE_PROGRAMS = new Set(['eval', 'source']);
39
+ const OPAQUE_PROGRAMS = new Set(['eval', 'source', 'alias']);
40
40
  const DANGEROUS_ENV_VARS = new Set([
41
41
  'LD_PRELOAD', 'LD_LIBRARY_PATH',
42
42
  'DYLD_INSERT_LIBRARIES', 'DYLD_LIBRARY_PATH', 'DYLD_FRAMEWORK_PATH',
@@ -89,17 +89,33 @@ const initGuard = new PromiseGuard<void>();
89
89
  */
90
90
  function findWasmPath(pkg: string, file: string): string {
91
91
  const dir = import.meta.dirname ?? __dirname;
92
- const sourcePath = join(dir, '..', '..', '..', 'node_modules', pkg, file);
93
92
 
94
- if (!existsSync(sourcePath) && dir.startsWith('/$bunfs/')) {
93
+ // In compiled Bun binaries, import.meta.dirname points into the virtual
94
+ // /$bunfs/ filesystem. Prefer bundled WASM assets shipped alongside the
95
+ // executable before falling back to process.cwd(), so we never accidentally
96
+ // pick up a mismatched version from the working directory.
97
+ if (dir.startsWith('/$bunfs/')) {
95
98
  const execDir = dirname(process.execPath);
96
99
  // macOS .app bundle: binary is in Contents/MacOS/, resources in Contents/Resources/
97
100
  const resourcesPath = join(execDir, '..', 'Resources', file);
98
101
  if (existsSync(resourcesPath)) return resourcesPath;
99
- // Fallback: next to the binary itself (non-app-bundle deployments)
100
- return join(execDir, file);
102
+ // Next to the binary itself (non-app-bundle deployments)
103
+ const execDirPath = join(execDir, file);
104
+ if (existsSync(execDirPath)) return execDirPath;
105
+ // Last resort: resolve from process.cwd() (the assistant/ directory)
106
+ const cwdPath = join(process.cwd(), 'node_modules', pkg, file);
107
+ if (existsSync(cwdPath)) return cwdPath;
108
+ return execDirPath;
101
109
  }
102
110
 
111
+ const sourcePath = join(dir, '..', '..', '..', 'node_modules', pkg, file);
112
+
113
+ if (existsSync(sourcePath)) return sourcePath;
114
+
115
+ // Fallback: resolve from process.cwd() (the assistant/ directory).
116
+ const cwdPath = join(process.cwd(), 'node_modules', pkg, file);
117
+ if (existsSync(cwdPath)) return cwdPath;
118
+
103
119
  return sourcePath;
104
120
  }
105
121
 
@@ -1,4 +1,4 @@
1
- import { mkdirSync, existsSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
1
+ import { mkdirSync, existsSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, readdirSync, chmodSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  /**
@@ -565,6 +565,13 @@ export function ensureDataDir(): void {
565
565
  mkdirSync(dir, { recursive: true });
566
566
  }
567
567
  }
568
+ // Lock down the root directory so only the owner can traverse it.
569
+ // Runtime files (socket, session token, PID) live directly under root.
570
+ try {
571
+ chmodSync(root, 0o700);
572
+ } catch {
573
+ // Non-fatal: some filesystems don't support Unix permissions
574
+ }
568
575
  }
569
576
 
570
577
  /**
package/src/util/retry.ts CHANGED
@@ -58,10 +58,10 @@ export function getHttpRetryDelay(
58
58
  const parsed = parseRetryAfterMs(retryAfter);
59
59
  if (parsed !== undefined) return parsed;
60
60
  }
61
- // Enforce a minimum floor of baseDelayMs when Retry-After is missing.
62
- // computeRetryDelay uses equal jitter (cap/2 + random*cap/2) which can
63
- // dip to ~500ms on attempt 0 too aggressive for 429 rate limits.
64
- return Math.max(baseDelayMs, computeRetryDelay(attempt, baseDelayMs));
61
+ // For attempt 0, double the base so jitter range [baseDelayMs, 2*baseDelayMs) stays above the floor.
62
+ // For attempt >= 1, use the original base — jitter is already above baseDelayMs.
63
+ const effectiveBase = attempt === 0 ? baseDelayMs * 2 : baseDelayMs;
64
+ return Math.max(baseDelayMs, computeRetryDelay(attempt, effectiveBase));
65
65
  }
66
66
 
67
67
  /**