wave-agent-sdk 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type QueuedMessage } from "./managers/messageQueue.js";
2
2
  import { SlashCommand, CustomSlashCommand, AgentOptions } from "./types/index.js";
3
- import type { Message, McpServerStatus, GatewayConfig, ModelConfig, Usage, PermissionMode, ForegroundTask } from "./types/index.js";
3
+ import type { Message, McpServerStatus, GatewayConfig, ModelConfig, Usage, PermissionMode, ForegroundTask, SkillMetadata } from "./types/index.js";
4
4
  import type { WorktreeSession } from "./utils/worktreeSession.js";
5
5
  export declare class Agent {
6
6
  private messageManager;
@@ -422,6 +422,12 @@ export declare class Agent {
422
422
  * @returns The list of subagent configurations
423
423
  */
424
424
  getSubagentConfigurations(): import("./utils/subagentParser.js").SubagentConfiguration[];
425
+ /**
426
+ * Get all skill metadata visible in this session (builtin, personal,
427
+ * project, and plugin skills).
428
+ * @returns The list of skill metadata
429
+ */
430
+ getSkillMetadata(): SkillMetadata[];
425
431
  /**
426
432
  * Get currently active subagent instances (status active/initializing).
427
433
  * @returns The list of active subagent instances
package/dist/agent.js CHANGED
@@ -18,7 +18,7 @@ export class Agent {
18
18
  };
19
19
  }
20
20
  getModelConfig() {
21
- return this.configurationService.resolveModelConfig(undefined, undefined, undefined, this.getPermissionMode());
21
+ return this.configurationService.resolveModelConfig(undefined, undefined, this.getPermissionMode());
22
22
  }
23
23
  getMaxInputTokens() {
24
24
  return this.configurationService.resolveMaxInputTokens();
@@ -983,6 +983,14 @@ export class Agent {
983
983
  getSubagentConfigurations() {
984
984
  return this.subagentManager.getConfigurations();
985
985
  }
986
+ /**
987
+ * Get all skill metadata visible in this session (builtin, personal,
988
+ * project, and plugin skills).
989
+ * @returns The list of skill metadata
990
+ */
991
+ getSkillMetadata() {
992
+ return this.skillManager.getAvailableSkills();
993
+ }
986
994
  /**
987
995
  * Get currently active subagent instances (status active/initializing).
988
996
  * @returns The list of active subagent instances
@@ -29,7 +29,7 @@ Wave uses several environment variables to control its core functionality. Varia
29
29
  | \`WAVE_MODEL\` | The primary AI model to use for the agent. | \`gemini-3-flash\` |
30
30
  | \`WAVE_FAST_MODEL\` | The fast AI model to use for quick tasks. | \`gemini-2.5-flash\` |
31
31
  | \`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) |
32
- | \`WAVE_MAX_INPUT_TOKENS\` | Maximum number of input tokens allowed. | \`200000\` |
32
+ | \`WAVE_MAX_INPUT_TOKENS\` | Maximum number of input tokens allowed. Overridden per-model by \`models[<model>].maxInputTokens\`. | \`200000\` |
33
33
  | \`WAVE_MAX_OUTPUT_TOKENS\` | Maximum number of output tokens allowed. | \`32000\` |
34
34
  | \`WAVE_DISABLE_AUTO_MEMORY\` | Set to \`1\` or \`true\` to disable the auto-memory feature. | \`false\` |
35
35
  | \`WAVE_AUTO_MEMORY_FREQUENCY\` | Auto memory update frequency. \`1\` = every turn, \`2\` = every 2 turns, etc. | \`1\` |
@@ -585,12 +585,27 @@ You can define overrides for specific models in the \`models\` field. The key sh
585
585
  Generation parameters are nested under the \`options\` field within each model's configuration. Wave supports passing arbitrary parameters to the underlying AI provider. Common parameters include:
586
586
 
587
587
  - \`temperature\`: Controls randomness (0.0 to 2.0).
588
- - \`maxTokens\`: Maximum number of tokens to generate in the response.
588
+ - \`max_tokens\`: Maximum number of tokens to generate in the response.
589
589
  - \`reasoning_effort\`: (OpenAI specific) Controls the reasoning effort for models like \`o1\` and \`o3-mini\`. Values: \`low\`, \`medium\`, \`high\`.
590
590
  - \`thinking\`: (Claude specific) Configures the thinking/reasoning capabilities for Claude 3.7+ models.
591
591
  - \`type\`: \`"enabled"\` or \`"disabled"\`.
592
592
  - \`budget_tokens\`: Maximum tokens to use for thinking.
593
593
 
594
+ ## Per-Model Input Context Window
595
+
596
+ Set \`maxInputTokens\` at the top level of a model entry (not inside \`options\`) to define that model's input context window. It overrides the global \`WAVE_MAX_INPUT_TOKENS\`, so compaction thresholds and usage display follow each model's own value:
597
+
598
+ \`\`\`json
599
+ {
600
+ "models": {
601
+ "deepseek-v4-flash": { "maxInputTokens": 200000 },
602
+ "kimi-k3": { "maxInputTokens": 131072 }
603
+ }
604
+ }
605
+ \`\`\`
606
+
607
+ Resolution priority: constructor > \`options\` > \`models[<model>].maxInputTokens\` > \`WAVE_MAX_INPUT_TOKENS\` > default. Subagents (\`fastModel\`/\`visionModel\`) resolve against the model they actually use.
608
+
594
609
  ## Model Capabilities
595
610
 
596
611
  Wave needs to know whether a model supports certain features. Instead of guessing from the model name, you declare these explicitly via the \`capabilities\` field. Note that \`capabilities\` is set at the top level of the model configuration, **not** inside the \`options\` field — \`options\` is reserved for generation parameters only.
@@ -133,7 +133,19 @@ export declare class AIManager {
133
133
  setIsLoading(isLoading: boolean): void;
134
134
  abortAIMessage(): void;
135
135
  private generateCompactParams;
136
- private handleTokenUsageAndCompaction;
136
+ private updateLatestTotalTokens;
137
+ /**
138
+ * Pre-request auto-compaction check (aligned with Claude Code's
139
+ * autoCompactIfNeeded). Runs before every API request is issued: estimates
140
+ * the context that is about to be sent (last response's total_tokens plus
141
+ * character estimates for newer messages) and compacts first when it
142
+ * exceeds maxInputTokens, so over-limit requests are never sent. Only
143
+ * total_tokens is used — under OpenAI-compatible usage it already includes
144
+ * cache hits; adding cache fields would double-count and fire early.
145
+ * Fork paths (compaction / auto-memory) never reach here: they run in
146
+ * runForkLoop, not sendAIMessage.
147
+ */
148
+ private maybeAutoCompactBeforeRequest;
137
149
  /**
138
150
  * Manually compact the conversation history.
139
151
  * Called by /compact slash command or auto-compaction trigger.
@@ -3,7 +3,7 @@ import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
3
3
  import { supportsVision } from "../utils/modelCapabilities.js";
4
4
  import { persistToolImages } from "../utils/toolImagePersistence.js";
5
5
  import { parseTaskNotificationXml, taskNotificationToXml, } from "../utils/notificationXml.js";
6
- import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
6
+ import { calculateComprehensiveTotalTokens, estimateContextTokens, } from "../utils/tokenCalculation.js";
7
7
  import { estimateTokens } from "../utils/tokenEstimate.js";
8
8
  import { getTaskReminderTurnCounts, maybeInjectTaskReminder, TASK_REMINDER_CONFIG, } from "../utils/taskReminder.js";
9
9
  import { createWriteStream, existsSync } from "node:fs";
@@ -144,7 +144,7 @@ export class AIManager {
144
144
  const permissionMode = this.container.has("PermissionMode")
145
145
  ? this.container.get("PermissionMode")
146
146
  : undefined;
147
- const parentModelConfig = this.configurationService.resolveModelConfig(undefined, undefined, undefined, permissionMode);
147
+ const parentModelConfig = this.configurationService.resolveModelConfig(undefined, undefined, permissionMode);
148
148
  let modelToUse;
149
149
  if (this.modelOverride) {
150
150
  if (this.modelOverride === "fastModel") {
@@ -157,10 +157,13 @@ export class AIManager {
157
157
  modelToUse = this.modelOverride;
158
158
  }
159
159
  }
160
- return this.configurationService.resolveModelConfig(modelToUse, undefined, undefined, permissionMode);
160
+ return this.configurationService.resolveModelConfig(modelToUse, undefined, permissionMode);
161
161
  }
162
162
  getMaxInputTokens() {
163
- return this.configurationService.resolveMaxInputTokens();
163
+ // Pass the resolved model (including fastModel/visionModel/override
164
+ // resolution) so subagents using a different model get that model's
165
+ // per-model maxInputTokens instead of the main model's value.
166
+ return this.configurationService.resolveMaxInputTokens(undefined, this.getModelConfig().model);
164
167
  }
165
168
  getLanguage() {
166
169
  return this.configurationService.resolveLanguage();
@@ -366,30 +369,43 @@ export class AIManager {
366
369
  }
367
370
  return "";
368
371
  }
369
- // Private method to handle token statistics and message compaction
370
- async handleTokenUsageAndCompaction(usage, abortController) {
372
+ // Private method to update the displayed token statistics from a response.
373
+ // The comprehensive value (including cache tokens) is intentionally kept:
374
+ // it drives cost/usage display. Auto-compaction is NOT decided here — it
375
+ // happens pre-request via maybeAutoCompactBeforeRequest so that over-limit
376
+ // requests never go out (aligned with Claude Code's proactive autocompact).
377
+ updateLatestTotalTokens(usage) {
371
378
  if (!usage)
372
379
  return;
373
380
  // Update token statistics - display comprehensive token usage including cache tokens
374
381
  const comprehensiveTotalTokens = calculateComprehensiveTotalTokens(usage);
375
382
  this.messageManager.setlatestTotalTokens(comprehensiveTotalTokens);
376
- // Check if token limit exceeded - use injected configuration
377
- if (usage.total_tokens +
378
- (usage.cache_read_input_tokens || 0) +
379
- (usage.cache_creation_input_tokens || 0) >
380
- this.getMaxInputTokens()) {
381
- logger?.debug(`Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`);
382
- const messagesToCompact = this.messageManager.getMessages();
383
- if (messagesToCompact.length === 0)
384
- return;
385
- // Circuit breaker: skip compaction after 3 consecutive failures
386
- if (this.consecutiveCompactionFailures >= 3) {
387
- logger?.warn(`Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`);
388
- return;
389
- }
390
- await this.compactConversation({
391
- abortSignal: abortController.signal,
392
- });
383
+ }
384
+ /**
385
+ * Pre-request auto-compaction check (aligned with Claude Code's
386
+ * autoCompactIfNeeded). Runs before every API request is issued: estimates
387
+ * the context that is about to be sent (last response's total_tokens plus
388
+ * character estimates for newer messages) and compacts first when it
389
+ * exceeds maxInputTokens, so over-limit requests are never sent. Only
390
+ * total_tokens is used — under OpenAI-compatible usage it already includes
391
+ * cache hits; adding cache fields would double-count and fire early.
392
+ * Fork paths (compaction / auto-memory) never reach here: they run in
393
+ * runForkLoop, not sendAIMessage.
394
+ */
395
+ async maybeAutoCompactBeforeRequest(abortController) {
396
+ // Circuit breaker: skip compaction after 3 consecutive failures
397
+ if (this.consecutiveCompactionFailures >= 3) {
398
+ logger?.warn(`Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`);
399
+ return;
400
+ }
401
+ const messages = this.messageManager.getMessages();
402
+ if (messages.length === 0)
403
+ return;
404
+ const maxTokens = this.getMaxInputTokens();
405
+ const estimatedTokens = estimateContextTokens(messages);
406
+ if (estimatedTokens > maxTokens) {
407
+ logger?.debug(`Estimated context tokens ${estimatedTokens} exceeded ${maxTokens}, compacting before request...`);
408
+ await this.compactConversation({ abortSignal: abortController.signal });
393
409
  }
394
410
  }
395
411
  /**
@@ -598,6 +614,7 @@ export class AIManager {
598
614
  workdir,
599
615
  tools: toolsConfig,
600
616
  systemPrompt,
617
+ maxTokens: this.configurationService.resolveMaxOutputTokens(),
601
618
  toolChoice: this.toolChoiceOverride,
602
619
  // Stream so a slow reasoning model emits first bytes before the
603
620
  // gateway's idle timeout fires (non-streaming waits for the full
@@ -1029,6 +1046,7 @@ ${question}`;
1029
1046
  workdir,
1030
1047
  tools: toolsConfig,
1031
1048
  systemPrompt,
1049
+ maxTokens: this.configurationService.resolveMaxOutputTokens(),
1032
1050
  toolChoice: this.toolChoiceOverride,
1033
1051
  // Stream so a slow reasoning model emits first bytes before the
1034
1052
  // gateway's idle timeout fires (same rationale as runCompactFork).
@@ -1281,6 +1299,10 @@ ${question}`;
1281
1299
  isMeta: true,
1282
1300
  });
1283
1301
  }
1302
+ // Pre-request auto-compaction: estimate the context about to be sent
1303
+ // and compact BEFORE issuing the request, so an over-limit request
1304
+ // never goes out. Skipped on fork paths (they use runForkLoop).
1305
+ await this.maybeAutoCompactBeforeRequest(abortController);
1284
1306
  // Get recent message history
1285
1307
  const rawMessages = this.messageManager.getMessages();
1286
1308
  const currentModelConfig = this.getModelConfig();
@@ -1305,7 +1327,7 @@ ${question}`;
1305
1327
  tools: toolsConfig, // Pass filtered tool configuration
1306
1328
  model: model, // Use passed model
1307
1329
  systemPrompt: mainSystemPrompt, // Pass custom system prompt
1308
- maxTokens: maxTokens, // Pass max tokens override
1330
+ maxTokens: maxTokens ?? this.configurationService.resolveMaxOutputTokens(), // Pass max tokens override, falling back to the resolved global value
1309
1331
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
1310
1332
  // Fast-model subagents send disable-thinking params only when
1311
1333
  // explicitly configured (never in the agent loop).
@@ -1489,8 +1511,9 @@ ${question}`;
1489
1511
  }
1490
1512
  }
1491
1513
  }
1492
- // Handle token statistics and message compaction
1493
- await this.handleTokenUsageAndCompaction(result.usage, abortController);
1514
+ // Update token statistics for display (compaction decision is
1515
+ // pre-request, see maybeAutoCompactBeforeRequest)
1516
+ this.updateLatestTotalTokens(result.usage);
1494
1517
  // Finalize text/reasoning blocks for the final response (no tools)
1495
1518
  this.messageManager.finalizeStreamingBlocks();
1496
1519
  // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
@@ -61,6 +61,13 @@ export class BackgroundTaskManager {
61
61
  // Create log file
62
62
  const logPath = path.join(os.tmpdir(), `wave-task-${id}.log`);
63
63
  const logStream = fs.createWriteStream(logPath, { flags: "w" });
64
+ // A failed open/write of the log file (e.g. EBUSY on Windows when a stale
65
+ // handle from a previous run still locks the temp file) must not surface as
66
+ // an uncaught stream error — the log is best-effort side output and the
67
+ // task itself keeps running regardless.
68
+ logStream.on("error", (error) => {
69
+ logger.warn(`Failed to write background task log ${logPath}:`, error);
70
+ });
64
71
  const shell = {
65
72
  id,
66
73
  type: "shell",
@@ -232,6 +239,11 @@ export class BackgroundTaskManager {
232
239
  // Create log file
233
240
  const logPath = path.join(os.tmpdir(), `wave-task-${id}.log`);
234
241
  const logStream = fs.createWriteStream(logPath, { flags: "w" });
242
+ // Same best-effort handling as startShell: a locked temp file (EBUSY on
243
+ // Windows) must not become an uncaught stream error.
244
+ logStream.on("error", (error) => {
245
+ logger.warn(`Failed to write background task log ${logPath}:`, error);
246
+ });
235
247
  // Write initial output to log file
236
248
  if (initialStdout) {
237
249
  logStream.write(stripAnsiColors(initialStdout));
@@ -32,15 +32,23 @@ export class PluginScopeManager {
32
32
  * Priority: local > project > user
33
33
  */
34
34
  findPluginScope(pluginId) {
35
- const projectPaths = this.configurationService.getConfigurationPaths(this.workdir).projectPaths; // [local, json]
36
- const userPaths = this.configurationService.getConfigurationPaths(this.workdir).userPaths; // [local, json]
35
+ const { projectPaths, userPaths } = this.configurationService.getConfigurationPaths(this.workdir);
36
+ const userPathSet = new Set(userPaths);
37
+ // When the workdir is the user's home directory, projectPaths overlaps
38
+ // userPaths (both point at ~/.wave/settings.json). Treat such a file as the
39
+ // user config, otherwise user-scope plugins would be mislabeled "project".
37
40
  const checkPaths = [
38
41
  { path: projectPaths[0], scope: "local" },
39
- { path: projectPaths[1], scope: "project" },
40
- { path: userPaths[0], scope: "user" }, // user local is still user scope
41
- { path: userPaths[1], scope: "user" },
42
+ ...(userPathSet.has(projectPaths[1])
43
+ ? []
44
+ : [{ path: projectPaths[1], scope: "project" }]),
45
+ ...userPaths.map((path) => ({ path, scope: "user" })),
42
46
  ];
47
+ const seen = new Set();
43
48
  for (const { path, scope } of checkPaths) {
49
+ if (!path || seen.has(path))
50
+ continue;
51
+ seen.add(path);
44
52
  const config = this.configurationService.loadWaveConfigFromFile(path);
45
53
  if (config?.enabledPlugins && pluginId in config.enabledPlugins) {
46
54
  return scope;
@@ -1,6 +1,6 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
- const execAsync = promisify(exec);
3
+ const execFileAsync = promisify(execFile);
4
4
  export class GitService {
5
5
  getTimeout() {
6
6
  const envTimeout = process.env.WAVE_PLUGIN_GIT_TIMEOUT_MS;
@@ -17,7 +17,7 @@ export class GitService {
17
17
  */
18
18
  async isGitAvailable() {
19
19
  try {
20
- await execAsync("git --version");
20
+ await execFileAsync("git", ["--version"]);
21
21
  return true;
22
22
  }
23
23
  catch {
@@ -43,8 +43,8 @@ export class GitService {
43
43
  url = `https://github.com/${urlOrRepo}.git`;
44
44
  }
45
45
  try {
46
- const refArgs = ref ? `-b "${ref}"` : "--depth 1";
47
- await execAsync(`git clone ${refArgs} "${url}" "${targetPath}"`, {
46
+ const refArgs = ref ? ["-b", ref] : ["--depth", "1"];
47
+ await execFileAsync("git", ["clone", ...refArgs, url, targetPath], {
48
48
  env: { ...process.env, LC_ALL: "C" },
49
49
  timeout: this.getTimeout(),
50
50
  });
@@ -64,7 +64,7 @@ export class GitService {
64
64
  throw new Error("Git is not installed or not found in PATH. Please install Git to use Git/GitHub marketplaces.");
65
65
  }
66
66
  try {
67
- await execAsync(`git -C "${targetPath}" pull`, {
67
+ await execFileAsync("git", ["-C", targetPath, "pull"], {
68
68
  env: { ...process.env, LC_ALL: "C" },
69
69
  timeout: this.getTimeout(),
70
70
  });
@@ -4,6 +4,7 @@ import { addOnceAbortListener } from "../utils/abortUtils.js";
4
4
  import { ConfigurationError, CONFIG_ERRORS } from "../types/index.js";
5
5
  import { transformMessagesForExplicitCache, extendUsageWithCacheMetrics, } from "../utils/cacheControlUtils.js";
6
6
  import { supportsPromptCaching } from "../utils/modelCapabilities.js";
7
+ import { DEFAULT_WAVE_MAX_OUTPUT_TOKENS } from "../utils/constants.js";
7
8
  import * as os from "os";
8
9
  import * as fs from "fs";
9
10
  import * as path from "path";
@@ -110,8 +111,11 @@ export async function callAgent(options) {
110
111
  fetchOptions: gatewayConfig.fetchOptions,
111
112
  fetch: gatewayConfig.fetch,
112
113
  });
113
- // Determine model early (needed for system prompt construction)
114
- const resolvedMaxTokens = options.maxTokens ?? modelConfig.maxTokens;
114
+ // Determine model early (needed for system prompt construction).
115
+ // Per-model overrides come from modelConfig.options.max_tokens (spread
116
+ // after this default below); callers pass the resolved global value
117
+ // (AgentOptions.maxTokens / WAVE_MAX_OUTPUT_TOKENS) via options.maxTokens.
118
+ const resolvedMaxTokens = options.maxTokens ?? DEFAULT_WAVE_MAX_OUTPUT_TOKENS;
115
119
  // Build system message content
116
120
  let systemMessage;
117
121
  if (Array.isArray(systemPrompt)) {
@@ -79,19 +79,19 @@ export declare class ConfigurationService {
79
79
  * Resolution priority: override > options > env (from settings.json) > process.env > default
80
80
  * @param model - Agent model override (optional)
81
81
  * @param fastModel - Fast model override (optional)
82
- * @param maxTokens - Max output tokens override (optional)
83
82
  * @param permissionMode - Permission mode override (optional)
84
83
  * @param visionModel - Vision model override (optional)
85
84
  * @returns Resolved model configuration with defaults
86
85
  */
87
- resolveModelConfig(model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
86
+ resolveModelConfig(model?: string, fastModel?: string, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
88
87
  /**
89
88
  * Resolves token limit with fallbacks
90
- * Resolution priority: override > options > env (from settings.json) > process.env > default
89
+ * Resolution priority: override > options > models[model].maxInputTokens > env (from settings.json) > process.env > default
91
90
  * @param constructorLimit - Token limit override (optional)
91
+ * @param model - Model to resolve per-model maxInputTokens for (optional; falls back to the resolved agent model chain)
92
92
  * @returns Resolved token limit
93
93
  */
94
- resolveMaxInputTokens(constructorLimit?: number): number;
94
+ resolveMaxInputTokens(constructorLimit?: number, model?: string): number;
95
95
  /**
96
96
  * Resolves preferred language with fallbacks
97
97
  * Resolution priority: override > options > settings.json > undefined
@@ -457,12 +457,11 @@ export class ConfigurationService {
457
457
  * Resolution priority: override > options > env (from settings.json) > process.env > default
458
458
  * @param model - Agent model override (optional)
459
459
  * @param fastModel - Fast model override (optional)
460
- * @param maxTokens - Max output tokens override (optional)
461
460
  * @param permissionMode - Permission mode override (optional)
462
461
  * @param visionModel - Vision model override (optional)
463
462
  * @returns Resolved model configuration with defaults
464
463
  */
465
- resolveModelConfig(model, fastModel, maxTokens, permissionMode, visionModel) {
464
+ resolveModelConfig(model, fastModel, permissionMode, visionModel) {
466
465
  // Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env
467
466
  // Priority: user's explicit model field > admin's env.WAVE_MODEL default.
468
467
  // If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings).
@@ -478,13 +477,10 @@ export class ConfigurationService {
478
477
  const resolvedVisionModel = visionModel ||
479
478
  this.options.visionModel ||
480
479
  (this.envSnapshot.WAVE_VISION_MODEL ?? process.env.WAVE_VISION_MODEL);
481
- // Resolve max output tokens
482
- const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
483
480
  const baseConfig = {
484
481
  model: resolvedAgentModel,
485
482
  fastModel: resolvedFastModel,
486
483
  visionModel: resolvedVisionModel,
487
- maxTokens: resolvedMaxTokens,
488
484
  permissionMode: permissionMode ?? this.options.permissionMode,
489
485
  };
490
486
  // Resolve fast model generation params from models[fastModel].options.
@@ -520,11 +516,12 @@ export class ConfigurationService {
520
516
  }
521
517
  /**
522
518
  * Resolves token limit with fallbacks
523
- * Resolution priority: override > options > env (from settings.json) > process.env > default
519
+ * Resolution priority: override > options > models[model].maxInputTokens > env (from settings.json) > process.env > default
524
520
  * @param constructorLimit - Token limit override (optional)
521
+ * @param model - Model to resolve per-model maxInputTokens for (optional; falls back to the resolved agent model chain)
525
522
  * @returns Resolved token limit
526
523
  */
527
- resolveMaxInputTokens(constructorLimit) {
524
+ resolveMaxInputTokens(constructorLimit, model) {
528
525
  // If override value provided, use it
529
526
  if (constructorLimit !== undefined) {
530
527
  return constructorLimit;
@@ -533,6 +530,16 @@ export class ConfigurationService {
533
530
  if (this.options.maxInputTokens !== undefined) {
534
531
  return this.options.maxInputTokens;
535
532
  }
533
+ // Per-model config (models[X].maxInputTokens). Resolve the model with the
534
+ // same chain as resolveModelConfig: caller > options > currentConfiguration > env.
535
+ const resolvedModel = model ||
536
+ this.options.model ||
537
+ this.currentConfiguration?.model ||
538
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
539
+ const modelConfig = resolvedModel && this.currentConfiguration?.models?.[resolvedModel];
540
+ if (modelConfig && modelConfig.maxInputTokens !== undefined) {
541
+ return modelConfig.maxInputTokens;
542
+ }
536
543
  // Try env (settings.json snapshot) first, then process.env
537
544
  const envMaxInputTokens = this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
538
545
  process.env.WAVE_MAX_INPUT_TOKENS;
@@ -4,7 +4,7 @@ import * as path from "path";
4
4
  import * as os from "os";
5
5
  import { logger } from "../utils/globalLogger.js";
6
6
  import { resolveShellPath } from "../utils/shellResolver.js";
7
- import { toPosixPath } from "../utils/path.js";
7
+ import { toPosixPath, toWindowsPath } from "../utils/path.js";
8
8
  import { stripAnsiColors } from "../utils/stringUtils.js";
9
9
  import { WindowsStreamDecoder } from "../utils/encoding.js";
10
10
  import { processToolResult } from "../utils/toolResultStorage.js";
@@ -498,12 +498,20 @@ The working directory persists between commands. Try to maintain your current wo
498
498
  try {
499
499
  if (fs.existsSync(tempCwdFile)) {
500
500
  newCwd = fs.readFileSync(tempCwdFile, "utf8").trim();
501
+ // Git Bash `pwd -P` writes MSYS-style POSIX paths (/c/Users/...);
502
+ // convert back to a native Windows path before validating, otherwise
503
+ // accessSync resolves /c/... to C:\c\... (ENOENT) on every command.
504
+ if (process.platform === "win32") {
505
+ newCwd = toWindowsPath(newCwd);
506
+ }
501
507
  // Validate the path exists before calling the callback
502
508
  fs.accessSync(newCwd, fs.constants.F_OK);
503
509
  }
504
510
  }
505
511
  catch (fileError) {
506
- logger.warn(`Could not read or validate new CWD from temp file ${tempCwdFile}:`, fileError);
512
+ // Best-effort CWD tracking: a stale/deleted target directory (e.g. a
513
+ // removed worktree) shouldn't fail the command or spam WARN logs.
514
+ logger.debug(`Could not read or validate new CWD from temp file ${tempCwdFile}:`, fileError);
507
515
  newCwd = undefined;
508
516
  }
509
517
  finally {
@@ -24,7 +24,8 @@ export interface ModelConfig {
24
24
  fastModel?: string;
25
25
  /** Vision-capable model for image recognition subagents (resolved from WAVE_VISION_MODEL env var). */
26
26
  visionModel?: string;
27
- maxTokens?: number;
27
+ /** Per-model input context window in tokens. Overrides the global `WAVE_MAX_INPUT_TOKENS` for this model. */
28
+ maxInputTokens?: number;
28
29
  permissionMode?: PermissionMode;
29
30
  capabilities?: ModelCapabilities;
30
31
  /** Generation params passed through to the API provider (temperature, thinking, etc.) */
@@ -5,6 +5,7 @@ import { join } from "path";
5
5
  import { tmpdir } from "os";
6
6
  import { SKILL_BASH_MAX_OUTPUT_CHARS, PREVIEW_SIZE_BYTES, } from "../constants/toolLimits.js";
7
7
  import { logger } from "./globalLogger.js";
8
+ import { resolveShellPath } from "./shellResolver.js";
8
9
  const execAsync = promisify(exec);
9
10
  /**
10
11
  * Parse YAML frontmatter from markdown content
@@ -198,12 +199,29 @@ export function replaceBashCommandsWithOutput(content, results) {
198
199
  */
199
200
  export async function executeBashCommands(commands, workdir, timeout = 30000) {
200
201
  const results = [];
201
- for (const command of commands) {
202
+ for (const originalCommand of commands) {
203
+ let command = originalCommand;
204
+ const execOptions = {
205
+ cwd: workdir,
206
+ timeout,
207
+ };
208
+ // Skill templates (e.g. !`gh pr view ... 2>/dev/null || echo '{}'`) use
209
+ // bash-only syntax. On Windows, child_process.exec defaults to cmd.exe,
210
+ // where `/dev/null` fails with a localized "path not found" error whose
211
+ // GBK bytes get mis-decoded as UTF-8 (mojibake). Run through Git Bash
212
+ // when available (same contract as the bash tool); otherwise force the
213
+ // UTF-8 codepage so cmd's own messages decode correctly.
214
+ if (process.platform === "win32") {
215
+ const bashPath = resolveShellPath();
216
+ if (bashPath) {
217
+ execOptions.shell = bashPath;
218
+ }
219
+ else {
220
+ command = `chcp 65001 >NUL && ${command}`;
221
+ }
222
+ }
202
223
  try {
203
- const { stdout, stderr } = await execAsync(command, {
204
- cwd: workdir,
205
- timeout,
206
- });
224
+ const { stdout, stderr } = await execAsync(command, execOptions);
207
225
  results.push({
208
226
  command,
209
227
  output: (stdout + (stderr || "")).trim(),
@@ -25,3 +25,12 @@ export declare function getDisplayPath(filePath: string, workdir: string): strin
25
25
  * Returns the path as-is on non-Windows platforms.
26
26
  */
27
27
  export declare function toPosixPath(p: string): string;
28
+ /**
29
+ * Convert a POSIX-style path to a native Windows path. Git Bash / MSYS shells
30
+ * print POSIX paths (e.g. `pwd -P` outputs `/c/Users/foo`), which Node.js on
31
+ * Windows would otherwise resolve against the current drive root (e.g. `/c/...`
32
+ * becomes `C:\c\...`) and fail with ENOENT. Handles `/c/...` (MSYS2/Git Bash),
33
+ * `/cygdrive/c/...` and `//server/share` (UNC) forms. Returns the path as-is on
34
+ * non-Windows platforms.
35
+ */
36
+ export declare function toWindowsPath(p: string): string;
@@ -61,3 +61,35 @@ export function getDisplayPath(filePath, workdir) {
61
61
  export function toPosixPath(p) {
62
62
  return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
63
63
  }
64
+ /**
65
+ * Convert a POSIX-style path to a native Windows path. Git Bash / MSYS shells
66
+ * print POSIX paths (e.g. `pwd -P` outputs `/c/Users/foo`), which Node.js on
67
+ * Windows would otherwise resolve against the current drive root (e.g. `/c/...`
68
+ * becomes `C:\c\...`) and fail with ENOENT. Handles `/c/...` (MSYS2/Git Bash),
69
+ * `/cygdrive/c/...` and `//server/share` (UNC) forms. Returns the path as-is on
70
+ * non-Windows platforms.
71
+ */
72
+ export function toWindowsPath(p) {
73
+ if (process.platform !== "win32")
74
+ return p;
75
+ // UNC paths: //server/share -> \\server\share
76
+ if (p.startsWith("//")) {
77
+ return p.replace(/\//g, "\\");
78
+ }
79
+ // /cygdrive/c/... -> C:\...
80
+ const cygdriveMatch = p.match(/^\/cygdrive\/([A-Za-z])(\/|$)/);
81
+ if (cygdriveMatch) {
82
+ const driveLetter = cygdriveMatch[1].toUpperCase();
83
+ const rest = p.slice(("/cygdrive/" + cygdriveMatch[1]).length);
84
+ return driveLetter + ":" + (rest || "\\").replace(/\//g, "\\");
85
+ }
86
+ // /c/... (MSYS2/Git Bash) -> C:\...
87
+ const driveMatch = p.match(/^\/([A-Za-z])(\/|$)/);
88
+ if (driveMatch) {
89
+ const driveLetter = driveMatch[1].toUpperCase();
90
+ const rest = p.slice(2);
91
+ return driveLetter + ":" + (rest || "\\").replace(/\//g, "\\");
92
+ }
93
+ // Already Windows or relative — just flip slashes
94
+ return p.replace(/\//g, "\\");
95
+ }
@@ -1,4 +1,4 @@
1
- import type { Usage } from "../types/index.js";
1
+ import type { Message, Usage } from "../types/index.js";
2
2
  /**
3
3
  * Calculate comprehensive total tokens including cache-related tokens
4
4
  *
@@ -23,3 +23,21 @@ export declare function calculateComprehensiveTotalTokens(usage: Usage): number;
23
23
  export declare function extractLatestTotalTokens(messages: Array<{
24
24
  usage?: Usage;
25
25
  }>): number;
26
+ /**
27
+ * Estimate the full context size of a message list for pre-request
28
+ * auto-compaction checks (aligned with Claude Code's tokenCountWithEstimation).
29
+ *
30
+ * Anchors on the most recent message carrying real usage (the last API
31
+ * response): that response's total_tokens (OpenAI-compatible semantics —
32
+ * already includes cache hits) plus a rough character-based estimate for
33
+ * every message added after it. Falls back to a pure character estimate
34
+ * when no usage anchor exists yet (e.g. the first request of a session).
35
+ */
36
+ export declare function estimateContextTokens(messages: Message[]): number;
37
+ /**
38
+ * Rough per-message token estimate over all message blocks.
39
+ * Used to estimate the messages added since the last usage-bearing response.
40
+ * Image blocks are ignored (images are stripped before compaction) and file
41
+ * history snapshots live on disk, so their content is not in the context.
42
+ */
43
+ export declare function roughTokenCountForMessages(messages: Message[]): number;
@@ -1,3 +1,4 @@
1
+ import { estimateTokens } from "./tokenEstimate.js";
1
2
  /**
2
3
  * Calculate comprehensive total tokens including cache-related tokens
3
4
  *
@@ -34,3 +35,66 @@ export function extractLatestTotalTokens(messages) {
34
35
  }
35
36
  return 0; // No usage data found
36
37
  }
38
+ /**
39
+ * Estimate the full context size of a message list for pre-request
40
+ * auto-compaction checks (aligned with Claude Code's tokenCountWithEstimation).
41
+ *
42
+ * Anchors on the most recent message carrying real usage (the last API
43
+ * response): that response's total_tokens (OpenAI-compatible semantics —
44
+ * already includes cache hits) plus a rough character-based estimate for
45
+ * every message added after it. Falls back to a pure character estimate
46
+ * when no usage anchor exists yet (e.g. the first request of a session).
47
+ */
48
+ export function estimateContextTokens(messages) {
49
+ for (let i = messages.length - 1; i >= 0; i--) {
50
+ const usage = messages[i].usage;
51
+ if (usage) {
52
+ return (usage.total_tokens + roughTokenCountForMessages(messages.slice(i + 1)));
53
+ }
54
+ }
55
+ return roughTokenCountForMessages(messages);
56
+ }
57
+ /**
58
+ * Rough per-message token estimate over all message blocks.
59
+ * Used to estimate the messages added since the last usage-bearing response.
60
+ * Image blocks are ignored (images are stripped before compaction) and file
61
+ * history snapshots live on disk, so their content is not in the context.
62
+ */
63
+ export function roughTokenCountForMessages(messages) {
64
+ let total = 0;
65
+ for (const message of messages) {
66
+ for (const block of message.blocks) {
67
+ switch (block.type) {
68
+ case "text":
69
+ case "reasoning":
70
+ case "error":
71
+ case "compact":
72
+ total += estimateTokens(block.content);
73
+ break;
74
+ case "tool":
75
+ if (block.parameters) {
76
+ total += estimateTokens(block.parameters, "json");
77
+ }
78
+ if (block.result) {
79
+ total += estimateTokens(block.result);
80
+ }
81
+ break;
82
+ case "bang":
83
+ if (block.command) {
84
+ total += estimateTokens(block.command);
85
+ }
86
+ if (block.output) {
87
+ total += estimateTokens(block.output);
88
+ }
89
+ break;
90
+ case "task_notification":
91
+ total += estimateTokens(block.summary);
92
+ break;
93
+ case "image":
94
+ case "file_history":
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ return total;
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",