wave-agent-sdk 1.0.7 → 1.0.9

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 (55) hide show
  1. package/builtin/skills/artifact/SKILL.md +14 -0
  2. package/builtin/skills/settings/ENV.md +2 -1
  3. package/builtin/skills/settings/MODELS.md +3 -0
  4. package/builtin/skills/settings/SKILL.md +7 -0
  5. package/builtin/skills/settings/SUBAGENTS.md +1 -1
  6. package/builtin/subagents/vision.md +18 -0
  7. package/dist/constants/tools.d.ts +1 -0
  8. package/dist/constants/tools.js +1 -0
  9. package/dist/managers/aiManager.js +46 -4
  10. package/dist/managers/backgroundTaskManager.js +52 -4
  11. package/dist/managers/messageManager.js +8 -2
  12. package/dist/managers/permissionManager.d.ts +5 -0
  13. package/dist/managers/permissionManager.js +18 -2
  14. package/dist/managers/pluginManager.d.ts +9 -0
  15. package/dist/managers/pluginManager.js +20 -0
  16. package/dist/managers/skillManager.d.ts +13 -0
  17. package/dist/managers/skillManager.js +30 -0
  18. package/dist/managers/subagentManager.d.ts +12 -0
  19. package/dist/managers/subagentManager.js +32 -2
  20. package/dist/managers/toolManager.d.ts +8 -0
  21. package/dist/managers/toolManager.js +18 -0
  22. package/dist/services/aiService.d.ts +1 -1
  23. package/dist/services/aiService.js +3 -2
  24. package/dist/services/artifactAvailability.d.ts +9 -0
  25. package/dist/services/artifactAvailability.js +34 -0
  26. package/dist/services/artifactSession.d.ts +27 -0
  27. package/dist/services/artifactSession.js +52 -0
  28. package/dist/services/configurationService.d.ts +2 -1
  29. package/dist/services/configurationService.js +16 -1
  30. package/dist/services/initializationService.js +5 -0
  31. package/dist/services/remoteSettingsService.js +2 -0
  32. package/dist/tools/agentTool.js +2 -1
  33. package/dist/tools/artifactTool.d.ts +2 -0
  34. package/dist/tools/artifactTool.js +357 -0
  35. package/dist/tools/bashTool.js +24 -13
  36. package/dist/tools/editTool.js +7 -2
  37. package/dist/tools/types.d.ts +3 -1
  38. package/dist/tools/webFetchTool.js +141 -0
  39. package/dist/types/agent.d.ts +2 -0
  40. package/dist/types/config.d.ts +2 -0
  41. package/dist/types/configuration.d.ts +2 -0
  42. package/dist/types/messaging.d.ts +3 -1
  43. package/dist/types/permissions.d.ts +3 -1
  44. package/dist/types/permissions.js +2 -1
  45. package/dist/utils/bashParser.d.ts +14 -0
  46. package/dist/utils/bashParser.js +45 -1
  47. package/dist/utils/containerSetup.js +8 -0
  48. package/dist/utils/convertMessagesForAPI.js +51 -4
  49. package/dist/utils/encoding.d.ts +28 -0
  50. package/dist/utils/encoding.js +99 -0
  51. package/dist/utils/messageOperations.d.ts +4 -2
  52. package/dist/utils/messageOperations.js +23 -7
  53. package/dist/utils/subagentParser.d.ts +5 -2
  54. package/dist/utils/subagentParser.js +14 -4
  55. package/package.json +2 -1
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: artifact
3
+ description: Publish a local HTML or Markdown file as a shareable web page
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Artifact: Publish a File as a Shareable Web Page
8
+
9
+ Publish a local `.html` or `.md` file as a default-private, shareable web page.
10
+
11
+ - If a file path was provided ($ARGUMENTS / $1), use it directly as the `file_path`.
12
+ - Otherwise, infer which file to publish from the conversation context; if it is not clear, ask the user which file to publish.
13
+
14
+ Call the `Artifact` tool with the resolved `file_path` (and `favicon` if relevant), then report the resulting URL to the user.
@@ -23,10 +23,11 @@ Wave uses several environment variables to control its core functionality. Varia
23
23
  | :--- | :--- | :--- |
24
24
  | `WAVE_API_KEY` | API key for the AI gateway. | - |
25
25
  | `WAVE_BASE_URL` | Base URL for the AI gateway. | - |
26
- | `WAVE_SERVER_URL` | Server URL for SSO authentication. **OS env only** set via OS env or `options.serverUrl`; not read from settings.json `env` (avoids a startup 401 race). | `https://codechat.codewave.163.com` |
26
+ | `WAVE_SERVER_URL` | Server URL for SSO authentication. Resolution order: `options.serverUrl` `process.env.WAVE_SERVER_URL` default. Unlike other `WAVE_*` vars, a settings.json `env` value is also mirrored to `process.env` so process-level singletons (AuthService) see it without a per-session snapshot. | `https://codechat.codewave.163.com` |
27
27
  | `WAVE_CUSTOM_HEADERS` | Custom HTTP headers for the AI gateway. Newline-separated `Key: Value` pairs (e.g., `"X-Foo: bar\nAuthorization: Bearer xxx"`). | - |
28
28
  | `WAVE_MODEL` | The primary AI model to use for the agent. | `gemini-3-flash` |
29
29
  | `WAVE_FAST_MODEL` | The fast AI model to use for quick tasks. | `gemini-2.5-flash` |
30
+ | `WAVE_VISION_MODEL` | Vision-capable model used by the built-in `vision` subagent for image recognition. When set, the built-in `vision` subagent is registered (its frontmatter `model: visionModel` resolves to this value); when unset, the subagent is not loaded. Useful when the main model is fast but non-vision (e.g. DeepSeek). | - (not registered) |
30
31
  | `WAVE_MAX_INPUT_TOKENS` | Maximum number of input tokens allowed. | `200000` |
31
32
  | `WAVE_MAX_OUTPUT_TOKENS` | Maximum number of output tokens allowed. | `32000` |
32
33
  | `WAVE_DISABLE_AUTO_MEMORY` | Set to `1` or `true` to disable the auto-memory feature. | `false` |
@@ -105,12 +105,15 @@ You can also set the default models Wave uses via environment variables in `sett
105
105
  "env": {
106
106
  "WAVE_MODEL": "gemini-3-flash",
107
107
  "WAVE_FAST_MODEL": "gemini-2.5-flash",
108
+ "WAVE_VISION_MODEL": "qwen-vl-max",
108
109
  "WAVE_MAX_INPUT_TOKENS": "100000",
109
110
  "WAVE_MAX_OUTPUT_TOKENS": "4096"
110
111
  }
111
112
  }
112
113
  ```
113
114
 
115
+ `WAVE_VISION_MODEL` names a vision-capable model for the built-in `vision` subagent. Setting it registers the subagent, whose frontmatter `model: visionModel` resolves to this value — so a fast non-vision main model can delegate image recognition. Leave it unset to disable the built-in `vision` subagent.
116
+
114
117
  ## Live Reload
115
118
 
116
119
  Model configurations support **live reload**. When you modify the `models` field or model-related environment variables in `settings.json`, the changes take effect immediately without restarting Wave.
@@ -104,6 +104,13 @@ For detailed guidance on creating plugins and marketplaces, see [PLUGINS.md](${W
104
104
  - `language`: Preferred language for agent communication (e.g., `"en"`, `"zh"`).
105
105
  - `autoMemoryEnabled`: Enable or disable auto-memory (default: `true`).
106
106
  - `autoMemoryFrequency`: Frequency of auto-memory extraction turns (default: `1`).
107
+ - `enableArtifact`: Enable the Artifact tool, which publishes local `.html`/`.md` files as shareable (default-private) web pages. Defaults to `false` while the frame backend is not live; set to `true` to register the tool and enable WebFetch interception for artifact URLs. Toggling it hot-reloads the tool registry.
108
+
109
+ ```json
110
+ {
111
+ "enableArtifact": true
112
+ }
113
+ ```
107
114
 
108
115
  ## How to use this skill
109
116
 
@@ -35,7 +35,7 @@ You are a specialized subagent for a specific task. Your goal is to:
35
35
  - `name`: (Required) Unique identifier.
36
36
  - `description`: (Required) Explains the subagent's expertise and when to use it.
37
37
  - `tools`: (Optional) List of tools the subagent can use.
38
- - `model`: (Optional) Overrides the default model for this subagent.
38
+ - `model`: (Optional) Overrides the default model for this subagent. The special values `fastModel` and `visionModel` resolve to the `WAVE_FAST_MODEL` / `WAVE_VISION_MODEL` env vars respectively. Built-in subagents declaring `model: visionModel` (e.g. the built-in `vision` agent) are only registered when `WAVE_VISION_MODEL` is set; for user-defined subagents the value simply resolves to the configured vision model.
39
39
 
40
40
  ## Subagent Locations
41
41
 
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: vision
3
+ description: 'Image recognition specialist that runs on the model specified by the WAVE_VISION_MODEL environment variable. Use this when the current model does not support image recognition but the user has shared an image (identify the image by its "[Image source: <path>]" metadata). Pass the image file path(s) in the prompt; this agent reads the image with the Read tool and returns a detailed text description of its contents.'
4
+ tools: [Read]
5
+ model: visionModel
6
+ ---
7
+
8
+ You are an image recognition specialist. You run on a vision-capable model and your job is to look at image files and return detailed text descriptions of their contents.
9
+
10
+ When given image file path(s):
11
+ - Use the Read tool on each image path to load it. The Read tool returns the image as base64 image data that you can see directly.
12
+ - Describe the image contents in detail: what is shown, any visible text (transcribe verbatim where relevant), layout, colors, objects, and anything else the caller asked about.
13
+ - If an image cannot be read (file missing, not an image, or too large), report the error clearly and state which path failed.
14
+ - Do not invent or guess content you cannot see — only describe what the image actually shows.
15
+ - Return your description directly as a text message. Do NOT create files.
16
+ - Avoid using emojis in your response.
17
+
18
+ Complete the image recognition task and report your findings clearly.
@@ -22,3 +22,4 @@ export declare const WEB_FETCH_TOOL_NAME = "WebFetch";
22
22
  export declare const ENTER_WORKTREE_TOOL_NAME = "EnterWorktree";
23
23
  export declare const EXIT_WORKTREE_TOOL_NAME = "ExitWorktree";
24
24
  export declare const WORKFLOW_TOOL_NAME = "Workflow";
25
+ export declare const ARTIFACT_TOOL_NAME = "Artifact";
@@ -22,3 +22,4 @@ export const WEB_FETCH_TOOL_NAME = "WebFetch";
22
22
  export const ENTER_WORKTREE_TOOL_NAME = "EnterWorktree";
23
23
  export const EXIT_WORKTREE_TOOL_NAME = "ExitWorktree";
24
24
  export const WORKFLOW_TOOL_NAME = "Workflow";
25
+ export const ARTIFACT_TOOL_NAME = "Artifact";
@@ -20,6 +20,14 @@ import { logOTelEvent } from "../telemetry/events.js";
20
20
  const MAX_FORK_TURNS = 3;
21
21
  /** Max turns for the auto-memory extraction fork. */
22
22
  const MAX_AUTO_MEMORY_FORK_TURNS = 5;
23
+ /**
24
+ * Max consecutive auto-resumes after `finish_reason === "length"` when the
25
+ * truncated turn produced NO tool calls (aligned with Claude Code's
26
+ * MAX_OUTPUT_TOKENS_RECOVERY_LIMIT). A tool call resets the counter — it is
27
+ * real progress. Exhausting the limit terminates the turn with an error
28
+ * instead of re-planning forever.
29
+ */
30
+ const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3;
23
31
  // Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
24
32
  // Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
25
33
  function truncateWithMarker(text, max) {
@@ -138,6 +146,9 @@ export class AIManager {
138
146
  if (this.modelOverride === "fastModel") {
139
147
  modelToUse = parentModelConfig.fastModel;
140
148
  }
149
+ else if (this.modelOverride === "visionModel") {
150
+ modelToUse = parentModelConfig.visionModel;
151
+ }
141
152
  else if (this.modelOverride !== "inherit") {
142
153
  modelToUse = this.modelOverride;
143
154
  }
@@ -1217,6 +1228,10 @@ ${question}`;
1217
1228
  toolAbortController = this.toolAbortController;
1218
1229
  }
1219
1230
  let turnDepth = turnOffset;
1231
+ // Consecutive truncation recoveries within this turn (no tool calls
1232
+ // in between). Reset to 0 on tool calls / at each sendAIMessage entry,
1233
+ // matching Claude Code's maxOutputTokensRecoveryCount semantics.
1234
+ let maxOutputTokensRecoveryCount = 0;
1220
1235
  inner: while (true) {
1221
1236
  let llmSpan;
1222
1237
  try {
@@ -1300,10 +1315,15 @@ ${question}`;
1300
1315
  // Use parametersChunk as compact param for better performance
1301
1316
  // No need to extract params or generate compact params during streaming
1302
1317
  // Update tool block with streaming parameters using parametersChunk as compact param
1318
+ // `parameters` is only present on start/running/end (authoritative); streaming
1319
+ // carries only `parametersChunk`, so don't forward `parameters: undefined`
1320
+ // (it would overwrite the accumulated block params in consumers)
1303
1321
  this.messageManager.updateToolBlock({
1304
1322
  id: toolCall.id,
1305
1323
  name: toolCall.name,
1306
- parameters: toolCall.parameters,
1324
+ ...(toolCall.parameters !== undefined
1325
+ ? { parameters: toolCall.parameters }
1326
+ : {}),
1307
1327
  parametersChunk: toolCall.parametersChunk,
1308
1328
  stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
1309
1329
  });
@@ -1462,13 +1482,34 @@ ${question}`;
1462
1482
  const lastMessage = this.messageManager.getMessages()[this.messageManager.getMessages().length - 1];
1463
1483
  const toolBlocks = lastMessage?.blocks.filter((block) => block.type === "tool") || [];
1464
1484
  const hasBackgrounded = toolBlocks.length > 0 &&
1465
- toolBlocks.some((block) => block.isManuallyBackgrounded);
1485
+ toolBlocks.some((block) => block.backgroundedByUser);
1466
1486
  if (hasBackgrounded) {
1467
1487
  logger?.info("Some tools were manually backgrounded, stopping.");
1468
1488
  }
1469
1489
  else if (!isCurrentlyAborted) {
1490
+ // If the response was truncated WITHOUT any tool calls,
1491
+ // enforce the consecutive recovery limit (aligned with Claude
1492
+ // Code's MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3). Re-planning
1493
+ // loops that never produce output would otherwise continue
1494
+ // until the platform kills the task.
1495
+ if (result.finish_reason === "length" &&
1496
+ toolCalls.length === 0 &&
1497
+ maxOutputTokensRecoveryCount >=
1498
+ MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
1499
+ this.messageManager.addErrorBlock(`Response exceeded the output token limit ${MAX_OUTPUT_TOKENS_RECOVERY_LIMIT + 1} consecutive times without producing output or tool calls. Stopped to avoid an infinite loop. Break the remaining work into smaller pieces and try again.`);
1500
+ break inner;
1501
+ }
1470
1502
  // If response was truncated, add a hidden continuation message
1471
1503
  if (result.finish_reason === "length") {
1504
+ if (toolCalls.length === 0) {
1505
+ // Pure truncation — count toward the recovery limit.
1506
+ maxOutputTokensRecoveryCount++;
1507
+ }
1508
+ else {
1509
+ // Truncated with tool calls — real progress, reset the
1510
+ // consecutive counter.
1511
+ maxOutputTokensRecoveryCount = 0;
1512
+ }
1472
1513
  this.messageManager.addUserMessage({
1473
1514
  content: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.",
1474
1515
  isMeta: true,
@@ -1881,7 +1922,9 @@ ${question}`;
1881
1922
  name: toolName,
1882
1923
  compactParams,
1883
1924
  shortResult: toolResult.shortResult,
1884
- isManuallyBackgrounded: toolResult.isManuallyBackgrounded,
1925
+ backgroundTaskId: toolResult.backgroundTaskId,
1926
+ backgroundedByUser: toolResult.backgroundedByUser,
1927
+ assistantAutoBackgrounded: toolResult.assistantAutoBackgrounded,
1885
1928
  startLineNumber: toolResult.startLineNumber,
1886
1929
  images: toolResult.images,
1887
1930
  timestamp: Date.now(),
@@ -1900,7 +1943,6 @@ ${question}`;
1900
1943
  stage: "end",
1901
1944
  name: toolName,
1902
1945
  compactParams,
1903
- isManuallyBackgrounded: false,
1904
1946
  timestamp: Date.now(),
1905
1947
  });
1906
1948
  }
@@ -3,6 +3,7 @@ import * as os from "os";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { stripAnsiColors } from "../utils/stringUtils.js";
6
+ import { WindowsStreamDecoder } from "../utils/encoding.js";
6
7
  import { logger } from "../utils/globalLogger.js";
7
8
  import { resolveShellPath } from "../utils/shellResolver.js";
8
9
  export class BackgroundTaskManager {
@@ -116,8 +117,12 @@ export class BackgroundTaskManager {
116
117
  }
117
118
  }, timeout);
118
119
  }
120
+ // On Windows, native tools (taskkill, powershell, ...) write GBK (cp936)
121
+ // instead of UTF-8; decode their byte streams accordingly (issue #1753).
122
+ const stdoutDecoder = process.platform === "win32" ? new WindowsStreamDecoder() : null;
123
+ const stderrDecoder = process.platform === "win32" ? new WindowsStreamDecoder() : null;
119
124
  const onStdout = (data) => {
120
- const stripped = stripAnsiColors(data.toString());
125
+ const stripped = stripAnsiColors(stdoutDecoder ? stdoutDecoder.push(data) : data.toString());
121
126
  shell.stdout += stripped;
122
127
  if (logStream.writable) {
123
128
  logStream.write(stripped);
@@ -125,7 +130,7 @@ export class BackgroundTaskManager {
125
130
  this.notifyTasksChange();
126
131
  };
127
132
  const onStderr = (data) => {
128
- const stripped = stripAnsiColors(data.toString());
133
+ const stripped = stripAnsiColors(stderrDecoder ? stderrDecoder.push(data) : data.toString());
129
134
  shell.stderr += stripped;
130
135
  if (logStream.writable) {
131
136
  logStream.write(stripped);
@@ -136,6 +141,26 @@ export class BackgroundTaskManager {
136
141
  if (timeoutHandle) {
137
142
  clearTimeout(timeoutHandle);
138
143
  }
144
+ // Decode any bytes still held at stream end (e.g. a trailing UTF-8
145
+ // character split across the last chunk).
146
+ if (stdoutDecoder) {
147
+ const rest = stdoutDecoder.flush();
148
+ if (rest) {
149
+ shell.stdout += rest;
150
+ if (logStream.writable) {
151
+ logStream.write(rest);
152
+ }
153
+ }
154
+ }
155
+ if (stderrDecoder) {
156
+ const rest = stderrDecoder.flush();
157
+ if (rest) {
158
+ shell.stderr += rest;
159
+ if (logStream.writable) {
160
+ logStream.write(rest);
161
+ }
162
+ }
163
+ }
139
164
  if (logStream.writable) {
140
165
  logStream.end();
141
166
  }
@@ -256,8 +281,12 @@ export class BackgroundTaskManager {
256
281
  };
257
282
  this.tasks.set(id, shell);
258
283
  this.notifyTasksChange();
284
+ // On Windows, native tools write GBK (cp936) instead of UTF-8; decode
285
+ // their byte streams accordingly (issue #1753).
286
+ const stdoutDecoder = process.platform === "win32" ? new WindowsStreamDecoder() : null;
287
+ const stderrDecoder = process.platform === "win32" ? new WindowsStreamDecoder() : null;
259
288
  child.stdout?.on("data", (data) => {
260
- const stripped = stripAnsiColors(data.toString());
289
+ const stripped = stripAnsiColors(stdoutDecoder ? stdoutDecoder.push(data) : data.toString());
261
290
  shell.stdout += stripped;
262
291
  if (logStream.writable) {
263
292
  logStream.write(stripped);
@@ -265,7 +294,7 @@ export class BackgroundTaskManager {
265
294
  this.notifyTasksChange();
266
295
  });
267
296
  child.stderr?.on("data", (data) => {
268
- const stripped = stripAnsiColors(data.toString());
297
+ const stripped = stripAnsiColors(stderrDecoder ? stderrDecoder.push(data) : data.toString());
269
298
  shell.stderr += stripped;
270
299
  if (logStream.writable) {
271
300
  logStream.write(stripped);
@@ -273,6 +302,25 @@ export class BackgroundTaskManager {
273
302
  this.notifyTasksChange();
274
303
  });
275
304
  child.on("exit", (code) => {
305
+ // Decode any bytes still held at stream end
306
+ if (stdoutDecoder) {
307
+ const rest = stdoutDecoder.flush();
308
+ if (rest) {
309
+ shell.stdout += rest;
310
+ if (logStream.writable) {
311
+ logStream.write(rest);
312
+ }
313
+ }
314
+ }
315
+ if (stderrDecoder) {
316
+ const rest = stderrDecoder.flush();
317
+ if (rest) {
318
+ shell.stderr += rest;
319
+ if (logStream.writable) {
320
+ logStream.write(rest);
321
+ }
322
+ }
323
+ }
276
324
  if (logStream.writable) {
277
325
  logStream.end();
278
326
  }
@@ -686,7 +686,9 @@ export class MessageManager {
686
686
  stage: "end",
687
687
  success: false,
688
688
  error: errorMessage,
689
- isManuallyBackgrounded: block.isManuallyBackgrounded,
689
+ backgroundTaskId: block.backgroundTaskId,
690
+ backgroundedByUser: block.backgroundedByUser,
691
+ assistantAutoBackgrounded: block.assistantAutoBackgrounded,
690
692
  timestamp,
691
693
  });
692
694
  }
@@ -730,7 +732,11 @@ export class MessageManager {
730
732
  // only sees messages from the latest compact summary forward — matching
731
733
  // the compact and resume behaviors (which also fold memory).
732
734
  this.setMessages(sliceFromLastCompact(newMessages));
733
- this.savedMessageCount = newMessages.length;
735
+ // savedMessageCount tracks in-memory progress, so it must be the folded
736
+ // length. Using the full disk count here would make saveSession's
737
+ // slice(savedMessageCount) empty after a rewind past a compact boundary
738
+ // and silently drop every subsequent message from the session file.
739
+ this.savedMessageCount = this.messages.length;
734
740
  }
735
741
  /**
736
742
  * Rewrite the session file with the current messages.
@@ -82,6 +82,11 @@ export declare class PermissionManager {
82
82
  * Get all instance-specific denied rules
83
83
  */
84
84
  getInstanceDeniedRules(): string[];
85
+ /**
86
+ * Add an instance-level allowed rule (session-level, in-memory only).
87
+ * Unlike addPermissionRule, this does NOT persist to settings.local.json.
88
+ */
89
+ addInstanceAllowedRule(rule: string): void;
85
90
  /**
86
91
  * Get all additional directories
87
92
  */
@@ -8,7 +8,7 @@
8
8
  import path from "node:path";
9
9
  import { minimatch } from "minimatch";
10
10
  import { RESTRICTED_TOOLS } from "../types/permissions.js";
11
- import { splitBashCommand, stripEnvVars, stripRedirections, hasWriteRedirections, getSmartPrefix, isDangerousFind, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, DANGEROUS_COMMANDS, READ_ONLY_COMMANDS, } from "../utils/bashParser.js";
11
+ import { splitBashCommand, stripEnvVars, stripRedirections, hasWriteRedirections, getSmartPrefix, isDangerousFind, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, stripGitScopePrefix, DANGEROUS_COMMANDS, READ_ONLY_COMMANDS, } from "../utils/bashParser.js";
12
12
  import { isPathInside } from "../utils/pathSafety.js";
13
13
  import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, READ_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "../constants/tools.js";
14
14
  const DEFAULT_ALLOWED_RULES = [
@@ -140,6 +140,15 @@ export class PermissionManager {
140
140
  getInstanceDeniedRules() {
141
141
  return [...this.instanceDeniedRules];
142
142
  }
143
+ /**
144
+ * Add an instance-level allowed rule (session-level, in-memory only).
145
+ * Unlike addPermissionRule, this does NOT persist to settings.local.json.
146
+ */
147
+ addInstanceAllowedRule(rule) {
148
+ if (!this.instanceAllowedRules.includes(rule)) {
149
+ this.instanceAllowedRules.push(rule);
150
+ }
151
+ }
143
152
  /**
144
153
  * Get all additional directories
145
154
  */
@@ -676,7 +685,14 @@ export class PermissionManager {
676
685
  .replace(/\*/g, ".*"); // Replace * with .*
677
686
  const regex = new RegExp(`^${regexPattern}$`, "s");
678
687
  const matched = regex.test(processedPart);
679
- return matched;
688
+ if (matched)
689
+ return true;
690
+ // Leading git global scope flags (e.g. `git -C <path>`) only change the
691
+ // target repository, not the subcommand being run, so rules like
692
+ // Bash(git status*) also cover `git -C <path> status`. The raw command
693
+ // is checked first so path-specific rules (e.g. deny rules on
694
+ // `git -C /secret status`) still match.
695
+ return regex.test(stripGitScopePrefix(processedPart));
680
696
  }
681
697
  // Handle path-based rules (e.g., "Read(**/*.env)")
682
698
  const pathTools = [READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME];
@@ -6,10 +6,19 @@ export interface PluginManagerOptions {
6
6
  }
7
7
  export declare class PluginManager {
8
8
  private container;
9
+ /**
10
+ * Read-only helper scripts shipped by builtin plugins that the agent runs via
11
+ * Bash. When such a plugin is enabled, its rules are registered as instance
12
+ * level allow rules (in-memory only, never persisted) so the scripts run
13
+ * without a permission prompt. Wildcards keep the rules valid across install
14
+ * locations; each rule anchors on the script filename.
15
+ */
16
+ private static readonly BUILTIN_PLUGIN_ALLOW_RULES;
9
17
  private plugins;
10
18
  private workdir;
11
19
  private enabledPlugins;
12
20
  constructor(container: Container, options: PluginManagerOptions);
21
+ private get permissionManager();
13
22
  private get skillManager();
14
23
  private get hookManager();
15
24
  private get lspManager();
@@ -11,6 +11,9 @@ export class PluginManager {
11
11
  this.workdir = options.workdir;
12
12
  this.enabledPlugins = options.enabledPlugins || {};
13
13
  }
14
+ get permissionManager() {
15
+ return this.container.get("PermissionManager");
16
+ }
14
17
  get skillManager() {
15
18
  return this.container.get("SkillManager");
16
19
  }
@@ -221,6 +224,13 @@ export class PluginManager {
221
224
  if (this.enabledPlugins[`${entry.name}@builtin`] !== true)
222
225
  continue;
223
226
  await this.loadSinglePlugin(path.join(builtinDir, entry.name));
227
+ // Register allow rules only after the plugin actually loaded, so a
228
+ // failed load never leaves permission grants behind.
229
+ if (this.plugins.has(entry.name)) {
230
+ for (const rule of PluginManager.BUILTIN_PLUGIN_ALLOW_RULES[entry.name] || []) {
231
+ this.permissionManager?.addInstanceAllowedRule(rule);
232
+ }
233
+ }
224
234
  }
225
235
  }
226
236
  catch (error) {
@@ -240,3 +250,13 @@ export class PluginManager {
240
250
  return this.plugins.get(name);
241
251
  }
242
252
  }
253
+ /**
254
+ * Read-only helper scripts shipped by builtin plugins that the agent runs via
255
+ * Bash. When such a plugin is enabled, its rules are registered as instance
256
+ * level allow rules (in-memory only, never persisted) so the scripts run
257
+ * without a permission prompt. Wildcards keep the rules valid across install
258
+ * locations; each rule anchors on the script filename.
259
+ */
260
+ PluginManager.BUILTIN_PLUGIN_ALLOW_RULES = {
261
+ sdd: ["Bash(node *spec-count.js*)"],
262
+ };
@@ -66,6 +66,19 @@ export declare class SkillManager extends EventEmitter {
66
66
  * Find all directories that could contain skills
67
67
  */
68
68
  private findSkillDirectories;
69
+ /**
70
+ * Whether a discovered skill is a feature-gated builtin skill whose gate
71
+ * is currently off. Mirrors ToolManager's gate for the Artifact tool so
72
+ * the /artifact skill command stays in sync with enableArtifact.
73
+ */
74
+ private isSkillGatedOff;
75
+ /**
76
+ * Re-evaluate feature-gated builtin skills after a live configuration
77
+ * reload (same hook as ToolManager.reloadFeatureGatedTools). Re-scans
78
+ * skill directories and emits "refreshed" so slash-command registration
79
+ * follows the gate.
80
+ */
81
+ reloadFeatureGatedSkills(): Promise<void>;
69
82
  /**
70
83
  * Execute a skill by name
71
84
  */
@@ -7,6 +7,7 @@ import { parseSkillFile, formatSkillError } from "../utils/skillParser.js";
7
7
  import { substituteCommandParameters } from "../utils/commandArgumentParser.js";
8
8
  import { parseBashCommands, replaceBashCommandsWithOutput, executeBashCommands, } from "../utils/markdownParser.js";
9
9
  import { getBuiltinSkillsDir } from "../utils/configPaths.js";
10
+ import { isArtifactEnabled } from "../services/artifactAvailability.js";
10
11
  import { logger } from "../utils/globalLogger.js";
11
12
  /**
12
13
  * Manages skill discovery and loading
@@ -247,6 +248,12 @@ export class SkillManager extends EventEmitter {
247
248
  ...parsed.skillMetadata,
248
249
  type: collection.type,
249
250
  };
251
+ // Feature-gated builtin skills (e.g. artifact behind
252
+ // enableArtifact) are skipped while the gate is off, mirroring
253
+ // ToolManager's registration gate for the Artifact tool.
254
+ if (this.isSkillGatedOff(skillMetadata)) {
255
+ continue;
256
+ }
250
257
  // Create full skill object with content
251
258
  const skill = {
252
259
  ...skillMetadata,
@@ -304,6 +311,29 @@ export class SkillManager extends EventEmitter {
304
311
  }
305
312
  return directories;
306
313
  }
314
+ /**
315
+ * Whether a discovered skill is a feature-gated builtin skill whose gate
316
+ * is currently off. Mirrors ToolManager's gate for the Artifact tool so
317
+ * the /artifact skill command stays in sync with enableArtifact.
318
+ */
319
+ isSkillGatedOff(metadata) {
320
+ if (metadata.type !== "builtin") {
321
+ return false;
322
+ }
323
+ if (metadata.name === "artifact") {
324
+ return !isArtifactEnabled(this.workdir);
325
+ }
326
+ return false;
327
+ }
328
+ /**
329
+ * Re-evaluate feature-gated builtin skills after a live configuration
330
+ * reload (same hook as ToolManager.reloadFeatureGatedTools). Re-scans
331
+ * skill directories and emits "refreshed" so slash-command registration
332
+ * follows the gate.
333
+ */
334
+ async reloadFeatureGatedSkills() {
335
+ await this.refreshSkills();
336
+ }
307
337
  /**
308
338
  * Execute a skill by name
309
339
  */
@@ -87,6 +87,18 @@ export declare class SubagentManager {
87
87
  * Load all available subagent configurations and cache them
88
88
  */
89
89
  loadConfigurations(): Promise<SubagentConfiguration[]>;
90
+ /**
91
+ * Rebuild the cached subagent configurations. Called after settings.json
92
+ * env becomes available (post loadMergedConfiguration) so conditional
93
+ * builtin subagents (e.g. `model: visionModel` requiring WAVE_VISION_MODEL)
94
+ * register correctly. Plugin agents are preserved across the rebuild.
95
+ */
96
+ refreshConfigurations(): Promise<SubagentConfiguration[]>;
97
+ /**
98
+ * Get the merged environment (OS env overlaid with settings.json env) used
99
+ * for conditional subagent registration (e.g. WAVE_VISION_MODEL).
100
+ */
101
+ private getMergedEnv;
90
102
  /**
91
103
  * Get cached configurations synchronously (must call loadConfigurations first)
92
104
  */
@@ -70,10 +70,40 @@ export class SubagentManager {
70
70
  async loadConfigurations() {
71
71
  if (this.cachedConfigurations === null) {
72
72
  const { loadSubagentConfigurations } = await import("../utils/subagentParser.js");
73
- this.cachedConfigurations = await loadSubagentConfigurations(this.workdir);
73
+ this.cachedConfigurations = await loadSubagentConfigurations(this.workdir, this.getMergedEnv());
74
74
  }
75
75
  return this.cachedConfigurations;
76
76
  }
77
+ /**
78
+ * Rebuild the cached subagent configurations. Called after settings.json
79
+ * env becomes available (post loadMergedConfiguration) so conditional
80
+ * builtin subagents (e.g. `model: visionModel` requiring WAVE_VISION_MODEL)
81
+ * register correctly. Plugin agents are preserved across the rebuild.
82
+ */
83
+ async refreshConfigurations() {
84
+ // Preserve plugin agents (namespaced `pluginName:agentName`) across the rebuild
85
+ const pluginAgents = (this.cachedConfigurations ?? []).filter((config) => config.name.includes(":"));
86
+ this.cachedConfigurations = null;
87
+ await this.loadConfigurations();
88
+ for (const agent of pluginAgents) {
89
+ this.cachedConfigurations.push(agent);
90
+ }
91
+ // Re-sort by priority then name (matches registerPluginAgents)
92
+ this.cachedConfigurations.sort((a, b) => {
93
+ if (a.priority !== b.priority)
94
+ return a.priority - b.priority;
95
+ return a.name.localeCompare(b.name);
96
+ });
97
+ return this.cachedConfigurations;
98
+ }
99
+ /**
100
+ * Get the merged environment (OS env overlaid with settings.json env) used
101
+ * for conditional subagent registration (e.g. WAVE_VISION_MODEL).
102
+ */
103
+ getMergedEnv() {
104
+ return (this.configurationService?.getMergedEnv?.() ??
105
+ process.env);
106
+ }
77
107
  /**
78
108
  * Get cached configurations synchronously (must call loadConfigurations first)
79
109
  */
@@ -95,7 +125,7 @@ export class SubagentManager {
95
125
  }
96
126
  // Fall back to filesystem scan for non-plugin agents
97
127
  const { findSubagentByName } = await import("../utils/subagentParser.js");
98
- return findSubagentByName(name, this.workdir);
128
+ return findSubagentByName(name, this.workdir, this.getMergedEnv());
99
129
  }
100
130
  /**
101
131
  * Register plugin agents into the cached configurations.
@@ -53,6 +53,14 @@ declare class ToolManager {
53
53
  * ```
54
54
  */
55
55
  initializeBuiltInTools(): void;
56
+ /**
57
+ * Re-evaluate feature-gated built-in tools after a live configuration
58
+ * reload. Currently gates the Artifact tool on settings.json
59
+ * `enableArtifact`. Safe to call multiple times: gated tools are removed
60
+ * from the registry first, then initializeBuiltInTools() re-registers them
61
+ * only if still enabled (so toggling the flag off actually unregisters).
62
+ */
63
+ reloadFeatureGatedTools(): void;
56
64
  /**
57
65
  * Check if a tool should be enabled based on tools configuration and permission rules
58
66
  */
@@ -9,6 +9,8 @@ import { cronCreateTool } from "../tools/cronCreateTool.js";
9
9
  import { cronDeleteTool } from "../tools/cronDeleteTool.js";
10
10
  import { cronListTool } from "../tools/cronListTool.js";
11
11
  import { webFetchTool } from "../tools/webFetchTool.js";
12
+ import { artifactTool } from "../tools/artifactTool.js";
13
+ import { isArtifactEnabled } from "../services/artifactAvailability.js";
12
14
  // New tools
13
15
  import { globTool } from "../tools/globTool.js";
14
16
  import { grepTool } from "../tools/grepTool.js";
@@ -95,6 +97,11 @@ class ToolManager {
95
97
  exitWorktreeTool,
96
98
  workflowTool,
97
99
  ];
100
+ // Artifact is a feature-gated tool: not registered at all while the frame
101
+ // backend is not live, unless settings.json opts in via enableArtifact: true.
102
+ if (isArtifactEnabled(this.container.get("Workdir"))) {
103
+ builtInTools.push(artifactTool);
104
+ }
98
105
  for (const tool of builtInTools) {
99
106
  if (this.shouldEnableTool(tool.name)) {
100
107
  this.toolsRegistry.set(tool.name, tool);
@@ -107,6 +114,17 @@ class ToolManager {
107
114
  }
108
115
  }
109
116
  }
117
+ /**
118
+ * Re-evaluate feature-gated built-in tools after a live configuration
119
+ * reload. Currently gates the Artifact tool on settings.json
120
+ * `enableArtifact`. Safe to call multiple times: gated tools are removed
121
+ * from the registry first, then initializeBuiltInTools() re-registers them
122
+ * only if still enabled (so toggling the flag off actually unregisters).
123
+ */
124
+ reloadFeatureGatedTools() {
125
+ this.toolsRegistry.delete(artifactTool.name);
126
+ this.initializeBuiltInTools();
127
+ }
110
128
  /**
111
129
  * Check if a tool should be enabled based on tools configuration and permission rules
112
130
  */