wave-agent-sdk 1.1.4 → 1.2.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 +72 -24
- package/dist/agent.js +123 -31
- package/dist/builtin/plugins.js +17 -2
- package/dist/builtin/skills/settings.js +6 -8
- package/dist/core/plugin.d.ts +4 -0
- package/dist/core/plugin.js +7 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/managers/aiManager.d.ts +0 -24
- package/dist/managers/aiManager.js +23 -181
- package/dist/managers/bashModeManager.d.ts +33 -0
- package/dist/managers/bashModeManager.js +110 -0
- package/dist/managers/hookManager.d.ts +5 -0
- package/dist/managers/hookManager.js +7 -0
- package/dist/managers/liveConfigManager.js +3 -3
- package/dist/managers/mcpManager.d.ts +23 -0
- package/dist/managers/mcpManager.js +162 -14
- package/dist/managers/messageManager.d.ts +12 -13
- package/dist/managers/messageManager.js +63 -60
- package/dist/managers/permissionManager.d.ts +29 -0
- package/dist/managers/permissionManager.js +158 -70
- package/dist/managers/planManager.d.ts +9 -0
- package/dist/managers/planManager.js +19 -1
- package/dist/managers/skillManager.d.ts +31 -0
- package/dist/managers/skillManager.js +122 -12
- package/dist/managers/slashCommandManager.js +9 -28
- package/dist/managers/subagentManager.d.ts +7 -0
- package/dist/managers/subagentManager.js +61 -9
- package/dist/managers/workflowManager.js +6 -0
- package/dist/prompts/index.d.ts +0 -1
- package/dist/prompts/index.js +0 -4
- package/dist/services/MarketplaceService.js +36 -14
- package/dist/services/configurationService.d.ts +34 -2
- package/dist/services/configurationService.js +123 -16
- package/dist/services/initializationService.js +2 -2
- package/dist/services/jsonlHandler.d.ts +14 -0
- package/dist/services/jsonlHandler.js +44 -1
- package/dist/services/memory.d.ts +14 -0
- package/dist/services/memory.js +33 -0
- package/dist/services/officialMarketplaceMirror.d.ts +85 -0
- package/dist/services/officialMarketplaceMirror.js +289 -0
- package/dist/services/remoteSettingsService.js +4 -4
- package/dist/services/session.js +30 -13
- package/dist/services/worktreeHooks.js +6 -1
- package/dist/stdio/index.d.ts +10 -0
- package/dist/stdio/index.js +10 -0
- package/dist/stdio/notificationRouter.d.ts +38 -0
- package/dist/stdio/notificationRouter.js +96 -0
- package/dist/stdio/rpcClient.d.ts +18 -0
- package/dist/stdio/rpcClient.js +10 -0
- package/dist/stdio/stdioAgent.d.ts +222 -0
- package/dist/stdio/stdioAgent.js +341 -0
- package/dist/tools/bashTool.js +2 -0
- package/dist/tools/exitPlanMode.js +10 -2
- package/dist/types/agent.d.ts +8 -0
- package/dist/types/commands.d.ts +7 -0
- package/dist/types/configuration.d.ts +6 -1
- package/dist/types/hooks.d.ts +1 -0
- package/dist/types/hooks.js +19 -0
- package/dist/types/mcp.d.ts +3 -0
- package/dist/types/messaging.d.ts +1 -8
- package/dist/types/skills.d.ts +11 -0
- package/dist/utils/bashParser.d.ts +17 -0
- package/dist/utils/bashParser.js +72 -0
- package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
- package/dist/utils/bashStructure/bashLexer.js +676 -0
- package/dist/utils/bashStructure/bashParser.d.ts +144 -0
- package/dist/utils/bashStructure/bashParser.js +606 -0
- package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
- package/dist/utils/bashStructure/bashSemantics.js +477 -0
- package/dist/utils/bashStructure/index.d.ts +26 -0
- package/dist/utils/bashStructure/index.js +27 -0
- package/dist/utils/bashStructure/types.d.ts +62 -0
- package/dist/utils/bashStructure/types.js +47 -0
- package/dist/utils/container.d.ts +6 -0
- package/dist/utils/container.js +9 -0
- package/dist/utils/containerSetup.d.ts +11 -1
- package/dist/utils/containerSetup.js +26 -11
- package/dist/utils/fileUtils.d.ts +11 -0
- package/dist/utils/fileUtils.js +37 -0
- package/dist/utils/messageOperations.d.ts +0 -18
- package/dist/utils/messageOperations.js +0 -62
- package/dist/utils/subagentParser.js +9 -2
- package/dist/utils/tokenCalculation.js +0 -8
- package/dist/utils/worktreeUtils.d.ts +2 -1
- package/dist/utils/worktreeUtils.js +64 -34
- package/package.json +6 -1
- package/dist/managers/bangManager.d.ts +0 -26
- package/dist/managers/bangManager.js +0 -78
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* handles custom callback integration.
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
9
10
|
import { minimatch } from "minimatch";
|
|
10
11
|
import { RESTRICTED_TOOLS } from "../types/permissions.js";
|
|
11
|
-
import { splitBashCommand, stripEnvVars, stripRedirections, hasWriteRedirections, getSmartPrefix, isDangerousFind, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, stripGitScopePrefix, DANGEROUS_COMMANDS, READ_ONLY_COMMANDS, } from "../utils/bashParser.js";
|
|
12
|
+
import { splitBashCommand, stripEnvVars, stripRedirections, hasWriteRedirections, getSmartPrefix, isDangerousFind, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, stripGitScopePrefix, DANGEROUS_COMMANDS, READ_ONLY_COMMANDS, PATH_COMMANDS, extractPathArgs, } from "../utils/bashParser.js";
|
|
13
|
+
import { parseBashStructure, } from "../utils/bashStructure/index.js";
|
|
12
14
|
import { isPathInside } from "../utils/pathSafety.js";
|
|
13
15
|
import { toWindowsPath } from "../utils/path.js";
|
|
14
16
|
import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, READ_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "../constants/tools.js";
|
|
@@ -55,6 +57,22 @@ const DEFAULT_ALLOWED_RULES = [
|
|
|
55
57
|
"Bash(sleep*)",
|
|
56
58
|
];
|
|
57
59
|
import { logger } from "../utils/globalLogger.js";
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an additional-directory config entry to an absolute path.
|
|
62
|
+
* Supports `~` / `~/` prefixes (expanded against the user home directory,
|
|
63
|
+
* aligned with Claude Code's expandPath) alongside absolute paths and paths
|
|
64
|
+
* relative to the working directory.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveAdditionalDirectory(dir, workdir) {
|
|
67
|
+
if (dir === "~" || dir.startsWith("~/")) {
|
|
68
|
+
const expanded = dir === "~" ? os.homedir() : path.join(os.homedir(), dir.slice(2));
|
|
69
|
+
return path.resolve(expanded);
|
|
70
|
+
}
|
|
71
|
+
if (workdir && !path.isAbsolute(dir)) {
|
|
72
|
+
return path.resolve(workdir, dir);
|
|
73
|
+
}
|
|
74
|
+
return path.resolve(dir);
|
|
75
|
+
}
|
|
58
76
|
export class PermissionManager {
|
|
59
77
|
constructor(container, options = {}) {
|
|
60
78
|
this.container = container;
|
|
@@ -220,22 +238,13 @@ export class PermissionManager {
|
|
|
220
238
|
* Update the additional directories (e.g., when configuration reloads)
|
|
221
239
|
*/
|
|
222
240
|
updateAdditionalDirectories(directories) {
|
|
223
|
-
|
|
224
|
-
this.additionalDirectories = directories.map((dir) => {
|
|
225
|
-
if (workdir && !path.isAbsolute(dir)) {
|
|
226
|
-
return path.resolve(workdir, dir);
|
|
227
|
-
}
|
|
228
|
-
return path.resolve(dir);
|
|
229
|
-
});
|
|
241
|
+
this.additionalDirectories = directories.map((dir) => resolveAdditionalDirectory(dir, this.workdir));
|
|
230
242
|
}
|
|
231
243
|
/**
|
|
232
244
|
* Add an instance-level additional directory (session-level, e.g. /add-dir command)
|
|
233
245
|
*/
|
|
234
246
|
addInstanceAdditionalDirectory(directory) {
|
|
235
|
-
const
|
|
236
|
-
const resolvedPath = workdir && !path.isAbsolute(directory)
|
|
237
|
-
? path.resolve(workdir, directory)
|
|
238
|
-
: path.resolve(directory);
|
|
247
|
+
const resolvedPath = resolveAdditionalDirectory(directory, this.workdir);
|
|
239
248
|
if (!this.instanceAdditionalDirectories.includes(resolvedPath)) {
|
|
240
249
|
this.instanceAdditionalDirectories.push(resolvedPath);
|
|
241
250
|
}
|
|
@@ -244,10 +253,7 @@ export class PermissionManager {
|
|
|
244
253
|
* Add a system-level additional directory that is persistent across configuration reloads
|
|
245
254
|
*/
|
|
246
255
|
addSystemAdditionalDirectory(directory) {
|
|
247
|
-
const
|
|
248
|
-
const resolvedPath = workdir && !path.isAbsolute(directory)
|
|
249
|
-
? path.resolve(workdir, directory)
|
|
250
|
-
: path.resolve(directory);
|
|
256
|
+
const resolvedPath = resolveAdditionalDirectory(directory, this.workdir);
|
|
251
257
|
if (!this.systemAdditionalDirectories.includes(resolvedPath)) {
|
|
252
258
|
this.systemAdditionalDirectories.push(resolvedPath);
|
|
253
259
|
}
|
|
@@ -634,20 +640,12 @@ export class PermissionManager {
|
|
|
634
640
|
const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
|
|
635
641
|
if (commandMatch) {
|
|
636
642
|
const cmd = commandMatch[1];
|
|
637
|
-
const args = commandMatch[2]?.trim() || "";
|
|
638
643
|
// Check blacklist
|
|
639
644
|
if (DANGEROUS_COMMANDS.includes(cmd)) {
|
|
640
645
|
return true;
|
|
641
646
|
}
|
|
642
|
-
// Check out-of-bounds for cd
|
|
643
|
-
|
|
644
|
-
const pathArgs = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter((arg) => !arg.startsWith("-")) || [];
|
|
645
|
-
return pathArgs.some((pathArg) => {
|
|
646
|
-
const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
|
|
647
|
-
const { isInside } = this.isInsideSafeZone(cleanPath, workdir);
|
|
648
|
-
return !isInside;
|
|
649
|
-
});
|
|
650
|
-
}
|
|
647
|
+
// Check out-of-bounds for cd and path-taking read-only commands
|
|
648
|
+
return this.isOutOfSafeZonePart(part, workdir);
|
|
651
649
|
}
|
|
652
650
|
return false;
|
|
653
651
|
});
|
|
@@ -758,6 +756,19 @@ export class PermissionManager {
|
|
|
758
756
|
// find with dangerous flags (e.g. -exec, -delete) disqualifies
|
|
759
757
|
if (cmd === "find" && isDangerousFind(part))
|
|
760
758
|
return false;
|
|
759
|
+
// Path-taking read-only commands must stay within the Safe Zone:
|
|
760
|
+
// accessing a path outside workdir / additional directories
|
|
761
|
+
// disqualifies (aligned with Claude Code's path constraints)
|
|
762
|
+
if (PATH_COMMANDS.includes(cmd)) {
|
|
763
|
+
const pathArgs = extractPathArgs(args);
|
|
764
|
+
if (pathArgs.length > 0) {
|
|
765
|
+
if (!workdir)
|
|
766
|
+
return false;
|
|
767
|
+
if (!pathArgs.every((pathArg) => this.isInsideSafeZone(pathArg, workdir).isInside)) {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
761
772
|
return true;
|
|
762
773
|
}
|
|
763
774
|
// cd is not read-only but is safe if all paths are within the Safe Zone
|
|
@@ -775,6 +786,26 @@ export class PermissionManager {
|
|
|
775
786
|
}
|
|
776
787
|
return false;
|
|
777
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Check whether a bash command part takes path arguments (cd or a
|
|
791
|
+
* path-taking read-only command) that escape the Safe Zone. Used to
|
|
792
|
+
* disqualify auto-allow via DEFAULT_ALLOWED_RULES and to hide the
|
|
793
|
+
* "don't ask again" persistence option for out-of-bounds access.
|
|
794
|
+
*/
|
|
795
|
+
isOutOfSafeZonePart(part, workdir) {
|
|
796
|
+
const processedPart = stripRedirections(stripEnvVars(part));
|
|
797
|
+
const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
|
|
798
|
+
if (!commandMatch)
|
|
799
|
+
return false;
|
|
800
|
+
const cmd = commandMatch[1];
|
|
801
|
+
if (cmd !== "cd" && !PATH_COMMANDS.includes(cmd))
|
|
802
|
+
return false;
|
|
803
|
+
const args = commandMatch[2]?.trim() || "";
|
|
804
|
+
const pathArgs = extractPathArgs(args);
|
|
805
|
+
if (pathArgs.length === 0)
|
|
806
|
+
return false;
|
|
807
|
+
return pathArgs.some((pathArg) => !this.isInsideSafeZone(pathArg, workdir).isInside);
|
|
808
|
+
}
|
|
778
809
|
/**
|
|
779
810
|
* Check if a tool call is allowed by persistent or temporary rules
|
|
780
811
|
*/
|
|
@@ -789,42 +820,80 @@ export class PermissionManager {
|
|
|
789
820
|
];
|
|
790
821
|
if (context.toolName === BASH_TOOL_NAME && context.toolInput?.command) {
|
|
791
822
|
const command = String(context.toolInput.command);
|
|
792
|
-
const parts = splitBashCommand(command);
|
|
793
|
-
if (parts.length === 0)
|
|
794
|
-
return false;
|
|
795
823
|
const workdir = context.toolInput?.workdir;
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
hasSedInPlace(part);
|
|
817
|
-
if (!isDangerousVariant &&
|
|
818
|
-
DEFAULT_ALLOWED_RULES.some((rule) => this.matchesRule(partContext, rule))) {
|
|
819
|
-
return true;
|
|
820
|
-
}
|
|
821
|
-
return !this.isRestrictedTool(context.toolName);
|
|
822
|
-
});
|
|
824
|
+
// Structure-aware expansion: compound commands and command substitutions
|
|
825
|
+
// are reduced to their leaf simple commands; each leaf is classified
|
|
826
|
+
// exactly like a top-level part of a chained command would be (spec:
|
|
827
|
+
// "复合命令与命令替换的结构感知判定").
|
|
828
|
+
const structure = parseBashStructure(command);
|
|
829
|
+
if (structure.status === "ok") {
|
|
830
|
+
if (structure.leaves.length === 0)
|
|
831
|
+
return false;
|
|
832
|
+
return structure.leaves.every((leaf) => this.isBashLeafAllowed(leaf, workdir, context, explicitRules));
|
|
833
|
+
}
|
|
834
|
+
// Fail-closed (heredoc / process substitution / arithmetic / brace /
|
|
835
|
+
// parse failure / eval / trap / …): the command cannot be statically
|
|
836
|
+
// reduced to leaves, so it is never auto-allowed and never matches the
|
|
837
|
+
// built-in default rules. Only an explicit user rule covering the whole
|
|
838
|
+
// command may allow it.
|
|
839
|
+
const wholeContext = {
|
|
840
|
+
...context,
|
|
841
|
+
toolInput: { ...context.toolInput, command },
|
|
842
|
+
};
|
|
843
|
+
return explicitRules.some((rule) => this.matchesRule(wholeContext, rule));
|
|
823
844
|
}
|
|
824
845
|
// For other tools, check if any rule matches
|
|
825
846
|
const allRules = [...explicitRules, ...DEFAULT_ALLOWED_RULES];
|
|
826
847
|
return allRules.some((rule) => this.matchesRule(context, rule));
|
|
827
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Classify one expanded leaf command. Every leaf of a compound command or
|
|
851
|
+
* command substitution is judged with the exact rules used for a plain
|
|
852
|
+
* command, with two structure-aware additions:
|
|
853
|
+
*
|
|
854
|
+
* - a leaf the parser flagged `unsafe` (a bare reference to a variable whose
|
|
855
|
+
* value is not statically known, or a command substitution inside the
|
|
856
|
+
* leaf's own argument words) is never auto-allowed and never matches the
|
|
857
|
+
* built-in default rules — only an explicit allow rule may cover it;
|
|
858
|
+
* - `read` (e.g. the condition of `while read …`) only reads stdin, so it
|
|
859
|
+
* is treated as safe when it carries no redirection of its own.
|
|
860
|
+
*/
|
|
861
|
+
isBashLeafAllowed(leaf, workdir, context, explicitRules) {
|
|
862
|
+
// Check for auto-allowed read-only commands (FR-019.2 through FR-019.7)
|
|
863
|
+
if (!leaf.unsafe) {
|
|
864
|
+
if (leaf.command === "read" && !/[<>]/.test(leaf.text))
|
|
865
|
+
return true;
|
|
866
|
+
if (this.isAutoAllowedPart(leaf.text, workdir))
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
if (explicitRules.some((rule) => this.matchesRule(this.ruleContext(context, leaf.text), rule)) ||
|
|
870
|
+
explicitRules.some((rule) => this.matchesRule(this.ruleContext(context, unquotedLeafCommand(leaf)), rule))) {
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
// Default rules must not auto-allow dangerous variants (write
|
|
874
|
+
// redirections, substitutions, sed -i, dangerous find, out-of-safe-zone
|
|
875
|
+
// paths, bare-unknown-variable / in-word substitution leaves) through
|
|
876
|
+
// broad rules like Bash(echo*) or Bash(cat*)
|
|
877
|
+
const isDangerousVariant = leaf.unsafe ||
|
|
878
|
+
hasWriteRedirections(leaf.text) ||
|
|
879
|
+
isDangerousFind(leaf.text) ||
|
|
880
|
+
hasCommandSubstitution(leaf.text) ||
|
|
881
|
+
hasProcessSubstitution(leaf.text) ||
|
|
882
|
+
hasSedInPlace(leaf.text) ||
|
|
883
|
+
this.isOutOfSafeZonePart(leaf.text, workdir);
|
|
884
|
+
if (!isDangerousVariant &&
|
|
885
|
+
DEFAULT_ALLOWED_RULES.some((rule) => this.matchesRule(this.ruleContext(context, leaf.text), rule))) {
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
return !this.isRestrictedTool(context.toolName);
|
|
889
|
+
}
|
|
890
|
+
/** A context whose Bash command is a single part/leaf text. */
|
|
891
|
+
ruleContext(context, command) {
|
|
892
|
+
return {
|
|
893
|
+
...context,
|
|
894
|
+
toolInput: { ...context.toolInput, command },
|
|
895
|
+
};
|
|
896
|
+
}
|
|
828
897
|
/**
|
|
829
898
|
* Expand a bash command into individual permission rules, filtering out safe commands.
|
|
830
899
|
* Used when saving permissions to the allow list.
|
|
@@ -834,7 +903,13 @@ export class PermissionManager {
|
|
|
834
903
|
* @returns Array of permission rules in "Bash(cmd)" format
|
|
835
904
|
*/
|
|
836
905
|
expandBashRule(command, workdir) {
|
|
837
|
-
|
|
906
|
+
// Expand compound commands structurally when possible so control-structure
|
|
907
|
+
// keywords (do/done/then/…) never leak into persisted rules. Constructs the
|
|
908
|
+
// parser cannot reduce fall back to the legacy textual part splitter.
|
|
909
|
+
const structure = parseBashStructure(command);
|
|
910
|
+
const parts = structure.status === "ok" && structure.leaves.length > 0
|
|
911
|
+
? structure.leaves.map((leaf) => leaf.text)
|
|
912
|
+
: splitBashCommand(command);
|
|
838
913
|
const rules = [];
|
|
839
914
|
for (const part of parts) {
|
|
840
915
|
const hasWrite = hasWriteRedirections(part);
|
|
@@ -846,23 +921,12 @@ export class PermissionManager {
|
|
|
846
921
|
const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
|
|
847
922
|
if (commandMatch) {
|
|
848
923
|
const cmd = commandMatch[1];
|
|
849
|
-
const args = commandMatch[2]?.trim() || "";
|
|
850
924
|
if (DANGEROUS_COMMANDS.includes(cmd) ||
|
|
851
925
|
isDangerousFind(part) ||
|
|
852
|
-
hasSedInPlace(part)
|
|
926
|
+
hasSedInPlace(part) ||
|
|
927
|
+
this.isOutOfSafeZonePart(part, workdir)) {
|
|
853
928
|
continue;
|
|
854
929
|
}
|
|
855
|
-
if (cmd === "cd") {
|
|
856
|
-
const pathArgs = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter((arg) => !arg.startsWith("-")) || [];
|
|
857
|
-
const isOutOfBounds = pathArgs.some((pathArg) => {
|
|
858
|
-
const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
|
|
859
|
-
const { isInside } = this.isInsideSafeZone(cleanPath, workdir);
|
|
860
|
-
return !isInside;
|
|
861
|
-
});
|
|
862
|
-
if (isOutOfBounds) {
|
|
863
|
-
continue;
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
930
|
}
|
|
867
931
|
const smartPrefix = hasWrite ? null : getSmartPrefix(processedPart);
|
|
868
932
|
if (smartPrefix) {
|
|
@@ -918,3 +982,27 @@ export class PermissionManager {
|
|
|
918
982
|
}
|
|
919
983
|
}
|
|
920
984
|
}
|
|
985
|
+
/**
|
|
986
|
+
* Build a rule-matching form of a leaf command with quoting removed from words
|
|
987
|
+
* that are a single quoted segment (`node "scripts/x"` → `node scripts/x`).
|
|
988
|
+
* Shell quoting that does not change the executed words must not prevent a
|
|
989
|
+
* rule such as `Bash(node scripts*)` from matching a leaf like
|
|
990
|
+
* `node "scripts/$f" --dry-run`. Redirect operators/targets (the text tail
|
|
991
|
+
* beyond argv) are preserved verbatim so write-redirection guards still apply.
|
|
992
|
+
*/
|
|
993
|
+
function unquotedLeafCommand(leaf) {
|
|
994
|
+
const rawCommand = leaf.argv.join(" ");
|
|
995
|
+
const tail = leaf.text.slice(rawCommand.length);
|
|
996
|
+
return leaf.argv.map(unquoteWord).join(" ") + tail;
|
|
997
|
+
}
|
|
998
|
+
/** Strip one layer of enclosing single/double quotes from a whole word. */
|
|
999
|
+
function unquoteWord(raw) {
|
|
1000
|
+
if (raw.length < 2)
|
|
1001
|
+
return raw;
|
|
1002
|
+
const first = raw[0];
|
|
1003
|
+
const last = raw[raw.length - 1];
|
|
1004
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
1005
|
+
return raw.slice(1, -1);
|
|
1006
|
+
}
|
|
1007
|
+
return raw;
|
|
1008
|
+
}
|
|
@@ -5,6 +5,9 @@ export declare class PlanManager {
|
|
|
5
5
|
private planDir;
|
|
6
6
|
private currentPlanFilePath;
|
|
7
7
|
private planEntryReminderPending;
|
|
8
|
+
/** Promise for the in-flight plan file path generation, resolved when the
|
|
9
|
+
* path is set on the PermissionManager. Cleared when leaving plan mode. */
|
|
10
|
+
private planFilePathPromise;
|
|
8
11
|
constructor(container: Container);
|
|
9
12
|
/**
|
|
10
13
|
* Ensures the plan directory exists and generates a new plan file path with a random name
|
|
@@ -22,6 +25,12 @@ export declare class PlanManager {
|
|
|
22
25
|
* @param mode - The current effective permission mode
|
|
23
26
|
*/
|
|
24
27
|
handlePlanModeTransition(mode: PermissionMode): void;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve once the plan file path for the current plan-mode entry has been
|
|
30
|
+
* generated and set on the PermissionManager. Resolves `undefined` when no
|
|
31
|
+
* path generation is in flight (not in plan mode, or generation failed).
|
|
32
|
+
*/
|
|
33
|
+
awaitPlanFilePath(): Promise<string | undefined>;
|
|
25
34
|
isPlanEntryReminderPending(): boolean;
|
|
26
35
|
consumePlanEntryReminder(): void;
|
|
27
36
|
}
|
|
@@ -11,6 +11,9 @@ export class PlanManager {
|
|
|
11
11
|
this.container = container;
|
|
12
12
|
this.currentPlanFilePath = null;
|
|
13
13
|
this.planEntryReminderPending = false;
|
|
14
|
+
/** Promise for the in-flight plan file path generation, resolved when the
|
|
15
|
+
* path is set on the PermissionManager. Cleared when leaving plan mode. */
|
|
16
|
+
this.planFilePathPromise = null;
|
|
14
17
|
this.planDir = path.join(os.homedir(), ".wave", "plans");
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
@@ -60,13 +63,18 @@ export class PlanManager {
|
|
|
60
63
|
// (prevents sending both plan_mode and plan_mode_exit on rapid toggle)
|
|
61
64
|
permissionManager?.setNeedsPlanModeExitAttachment(false);
|
|
62
65
|
this.planEntryReminderPending = true;
|
|
63
|
-
|
|
66
|
+
// Store the promise so callers (e.g. the CLI /plan command) can await
|
|
67
|
+
// path readiness before triggering a query — the plan mode reminder
|
|
68
|
+
// injected by AIManager only fires once the path is set.
|
|
69
|
+
this.planFilePathPromise = this.getOrGeneratePlanFilePath(messageManager?.getRootSessionId())
|
|
64
70
|
.then(({ path }) => {
|
|
65
71
|
logger?.debug("Plan file path generated", { path });
|
|
66
72
|
permissionManager?.setPlanFilePath(path);
|
|
73
|
+
return path;
|
|
67
74
|
})
|
|
68
75
|
.catch((error) => {
|
|
69
76
|
logger?.error("Failed to generate plan file path", error);
|
|
77
|
+
return undefined;
|
|
70
78
|
});
|
|
71
79
|
}
|
|
72
80
|
else if (previousMode === "plan") {
|
|
@@ -75,11 +83,21 @@ export class PlanManager {
|
|
|
75
83
|
permissionManager?.setNeedsPlanModeExitAttachment(true);
|
|
76
84
|
permissionManager?.setPlanFilePath(undefined);
|
|
77
85
|
this.planEntryReminderPending = false;
|
|
86
|
+
this.planFilePathPromise = null;
|
|
78
87
|
}
|
|
79
88
|
else {
|
|
80
89
|
permissionManager?.setPlanFilePath(undefined);
|
|
90
|
+
this.planFilePathPromise = null;
|
|
81
91
|
}
|
|
82
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve once the plan file path for the current plan-mode entry has been
|
|
95
|
+
* generated and set on the PermissionManager. Resolves `undefined` when no
|
|
96
|
+
* path generation is in flight (not in plan mode, or generation failed).
|
|
97
|
+
*/
|
|
98
|
+
awaitPlanFilePath() {
|
|
99
|
+
return this.planFilePathPromise ?? Promise.resolve(undefined);
|
|
100
|
+
}
|
|
83
101
|
isPlanEntryReminderPending() {
|
|
84
102
|
return this.planEntryReminderPending;
|
|
85
103
|
}
|
|
@@ -16,6 +16,11 @@ export declare class SkillManager extends EventEmitter {
|
|
|
16
16
|
private pluginSkillMetadata;
|
|
17
17
|
private pluginSkillContent;
|
|
18
18
|
private initialized;
|
|
19
|
+
/** Serializes full rescans (initialize / file-watcher events / deleteSkill
|
|
20
|
+
* convergence). Overlapping scans over the shared caches must not interleave:
|
|
21
|
+
* chaining them keeps the last completed scan in sync with the newest disk
|
|
22
|
+
* state instead of letting an older snapshot win a race. */
|
|
23
|
+
private refreshChain;
|
|
19
24
|
private fileWatcher;
|
|
20
25
|
private watchEnabled;
|
|
21
26
|
constructor(container: Container, options?: SkillManagerOptions);
|
|
@@ -27,6 +32,7 @@ export declare class SkillManager extends EventEmitter {
|
|
|
27
32
|
* Refresh skills by re-discovering them
|
|
28
33
|
*/
|
|
29
34
|
private refreshSkills;
|
|
35
|
+
private performRefresh;
|
|
30
36
|
/**
|
|
31
37
|
* Setup file watcher for skill directories
|
|
32
38
|
*/
|
|
@@ -113,4 +119,29 @@ export declare class SkillManager extends EventEmitter {
|
|
|
113
119
|
* Register skills provided by a plugin
|
|
114
120
|
*/
|
|
115
121
|
registerPluginSkills(pluginName: string, skills: Skill[]): void;
|
|
122
|
+
/**
|
|
123
|
+
* Directory roots that may hold deletable copies of a skill in the given
|
|
124
|
+
* discovery scope. Personal skills are scanned from three user-level dirs
|
|
125
|
+
* (~/.wave|~/.claude|~/.agents/skills) and project skills from the current
|
|
126
|
+
* project's three project dirs (.wave|.claude|.agents/skills). The discovery
|
|
127
|
+
* map merges same-named copies from these roots into a single name-keyed
|
|
128
|
+
* entry, so deleteSkill must sweep the whole set to remove a skill fully.
|
|
129
|
+
*/
|
|
130
|
+
private scopeSkillRoots;
|
|
131
|
+
/**
|
|
132
|
+
* Delete a user/personal or project skill by removing its directory
|
|
133
|
+
* (containing SKILL.md). Builtin and plugin skills are read-only and
|
|
134
|
+
* cannot be deleted.
|
|
135
|
+
*
|
|
136
|
+
* A same-named skill may physically live in several same-scope directories
|
|
137
|
+
* (~/.wave/skills + ~/.claude/skills + ~/.agents/skills for user skills,
|
|
138
|
+
* .wave/.claude/.agents/skills under the project for project skills) while
|
|
139
|
+
* only the highest-priority copy is listed; every copy is removed so one
|
|
140
|
+
* delete removes the skill completely instead of re-surfacing the next
|
|
141
|
+
* copy on the next scan. Caches are then re-converged with disk before
|
|
142
|
+
* returning so the caller's follow-up metadata read reflects the result.
|
|
143
|
+
* @param name - The skill name as shown in skill metadata
|
|
144
|
+
* @returns true if the skill was deleted
|
|
145
|
+
*/
|
|
146
|
+
deleteSkill(name: string): Promise<boolean>;
|
|
116
147
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, stat } from "fs/promises";
|
|
1
|
+
import { readdir, stat, rm } from "fs/promises";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { EventEmitter } from "events";
|
|
@@ -21,6 +21,11 @@ export class SkillManager extends EventEmitter {
|
|
|
21
21
|
this.pluginSkillMetadata = new Map();
|
|
22
22
|
this.pluginSkillContent = new Map();
|
|
23
23
|
this.initialized = false;
|
|
24
|
+
/** Serializes full rescans (initialize / file-watcher events / deleteSkill
|
|
25
|
+
* convergence). Overlapping scans over the shared caches must not interleave:
|
|
26
|
+
* chaining them keeps the last completed scan in sync with the newest disk
|
|
27
|
+
* state instead of letting an older snapshot win a race. */
|
|
28
|
+
this.refreshChain = Promise.resolve();
|
|
24
29
|
this.fileWatcher = null;
|
|
25
30
|
this.personalSkillsPath =
|
|
26
31
|
options.personalSkillsPath || join(homedir(), ".wave", "skills");
|
|
@@ -53,27 +58,52 @@ export class SkillManager extends EventEmitter {
|
|
|
53
58
|
/**
|
|
54
59
|
* Refresh skills by re-discovering them
|
|
55
60
|
*/
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
refreshSkills() {
|
|
62
|
+
// Serialize rescans: initialize / file-watcher events / deleteSkill all
|
|
63
|
+
// trigger full re-scans; running them concurrently makes a stale snapshot
|
|
64
|
+
// or a partial cache (see performRefresh's atomic swap) observable to
|
|
65
|
+
// metadata readers such as the settings-page skill list.
|
|
66
|
+
const run = this.refreshChain.then(() => this.performRefresh());
|
|
67
|
+
this.refreshChain = run.catch(() => { });
|
|
68
|
+
return run;
|
|
69
|
+
}
|
|
70
|
+
async performRefresh() {
|
|
71
|
+
// Route this scan's incremental skill-content writes into a scratch map
|
|
72
|
+
// (processSkillDirs writes to this.skillContent) and rebuild both caches
|
|
73
|
+
// fully before swapping them in — readers must never observe a
|
|
74
|
+
// half-refreshed (e.g. momentarily empty) skill list mid-scan.
|
|
75
|
+
const scratchContent = new Map();
|
|
76
|
+
const previousContent = this.skillContent;
|
|
77
|
+
this.skillContent = scratchContent;
|
|
78
|
+
let discovery;
|
|
79
|
+
try {
|
|
80
|
+
discovery = await this.discoverSkills();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.skillContent = previousContent;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const nextMetadata = new Map();
|
|
87
|
+
const nextContent = new Map();
|
|
88
|
+
// Store discovered skills (builtin/personal/project)
|
|
62
89
|
discovery.builtinSkills.forEach((skill, name) => {
|
|
63
|
-
|
|
90
|
+
nextMetadata.set(name, skill);
|
|
64
91
|
});
|
|
65
92
|
discovery.personalSkills.forEach((skill, name) => {
|
|
66
|
-
|
|
93
|
+
nextMetadata.set(name, skill);
|
|
67
94
|
});
|
|
68
95
|
discovery.projectSkills.forEach((skill, name) => {
|
|
69
|
-
|
|
96
|
+
nextMetadata.set(name, skill);
|
|
97
|
+
});
|
|
98
|
+
scratchContent.forEach((skill, name) => {
|
|
99
|
+
nextContent.set(name, skill);
|
|
70
100
|
});
|
|
71
101
|
// Restore plugin skills
|
|
72
102
|
this.pluginSkillMetadata.forEach((metadata, name) => {
|
|
73
|
-
|
|
103
|
+
nextMetadata.set(name, metadata);
|
|
74
104
|
});
|
|
75
105
|
this.pluginSkillContent.forEach((skill, name) => {
|
|
76
|
-
|
|
106
|
+
nextContent.set(name, skill);
|
|
77
107
|
});
|
|
78
108
|
// Log any discovery errors
|
|
79
109
|
if (discovery.errors.length > 0) {
|
|
@@ -82,6 +112,9 @@ export class SkillManager extends EventEmitter {
|
|
|
82
112
|
logger?.warn(`Skill error in ${error.skillPath}: ${error.message}`);
|
|
83
113
|
});
|
|
84
114
|
}
|
|
115
|
+
// Atomic swap: from this point on readers see the complete new snapshot
|
|
116
|
+
this.skillMetadata = nextMetadata;
|
|
117
|
+
this.skillContent = nextContent;
|
|
85
118
|
this.emit("refreshed", Array.from(this.skillMetadata.values()));
|
|
86
119
|
}
|
|
87
120
|
/**
|
|
@@ -463,4 +496,81 @@ export class SkillManager extends EventEmitter {
|
|
|
463
496
|
}
|
|
464
497
|
logger?.debug(`Registered ${skills.length} plugin skills from ${pluginName}. Total skills: ${this.skillMetadata.size}`);
|
|
465
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Directory roots that may hold deletable copies of a skill in the given
|
|
501
|
+
* discovery scope. Personal skills are scanned from three user-level dirs
|
|
502
|
+
* (~/.wave|~/.claude|~/.agents/skills) and project skills from the current
|
|
503
|
+
* project's three project dirs (.wave|.claude|.agents/skills). The discovery
|
|
504
|
+
* map merges same-named copies from these roots into a single name-keyed
|
|
505
|
+
* entry, so deleteSkill must sweep the whole set to remove a skill fully.
|
|
506
|
+
*/
|
|
507
|
+
scopeSkillRoots(type) {
|
|
508
|
+
if (type === "personal") {
|
|
509
|
+
return [
|
|
510
|
+
this.personalSkillsPath,
|
|
511
|
+
this.personalClaudeSkillsPath,
|
|
512
|
+
this.personalAgentsSkillsPath,
|
|
513
|
+
];
|
|
514
|
+
}
|
|
515
|
+
if (type === "project") {
|
|
516
|
+
return [
|
|
517
|
+
join(this.workdir, ".wave", "skills"),
|
|
518
|
+
join(this.workdir, ".claude", "skills"),
|
|
519
|
+
join(this.workdir, ".agents", "skills"),
|
|
520
|
+
];
|
|
521
|
+
}
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Delete a user/personal or project skill by removing its directory
|
|
526
|
+
* (containing SKILL.md). Builtin and plugin skills are read-only and
|
|
527
|
+
* cannot be deleted.
|
|
528
|
+
*
|
|
529
|
+
* A same-named skill may physically live in several same-scope directories
|
|
530
|
+
* (~/.wave/skills + ~/.claude/skills + ~/.agents/skills for user skills,
|
|
531
|
+
* .wave/.claude/.agents/skills under the project for project skills) while
|
|
532
|
+
* only the highest-priority copy is listed; every copy is removed so one
|
|
533
|
+
* delete removes the skill completely instead of re-surfacing the next
|
|
534
|
+
* copy on the next scan. Caches are then re-converged with disk before
|
|
535
|
+
* returning so the caller's follow-up metadata read reflects the result.
|
|
536
|
+
* @param name - The skill name as shown in skill metadata
|
|
537
|
+
* @returns true if the skill was deleted
|
|
538
|
+
*/
|
|
539
|
+
async deleteSkill(name) {
|
|
540
|
+
if (!this.initialized) {
|
|
541
|
+
throw new Error("SkillManager not initialized. Call initialize() first.");
|
|
542
|
+
}
|
|
543
|
+
const metadata = this.skillMetadata.get(name);
|
|
544
|
+
if (!metadata) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
// Builtin and plugin skills are read-only
|
|
548
|
+
if (metadata.type === "builtin" || metadata.pluginName) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
if (!metadata.skillPath) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
const dirsToRemove = new Set([metadata.skillPath]);
|
|
555
|
+
for (const root of this.scopeSkillRoots(metadata.type)) {
|
|
556
|
+
dirsToRemove.add(join(root, name));
|
|
557
|
+
}
|
|
558
|
+
for (const dir of dirsToRemove) {
|
|
559
|
+
await rm(dir, { recursive: true, force: true });
|
|
560
|
+
}
|
|
561
|
+
// Converge caches with disk: the serialized full refresh both drops the
|
|
562
|
+
// deleted entries and reflects any copy that survived deletion. If the
|
|
563
|
+
// refresh itself fails, fall back to dropping the entry from memory so
|
|
564
|
+
// the deleted skill does not keep being served.
|
|
565
|
+
try {
|
|
566
|
+
await this.refreshSkills();
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
logger?.warn(`Failed to refresh skills after deleting '${name}': ${error}`);
|
|
570
|
+
this.skillMetadata.delete(name);
|
|
571
|
+
this.skillContent.delete(name);
|
|
572
|
+
}
|
|
573
|
+
logger?.debug(`Deleted skill '${name}' at ${metadata.skillPath}`);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
466
576
|
}
|