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.
Files changed (144) hide show
  1. package/builtin/skills/loop/SKILL.md +29 -3
  2. package/dist/agent.d.ts +11 -2
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +44 -11
  5. package/dist/constants/tools.d.ts +3 -0
  6. package/dist/constants/tools.d.ts.map +1 -1
  7. package/dist/constants/tools.js +3 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/managers/aiManager.d.ts +13 -1
  12. package/dist/managers/aiManager.d.ts.map +1 -1
  13. package/dist/managers/aiManager.js +69 -17
  14. package/dist/managers/hookManager.d.ts.map +1 -1
  15. package/dist/managers/hookManager.js +9 -0
  16. package/dist/managers/mcpManager.d.ts +4 -1
  17. package/dist/managers/mcpManager.d.ts.map +1 -1
  18. package/dist/managers/mcpManager.js +25 -5
  19. package/dist/managers/messageManager.d.ts.map +1 -1
  20. package/dist/managers/messageManager.js +7 -6
  21. package/dist/managers/permissionManager.d.ts +0 -2
  22. package/dist/managers/permissionManager.d.ts.map +1 -1
  23. package/dist/managers/permissionManager.js +0 -30
  24. package/dist/managers/slashCommandManager.d.ts +1 -0
  25. package/dist/managers/slashCommandManager.d.ts.map +1 -1
  26. package/dist/managers/slashCommandManager.js +20 -4
  27. package/dist/managers/subagentManager.d.ts +6 -1
  28. package/dist/managers/subagentManager.d.ts.map +1 -1
  29. package/dist/managers/subagentManager.js +17 -18
  30. package/dist/managers/toolManager.d.ts +6 -0
  31. package/dist/managers/toolManager.d.ts.map +1 -1
  32. package/dist/managers/toolManager.js +41 -1
  33. package/dist/prompts/index.d.ts +1 -2
  34. package/dist/prompts/index.d.ts.map +1 -1
  35. package/dist/prompts/index.js +14 -6
  36. package/dist/services/initializationService.d.ts +0 -2
  37. package/dist/services/initializationService.d.ts.map +1 -1
  38. package/dist/services/initializationService.js +3 -35
  39. package/dist/services/jsonlHandler.d.ts +4 -4
  40. package/dist/services/jsonlHandler.d.ts.map +1 -1
  41. package/dist/services/jsonlHandler.js +4 -13
  42. package/dist/services/memory.d.ts +6 -0
  43. package/dist/services/memory.d.ts.map +1 -1
  44. package/dist/services/memory.js +27 -14
  45. package/dist/services/session.d.ts.map +1 -1
  46. package/dist/services/session.js +3 -12
  47. package/dist/tools/agentTool.d.ts.map +1 -1
  48. package/dist/tools/agentTool.js +16 -4
  49. package/dist/tools/bashTool.d.ts.map +1 -1
  50. package/dist/tools/bashTool.js +2 -5
  51. package/dist/tools/cronCreateTool.d.ts.map +1 -1
  52. package/dist/tools/cronCreateTool.js +71 -6
  53. package/dist/tools/cronDeleteTool.d.ts.map +1 -1
  54. package/dist/tools/cronDeleteTool.js +5 -1
  55. package/dist/tools/cronListTool.d.ts.map +1 -1
  56. package/dist/tools/cronListTool.js +5 -1
  57. package/dist/tools/enterWorktreeTool.d.ts +8 -0
  58. package/dist/tools/enterWorktreeTool.d.ts.map +1 -0
  59. package/dist/tools/enterWorktreeTool.js +144 -0
  60. package/dist/tools/exitWorktreeTool.d.ts +8 -0
  61. package/dist/tools/exitWorktreeTool.d.ts.map +1 -0
  62. package/dist/tools/exitWorktreeTool.js +184 -0
  63. package/dist/tools/skillTool.d.ts.map +1 -1
  64. package/dist/tools/skillTool.js +16 -4
  65. package/dist/tools/taskManagementTools.d.ts.map +1 -1
  66. package/dist/tools/taskManagementTools.js +4 -0
  67. package/dist/tools/toolSearchTool.d.ts +15 -0
  68. package/dist/tools/toolSearchTool.d.ts.map +1 -0
  69. package/dist/tools/toolSearchTool.js +185 -0
  70. package/dist/tools/types.d.ts +19 -0
  71. package/dist/tools/types.d.ts.map +1 -1
  72. package/dist/tools/webFetchTool.d.ts.map +1 -1
  73. package/dist/tools/webFetchTool.js +1 -0
  74. package/dist/types/agent.d.ts +6 -1
  75. package/dist/types/agent.d.ts.map +1 -1
  76. package/dist/types/hooks.d.ts +3 -1
  77. package/dist/types/hooks.d.ts.map +1 -1
  78. package/dist/types/hooks.js +1 -0
  79. package/dist/types/messaging.d.ts +1 -0
  80. package/dist/types/messaging.d.ts.map +1 -1
  81. package/dist/types/session.d.ts +0 -4
  82. package/dist/types/session.d.ts.map +1 -1
  83. package/dist/utils/containerSetup.d.ts.map +1 -1
  84. package/dist/utils/containerSetup.js +4 -6
  85. package/dist/utils/cronToHuman.d.ts +6 -0
  86. package/dist/utils/cronToHuman.d.ts.map +1 -0
  87. package/dist/utils/cronToHuman.js +79 -0
  88. package/dist/utils/isDeferredTool.d.ts +19 -0
  89. package/dist/utils/isDeferredTool.d.ts.map +1 -0
  90. package/dist/utils/isDeferredTool.js +31 -0
  91. package/dist/utils/mcpUtils.d.ts.map +1 -1
  92. package/dist/utils/mcpUtils.js +1 -0
  93. package/dist/utils/messageOperations.d.ts.map +1 -1
  94. package/dist/utils/messageOperations.js +5 -0
  95. package/dist/utils/parseCronExpression.d.ts +6 -0
  96. package/dist/utils/parseCronExpression.d.ts.map +1 -0
  97. package/dist/utils/parseCronExpression.js +74 -0
  98. package/dist/utils/worktreeSession.d.ts +26 -0
  99. package/dist/utils/worktreeSession.d.ts.map +1 -0
  100. package/dist/utils/worktreeSession.js +14 -0
  101. package/dist/utils/worktreeUtils.d.ts +42 -0
  102. package/dist/utils/worktreeUtils.d.ts.map +1 -0
  103. package/dist/utils/worktreeUtils.js +236 -0
  104. package/package.json +1 -1
  105. package/src/agent.ts +61 -12
  106. package/src/constants/tools.ts +3 -0
  107. package/src/index.ts +1 -0
  108. package/src/managers/aiManager.ts +73 -18
  109. package/src/managers/hookManager.ts +10 -0
  110. package/src/managers/mcpManager.ts +32 -6
  111. package/src/managers/messageManager.ts +7 -8
  112. package/src/managers/permissionManager.ts +0 -42
  113. package/src/managers/slashCommandManager.ts +30 -5
  114. package/src/managers/subagentManager.ts +28 -23
  115. package/src/managers/toolManager.ts +47 -1
  116. package/src/prompts/index.ts +17 -6
  117. package/src/services/initializationService.ts +2 -41
  118. package/src/services/jsonlHandler.ts +12 -24
  119. package/src/services/memory.ts +30 -17
  120. package/src/services/session.ts +3 -14
  121. package/src/tools/agentTool.ts +24 -5
  122. package/src/tools/bashTool.ts +2 -5
  123. package/src/tools/cronCreateTool.ts +81 -8
  124. package/src/tools/cronDeleteTool.ts +7 -2
  125. package/src/tools/cronListTool.ts +7 -2
  126. package/src/tools/enterWorktreeTool.ts +183 -0
  127. package/src/tools/exitWorktreeTool.ts +242 -0
  128. package/src/tools/skillTool.ts +24 -4
  129. package/src/tools/taskManagementTools.ts +4 -0
  130. package/src/tools/toolSearchTool.ts +228 -0
  131. package/src/tools/types.ts +19 -0
  132. package/src/tools/webFetchTool.ts +1 -0
  133. package/src/types/agent.ts +6 -0
  134. package/src/types/hooks.ts +4 -0
  135. package/src/types/messaging.ts +1 -0
  136. package/src/types/session.ts +0 -8
  137. package/src/utils/containerSetup.ts +7 -8
  138. package/src/utils/cronToHuman.ts +99 -0
  139. package/src/utils/isDeferredTool.ts +36 -0
  140. package/src/utils/mcpUtils.ts +1 -0
  141. package/src/utils/messageOperations.ts +5 -0
  142. package/src/utils/parseCronExpression.ts +78 -0
  143. package/src/utils/worktreeSession.ts +36 -0
  144. 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, { callbacks });
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
+ }
@@ -91,6 +91,7 @@ export function createMcpToolPlugin(
91
91
  return {
92
92
  name: prefixedName,
93
93
  config: mcpToolToOpenAITool(mcpTool, serverName),
94
+ isMcp: true, // MCP tools are always deferred
94
95
  async execute(
95
96
  args: Record<string, unknown>,
96
97
  context?: ToolContext,
@@ -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
+ }