wave-agent-sdk 1.1.5 → 1.3.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 (210) hide show
  1. package/dist/agent.d.ts +128 -28
  2. package/dist/agent.js +201 -49
  3. package/dist/builtin/index.js +2 -0
  4. package/dist/builtin/plugins.js +11 -20
  5. package/dist/builtin/skills/settings.js +7 -20
  6. package/dist/builtin/skills/wave-daemon.d.ts +1 -0
  7. package/dist/builtin/skills/wave-daemon.js +194 -0
  8. package/dist/constants/images.d.ts +26 -0
  9. package/dist/constants/images.js +26 -0
  10. package/dist/constants/index.d.ts +16 -0
  11. package/dist/constants/index.js +16 -0
  12. package/dist/constants/memory.d.ts +26 -0
  13. package/dist/constants/memory.js +34 -0
  14. package/dist/constants/messages.d.ts +11 -0
  15. package/dist/constants/messages.js +11 -0
  16. package/dist/constants/plugins.d.ts +8 -0
  17. package/dist/constants/plugins.js +8 -0
  18. package/dist/constants/tools.d.ts +1 -0
  19. package/dist/constants/tools.js +1 -0
  20. package/dist/core/plugin.d.ts +54 -10
  21. package/dist/core/plugin.js +137 -23
  22. package/dist/core/session.d.ts +1 -1
  23. package/dist/core/session.js +1 -1
  24. package/dist/exec/catalog.d.ts +140 -0
  25. package/dist/exec/catalog.js +470 -0
  26. package/dist/exec/catalogAnnouncement.d.ts +89 -0
  27. package/dist/exec/catalogAnnouncement.js +293 -0
  28. package/dist/exec/constants.d.ts +51 -0
  29. package/dist/exec/constants.js +51 -0
  30. package/dist/exec/execRuntime.d.ts +55 -0
  31. package/dist/exec/execRuntime.js +217 -0
  32. package/dist/exec/workerSource.d.ts +28 -0
  33. package/dist/exec/workerSource.js +299 -0
  34. package/dist/host/index.d.ts +23 -0
  35. package/dist/host/index.js +23 -0
  36. package/dist/index.d.ts +7 -1
  37. package/dist/index.js +8 -1
  38. package/dist/managers/MemoryRuleManager.d.ts +6 -0
  39. package/dist/managers/MemoryRuleManager.js +12 -0
  40. package/dist/managers/aiManager.d.ts +35 -25
  41. package/dist/managers/aiManager.js +204 -202
  42. package/dist/managers/backgroundTaskManager.js +14 -0
  43. package/dist/managers/bashModeManager.d.ts +33 -0
  44. package/dist/managers/bashModeManager.js +110 -0
  45. package/dist/managers/hookManager.d.ts +18 -0
  46. package/dist/managers/hookManager.js +37 -3
  47. package/dist/managers/liveConfigManager.d.ts +33 -0
  48. package/dist/managers/liveConfigManager.js +106 -11
  49. package/dist/managers/lspManager.d.ts +9 -0
  50. package/dist/managers/lspManager.js +47 -18
  51. package/dist/managers/mcpManager.d.ts +68 -10
  52. package/dist/managers/mcpManager.js +265 -15
  53. package/dist/managers/messageManager.d.ts +60 -18
  54. package/dist/managers/messageManager.js +170 -81
  55. package/dist/managers/permissionManager.d.ts +69 -0
  56. package/dist/managers/permissionManager.js +221 -78
  57. package/dist/managers/planManager.d.ts +9 -0
  58. package/dist/managers/planManager.js +19 -1
  59. package/dist/managers/pluginManager.d.ts +46 -2
  60. package/dist/managers/pluginManager.js +117 -11
  61. package/dist/managers/pluginScopeManager.d.ts +15 -2
  62. package/dist/managers/pluginScopeManager.js +20 -1
  63. package/dist/managers/skillManager.d.ts +50 -0
  64. package/dist/managers/skillManager.js +166 -12
  65. package/dist/managers/slashCommandManager.d.ts +10 -0
  66. package/dist/managers/slashCommandManager.js +44 -31
  67. package/dist/managers/subagentManager.d.ts +15 -0
  68. package/dist/managers/subagentManager.js +81 -9
  69. package/dist/managers/toolManager.d.ts +29 -3
  70. package/dist/managers/toolManager.js +87 -13
  71. package/dist/managers/workflowManager.js +6 -0
  72. package/dist/prompts/autoMemory.d.ts +9 -0
  73. package/dist/prompts/autoMemory.js +30 -31
  74. package/dist/prompts/autoMemoryExtraction.d.ts +4 -0
  75. package/dist/prompts/autoMemoryExtraction.js +8 -111
  76. package/dist/prompts/index.d.ts +0 -1
  77. package/dist/prompts/index.js +0 -4
  78. package/dist/prompts/memoryTypes.d.ts +63 -0
  79. package/dist/prompts/memoryTypes.js +191 -0
  80. package/dist/services/GitService.d.ts +7 -0
  81. package/dist/services/GitService.js +23 -0
  82. package/dist/services/MarketplaceService.d.ts +101 -17
  83. package/dist/services/MarketplaceService.js +323 -102
  84. package/dist/services/artifactContent.d.ts +84 -0
  85. package/dist/services/artifactContent.js +204 -0
  86. package/dist/services/artifactSession.d.ts +6 -0
  87. package/dist/services/artifactSession.js +17 -0
  88. package/dist/services/autoMemoryService.js +5 -13
  89. package/dist/services/configurationService.d.ts +92 -9
  90. package/dist/services/configurationService.js +246 -64
  91. package/dist/services/contentSummarizer.d.ts +15 -0
  92. package/dist/services/contentSummarizer.js +45 -0
  93. package/dist/services/execAvailability.d.ts +9 -0
  94. package/dist/services/execAvailability.js +32 -0
  95. package/dist/services/fileWatcher.js +61 -6
  96. package/dist/services/initializationService.js +21 -17
  97. package/dist/services/interactionService.d.ts +9 -1
  98. package/dist/services/interactionService.js +28 -8
  99. package/dist/services/jsonlHandler.d.ts +98 -0
  100. package/dist/services/jsonlHandler.js +250 -12
  101. package/dist/services/memory.d.ts +17 -1
  102. package/dist/services/memory.js +44 -7
  103. package/dist/services/officialMarketplaceMirror.d.ts +85 -0
  104. package/dist/services/officialMarketplaceMirror.js +290 -0
  105. package/dist/services/pluginLoader.d.ts +12 -4
  106. package/dist/services/pluginLoader.js +38 -7
  107. package/dist/services/remoteSettingsService.js +20 -6
  108. package/dist/services/session.d.ts +74 -0
  109. package/dist/services/session.js +174 -16
  110. package/dist/services/sessionEntries.d.ts +2 -0
  111. package/dist/services/sessionEntries.js +20 -0
  112. package/dist/services/worktreeHooks.js +6 -1
  113. package/dist/stdio/index.d.ts +12 -0
  114. package/dist/stdio/index.js +12 -0
  115. package/dist/stdio/notificationRouter.d.ts +38 -0
  116. package/dist/stdio/notificationRouter.js +97 -0
  117. package/dist/stdio/rpcClient.d.ts +18 -0
  118. package/dist/stdio/rpcClient.js +10 -0
  119. package/dist/stdio/stdioAgent.d.ts +229 -0
  120. package/dist/stdio/stdioAgent.js +360 -0
  121. package/dist/tools/artifactTool.js +406 -273
  122. package/dist/tools/bashTool.js +10 -6
  123. package/dist/tools/editTool.js +6 -3
  124. package/dist/tools/execTool.d.ts +2 -0
  125. package/dist/tools/execTool.js +165 -0
  126. package/dist/tools/exitPlanMode.js +10 -2
  127. package/dist/tools/grepTool.js +7 -1
  128. package/dist/tools/readTool.js +30 -2
  129. package/dist/tools/types.d.ts +34 -8
  130. package/dist/tools/webFetchTool.js +15 -166
  131. package/dist/tools/workflowTool.js +40 -8
  132. package/dist/tools/writeTool.js +6 -3
  133. package/dist/types/agent.d.ts +24 -1
  134. package/dist/types/commands.d.ts +7 -0
  135. package/dist/types/configuration.d.ts +45 -2
  136. package/dist/types/hooks.d.ts +1 -0
  137. package/dist/types/hooks.js +19 -0
  138. package/dist/types/marketplace.d.ts +40 -2
  139. package/dist/types/mcp.d.ts +42 -0
  140. package/dist/types/messaging.d.ts +1 -8
  141. package/dist/types/permissions.d.ts +22 -0
  142. package/dist/types/permissions.js +17 -0
  143. package/dist/types/plugins.d.ts +26 -2
  144. package/dist/types/skills.d.ts +26 -0
  145. package/dist/utils/bashParser.d.ts +17 -0
  146. package/dist/utils/bashParser.js +72 -0
  147. package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
  148. package/dist/utils/bashStructure/bashLexer.js +676 -0
  149. package/dist/utils/bashStructure/bashParser.d.ts +144 -0
  150. package/dist/utils/bashStructure/bashParser.js +606 -0
  151. package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
  152. package/dist/utils/bashStructure/bashSemantics.js +477 -0
  153. package/dist/utils/bashStructure/index.d.ts +26 -0
  154. package/dist/utils/bashStructure/index.js +27 -0
  155. package/dist/utils/bashStructure/types.d.ts +62 -0
  156. package/dist/utils/bashStructure/types.js +47 -0
  157. package/dist/utils/constants.d.ts +10 -0
  158. package/dist/utils/constants.js +10 -0
  159. package/dist/utils/containerSetup.js +48 -6
  160. package/dist/utils/convertMessagesForAPI.d.ts +7 -1
  161. package/dist/utils/convertMessagesForAPI.js +64 -14
  162. package/dist/utils/fileChangeReminder.d.ts +20 -0
  163. package/dist/utils/fileChangeReminder.js +153 -0
  164. package/dist/utils/fileSearch.js +4 -3
  165. package/dist/utils/fileUtils.d.ts +44 -0
  166. package/dist/utils/fileUtils.js +118 -0
  167. package/dist/utils/frontmatterYaml.d.ts +33 -0
  168. package/dist/utils/frontmatterYaml.js +192 -0
  169. package/dist/utils/imageBudget.d.ts +85 -0
  170. package/dist/utils/imageBudget.js +109 -0
  171. package/dist/utils/imageDimensions.d.ts +83 -0
  172. package/dist/utils/imageDimensions.js +232 -0
  173. package/dist/utils/imageProcessor.d.ts +66 -0
  174. package/dist/utils/imageProcessor.js +84 -0
  175. package/dist/utils/imageRewrite.d.ts +29 -0
  176. package/dist/utils/imageRewrite.js +251 -0
  177. package/dist/utils/markdownParser.d.ts +5 -1
  178. package/dist/utils/markdownParser.js +9 -51
  179. package/dist/utils/mcpInstructions.d.ts +61 -0
  180. package/dist/utils/mcpInstructions.js +126 -0
  181. package/dist/utils/mcpUtils.d.ts +7 -0
  182. package/dist/utils/mcpUtils.js +11 -2
  183. package/dist/utils/memoryAge.d.ts +32 -0
  184. package/dist/utils/memoryAge.js +47 -0
  185. package/dist/utils/memoryEntrypoint.d.ts +20 -0
  186. package/dist/utils/memoryEntrypoint.js +49 -0
  187. package/dist/utils/memoryIndex.d.ts +30 -0
  188. package/dist/utils/memoryIndex.js +76 -0
  189. package/dist/utils/messageOperations.d.ts +6 -20
  190. package/dist/utils/messageOperations.js +40 -91
  191. package/dist/utils/nestedMemory.d.ts +22 -0
  192. package/dist/utils/nestedMemory.js +61 -0
  193. package/dist/utils/npmTarball.d.ts +19 -0
  194. package/dist/utils/npmTarball.js +92 -0
  195. package/dist/utils/pluginSource.d.ts +37 -0
  196. package/dist/utils/pluginSource.js +73 -0
  197. package/dist/utils/ripgrep.d.ts +18 -4
  198. package/dist/utils/ripgrep.js +56 -4
  199. package/dist/utils/runtimeDeps.d.ts +35 -0
  200. package/dist/utils/runtimeDeps.js +426 -0
  201. package/dist/utils/skillParser.js +22 -52
  202. package/dist/utils/subagentParser.js +48 -45
  203. package/dist/utils/tokenCalculation.js +0 -8
  204. package/dist/utils/userSettings.d.ts +90 -0
  205. package/dist/utils/userSettings.js +291 -0
  206. package/dist/utils/worktreeUtils.d.ts +2 -1
  207. package/dist/utils/worktreeUtils.js +64 -34
  208. package/package.json +12 -4
  209. package/dist/managers/bangManager.d.ts +0 -26
  210. package/dist/managers/bangManager.js +0 -78
@@ -6,12 +6,14 @@
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
- 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";
11
+ import { RESTRICTED_TOOLS, USER_INTERACTION_REQUIRED_TOOLS, } from "../types/permissions.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
- import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, READ_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "../constants/tools.js";
16
+ import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, READ_TOOL_NAME, } from "../constants/tools.js";
15
17
  const DEFAULT_ALLOWED_RULES = [
16
18
  "Bash(git status*)",
17
19
  "Bash(git diff*)",
@@ -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;
@@ -69,6 +87,7 @@ export class PermissionManager {
69
87
  this.hasExitedPlanMode = false;
70
88
  this.needsPlanModeExitAttachment = false;
71
89
  this.configuredPermissionMode = options.configuredPermissionMode;
90
+ this.bypassAuthorization = options.bypassAuthorization;
72
91
  this.allowedRules = options.allowedRules || [];
73
92
  this.deniedRules = options.deniedRules || [];
74
93
  this.instanceAllowedRules = options.instanceAllowedRules || [];
@@ -117,6 +136,28 @@ export class PermissionManager {
117
136
  getConfiguredPermissionMode() {
118
137
  return this.configuredPermissionMode;
119
138
  }
139
+ /**
140
+ * Freeze the session-level bypass authorization from the permission mode in
141
+ * effect when the session was created (CLI mode > configured default mode >
142
+ * `default`). No-op once captured, so switching to `bypassPermissions`
143
+ * mid-session does not grant it.
144
+ */
145
+ captureBypassAuthorization() {
146
+ if (this.bypassAuthorization !== undefined) {
147
+ return;
148
+ }
149
+ const sessionMode = this.container.has("PermissionMode")
150
+ ? this.container.get("PermissionMode")
151
+ : undefined;
152
+ this.bypassAuthorization =
153
+ this.getCurrentEffectiveMode(sessionMode) === "bypassPermissions";
154
+ }
155
+ /**
156
+ * Whether this session keeps bypass semantics while in plan mode
157
+ */
158
+ getBypassAuthorization() {
159
+ return this.bypassAuthorization === true;
160
+ }
120
161
  /**
121
162
  * Get all currently allowed rules (user-defined)
122
163
  */
@@ -150,6 +191,20 @@ export class PermissionManager {
150
191
  this.instanceAllowedRules.push(rule);
151
192
  }
152
193
  }
194
+ /**
195
+ * Remove an instance-level allowed rule added via addInstanceAllowedRule.
196
+ * Used when a builtin plugin that contributed a helper-script grant is
197
+ * unloaded, so the grant does not outlive the plugin.
198
+ * @returns true if the rule was present
199
+ */
200
+ removeInstanceAllowedRule(rule) {
201
+ const index = this.instanceAllowedRules.indexOf(rule);
202
+ if (index === -1) {
203
+ return false;
204
+ }
205
+ this.instanceAllowedRules.splice(index, 1);
206
+ return true;
207
+ }
153
208
  /**
154
209
  * Get all additional directories
155
210
  */
@@ -220,22 +275,13 @@ export class PermissionManager {
220
275
  * Update the additional directories (e.g., when configuration reloads)
221
276
  */
222
277
  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
- });
278
+ this.additionalDirectories = directories.map((dir) => resolveAdditionalDirectory(dir, this.workdir));
230
279
  }
231
280
  /**
232
281
  * Add an instance-level additional directory (session-level, e.g. /add-dir command)
233
282
  */
234
283
  addInstanceAdditionalDirectory(directory) {
235
- const workdir = this.workdir;
236
- const resolvedPath = workdir && !path.isAbsolute(directory)
237
- ? path.resolve(workdir, directory)
238
- : path.resolve(directory);
284
+ const resolvedPath = resolveAdditionalDirectory(directory, this.workdir);
239
285
  if (!this.instanceAdditionalDirectories.includes(resolvedPath)) {
240
286
  this.instanceAdditionalDirectories.push(resolvedPath);
241
287
  }
@@ -244,14 +290,21 @@ export class PermissionManager {
244
290
  * Add a system-level additional directory that is persistent across configuration reloads
245
291
  */
246
292
  addSystemAdditionalDirectory(directory) {
247
- const workdir = this.workdir;
248
- const resolvedPath = workdir && !path.isAbsolute(directory)
249
- ? path.resolve(workdir, directory)
250
- : path.resolve(directory);
293
+ const resolvedPath = resolveAdditionalDirectory(directory, this.workdir);
251
294
  if (!this.systemAdditionalDirectories.includes(resolvedPath)) {
252
295
  this.systemAdditionalDirectories.push(resolvedPath);
253
296
  }
254
297
  }
298
+ /**
299
+ * Remove a system-level additional directory (idempotent). Used when a
300
+ * settings.json change revokes a privilege that was granted at construction
301
+ * time — e.g. turning auto-memory off removes its directory from the safe
302
+ * zone (core/agent-config.md scenario 6).
303
+ */
304
+ removeSystemAdditionalDirectory(directory) {
305
+ const resolvedPath = resolveAdditionalDirectory(directory, this.workdir);
306
+ this.systemAdditionalDirectories = this.systemAdditionalDirectories.filter((dir) => dir !== resolvedPath);
307
+ }
255
308
  /**
256
309
  * Set the current plan file path
257
310
  */
@@ -428,12 +481,17 @@ export class PermissionManager {
428
481
  }
429
482
  }
430
483
  }
431
- // If bypassPermissions mode, always allow
432
- // Exception: tools that require user interaction (e.g. AskUserQuestion)
433
- // must still prompt the user, matching Claude Code's requiresUserInteraction behavior.
484
+ // If bypassPermissions mode — or plan mode in a session that was authorized
485
+ // to bypass at creation time — always allow.
486
+ // Exception: tools that require user interaction (AskUserQuestion and
487
+ // ExitPlanMode) must still prompt, matching Claude Code's
488
+ // requiresUserInteraction behavior (its step 1e returns the tool's ask
489
+ // result before the bypass step).
434
490
  // Worktree safety check above runs unconditionally, so bypass never skips it.
435
- if (context.permissionMode === "bypassPermissions") {
436
- const requiresUserInteraction = context.toolName === ASK_USER_QUESTION_TOOL_NAME;
491
+ const bypassesPermissionChecks = context.permissionMode === "bypassPermissions" ||
492
+ (context.permissionMode === "plan" && this.getBypassAuthorization());
493
+ if (bypassesPermissionChecks) {
494
+ const requiresUserInteraction = USER_INTERACTION_REQUIRED_TOOLS.includes(context.toolName);
437
495
  if (!requiresUserInteraction) {
438
496
  return { behavior: "allow" };
439
497
  }
@@ -609,9 +667,12 @@ export class PermissionManager {
609
667
  const targetPath = toolInput?.file_path;
610
668
  const workdir = toolInput?.workdir;
611
669
  if (targetPath) {
612
- const { isInside } = this.isInsideSafeZone(targetPath, workdir);
670
+ const { isInside, resolvedPath } = this.isInsideSafeZone(targetPath, workdir);
613
671
  if (!isInside) {
614
672
  context.hidePersistentOption = true;
673
+ // Surface the target's directory so the confirmation UI can offer
674
+ // adding it to the session Safe Zone.
675
+ context.outsideSafeZoneDirectory = path.dirname(resolvedPath);
615
676
  }
616
677
  }
617
678
  }
@@ -634,20 +695,12 @@ export class PermissionManager {
634
695
  const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
635
696
  if (commandMatch) {
636
697
  const cmd = commandMatch[1];
637
- const args = commandMatch[2]?.trim() || "";
638
698
  // Check blacklist
639
699
  if (DANGEROUS_COMMANDS.includes(cmd)) {
640
700
  return true;
641
701
  }
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
- }
702
+ // Check out-of-bounds for cd and path-taking read-only commands
703
+ return this.isOutOfSafeZonePart(part, workdir);
651
704
  }
652
705
  return false;
653
706
  });
@@ -758,6 +811,19 @@ export class PermissionManager {
758
811
  // find with dangerous flags (e.g. -exec, -delete) disqualifies
759
812
  if (cmd === "find" && isDangerousFind(part))
760
813
  return false;
814
+ // Path-taking read-only commands must stay within the Safe Zone:
815
+ // accessing a path outside workdir / additional directories
816
+ // disqualifies (aligned with Claude Code's path constraints)
817
+ if (PATH_COMMANDS.includes(cmd)) {
818
+ const pathArgs = extractPathArgs(args);
819
+ if (pathArgs.length > 0) {
820
+ if (!workdir)
821
+ return false;
822
+ if (!pathArgs.every((pathArg) => this.isInsideSafeZone(pathArg, workdir).isInside)) {
823
+ return false;
824
+ }
825
+ }
826
+ }
761
827
  return true;
762
828
  }
763
829
  // cd is not read-only but is safe if all paths are within the Safe Zone
@@ -775,6 +841,26 @@ export class PermissionManager {
775
841
  }
776
842
  return false;
777
843
  }
844
+ /**
845
+ * Check whether a bash command part takes path arguments (cd or a
846
+ * path-taking read-only command) that escape the Safe Zone. Used to
847
+ * disqualify auto-allow via DEFAULT_ALLOWED_RULES and to hide the
848
+ * "don't ask again" persistence option for out-of-bounds access.
849
+ */
850
+ isOutOfSafeZonePart(part, workdir) {
851
+ const processedPart = stripRedirections(stripEnvVars(part));
852
+ const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
853
+ if (!commandMatch)
854
+ return false;
855
+ const cmd = commandMatch[1];
856
+ if (cmd !== "cd" && !PATH_COMMANDS.includes(cmd))
857
+ return false;
858
+ const args = commandMatch[2]?.trim() || "";
859
+ const pathArgs = extractPathArgs(args);
860
+ if (pathArgs.length === 0)
861
+ return false;
862
+ return pathArgs.some((pathArg) => !this.isInsideSafeZone(pathArg, workdir).isInside);
863
+ }
778
864
  /**
779
865
  * Check if a tool call is allowed by persistent or temporary rules
780
866
  */
@@ -789,42 +875,80 @@ export class PermissionManager {
789
875
  ];
790
876
  if (context.toolName === BASH_TOOL_NAME && context.toolInput?.command) {
791
877
  const command = String(context.toolInput.command);
792
- const parts = splitBashCommand(command);
793
- if (parts.length === 0)
794
- return false;
795
878
  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
- });
879
+ // Structure-aware expansion: compound commands and command substitutions
880
+ // are reduced to their leaf simple commands; each leaf is classified
881
+ // exactly like a top-level part of a chained command would be (spec:
882
+ // "复合命令与命令替换的结构感知判定").
883
+ const structure = parseBashStructure(command);
884
+ if (structure.status === "ok") {
885
+ if (structure.leaves.length === 0)
886
+ return false;
887
+ return structure.leaves.every((leaf) => this.isBashLeafAllowed(leaf, workdir, context, explicitRules));
888
+ }
889
+ // Fail-closed (heredoc / process substitution / arithmetic / brace /
890
+ // parse failure / eval / trap / …): the command cannot be statically
891
+ // reduced to leaves, so it is never auto-allowed and never matches the
892
+ // built-in default rules. Only an explicit user rule covering the whole
893
+ // command may allow it.
894
+ const wholeContext = {
895
+ ...context,
896
+ toolInput: { ...context.toolInput, command },
897
+ };
898
+ return explicitRules.some((rule) => this.matchesRule(wholeContext, rule));
823
899
  }
824
900
  // For other tools, check if any rule matches
825
901
  const allRules = [...explicitRules, ...DEFAULT_ALLOWED_RULES];
826
902
  return allRules.some((rule) => this.matchesRule(context, rule));
827
903
  }
904
+ /**
905
+ * Classify one expanded leaf command. Every leaf of a compound command or
906
+ * command substitution is judged with the exact rules used for a plain
907
+ * command, with two structure-aware additions:
908
+ *
909
+ * - a leaf the parser flagged `unsafe` (a bare reference to a variable whose
910
+ * value is not statically known, or a command substitution inside the
911
+ * leaf's own argument words) is never auto-allowed and never matches the
912
+ * built-in default rules — only an explicit allow rule may cover it;
913
+ * - `read` (e.g. the condition of `while read …`) only reads stdin, so it
914
+ * is treated as safe when it carries no redirection of its own.
915
+ */
916
+ isBashLeafAllowed(leaf, workdir, context, explicitRules) {
917
+ // Check for auto-allowed read-only commands (FR-019.2 through FR-019.7)
918
+ if (!leaf.unsafe) {
919
+ if (leaf.command === "read" && !/[<>]/.test(leaf.text))
920
+ return true;
921
+ if (this.isAutoAllowedPart(leaf.text, workdir))
922
+ return true;
923
+ }
924
+ if (explicitRules.some((rule) => this.matchesRule(this.ruleContext(context, leaf.text), rule)) ||
925
+ explicitRules.some((rule) => this.matchesRule(this.ruleContext(context, unquotedLeafCommand(leaf)), rule))) {
926
+ return true;
927
+ }
928
+ // Default rules must not auto-allow dangerous variants (write
929
+ // redirections, substitutions, sed -i, dangerous find, out-of-safe-zone
930
+ // paths, bare-unknown-variable / in-word substitution leaves) through
931
+ // broad rules like Bash(echo*) or Bash(cat*)
932
+ const isDangerousVariant = leaf.unsafe ||
933
+ hasWriteRedirections(leaf.text) ||
934
+ isDangerousFind(leaf.text) ||
935
+ hasCommandSubstitution(leaf.text) ||
936
+ hasProcessSubstitution(leaf.text) ||
937
+ hasSedInPlace(leaf.text) ||
938
+ this.isOutOfSafeZonePart(leaf.text, workdir);
939
+ if (!isDangerousVariant &&
940
+ DEFAULT_ALLOWED_RULES.some((rule) => this.matchesRule(this.ruleContext(context, leaf.text), rule))) {
941
+ return true;
942
+ }
943
+ return !this.isRestrictedTool(context.toolName);
944
+ }
945
+ /** A context whose Bash command is a single part/leaf text. */
946
+ ruleContext(context, command) {
947
+ return {
948
+ ...context,
949
+ toolInput: { ...context.toolInput, command },
950
+ };
951
+ }
828
952
  /**
829
953
  * Expand a bash command into individual permission rules, filtering out safe commands.
830
954
  * Used when saving permissions to the allow list.
@@ -834,7 +958,13 @@ export class PermissionManager {
834
958
  * @returns Array of permission rules in "Bash(cmd)" format
835
959
  */
836
960
  expandBashRule(command, workdir) {
837
- const parts = splitBashCommand(command);
961
+ // Expand compound commands structurally when possible so control-structure
962
+ // keywords (do/done/then/…) never leak into persisted rules. Constructs the
963
+ // parser cannot reduce fall back to the legacy textual part splitter.
964
+ const structure = parseBashStructure(command);
965
+ const parts = structure.status === "ok" && structure.leaves.length > 0
966
+ ? structure.leaves.map((leaf) => leaf.text)
967
+ : splitBashCommand(command);
838
968
  const rules = [];
839
969
  for (const part of parts) {
840
970
  const hasWrite = hasWriteRedirections(part);
@@ -846,23 +976,12 @@ export class PermissionManager {
846
976
  const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
847
977
  if (commandMatch) {
848
978
  const cmd = commandMatch[1];
849
- const args = commandMatch[2]?.trim() || "";
850
979
  if (DANGEROUS_COMMANDS.includes(cmd) ||
851
980
  isDangerousFind(part) ||
852
- hasSedInPlace(part)) {
981
+ hasSedInPlace(part) ||
982
+ this.isOutOfSafeZonePart(part, workdir)) {
853
983
  continue;
854
984
  }
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
985
  }
867
986
  const smartPrefix = hasWrite ? null : getSmartPrefix(processedPart);
868
987
  if (smartPrefix) {
@@ -918,3 +1037,27 @@ export class PermissionManager {
918
1037
  }
919
1038
  }
920
1039
  }
1040
+ /**
1041
+ * Build a rule-matching form of a leaf command with quoting removed from words
1042
+ * that are a single quoted segment (`node "scripts/x"` → `node scripts/x`).
1043
+ * Shell quoting that does not change the executed words must not prevent a
1044
+ * rule such as `Bash(node scripts*)` from matching a leaf like
1045
+ * `node "scripts/$f" --dry-run`. Redirect operators/targets (the text tail
1046
+ * beyond argv) are preserved verbatim so write-redirection guards still apply.
1047
+ */
1048
+ function unquotedLeafCommand(leaf) {
1049
+ const rawCommand = leaf.argv.join(" ");
1050
+ const tail = leaf.text.slice(rawCommand.length);
1051
+ return leaf.argv.map(unquoteWord).join(" ") + tail;
1052
+ }
1053
+ /** Strip one layer of enclosing single/double quotes from a whole word. */
1054
+ function unquoteWord(raw) {
1055
+ if (raw.length < 2)
1056
+ return raw;
1057
+ const first = raw[0];
1058
+ const last = raw[raw.length - 1];
1059
+ if ((first === '"' || first === "'") && first === last) {
1060
+ return raw.slice(1, -1);
1061
+ }
1062
+ return raw;
1063
+ }
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { Plugin, PluginConfig } from "../types/index.js";
1
+ import { Plugin, PluginConfig, PluginLoadFailure, PluginReloadResult } from "../types/index.js";
2
2
  import { Container } from "../utils/container.js";
3
3
  export interface PluginManagerOptions {
4
4
  workdir: string;
@@ -17,6 +17,17 @@ export declare class PluginManager {
17
17
  private plugins;
18
18
  private workdir;
19
19
  private enabledPlugins;
20
+ /** Explicit configs from the last load, replayed by reloadAllPlugins(). */
21
+ private lastLoadConfigs;
22
+ /** Failures collected by the current loadPlugins() run. */
23
+ private loadFailures;
24
+ /**
25
+ * Serializes plugin (re)loads. A managed-settings sync (or an early
26
+ * `/reload-plugins`) can ask for a reload while the startup load is still
27
+ * running; interleaving them would leave half-unloaded capability registries.
28
+ */
29
+ private loadChain;
30
+ private serialize;
20
31
  constructor(container: Container, options: PluginManagerOptions);
21
32
  private get permissionManager();
22
33
  private get skillManager();
@@ -41,8 +52,41 @@ export declare class PluginManager {
41
52
  /**
42
53
  * Load plugins from configuration
43
54
  * @param configs Array of plugin configurations
55
+ * @returns the plugins that failed to load
56
+ */
57
+ loadPlugins(configs: PluginConfig[]): Promise<PluginLoadFailure[]>;
58
+ /**
59
+ * The actual load. Reached through [loadPlugins] (which serializes concurrent
60
+ * requests) and directly from [reloadAllPlugins], which already holds the slot.
61
+ */
62
+ private doLoadPlugins;
63
+ /**
64
+ * Unload a single plugin: drop everything it contributed from the six
65
+ * capability registries, then forget it. Other plugins and the running
66
+ * session are untouched.
67
+ * @returns true if the plugin was loaded
68
+ */
69
+ unloadPlugin(name: string): Promise<boolean>;
70
+ /**
71
+ * Re-read every plugin in place — the `/reload-plugins` command.
72
+ *
73
+ * Unloads everything currently loaded, re-reads `enabledPlugins` from the
74
+ * configuration chain (so a settings edit is honored), then replays the
75
+ * original load order (explicit configs → installed → builtin). The session,
76
+ * transcript and sessionId are untouched; only the capability registries are
77
+ * swapped, which is what makes this an in-place reload instead of a rebuild.
78
+ *
79
+ * Unload must happen before any caller-side mutation of the plugin cache dir:
80
+ * hooks, MCP, LSP, skills and agents all bake absolute plugin paths at
81
+ * registration time, so an entry left pointing at a deleted directory is
82
+ * worse than a merely stale one.
83
+ */
84
+ reloadAllPlugins(): Promise<PluginReloadResult>;
85
+ /**
86
+ * Re-read enabled plugins from the merged configuration chain, falling back
87
+ * to the last known value when the service is unavailable.
44
88
  */
45
- loadPlugins(configs: PluginConfig[]): Promise<void>;
89
+ private refreshEnabledPlugins;
46
90
  /**
47
91
  * Load built-in plugins bundled with the SDK (e.g. sdd).
48
92
  * Opt-in via `enabledPlugins`, keyed `<name>@builtin` (default off, consistent