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.
- package/dist/agent.d.ts +9 -6
- package/dist/agent.js +35 -33
- package/dist/builtin/skills/settings.js +31 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/managers/aiManager.d.ts +10 -0
- package/dist/managers/aiManager.js +31 -4
- package/dist/managers/subagentManager.d.ts +1 -0
- package/dist/managers/subagentManager.js +5 -1
- package/dist/services/hook.js +41 -7
- package/dist/services/initializationService.js +0 -21
- package/dist/services/jsonlHandler.d.ts +37 -2
- package/dist/services/jsonlHandler.js +55 -6
- package/dist/services/session.d.ts +35 -4
- package/dist/services/session.js +233 -36
- package/dist/services/worktreeHooks.d.ts +45 -0
- package/dist/services/worktreeHooks.js +133 -0
- package/dist/tools/agentTool.js +36 -1
- package/dist/tools/bashTool.js +120 -57
- package/dist/tools/enterWorktreeTool.d.ts +1 -1
- package/dist/tools/enterWorktreeTool.js +44 -31
- package/dist/tools/exitWorktreeTool.js +21 -26
- package/dist/types/agent.d.ts +5 -0
- package/dist/types/hooks.d.ts +3 -2
- package/dist/types/skills.d.ts +0 -1
- package/dist/types/skills.js +0 -1
- package/dist/utils/asyncWorkRegistry.d.ts +32 -0
- package/dist/utils/asyncWorkRegistry.js +81 -0
- package/dist/utils/containerSetup.js +5 -0
- package/dist/utils/skillParser.js +3 -6
- package/dist/utils/windowsPaths.d.ts +28 -0
- package/dist/utils/windowsPaths.js +47 -0
- package/dist/utils/worktreeSession.d.ts +6 -0
- package/dist/utils/worktreeUtils.d.ts +7 -0
- package/dist/utils/worktreeUtils.js +88 -40
- 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
|
+
}
|
package/dist/tools/agentTool.js
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
package/dist/tools/bashTool.js
CHANGED
|
@@ -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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
475
|
+
const remainingStreams = new Set(pendingStreams);
|
|
476
|
+
const onStreamDone = (stream) => {
|
|
477
|
+
remainingStreams.delete(stream);
|
|
478
|
+
if (remainingStreams.size === 0) {
|
|
479
|
+
finish();
|
|
444
480
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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}`,
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/types/hooks.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface HookExecutionContext {
|
|
|
22
22
|
toolName?: string;
|
|
23
23
|
projectDir: string;
|
|
24
24
|
timestamp: Date;
|
|
25
|
-
|
|
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;
|
package/dist/types/skills.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/skills.js
CHANGED