wave-agent-sdk 1.0.10 → 1.1.1

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 (48) hide show
  1. package/dist/agent.d.ts +16 -7
  2. package/dist/agent.js +44 -34
  3. package/dist/builtin/skills/settings.js +48 -8
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/managers/aiManager.d.ts +23 -1
  7. package/dist/managers/aiManager.js +80 -30
  8. package/dist/managers/backgroundTaskManager.js +12 -0
  9. package/dist/managers/pluginScopeManager.js +13 -5
  10. package/dist/managers/subagentManager.d.ts +1 -0
  11. package/dist/managers/subagentManager.js +5 -1
  12. package/dist/services/GitService.js +6 -6
  13. package/dist/services/aiService.js +6 -2
  14. package/dist/services/configurationService.d.ts +4 -4
  15. package/dist/services/configurationService.js +14 -7
  16. package/dist/services/hook.js +41 -7
  17. package/dist/services/initializationService.js +0 -21
  18. package/dist/services/jsonlHandler.d.ts +37 -2
  19. package/dist/services/jsonlHandler.js +55 -6
  20. package/dist/services/session.d.ts +35 -4
  21. package/dist/services/session.js +233 -36
  22. package/dist/services/worktreeHooks.d.ts +45 -0
  23. package/dist/services/worktreeHooks.js +133 -0
  24. package/dist/tools/agentTool.js +36 -1
  25. package/dist/tools/bashTool.js +129 -58
  26. package/dist/tools/enterWorktreeTool.d.ts +1 -1
  27. package/dist/tools/enterWorktreeTool.js +44 -31
  28. package/dist/tools/exitWorktreeTool.js +21 -26
  29. package/dist/types/agent.d.ts +5 -0
  30. package/dist/types/config.d.ts +2 -1
  31. package/dist/types/hooks.d.ts +3 -2
  32. package/dist/types/skills.d.ts +0 -1
  33. package/dist/types/skills.js +0 -1
  34. package/dist/utils/asyncWorkRegistry.d.ts +32 -0
  35. package/dist/utils/asyncWorkRegistry.js +81 -0
  36. package/dist/utils/containerSetup.js +5 -0
  37. package/dist/utils/markdownParser.js +23 -5
  38. package/dist/utils/path.d.ts +9 -0
  39. package/dist/utils/path.js +32 -0
  40. package/dist/utils/skillParser.js +3 -6
  41. package/dist/utils/tokenCalculation.d.ts +19 -1
  42. package/dist/utils/tokenCalculation.js +64 -0
  43. package/dist/utils/windowsPaths.d.ts +28 -0
  44. package/dist/utils/windowsPaths.js +47 -0
  45. package/dist/utils/worktreeSession.d.ts +6 -0
  46. package/dist/utils/worktreeUtils.d.ts +7 -0
  47. package/dist/utils/worktreeUtils.js +88 -40
  48. package/package.json +5 -3
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;
@@ -23,7 +23,9 @@ export declare class Agent {
23
23
  private messageQueue;
24
24
  private dispatchPromise;
25
25
  private isAborting;
26
+ private isDestroyed;
26
27
  private dispatchAborted;
28
+ private asyncWorkRegistry;
27
29
  private memoryRuleManager;
28
30
  private liveConfigManager;
29
31
  private taskManager;
@@ -122,6 +124,13 @@ export declare class Agent {
122
124
  }>;
123
125
  type?: "message" | "bang";
124
126
  }): boolean;
127
+ /**
128
+ * Terminal-lifecycle guard for public APIs. destroy() is terminal: after it
129
+ * returns, no new work may start on this agent. Silent drops hid misuse until
130
+ * a ghost async side effect surfaced later (issue #1808); throwing surfaces
131
+ * the contract violation immediately at the call site.
132
+ */
133
+ private assertNotDestroyed;
125
134
  /**
126
135
  * Unified dispatch trigger — checks state machine before processing.
127
136
  * Handles user messages, bang commands, and background task notifications
@@ -244,12 +253,6 @@ export declare class Agent {
244
253
  * Background the current foreground task
245
254
  */
246
255
  backgroundCurrentTask(): Promise<void>;
247
- /**
248
- * Trigger WorktreeRemove hook before agent destruction.
249
- * Called from CLI exit dialog when user chooses to remove the worktree.
250
- * Non-blocking: errors logged but don't prevent removal.
251
- */
252
- triggerWorktreeRemoveHook(worktreePath: string): Promise<void>;
253
256
  /** Destroy managers, clean up resources */
254
257
  destroy(): Promise<void>;
255
258
  /**
@@ -419,6 +422,12 @@ export declare class Agent {
419
422
  * @returns The list of subagent configurations
420
423
  */
421
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[];
422
431
  /**
423
432
  * Get currently active subagent instances (status active/initializing).
424
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();
@@ -53,7 +53,8 @@ export class Agent {
53
53
  constructor(options) {
54
54
  this.bangManager = null;
55
55
  this.dispatchPromise = null; // Track current dispatch for teardown
56
- this.isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
56
+ this.isAborting = false; // Transient guard: prevents tryDispatch from firing during abortMessage (reset when the abort completes)
57
+ this.isDestroyed = false; // Terminal guard: set in destroy(), never reset — no dispatch may ever start after destroy
57
58
  this.dispatchAborted = false; // Set on abort while a dispatch is running: suppress the .finally re-check so preserved notifications don't get dispatched after abort
58
59
  this.sessionStartTime = Date.now();
59
60
  const { logger, workdir, systemPrompt, stream = true } = options;
@@ -113,6 +114,7 @@ export class Agent {
113
114
  this.bangManager = this.container.get("BangManager");
114
115
  this.cronManager = this.container.get("CronManager");
115
116
  this.messageQueue = this.container.get("MessageQueue");
117
+ this.asyncWorkRegistry = this.container.get("AsyncWorkRegistry");
116
118
  // Wire up CWD change callback from AIManager to sync Agent's workdir
117
119
  this.aiManager.setOnCwdChange((newCwd) => {
118
120
  this.workdir = newCwd;
@@ -256,6 +258,17 @@ export class Agent {
256
258
  }
257
259
  return updated;
258
260
  }
261
+ /**
262
+ * Terminal-lifecycle guard for public APIs. destroy() is terminal: after it
263
+ * returns, no new work may start on this agent. Silent drops hid misuse until
264
+ * a ghost async side effect surfaced later (issue #1808); throwing surfaces
265
+ * the contract violation immediately at the call site.
266
+ */
267
+ assertNotDestroyed() {
268
+ if (this.isDestroyed) {
269
+ throw new Error("Agent destroyed");
270
+ }
271
+ }
259
272
  /**
260
273
  * Unified dispatch trigger — checks state machine before processing.
261
274
  * Handles user messages, bang commands, and background task notifications
@@ -263,6 +276,8 @@ export class Agent {
263
276
  * onLoadingChange(false), and onCommandRunningChange(false).
264
277
  */
265
278
  tryDispatch() {
279
+ if (this.isDestroyed)
280
+ return; // Terminal: agent destroyed, never dispatch again
266
281
  if (this.isAborting)
267
282
  return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
268
283
  if (this.dispatchAborted)
@@ -274,7 +289,7 @@ export class Agent {
274
289
  if (this.aiManager.isLoading || this.isCommandRunning)
275
290
  return;
276
291
  this.messageQueue.transitionTo("dispatching");
277
- this.dispatchPromise = this.processQueuedMessage()
292
+ this.dispatchPromise = this.asyncWorkRegistry.track(this.processQueuedMessage()
278
293
  .catch((error) => {
279
294
  this.logger?.error("Failed to process queued message:", error);
280
295
  })
@@ -285,7 +300,7 @@ export class Agent {
285
300
  this.tryDispatch(); // Re-check after processing
286
301
  }
287
302
  this.dispatchAborted = false;
288
- });
303
+ }));
289
304
  }
290
305
  /**
291
306
  * Process the next queued item when the agent becomes idle.
@@ -499,6 +514,7 @@ export class Agent {
499
514
  }
500
515
  /** Execute bash command (bang command) */
501
516
  async bang(command) {
517
+ this.assertNotDestroyed();
502
518
  // If the agent is busy, enqueue the bang command
503
519
  if (this.aiManager.isLoading || this.isCommandRunning) {
504
520
  this.messageQueue.enqueue({ type: "bang", content: command });
@@ -625,37 +641,14 @@ export class Agent {
625
641
  await this.foregroundTaskManager.backgroundCurrentTask();
626
642
  this.options.callbacks?.onBackgroundCurrentTask?.();
627
643
  }
628
- /**
629
- * Trigger WorktreeRemove hook before agent destruction.
630
- * Called from CLI exit dialog when user chooses to remove the worktree.
631
- * Non-blocking: errors logged but don't prevent removal.
632
- */
633
- async triggerWorktreeRemoveHook(worktreePath) {
634
- if (!this.hookManager.hasHooks("WorktreeRemove")) {
635
- return;
636
- }
637
- try {
638
- const sessionId = this.messageManager.getSessionId();
639
- const transcriptPath = this.messageManager.getTranscriptPath();
640
- const hookResults = await this.hookManager.executeHooks("WorktreeRemove", {
641
- event: "WorktreeRemove",
642
- projectDir: this.workdir,
643
- timestamp: new Date(),
644
- sessionId,
645
- transcriptPath,
646
- cwd: this.workdir,
647
- worktreePath,
648
- env: Object.fromEntries(Object.entries(this.configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
649
- });
650
- // Process results via messageManager (may not be visible during shutdown)
651
- this.hookManager.processHookResults("WorktreeRemove", hookResults, this.messageManager);
652
- }
653
- catch (error) {
654
- this.logger?.warn("WorktreeRemove hooks execution failed:", error);
655
- }
656
- }
657
644
  /** Destroy managers, clean up resources */
658
645
  async destroy() {
646
+ // Terminal guard: suppress any dispatch triggered during teardown (e.g. the
647
+ // onLoadingChange(false) fired by abortAIMessage() below, or the dispatch
648
+ // .finally re-check). Without this, leftover queued messages would be
649
+ // dispatched fire-and-forget and outlive the agent. Never reset: destroy()
650
+ // is terminal, no dispatch should ever start afterwards.
651
+ this.isDestroyed = true;
659
652
  // Log session_end event and shutdown telemetry
660
653
  await logOTelEvent("session_end", {
661
654
  duration: String(Math.round((Date.now() - this.sessionStartTime) / 1000)),
@@ -717,7 +710,13 @@ export class Agent {
717
710
  }
718
711
  // Cleanup remote settings polling
719
712
  remoteSettingsService.shutdown();
720
- // Cleanup memory store
713
+ // Drain live async work (dispatch, background subagents, fork subagents):
714
+ // abort steps above make them settle; wait for them so no async side
715
+ // effect outlives the agent. Timeout fallback keeps destroy bounded.
716
+ const drained = await this.asyncWorkRegistry.drain();
717
+ if (!drained) {
718
+ this.logger?.error(`Async work did not drain: ${this.asyncWorkRegistry.size} live work item(s) remain after destroy`);
719
+ }
721
720
  }
722
721
  /**
723
722
  * Trigger the rewind UI callback
@@ -738,6 +737,7 @@ export class Agent {
738
737
  * @returns Promise that resolves to the AI's answer
739
738
  */
740
739
  async askBtw(question, abortSignal, onContent, onReasoning) {
740
+ this.assertNotDestroyed();
741
741
  const result = await this.aiManager.runBtwFork(question, abortSignal, onContent, onReasoning);
742
742
  return result.content ?? result.error ?? "No response received";
743
743
  }
@@ -760,6 +760,7 @@ export class Agent {
760
760
  * @returns Promise that resolves to the background task ID
761
761
  */
762
762
  async forkSubagent(prompt, options, abortSignal) {
763
+ this.assertNotDestroyed();
763
764
  return this.aiManager.runForkSubagent(prompt, options, abortSignal);
764
765
  }
765
766
  /**
@@ -789,6 +790,7 @@ export class Agent {
789
790
  * ```
790
791
  */
791
792
  async sendMessage(content, images) {
793
+ this.assertNotDestroyed();
792
794
  // If the agent is busy, enqueue the message — unless it's an immediate
793
795
  // slash command (e.g., /clear, /compact) that should execute
794
796
  // right away even while AI is processing
@@ -981,6 +983,14 @@ export class Agent {
981
983
  getSubagentConfigurations() {
982
984
  return this.subagentManager.getConfigurations();
983
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
+ }
984
994
  /**
985
995
  * Get currently active subagent instances (status active/initializing).
986
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\` |
@@ -92,8 +92,8 @@ Wave supports the following hook events:
92
92
  - \`PermissionRequest\`: Triggered when Wave requests permission to use a tool.
93
93
  - \`Stop\`: Triggered when Wave finishes its response cycle (no more tool calls).
94
94
  - \`SubagentStop\`: Triggered when a subagent finishes its response cycle.
95
- - \`WorktreeCreate\`: Triggered when a new worktree is created.
96
- - \`WorktreeRemove\`: Triggered before a worktree is removed (e.g., via ExitWorktree with \`action: "remove"\`). Non-blocking. Fires **before** the worktree directory is deleted so hooks can still read files inside it. The hook receives \`worktree_path\` in the JSON input. Useful for cleanup tasks (e.g., \`docker compose -p $(basename "$worktree_path") down\`).
95
+ - \`WorktreeCreate\`: Triggered to create a new worktree, replacing \`git worktree add\`. The hook performs the creation itself (e.g., \`git worktree add\`, or any other VCS/external provisioning) and must output the worktree's absolute path on stdout (path return). Creation is blocked if all hooks fail or produce no output. Receives \`name\` in the JSON input. The resulting session is marked "hook-based".
96
+ - \`WorktreeRemove\`: Triggered when a hook-based worktree (created by a \`WorktreeCreate\` hook) is removed (e.g., via ExitWorktree with \`action: "remove"\`), replacing \`git worktree remove\` for that worktree: the hook performs the removal itself (e.g., \`git worktree remove --force\` plus external resource cleanup). Fires **before** the worktree directory is deleted so hooks can still read files inside it. Receives \`worktree_path\` in the JSON input. Failures are logged but non-blocking. Git-created worktrees are removed by git directly and do not trigger this hook.
97
97
  - \`CwdChanged\`: Triggered when the working directory changes (e.g., entering/exiting a worktree). Non-blocking.
98
98
  - \`SessionStart\`: Triggered during session initialization. Hooks can inject \`additionalContext\` and \`initialUserMessage\` via stdout.
99
99
  - \`SessionEnd\`: Triggered during agent destruction (fire-and-forget, non-blocking). Useful for cleanup, resource teardown, and analytics.
@@ -245,9 +245,34 @@ SessionEnd hooks receive \`end_source\` in the JSON input indicating how the ses
245
245
  }
246
246
  \`\`\`
247
247
 
248
+ ## WorktreeCreate Hooks
249
+
250
+ \`WorktreeCreate\` hooks replace \`git worktree add\`: when configured, Wave does not create the worktree itself — the hook does. The hook must output the worktree's absolute path on **stdout** (the first successful hook's trimmed stdout is used as the path). All hooks failing or producing no output blocks the creation with \`WorktreeCreate hook failed: ...\`. The resulting session is marked "hook-based" and skips Wave's post-creation setup (\`settings.local.json\` / \`.worktreeinclude\` propagation) — the hook is responsible for any initialization.
251
+
252
+ ### Input
253
+ WorktreeCreate hooks receive \`name\` (the worktree name) in the JSON input, alongside the common fields \`session_id\`, \`transcript_path\`, \`cwd\`, \`hook_event_name\`.
254
+
255
+ ### Example Configuration
256
+ \`\`\`json
257
+ {
258
+ "hooks": {
259
+ "WorktreeCreate": [
260
+ {
261
+ "hooks": [
262
+ {
263
+ "command": "worktree_path=\\"$WAVE_PROJECT_DIR/.wave/worktrees/$(jq -r '.name')\\" && mkdir -p \\"$worktree_path\\" && git worktree add \\"$worktree_path\\" 2>/dev/null; echo \\"$worktree_path\\"",
264
+ "description": "Create the worktree and print its path"
265
+ }
266
+ ]
267
+ }
268
+ ]
269
+ }
270
+ }
271
+ \`\`\`
272
+
248
273
  ## WorktreeRemove Hooks
249
274
 
250
- \`WorktreeRemove\` hooks fire **before** the worktree directory is deleted, so they can still read files inside it. They are non-blocking (Notification type): the hook never replaces \`git worktree remove\` itself. Useful for cleaning up external resources that were provisioned for the worktree (databases, containers, etc.).
275
+ \`WorktreeRemove\` hooks replace \`git worktree remove\` for **hook-based** worktrees (those created by a \`WorktreeCreate\` hook). When a hook-based worktree is removed from any entry point (CLI exit, ExitWorktree tool, \`wave -p\`, stdio RPC), Wave calls the hook instead of running \`git worktree remove\` — the hook performs the actual removal, so it can clean up external resources it provisioned (databases, containers, etc.) at the same time. Hooks fire **before** the worktree directory is deleted, so they can still read files inside it. Failures are logged but non-blocking. If no \`WorktreeRemove\` hook is configured for a hook-based worktree, Wave logs a warning and leaves the worktree in place. Git-created worktrees (no \`WorktreeCreate\` hook) are removed by git directly and do **not** trigger this hook.
251
276
 
252
277
  ### Input
253
278
  WorktreeRemove hooks receive \`worktree_path\` in the JSON input (alongside the common fields \`session_id\`, \`transcript_path\`, \`cwd\`, \`hook_event_name\`). The worktree name can be derived via \`basename "$worktree_path"\`.
@@ -260,8 +285,8 @@ WorktreeRemove hooks receive \`worktree_path\` in the JSON input (alongside the
260
285
  {
261
286
  "hooks": [
262
287
  {
263
- "command": "worktree_path=$(jq -r '.worktree_path') && docker compose -p \\"$(basename \\"$worktree_path\\")\\" down || true",
264
- "description": "Tear down the worktree's docker compose project before removal"
288
+ "command": "worktree_path=$(jq -r '.worktree_path') && git worktree remove --force \\"$worktree_path\\" && docker compose -p \\"$(basename \\"$worktree_path\\")\\" down",
289
+ "description": "Remove the worktree and tear down its docker compose project"
265
290
  }
266
291
  ]
267
292
  }
@@ -297,7 +322,7 @@ When hooks are registered via a **plugin**, Wave automatically:
297
322
  }
298
323
  \`\`\`
299
324
 
300
- The shell also receives \`WAVE_PLUGIN_ROOT\` as an env var, so \`$WAVE_PLUGIN_ROOT\` works in the hook script itself.
325
+ The shell also receives \`WAVE_PLUGIN_ROOT\` as an env var, so \`$WAVE_PLUGIN_ROOT\` works in the hook script itself. For \`WorktreeCreate\`, the script must print the created worktree's absolute path to stdout.
301
326
 
302
327
  ## Best Practices
303
328
 
@@ -560,12 +585,27 @@ You can define overrides for specific models in the \`models\` field. The key sh
560
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:
561
586
 
562
587
  - \`temperature\`: Controls randomness (0.0 to 2.0).
563
- - \`maxTokens\`: Maximum number of tokens to generate in the response.
588
+ - \`max_tokens\`: Maximum number of tokens to generate in the response.
564
589
  - \`reasoning_effort\`: (OpenAI specific) Controls the reasoning effort for models like \`o1\` and \`o3-mini\`. Values: \`low\`, \`medium\`, \`high\`.
565
590
  - \`thinking\`: (Claude specific) Configures the thinking/reasoning capabilities for Claude 3.7+ models.
566
591
  - \`type\`: \`"enabled"\` or \`"disabled"\`.
567
592
  - \`budget_tokens\`: Maximum tokens to use for thinking.
568
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
+
569
609
  ## Model Capabilities
570
610
 
571
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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./core/session.js";
2
2
  export * from "./services/authService.js";
3
+ export * from "./services/worktreeHooks.js";
3
4
  export * from "./constants/tools.js";
4
5
  export * from "./agent.js";
5
6
  export * from "./core/plugin.js";
@@ -21,6 +22,7 @@ export * from "./utils/hookMatcher.js";
21
22
  export * from "./utils/tokenCalculation.js";
22
23
  export * from "./utils/gitUtils.js";
23
24
  export * from "./utils/nameGenerator.js";
25
+ export * from "./utils/pathEncoder.js";
24
26
  export * from "./utils/worktreeSession.js";
25
27
  export * from "./utils/worktreeUtils.js";
26
28
  export { loadMergedWaveConfig, loadUserConfigEnv, } from "./services/configurationService.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Export all services
2
2
  export * from "./core/session.js";
3
3
  export * from "./services/authService.js";
4
+ export * from "./services/worktreeHooks.js";
4
5
  // Export constants
5
6
  export * from "./constants/tools.js";
6
7
  // Export main agent
@@ -25,6 +26,7 @@ export * from "./utils/hookMatcher.js";
25
26
  export * from "./utils/tokenCalculation.js";
26
27
  export * from "./utils/gitUtils.js";
27
28
  export * from "./utils/nameGenerator.js";
29
+ export * from "./utils/pathEncoder.js";
28
30
  export * from "./utils/worktreeSession.js";
29
31
  export * from "./utils/worktreeUtils.js";
30
32
  export { loadMergedWaveConfig, loadUserConfigEnv, } from "./services/configurationService.js";
@@ -12,6 +12,15 @@ interface ForkLoopResult {
12
12
  }
13
13
  export interface AIManagerCallbacks {
14
14
  onCompactionStateChange?: (isCompacting: boolean) => void;
15
+ /**
16
+ * Streaming content from the compaction fork (accumulated, same semantics
17
+ * as `onContentUpdate` in CallAgentOptions). Reasoning chunks fall back to
18
+ * this channel when no reasoning callback is provided. Consumed by the CLI
19
+ * to show the compaction loading tail.
20
+ */
21
+ onCompactionContentUpdate?: (content: string) => void;
22
+ /** Streaming reasoning from the compaction fork (accumulated). */
23
+ onCompactionReasoningUpdate?: (content: string) => void;
15
24
  onUsageAdded?: (usage: Usage) => void;
16
25
  onCwdChange?: (newCwd: string) => void;
17
26
  }
@@ -59,6 +68,7 @@ export declare class AIManager {
59
68
  private get memoryService();
60
69
  private get taskManager();
61
70
  private get backgroundTaskManager();
71
+ private get asyncWorkRegistry();
62
72
  private get hookManager();
63
73
  private get reversionManager();
64
74
  private get permissionManager();
@@ -123,7 +133,19 @@ export declare class AIManager {
123
133
  setIsLoading(isLoading: boolean): void;
124
134
  abortAIMessage(): void;
125
135
  private generateCompactParams;
126
- 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;
127
149
  /**
128
150
  * Manually compact the conversation history.
129
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";
@@ -103,6 +103,9 @@ export class AIManager {
103
103
  get backgroundTaskManager() {
104
104
  return this.container.get("BackgroundTaskManager");
105
105
  }
106
+ get asyncWorkRegistry() {
107
+ return this.container.get("AsyncWorkRegistry");
108
+ }
106
109
  get hookManager() {
107
110
  return this.container.get("HookManager");
108
111
  }
@@ -141,7 +144,7 @@ export class AIManager {
141
144
  const permissionMode = this.container.has("PermissionMode")
142
145
  ? this.container.get("PermissionMode")
143
146
  : undefined;
144
- const parentModelConfig = this.configurationService.resolveModelConfig(undefined, undefined, undefined, permissionMode);
147
+ const parentModelConfig = this.configurationService.resolveModelConfig(undefined, undefined, permissionMode);
145
148
  let modelToUse;
146
149
  if (this.modelOverride) {
147
150
  if (this.modelOverride === "fastModel") {
@@ -154,10 +157,13 @@ export class AIManager {
154
157
  modelToUse = this.modelOverride;
155
158
  }
156
159
  }
157
- return this.configurationService.resolveModelConfig(modelToUse, undefined, undefined, permissionMode);
160
+ return this.configurationService.resolveModelConfig(modelToUse, undefined, permissionMode);
158
161
  }
159
162
  getMaxInputTokens() {
160
- 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);
161
167
  }
162
168
  getLanguage() {
163
169
  return this.configurationService.resolveLanguage();
@@ -363,30 +369,43 @@ export class AIManager {
363
369
  }
364
370
  return "";
365
371
  }
366
- // Private method to handle token statistics and message compaction
367
- 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) {
368
378
  if (!usage)
369
379
  return;
370
380
  // Update token statistics - display comprehensive token usage including cache tokens
371
381
  const comprehensiveTotalTokens = calculateComprehensiveTotalTokens(usage);
372
382
  this.messageManager.setlatestTotalTokens(comprehensiveTotalTokens);
373
- // Check if token limit exceeded - use injected configuration
374
- if (usage.total_tokens +
375
- (usage.cache_read_input_tokens || 0) +
376
- (usage.cache_creation_input_tokens || 0) >
377
- this.getMaxInputTokens()) {
378
- logger?.debug(`Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`);
379
- const messagesToCompact = this.messageManager.getMessages();
380
- if (messagesToCompact.length === 0)
381
- return;
382
- // Circuit breaker: skip compaction after 3 consecutive failures
383
- if (this.consecutiveCompactionFailures >= 3) {
384
- logger?.warn(`Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`);
385
- return;
386
- }
387
- await this.compactConversation({
388
- abortSignal: abortController.signal,
389
- });
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 });
390
409
  }
391
410
  }
392
411
  /**
@@ -431,7 +450,7 @@ export class AIManager {
431
450
  // 4. Fork path: fork the conversation with the same system prompt,
432
451
  // tools, model, and generation params as the main loop so the forked
433
452
  // request prefix matches exactly and the prompt cache is reused.
434
- const forkResult = await this.runCompactFork(recentChatMessages, compactPrompt, options.abortSignal);
453
+ const forkResult = await this.runCompactFork(recentChatMessages, compactPrompt, options.abortSignal, this.callbacks?.onCompactionContentUpdate, this.callbacks?.onCompactionReasoningUpdate);
435
454
  const summaryContent = forkResult.content;
436
455
  const compactTokens = forkResult.usage;
437
456
  if (!summaryContent) {
@@ -595,11 +614,31 @@ export class AIManager {
595
614
  workdir,
596
615
  tools: toolsConfig,
597
616
  systemPrompt,
617
+ maxTokens: this.configurationService.resolveMaxOutputTokens(),
598
618
  toolChoice: this.toolChoiceOverride,
599
619
  // Stream so a slow reasoning model emits first bytes before the
600
620
  // gateway's idle timeout fires (non-streaming waits for the full
601
621
  // response, which exceeds the timeout on large contexts).
602
622
  stream: true,
623
+ // Surface partial output to the caller (e.g. the compaction loading
624
+ // tail) as it arrives. Reasoning chunks from thinking models go to
625
+ // the reasoning channel when the caller supplies one; otherwise they
626
+ // fall back to the content channel (CLI mixes both).
627
+ onContentUpdate: (content) => {
628
+ if (content.trim()) {
629
+ options.onContentUpdate?.(content);
630
+ }
631
+ },
632
+ onReasoningUpdate: (reasoning) => {
633
+ if (reasoning.trim()) {
634
+ if (options.onReasoningUpdate) {
635
+ options.onReasoningUpdate(reasoning);
636
+ }
637
+ else {
638
+ options.onContentUpdate?.(reasoning);
639
+ }
640
+ }
641
+ },
603
642
  });
604
643
  if (result.usage) {
605
644
  totalUsage = {
@@ -652,10 +691,12 @@ export class AIManager {
652
691
  * Fork-path compaction: deny all tool calls locally (the model is told to
653
692
  * summarize, not act) and feed the rejections back for another turn.
654
693
  */
655
- async runCompactFork(historyMessages, compactPrompt, abortSignal) {
694
+ async runCompactFork(historyMessages, compactPrompt, abortSignal, onContentUpdate, onReasoningUpdate) {
656
695
  return this.runForkLoop(historyMessages, compactPrompt, {
657
696
  maxTurns: MAX_FORK_TURNS,
658
697
  deniedToolMessage: "Tool use is not allowed during compaction",
698
+ onContentUpdate,
699
+ onReasoningUpdate,
659
700
  }, abortSignal);
660
701
  }
661
702
  /**
@@ -811,8 +852,10 @@ export class AIManager {
811
852
  }
812
853
  };
813
854
  // Fire-and-forget: the fork runs to completion in the background; the
814
- // caller gets the task ID immediately.
815
- (async () => {
855
+ // caller gets the task ID immediately. Tracked in the async work registry
856
+ // so destroy() drains it before returning (abort via the task's onStop
857
+ // makes the loop settle).
858
+ const forkPromise = (async () => {
816
859
  try {
817
860
  const result = await this.runForkLoop(historyMessages, prompt, {
818
861
  // /subtask aligns with Claude Code's fork subagent
@@ -859,6 +902,7 @@ export class AIManager {
859
902
  abortCleanup?.();
860
903
  }
861
904
  })();
905
+ this.asyncWorkRegistry?.track(forkPromise);
862
906
  return taskId;
863
907
  }
864
908
  /**
@@ -1002,6 +1046,7 @@ ${question}`;
1002
1046
  workdir,
1003
1047
  tools: toolsConfig,
1004
1048
  systemPrompt,
1049
+ maxTokens: this.configurationService.resolveMaxOutputTokens(),
1005
1050
  toolChoice: this.toolChoiceOverride,
1006
1051
  // Stream so a slow reasoning model emits first bytes before the
1007
1052
  // gateway's idle timeout fires (same rationale as runCompactFork).
@@ -1254,6 +1299,10 @@ ${question}`;
1254
1299
  isMeta: true,
1255
1300
  });
1256
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);
1257
1306
  // Get recent message history
1258
1307
  const rawMessages = this.messageManager.getMessages();
1259
1308
  const currentModelConfig = this.getModelConfig();
@@ -1278,7 +1327,7 @@ ${question}`;
1278
1327
  tools: toolsConfig, // Pass filtered tool configuration
1279
1328
  model: model, // Use passed model
1280
1329
  systemPrompt: mainSystemPrompt, // Pass custom system prompt
1281
- maxTokens: maxTokens, // Pass max tokens override
1330
+ maxTokens: maxTokens ?? this.configurationService.resolveMaxOutputTokens(), // Pass max tokens override, falling back to the resolved global value
1282
1331
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
1283
1332
  // Fast-model subagents send disable-thinking params only when
1284
1333
  // explicitly configured (never in the agent loop).
@@ -1462,8 +1511,9 @@ ${question}`;
1462
1511
  }
1463
1512
  }
1464
1513
  }
1465
- // Handle token statistics and message compaction
1466
- 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);
1467
1517
  // Finalize text/reasoning blocks for the final response (no tools)
1468
1518
  this.messageManager.finalizeStreamingBlocks();
1469
1519
  // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call