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.
Files changed (89) hide show
  1. package/dist/agent.d.ts +72 -24
  2. package/dist/agent.js +123 -31
  3. package/dist/builtin/plugins.js +17 -2
  4. package/dist/builtin/skills/settings.js +6 -8
  5. package/dist/core/plugin.d.ts +4 -0
  6. package/dist/core/plugin.js +7 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/managers/aiManager.d.ts +0 -24
  10. package/dist/managers/aiManager.js +23 -181
  11. package/dist/managers/bashModeManager.d.ts +33 -0
  12. package/dist/managers/bashModeManager.js +110 -0
  13. package/dist/managers/hookManager.d.ts +5 -0
  14. package/dist/managers/hookManager.js +7 -0
  15. package/dist/managers/liveConfigManager.js +3 -3
  16. package/dist/managers/mcpManager.d.ts +23 -0
  17. package/dist/managers/mcpManager.js +162 -14
  18. package/dist/managers/messageManager.d.ts +12 -13
  19. package/dist/managers/messageManager.js +63 -60
  20. package/dist/managers/permissionManager.d.ts +29 -0
  21. package/dist/managers/permissionManager.js +158 -70
  22. package/dist/managers/planManager.d.ts +9 -0
  23. package/dist/managers/planManager.js +19 -1
  24. package/dist/managers/skillManager.d.ts +31 -0
  25. package/dist/managers/skillManager.js +122 -12
  26. package/dist/managers/slashCommandManager.js +9 -28
  27. package/dist/managers/subagentManager.d.ts +7 -0
  28. package/dist/managers/subagentManager.js +61 -9
  29. package/dist/managers/workflowManager.js +6 -0
  30. package/dist/prompts/index.d.ts +0 -1
  31. package/dist/prompts/index.js +0 -4
  32. package/dist/services/MarketplaceService.js +36 -14
  33. package/dist/services/configurationService.d.ts +34 -2
  34. package/dist/services/configurationService.js +123 -16
  35. package/dist/services/initializationService.js +2 -2
  36. package/dist/services/jsonlHandler.d.ts +14 -0
  37. package/dist/services/jsonlHandler.js +44 -1
  38. package/dist/services/memory.d.ts +14 -0
  39. package/dist/services/memory.js +33 -0
  40. package/dist/services/officialMarketplaceMirror.d.ts +85 -0
  41. package/dist/services/officialMarketplaceMirror.js +289 -0
  42. package/dist/services/remoteSettingsService.js +4 -4
  43. package/dist/services/session.js +30 -13
  44. package/dist/services/worktreeHooks.js +6 -1
  45. package/dist/stdio/index.d.ts +10 -0
  46. package/dist/stdio/index.js +10 -0
  47. package/dist/stdio/notificationRouter.d.ts +38 -0
  48. package/dist/stdio/notificationRouter.js +96 -0
  49. package/dist/stdio/rpcClient.d.ts +18 -0
  50. package/dist/stdio/rpcClient.js +10 -0
  51. package/dist/stdio/stdioAgent.d.ts +222 -0
  52. package/dist/stdio/stdioAgent.js +341 -0
  53. package/dist/tools/bashTool.js +2 -0
  54. package/dist/tools/exitPlanMode.js +10 -2
  55. package/dist/types/agent.d.ts +8 -0
  56. package/dist/types/commands.d.ts +7 -0
  57. package/dist/types/configuration.d.ts +6 -1
  58. package/dist/types/hooks.d.ts +1 -0
  59. package/dist/types/hooks.js +19 -0
  60. package/dist/types/mcp.d.ts +3 -0
  61. package/dist/types/messaging.d.ts +1 -8
  62. package/dist/types/skills.d.ts +11 -0
  63. package/dist/utils/bashParser.d.ts +17 -0
  64. package/dist/utils/bashParser.js +72 -0
  65. package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
  66. package/dist/utils/bashStructure/bashLexer.js +676 -0
  67. package/dist/utils/bashStructure/bashParser.d.ts +144 -0
  68. package/dist/utils/bashStructure/bashParser.js +606 -0
  69. package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
  70. package/dist/utils/bashStructure/bashSemantics.js +477 -0
  71. package/dist/utils/bashStructure/index.d.ts +26 -0
  72. package/dist/utils/bashStructure/index.js +27 -0
  73. package/dist/utils/bashStructure/types.d.ts +62 -0
  74. package/dist/utils/bashStructure/types.js +47 -0
  75. package/dist/utils/container.d.ts +6 -0
  76. package/dist/utils/container.js +9 -0
  77. package/dist/utils/containerSetup.d.ts +11 -1
  78. package/dist/utils/containerSetup.js +26 -11
  79. package/dist/utils/fileUtils.d.ts +11 -0
  80. package/dist/utils/fileUtils.js +37 -0
  81. package/dist/utils/messageOperations.d.ts +0 -18
  82. package/dist/utils/messageOperations.js +0 -62
  83. package/dist/utils/subagentParser.js +9 -2
  84. package/dist/utils/tokenCalculation.js +0 -8
  85. package/dist/utils/worktreeUtils.d.ts +2 -1
  86. package/dist/utils/worktreeUtils.js +64 -34
  87. package/package.json +6 -1
  88. package/dist/managers/bangManager.d.ts +0 -26
  89. 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
- const workdir = this.workdir;
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 workdir = this.workdir;
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 workdir = this.workdir;
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
- if (cmd === "cd") {
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
- return parts.every((part) => {
797
- // Check for auto-allowed read-only commands (FR-019.2 through FR-019.7)
798
- if (this.isAutoAllowedPart(part, workdir)) {
799
- return true;
800
- }
801
- // We create a temporary context with just this part of the command
802
- const partContext = {
803
- ...context,
804
- toolInput: { ...context.toolInput, command: part },
805
- };
806
- if (explicitRules.some((rule) => this.matchesRule(partContext, rule))) {
807
- return true;
808
- }
809
- // Default rules must not auto-allow dangerous variants (write
810
- // redirections, substitutions, sed -i, dangerous find) through broad
811
- // rules like Bash(echo*) or Bash(cat*)
812
- const isDangerousVariant = hasWriteRedirections(part) ||
813
- isDangerousFind(part) ||
814
- hasCommandSubstitution(part) ||
815
- hasProcessSubstitution(part) ||
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
- const parts = splitBashCommand(command);
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
- this.getOrGeneratePlanFilePath(messageManager?.getRootSessionId())
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
- async refreshSkills() {
57
- // Clear only discovered skills (builtin/personal/project), preserve plugin skills
58
- this.skillMetadata.clear();
59
- this.skillContent.clear();
60
- const discovery = await this.discoverSkills();
61
- // Store discovered skill metadata
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
- this.skillMetadata.set(name, skill);
90
+ nextMetadata.set(name, skill);
64
91
  });
65
92
  discovery.personalSkills.forEach((skill, name) => {
66
- this.skillMetadata.set(name, skill);
93
+ nextMetadata.set(name, skill);
67
94
  });
68
95
  discovery.projectSkills.forEach((skill, name) => {
69
- this.skillMetadata.set(name, skill);
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
- this.skillMetadata.set(name, metadata);
103
+ nextMetadata.set(name, metadata);
74
104
  });
75
105
  this.pluginSkillContent.forEach((skill, name) => {
76
- this.skillContent.set(name, skill);
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
  }