wave-agent-sdk 0.19.9 → 1.0.1

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 (89) hide show
  1. package/builtin/plugins/sdd/scripts/session-start.js +1 -1
  2. package/builtin/plugins/sdd/skills/specify/SKILL.md +3 -4
  3. package/builtin/skills/settings/ENV.md +15 -9
  4. package/builtin/skills/settings/HOOKS.md +27 -2
  5. package/dist/agent.d.ts +9 -20
  6. package/dist/agent.js +28 -99
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/managers/aiManager.d.ts +71 -8
  10. package/dist/managers/aiManager.js +290 -85
  11. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  12. package/dist/managers/backgroundTaskManager.js +11 -0
  13. package/dist/managers/bangManager.d.ts +6 -0
  14. package/dist/managers/bangManager.js +11 -0
  15. package/dist/managers/hookManager.d.ts +8 -2
  16. package/dist/managers/hookManager.js +14 -4
  17. package/dist/managers/mcpManager.d.ts +18 -4
  18. package/dist/managers/mcpManager.js +40 -18
  19. package/dist/managers/messageManager.d.ts +9 -5
  20. package/dist/managers/messageManager.js +36 -12
  21. package/dist/managers/subagentManager.d.ts +6 -0
  22. package/dist/managers/subagentManager.js +33 -22
  23. package/dist/managers/toolManager.js +5 -0
  24. package/dist/prompts/index.d.ts +0 -1
  25. package/dist/prompts/index.js +0 -4
  26. package/dist/services/aiService.d.ts +1 -34
  27. package/dist/services/aiService.js +18 -130
  28. package/dist/services/autoMemoryService.d.ts +27 -2
  29. package/dist/services/autoMemoryService.js +124 -36
  30. package/dist/services/configurationService.d.ts +21 -2
  31. package/dist/services/configurationService.js +86 -24
  32. package/dist/services/initializationService.js +14 -4
  33. package/dist/services/interactionService.js +35 -7
  34. package/dist/services/remoteSettingsService.d.ts +12 -0
  35. package/dist/services/remoteSettingsService.js +15 -1
  36. package/dist/services/session.d.ts +13 -0
  37. package/dist/services/session.js +64 -0
  38. package/dist/services/taskManager.js +7 -1
  39. package/dist/tools/bashTool.js +1 -0
  40. package/dist/tools/enterWorktreeTool.js +14 -3
  41. package/dist/tools/exitWorktreeTool.js +11 -10
  42. package/dist/tools/types.d.ts +7 -0
  43. package/dist/types/agent.d.ts +0 -2
  44. package/dist/types/config.d.ts +9 -0
  45. package/dist/types/core.d.ts +1 -1
  46. package/dist/types/hooks.d.ts +2 -2
  47. package/dist/utils/containerSetup.js +13 -4
  48. package/dist/utils/openaiClient.js +2 -1
  49. package/dist/utils/pathEncoder.js +7 -2
  50. package/dist/utils/worktreeUtils.d.ts +17 -0
  51. package/dist/utils/worktreeUtils.js +339 -1
  52. package/package.json +1 -1
  53. package/src/agent.ts +43 -112
  54. package/src/index.ts +1 -0
  55. package/src/managers/aiManager.ts +389 -110
  56. package/src/managers/backgroundTaskManager.ts +15 -0
  57. package/src/managers/bangManager.ts +15 -0
  58. package/src/managers/hookManager.ts +20 -5
  59. package/src/managers/mcpManager.ts +60 -18
  60. package/src/managers/messageManager.ts +51 -23
  61. package/src/managers/subagentManager.ts +36 -25
  62. package/src/managers/toolManager.ts +7 -0
  63. package/src/prompts/index.ts +0 -4
  64. package/src/services/aiService.ts +25 -203
  65. package/src/services/autoMemoryService.ts +145 -39
  66. package/src/services/configurationService.ts +100 -24
  67. package/src/services/initializationService.ts +17 -4
  68. package/src/services/interactionService.ts +49 -6
  69. package/src/services/remoteSettingsService.ts +16 -1
  70. package/src/services/session.ts +68 -0
  71. package/src/services/taskManager.ts +10 -1
  72. package/src/tools/bashTool.ts +1 -0
  73. package/src/tools/enterWorktreeTool.ts +19 -2
  74. package/src/tools/exitWorktreeTool.ts +15 -12
  75. package/src/tools/types.ts +7 -0
  76. package/src/types/agent.ts +0 -6
  77. package/src/types/config.ts +9 -0
  78. package/src/types/core.ts +1 -1
  79. package/src/types/hooks.ts +2 -2
  80. package/src/utils/containerSetup.ts +15 -5
  81. package/src/utils/openaiClient.ts +2 -0
  82. package/src/utils/pathEncoder.ts +7 -2
  83. package/src/utils/worktreeUtils.ts +401 -1
  84. package/dist/constants/goalPrompts.d.ts +0 -1
  85. package/dist/constants/goalPrompts.js +0 -10
  86. package/dist/managers/goalManager.d.ts +0 -42
  87. package/dist/managers/goalManager.js +0 -177
  88. package/src/constants/goalPrompts.ts +0 -10
  89. package/src/managers/goalManager.ts +0 -232
@@ -54,8 +54,8 @@ export declare class HookConfigurationError extends Error {
54
54
  readonly validationErrors: string[];
55
55
  constructor(configPath: string, validationErrors: string[]);
56
56
  }
57
- export type SessionStartSource = "startup" | "compact" | "clear";
58
- export type SessionEndSource = "exit" | "stop" | "compact" | "clear";
57
+ export type SessionStartSource = "startup" | "resume" | "compact" | "clear";
58
+ export type SessionEndSource = "exit" | "resume" | "stop" | "compact" | "clear";
59
59
  export declare function isValidHookEvent(event: string): event is HookEvent;
60
60
  export declare function isValidHookCommand(cmd: unknown): cmd is HookCommand;
61
61
  export declare function isValidHookEventConfig(config: unknown): config is HookEventConfig;
@@ -16,7 +16,6 @@ import { SlashCommandManager } from "../managers/slashCommandManager.js";
16
16
  import { PluginManager } from "../managers/pluginManager.js";
17
17
  import { BangManager } from "../managers/bangManager.js";
18
18
  import { CronManager } from "../managers/cronManager.js";
19
- import { GoalManager } from "../managers/goalManager.js";
20
19
  import { WorkflowManager } from "../managers/workflowManager.js";
21
20
  import { MemoryRuleManager } from "../managers/MemoryRuleManager.js";
22
21
  import { ReversionManager } from "../managers/reversionManager.js";
@@ -24,6 +23,7 @@ import { SubagentManager } from "../managers/subagentManager.js";
24
23
  import { ForkedAgentManager } from "../managers/forkedAgentManager.js";
25
24
  import { LiveConfigManager } from "../managers/liveConfigManager.js";
26
25
  import { ReversionService } from "../services/reversionService.js";
26
+ import { cleanupMetaOnlySessions } from "../services/session.js";
27
27
  import { MemoryService } from "../services/memory.js";
28
28
  import { AutoMemoryService } from "../services/autoMemoryService.js";
29
29
  import { USER_MEMORY_FILE } from "./constants.js";
@@ -144,6 +144,17 @@ export function setupAgentContainer(setupOptions) {
144
144
  reversionService.cleanupOldSessions(30).catch((error) => {
145
145
  logger.error("Failed to cleanup old file history:", error);
146
146
  });
147
+ // Remove pre-existing meta-only session files (SessionStart hook context
148
+ // with no real messages) that were persisted before lazy materialization.
149
+ cleanupMetaOnlySessions()
150
+ .then((count) => {
151
+ if (count > 0) {
152
+ logger.debug(`Removed ${count} meta-only session file(s)`);
153
+ }
154
+ })
155
+ .catch((error) => {
156
+ logger.error("Failed to cleanup meta-only session files:", error);
157
+ });
147
158
  const reversionManager = new ReversionManager(container);
148
159
  container.register("ReversionManager", reversionManager);
149
160
  const canUseToolWithPermissionRequest = options.canUseTool
@@ -159,7 +170,7 @@ export function setupAgentContainer(setupOptions) {
159
170
  toolName: context.toolName,
160
171
  toolInput: context.toolInput,
161
172
  planFilePath: permissionManager.getPlanFilePath(),
162
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
173
+ env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
163
174
  });
164
175
  if (results.length > 0) {
165
176
  const processResult = hookManager.processHookResults("PermissionRequest", results, messageManager);
@@ -255,8 +266,6 @@ export function setupAgentContainer(setupOptions) {
255
266
  const cronManager = new CronManager(container, messageManager.getSessionId());
256
267
  container.register("CronManager", cronManager);
257
268
  cronManager.start();
258
- const goalManager = new GoalManager(container);
259
- container.register("GoalManager", goalManager);
260
269
  const workflowManager = new WorkflowManager(container);
261
270
  container.register("WorkflowManager", workflowManager);
262
271
  return container;
@@ -36,11 +36,12 @@ export class OpenAIClient {
36
36
  };
37
37
  }
38
38
  async _create(params, options) {
39
- const { baseURL, apiKey, defaultHeaders, fetchOptions, fetch: customFetch, } = this.config;
39
+ const { baseURL, apiKey, sessionId, defaultHeaders, fetchOptions, fetch: customFetch, } = this.config;
40
40
  const url = `${baseURL}/chat/completions`;
41
41
  const headers = {
42
42
  "Content-Type": "application/json",
43
43
  ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
44
+ ...(sessionId ? { "x-session-id": sessionId } : {}),
44
45
  ...defaultHeaders,
45
46
  };
46
47
  const fetchFn = customFetch || fetch;
@@ -127,8 +127,13 @@ export class PathEncoder {
127
127
  // If realpath fails, use the absolute path
128
128
  resolvedPath = absolutePath;
129
129
  }
130
- // Encode the resolved path
131
- const encodedName = await this.encode(resolvedPath);
130
+ // Encode the resolved path. Use encodeSync (pure string transform) rather
131
+ // than encode(): realpath was already attempted above with its own fallback,
132
+ // so re-resolving via encode()->resolvePath() would throw ENOENT again when
133
+ // the workdir no longer exists (e.g. a deleted worktree during agent
134
+ // shutdown). Session files live under <baseSessionDir>/<encoded>/ regardless
135
+ // of the workdir's existence, so encoding must not require it to exist.
136
+ const encodedName = this.encodeSync(resolvedPath);
132
137
  const encodedPath = join(baseSessionDir, encodedName);
133
138
  // Generate hash if encoding resulted in truncation
134
139
  let pathHash;
@@ -33,10 +33,27 @@ export declare function getHeadCommit(cwd: string): string;
33
33
  export declare function createWorktree(name: string, cwd: string, options?: {
34
34
  baseRef?: "fresh" | "head";
35
35
  }): WorktreeInfo;
36
+ /**
37
+ * Set up a freshly created worktree: copy local settings and gitignored
38
+ * project files (via .worktreeinclude) from the main repo. Best-effort — any
39
+ * failure only logs a warning and never fails worktree creation.
40
+ */
41
+ export declare function performPostCreationSetup(worktreePath: string, repoRoot: string): Promise<void>;
36
42
  /**
37
43
  * Remove a git worktree and its branch.
38
44
  */
39
45
  export declare function removeWorktree(info: WorktreeInfo): void;
46
+ /**
47
+ * Validate that a worktree path is safe to remove before running git removal.
48
+ * Aligns with Claude Code v2.1.216+ background-session checks:
49
+ * - rejects a path whose final component is a symlink;
50
+ * - rejects a path that resolves outside the repo root.
51
+ *
52
+ * A path that no longer exists (worktree already removed) is allowed so that
53
+ * removal stays best-effort/idempotent; its nearest existing ancestor is used
54
+ * for the containment check. Throws an Error on invalid paths.
55
+ */
56
+ export declare function validateWorktreeRemovalPath(worktreePath: string, repoRoot: string): void;
40
57
  /**
41
58
  * Count uncommitted files and new commits in a worktree.
42
59
  * Returns null if git commands fail (fail-closed).
@@ -2,11 +2,16 @@
2
2
  * Git worktree creation and removal utilities for the SDK.
3
3
  * Used by EnterWorktree and ExitWorktree tools.
4
4
  */
5
- import { execFileSync } from "node:child_process";
5
+ import { execFile, execFileSync } from "node:child_process";
6
+ import { promisify } from "node:util";
6
7
  import * as path from "node:path";
7
8
  import * as fs from "node:fs";
8
9
  import { getGitMainRepoRoot, getDefaultRemoteBranch, ensureWaveRuntimeFilesExcluded, } from "./gitUtils.js";
9
10
  import { logger } from "./globalLogger.js";
11
+ // Post-creation setup runs inside the shared `wave --stdio` process too
12
+ // (desktop sessions), so use the async execFile: a synchronous git call
13
+ // (e.g. `git ls-files` over a large working tree) would freeze every session.
14
+ const execFileAsync = promisify(execFile);
10
15
  /**
11
16
  * Validate a worktree name to prevent path traversal and invalid characters.
12
17
  */
@@ -211,6 +216,288 @@ export function createWorktree(name, cwd, options) {
211
216
  throw new Error(`Failed to create worktree: ${error.message}\n${stderr}`);
212
217
  }
213
218
  }
219
+ /** Translate a single gitignore glob into a RegExp (gitignore semantics). */
220
+ function globToRegExp(glob) {
221
+ let source = "^";
222
+ for (let i = 0; i < glob.length; i++) {
223
+ const ch = glob[i];
224
+ if (ch === "*") {
225
+ if (glob[i + 1] === "*") {
226
+ if (glob[i + 2] === "/") {
227
+ // "**/" matches zero or more leading directories
228
+ source += "(?:.*/)?";
229
+ i += 2;
230
+ }
231
+ else {
232
+ source += ".*";
233
+ i += 1;
234
+ }
235
+ }
236
+ else {
237
+ source += "[^/]*";
238
+ }
239
+ }
240
+ else if (ch === "?") {
241
+ source += "[^/]";
242
+ }
243
+ else if (ch === "[") {
244
+ const end = glob.indexOf("]", i + 1);
245
+ if (end === -1) {
246
+ source += "\\[";
247
+ }
248
+ else {
249
+ let charClass = glob.slice(i, end + 1);
250
+ if (charClass.startsWith("[!")) {
251
+ // gitignore uses "[!...]" for a negated character class
252
+ charClass = "[^" + charClass.slice(2);
253
+ }
254
+ source += charClass;
255
+ i = end;
256
+ }
257
+ }
258
+ else {
259
+ source += ch.replace(/[.+^${}()|\\]/g, "\\$&");
260
+ }
261
+ }
262
+ source += "$";
263
+ return new RegExp(source);
264
+ }
265
+ /**
266
+ * Parse .worktreeinclude content into patterns. Blank lines and "#" comments
267
+ * are skipped, matching gitignore conventions.
268
+ */
269
+ function parseWorktreeIncludePatterns(content) {
270
+ const patterns = [];
271
+ for (const rawLine of content.split(/\r?\n/)) {
272
+ const line = rawLine.trim();
273
+ if (!line || line.startsWith("#"))
274
+ continue;
275
+ let raw = line;
276
+ const negated = raw.startsWith("!");
277
+ if (negated)
278
+ raw = raw.slice(1);
279
+ const dirOnly = raw.endsWith("/");
280
+ if (dirOnly)
281
+ raw = raw.slice(0, -1);
282
+ const anchored = raw.startsWith("/");
283
+ if (anchored)
284
+ raw = raw.slice(1);
285
+ if (!raw)
286
+ continue;
287
+ patterns.push({
288
+ raw,
289
+ negated,
290
+ dirOnly,
291
+ anchored,
292
+ anyLevel: !anchored && !raw.includes("/"),
293
+ regex: globToRegExp(raw),
294
+ });
295
+ }
296
+ return patterns;
297
+ }
298
+ /** Test a single pattern against one candidate path. */
299
+ function testPatternAgainst(pattern, candidate) {
300
+ if (pattern.anyLevel) {
301
+ // Match the full path or any "/"-suffix (basename at any level)
302
+ if (pattern.regex.test(candidate))
303
+ return true;
304
+ for (let i = 0; i < candidate.length; i++) {
305
+ if (candidate[i] === "/" && pattern.regex.test(candidate.slice(i + 1))) {
306
+ return true;
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+ return pattern.regex.test(candidate);
312
+ }
313
+ /**
314
+ * Test a pattern against a path and every ancestor prefix. Excluding a
315
+ * directory excludes everything beneath it (gitignore semantics), so ancestor
316
+ * prefixes are always treated as directories.
317
+ */
318
+ function patternMatchesPath(pattern, relPath, isDir) {
319
+ let candidate = relPath;
320
+ for (;;) {
321
+ const candidateIsDir = candidate === relPath ? isDir : true;
322
+ if (!(pattern.dirOnly && !candidateIsDir) &&
323
+ testPatternAgainst(pattern, candidate)) {
324
+ return true;
325
+ }
326
+ const idx = candidate.lastIndexOf("/");
327
+ if (idx === -1)
328
+ break;
329
+ candidate = candidate.slice(0, idx);
330
+ }
331
+ return false;
332
+ }
333
+ /** Last-match-wins resolution with "!" negation. */
334
+ function worktreeIncludeMatches(patterns, relPath, isDir) {
335
+ let matched = false;
336
+ for (const pattern of patterns) {
337
+ if (patternMatchesPath(pattern, relPath, isDir)) {
338
+ matched = !pattern.negated;
339
+ }
340
+ }
341
+ return matched;
342
+ }
343
+ /**
344
+ * A positive pattern targets something inside a collapsed directory: either it
345
+ * literally starts with the directory path, or the directory path starts with
346
+ * the pattern's literal (pre-glob) prefix.
347
+ */
348
+ function needsExpansion(patterns, dir) {
349
+ return patterns.some((p) => {
350
+ if (p.negated)
351
+ return false;
352
+ if (p.raw.startsWith(dir + "/"))
353
+ return true;
354
+ const globIdx = p.raw.search(/[*?[]/);
355
+ if (globIdx > 0 && dir.startsWith(p.raw.slice(0, globIdx)))
356
+ return true;
357
+ return false;
358
+ });
359
+ }
360
+ // --- Post-creation setup -----------------------------------------------------
361
+ /**
362
+ * Copy <repoRoot>/.wave/settings.local.json into the worktree so local
363
+ * configuration (permissions, env, ...) carries over. Missing files are
364
+ * skipped silently; other failures only log a warning.
365
+ */
366
+ async function copyLocalSettingsToWorktree(repoRoot, worktreePath) {
367
+ const relativePath = path.join(".wave", "settings.local.json");
368
+ const sourcePath = path.join(repoRoot, relativePath);
369
+ const destPath = path.join(worktreePath, relativePath);
370
+ let content;
371
+ try {
372
+ content = await fs.promises.readFile(sourcePath, "utf8");
373
+ }
374
+ catch (error) {
375
+ if (error.code !== "ENOENT") {
376
+ logger.warn(`Failed to read ${sourcePath}: ${error.message}`);
377
+ }
378
+ return;
379
+ }
380
+ try {
381
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
382
+ await fs.promises.writeFile(destPath, content);
383
+ }
384
+ catch (error) {
385
+ logger.warn(`Failed to copy ${relativePath} into worktree: ${error.message}`);
386
+ }
387
+ }
388
+ /**
389
+ * Copy files listed in <repoRoot>/.worktreeinclude into the worktree. Each
390
+ * non-comment line is a gitignore pattern; matching gitignored files are
391
+ * copied at their relative paths.
392
+ */
393
+ async function copyWorktreeIncludeFiles(repoRoot, worktreePath) {
394
+ const includePath = path.join(repoRoot, ".worktreeinclude");
395
+ let content;
396
+ try {
397
+ content = await fs.promises.readFile(includePath, "utf8");
398
+ }
399
+ catch {
400
+ return [];
401
+ }
402
+ const patterns = parseWorktreeIncludePatterns(content);
403
+ if (patterns.length === 0)
404
+ return [];
405
+ const worktreeRelPath = path
406
+ .relative(repoRoot, worktreePath)
407
+ .split(path.sep)
408
+ .join("/");
409
+ // The worktree lives under .wave/worktrees/, which is gitignored — never
410
+ // copy the worktree (or anything inside it) into itself.
411
+ const isSelf = (entry) => entry === worktreeRelPath || entry.startsWith(worktreeRelPath + "/");
412
+ // "--directory" collapses fully-ignored directories into single entries,
413
+ // which keeps the listing fast on large repos.
414
+ let entries;
415
+ try {
416
+ const { stdout } = await execFileAsync("git", [
417
+ "ls-files",
418
+ "--others",
419
+ "--ignored",
420
+ "--exclude-standard",
421
+ "--directory",
422
+ ], { cwd: repoRoot, encoding: "utf8" });
423
+ entries = stdout.trim().split("\n").filter(Boolean);
424
+ }
425
+ catch {
426
+ return [];
427
+ }
428
+ const copied = [];
429
+ const dirsToExpand = [];
430
+ const copyToWorktree = async (relativePath) => {
431
+ const srcPath = path.join(repoRoot, relativePath);
432
+ const destPath = path.join(worktreePath, relativePath);
433
+ try {
434
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
435
+ await fs.promises.copyFile(srcPath, destPath);
436
+ copied.push(relativePath);
437
+ }
438
+ catch (error) {
439
+ logger.warn(`Failed to copy ${relativePath} into worktree: ${error.message}`);
440
+ }
441
+ };
442
+ for (const entry of entries) {
443
+ if (isSelf(entry))
444
+ continue;
445
+ if (entry.endsWith("/")) {
446
+ const dir = entry.slice(0, -1);
447
+ if (worktreeIncludeMatches(patterns, dir, true)) {
448
+ // The directory itself matches — copy it wholesale
449
+ try {
450
+ await fs.promises.cp(path.join(repoRoot, dir), path.join(worktreePath, dir), {
451
+ recursive: true,
452
+ });
453
+ copied.push(entry);
454
+ }
455
+ catch (error) {
456
+ logger.warn(`Failed to copy ${entry} into worktree: ${error.message}`);
457
+ }
458
+ }
459
+ else if (needsExpansion(patterns, dir)) {
460
+ dirsToExpand.push(dir);
461
+ }
462
+ }
463
+ else if (worktreeIncludeMatches(patterns, entry, false)) {
464
+ await copyToWorktree(entry);
465
+ }
466
+ }
467
+ if (dirsToExpand.length > 0) {
468
+ // List the files inside collapsed dirs whose contents are targeted
469
+ try {
470
+ const { stdout } = await execFileAsync("git", [
471
+ "ls-files",
472
+ "--others",
473
+ "--ignored",
474
+ "--exclude-standard",
475
+ "--",
476
+ ...dirsToExpand,
477
+ ], { cwd: repoRoot, encoding: "utf8" });
478
+ for (const file of stdout.trim().split("\n").filter(Boolean)) {
479
+ if (isSelf(file))
480
+ continue;
481
+ if (worktreeIncludeMatches(patterns, file, false)) {
482
+ await copyToWorktree(file);
483
+ }
484
+ }
485
+ }
486
+ catch {
487
+ // Expansion is best-effort; the worktree is already usable without it
488
+ }
489
+ }
490
+ return copied;
491
+ }
492
+ /**
493
+ * Set up a freshly created worktree: copy local settings and gitignored
494
+ * project files (via .worktreeinclude) from the main repo. Best-effort — any
495
+ * failure only logs a warning and never fails worktree creation.
496
+ */
497
+ export async function performPostCreationSetup(worktreePath, repoRoot) {
498
+ await copyLocalSettingsToWorktree(repoRoot, worktreePath);
499
+ await copyWorktreeIncludeFiles(repoRoot, worktreePath);
500
+ }
214
501
  /**
215
502
  * Remove a git worktree and its branch.
216
503
  */
@@ -273,6 +560,57 @@ export function removeWorktree(info) {
273
560
  throw error;
274
561
  }
275
562
  }
563
+ /**
564
+ * Validate that a worktree path is safe to remove before running git removal.
565
+ * Aligns with Claude Code v2.1.216+ background-session checks:
566
+ * - rejects a path whose final component is a symlink;
567
+ * - rejects a path that resolves outside the repo root.
568
+ *
569
+ * A path that no longer exists (worktree already removed) is allowed so that
570
+ * removal stays best-effort/idempotent; its nearest existing ancestor is used
571
+ * for the containment check. Throws an Error on invalid paths.
572
+ */
573
+ export function validateWorktreeRemovalPath(worktreePath, repoRoot) {
574
+ const SYMLINK_PREFIX = "Refusing to remove worktree at symlink path:";
575
+ // Reject a symlink as the final path component.
576
+ try {
577
+ if (fs.lstatSync(worktreePath).isSymbolicLink()) {
578
+ throw new Error(`${SYMLINK_PREFIX} ${worktreePath}`);
579
+ }
580
+ }
581
+ catch (error) {
582
+ if (error instanceof Error && error.message.startsWith(SYMLINK_PREFIX)) {
583
+ throw error;
584
+ }
585
+ // lstat failed (path missing) — the containment check below still applies.
586
+ }
587
+ const resolvedRepoRoot = fs.realpathSync(repoRoot);
588
+ // Resolve the path, following symlinks in existing components. If the path
589
+ // (or a parent) no longer exists, fall back to the deepest existing ancestor
590
+ // so the containment check still guards against traversal outside the repo.
591
+ let resolvedPath;
592
+ let existingAncestor = worktreePath;
593
+ for (;;) {
594
+ try {
595
+ resolvedPath = fs.realpathSync(existingAncestor);
596
+ break;
597
+ }
598
+ catch {
599
+ const parent = path.dirname(existingAncestor);
600
+ if (parent === existingAncestor) {
601
+ throw new Error(`Invalid worktree path for removal: ${worktreePath}`);
602
+ }
603
+ existingAncestor = parent;
604
+ }
605
+ }
606
+ if (existingAncestor !== worktreePath) {
607
+ resolvedPath = path.join(resolvedPath, path.relative(existingAncestor, worktreePath));
608
+ }
609
+ const relative = path.relative(resolvedRepoRoot, resolvedPath);
610
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
611
+ throw new Error(`Refusing to remove worktree outside repo root: ${worktreePath}`);
612
+ }
613
+ }
276
614
  /**
277
615
  * Count uncommitted files and new commits in a worktree.
278
616
  * Returns null if git commands fail (fail-closed).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "0.19.9",
3
+ "version": "1.0.1",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",