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
@@ -6,6 +6,7 @@ import { logger } from "../utils/globalLogger.js";
6
6
  import { resolveShellPath } from "../utils/shellResolver.js";
7
7
  import { toPosixPath } from "../utils/path.js";
8
8
  import { stripAnsiColors } from "../utils/stringUtils.js";
9
+ import { WindowsStreamDecoder } from "../utils/encoding.js";
9
10
  import { processToolResult } from "../utils/toolResultStorage.js";
10
11
  import { BASH_MAX_OUTPUT_CHARS } from "../constants/toolLimits.js";
11
12
  import { BASH_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME, READ_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, } from "../constants/tools.js";
@@ -219,17 +220,14 @@ The working directory persists between commands. Try to maintain your current wo
219
220
  const { id: taskId } = backgroundTaskManager.startShell(command, undefined, context.workdir);
220
221
  const task = backgroundTaskManager.getTask(taskId);
221
222
  const outputPath = task?.outputPath;
222
- const backgroundMsg = [
223
- `Command started in background with ID: ${taskId}.`,
224
- `You will be notified automatically when it completes.`,
225
- outputPath
226
- ? `output_file: ${outputPath}`
227
- : `Use ${READ_TOOL_NAME} tool with task_id="${taskId}" to read the output.`,
228
- ].join("\n");
223
+ const backgroundMsg = outputPath
224
+ ? `Command running in background with ID: ${taskId}. Output is being written to: ${outputPath}`
225
+ : `Command running in background with ID: ${taskId}. Use ${READ_TOOL_NAME} tool with task_id="${taskId}" to read the output.`;
229
226
  return {
230
227
  success: true,
231
228
  content: recoveryNotice + backgroundMsg,
232
229
  shortResult: `Background process ${taskId} started${outputPath ? ` → ${outputPath}` : ""}`,
230
+ backgroundTaskId: taskId,
233
231
  };
234
232
  }
235
233
  // Foreground execution (original behavior)
@@ -253,6 +251,10 @@ The working directory persists between commands. Try to maintain your current wo
253
251
  let isAborted = false;
254
252
  let isBackgrounded = false;
255
253
  let isFinished = false;
254
+ // On Windows, native tools (taskkill, powershell, ...) write GBK (cp936)
255
+ // instead of UTF-8; decode their byte streams accordingly (issue #1753).
256
+ const stdoutDecoder = process.platform === "win32" ? new WindowsStreamDecoder() : null;
257
+ const stderrDecoder = process.platform === "win32" ? new WindowsStreamDecoder() : null;
256
258
  // Best-effort cleanup of the temp CWD file — used by abort/error/exit paths
257
259
  const cleanupTempFile = () => {
258
260
  try {
@@ -303,9 +305,10 @@ The working directory persists between commands. Try to maintain your current wo
303
305
  const outputPath = task?.outputPath;
304
306
  resolve({
305
307
  success: true,
306
- content: `Command moved to background with ID: ${taskId}.${outputPath ? ` Real-time output: ${outputPath}` : ""}`,
308
+ content: `Command was manually backgrounded by user with ID: ${taskId}.${outputPath ? ` Output is being written to: ${outputPath}` : ""}`,
307
309
  shortResult: `Process ${taskId} backgrounded`,
308
- isManuallyBackgrounded: true,
310
+ backgroundedByUser: true,
311
+ backgroundTaskId: taskId,
309
312
  });
310
313
  }
311
314
  else {
@@ -337,8 +340,10 @@ The working directory persists between commands. Try to maintain your current wo
337
340
  logger.info(`[Bash] Command timed out after ${timeout}ms, auto-backgrounded as ${taskId}`);
338
341
  resolve({
339
342
  success: true,
340
- content: `Command timed out after ${timeout / 1000} seconds and was moved to background with ID: ${taskId}.${outputPath ? ` Real-time output: ${outputPath}` : ""}`,
341
- shortResult: `Process ${taskId} auto-backgrounded (timeout)`,
343
+ content: `Command exceeded the timeout (${timeout / 1000}s) and was moved to the background with ID: ${taskId}. It is still running — you will be notified when it completes.${outputPath ? ` Output is being written to: ${outputPath}` : ""}`,
344
+ shortResult: `Process ${taskId} auto-backgrounded`,
345
+ assistantAutoBackgrounded: true,
346
+ backgroundTaskId: taskId,
342
347
  });
343
348
  }
344
349
  else {
@@ -416,14 +421,14 @@ The working directory persists between commands. Try to maintain your current wo
416
421
  }
417
422
  child.stdout?.on("data", (data) => {
418
423
  if (!isAborted && !isBackgrounded && !runInBackground) {
419
- const chunk = stripAnsiColors(data.toString());
424
+ const chunk = stripAnsiColors(stdoutDecoder ? stdoutDecoder.push(data) : data.toString());
420
425
  outputBuffer += chunk;
421
426
  updateRealtimeResults();
422
427
  }
423
428
  });
424
429
  child.stderr?.on("data", (data) => {
425
430
  if (!isAborted && !isBackgrounded && !runInBackground) {
426
- const chunk = stripAnsiColors(data.toString());
431
+ const chunk = stripAnsiColors(stderrDecoder ? stderrDecoder.push(data) : data.toString());
427
432
  errorBuffer += chunk;
428
433
  updateRealtimeResults();
429
434
  }
@@ -467,6 +472,12 @@ The working directory persists between commands. Try to maintain your current wo
467
472
  }
468
473
  }
469
474
  const exitCode = code ?? 0;
475
+ // Decode any bytes still held at stream end (e.g. a trailing UTF-8
476
+ // character split across the last chunk).
477
+ if (stdoutDecoder)
478
+ outputBuffer += stdoutDecoder.flush();
479
+ if (stderrDecoder)
480
+ errorBuffer += stderrDecoder.flush();
470
481
  const combinedOutput = outputBuffer + (errorBuffer ? "\n" + errorBuffer : "");
471
482
  // Prepend CWD change message to output if present
472
483
  const finalOutput = recoveryNotice +
@@ -168,7 +168,11 @@ Usage:
168
168
  if (replaceAll) {
169
169
  // Replace all matches
170
170
  const regex = new RegExp(escapeRegExp(matchedOldString), "g");
171
- newContent = normalizedContent.replace(regex, newString);
171
+ // Function replacer (claude-code's applyEditToFile approach): newString
172
+ // is inserted literally, never parsed as a $ replacement template. A
173
+ // string replacer would expand $& to the matched text, $$ to a single
174
+ // $, and `$` could even truncate and duplicate the file (issue #1752).
175
+ newContent = normalizedContent.replace(regex, () => newString);
172
176
  replacementCount = (normalizedContent.match(regex) || []).length;
173
177
  }
174
178
  else {
@@ -181,7 +185,8 @@ Usage:
181
185
  error: `old_string appears ${matches} times in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all=true to change every instance.`,
182
186
  };
183
187
  }
184
- newContent = normalizedContent.replace(matchedOldString, newString);
188
+ // Function replacer: see note above — $ in newString stays literal
189
+ newContent = normalizedContent.replace(matchedOldString, () => newString);
185
190
  replacementCount = 1;
186
191
  }
187
192
  // Permission check after validation but before real operation
@@ -37,7 +37,9 @@ export interface ToolResult {
37
37
  data: string;
38
38
  mediaType?: string;
39
39
  }>;
40
- isManuallyBackgrounded?: boolean;
40
+ backgroundTaskId?: string;
41
+ backgroundedByUser?: boolean;
42
+ assistantAutoBackgrounded?: boolean;
41
43
  metadata?: Record<string, unknown>;
42
44
  }
43
45
  export interface ToolContext {
@@ -2,12 +2,18 @@ import TurndownService from "turndown";
2
2
  import { LRUCache } from "lru-cache";
3
3
  import { WEB_FETCH_TOOL_NAME } from "../constants/tools.js";
4
4
  import { logger } from "../utils/globalLogger.js";
5
+ import { isArtifactEnabled } from "../services/artifactAvailability.js";
6
+ import { authService, createAuthAwareFetch } from "../services/authService.js";
7
+ import { recordVersion } from "../services/artifactSession.js";
8
+ import { buildPersistedOutputMessage, generatePreview, persistToolResult, } from "../utils/toolResultStorage.js";
5
9
  // --- Security Limits ---
6
10
  const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024; // 10MB
7
11
  const FETCH_TIMEOUT_MS = 60000; // 60s
8
12
  const MAX_REDIRECTS = 10;
9
13
  const MAX_MARKDOWN_LENGTH = 100000;
10
14
  const USER_AGENT = "Wave-User (+https://github.com/netease-lcap/wave-agent)";
15
+ /** Artifact HTML beyond this size is persisted to a temp file (path + head preview). */
16
+ const ARTIFACT_PREVIEW_BYTES = 2 * 1024; // ~2KB
11
17
  // --- Cache (LRU with 15min TTL, 50MB max) ---
12
18
  const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
13
19
  const CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50MB
@@ -73,6 +79,124 @@ function isPermittedRedirect(originalUrl, redirectUrl) {
73
79
  return false;
74
80
  }
75
81
  }
82
+ // --- Artifact read channel ---
83
+ /** Extract the artifact slug from a `{host}/code/artifact/{slug}` URL. */
84
+ function extractArtifactSlug(url) {
85
+ try {
86
+ const parsed = new URL(url);
87
+ const match = parsed.pathname.match(/^\/code\/artifact\/([^/]+)\/?$/);
88
+ return match ? decodeURIComponent(match[1]) : null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /**
95
+ * Fetch artifact metadata (`via=model_read`) then the HTML content (Bearer).
96
+ * Returns markdown (HTML converted via turndown) on success.
97
+ */
98
+ async function readArtifact(url, slug, abortSignal) {
99
+ const serverUrl = authService.getServerUrl();
100
+ const authFetch = createAuthAwareFetch(globalThis.fetch);
101
+ const signal = abortSignal
102
+ ? AbortSignal.any([abortSignal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
103
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS);
104
+ let meta;
105
+ try {
106
+ const metaRes = await authFetch(`${serverUrl}/api/frame/${encodeURIComponent(slug)}?via=model_read`, { method: "GET", signal });
107
+ if (metaRes.status === 404) {
108
+ return {
109
+ kind: "error",
110
+ error: `Artifact not found: ${url} (it may have been deleted)`,
111
+ };
112
+ }
113
+ if (!metaRes.ok) {
114
+ return {
115
+ kind: "error",
116
+ error: `Failed to fetch artifact metadata: ${metaRes.status} ${metaRes.statusText}`,
117
+ };
118
+ }
119
+ meta = (await metaRes.json());
120
+ }
121
+ catch (error) {
122
+ logger?.warn("Artifact read metadata failed", {
123
+ slug,
124
+ error: String(error),
125
+ });
126
+ return {
127
+ kind: "error",
128
+ error: `Failed to fetch artifact metadata: ${error instanceof Error ? error.message : String(error)}`,
129
+ };
130
+ }
131
+ const contentUrl = typeof meta.contentUrl === "string" ? meta.contentUrl : "";
132
+ const version = typeof meta.version === "string" ? meta.version : "";
133
+ if (!contentUrl) {
134
+ return {
135
+ kind: "error",
136
+ error: "Artifact metadata did not include a contentUrl",
137
+ };
138
+ }
139
+ let html;
140
+ try {
141
+ const contentRes = await authFetch(new URL(contentUrl, serverUrl).toString(), { method: "GET", signal });
142
+ if (!contentRes.ok) {
143
+ return {
144
+ kind: "error",
145
+ error: `Failed to fetch artifact content: ${contentRes.status} ${contentRes.statusText}`,
146
+ };
147
+ }
148
+ html = await contentRes.text();
149
+ }
150
+ catch (error) {
151
+ logger?.warn("Artifact read content failed", {
152
+ slug,
153
+ error: String(error),
154
+ });
155
+ return {
156
+ kind: "error",
157
+ error: `Failed to fetch artifact content: ${error instanceof Error ? error.message : String(error)}`,
158
+ };
159
+ }
160
+ const turndownService = new TurndownService();
161
+ const markdown = turndownService.turndown(html);
162
+ return {
163
+ kind: "ok",
164
+ slug,
165
+ version,
166
+ markdown,
167
+ bytes: new TextEncoder().encode(markdown).length,
168
+ };
169
+ }
170
+ /**
171
+ * Process an artifact read result: persist content >~2KB to a temp file
172
+ * (return file path + head preview), run the prompt, attach artifactRead metadata.
173
+ */
174
+ async function processArtifactRead(url, prompt, markdown, slug, version, context) {
175
+ const bytes = new TextEncoder().encode(markdown).length;
176
+ let aiInput = markdown;
177
+ let persistedMessage = "";
178
+ if (bytes > ARTIFACT_PREVIEW_BYTES) {
179
+ const filePath = persistToolResult(markdown, "artifact");
180
+ if (filePath) {
181
+ persistedMessage = buildPersistedOutputMessage(markdown.length, filePath, generatePreview(markdown));
182
+ aiInput = persistedMessage;
183
+ }
184
+ else {
185
+ aiInput =
186
+ markdown.substring(0, MAX_MARKDOWN_LENGTH) +
187
+ "\n\n... (content truncated, failed to persist full output)";
188
+ }
189
+ }
190
+ const result = await processWithAI(url, prompt, aiInput, 200, "OK", context, bytes);
191
+ result.metadata = { artifactRead: { slug, ver: version } };
192
+ if (persistedMessage) {
193
+ // Append the persisted-output message so the model can Read the full content.
194
+ result.content = result.content
195
+ ? result.content + "\n\n" + persistedMessage
196
+ : persistedMessage;
197
+ }
198
+ return result;
199
+ }
76
200
  // --- Tool ---
77
201
  export const webFetchTool = {
78
202
  name: WEB_FETCH_TOOL_NAME,
@@ -141,6 +265,23 @@ Usage notes:
141
265
  error: validation.error,
142
266
  };
143
267
  }
268
+ // Artifact URL interception: {host}/code/artifact/{slug} goes through the
269
+ // dedicated read channel (via=model_read + Bearer) instead of a public fetch.
270
+ // Only when the Artifact tool is enabled (spec 6.4: disabled = no interception).
271
+ if (isArtifactEnabled(context.workdir)) {
272
+ const artifactSlug = extractArtifactSlug(url);
273
+ if (artifactSlug) {
274
+ const readResult = await readArtifact(url, artifactSlug, context.abortSignal);
275
+ if (readResult.kind === "error") {
276
+ return { success: false, content: "", error: readResult.error };
277
+ }
278
+ if (context.sessionId && readResult.version) {
279
+ // Keep the stale-version guard in sync with what the model has seen.
280
+ recordVersion(context.sessionId, artifactSlug, readResult.version);
281
+ }
282
+ return processArtifactRead(url, prompt, readResult.markdown, artifactSlug, readResult.version, context);
283
+ }
284
+ }
144
285
  try {
145
286
  const cached = cache.get(url);
146
287
  if (cached) {
@@ -25,6 +25,8 @@ export interface AgentOptions {
25
25
  fetch?: ClientOptions["fetch"];
26
26
  model?: string;
27
27
  fastModel?: string;
28
+ /** Vision-capable model used by the builtin vision subagent (resolved from WAVE_VISION_MODEL env var). */
29
+ visionModel?: string;
28
30
  maxInputTokens?: number;
29
31
  maxTokens?: number;
30
32
  /** Preferred language for agent communication */
@@ -22,6 +22,8 @@ export interface ModelCapabilities {
22
22
  export interface ModelConfig {
23
23
  model?: string;
24
24
  fastModel?: string;
25
+ /** Vision-capable model for image recognition subagents (resolved from WAVE_VISION_MODEL env var). */
26
+ visionModel?: string;
25
27
  maxTokens?: number;
26
28
  permissionMode?: PermissionMode;
27
29
  capabilities?: ModelCapabilities;
@@ -55,6 +55,8 @@ export interface WaveConfiguration {
55
55
  /** Base ref for new worktrees: "fresh" (origin/<default-branch>, default) | "head" (local HEAD) */
56
56
  baseRef?: "fresh" | "head";
57
57
  };
58
+ /** Whether the Artifact tool is enabled. Unset follows the code default constant (ARTIFACT_DEFAULT_ENABLED). */
59
+ enableArtifact?: boolean;
58
60
  }
59
61
  /**
60
62
  * Legacy alias for backward compatibility - will be deprecated
@@ -52,7 +52,9 @@ export interface ToolBlock {
52
52
  error?: string | Error;
53
53
  compactParams?: string;
54
54
  parametersChunk?: string;
55
- isManuallyBackgrounded?: boolean;
55
+ backgroundTaskId?: string;
56
+ backgroundedByUser?: boolean;
57
+ assistantAutoBackgrounded?: boolean;
56
58
  timestamp?: number;
57
59
  }
58
60
  export interface ImageBlock {
@@ -36,9 +36,11 @@ export interface ToolPermissionContext {
36
36
  toolCallId?: string;
37
37
  /** The content of the plan being exited from */
38
38
  planContent?: string;
39
+ /** Optional warning line to surface in the confirmation UI (e.g. shared-live redeploy impact) */
40
+ warning?: string;
39
41
  }
40
42
  /** List of tools that require permission checks in default mode */
41
- export declare const RESTRICTED_TOOLS: readonly ["Edit", "Bash", "Write", "EnterPlanMode", "ExitPlanMode", "AskUserQuestion"];
43
+ export declare const RESTRICTED_TOOLS: readonly ["Edit", "Bash", "Write", "EnterPlanMode", "ExitPlanMode", "AskUserQuestion", "Artifact"];
42
44
  /** Type for restricted tool names */
43
45
  export type RestrictedTool = (typeof RESTRICTED_TOOLS)[number];
44
46
  export declare const OPERATION_CANCELLED_BY_USER = "Operation cancelled by user";
@@ -2,7 +2,7 @@
2
2
  * Permission system types for Wave Agent SDK
3
3
  * Dependencies: None
4
4
  */
5
- import { EDIT_TOOL_NAME, BASH_TOOL_NAME, WRITE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "../constants/tools.js";
5
+ import { EDIT_TOOL_NAME, BASH_TOOL_NAME, WRITE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, ARTIFACT_TOOL_NAME, } from "../constants/tools.js";
6
6
  /** List of tools that require permission checks in default mode */
7
7
  export const RESTRICTED_TOOLS = [
8
8
  EDIT_TOOL_NAME,
@@ -11,5 +11,6 @@ export const RESTRICTED_TOOLS = [
11
11
  ENTER_PLAN_MODE_TOOL_NAME,
12
12
  EXIT_PLAN_MODE_TOOL_NAME,
13
13
  ASK_USER_QUESTION_TOOL_NAME,
14
+ ARTIFACT_TOOL_NAME,
14
15
  ];
15
16
  export const OPERATION_CANCELLED_BY_USER = "Operation cancelled by user";
@@ -46,6 +46,12 @@ export interface ToolRule {
46
46
  depth: number;
47
47
  scopeFlags?: string[];
48
48
  }
49
+ /**
50
+ * Global scope flags for git that only change the target repository or
51
+ * configuration, not the subcommand being run. Shared by TOOL_RULES (smart
52
+ * prefix extraction) and stripGitScopePrefix (rule matching).
53
+ */
54
+ export declare const GIT_SCOPE_FLAGS: string[];
49
55
  export declare const TOOL_RULES: Record<string, ToolRule>;
50
56
  /**
51
57
  * Registry of dangerous subcommands for specific tools.
@@ -71,6 +77,14 @@ export declare function hasProcessSubstitution(command: string): boolean;
71
77
  * sed -i modifies files in place and must NOT be auto-allowed. FR-019.5.
72
78
  */
73
79
  export declare function hasSedInPlace(command: string): boolean;
80
+ /**
81
+ * Removes leading git global scope flags (e.g. `git -C <path>`, `git -c <key>=<value>`,
82
+ * `git --git-dir <path>`) from a command string, so that `git -C /tmp/foo status` is
83
+ * classified the same as `git status`. Only the leading sequence before the git
84
+ * subcommand is stripped; the remainder is re-joined with single spaces.
85
+ * Returns the input unchanged when nothing is stripped.
86
+ */
87
+ export declare function stripGitScopePrefix(command: string): string;
74
88
  /**
75
89
  * Extracts a "smart prefix" from a bash command based on common developer tools.
76
90
  * Returns null if the command is blacklisted or cannot be safely prefix-matched.
@@ -500,6 +500,18 @@ export const READ_ONLY_COMMANDS = [
500
500
  "bc",
501
501
  "sleep",
502
502
  ];
503
+ /**
504
+ * Global scope flags for git that only change the target repository or
505
+ * configuration, not the subcommand being run. Shared by TOOL_RULES (smart
506
+ * prefix extraction) and stripGitScopePrefix (rule matching).
507
+ */
508
+ export const GIT_SCOPE_FLAGS = [
509
+ "-C",
510
+ "-c",
511
+ "--directory",
512
+ "--work-tree",
513
+ "--git-dir",
514
+ ];
503
515
  export const TOOL_RULES = {
504
516
  // Node/JS
505
517
  npm: { depth: 2, scopeFlags: ["--prefix", "-C", "--registry"] },
@@ -517,7 +529,7 @@ export const TOOL_RULES = {
517
529
  // Git
518
530
  git: {
519
531
  depth: 2,
520
- scopeFlags: ["-C", "-c", "--directory", "--work-tree", "--git-dir"],
532
+ scopeFlags: GIT_SCOPE_FLAGS,
521
533
  },
522
534
  // Python
523
535
  python: { depth: 2 },
@@ -670,6 +682,38 @@ export function hasSedInPlace(command) {
670
682
  return false;
671
683
  return tokens.some((token) => /^-i(\..*)?$/.test(token));
672
684
  }
685
+ /**
686
+ * Removes leading git global scope flags (e.g. `git -C <path>`, `git -c <key>=<value>`,
687
+ * `git --git-dir <path>`) from a command string, so that `git -C /tmp/foo status` is
688
+ * classified the same as `git status`. Only the leading sequence before the git
689
+ * subcommand is stripped; the remainder is re-joined with single spaces.
690
+ * Returns the input unchanged when nothing is stripped.
691
+ */
692
+ export function stripGitScopePrefix(command) {
693
+ const trimmed = command.trim();
694
+ if (!/^git(?:\s|$)/.test(trimmed))
695
+ return command;
696
+ const tokens = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
697
+ if (tokens.length === 0 || tokens[0] !== "git")
698
+ return command;
699
+ let i = 1;
700
+ while (i < tokens.length) {
701
+ const token = tokens[i];
702
+ const eqIndex = token.indexOf("=");
703
+ const flag = eqIndex > 0 ? token.slice(0, eqIndex) : token;
704
+ if (!GIT_SCOPE_FLAGS.includes(flag))
705
+ break;
706
+ if (eqIndex > 0) {
707
+ i++; // --flag=value form carries its own value
708
+ }
709
+ else {
710
+ i += 2; // skip the flag and its argument
711
+ }
712
+ }
713
+ if (i === 1)
714
+ return command;
715
+ return ["git", ...tokens.slice(i)].join(" ");
716
+ }
673
717
  /**
674
718
  * Extracts a "smart prefix" from a bash command based on common developer tools.
675
719
  * Returns null if the command is blacklisted or cannot be safely prefix-matched.
@@ -219,6 +219,14 @@ export function setupAgentContainer(setupOptions) {
219
219
  onReload: () => {
220
220
  const models = configurationService.getConfiguredModels();
221
221
  callbacks.onConfiguredModelsChange?.(models);
222
+ // Re-evaluate feature-gated tools (e.g. Artifact behind
223
+ // enableArtifact) so toggling the flag applies without a restart.
224
+ toolManager.reloadFeatureGatedTools();
225
+ // Same gate for the builtin /artifact skill: refresh emits "refreshed"
226
+ // so slash-command registration follows enableArtifact.
227
+ void skillManager.reloadFeatureGatedSkills().catch((error) => {
228
+ logger.error("Failed to reload feature-gated skills:", error);
229
+ });
222
230
  },
223
231
  });
224
232
  container.register("LiveConfigManager", liveConfigManager);
@@ -158,14 +158,38 @@ export function convertMessagesForAPI(messages, options) {
158
158
  tool_calls = undefined;
159
159
  }
160
160
  }
161
- // Construct assistant message - only add if there is meaningful content or tool calls
161
+ // Construct assistant message - only add if there is meaningful content,
162
+ // tool calls, or reasoning content. Reasoning-only messages (a truncated
163
+ // turn that produced nothing but thinking) must be preserved so the next
164
+ // round can continue from the previous reasoning instead of starting
165
+ // over (aligned with Claude Code's thinking trajectory preservation).
162
166
  const hasContent = content && content.trim().length > 0;
163
167
  const hasToolCalls = tool_calls && tool_calls.length > 0;
164
- if (hasContent || hasToolCalls) {
168
+ const hasReasoning = reasoning_content && reasoning_content.trim().length > 0;
169
+ if (hasContent || hasToolCalls || hasReasoning) {
170
+ // OpenAI-compatible upstreams reject assistant messages where neither
171
+ // content nor tool_calls is set — a reasoning_content-only message
172
+ // gets stripped and returns 400 "content or tool_calls must be set"
173
+ // (this is fine for Claude Code, whose thinking blocks ARE content).
174
+ // When a turn produced only thinking (interrupted/truncated
175
+ // mid-reasoning), the thinking stays on its native reasoning_content
176
+ // field (the channel reasoning models natively continue from), and
177
+ // content carries an explanatory note so the request stays valid and
178
+ // the model knows the thinking was cut off and auto-preserved.
179
+ // Tool calls are meaningful content of their own — a reasoning +
180
+ // tool-call turn is not truncated, so no note is injected.
181
+ const fallbackContent = hasContent
182
+ ? content
183
+ : hasReasoning && !hasToolCalls
184
+ ? "[Note: The reasoning above was cut off before completion. It was auto-preserved from an interrupted or truncated turn — continue from where it left off, or disregard it if no longer relevant.]"
185
+ : undefined;
165
186
  const assistantMessage = {
166
187
  role: "assistant",
167
- content: hasContent ? content : undefined,
188
+ content: fallbackContent,
168
189
  tool_calls,
190
+ // Sent whenever reasoning exists: alongside real text it is the
191
+ // turn's own thinking; in the fallback case it carries the
192
+ // preserved thinking itself (content only holds the note).
169
193
  ...(reasoning_content ? { reasoning_content } : {}),
170
194
  ...(message.additionalFields ? { ...message.additionalFields } : {}),
171
195
  };
@@ -196,12 +220,25 @@ export function convertMessagesForAPI(messages, options) {
196
220
  type: "text",
197
221
  text: "[User shared an image, but the current model does not support image recognition]",
198
222
  });
223
+ // Append source path metadata for local-file images so the main
224
+ // model can delegate recognition to a vision subagent (which
225
+ // reads the path with the Read tool). Inline dataURLs are
226
+ // skipped — only persisted paths are delegatable.
227
+ block.imageUrls.forEach((imageUrl) => {
228
+ if (!imageUrl.startsWith("data:image/")) {
229
+ contentParts.push({
230
+ type: "text",
231
+ text: `[Image source: ${imageUrl}]`,
232
+ });
233
+ }
234
+ });
199
235
  }
200
236
  else {
201
237
  block.imageUrls.forEach((imageUrl) => {
202
238
  // Check if it's already base64, convert if not
239
+ const isDataUrl = imageUrl.startsWith("data:image/");
203
240
  let finalImageUrl = imageUrl;
204
- if (!imageUrl.startsWith("data:image/")) {
241
+ if (!isDataUrl) {
205
242
  // If it's a file path, it needs to be converted to base64
206
243
  try {
207
244
  finalImageUrl = convertImageToBase64(imageUrl);
@@ -219,6 +256,16 @@ export function convertMessagesForAPI(messages, options) {
219
256
  detail: "auto",
220
257
  },
221
258
  });
259
+ // Aligned with Claude Code: when the image comes from a local
260
+ // file (not an inline dataURL), append its source path as text
261
+ // metadata so the model can reference the file with tools
262
+ // (e.g. a vision-model subagent reading the image).
263
+ if (!isDataUrl) {
264
+ contentParts.push({
265
+ type: "text",
266
+ text: `[Image source: ${imageUrl}]`,
267
+ });
268
+ }
222
269
  });
223
270
  }
224
271
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Streaming byte-to-text decoder for bash tool output on Windows.
3
+ *
4
+ * Git Bash (MSYS) tools emit UTF-8, but native Windows programs (taskkill,
5
+ * powershell, ping, ...) write the system OEM code page — GBK (cp936) on
6
+ * zh-CN systems. Node's default `data.toString()` decodes every stream as
7
+ * UTF-8, so GBK bytes turn into U+FFFD mojibake (issue #1753).
8
+ *
9
+ * Strategy (per-chunk buffering, decide-once):
10
+ * - Accumulate raw bytes; try strict UTF-8 (`fatal: true`) over everything
11
+ * buffered so far. If it decodes cleanly, emit the text.
12
+ * - If strict UTF-8 fails, hold back up to 3 trailing bytes (a UTF-8
13
+ * character split across chunk boundaries) and retry on the next chunk.
14
+ * - Once even the head is not valid UTF-8, the stream is GBK: re-decode all
15
+ * buffered bytes with GBK and switch to GBK for every subsequent chunk.
16
+ * - `flush()` decodes any leftover buffered bytes (leniently) at stream end.
17
+ */
18
+ export declare class WindowsStreamDecoder {
19
+ private pending;
20
+ private gbkDecoder;
21
+ /** Longest possible incomplete UTF-8 tail at a chunk boundary is 3 bytes. */
22
+ private static readonly MAX_UTF8_TRAIL_BYTES;
23
+ push(data: Buffer): string;
24
+ /** Decode any bytes still held at stream end (lenient UTF-8, else GBK). */
25
+ flush(): string;
26
+ }
27
+ /** Decode a single complete byte sequence (used for non-streaming reads). */
28
+ export declare function decodeBytes(buf: Buffer): string;