wave-agent-sdk 1.0.9 → 1.1.0

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 (94) hide show
  1. package/dist/agent.d.ts +9 -6
  2. package/dist/agent.js +35 -33
  3. package/dist/builtin/index.d.ts +1 -0
  4. package/dist/builtin/index.js +20 -0
  5. package/dist/builtin/plugins.d.ts +1 -0
  6. package/dist/builtin/plugins.js +225 -0
  7. package/dist/builtin/skills/artifact.d.ts +1 -0
  8. package/dist/builtin/skills/artifact.js +18 -0
  9. package/dist/builtin/skills/code-review.d.ts +1 -0
  10. package/{builtin/skills/code-review/SKILL.md → dist/builtin/skills/code-review.js} +23 -19
  11. package/dist/builtin/skills/deep-research.d.ts +1 -0
  12. package/{builtin/skills/deep-research/SKILL.md → dist/builtin/skills/deep-research.js} +18 -14
  13. package/dist/builtin/skills/init.d.ts +1 -0
  14. package/{builtin/skills/init/SKILL.md → dist/builtin/skills/init.js} +6 -3
  15. package/dist/builtin/skills/loop.d.ts +1 -0
  16. package/dist/builtin/skills/loop.js +83 -0
  17. package/dist/builtin/skills/settings.d.ts +1 -0
  18. package/dist/builtin/skills/settings.js +1249 -0
  19. package/dist/builtin/skills/simplify.d.ts +1 -0
  20. package/{builtin/skills/simplify/SKILL.md → dist/builtin/skills/simplify.js} +7 -3
  21. package/dist/builtin/subagents.d.ts +1 -0
  22. package/dist/builtin/subagents.js +164 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/managers/aiManager.d.ts +10 -0
  26. package/dist/managers/aiManager.js +49 -5
  27. package/dist/managers/mcpManager.js +1 -1
  28. package/dist/managers/messageManager.d.ts +6 -0
  29. package/dist/managers/messageManager.js +33 -0
  30. package/dist/managers/subagentManager.d.ts +9 -0
  31. package/dist/managers/subagentManager.js +62 -3
  32. package/dist/services/hook.js +41 -7
  33. package/dist/services/initializationService.js +0 -21
  34. package/dist/services/jsonlHandler.d.ts +37 -2
  35. package/dist/services/jsonlHandler.js +55 -6
  36. package/dist/services/session.d.ts +35 -4
  37. package/dist/services/session.js +233 -36
  38. package/dist/services/worktreeHooks.d.ts +45 -0
  39. package/dist/services/worktreeHooks.js +133 -0
  40. package/dist/tools/agentTool.js +36 -1
  41. package/dist/tools/bashTool.js +120 -57
  42. package/dist/tools/editTool.js +1 -0
  43. package/dist/tools/enterWorktreeTool.d.ts +1 -1
  44. package/dist/tools/enterWorktreeTool.js +44 -31
  45. package/dist/tools/exitWorktreeTool.js +21 -26
  46. package/dist/tools/readTool.js +6 -3
  47. package/dist/tools/types.d.ts +1 -0
  48. package/dist/tools/writeTool.js +1 -0
  49. package/dist/types/agent.d.ts +5 -0
  50. package/dist/types/hooks.d.ts +3 -2
  51. package/dist/types/messaging.d.ts +1 -0
  52. package/dist/types/skills.d.ts +0 -1
  53. package/dist/types/skills.js +0 -1
  54. package/dist/utils/asyncWorkRegistry.d.ts +32 -0
  55. package/dist/utils/asyncWorkRegistry.js +81 -0
  56. package/dist/utils/builtinEmbed.d.ts +21 -0
  57. package/dist/utils/builtinEmbed.js +53 -0
  58. package/dist/utils/configPaths.d.ts +0 -1
  59. package/dist/utils/configPaths.js +5 -26
  60. package/dist/utils/containerSetup.js +5 -0
  61. package/dist/utils/convertMessagesForAPI.js +21 -2
  62. package/dist/utils/messageOperations.d.ts +1 -0
  63. package/dist/utils/skillParser.js +3 -6
  64. package/dist/utils/toolImagePersistence.d.ts +25 -0
  65. package/dist/utils/toolImagePersistence.js +56 -0
  66. package/dist/utils/windowsPaths.d.ts +28 -0
  67. package/dist/utils/windowsPaths.js +47 -0
  68. package/dist/utils/worktreeSession.d.ts +6 -0
  69. package/dist/utils/worktreeUtils.d.ts +7 -0
  70. package/dist/utils/worktreeUtils.js +88 -40
  71. package/package.json +5 -5
  72. package/builtin/plugins/sdd/.wave-plugin/plugin.json +0 -8
  73. package/builtin/plugins/sdd/hooks/hooks.json +0 -14
  74. package/builtin/plugins/sdd/scripts/session-start.js +0 -24
  75. package/builtin/plugins/sdd/scripts/spec-count.js +0 -77
  76. package/builtin/plugins/sdd/skills/specify/SKILL.md +0 -47
  77. package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +0 -47
  78. package/builtin/skills/artifact/SKILL.md +0 -14
  79. package/builtin/skills/loop/SKILL.md +0 -79
  80. package/builtin/skills/settings/ENV.md +0 -78
  81. package/builtin/skills/settings/HOOKS.md +0 -227
  82. package/builtin/skills/settings/MCP.md +0 -137
  83. package/builtin/skills/settings/MEMORY.md +0 -76
  84. package/builtin/skills/settings/MODELS.md +0 -119
  85. package/builtin/skills/settings/PERMISSIONS.md +0 -88
  86. package/builtin/skills/settings/PLUGINS.md +0 -171
  87. package/builtin/skills/settings/SKILL.md +0 -132
  88. package/builtin/skills/settings/SKILLS.md +0 -107
  89. package/builtin/skills/settings/SUBAGENTS.md +0 -77
  90. package/builtin/subagents/bash.md +0 -19
  91. package/builtin/subagents/explore.md +0 -43
  92. package/builtin/subagents/general-purpose.md +0 -20
  93. package/builtin/subagents/plan.md +0 -56
  94. package/builtin/subagents/vision.md +0 -18
@@ -11,6 +11,14 @@ import { processToolResult } from "../utils/toolResultStorage.js";
11
11
  import { BASH_MAX_OUTPUT_CHARS } from "../constants/toolLimits.js";
12
12
  import { BASH_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME, READ_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, } from "../constants/tools.js";
13
13
  const BASH_DEFAULT_TIMEOUT_MS = 120000;
14
+ // After the shell exits, its last stdout/stderr chunks may still be in flight:
15
+ // Node emits the child's 'exit' event as soon as the process terminates, then
16
+ // delivers the remaining pipe data on a later event-loop turn and finally
17
+ // emits 'close'. Finalization therefore waits for the streams to 'end' before
18
+ // reading the buffers. This bound covers grandchildren that inherit the pipe
19
+ // write ends (e.g. `sleep 30 &`), which would delay 'end' indefinitely — in
20
+ // that case the shell's own output is returned, matching pre-existing behavior.
21
+ const SHELL_STREAM_FLUSH_TIMEOUT_MS = 100;
14
22
  /**
15
23
  * Wrap a user command so we can append CWD tracking (`&& pwd -P`) without the
16
24
  * appended part being affected by trailing here-docs, unbalanced quotes, or
@@ -433,70 +441,125 @@ The working directory persists between commands. Try to maintain your current wo
433
441
  updateRealtimeResults();
434
442
  }
435
443
  });
436
- child.on("exit", async (code) => {
437
- isFinished = true;
438
- if (context.foregroundTaskManager) {
439
- context.foregroundTaskManager.unregisterForegroundTask(foregroundTaskId);
444
+ // The 'exit' event fires before the stdio pipes have fully drained:
445
+ // Node emits 'exit' as soon as the process terminates, then delivers the
446
+ // remaining pipe data on a later event-loop turn and finally emits
447
+ // 'close'. Reading the buffers at 'exit' could therefore drop the
448
+ // shell's trailing output, written just before it exited (observed as
449
+ // empty stdout with exit code 0 for a fast `echo` under load). The
450
+ // result is only finalized after the streams have emitted 'end'. A
451
+ // bounded valve covers grandchildren that inherit the pipe write ends
452
+ // (e.g. `sleep 30 &`), which would delay 'end' indefinitely — in that
453
+ // case the shell's own output is returned, matching pre-existing
454
+ // behavior.
455
+ const finalizeShellResult = (code) => {
456
+ // Streams that may still hold undelivered data: real pipe streams not
457
+ // yet at EOF. Mocked child_process tests provide plain objects without
458
+ // `once` that never emit 'end' — skip the flush wait for them so the
459
+ // exit path is unchanged.
460
+ const pendingStreams = [child.stdout, child.stderr].filter((stream) => !!stream &&
461
+ !stream.readableEnded &&
462
+ typeof stream.once === "function");
463
+ let finalized = false;
464
+ const finish = () => {
465
+ if (finalized)
466
+ return;
467
+ finalized = true;
468
+ clearTimeout(flushTimer);
469
+ completeShellResult(code);
470
+ };
471
+ if (pendingStreams.length === 0) {
472
+ completeShellResult(code);
473
+ return;
440
474
  }
441
- if (!isAborted && !isBackgrounded) {
442
- if (timeoutHandle) {
443
- clearTimeout(timeoutHandle);
475
+ const remainingStreams = new Set(pendingStreams);
476
+ const onStreamDone = (stream) => {
477
+ remainingStreams.delete(stream);
478
+ if (remainingStreams.size === 0) {
479
+ finish();
444
480
  }
445
- // Read the new CWD from the temporary file
446
- let newCwd;
447
- try {
448
- if (fs.existsSync(tempCwdFile)) {
449
- newCwd = fs.readFileSync(tempCwdFile, "utf8").trim();
450
- // Validate the path exists before calling the callback
451
- fs.accessSync(newCwd, fs.constants.F_OK);
452
- }
481
+ };
482
+ for (const stream of pendingStreams) {
483
+ stream.once("end", () => onStreamDone(stream));
484
+ stream.once("error", () => onStreamDone(stream));
485
+ }
486
+ // Safety valve for the grandchild case. Runs via setImmediate (which
487
+ // executes after the next poll), so any data still in the kernel pipe
488
+ // is delivered before the buffers are read.
489
+ const flushTimer = setTimeout(() => {
490
+ if (!finalized) {
491
+ setImmediate(finish);
453
492
  }
454
- catch (fileError) {
455
- logger.warn(`Could not read or validate new CWD from temp file ${tempCwdFile}:`, fileError);
456
- newCwd = undefined;
493
+ }, SHELL_STREAM_FLUSH_TIMEOUT_MS);
494
+ };
495
+ const completeShellResult = (code) => {
496
+ // Read the new CWD from the temporary file
497
+ let newCwd;
498
+ try {
499
+ if (fs.existsSync(tempCwdFile)) {
500
+ newCwd = fs.readFileSync(tempCwdFile, "utf8").trim();
501
+ // Validate the path exists before calling the callback
502
+ fs.accessSync(newCwd, fs.constants.F_OK);
457
503
  }
458
- finally {
459
- cleanupTempFile();
504
+ }
505
+ catch (fileError) {
506
+ logger.warn(`Could not read or validate new CWD from temp file ${tempCwdFile}:`, fileError);
507
+ newCwd = undefined;
508
+ }
509
+ finally {
510
+ cleanupTempFile();
511
+ }
512
+ // If CWD changed, call the onCwdChange callback and add notification
513
+ let cwdMessage;
514
+ if (newCwd && newCwd !== context.workdir && context.onCwdChange) {
515
+ const isInSafeZone = context.permissionManager?.isPathInSafeZone?.(newCwd) ?? true;
516
+ if (!isInSafeZone && context.originalWorkdir) {
517
+ context.onCwdChange(context.originalWorkdir);
518
+ cwdMessage = `Shell cwd was reset to ${context.originalWorkdir}`;
460
519
  }
461
- // If CWD changed, call the onCwdChange callback and add notification
462
- let cwdMessage;
463
- if (newCwd && newCwd !== context.workdir && context.onCwdChange) {
464
- const isInSafeZone = context.permissionManager?.isPathInSafeZone?.(newCwd) ?? true;
465
- if (!isInSafeZone && context.originalWorkdir) {
466
- context.onCwdChange(context.originalWorkdir);
467
- cwdMessage = `Shell cwd was reset to ${context.originalWorkdir}`;
468
- }
469
- else {
470
- context.onCwdChange(newCwd);
471
- cwdMessage = `Shell working directory changed to ${newCwd}`;
472
- }
520
+ else {
521
+ context.onCwdChange(newCwd);
522
+ cwdMessage = `Shell working directory changed to ${newCwd}`;
473
523
  }
474
- const exitCode = code ?? 0;
475
- // Decode any bytes still held at stream end (e.g. a trailing UTF-8
476
- // character split across the last chunk).
477
- if (stdoutDecoder)
478
- outputBuffer += stdoutDecoder.flush();
479
- if (stderrDecoder)
480
- errorBuffer += stderrDecoder.flush();
481
- const combinedOutput = outputBuffer + (errorBuffer ? "\n" + errorBuffer : "");
482
- // Prepend CWD change message to output if present
483
- const finalOutput = recoveryNotice +
484
- (cwdMessage ? cwdMessage + "\n" : "") +
485
- (combinedOutput || `Command executed with exit code: ${exitCode}`);
486
- const content = processToolResult(finalOutput, BASH_MAX_OUTPUT_CHARS, "bash");
487
- const lines = combinedOutput.trim().split("\n");
488
- const shortResult = lines.length <= 3
489
- ? lines.join("\n")
490
- : `... +${lines.length - 3} lines\n` + lines.slice(-3).join("\n");
491
- resolve({
492
- success: exitCode === 0,
493
- content,
494
- shortResult: shortResult || undefined,
495
- error: exitCode !== 0
496
- ? `Command failed with exit code: ${exitCode}`
497
- : undefined,
498
- });
499
524
  }
525
+ const exitCode = code ?? 0;
526
+ // Decode any bytes still held at stream end (e.g. a trailing UTF-8
527
+ // character split across the last chunk).
528
+ if (stdoutDecoder)
529
+ outputBuffer += stdoutDecoder.flush();
530
+ if (stderrDecoder)
531
+ errorBuffer += stderrDecoder.flush();
532
+ const combinedOutput = outputBuffer + (errorBuffer ? "\n" + errorBuffer : "");
533
+ // Prepend CWD change message to output if present
534
+ const finalOutput = recoveryNotice +
535
+ (cwdMessage ? cwdMessage + "\n" : "") +
536
+ (combinedOutput || `Command executed with exit code: ${exitCode}`);
537
+ const content = processToolResult(finalOutput, BASH_MAX_OUTPUT_CHARS, "bash");
538
+ const lines = combinedOutput.trim().split("\n");
539
+ const shortResult = lines.length <= 3
540
+ ? lines.join("\n")
541
+ : `... +${lines.length - 3} lines\n` + lines.slice(-3).join("\n");
542
+ resolve({
543
+ success: exitCode === 0,
544
+ content,
545
+ shortResult: shortResult || undefined,
546
+ error: exitCode !== 0
547
+ ? `Command failed with exit code: ${exitCode}`
548
+ : undefined,
549
+ });
550
+ };
551
+ child.on("exit", (code) => {
552
+ isFinished = true;
553
+ if (context.foregroundTaskManager) {
554
+ context.foregroundTaskManager.unregisterForegroundTask(foregroundTaskId);
555
+ }
556
+ if (isAborted || isBackgrounded) {
557
+ return;
558
+ }
559
+ if (timeoutHandle) {
560
+ clearTimeout(timeoutHandle);
561
+ }
562
+ finalizeShellResult(code);
500
563
  });
501
564
  child.on("error", (error) => {
502
565
  isFinished = true;
@@ -243,6 +243,7 @@ Usage:
243
243
  context.readFileState.set(resolvedPath, {
244
244
  mtime: newStats.mtime.getTime(),
245
245
  hash,
246
+ source: "edit",
246
247
  offset: undefined,
247
248
  limit: undefined,
248
249
  });
@@ -3,5 +3,5 @@
3
3
  * Mirrors Claude Code's EnterWorktree tool behavior and prompt.
4
4
  */
5
5
  import type { ToolPlugin } from "./types.js";
6
- export declare const ENTER_WORKTREE_TOOL_PROMPT = "Use this tool ONLY when the user explicitly asks to work in a worktree. This tool creates an isolated git worktree and switches the current session into it.\n\n## When to Use\n\n- The user explicitly says \"worktree\" (e.g., \"start a worktree\", \"work in a worktree\", \"create a worktree\", \"use a worktree\")\n\n## When NOT to Use\n\n- The user asks to create a branch, switch branches, or work on a different branch \u2014 use git commands instead\n- The user asks to fix a bug or work on a feature \u2014 use normal git workflow unless they specifically mention worktrees\n- Never use this tool unless the user explicitly mentions \"worktree\"\n\n## Requirements\n\n- Must be in a git repository\n- Must not already be in a worktree\n\n## Behavior\n\n- Creates a new git worktree inside `.wave/worktrees/` with a new branch based on HEAD\n- Switches the session's working directory to the new worktree\n- Use ExitWorktree to leave the worktree mid-session (keep or remove). On session exit, if still in the worktree, the user will be prompted to keep or remove it\n\n## Parameters\n\n- `name` (optional): A name for the worktree. Each \"/\"-separated segment may contain only letters, digits, dots, underscores, and dashes; max 64 chars total. A random name is generated if not provided.\n";
6
+ export declare const ENTER_WORKTREE_TOOL_PROMPT = "Use this tool ONLY when the user explicitly asks to work in a worktree. This tool creates an isolated git worktree and switches the current session into it.\n\n## When to Use\n\n- The user explicitly says \"worktree\" (e.g., \"start a worktree\", \"work in a worktree\", \"create a worktree\", \"use a worktree\")\n\n## When NOT to Use\n\n- The user asks to create a branch, switch branches, or work on a different branch \u2014 use git commands instead\n- The user asks to fix a bug or work on a feature \u2014 use normal git workflow unless they specifically mention worktrees\n- Never use this tool unless the user explicitly mentions \"worktree\"\n\n## Requirements\n\n- Must be in a git repository (or have a WorktreeCreate hook configured, in which case the hook creates the worktree)\n\n## Behavior\n\n- Creates a new git worktree inside `.wave/worktrees/` with a new branch based on HEAD\n- When a WorktreeCreate hook is configured, the hook creates the worktree instead (its stdout provides the worktree path) and no git command runs\n- Switches the session's working directory to the new worktree\n- Use ExitWorktree to leave the worktree mid-session (keep or remove). On session exit, if still in the worktree, the user will be prompted to keep or remove it\n\n## Parameters\n\n- `name` (optional): A name for the worktree. Each \"/\"-separated segment may contain only letters, digits, dots, underscores, and dashes; max 64 chars total. A random name is generated if not provided.\n";
7
7
  export declare const enterWorktreeTool: ToolPlugin;
@@ -6,6 +6,7 @@ import { createWorktree, validateWorktreeName, generateWorktreeName, performPost
6
6
  import { getGitMainRepoRoot } from "../utils/gitUtils.js";
7
7
  import { ENTER_WORKTREE_TOOL_NAME } from "../constants/tools.js";
8
8
  import { logger } from "../utils/globalLogger.js";
9
+ import { executeWorktreeCreateHook, hasWorktreeCreateHook, } from "../services/worktreeHooks.js";
9
10
  export const ENTER_WORKTREE_TOOL_PROMPT = `Use this tool ONLY when the user explicitly asks to work in a worktree. This tool creates an isolated git worktree and switches the current session into it.
10
11
 
11
12
  ## When to Use
@@ -20,12 +21,12 @@ export const ENTER_WORKTREE_TOOL_PROMPT = `Use this tool ONLY when the user expl
20
21
 
21
22
  ## Requirements
22
23
 
23
- - Must be in a git repository
24
- - Must not already be in a worktree
24
+ - Must be in a git repository (or have a WorktreeCreate hook configured, in which case the hook creates the worktree)
25
25
 
26
26
  ## Behavior
27
27
 
28
28
  - Creates a new git worktree inside \`.wave/worktrees/\` with a new branch based on HEAD
29
+ - When a WorktreeCreate hook is configured, the hook creates the worktree instead (its stdout provides the worktree path) and no git command runs
29
30
  - Switches the session's working directory to the new worktree
30
31
  - Use ExitWorktree to leave the worktree mid-session (keep or remove). On session exit, if still in the worktree, the user will be prompted to keep or remove it
31
32
 
@@ -73,7 +74,46 @@ export const enterWorktreeTool = {
73
74
  error: `Invalid worktree name: ${e.message}`,
74
75
  };
75
76
  }
76
- // Resolve to main repo root so worktree creation works from within a subdirectory
77
+ // Hook-based creation first (allows user-configured VCS, including non-git)
78
+ const hookConfiguration = context.hookManager?.getConfiguration();
79
+ if (hasWorktreeCreateHook(hookConfiguration)) {
80
+ try {
81
+ const { worktreePath } = await executeWorktreeCreateHook(name, hookConfiguration, {
82
+ projectDir: context.workdir,
83
+ sessionId: context.sessionId ?? "",
84
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
85
+ env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
86
+ });
87
+ // Hook-based worktrees skip post-creation setup; the hook script owns
88
+ // initialization. repoRoot falls back to the workdir for non-git repos.
89
+ const hookBasedSession = {
90
+ originalCwd: context.workdir,
91
+ worktreePath,
92
+ worktreeBranch: "",
93
+ worktreeName: name,
94
+ isNew: true,
95
+ repoRoot: getGitMainRepoRoot(context.workdir) ?? context.workdir,
96
+ hookBased: true,
97
+ };
98
+ const aiManager = context.aiManager;
99
+ if (aiManager) {
100
+ aiManager.setWorktreeSession(hookBasedSession);
101
+ aiManager.setWorkdir(worktreePath);
102
+ }
103
+ return {
104
+ success: true,
105
+ content: `Created worktree at ${worktreePath}. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted. WorktreeCreate hooks were executed.`,
106
+ };
107
+ }
108
+ catch (error) {
109
+ return {
110
+ success: false,
111
+ content: `Failed to create worktree: ${error.message}`,
112
+ error: "WorktreeCreate hook failed",
113
+ };
114
+ }
115
+ }
116
+ // Git-based fallback
77
117
  const mainRepoRoot = getGitMainRepoRoot(context.workdir);
78
118
  if (!mainRepoRoot) {
79
119
  return {
@@ -112,39 +152,12 @@ export const enterWorktreeTool = {
112
152
  aiManager.setWorktreeSession(session);
113
153
  aiManager.setWorkdir(worktreeInfo.path);
114
154
  }
115
- // Trigger WorktreeCreate hook if worktree is new
116
- let hookTriggered = false;
117
- if (session.isNew && context.hookManager) {
118
- try {
119
- const hookResults = await context.hookManager.executeHooks("WorktreeCreate", {
120
- event: "WorktreeCreate",
121
- projectDir: worktreeInfo.path,
122
- timestamp: new Date(),
123
- sessionId: context.sessionId ?? "",
124
- transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
125
- cwd: worktreeInfo.path,
126
- worktreeName: worktreeInfo.name,
127
- env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
128
- });
129
- if (context.messageManager) {
130
- context.hookManager.processHookResults("WorktreeCreate", hookResults, context.messageManager);
131
- }
132
- hookTriggered = true;
133
- }
134
- catch (error) {
135
- // Non-blocking: log but don't fail the tool
136
- logger?.warn("WorktreeCreate hooks execution failed:", error);
137
- }
138
- }
139
155
  const branchInfo = worktreeInfo.branch
140
156
  ? ` on branch ${worktreeInfo.branch}`
141
157
  : "";
142
- const hookInfo = hookTriggered
143
- ? " WorktreeCreate hooks were executed."
144
- : "";
145
158
  return {
146
159
  success: true,
147
- content: `Created worktree at ${worktreeInfo.path}${branchInfo}. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted.${hookInfo}`,
160
+ content: `Created worktree at ${worktreeInfo.path}${branchInfo}. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted.`,
148
161
  };
149
162
  },
150
163
  };
@@ -5,6 +5,7 @@
5
5
  import { removeWorktree, countWorktreeChanges, } from "../utils/worktreeUtils.js";
6
6
  import { EXIT_WORKTREE_TOOL_NAME } from "../constants/tools.js";
7
7
  import { logger } from "../utils/globalLogger.js";
8
+ import { executeWorktreeRemoveHook } from "../services/worktreeHooks.js";
8
9
  export const EXIT_WORKTREE_TOOL_PROMPT = `Exit a worktree session created by EnterWorktree and return the session to the original working directory.
9
10
 
10
11
  ## Scope
@@ -128,32 +129,29 @@ export const exitWorktreeTool = {
128
129
  };
129
130
  // Count changes BEFORE removing the worktree (directory will be gone after)
130
131
  const summary = countWorktreeChanges(worktreePath, session.originalHeadCommit) ?? { changedFiles: 0, commits: 0 };
131
- // Trigger WorktreeRemove hook (non-blocking) BEFORE git removal so hooks
132
- // can still read files inside the worktree to clean up external resources.
133
- let hookTriggered = false;
134
- if (context.hookManager) {
135
- try {
136
- const hookResults = await context.hookManager.executeHooks("WorktreeRemove", {
137
- event: "WorktreeRemove",
138
- projectDir: originalCwd,
139
- timestamp: new Date(),
140
- sessionId: context.sessionId ?? "",
141
- transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
142
- cwd: originalCwd,
143
- worktreePath,
144
- env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
145
- });
146
- if (context.messageManager) {
147
- context.hookManager.processHookResults("WorktreeRemove", hookResults, context.messageManager);
148
- }
149
- hookTriggered = true;
132
+ let hookNote = "";
133
+ if (session.hookBased) {
134
+ // Hook-based worktree: delegate removal to the WorktreeRemove hook.
135
+ // wave never runs `git worktree remove` for hook-based worktrees.
136
+ const configuration = context.hookManager?.getConfiguration();
137
+ const hookRan = await executeWorktreeRemoveHook(worktreePath, configuration, {
138
+ projectDir: originalCwd,
139
+ sessionId: context.sessionId ?? "",
140
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
141
+ env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
142
+ });
143
+ if (hookRan) {
144
+ hookNote = " WorktreeRemove hooks were executed.";
150
145
  }
151
- catch (error) {
152
- // Non-blocking: log but don't fail the tool
153
- logger?.warn("WorktreeRemove hooks execution failed:", error);
146
+ else {
147
+ logger?.warn(`No WorktreeRemove hook configured, hook-based worktree left at: ${worktreePath}`);
148
+ hookNote =
149
+ " No WorktreeRemove hook configured, hook-based worktree left at the path above.";
154
150
  }
155
151
  }
156
- removeWorktree(worktreeInfo);
152
+ else {
153
+ removeWorktree(worktreeInfo);
154
+ }
157
155
  // Clear session state and restore CWD
158
156
  const aiManager = context.aiManager;
159
157
  if (aiManager) {
@@ -170,9 +168,6 @@ export const exitWorktreeTool = {
170
168
  const discardNote = discardParts.length > 0
171
169
  ? ` Discarded ${discardParts.join(" and ")}.`
172
170
  : "";
173
- const hookNote = hookTriggered
174
- ? " WorktreeRemove hooks were executed."
175
- : "";
176
171
  return {
177
172
  success: true,
178
173
  content: `Exited and removed worktree at ${worktreePath}.${discardNote} Session is now back in ${originalCwd}.${hookNote}`,
@@ -208,12 +208,14 @@ Usage:
208
208
  : resolvePath(filePath, context.workdir);
209
209
  const stats = await stat(actualFilePath);
210
210
  // Deduplication: only dedup if the exact same range was read before
211
+ // (aligned with Claude Code — full reads dedup too). Entries written by
212
+ // Edit/Write have source "edit"/"write" and never dedup, so a file that
213
+ // was written before its first Read is still returned in full.
211
214
  if (context.readFileState) {
212
215
  const state = context.readFileState.get(actualFilePath);
213
- // Only dedup entries from a prior Read with explicit offset (not Edit/Write entries)
214
216
  if (state &&
215
- state.mtime === stats.mtime.getTime() &&
216
- state.offset !== undefined) {
217
+ state.source === "read" &&
218
+ state.mtime === stats.mtime.getTime()) {
217
219
  const rangeMatch = state.offset === offset && state.limit === limit;
218
220
  if (rangeMatch) {
219
221
  return {
@@ -249,6 +251,7 @@ Usage:
249
251
  context.readFileState.set(actualFilePath, {
250
252
  mtime: stats.mtime.getTime(),
251
253
  hash,
254
+ source: "read",
252
255
  offset, // undefined for full reads
253
256
  limit, // undefined for full reads
254
257
  });
@@ -95,6 +95,7 @@ export interface ToolContext {
95
95
  readFileState?: Map<string, {
96
96
  mtime: number;
97
97
  hash: string;
98
+ source: "read" | "edit" | "write";
98
99
  offset?: number;
99
100
  limit?: number;
100
101
  }>;
@@ -179,6 +179,7 @@ Usage:
179
179
  context.readFileState.set(resolvedPath, {
180
180
  mtime: newStats.mtime.getTime(),
181
181
  hash,
182
+ source: "write",
182
183
  offset: undefined,
183
184
  limit: undefined,
184
185
  });
@@ -106,4 +106,9 @@ export interface AgentCallbacks extends MessageManagerCallbacks, BackgroundTaskM
106
106
  onCommandRunningChange?: (running: boolean) => void;
107
107
  onWorkdirChange?: (newCwd: string) => void;
108
108
  onQueuedMessagesChange?: (messages: QueuedMessage[]) => void;
109
+ /** Streaming content from the compaction fork (accumulated). Reasoning
110
+ * chunks fall back to this channel when no reasoning callback is provided. */
111
+ onCompactionContentUpdate?: (content: string) => void;
112
+ /** Streaming reasoning from the compaction fork (accumulated). */
113
+ onCompactionReasoningUpdate?: (content: string) => void;
109
114
  }
@@ -22,7 +22,7 @@ export interface HookExecutionContext {
22
22
  toolName?: string;
23
23
  projectDir: string;
24
24
  timestamp: Date;
25
- worktreeName?: string;
25
+ name?: string;
26
26
  worktreePath?: string;
27
27
  planFilePath?: string;
28
28
  }
@@ -33,6 +33,8 @@ export interface HookExecutionResult {
33
33
  stderr?: string;
34
34
  duration: number;
35
35
  timedOut: boolean;
36
+ /** The executed command, tagged onto results by callers that iterate multiple hooks */
37
+ command?: string;
36
38
  }
37
39
  export interface HookExecutionOptions {
38
40
  timeout?: number;
@@ -107,7 +109,6 @@ export interface ExtendedHookExecutionContext extends HookExecutionContext {
107
109
  env?: Record<string, string>;
108
110
  userPrompt?: string;
109
111
  subagentType?: string;
110
- worktreeName?: string;
111
112
  oldCwd?: string;
112
113
  newCwd?: string;
113
114
  source?: SessionStartSource;
@@ -37,6 +37,7 @@ export interface ToolBlock {
37
37
  images?: Array<{
38
38
  data: string;
39
39
  mediaType?: string;
40
+ path?: string;
40
41
  }>;
41
42
  id?: string;
42
43
  name?: string;
@@ -85,7 +85,6 @@ export declare const SKILL_DEFAULTS: {
85
85
  readonly PROJECT_SKILLS_DIR: ".wave/skills";
86
86
  readonly SKILL_FILE_NAME: "SKILL.md";
87
87
  readonly MAX_NAME_LENGTH: 64;
88
- readonly MAX_DESCRIPTION_LENGTH: 1024;
89
88
  readonly MIN_DESCRIPTION_LENGTH: 1;
90
89
  readonly NAME_PATTERN: RegExp;
91
90
  readonly MAX_METADATA_CACHE: 1000;
@@ -7,7 +7,6 @@ export const SKILL_DEFAULTS = {
7
7
  PROJECT_SKILLS_DIR: ".wave/skills",
8
8
  SKILL_FILE_NAME: "SKILL.md",
9
9
  MAX_NAME_LENGTH: 64,
10
- MAX_DESCRIPTION_LENGTH: 1024,
11
10
  MIN_DESCRIPTION_LENGTH: 1,
12
11
  NAME_PATTERN: /^[a-z0-9-]+$/,
13
12
  MAX_METADATA_CACHE: 1000,
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Registry of live async work that must drain before the agent is destroyed.
3
+ *
4
+ * Fire-and-forget work (dispatch, background subagents, fork subagents)
5
+ * registers its promise here so destroy() can wait deterministically instead
6
+ * of silently abandoning it (the "ghost work" class of #1808). The registry
7
+ * swallows rejections so an unregistered fire-and-forget caller can't turn a
8
+ * settled-then-removed promise into an unhandled rejection.
9
+ */
10
+ export declare class AsyncWorkRegistry {
11
+ private readonly drainTimeoutMs;
12
+ private work;
13
+ private waiters;
14
+ constructor(drainTimeoutMs?: number);
15
+ get size(): number;
16
+ isEmpty(): boolean;
17
+ /**
18
+ * Register a promise as live work. The promise is removed when it settles
19
+ * (fulfilled or rejected); rejections are consumed here so tracking never
20
+ * produces an unhandled rejection. Returns the original promise unchanged
21
+ * so the caller can still await it.
22
+ */
23
+ track<T>(promise: Promise<T>): Promise<T>;
24
+ private remove;
25
+ /**
26
+ * Wait until the registry is empty, or until the timeout elapses. Loops
27
+ * until empty: work registered after an await point is still drained.
28
+ * Returns true if drained, false on timeout (leftover work is no longer
29
+ * lifecycle-managed).
30
+ */
31
+ drain(timeoutMs?: number): Promise<boolean>;
32
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Registry of live async work that must drain before the agent is destroyed.
3
+ *
4
+ * Fire-and-forget work (dispatch, background subagents, fork subagents)
5
+ * registers its promise here so destroy() can wait deterministically instead
6
+ * of silently abandoning it (the "ghost work" class of #1808). The registry
7
+ * swallows rejections so an unregistered fire-and-forget caller can't turn a
8
+ * settled-then-removed promise into an unhandled rejection.
9
+ */
10
+ export class AsyncWorkRegistry {
11
+ constructor(drainTimeoutMs = 10000) {
12
+ this.drainTimeoutMs = drainTimeoutMs;
13
+ this.work = new Set();
14
+ this.waiters = [];
15
+ }
16
+ get size() {
17
+ return this.work.size;
18
+ }
19
+ isEmpty() {
20
+ return this.work.size === 0;
21
+ }
22
+ /**
23
+ * Register a promise as live work. The promise is removed when it settles
24
+ * (fulfilled or rejected); rejections are consumed here so tracking never
25
+ * produces an unhandled rejection. Returns the original promise unchanged
26
+ * so the caller can still await it.
27
+ */
28
+ track(promise) {
29
+ this.work.add(promise);
30
+ promise.then(() => this.remove(promise), () => this.remove(promise));
31
+ return promise;
32
+ }
33
+ remove(promise) {
34
+ this.work.delete(promise);
35
+ if (this.work.size === 0) {
36
+ const waiters = this.waiters;
37
+ this.waiters = [];
38
+ for (const resolve of waiters) {
39
+ resolve();
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * Wait until the registry is empty, or until the timeout elapses. Loops
45
+ * until empty: work registered after an await point is still drained.
46
+ * Returns true if drained, false on timeout (leftover work is no longer
47
+ * lifecycle-managed).
48
+ */
49
+ async drain(timeoutMs = this.drainTimeoutMs) {
50
+ const deadline = Date.now() + timeoutMs;
51
+ while (this.work.size > 0) {
52
+ const remaining = deadline - Date.now();
53
+ if (remaining <= 0) {
54
+ return false;
55
+ }
56
+ // Wait for the registry to empty, or for the timeout — whichever comes
57
+ // first (a tracked promise may never settle).
58
+ let waiter;
59
+ const notified = new Promise((resolve) => {
60
+ waiter = resolve;
61
+ this.waiters.push(resolve);
62
+ });
63
+ let timer;
64
+ const timedOut = new Promise((resolve) => {
65
+ timer = setTimeout(resolve, remaining);
66
+ });
67
+ await Promise.race([notified, timedOut]);
68
+ if (timer)
69
+ clearTimeout(timer);
70
+ // If the timer won, the work set is still non-empty: drop our waiter so
71
+ // a later drain isn't woken by a stale notification. If notified won,
72
+ // remove() already cleared the waiters array.
73
+ if (waiter && this.work.size > 0) {
74
+ const idx = this.waiters.indexOf(waiter);
75
+ if (idx >= 0)
76
+ this.waiters.splice(idx, 1);
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Builtin content embedding.
3
+ *
4
+ * Builtin skills/subagents/plugins ship as TS source (src/builtin/*.ts) and are
5
+ * compiled into the bundle. At runtime they are lazily materialized to a
6
+ * content-hashed cache directory under the OS temp dir, so consumers that
7
+ * readdirSync real directories keep working unchanged — including inside
8
+ * esbuild bundles that never ship a `builtin/` dir on disk.
9
+ */
10
+ /**
11
+ * Content-hash key: sha256 of the sorted `rel\0content` entries, truncated.
12
+ * Any change to builtin content yields a new key, auto-invalidating stale caches.
13
+ */
14
+ export declare function getBuiltinCacheKey(): string;
15
+ /**
16
+ * Materialize builtin content to `{tmpdir}/wave-builtin/{contentHash}/` and
17
+ * return the directory. Memoized per process; idempotent across processes via
18
+ * the `.wave-builtin-complete` marker written last (a torn write leaves no
19
+ * marker, so the next run rewrites).
20
+ */
21
+ export declare function ensureBuiltinMaterialized(): string;