wave-agent-sdk 0.15.0 → 0.15.2
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/builtin/skills/loop/SKILL.md +29 -3
- package/dist/agent.d.ts +11 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +44 -11
- package/dist/constants/tools.d.ts +3 -0
- package/dist/constants/tools.d.ts.map +1 -1
- package/dist/constants/tools.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +13 -1
- package/dist/managers/aiManager.d.ts.map +1 -1
- package/dist/managers/aiManager.js +69 -17
- package/dist/managers/hookManager.d.ts.map +1 -1
- package/dist/managers/hookManager.js +9 -0
- package/dist/managers/mcpManager.d.ts +4 -1
- package/dist/managers/mcpManager.d.ts.map +1 -1
- package/dist/managers/mcpManager.js +25 -5
- package/dist/managers/messageManager.d.ts.map +1 -1
- package/dist/managers/messageManager.js +7 -6
- package/dist/managers/permissionManager.d.ts +0 -2
- package/dist/managers/permissionManager.d.ts.map +1 -1
- package/dist/managers/permissionManager.js +0 -30
- package/dist/managers/slashCommandManager.d.ts +1 -0
- package/dist/managers/slashCommandManager.d.ts.map +1 -1
- package/dist/managers/slashCommandManager.js +20 -4
- package/dist/managers/subagentManager.d.ts +6 -1
- package/dist/managers/subagentManager.d.ts.map +1 -1
- package/dist/managers/subagentManager.js +17 -18
- package/dist/managers/toolManager.d.ts +6 -0
- package/dist/managers/toolManager.d.ts.map +1 -1
- package/dist/managers/toolManager.js +41 -1
- package/dist/prompts/index.d.ts +1 -2
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +14 -6
- package/dist/services/initializationService.d.ts +0 -2
- package/dist/services/initializationService.d.ts.map +1 -1
- package/dist/services/initializationService.js +3 -35
- package/dist/services/jsonlHandler.d.ts +4 -4
- package/dist/services/jsonlHandler.d.ts.map +1 -1
- package/dist/services/jsonlHandler.js +4 -13
- package/dist/services/memory.d.ts +6 -0
- package/dist/services/memory.d.ts.map +1 -1
- package/dist/services/memory.js +27 -14
- package/dist/services/session.d.ts.map +1 -1
- package/dist/services/session.js +3 -12
- package/dist/tools/agentTool.d.ts.map +1 -1
- package/dist/tools/agentTool.js +16 -4
- package/dist/tools/bashTool.d.ts.map +1 -1
- package/dist/tools/bashTool.js +2 -5
- package/dist/tools/cronCreateTool.d.ts.map +1 -1
- package/dist/tools/cronCreateTool.js +71 -6
- package/dist/tools/cronDeleteTool.d.ts.map +1 -1
- package/dist/tools/cronDeleteTool.js +5 -1
- package/dist/tools/cronListTool.d.ts.map +1 -1
- package/dist/tools/cronListTool.js +5 -1
- package/dist/tools/enterWorktreeTool.d.ts +8 -0
- package/dist/tools/enterWorktreeTool.d.ts.map +1 -0
- package/dist/tools/enterWorktreeTool.js +144 -0
- package/dist/tools/exitWorktreeTool.d.ts +8 -0
- package/dist/tools/exitWorktreeTool.d.ts.map +1 -0
- package/dist/tools/exitWorktreeTool.js +184 -0
- package/dist/tools/skillTool.d.ts.map +1 -1
- package/dist/tools/skillTool.js +16 -4
- package/dist/tools/taskManagementTools.d.ts.map +1 -1
- package/dist/tools/taskManagementTools.js +4 -0
- package/dist/tools/toolSearchTool.d.ts +15 -0
- package/dist/tools/toolSearchTool.d.ts.map +1 -0
- package/dist/tools/toolSearchTool.js +185 -0
- package/dist/tools/types.d.ts +19 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/webFetchTool.d.ts.map +1 -1
- package/dist/tools/webFetchTool.js +1 -0
- package/dist/types/agent.d.ts +6 -1
- package/dist/types/agent.d.ts.map +1 -1
- package/dist/types/hooks.d.ts +3 -1
- package/dist/types/hooks.d.ts.map +1 -1
- package/dist/types/hooks.js +1 -0
- package/dist/types/messaging.d.ts +1 -0
- package/dist/types/messaging.d.ts.map +1 -1
- package/dist/types/session.d.ts +0 -4
- package/dist/types/session.d.ts.map +1 -1
- package/dist/utils/containerSetup.d.ts.map +1 -1
- package/dist/utils/containerSetup.js +4 -6
- package/dist/utils/cronToHuman.d.ts +6 -0
- package/dist/utils/cronToHuman.d.ts.map +1 -0
- package/dist/utils/cronToHuman.js +79 -0
- package/dist/utils/isDeferredTool.d.ts +19 -0
- package/dist/utils/isDeferredTool.d.ts.map +1 -0
- package/dist/utils/isDeferredTool.js +31 -0
- package/dist/utils/mcpUtils.d.ts.map +1 -1
- package/dist/utils/mcpUtils.js +1 -0
- package/dist/utils/messageOperations.d.ts.map +1 -1
- package/dist/utils/messageOperations.js +5 -0
- package/dist/utils/parseCronExpression.d.ts +6 -0
- package/dist/utils/parseCronExpression.d.ts.map +1 -0
- package/dist/utils/parseCronExpression.js +74 -0
- package/dist/utils/worktreeSession.d.ts +26 -0
- package/dist/utils/worktreeSession.d.ts.map +1 -0
- package/dist/utils/worktreeSession.js +14 -0
- package/dist/utils/worktreeUtils.d.ts +42 -0
- package/dist/utils/worktreeUtils.d.ts.map +1 -0
- package/dist/utils/worktreeUtils.js +236 -0
- package/package.json +1 -1
- package/src/agent.ts +61 -12
- package/src/constants/tools.ts +3 -0
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +73 -18
- package/src/managers/hookManager.ts +10 -0
- package/src/managers/mcpManager.ts +32 -6
- package/src/managers/messageManager.ts +7 -8
- package/src/managers/permissionManager.ts +0 -42
- package/src/managers/slashCommandManager.ts +30 -5
- package/src/managers/subagentManager.ts +28 -23
- package/src/managers/toolManager.ts +47 -1
- package/src/prompts/index.ts +17 -6
- package/src/services/initializationService.ts +2 -41
- package/src/services/jsonlHandler.ts +12 -24
- package/src/services/memory.ts +30 -17
- package/src/services/session.ts +3 -14
- package/src/tools/agentTool.ts +24 -5
- package/src/tools/bashTool.ts +2 -5
- package/src/tools/cronCreateTool.ts +81 -8
- package/src/tools/cronDeleteTool.ts +7 -2
- package/src/tools/cronListTool.ts +7 -2
- package/src/tools/enterWorktreeTool.ts +183 -0
- package/src/tools/exitWorktreeTool.ts +242 -0
- package/src/tools/skillTool.ts +24 -4
- package/src/tools/taskManagementTools.ts +4 -0
- package/src/tools/toolSearchTool.ts +228 -0
- package/src/tools/types.ts +19 -0
- package/src/tools/webFetchTool.ts +1 -0
- package/src/types/agent.ts +6 -0
- package/src/types/hooks.ts +4 -0
- package/src/types/messaging.ts +1 -0
- package/src/types/session.ts +0 -8
- package/src/utils/containerSetup.ts +7 -8
- package/src/utils/cronToHuman.ts +99 -0
- package/src/utils/isDeferredTool.ts +36 -0
- package/src/utils/mcpUtils.ts +1 -0
- package/src/utils/messageOperations.ts +5 -0
- package/src/utils/parseCronExpression.ts +78 -0
- package/src/utils/worktreeSession.ts +36 -0
- package/src/utils/worktreeUtils.ts +288 -0
|
@@ -26,9 +26,8 @@ import { ConfigurationService } from "../services/configurationService.js";
|
|
|
26
26
|
import { ReversionService } from "../services/reversionService.js";
|
|
27
27
|
import { MemoryService } from "../services/memory.js";
|
|
28
28
|
import { AutoMemoryService } from "../services/autoMemoryService.js";
|
|
29
|
-
import { getGitMainRepoRoot } from "./gitUtils.js";
|
|
30
29
|
import { USER_MEMORY_FILE } from "./constants.js";
|
|
31
|
-
import type { AgentOptions } from "../types/index.js";
|
|
30
|
+
import type { AgentOptions, McpServerConfig } from "../types/index.js";
|
|
32
31
|
import type {
|
|
33
32
|
PermissionMode,
|
|
34
33
|
Usage,
|
|
@@ -89,11 +88,6 @@ export function setupAgentContainer(
|
|
|
89
88
|
container.register("ForegroundTaskManager", foregroundTaskManager);
|
|
90
89
|
container.register("ConfigurationService", configurationService);
|
|
91
90
|
|
|
92
|
-
if (options.worktreeName) {
|
|
93
|
-
container.register("WorktreeName", options.worktreeName);
|
|
94
|
-
container.register("MainRepoRoot", getGitMainRepoRoot(workdir));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
91
|
const memoryService = new MemoryService(container);
|
|
98
92
|
container.register("MemoryService", memoryService);
|
|
99
93
|
|
|
@@ -145,7 +139,12 @@ export function setupAgentContainer(
|
|
|
145
139
|
});
|
|
146
140
|
container.register("BackgroundTaskManager", backgroundTaskManager);
|
|
147
141
|
|
|
148
|
-
const mcpManager = new McpManager(container, {
|
|
142
|
+
const mcpManager = new McpManager(container, {
|
|
143
|
+
callbacks,
|
|
144
|
+
mcpServers: options.mcpServers as
|
|
145
|
+
| Record<string, McpServerConfig>
|
|
146
|
+
| undefined,
|
|
147
|
+
});
|
|
149
148
|
container.register("McpManager", mcpManager);
|
|
150
149
|
|
|
151
150
|
const lspManager = options.lspManager || new LspManager(container);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Human-readable cron expression display.
|
|
2
|
+
// Intentionally narrow: covers common patterns; falls through to raw cron
|
|
3
|
+
// string for anything else. Based on Claude Code's cronToHuman implementation.
|
|
4
|
+
|
|
5
|
+
const DAY_NAMES = [
|
|
6
|
+
"Sunday",
|
|
7
|
+
"Monday",
|
|
8
|
+
"Tuesday",
|
|
9
|
+
"Wednesday",
|
|
10
|
+
"Thursday",
|
|
11
|
+
"Friday",
|
|
12
|
+
"Saturday",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function formatLocalTime(minute: number, hour: number): string {
|
|
16
|
+
const d = new Date(2000, 0, 1, hour, minute);
|
|
17
|
+
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert a cron expression to a human-readable string.
|
|
22
|
+
* Covers common patterns; falls through to raw cron for anything else.
|
|
23
|
+
*/
|
|
24
|
+
export function cronToHuman(cron: string): string {
|
|
25
|
+
const parts = cron.trim().split(/\s+/);
|
|
26
|
+
if (parts.length !== 5) return cron;
|
|
27
|
+
|
|
28
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [
|
|
29
|
+
string,
|
|
30
|
+
string,
|
|
31
|
+
string,
|
|
32
|
+
string,
|
|
33
|
+
string,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Every N minutes: step/N * * * *
|
|
37
|
+
const everyMinMatch = minute.match(/^\*\/(\d+)$/);
|
|
38
|
+
if (
|
|
39
|
+
everyMinMatch &&
|
|
40
|
+
hour === "*" &&
|
|
41
|
+
dayOfMonth === "*" &&
|
|
42
|
+
month === "*" &&
|
|
43
|
+
dayOfWeek === "*"
|
|
44
|
+
) {
|
|
45
|
+
const n = parseInt(everyMinMatch[1]!, 10);
|
|
46
|
+
return n === 1 ? "Every minute" : `Every ${n} minutes`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Every hour: 0 * * * *
|
|
50
|
+
if (
|
|
51
|
+
minute.match(/^\d+$/) &&
|
|
52
|
+
hour === "*" &&
|
|
53
|
+
dayOfMonth === "*" &&
|
|
54
|
+
month === "*" &&
|
|
55
|
+
dayOfWeek === "*"
|
|
56
|
+
) {
|
|
57
|
+
const m = parseInt(minute, 10);
|
|
58
|
+
if (m === 0) return "Every hour";
|
|
59
|
+
return `Every hour at :${m.toString().padStart(2, "0")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every N hours: 0 step/N * * *
|
|
63
|
+
const everyHourMatch = hour.match(/^\*\/(\d+)$/);
|
|
64
|
+
if (
|
|
65
|
+
minute.match(/^\d+$/) &&
|
|
66
|
+
everyHourMatch &&
|
|
67
|
+
dayOfMonth === "*" &&
|
|
68
|
+
month === "*" &&
|
|
69
|
+
dayOfWeek === "*"
|
|
70
|
+
) {
|
|
71
|
+
const n = parseInt(everyHourMatch[1]!, 10);
|
|
72
|
+
const m = parseInt(minute, 10);
|
|
73
|
+
const suffix = m === 0 ? "" : ` at :${m.toString().padStart(2, "0")}`;
|
|
74
|
+
return n === 1 ? `Every hour${suffix}` : `Every ${n} hours${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!minute.match(/^\d+$/) || !hour.match(/^\d+$/)) return cron;
|
|
78
|
+
const m = parseInt(minute, 10);
|
|
79
|
+
const h = parseInt(hour, 10);
|
|
80
|
+
|
|
81
|
+
// Daily at specific time: M H * * *
|
|
82
|
+
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
|
|
83
|
+
return `Every day at ${formatLocalTime(m, h)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Specific day of week: M H * * D
|
|
87
|
+
if (dayOfMonth === "*" && month === "*" && dayOfWeek.match(/^\d$/)) {
|
|
88
|
+
const dayIndex = parseInt(dayOfWeek, 10) % 7;
|
|
89
|
+
const dayName = DAY_NAMES[dayIndex];
|
|
90
|
+
if (dayName) return `Every ${dayName} at ${formatLocalTime(m, h)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Weekdays: M H * * 1-5
|
|
94
|
+
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "1-5") {
|
|
95
|
+
return `Weekdays at ${formatLocalTime(m, h)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return cron;
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determines if a tool should be deferred (not sent to the API until discovered).
|
|
3
|
+
*
|
|
4
|
+
* A tool is deferred if:
|
|
5
|
+
* - It has shouldDefer: true
|
|
6
|
+
* - It is an MCP tool (isMcp: true)
|
|
7
|
+
*
|
|
8
|
+
* A tool is NEVER deferred if:
|
|
9
|
+
* - It has alwaysLoad: true
|
|
10
|
+
* - It is the ToolSearch tool itself (must always be available)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ToolPlugin } from "../tools/types.js";
|
|
14
|
+
|
|
15
|
+
export const TOOL_SEARCH_TOOL_NAME = "ToolSearch";
|
|
16
|
+
|
|
17
|
+
export function isDeferredTool(tool: ToolPlugin): boolean {
|
|
18
|
+
// Never defer if explicitly marked as alwaysLoad
|
|
19
|
+
if (tool.alwaysLoad === true) return false;
|
|
20
|
+
|
|
21
|
+
// Never defer ToolSearch itself — the model needs it to discover other tools
|
|
22
|
+
if (tool.name === TOOL_SEARCH_TOOL_NAME) return false;
|
|
23
|
+
|
|
24
|
+
// MCP tools are always deferred (workflow-specific, potentially many)
|
|
25
|
+
if (tool.isMcp === true) return true;
|
|
26
|
+
|
|
27
|
+
// Defer if marked with shouldDefer flag
|
|
28
|
+
return tool.shouldDefer === true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the list of deferred tool names from a tools array.
|
|
33
|
+
*/
|
|
34
|
+
export function getDeferredToolNames(tools: ToolPlugin[]): string[] {
|
|
35
|
+
return tools.filter(isDeferredTool).map((t) => t.name);
|
|
36
|
+
}
|
package/src/utils/mcpUtils.ts
CHANGED
|
@@ -160,6 +160,7 @@ export const addUserMessageToMessages = ({
|
|
|
160
160
|
id: id || generateMessageId(),
|
|
161
161
|
role: "user",
|
|
162
162
|
blocks,
|
|
163
|
+
timestamp: new Date().toISOString(),
|
|
163
164
|
...(isMeta !== undefined && { isMeta }),
|
|
164
165
|
};
|
|
165
166
|
return [...messages, userMessage];
|
|
@@ -231,6 +232,7 @@ export const addAssistantMessageToMessages = (
|
|
|
231
232
|
id: generateMessageId(),
|
|
232
233
|
role: "assistant",
|
|
233
234
|
blocks,
|
|
235
|
+
timestamp: new Date().toISOString(),
|
|
234
236
|
usage, // Include usage data if provided
|
|
235
237
|
...(additionalFields ? { additionalFields: { ...additionalFields } } : {}),
|
|
236
238
|
};
|
|
@@ -407,6 +409,7 @@ export const addErrorBlockToMessage = ({
|
|
|
407
409
|
content: error,
|
|
408
410
|
},
|
|
409
411
|
],
|
|
412
|
+
timestamp: new Date().toISOString(),
|
|
410
413
|
});
|
|
411
414
|
}
|
|
412
415
|
|
|
@@ -430,6 +433,7 @@ export const addBangMessage = ({
|
|
|
430
433
|
exitCode: null,
|
|
431
434
|
},
|
|
432
435
|
],
|
|
436
|
+
timestamp: new Date().toISOString(),
|
|
433
437
|
};
|
|
434
438
|
|
|
435
439
|
return [...messages, outputMessage];
|
|
@@ -620,6 +624,7 @@ export const addNotificationMessageToMessages = ({
|
|
|
620
624
|
id: generateMessageId(),
|
|
621
625
|
role: "user",
|
|
622
626
|
blocks: [block],
|
|
627
|
+
timestamp: new Date().toISOString(),
|
|
623
628
|
};
|
|
624
629
|
|
|
625
630
|
return [...messages, notificationMessage];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Minimal cron expression validation.
|
|
2
|
+
// Supports the standard 5-field cron subset:
|
|
3
|
+
// minute hour day-of-month month day-of-week
|
|
4
|
+
//
|
|
5
|
+
// Field syntax: wildcard, N, step (star-slash-N), range (N-M), list (N,M,...).
|
|
6
|
+
// No L, W, ?, or name aliases. Based on Claude Code's parseCronExpression.
|
|
7
|
+
|
|
8
|
+
type FieldRange = { min: number; max: number };
|
|
9
|
+
|
|
10
|
+
const FIELD_RANGES: FieldRange[] = [
|
|
11
|
+
{ min: 0, max: 59 }, // minute
|
|
12
|
+
{ min: 0, max: 23 }, // hour
|
|
13
|
+
{ min: 1, max: 31 }, // dayOfMonth
|
|
14
|
+
{ min: 1, max: 12 }, // month
|
|
15
|
+
{ min: 0, max: 6 }, // dayOfWeek (0=Sunday; 7 accepted as Sunday alias)
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function expandField(field: string, range: FieldRange): number[] | null {
|
|
19
|
+
const { min, max } = range;
|
|
20
|
+
const out = new Set<number>();
|
|
21
|
+
|
|
22
|
+
for (const part of field.split(",")) {
|
|
23
|
+
// wildcard or star-slash-N
|
|
24
|
+
const stepMatch = part.match(/^\*(?:\/(\d+))?$/);
|
|
25
|
+
if (stepMatch) {
|
|
26
|
+
const step = stepMatch[1] ? parseInt(stepMatch[1], 10) : 1;
|
|
27
|
+
if (step < 1) return null;
|
|
28
|
+
for (let i = min; i <= max; i += step) out.add(i);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// N-M or N-M/S
|
|
33
|
+
const rangeMatch = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
|
|
34
|
+
if (rangeMatch) {
|
|
35
|
+
const lo = parseInt(rangeMatch[1]!, 10);
|
|
36
|
+
const hi = parseInt(rangeMatch[2]!, 10);
|
|
37
|
+
const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1;
|
|
38
|
+
const isDow = min === 0 && max === 6;
|
|
39
|
+
const effMax = isDow ? 7 : max;
|
|
40
|
+
if (lo > hi || step < 1 || lo < min || hi > effMax) return null;
|
|
41
|
+
for (let i = lo; i <= hi; i += step) {
|
|
42
|
+
out.add(isDow && i === 7 ? 0 : i);
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// plain N
|
|
48
|
+
const singleMatch = part.match(/^\d+$/);
|
|
49
|
+
if (singleMatch) {
|
|
50
|
+
let n = parseInt(part, 10);
|
|
51
|
+
if (min === 0 && max === 6 && n === 7) n = 0;
|
|
52
|
+
if (n < min || n > max) return null;
|
|
53
|
+
out.add(n);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (out.size === 0) return null;
|
|
61
|
+
return Array.from(out).sort((a, b) => a - b);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate a 5-field cron expression.
|
|
66
|
+
* Returns true if valid, false otherwise.
|
|
67
|
+
*/
|
|
68
|
+
export function parseCronExpression(expr: string): boolean {
|
|
69
|
+
const parts = expr.trim().split(/\s+/);
|
|
70
|
+
if (parts.length !== 5) return false;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < 5; i++) {
|
|
73
|
+
const result = expandField(parts[i]!, FIELD_RANGES[i]!);
|
|
74
|
+
if (!result) return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level worktree session state tracking.
|
|
3
|
+
* Analogous to Claude Code's currentWorktreeSession in worktree.ts.
|
|
4
|
+
*
|
|
5
|
+
* Tracks whether the current session entered a worktree via EnterWorktree tool,
|
|
6
|
+
* so ExitWorktree can validate scope and restore the original CWD.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface WorktreeSession {
|
|
10
|
+
/** The working directory the session was in before EnterWorktree */
|
|
11
|
+
originalCwd: string;
|
|
12
|
+
/** Path to the worktree directory */
|
|
13
|
+
worktreePath: string;
|
|
14
|
+
/** Git branch name for the worktree */
|
|
15
|
+
worktreeBranch: string;
|
|
16
|
+
/** User-provided or auto-generated worktree name */
|
|
17
|
+
worktreeName: string;
|
|
18
|
+
/** Whether this worktree was newly created (vs resumed existing) */
|
|
19
|
+
isNew: boolean;
|
|
20
|
+
/** The canonical git repo root */
|
|
21
|
+
repoRoot: string;
|
|
22
|
+
/** The HEAD commit of the original branch at worktree creation time */
|
|
23
|
+
originalHeadCommit?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let currentWorktreeSession: WorktreeSession | null = null;
|
|
27
|
+
|
|
28
|
+
export function getCurrentWorktreeSession(): WorktreeSession | null {
|
|
29
|
+
return currentWorktreeSession;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setCurrentWorktreeSession(
|
|
33
|
+
session: WorktreeSession | null,
|
|
34
|
+
): void {
|
|
35
|
+
currentWorktreeSession = session;
|
|
36
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree creation and removal utilities for the SDK.
|
|
3
|
+
* Used by EnterWorktree and ExitWorktree tools.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import { getGitMainRepoRoot, getDefaultRemoteBranch } from "./gitUtils.js";
|
|
10
|
+
import { logger } from "./globalLogger.js";
|
|
11
|
+
|
|
12
|
+
export interface WorktreeInfo {
|
|
13
|
+
name: string;
|
|
14
|
+
path: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
repoRoot: string;
|
|
17
|
+
isNew: boolean;
|
|
18
|
+
/** HEAD commit of the original branch at creation time, for dirty-check on exit */
|
|
19
|
+
originalHeadCommit?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a worktree name to prevent path traversal and invalid characters.
|
|
24
|
+
*/
|
|
25
|
+
export function validateWorktreeName(name: string): void {
|
|
26
|
+
const MAX_LENGTH = 64;
|
|
27
|
+
if (name.length > MAX_LENGTH) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid worktree name: must be ${MAX_LENGTH} characters or fewer (got ${name.length})`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
for (const segment of name.split("/")) {
|
|
33
|
+
if (segment === "." || segment === "..") {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Invalid worktree name "${name}": must not contain "." or ".." path segments`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Invalid worktree name "${name}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate a random worktree name.
|
|
48
|
+
*/
|
|
49
|
+
export function generateWorktreeName(): string {
|
|
50
|
+
const adjectives = [
|
|
51
|
+
"swift",
|
|
52
|
+
"calm",
|
|
53
|
+
"bold",
|
|
54
|
+
"keen",
|
|
55
|
+
"bright",
|
|
56
|
+
"cool",
|
|
57
|
+
"deep",
|
|
58
|
+
"fair",
|
|
59
|
+
"gentle",
|
|
60
|
+
"grand",
|
|
61
|
+
];
|
|
62
|
+
const nouns = [
|
|
63
|
+
"fox",
|
|
64
|
+
"owl",
|
|
65
|
+
"hawk",
|
|
66
|
+
"wolf",
|
|
67
|
+
"bear",
|
|
68
|
+
"lynx",
|
|
69
|
+
"pike",
|
|
70
|
+
"kite",
|
|
71
|
+
"dove",
|
|
72
|
+
"stag",
|
|
73
|
+
];
|
|
74
|
+
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
75
|
+
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
76
|
+
const num = Math.floor(Math.random() * 900) + 100;
|
|
77
|
+
return `${adj}-${noun}-${num}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get the current HEAD commit SHA.
|
|
82
|
+
*/
|
|
83
|
+
export function getHeadCommit(cwd: string): string {
|
|
84
|
+
return execSync(`git -C "${cwd}" rev-parse HEAD`, {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
87
|
+
}).trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a git worktree for use during a session.
|
|
92
|
+
*/
|
|
93
|
+
export function createWorktree(name: string, cwd: string): WorktreeInfo {
|
|
94
|
+
const repoRoot = getGitMainRepoRoot(cwd);
|
|
95
|
+
if (!repoRoot) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"Cannot create a worktree: not in a git repository. Configure WorktreeCreate and WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Capture HEAD commit before creating worktree (for dirty-check on exit)
|
|
102
|
+
const originalHeadCommit = getHeadCommit(cwd);
|
|
103
|
+
|
|
104
|
+
const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
|
|
105
|
+
const branchName = `worktree-${name}`;
|
|
106
|
+
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
107
|
+
|
|
108
|
+
// Ensure parent directory exists
|
|
109
|
+
const parentDir = path.dirname(worktreePath);
|
|
110
|
+
if (!fs.existsSync(parentDir)) {
|
|
111
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if worktree already exists
|
|
115
|
+
if (fs.existsSync(worktreePath)) {
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
path: worktreePath,
|
|
119
|
+
branch: branchName,
|
|
120
|
+
repoRoot,
|
|
121
|
+
isNew: false,
|
|
122
|
+
originalHeadCommit,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Create worktree and branch
|
|
128
|
+
execSync(
|
|
129
|
+
`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`,
|
|
130
|
+
{
|
|
131
|
+
cwd: repoRoot,
|
|
132
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
133
|
+
env: {
|
|
134
|
+
...process.env,
|
|
135
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
136
|
+
GIT_ASKPASS: "",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
path: worktreePath,
|
|
144
|
+
branch: branchName,
|
|
145
|
+
repoRoot,
|
|
146
|
+
isNew: true,
|
|
147
|
+
originalHeadCommit,
|
|
148
|
+
};
|
|
149
|
+
} catch (error: unknown) {
|
|
150
|
+
const stderr = (error as { stderr?: Buffer }).stderr?.toString() || "";
|
|
151
|
+
if (stderr.includes("already exists")) {
|
|
152
|
+
// Branch exists but worktree doesn't — attach to existing branch
|
|
153
|
+
try {
|
|
154
|
+
execSync(`git worktree add "${worktreePath}" ${branchName}`, {
|
|
155
|
+
cwd: repoRoot,
|
|
156
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
157
|
+
env: {
|
|
158
|
+
...process.env,
|
|
159
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
160
|
+
GIT_ASKPASS: "",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
name,
|
|
165
|
+
path: worktreePath,
|
|
166
|
+
branch: branchName,
|
|
167
|
+
repoRoot,
|
|
168
|
+
isNew: true,
|
|
169
|
+
originalHeadCommit,
|
|
170
|
+
};
|
|
171
|
+
} catch (innerError: unknown) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Failed to add worktree: ${(innerError as Error).message}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Failed to create worktree: ${(error as Error).message}\n${stderr}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Remove a git worktree and its branch.
|
|
185
|
+
*/
|
|
186
|
+
export function removeWorktree(info: WorktreeInfo): void {
|
|
187
|
+
const repoRoot = info.repoRoot;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Get current branch in worktree before removing
|
|
191
|
+
let currentBranch: string | undefined;
|
|
192
|
+
try {
|
|
193
|
+
currentBranch = execSync(`git rev-parse --abbrev-ref HEAD`, {
|
|
194
|
+
cwd: info.path,
|
|
195
|
+
encoding: "utf8",
|
|
196
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
197
|
+
}).trim();
|
|
198
|
+
} catch {
|
|
199
|
+
// Ignore errors
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Remove worktree
|
|
203
|
+
execSync(`git worktree remove --force "${info.path}"`, {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Delete worktree branch
|
|
209
|
+
try {
|
|
210
|
+
execSync(`git branch -D ${info.branch}`, {
|
|
211
|
+
cwd: repoRoot,
|
|
212
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
// Ignore errors
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Delete current branch if different and not protected
|
|
219
|
+
if (
|
|
220
|
+
currentBranch &&
|
|
221
|
+
currentBranch !== info.branch &&
|
|
222
|
+
currentBranch !== "HEAD"
|
|
223
|
+
) {
|
|
224
|
+
const defaultRemoteBranch = getDefaultRemoteBranch(repoRoot);
|
|
225
|
+
const defaultBranchName = defaultRemoteBranch.split("/").pop();
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
currentBranch !== defaultBranchName &&
|
|
229
|
+
currentBranch !== "main" &&
|
|
230
|
+
currentBranch !== "master"
|
|
231
|
+
) {
|
|
232
|
+
try {
|
|
233
|
+
execSync(`git branch -D ${currentBranch}`, {
|
|
234
|
+
cwd: repoRoot,
|
|
235
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
// Ignore errors
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (error: unknown) {
|
|
243
|
+
logger.error("Failed to remove worktree or branch:", {
|
|
244
|
+
error: error instanceof Error ? error.message : String(error),
|
|
245
|
+
worktreePath: info.path,
|
|
246
|
+
});
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Count uncommitted files and new commits in a worktree.
|
|
253
|
+
* Returns null if git commands fail (fail-closed).
|
|
254
|
+
*/
|
|
255
|
+
export function countWorktreeChanges(
|
|
256
|
+
worktreePath: string,
|
|
257
|
+
originalHeadCommit: string | undefined,
|
|
258
|
+
): { changedFiles: number; commits: number } | null {
|
|
259
|
+
try {
|
|
260
|
+
const statusOutput = execSync(
|
|
261
|
+
`git -C "${worktreePath}" status --porcelain`,
|
|
262
|
+
{
|
|
263
|
+
encoding: "utf8",
|
|
264
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
const changedFiles = statusOutput
|
|
268
|
+
.split("\n")
|
|
269
|
+
.filter((l) => l.trim() !== "").length;
|
|
270
|
+
|
|
271
|
+
if (!originalHeadCommit) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const revListOutput = execSync(
|
|
276
|
+
`git -C "${worktreePath}" rev-list --count ${originalHeadCommit}..HEAD`,
|
|
277
|
+
{
|
|
278
|
+
encoding: "utf8",
|
|
279
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
const commits = parseInt(revListOutput.trim(), 10) || 0;
|
|
283
|
+
|
|
284
|
+
return { changedFiles, commits };
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|