wave-agent-sdk 1.0.10 → 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 (36) hide show
  1. package/dist/agent.d.ts +9 -6
  2. package/dist/agent.js +35 -33
  3. package/dist/builtin/skills/settings.js +31 -6
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/managers/aiManager.d.ts +10 -0
  7. package/dist/managers/aiManager.js +31 -4
  8. package/dist/managers/subagentManager.d.ts +1 -0
  9. package/dist/managers/subagentManager.js +5 -1
  10. package/dist/services/hook.js +41 -7
  11. package/dist/services/initializationService.js +0 -21
  12. package/dist/services/jsonlHandler.d.ts +37 -2
  13. package/dist/services/jsonlHandler.js +55 -6
  14. package/dist/services/session.d.ts +35 -4
  15. package/dist/services/session.js +233 -36
  16. package/dist/services/worktreeHooks.d.ts +45 -0
  17. package/dist/services/worktreeHooks.js +133 -0
  18. package/dist/tools/agentTool.js +36 -1
  19. package/dist/tools/bashTool.js +120 -57
  20. package/dist/tools/enterWorktreeTool.d.ts +1 -1
  21. package/dist/tools/enterWorktreeTool.js +44 -31
  22. package/dist/tools/exitWorktreeTool.js +21 -26
  23. package/dist/types/agent.d.ts +5 -0
  24. package/dist/types/hooks.d.ts +3 -2
  25. package/dist/types/skills.d.ts +0 -1
  26. package/dist/types/skills.js +0 -1
  27. package/dist/utils/asyncWorkRegistry.d.ts +32 -0
  28. package/dist/utils/asyncWorkRegistry.js +81 -0
  29. package/dist/utils/containerSetup.js +5 -0
  30. package/dist/utils/skillParser.js +3 -6
  31. package/dist/utils/windowsPaths.d.ts +28 -0
  32. package/dist/utils/windowsPaths.js +47 -0
  33. package/dist/utils/worktreeSession.d.ts +6 -0
  34. package/dist/utils/worktreeUtils.d.ts +7 -0
  35. package/dist/utils/worktreeUtils.js +88 -40
  36. package/package.json +5 -3
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Worktree hooks — replace semantics (aligned with Claude Code).
3
+ *
4
+ * When `WorktreeCreate` / `WorktreeRemove` hooks are configured, wave delegates
5
+ * worktree creation/removal to the hooks instead of running git itself:
6
+ * - `WorktreeCreate`: the first successful hook's stdout (trimmed) is the
7
+ * worktree path. All failures / empty output block creation.
8
+ * - `WorktreeRemove`: only fires for hook-based worktrees; wave never runs
9
+ * `git worktree remove` for them. Failures are logged, never blocking.
10
+ *
11
+ * Unlike HookManager (DI-bound, per-agent), this module operates on a merged
12
+ * `PartialHookConfiguration` so it can be used from standalone CLI paths
13
+ * (packages/code) that load settings via `loadMergedWaveConfig`.
14
+ * See docs/specs/multi-agent/worktree.md.
15
+ */
16
+ import { executeCommand } from "./hook.js";
17
+ import { logger } from "../utils/globalLogger.js";
18
+ export function hasWorktreeCreateHook(configuration) {
19
+ return hasWorktreeHook(configuration, "WorktreeCreate");
20
+ }
21
+ export function hasWorktreeRemoveHook(configuration) {
22
+ return hasWorktreeHook(configuration, "WorktreeRemove");
23
+ }
24
+ function hasWorktreeHook(configuration, event) {
25
+ return (configuration?.[event]?.some((config) => config.hooks.length > 0) ?? false);
26
+ }
27
+ /**
28
+ * Execute WorktreeCreate hooks and return the worktree path from hook stdout.
29
+ *
30
+ * The first successful hook (exit code 0) with non-empty stdout provides the
31
+ * worktree path (trimmed). Throws if every hook fails or none emits output —
32
+ * creation is blocked.
33
+ *
34
+ * Callers should check hasWorktreeCreateHook() before calling.
35
+ */
36
+ export async function executeWorktreeCreateHook(name, configuration, context) {
37
+ const hookConfigs = configuration?.["WorktreeCreate"] ?? [];
38
+ const results = await runWorktreeHooks("WorktreeCreate", hookConfigs, context, { name });
39
+ const successful = results.find((r) => r.success && (r.stdout ?? "").trim().length > 0);
40
+ if (!successful) {
41
+ const failedOutputs = results
42
+ .filter((r) => !r.success)
43
+ .map((r) => `${r.command}: ${r.stderr || r.stdout || "no output"}`);
44
+ throw new Error(`WorktreeCreate hook failed: ${failedOutputs.join("; ") || "no successful output"}`);
45
+ }
46
+ return { worktreePath: successful.stdout.trim() };
47
+ }
48
+ /**
49
+ * Execute WorktreeRemove hooks for a hook-based worktree.
50
+ * Returns true if hooks ran, false if none were configured.
51
+ * Failures are logged but never throw (non-blocking, aligned with Claude Code).
52
+ */
53
+ export async function executeWorktreeRemoveHook(worktreePath, configuration, context) {
54
+ const hookConfigs = configuration?.["WorktreeRemove"] ?? [];
55
+ if (hookConfigs.length === 0) {
56
+ return false;
57
+ }
58
+ const results = await runWorktreeHooks("WorktreeRemove", hookConfigs, context, { worktreePath });
59
+ for (const result of results) {
60
+ if (!result.success) {
61
+ logger?.error(`WorktreeRemove hook failed [${result.command}]: ${result.stderr || "no output"}`);
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+ /**
67
+ * Run every configured hook command for a worktree event, mirroring
68
+ * HookManager.executeHooks' per-command handling: `${WAVE_PLUGIN_ROOT}` /
69
+ * `${CLAUDE_PLUGIN_ROOT}` substitution, plugin env injection, per-command
70
+ * timeout, and fire-and-forget async hooks. Results are tagged with the
71
+ * command for error messages.
72
+ */
73
+ async function runWorktreeHooks(event, eventConfigs, context, extra) {
74
+ const baseContext = {
75
+ event,
76
+ projectDir: context.projectDir,
77
+ timestamp: new Date(),
78
+ sessionId: context.sessionId,
79
+ transcriptPath: context.transcriptPath,
80
+ cwd: context.projectDir,
81
+ env: context.env,
82
+ ...extra,
83
+ };
84
+ const results = [];
85
+ for (const config of eventConfigs) {
86
+ for (const hookCommand of config.hooks) {
87
+ const options = hookCommand.timeout
88
+ ? { timeout: hookCommand.timeout * 1000 }
89
+ : undefined;
90
+ // Build execution context with WAVE_PLUGIN_ROOT if this is a plugin hook
91
+ let command = hookCommand.command;
92
+ const execContext = hookCommand.pluginRoot
93
+ ? {
94
+ ...baseContext,
95
+ env: {
96
+ ...(baseContext.env ?? {}),
97
+ WAVE_PLUGIN_ROOT: hookCommand.pluginRoot,
98
+ CLAUDE_PLUGIN_ROOT: hookCommand.pluginRoot,
99
+ },
100
+ }
101
+ : baseContext;
102
+ if (hookCommand.pluginRoot) {
103
+ command = command.replace(/\$\{WAVE_PLUGIN_ROOT\}/g, hookCommand.pluginRoot);
104
+ command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, hookCommand.pluginRoot);
105
+ }
106
+ if (hookCommand.async) {
107
+ // Async hooks are fire-and-forget (never block, never contribute a
108
+ // path for WorktreeCreate).
109
+ executeCommand(command, execContext, options).catch((error) => {
110
+ const message = error instanceof Error ? error.message : "Unknown execution error";
111
+ logger?.error(`[worktreeHooks] Async ${event} hook failed: ${message}`);
112
+ });
113
+ continue;
114
+ }
115
+ try {
116
+ const result = await executeCommand(command, execContext, options);
117
+ results.push({ ...result, command });
118
+ }
119
+ catch (error) {
120
+ // Rare — executeCommand resolves rather than rejects on failure.
121
+ const message = error instanceof Error ? error.message : "Unknown execution error";
122
+ results.push({
123
+ success: false,
124
+ stderr: message,
125
+ duration: 0,
126
+ timedOut: false,
127
+ command,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ return results;
133
+ }
@@ -1,6 +1,26 @@
1
1
  import { EXPLORE_SUBAGENT_TYPE } from "../constants/subagents.js";
2
2
  import { AGENT_TOOL_NAME } from "../constants/tools.js";
3
3
  import { countToolBlocks, formatToolTokenSummary, } from "../utils/messageOperations.js";
4
+ /**
5
+ * Find the last streaming text/reasoning block across the subagent's messages.
6
+ * The message manager finalizes streaming content blocks (stage → "end")
7
+ * before a tool call begins, so at most one block is "streaming" at a time:
8
+ * its presence means the subagent is currently generating content (thinking or
9
+ * answering), its absence means it is in a tool phase (streaming/running/idle).
10
+ */
11
+ function findActiveStreamingBlock(messages) {
12
+ for (let i = messages.length - 1; i >= 0; i--) {
13
+ const blocks = messages[i].blocks;
14
+ for (let j = blocks.length - 1; j >= 0; j--) {
15
+ const block = blocks[j];
16
+ if ((block.type === "text" || block.type === "reasoning") &&
17
+ block.stage === "streaming") {
18
+ return block;
19
+ }
20
+ }
21
+ }
22
+ return undefined;
23
+ }
4
24
  /**
5
25
  * Agent tool plugin for launching specialized agents to handle complex tasks
6
26
  */
@@ -132,7 +152,13 @@ When using the Agent tool, you must specify a subagent_type parameter to select
132
152
  return;
133
153
  const messages = instance.messageManager.getMessages();
134
154
  const tokens = instance.messageManager.getLatestTotalTokens();
135
- const usedTools = instance.usedTools;
155
+ // While the subagent is streaming content (reasoning or reply text),
156
+ // show the last ONE tool plus the streaming content tail; otherwise
157
+ // (tool streaming/running/idle) keep the last two tools.
158
+ const activeStreaming = findActiveStreamingBlock(messages);
159
+ const usedTools = activeStreaming
160
+ ? instance.usedTools.slice(-1)
161
+ : instance.usedTools;
136
162
  const toolCount = countToolBlocks(messages);
137
163
  const summary = formatToolTokenSummary(toolCount, tokens);
138
164
  const getDisplayParam = (t) => {
@@ -155,6 +181,15 @@ When using the Agent tool, you must specify a subagent_type parameter to select
155
181
  .map((t) => `${t.name} ${getDisplayParam(t)}`)
156
182
  .join("\n");
157
183
  }
184
+ // Streaming content tail, on its own line below the tool lines
185
+ // (compact style, mirroring the tool's truncated compact params).
186
+ if (activeStreaming) {
187
+ const flat = activeStreaming.content.replace(/\s+/g, " ").trim();
188
+ if (flat) {
189
+ const tail = flat.length > 30 ? `…${flat.slice(-30)}` : flat;
190
+ shortResult += (shortResult ? "\n" : "") + tail;
191
+ }
192
+ }
158
193
  context.onShortResultUpdate?.(shortResult);
159
194
  });
160
195
  return new Promise((resolve) => {
@@ -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;
@@ -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}`,
@@ -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;
@@ -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,