wave-agent-sdk 0.19.8 → 1.0.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/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
- package/builtin/plugins/sdd/hooks/hooks.json +14 -0
- package/builtin/plugins/sdd/scripts/session-start.js +24 -0
- package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
- package/builtin/plugins/sdd/skills/specify/SKILL.md +47 -0
- package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
- package/builtin/skills/settings/ENV.md +15 -9
- package/builtin/skills/settings/HOOKS.md +27 -2
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +26 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +25 -0
- package/dist/managers/aiManager.js +172 -51
- package/dist/managers/backgroundTaskManager.d.ts +6 -0
- package/dist/managers/backgroundTaskManager.js +11 -0
- package/dist/managers/bangManager.d.ts +6 -0
- package/dist/managers/bangManager.js +11 -0
- package/dist/managers/hookManager.d.ts +8 -2
- package/dist/managers/hookManager.js +14 -4
- package/dist/managers/mcpManager.d.ts +18 -4
- package/dist/managers/mcpManager.js +40 -18
- package/dist/managers/permissionManager.d.ts +7 -0
- package/dist/managers/permissionManager.js +102 -142
- package/dist/managers/pluginManager.d.ts +7 -0
- package/dist/managers/pluginManager.js +31 -0
- package/dist/managers/toolManager.js +5 -0
- package/dist/prompts/index.d.ts +12 -1
- package/dist/prompts/index.js +133 -45
- package/dist/services/aiService.d.ts +1 -17
- package/dist/services/aiService.js +3 -85
- package/dist/services/configurationService.d.ts +21 -2
- package/dist/services/configurationService.js +72 -23
- package/dist/services/initializationService.js +14 -4
- package/dist/services/interactionService.js +35 -7
- package/dist/services/remoteSettingsService.d.ts +12 -0
- package/dist/services/remoteSettingsService.js +15 -1
- package/dist/services/session.d.ts +3 -1
- package/dist/services/session.js +12 -4
- package/dist/services/taskManager.d.ts +1 -0
- package/dist/services/taskManager.js +41 -6
- package/dist/tools/bashTool.js +1 -0
- package/dist/tools/editTool.js +24 -10
- package/dist/tools/enterWorktreeTool.js +14 -3
- package/dist/tools/exitWorktreeTool.js +11 -10
- package/dist/tools/grepTool.js +8 -2
- package/dist/tools/types.d.ts +7 -0
- package/dist/tools/writeTool.js +36 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/hooks.d.ts +2 -2
- package/dist/utils/bashParser.d.ts +25 -0
- package/dist/utils/bashParser.js +103 -0
- package/dist/utils/configPaths.d.ts +4 -0
- package/dist/utils/configPaths.js +6 -0
- package/dist/utils/containerSetup.js +1 -1
- package/dist/utils/fileSearch.js +4 -2
- package/dist/utils/openaiClient.js +2 -1
- package/dist/utils/pathEncoder.js +7 -2
- package/dist/utils/worktreeUtils.d.ts +17 -0
- package/dist/utils/worktreeUtils.js +339 -1
- package/package.json +1 -1
- package/src/agent.ts +26 -12
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +238 -66
- package/src/managers/backgroundTaskManager.ts +15 -0
- package/src/managers/bangManager.ts +15 -0
- package/src/managers/hookManager.ts +20 -5
- package/src/managers/mcpManager.ts +60 -18
- package/src/managers/permissionManager.ts +116 -168
- package/src/managers/pluginManager.ts +29 -0
- package/src/managers/toolManager.ts +7 -0
- package/src/prompts/index.ts +144 -37
- package/src/services/aiService.ts +9 -128
- package/src/services/configurationService.ts +84 -23
- package/src/services/initializationService.ts +17 -4
- package/src/services/interactionService.ts +49 -6
- package/src/services/remoteSettingsService.ts +16 -1
- package/src/services/session.ts +18 -4
- package/src/services/taskManager.ts +56 -8
- package/src/tools/bashTool.ts +1 -0
- package/src/tools/editTool.ts +29 -11
- package/src/tools/enterWorktreeTool.ts +19 -2
- package/src/tools/exitWorktreeTool.ts +15 -12
- package/src/tools/grepTool.ts +11 -2
- package/src/tools/types.ts +7 -0
- package/src/tools/writeTool.ts +43 -0
- package/src/types/config.ts +2 -0
- package/src/types/hooks.ts +2 -2
- package/src/utils/bashParser.ts +106 -0
- package/src/utils/configPaths.ts +7 -0
- package/src/utils/containerSetup.ts +3 -1
- package/src/utils/fileSearch.ts +6 -2
- package/src/utils/openaiClient.ts +2 -0
- package/src/utils/pathEncoder.ts +7 -2
- package/src/utils/worktreeUtils.ts +401 -1
package/dist/utils/bashParser.js
CHANGED
|
@@ -430,6 +430,76 @@ export const DANGEROUS_COMMANDS = [
|
|
|
430
430
|
"nc",
|
|
431
431
|
"netcat",
|
|
432
432
|
];
|
|
433
|
+
/**
|
|
434
|
+
* Read-only command set: commands that only read/transform data and write to stdout.
|
|
435
|
+
* When a command in this set is used without write redirections, command substitution,
|
|
436
|
+
* or dangerous flags (e.g. sed -i), it is auto-allowed without a confirmation dialog.
|
|
437
|
+
* Aligned with Claude Code's SEMANTIC_READ_ONLY_COMMANDS, excluding interactive pagers
|
|
438
|
+
* (less, more, man, info), command executors (xargs), and infinite-output generators (yes).
|
|
439
|
+
* FR-019.2 through FR-019.7 in tool-permission-system.md.
|
|
440
|
+
*/
|
|
441
|
+
export const READ_ONLY_COMMANDS = [
|
|
442
|
+
"ls",
|
|
443
|
+
"cat",
|
|
444
|
+
"head",
|
|
445
|
+
"tail",
|
|
446
|
+
"wc",
|
|
447
|
+
"sort",
|
|
448
|
+
"uniq",
|
|
449
|
+
"grep",
|
|
450
|
+
"egrep",
|
|
451
|
+
"fgrep",
|
|
452
|
+
"rg",
|
|
453
|
+
"find",
|
|
454
|
+
"which",
|
|
455
|
+
"whereis",
|
|
456
|
+
"file",
|
|
457
|
+
"stat",
|
|
458
|
+
"du",
|
|
459
|
+
"df",
|
|
460
|
+
"free",
|
|
461
|
+
"uptime",
|
|
462
|
+
"uname",
|
|
463
|
+
"hostname",
|
|
464
|
+
"whoami",
|
|
465
|
+
"id",
|
|
466
|
+
"groups",
|
|
467
|
+
"env",
|
|
468
|
+
"printenv",
|
|
469
|
+
"echo",
|
|
470
|
+
"printf",
|
|
471
|
+
"date",
|
|
472
|
+
"true",
|
|
473
|
+
"false",
|
|
474
|
+
"pwd",
|
|
475
|
+
"tree",
|
|
476
|
+
"diff",
|
|
477
|
+
"cmp",
|
|
478
|
+
"md5sum",
|
|
479
|
+
"sha256sum",
|
|
480
|
+
"sha1sum",
|
|
481
|
+
"xxd",
|
|
482
|
+
"od",
|
|
483
|
+
"hexdump",
|
|
484
|
+
"strings",
|
|
485
|
+
"readlink",
|
|
486
|
+
"realpath",
|
|
487
|
+
"basename",
|
|
488
|
+
"dirname",
|
|
489
|
+
"seq",
|
|
490
|
+
"column",
|
|
491
|
+
"jq",
|
|
492
|
+
"yq",
|
|
493
|
+
"cut",
|
|
494
|
+
"paste",
|
|
495
|
+
"tr",
|
|
496
|
+
"awk",
|
|
497
|
+
"sed",
|
|
498
|
+
"test",
|
|
499
|
+
"expr",
|
|
500
|
+
"bc",
|
|
501
|
+
"sleep",
|
|
502
|
+
];
|
|
433
503
|
export const TOOL_RULES = {
|
|
434
504
|
// Node/JS
|
|
435
505
|
npm: { depth: 2, scopeFlags: ["--prefix", "-C", "--registry"] },
|
|
@@ -567,6 +637,39 @@ export function isDangerousFind(command) {
|
|
|
567
637
|
return dangerousFlags.includes(unquoted);
|
|
568
638
|
});
|
|
569
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Detects command substitution $(...) or backticks `...` in a command string.
|
|
642
|
+
* Commands with substitution are never auto-allowed because the substituted
|
|
643
|
+
* command may be dangerous (e.g. cat $(rm x)). FR-019.6.
|
|
644
|
+
*/
|
|
645
|
+
export function hasCommandSubstitution(command) {
|
|
646
|
+
// Remove quoted strings first so $() or backticks inside quotes don't trigger
|
|
647
|
+
const stripped = command
|
|
648
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
649
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
650
|
+
return /\$\([^)]*\)/.test(stripped) || /`[^`]*`/.test(stripped);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Detects process substitution <(...) or >(...) in a command string.
|
|
654
|
+
* These can execute side effects and are never auto-allowed. FR-019.6.
|
|
655
|
+
*/
|
|
656
|
+
export function hasProcessSubstitution(command) {
|
|
657
|
+
const stripped = command
|
|
658
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
659
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
660
|
+
return /[<>]\([^)]*\)/.test(stripped);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Detects sed in-place edit flag (-i, with optional backup suffix like -i.bak).
|
|
664
|
+
* sed -i modifies files in place and must NOT be auto-allowed. FR-019.5.
|
|
665
|
+
*/
|
|
666
|
+
export function hasSedInPlace(command) {
|
|
667
|
+
const stripped = stripRedirections(stripEnvVars(command));
|
|
668
|
+
const tokens = stripped.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
669
|
+
if (tokens.length === 0 || tokens[0] !== "sed")
|
|
670
|
+
return false;
|
|
671
|
+
return tokens.some((token) => /^-i(\..*)?$/.test(token));
|
|
672
|
+
}
|
|
570
673
|
/**
|
|
571
674
|
* Extracts a "smart prefix" from a bash command based on common developer tools.
|
|
572
675
|
* Returns null if the command is blacklisted or cannot be safely prefix-matched.
|
|
@@ -18,6 +18,10 @@ export declare function getBuiltinSkillsDir(): string;
|
|
|
18
18
|
* Get the builtin subagents directory path
|
|
19
19
|
*/
|
|
20
20
|
export declare function getBuiltinSubagentsDir(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Get the builtin plugins directory path
|
|
23
|
+
*/
|
|
24
|
+
export declare function getBuiltinPluginsDir(): string;
|
|
21
25
|
/**
|
|
22
26
|
* Get the user-specific configuration file path (legacy function)
|
|
23
27
|
* @deprecated Use getUserConfigPaths() for better priority support
|
|
@@ -46,6 +46,12 @@ export function getBuiltinSkillsDir() {
|
|
|
46
46
|
export function getBuiltinSubagentsDir() {
|
|
47
47
|
return join(getPackageRoot(), "builtin", "subagents");
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Get the builtin plugins directory path
|
|
51
|
+
*/
|
|
52
|
+
export function getBuiltinPluginsDir() {
|
|
53
|
+
return join(getPackageRoot(), "builtin", "plugins");
|
|
54
|
+
}
|
|
49
55
|
/**
|
|
50
56
|
* Get the user-specific configuration file path (legacy function)
|
|
51
57
|
* @deprecated Use getUserConfigPaths() for better priority support
|
|
@@ -159,7 +159,7 @@ export function setupAgentContainer(setupOptions) {
|
|
|
159
159
|
toolName: context.toolName,
|
|
160
160
|
toolInput: context.toolInput,
|
|
161
161
|
planFilePath: permissionManager.getPlanFilePath(),
|
|
162
|
-
env: Object.fromEntries(Object.entries(
|
|
162
|
+
env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
|
|
163
163
|
});
|
|
164
164
|
if (results.length > 0) {
|
|
165
165
|
const processResult = hookManager.processHookResults("PermissionRequest", results, messageManager);
|
package/dist/utils/fileSearch.js
CHANGED
|
@@ -25,9 +25,11 @@ async function getAllFiles(workingDirectory) {
|
|
|
25
25
|
stderr += data.toString();
|
|
26
26
|
});
|
|
27
27
|
child.on("close", (code) => {
|
|
28
|
+
// Exit 2 = some files were unreadable (e.g. device-name files like
|
|
29
|
+
// "nul" on Windows); stdout still holds usable partial results.
|
|
30
|
+
// Spawn failures surface via the 'error' listener instead.
|
|
28
31
|
if (code !== 0 && code !== 1) {
|
|
29
|
-
|
|
30
|
-
return;
|
|
32
|
+
logger.warn(`ripgrep exited with code ${code}, keeping partial results: ${stderr.trim()}`);
|
|
31
33
|
}
|
|
32
34
|
const files = stdout
|
|
33
35
|
.trim()
|
|
@@ -36,11 +36,12 @@ export class OpenAIClient {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
async _create(params, options) {
|
|
39
|
-
const { baseURL, apiKey, defaultHeaders, fetchOptions, fetch: customFetch, } = this.config;
|
|
39
|
+
const { baseURL, apiKey, sessionId, defaultHeaders, fetchOptions, fetch: customFetch, } = this.config;
|
|
40
40
|
const url = `${baseURL}/chat/completions`;
|
|
41
41
|
const headers = {
|
|
42
42
|
"Content-Type": "application/json",
|
|
43
43
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
44
|
+
...(sessionId ? { "x-session-id": sessionId } : {}),
|
|
44
45
|
...defaultHeaders,
|
|
45
46
|
};
|
|
46
47
|
const fetchFn = customFetch || fetch;
|
|
@@ -127,8 +127,13 @@ export class PathEncoder {
|
|
|
127
127
|
// If realpath fails, use the absolute path
|
|
128
128
|
resolvedPath = absolutePath;
|
|
129
129
|
}
|
|
130
|
-
// Encode the resolved path
|
|
131
|
-
|
|
130
|
+
// Encode the resolved path. Use encodeSync (pure string transform) rather
|
|
131
|
+
// than encode(): realpath was already attempted above with its own fallback,
|
|
132
|
+
// so re-resolving via encode()->resolvePath() would throw ENOENT again when
|
|
133
|
+
// the workdir no longer exists (e.g. a deleted worktree during agent
|
|
134
|
+
// shutdown). Session files live under <baseSessionDir>/<encoded>/ regardless
|
|
135
|
+
// of the workdir's existence, so encoding must not require it to exist.
|
|
136
|
+
const encodedName = this.encodeSync(resolvedPath);
|
|
132
137
|
const encodedPath = join(baseSessionDir, encodedName);
|
|
133
138
|
// Generate hash if encoding resulted in truncation
|
|
134
139
|
let pathHash;
|
|
@@ -33,10 +33,27 @@ export declare function getHeadCommit(cwd: string): string;
|
|
|
33
33
|
export declare function createWorktree(name: string, cwd: string, options?: {
|
|
34
34
|
baseRef?: "fresh" | "head";
|
|
35
35
|
}): WorktreeInfo;
|
|
36
|
+
/**
|
|
37
|
+
* Set up a freshly created worktree: copy local settings and gitignored
|
|
38
|
+
* project files (via .worktreeinclude) from the main repo. Best-effort — any
|
|
39
|
+
* failure only logs a warning and never fails worktree creation.
|
|
40
|
+
*/
|
|
41
|
+
export declare function performPostCreationSetup(worktreePath: string, repoRoot: string): Promise<void>;
|
|
36
42
|
/**
|
|
37
43
|
* Remove a git worktree and its branch.
|
|
38
44
|
*/
|
|
39
45
|
export declare function removeWorktree(info: WorktreeInfo): void;
|
|
46
|
+
/**
|
|
47
|
+
* Validate that a worktree path is safe to remove before running git removal.
|
|
48
|
+
* Aligns with Claude Code v2.1.216+ background-session checks:
|
|
49
|
+
* - rejects a path whose final component is a symlink;
|
|
50
|
+
* - rejects a path that resolves outside the repo root.
|
|
51
|
+
*
|
|
52
|
+
* A path that no longer exists (worktree already removed) is allowed so that
|
|
53
|
+
* removal stays best-effort/idempotent; its nearest existing ancestor is used
|
|
54
|
+
* for the containment check. Throws an Error on invalid paths.
|
|
55
|
+
*/
|
|
56
|
+
export declare function validateWorktreeRemovalPath(worktreePath: string, repoRoot: string): void;
|
|
40
57
|
/**
|
|
41
58
|
* Count uncommitted files and new commits in a worktree.
|
|
42
59
|
* Returns null if git commands fail (fail-closed).
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
* Git worktree creation and removal utilities for the SDK.
|
|
3
3
|
* Used by EnterWorktree and ExitWorktree tools.
|
|
4
4
|
*/
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import * as fs from "node:fs";
|
|
8
9
|
import { getGitMainRepoRoot, getDefaultRemoteBranch, ensureWaveRuntimeFilesExcluded, } from "./gitUtils.js";
|
|
9
10
|
import { logger } from "./globalLogger.js";
|
|
11
|
+
// Post-creation setup runs inside the shared `wave --stdio` process too
|
|
12
|
+
// (desktop sessions), so use the async execFile: a synchronous git call
|
|
13
|
+
// (e.g. `git ls-files` over a large working tree) would freeze every session.
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
10
15
|
/**
|
|
11
16
|
* Validate a worktree name to prevent path traversal and invalid characters.
|
|
12
17
|
*/
|
|
@@ -211,6 +216,288 @@ export function createWorktree(name, cwd, options) {
|
|
|
211
216
|
throw new Error(`Failed to create worktree: ${error.message}\n${stderr}`);
|
|
212
217
|
}
|
|
213
218
|
}
|
|
219
|
+
/** Translate a single gitignore glob into a RegExp (gitignore semantics). */
|
|
220
|
+
function globToRegExp(glob) {
|
|
221
|
+
let source = "^";
|
|
222
|
+
for (let i = 0; i < glob.length; i++) {
|
|
223
|
+
const ch = glob[i];
|
|
224
|
+
if (ch === "*") {
|
|
225
|
+
if (glob[i + 1] === "*") {
|
|
226
|
+
if (glob[i + 2] === "/") {
|
|
227
|
+
// "**/" matches zero or more leading directories
|
|
228
|
+
source += "(?:.*/)?";
|
|
229
|
+
i += 2;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
source += ".*";
|
|
233
|
+
i += 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
source += "[^/]*";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (ch === "?") {
|
|
241
|
+
source += "[^/]";
|
|
242
|
+
}
|
|
243
|
+
else if (ch === "[") {
|
|
244
|
+
const end = glob.indexOf("]", i + 1);
|
|
245
|
+
if (end === -1) {
|
|
246
|
+
source += "\\[";
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
let charClass = glob.slice(i, end + 1);
|
|
250
|
+
if (charClass.startsWith("[!")) {
|
|
251
|
+
// gitignore uses "[!...]" for a negated character class
|
|
252
|
+
charClass = "[^" + charClass.slice(2);
|
|
253
|
+
}
|
|
254
|
+
source += charClass;
|
|
255
|
+
i = end;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
source += ch.replace(/[.+^${}()|\\]/g, "\\$&");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
source += "$";
|
|
263
|
+
return new RegExp(source);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Parse .worktreeinclude content into patterns. Blank lines and "#" comments
|
|
267
|
+
* are skipped, matching gitignore conventions.
|
|
268
|
+
*/
|
|
269
|
+
function parseWorktreeIncludePatterns(content) {
|
|
270
|
+
const patterns = [];
|
|
271
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
272
|
+
const line = rawLine.trim();
|
|
273
|
+
if (!line || line.startsWith("#"))
|
|
274
|
+
continue;
|
|
275
|
+
let raw = line;
|
|
276
|
+
const negated = raw.startsWith("!");
|
|
277
|
+
if (negated)
|
|
278
|
+
raw = raw.slice(1);
|
|
279
|
+
const dirOnly = raw.endsWith("/");
|
|
280
|
+
if (dirOnly)
|
|
281
|
+
raw = raw.slice(0, -1);
|
|
282
|
+
const anchored = raw.startsWith("/");
|
|
283
|
+
if (anchored)
|
|
284
|
+
raw = raw.slice(1);
|
|
285
|
+
if (!raw)
|
|
286
|
+
continue;
|
|
287
|
+
patterns.push({
|
|
288
|
+
raw,
|
|
289
|
+
negated,
|
|
290
|
+
dirOnly,
|
|
291
|
+
anchored,
|
|
292
|
+
anyLevel: !anchored && !raw.includes("/"),
|
|
293
|
+
regex: globToRegExp(raw),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return patterns;
|
|
297
|
+
}
|
|
298
|
+
/** Test a single pattern against one candidate path. */
|
|
299
|
+
function testPatternAgainst(pattern, candidate) {
|
|
300
|
+
if (pattern.anyLevel) {
|
|
301
|
+
// Match the full path or any "/"-suffix (basename at any level)
|
|
302
|
+
if (pattern.regex.test(candidate))
|
|
303
|
+
return true;
|
|
304
|
+
for (let i = 0; i < candidate.length; i++) {
|
|
305
|
+
if (candidate[i] === "/" && pattern.regex.test(candidate.slice(i + 1))) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
return pattern.regex.test(candidate);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Test a pattern against a path and every ancestor prefix. Excluding a
|
|
315
|
+
* directory excludes everything beneath it (gitignore semantics), so ancestor
|
|
316
|
+
* prefixes are always treated as directories.
|
|
317
|
+
*/
|
|
318
|
+
function patternMatchesPath(pattern, relPath, isDir) {
|
|
319
|
+
let candidate = relPath;
|
|
320
|
+
for (;;) {
|
|
321
|
+
const candidateIsDir = candidate === relPath ? isDir : true;
|
|
322
|
+
if (!(pattern.dirOnly && !candidateIsDir) &&
|
|
323
|
+
testPatternAgainst(pattern, candidate)) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
const idx = candidate.lastIndexOf("/");
|
|
327
|
+
if (idx === -1)
|
|
328
|
+
break;
|
|
329
|
+
candidate = candidate.slice(0, idx);
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
/** Last-match-wins resolution with "!" negation. */
|
|
334
|
+
function worktreeIncludeMatches(patterns, relPath, isDir) {
|
|
335
|
+
let matched = false;
|
|
336
|
+
for (const pattern of patterns) {
|
|
337
|
+
if (patternMatchesPath(pattern, relPath, isDir)) {
|
|
338
|
+
matched = !pattern.negated;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return matched;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A positive pattern targets something inside a collapsed directory: either it
|
|
345
|
+
* literally starts with the directory path, or the directory path starts with
|
|
346
|
+
* the pattern's literal (pre-glob) prefix.
|
|
347
|
+
*/
|
|
348
|
+
function needsExpansion(patterns, dir) {
|
|
349
|
+
return patterns.some((p) => {
|
|
350
|
+
if (p.negated)
|
|
351
|
+
return false;
|
|
352
|
+
if (p.raw.startsWith(dir + "/"))
|
|
353
|
+
return true;
|
|
354
|
+
const globIdx = p.raw.search(/[*?[]/);
|
|
355
|
+
if (globIdx > 0 && dir.startsWith(p.raw.slice(0, globIdx)))
|
|
356
|
+
return true;
|
|
357
|
+
return false;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// --- Post-creation setup -----------------------------------------------------
|
|
361
|
+
/**
|
|
362
|
+
* Copy <repoRoot>/.wave/settings.local.json into the worktree so local
|
|
363
|
+
* configuration (permissions, env, ...) carries over. Missing files are
|
|
364
|
+
* skipped silently; other failures only log a warning.
|
|
365
|
+
*/
|
|
366
|
+
async function copyLocalSettingsToWorktree(repoRoot, worktreePath) {
|
|
367
|
+
const relativePath = path.join(".wave", "settings.local.json");
|
|
368
|
+
const sourcePath = path.join(repoRoot, relativePath);
|
|
369
|
+
const destPath = path.join(worktreePath, relativePath);
|
|
370
|
+
let content;
|
|
371
|
+
try {
|
|
372
|
+
content = await fs.promises.readFile(sourcePath, "utf8");
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (error.code !== "ENOENT") {
|
|
376
|
+
logger.warn(`Failed to read ${sourcePath}: ${error.message}`);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
382
|
+
await fs.promises.writeFile(destPath, content);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
logger.warn(`Failed to copy ${relativePath} into worktree: ${error.message}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Copy files listed in <repoRoot>/.worktreeinclude into the worktree. Each
|
|
390
|
+
* non-comment line is a gitignore pattern; matching gitignored files are
|
|
391
|
+
* copied at their relative paths.
|
|
392
|
+
*/
|
|
393
|
+
async function copyWorktreeIncludeFiles(repoRoot, worktreePath) {
|
|
394
|
+
const includePath = path.join(repoRoot, ".worktreeinclude");
|
|
395
|
+
let content;
|
|
396
|
+
try {
|
|
397
|
+
content = await fs.promises.readFile(includePath, "utf8");
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
const patterns = parseWorktreeIncludePatterns(content);
|
|
403
|
+
if (patterns.length === 0)
|
|
404
|
+
return [];
|
|
405
|
+
const worktreeRelPath = path
|
|
406
|
+
.relative(repoRoot, worktreePath)
|
|
407
|
+
.split(path.sep)
|
|
408
|
+
.join("/");
|
|
409
|
+
// The worktree lives under .wave/worktrees/, which is gitignored — never
|
|
410
|
+
// copy the worktree (or anything inside it) into itself.
|
|
411
|
+
const isSelf = (entry) => entry === worktreeRelPath || entry.startsWith(worktreeRelPath + "/");
|
|
412
|
+
// "--directory" collapses fully-ignored directories into single entries,
|
|
413
|
+
// which keeps the listing fast on large repos.
|
|
414
|
+
let entries;
|
|
415
|
+
try {
|
|
416
|
+
const { stdout } = await execFileAsync("git", [
|
|
417
|
+
"ls-files",
|
|
418
|
+
"--others",
|
|
419
|
+
"--ignored",
|
|
420
|
+
"--exclude-standard",
|
|
421
|
+
"--directory",
|
|
422
|
+
], { cwd: repoRoot, encoding: "utf8" });
|
|
423
|
+
entries = stdout.trim().split("\n").filter(Boolean);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
const copied = [];
|
|
429
|
+
const dirsToExpand = [];
|
|
430
|
+
const copyToWorktree = async (relativePath) => {
|
|
431
|
+
const srcPath = path.join(repoRoot, relativePath);
|
|
432
|
+
const destPath = path.join(worktreePath, relativePath);
|
|
433
|
+
try {
|
|
434
|
+
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
435
|
+
await fs.promises.copyFile(srcPath, destPath);
|
|
436
|
+
copied.push(relativePath);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
logger.warn(`Failed to copy ${relativePath} into worktree: ${error.message}`);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
if (isSelf(entry))
|
|
444
|
+
continue;
|
|
445
|
+
if (entry.endsWith("/")) {
|
|
446
|
+
const dir = entry.slice(0, -1);
|
|
447
|
+
if (worktreeIncludeMatches(patterns, dir, true)) {
|
|
448
|
+
// The directory itself matches — copy it wholesale
|
|
449
|
+
try {
|
|
450
|
+
await fs.promises.cp(path.join(repoRoot, dir), path.join(worktreePath, dir), {
|
|
451
|
+
recursive: true,
|
|
452
|
+
});
|
|
453
|
+
copied.push(entry);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
logger.warn(`Failed to copy ${entry} into worktree: ${error.message}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else if (needsExpansion(patterns, dir)) {
|
|
460
|
+
dirsToExpand.push(dir);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else if (worktreeIncludeMatches(patterns, entry, false)) {
|
|
464
|
+
await copyToWorktree(entry);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (dirsToExpand.length > 0) {
|
|
468
|
+
// List the files inside collapsed dirs whose contents are targeted
|
|
469
|
+
try {
|
|
470
|
+
const { stdout } = await execFileAsync("git", [
|
|
471
|
+
"ls-files",
|
|
472
|
+
"--others",
|
|
473
|
+
"--ignored",
|
|
474
|
+
"--exclude-standard",
|
|
475
|
+
"--",
|
|
476
|
+
...dirsToExpand,
|
|
477
|
+
], { cwd: repoRoot, encoding: "utf8" });
|
|
478
|
+
for (const file of stdout.trim().split("\n").filter(Boolean)) {
|
|
479
|
+
if (isSelf(file))
|
|
480
|
+
continue;
|
|
481
|
+
if (worktreeIncludeMatches(patterns, file, false)) {
|
|
482
|
+
await copyToWorktree(file);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// Expansion is best-effort; the worktree is already usable without it
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return copied;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Set up a freshly created worktree: copy local settings and gitignored
|
|
494
|
+
* project files (via .worktreeinclude) from the main repo. Best-effort — any
|
|
495
|
+
* failure only logs a warning and never fails worktree creation.
|
|
496
|
+
*/
|
|
497
|
+
export async function performPostCreationSetup(worktreePath, repoRoot) {
|
|
498
|
+
await copyLocalSettingsToWorktree(repoRoot, worktreePath);
|
|
499
|
+
await copyWorktreeIncludeFiles(repoRoot, worktreePath);
|
|
500
|
+
}
|
|
214
501
|
/**
|
|
215
502
|
* Remove a git worktree and its branch.
|
|
216
503
|
*/
|
|
@@ -273,6 +560,57 @@ export function removeWorktree(info) {
|
|
|
273
560
|
throw error;
|
|
274
561
|
}
|
|
275
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Validate that a worktree path is safe to remove before running git removal.
|
|
565
|
+
* Aligns with Claude Code v2.1.216+ background-session checks:
|
|
566
|
+
* - rejects a path whose final component is a symlink;
|
|
567
|
+
* - rejects a path that resolves outside the repo root.
|
|
568
|
+
*
|
|
569
|
+
* A path that no longer exists (worktree already removed) is allowed so that
|
|
570
|
+
* removal stays best-effort/idempotent; its nearest existing ancestor is used
|
|
571
|
+
* for the containment check. Throws an Error on invalid paths.
|
|
572
|
+
*/
|
|
573
|
+
export function validateWorktreeRemovalPath(worktreePath, repoRoot) {
|
|
574
|
+
const SYMLINK_PREFIX = "Refusing to remove worktree at symlink path:";
|
|
575
|
+
// Reject a symlink as the final path component.
|
|
576
|
+
try {
|
|
577
|
+
if (fs.lstatSync(worktreePath).isSymbolicLink()) {
|
|
578
|
+
throw new Error(`${SYMLINK_PREFIX} ${worktreePath}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
if (error instanceof Error && error.message.startsWith(SYMLINK_PREFIX)) {
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
// lstat failed (path missing) — the containment check below still applies.
|
|
586
|
+
}
|
|
587
|
+
const resolvedRepoRoot = fs.realpathSync(repoRoot);
|
|
588
|
+
// Resolve the path, following symlinks in existing components. If the path
|
|
589
|
+
// (or a parent) no longer exists, fall back to the deepest existing ancestor
|
|
590
|
+
// so the containment check still guards against traversal outside the repo.
|
|
591
|
+
let resolvedPath;
|
|
592
|
+
let existingAncestor = worktreePath;
|
|
593
|
+
for (;;) {
|
|
594
|
+
try {
|
|
595
|
+
resolvedPath = fs.realpathSync(existingAncestor);
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
const parent = path.dirname(existingAncestor);
|
|
600
|
+
if (parent === existingAncestor) {
|
|
601
|
+
throw new Error(`Invalid worktree path for removal: ${worktreePath}`);
|
|
602
|
+
}
|
|
603
|
+
existingAncestor = parent;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (existingAncestor !== worktreePath) {
|
|
607
|
+
resolvedPath = path.join(resolvedPath, path.relative(existingAncestor, worktreePath));
|
|
608
|
+
}
|
|
609
|
+
const relative = path.relative(resolvedRepoRoot, resolvedPath);
|
|
610
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
611
|
+
throw new Error(`Refusing to remove worktree outside repo root: ${worktreePath}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
276
614
|
/**
|
|
277
615
|
* Count uncommitted files and new commits in a worktree.
|
|
278
616
|
* Returns null if git commands fail (fail-closed).
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -78,6 +78,7 @@ export class Agent {
|
|
|
78
78
|
private reversionManager: ReversionManager;
|
|
79
79
|
private messageQueue: MessageQueue; // Unified queue for messages, bang commands, and notifications
|
|
80
80
|
private dispatchPromise: Promise<void> | null = null; // Track current dispatch for teardown
|
|
81
|
+
private isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
|
|
81
82
|
private memoryRuleManager: MemoryRuleManager; // Add memory rule manager instance
|
|
82
83
|
private liveConfigManager: LiveConfigManager; // Add live configuration manager
|
|
83
84
|
private taskManager: TaskManager;
|
|
@@ -94,7 +95,10 @@ export class Agent {
|
|
|
94
95
|
|
|
95
96
|
// Dynamic configuration getter methods
|
|
96
97
|
public getGatewayConfig(): GatewayConfig {
|
|
97
|
-
return
|
|
98
|
+
return {
|
|
99
|
+
...this.configurationService.resolveGatewayConfig(),
|
|
100
|
+
sessionId: this.messageManager.getSessionId(),
|
|
101
|
+
};
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
public getModelConfig(): ModelConfig {
|
|
@@ -412,6 +416,7 @@ export class Agent {
|
|
|
412
416
|
* onLoadingChange(false), and onCommandRunningChange(false).
|
|
413
417
|
*/
|
|
414
418
|
private tryDispatch(): void {
|
|
419
|
+
if (this.isAborting) return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
|
|
415
420
|
if (this.messageQueue.state !== "idle") return;
|
|
416
421
|
if (!this.messageQueue.hasPending()) return;
|
|
417
422
|
if (this.aiManager.isLoading || this.isCommandRunning) return;
|
|
@@ -847,17 +852,24 @@ export class Agent {
|
|
|
847
852
|
|
|
848
853
|
/** Unified interrupt method, interrupts both AI messages and command execution */
|
|
849
854
|
public abortMessage(): void {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
this.
|
|
855
|
-
|
|
855
|
+
// Guard: prevent tryDispatch (triggered by abortAIMessage → setIsLoading(false))
|
|
856
|
+
// from dispatching preserved notifications as a new AI turn during the abort.
|
|
857
|
+
this.isAborting = true;
|
|
858
|
+
try {
|
|
859
|
+
if (this.aiManager.isLoading || this.isCommandRunning) {
|
|
860
|
+
// Clear user-facing queue items first to prevent processQueuedMessage
|
|
861
|
+
// from dequeuing when abortAIMessage triggers onLoadingChange(false).
|
|
862
|
+
// Notifications are preserved so background task results aren't lost.
|
|
863
|
+
this.messageQueue.clear();
|
|
864
|
+
this.options.callbacks?.onQueuedMessagesChange?.(this.queuedMessages);
|
|
865
|
+
}
|
|
866
|
+
this.messageQueue.transitionTo("idle"); // Reset state on abort
|
|
867
|
+
this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
|
|
868
|
+
this.abortBashCommand();
|
|
869
|
+
this.abortSlashCommand();
|
|
870
|
+
} finally {
|
|
871
|
+
this.isAborting = false;
|
|
856
872
|
}
|
|
857
|
-
this.messageQueue.transitionTo("idle"); // Reset state on abort
|
|
858
|
-
this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
|
|
859
|
-
this.abortBashCommand();
|
|
860
|
-
this.abortSlashCommand();
|
|
861
873
|
}
|
|
862
874
|
|
|
863
875
|
/** Interrupt bash command execution */
|
|
@@ -915,7 +927,9 @@ export class Agent {
|
|
|
915
927
|
cwd: this.workdir,
|
|
916
928
|
worktreePath,
|
|
917
929
|
env: Object.fromEntries(
|
|
918
|
-
Object.entries(
|
|
930
|
+
Object.entries(this.configurationService.getMergedEnv()).filter(
|
|
931
|
+
(e) => e[1] !== undefined,
|
|
932
|
+
),
|
|
919
933
|
) as Record<string, string>,
|
|
920
934
|
},
|
|
921
935
|
);
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./utils/tokenCalculation.js";
|
|
|
29
29
|
export * from "./utils/gitUtils.js";
|
|
30
30
|
export * from "./utils/nameGenerator.js";
|
|
31
31
|
export * from "./utils/worktreeSession.js";
|
|
32
|
+
export * from "./utils/worktreeUtils.js";
|
|
32
33
|
export { loadMergedWaveConfig } from "./services/configurationService.js";
|
|
33
34
|
export * from "./types/index.js";
|
|
34
35
|
|