wave-agent-sdk 1.0.5 → 1.0.6

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 (193) hide show
  1. package/dist/agent.d.ts +23 -1
  2. package/dist/agent.js +34 -4
  3. package/dist/managers/aiManager.d.ts +36 -0
  4. package/dist/managers/aiManager.js +240 -8
  5. package/dist/managers/messageManager.d.ts +1 -3
  6. package/dist/managers/messageManager.js +13 -6
  7. package/dist/managers/slashCommandManager.js +28 -0
  8. package/dist/managers/subagentManager.d.ts +0 -2
  9. package/dist/telemetry/instrumentation.d.ts +1 -1
  10. package/dist/telemetry/instrumentation.js +57 -28
  11. package/dist/tools/bashTool.js +16 -6
  12. package/dist/tools/grepTool.js +11 -3
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/messaging.d.ts +2 -0
  15. package/dist/utils/containerSetup.js +0 -3
  16. package/dist/utils/messageOperations.d.ts +2 -1
  17. package/dist/utils/messageOperations.js +2 -1
  18. package/dist/utils/notificationXml.js +5 -0
  19. package/dist/utils/ripgrep.d.ts +4 -4
  20. package/dist/utils/ripgrep.js +4 -27
  21. package/package.json +20 -8
  22. package/bin/rg +0 -79
  23. package/dist/managers/forkedAgentManager.d.ts +0 -50
  24. package/dist/managers/forkedAgentManager.js +0 -130
  25. package/scripts/install_ripgrep.js +0 -111
  26. package/scripts/postinstall.js +0 -38
  27. package/src/agent.ts +0 -1271
  28. package/src/constants/subagents.ts +0 -4
  29. package/src/constants/toolLimits.ts +0 -15
  30. package/src/constants/tools.ts +0 -24
  31. package/src/core/plugin.ts +0 -237
  32. package/src/core/session.ts +0 -9
  33. package/src/index.ts +0 -38
  34. package/src/managers/MemoryRuleManager.ts +0 -198
  35. package/src/managers/aiManager.ts +0 -2429
  36. package/src/managers/backgroundTaskManager.ts +0 -480
  37. package/src/managers/bangManager.ts +0 -119
  38. package/src/managers/cronManager.ts +0 -401
  39. package/src/managers/foregroundTaskManager.ts +0 -30
  40. package/src/managers/forkedAgentManager.ts +0 -216
  41. package/src/managers/hookManager.ts +0 -1089
  42. package/src/managers/liveConfigManager.ts +0 -445
  43. package/src/managers/lspManager.ts +0 -470
  44. package/src/managers/mcpManager.ts +0 -1010
  45. package/src/managers/messageManager.ts +0 -1184
  46. package/src/managers/messageQueue.ts +0 -157
  47. package/src/managers/permissionManager.ts +0 -1140
  48. package/src/managers/planManager.ts +0 -112
  49. package/src/managers/pluginManager.ts +0 -330
  50. package/src/managers/pluginScopeManager.ts +0 -124
  51. package/src/managers/reversionManager.ts +0 -178
  52. package/src/managers/skillManager.ts +0 -578
  53. package/src/managers/slashCommandManager.ts +0 -564
  54. package/src/managers/subagentManager.ts +0 -915
  55. package/src/managers/toolManager.ts +0 -489
  56. package/src/managers/workflowManager.ts +0 -493
  57. package/src/prompts/autoMemory.ts +0 -33
  58. package/src/prompts/autoMemoryExtraction.ts +0 -146
  59. package/src/prompts/index.ts +0 -506
  60. package/src/prompts/planModeReminders.ts +0 -126
  61. package/src/services/GitService.ts +0 -131
  62. package/src/services/MarketplaceService.ts +0 -1052
  63. package/src/services/MemoryRuleService.ts +0 -71
  64. package/src/services/aiService.ts +0 -919
  65. package/src/services/authService.ts +0 -565
  66. package/src/services/autoMemoryService.ts +0 -280
  67. package/src/services/configurationService.ts +0 -1520
  68. package/src/services/fileWatcher.ts +0 -319
  69. package/src/services/hook.ts +0 -348
  70. package/src/services/initializationService.ts +0 -357
  71. package/src/services/interactionService.ts +0 -229
  72. package/src/services/jsonlHandler.ts +0 -295
  73. package/src/services/memory.ts +0 -234
  74. package/src/services/pluginLoader.ts +0 -252
  75. package/src/services/remoteSettingsService.ts +0 -366
  76. package/src/services/reversionService.ts +0 -122
  77. package/src/services/session.ts +0 -917
  78. package/src/services/taskManager.ts +0 -321
  79. package/src/telemetry/events.ts +0 -65
  80. package/src/telemetry/instrumentation.ts +0 -499
  81. package/src/telemetry/sessionTracing.ts +0 -348
  82. package/src/tools/agentTool.ts +0 -322
  83. package/src/tools/askUserQuestion.ts +0 -160
  84. package/src/tools/bashTool.ts +0 -621
  85. package/src/tools/buildTool.ts +0 -61
  86. package/src/tools/cronCreateTool.ts +0 -161
  87. package/src/tools/cronDeleteTool.ts +0 -51
  88. package/src/tools/cronListTool.ts +0 -47
  89. package/src/tools/editTool.ts +0 -313
  90. package/src/tools/enterPlanMode.ts +0 -124
  91. package/src/tools/enterWorktreeTool.ts +0 -190
  92. package/src/tools/exitPlanMode.ts +0 -139
  93. package/src/tools/exitWorktreeTool.ts +0 -236
  94. package/src/tools/globTool.ts +0 -167
  95. package/src/tools/grepTool.ts +0 -399
  96. package/src/tools/lspTool.ts +0 -883
  97. package/src/tools/readTool.ts +0 -426
  98. package/src/tools/skillTool.ts +0 -248
  99. package/src/tools/taskManagementTools.ts +0 -574
  100. package/src/tools/taskStopTool.ts +0 -78
  101. package/src/tools/types.ts +0 -137
  102. package/src/tools/webFetchTool.ts +0 -373
  103. package/src/tools/workflowTool.ts +0 -205
  104. package/src/tools/writeTool.ts +0 -267
  105. package/src/types/agent.ts +0 -128
  106. package/src/types/auth.ts +0 -19
  107. package/src/types/commands.ts +0 -30
  108. package/src/types/config.ts +0 -43
  109. package/src/types/configuration.ts +0 -168
  110. package/src/types/core.ts +0 -105
  111. package/src/types/cron.ts +0 -13
  112. package/src/types/environment.ts +0 -106
  113. package/src/types/fileSearch.ts +0 -4
  114. package/src/types/history.ts +0 -7
  115. package/src/types/hooks.ts +0 -256
  116. package/src/types/index.ts +0 -41
  117. package/src/types/lsp.ts +0 -98
  118. package/src/types/marketplace.ts +0 -71
  119. package/src/types/mcp.ts +0 -43
  120. package/src/types/memoryRule.ts +0 -31
  121. package/src/types/messaging.ts +0 -114
  122. package/src/types/permissions.ts +0 -80
  123. package/src/types/plugins.ts +0 -39
  124. package/src/types/processes.ts +0 -99
  125. package/src/types/reversion.ts +0 -29
  126. package/src/types/session.ts +0 -12
  127. package/src/types/skills.ts +0 -108
  128. package/src/types/tasks.ts +0 -13
  129. package/src/types/telemetry.ts +0 -98
  130. package/src/types/tools.ts +0 -44
  131. package/src/types/workflow.ts +0 -6
  132. package/src/utils/abortUtils.ts +0 -118
  133. package/src/utils/atomicWrite.ts +0 -61
  134. package/src/utils/bashParser.ts +0 -899
  135. package/src/utils/cacheControlUtils.ts +0 -426
  136. package/src/utils/commandArgumentParser.ts +0 -161
  137. package/src/utils/commandPathResolver.ts +0 -88
  138. package/src/utils/configPaths.ts +0 -220
  139. package/src/utils/configValidator.ts +0 -100
  140. package/src/utils/constants.ts +0 -39
  141. package/src/utils/container.ts +0 -92
  142. package/src/utils/containerSetup.ts +0 -371
  143. package/src/utils/convertMessagesForAPI.ts +0 -326
  144. package/src/utils/cronTasks.ts +0 -128
  145. package/src/utils/cronTasksLock.ts +0 -182
  146. package/src/utils/cronToHuman.ts +0 -99
  147. package/src/utils/customCommands.ts +0 -95
  148. package/src/utils/editUtils.ts +0 -20
  149. package/src/utils/fileFormat.ts +0 -40
  150. package/src/utils/fileSearch.ts +0 -151
  151. package/src/utils/fileUtils.ts +0 -223
  152. package/src/utils/gitUtils.ts +0 -315
  153. package/src/utils/globalLogger.ts +0 -128
  154. package/src/utils/groupMessagesByApiRound.ts +0 -121
  155. package/src/utils/hookMatcher.ts +0 -176
  156. package/src/utils/markdownParser.ts +0 -317
  157. package/src/utils/mcpUtils.ts +0 -138
  158. package/src/utils/messageOperations.ts +0 -659
  159. package/src/utils/modelCapabilities.ts +0 -30
  160. package/src/utils/nameGenerator.ts +0 -99
  161. package/src/utils/notificationXml.ts +0 -57
  162. package/src/utils/openaiClient.ts +0 -251
  163. package/src/utils/parseCronExpression.ts +0 -78
  164. package/src/utils/path.ts +0 -72
  165. package/src/utils/pathEncoder.ts +0 -400
  166. package/src/utils/pathSafety.ts +0 -40
  167. package/src/utils/promptHistory.ts +0 -168
  168. package/src/utils/ripgrep.ts +0 -35
  169. package/src/utils/shellResolver.ts +0 -190
  170. package/src/utils/skillParser.ts +0 -246
  171. package/src/utils/stringUtils.ts +0 -156
  172. package/src/utils/subagentParser.ts +0 -279
  173. package/src/utils/taskReminder.ts +0 -96
  174. package/src/utils/tokenCalculation.ts +0 -43
  175. package/src/utils/tokenEstimate.ts +0 -34
  176. package/src/utils/toolResultStorage.ts +0 -117
  177. package/src/utils/worktreeSession.ts +0 -26
  178. package/src/utils/worktreeUtils.ts +0 -786
  179. package/src/workflow/budgetTracker.ts +0 -34
  180. package/src/workflow/concurrencyLimiter.ts +0 -47
  181. package/src/workflow/journal.ts +0 -95
  182. package/src/workflow/progressReporter.ts +0 -141
  183. package/src/workflow/runState.ts +0 -65
  184. package/src/workflow/scriptRuntime.ts +0 -274
  185. package/src/workflow/structuredOutput.ts +0 -123
  186. package/src/workflow/types.ts +0 -101
  187. package/src/workflow/workflowApis.ts +0 -410
  188. package/vendor/ripgrep/linux-aarch64/rg +0 -0
  189. package/vendor/ripgrep/linux-x86_64/rg +0 -0
  190. package/vendor/ripgrep/macos-aarch64/rg +0 -0
  191. package/vendor/ripgrep/macos-x86_64/rg +0 -0
  192. package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
  193. package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
package/dist/agent.d.ts CHANGED
@@ -14,7 +14,6 @@ export declare class Agent {
14
14
  private permissionManager;
15
15
  private planManager;
16
16
  private subagentManager;
17
- private forkedAgentManager;
18
17
  private slashCommandManager;
19
18
  private pluginManager;
20
19
  private skillManager;
@@ -24,6 +23,7 @@ export declare class Agent {
24
23
  private messageQueue;
25
24
  private dispatchPromise;
26
25
  private isAborting;
26
+ private dispatchAborted;
27
27
  private memoryRuleManager;
28
28
  private liveConfigManager;
29
29
  private taskManager;
@@ -269,6 +269,28 @@ export declare class Agent {
269
269
  * @returns Promise that resolves to the AI's answer
270
270
  */
271
271
  askBtw(question: string, abortSignal?: AbortSignal, onContent?: (content: string) => void, onReasoning?: (content: string) => void): Promise<string>;
272
+ /**
273
+ * Start a fork subagent in the background (the "/subtask" command path).
274
+ *
275
+ * The fork inherits the parent's full conversation context — same system
276
+ * prompt, tools, model, and message prefix — so the prompt cache is reused,
277
+ * then works independently in the background. On completion its final
278
+ * response is delivered back to the main conversation as a task notification
279
+ * carrying the `<result>` tag. Recursion is prevented inside the fork (the
280
+ * Agent tool and Task tools are denied).
281
+ *
282
+ * @param prompt - The task description for the fork subagent
283
+ * @param options - Background task label and optional loose turn bound
284
+ * @param options.description - Display label for the background task
285
+ * @param options.maxTurns - Loose turn bound (defaults to 200, aligned with
286
+ * Claude Code's fork subagent)
287
+ * @param abortSignal - Optional signal to abort the fork
288
+ * @returns Promise that resolves to the background task ID
289
+ */
290
+ forkSubagent(prompt: string, options: {
291
+ description: string;
292
+ maxTurns?: number;
293
+ }, abortSignal?: AbortSignal): Promise<string>;
272
294
  /**
273
295
  * Send a message to the AI agent with optional images
274
296
  *
package/dist/agent.js CHANGED
@@ -54,6 +54,7 @@ export class Agent {
54
54
  this.bangManager = null;
55
55
  this.dispatchPromise = null; // Track current dispatch for teardown
56
56
  this.isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
57
+ 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
57
58
  this.sessionStartTime = Date.now();
58
59
  const { logger, workdir, systemPrompt, stream = true } = options;
59
60
  // Set working directory early as we need it for loading configuration
@@ -106,7 +107,6 @@ export class Agent {
106
107
  this.toolManager = this.container.get("ToolManager");
107
108
  this.liveConfigManager = this.container.get("LiveConfigManager");
108
109
  this.subagentManager = this.container.get("SubagentManager");
109
- this.forkedAgentManager = this.container.get("ForkedAgentManager");
110
110
  this.aiManager = this.container.get("AIManager");
111
111
  this.slashCommandManager = this.container.get("SlashCommandManager");
112
112
  this.pluginManager = this.container.get("PluginManager");
@@ -265,6 +265,8 @@ export class Agent {
265
265
  tryDispatch() {
266
266
  if (this.isAborting)
267
267
  return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
268
+ if (this.dispatchAborted)
269
+ return; // Aborted mid-dispatch: don't start a new dispatch (the user interrupted this turn)
268
270
  if (this.messageQueue.state !== "idle")
269
271
  return;
270
272
  if (!this.messageQueue.hasPending())
@@ -279,7 +281,10 @@ export class Agent {
279
281
  .finally(() => {
280
282
  this.messageQueue.transitionTo("idle");
281
283
  this.dispatchPromise = null;
282
- this.tryDispatch(); // Re-check after processing
284
+ if (!this.dispatchAborted) {
285
+ this.tryDispatch(); // Re-check after processing
286
+ }
287
+ this.dispatchAborted = false;
283
288
  });
284
289
  }
285
290
  /**
@@ -571,6 +576,12 @@ export class Agent {
571
576
  // from dispatching preserved notifications as a new AI turn during the abort.
572
577
  this.isAborting = true;
573
578
  try {
579
+ // If a queued dispatch is in flight (e.g. a notification turn was being
580
+ // processed), remember the abort so the .finally re-check won't start a
581
+ // new dispatch with the preserved notifications right after aborting.
582
+ if (this.dispatchPromise) {
583
+ this.dispatchAborted = true;
584
+ }
574
585
  if (this.aiManager.isLoading || this.isCommandRunning) {
575
586
  // Clear user-facing queue items first to prevent processQueuedMessage
576
587
  // from dequeuing when abortAIMessage triggers onLoadingChange(false).
@@ -684,8 +695,6 @@ export class Agent {
684
695
  }
685
696
  // Cleanup subagent manager
686
697
  this.subagentManager.cleanup();
687
- // Cleanup forked agent manager
688
- await this.forkedAgentManager.cleanup();
689
698
  // Drain an in-flight auto-memory extraction fork so the process doesn't
690
699
  // exit mid-extraction
691
700
  try {
@@ -732,6 +741,27 @@ export class Agent {
732
741
  const result = await this.aiManager.runBtwFork(question, abortSignal, onContent, onReasoning);
733
742
  return result.content ?? result.error ?? "No response received";
734
743
  }
744
+ /**
745
+ * Start a fork subagent in the background (the "/subtask" command path).
746
+ *
747
+ * The fork inherits the parent's full conversation context — same system
748
+ * prompt, tools, model, and message prefix — so the prompt cache is reused,
749
+ * then works independently in the background. On completion its final
750
+ * response is delivered back to the main conversation as a task notification
751
+ * carrying the `<result>` tag. Recursion is prevented inside the fork (the
752
+ * Agent tool and Task tools are denied).
753
+ *
754
+ * @param prompt - The task description for the fork subagent
755
+ * @param options - Background task label and optional loose turn bound
756
+ * @param options.description - Display label for the background task
757
+ * @param options.maxTurns - Loose turn bound (defaults to 200, aligned with
758
+ * Claude Code's fork subagent)
759
+ * @param abortSignal - Optional signal to abort the fork
760
+ * @returns Promise that resolves to the background task ID
761
+ */
762
+ async forkSubagent(prompt, options, abortSignal) {
763
+ return this.aiManager.runForkSubagent(prompt, options, abortSignal);
764
+ }
735
765
  /**
736
766
  * Send a message to the AI agent with optional images
737
767
  *
@@ -40,6 +40,8 @@ export declare class AIManager {
40
40
  private modelOverride?;
41
41
  private _onCwdChange?;
42
42
  private originalWorkdir;
43
+ /** Pending cwd-recovery notice (set when getWorkdir() auto-recovers), consumed by tools */
44
+ private pendingCwdRecovery;
43
45
  private consecutiveCompactionFailures;
44
46
  private readonly maxTurns?;
45
47
  /** Tracks file mtime/hash at read time for staleness detection on Edit/Write */
@@ -77,6 +79,16 @@ export declare class AIManager {
77
79
  getAutoMemoryEnabled(): boolean;
78
80
  getWorktreeBaseRef(): "fresh" | "head";
79
81
  getWorkdir(): string;
82
+ /**
83
+ * Consume a pending cwd-recovery notice (if any). After the session
84
+ * workdir was auto-recovered because the previous directory disappeared,
85
+ * the first tool that calls this surfaces the notice to the model.
86
+ * Returns null when no recovery is pending.
87
+ */
88
+ consumeCwdRecovery(): {
89
+ from: string;
90
+ to: string;
91
+ } | null;
80
92
  getOriginalWorkdir(): string;
81
93
  /**
82
94
  * Update the working directory mid-session (e.g., when entering/exiting a worktree).
@@ -155,6 +167,30 @@ export declare class AIManager {
155
167
  deniedToolMessage?: string;
156
168
  maxTurns?: number;
157
169
  }, abortSignal?: AbortSignal): Promise<ForkLoopResult>;
170
+ /**
171
+ * Fork subagent ("/subtask"): spawn a background agent that inherits the
172
+ * parent's full conversation context — the same system prompt, tools, model,
173
+ * and message prefix as the main loop — so the forked request prefix matches
174
+ * exactly and the prompt cache is reused. The fork runs to completion in the
175
+ * background via the shared BackgroundTaskManager (visible in /tasks, log at
176
+ * `os.tmpdir()/wave-subagent-<taskId>.log`); its final response is enqueued
177
+ * as a task notification carrying the `<result>` tag, so the main agent
178
+ * picks it up on the next turn and can act on it.
179
+ *
180
+ * Recursion is prevented by denying the Agent tool and the Task management
181
+ * tools (the fork cannot spawn further subagents or mutate the shared task
182
+ * list), and by honoring the parent's denied rules for everything else.
183
+ * Gate-approved tools execute locally in the stripped fork context (no
184
+ * permission prompts), mirroring the other fork paths.
185
+ *
186
+ * Returns the background task ID (empty string when no BackgroundTaskManager
187
+ * is registered, in which case the fork still runs but without task
188
+ * bookkeeping or notification).
189
+ */
190
+ runForkSubagent(prompt: string, options: {
191
+ description: string;
192
+ maxTurns?: number;
193
+ }, abortSignal?: AbortSignal): Promise<string>;
158
194
  /**
159
195
  * Parse a fork tool call's JSON arguments, recovering truncated JSON the
160
196
  * same way the main loop does. Unparseable arguments fall back to `{}` and
@@ -1,14 +1,18 @@
1
1
  import * as aiService from "../services/aiService.js";
2
2
  import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
3
3
  import { supportsVision } from "../utils/modelCapabilities.js";
4
- import { parseTaskNotificationXml } from "../utils/notificationXml.js";
4
+ import { parseTaskNotificationXml, taskNotificationToXml, } from "../utils/notificationXml.js";
5
5
  import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
6
6
  import { estimateTokens } from "../utils/tokenEstimate.js";
7
7
  import { getTaskReminderTurnCounts, maybeInjectTaskReminder, TASK_REMINDER_CONFIG, } from "../utils/taskReminder.js";
8
- import { existsSync } from "node:fs";
8
+ import { createWriteStream, existsSync } from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
9
11
  import { buildSystemPrompt, formatCompactSummary, getCompactPrompt, } from "../prompts/index.js";
10
12
  import { buildPlanModeReminder, buildPlanModeReEntryReminder, buildExitedPlanModeReminder, wrapInSystemReminder, } from "../prompts/planModeReminders.js";
11
13
  import { recoverTruncatedJson } from "../utils/stringUtils.js";
14
+ import { AGENT_TOOL_NAME, TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_LIST_TOOL_NAME, TASK_UPDATE_TOOL_NAME, } from "../constants/tools.js";
15
+ import { addConsolidatedAbortListener } from "../utils/abortUtils.js";
12
16
  import { logger } from "../utils/globalLogger.js";
13
17
  import { startInteractionSpan, endInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, resetTracingState, } from "../telemetry/sessionTracing.js";
14
18
  import { logOTelEvent } from "../telemetry/events.js";
@@ -60,6 +64,8 @@ export class AIManager {
60
64
  this.turnGeneration = 0; // Guards against concurrent sendAIMessage turns
61
65
  this.abortController = null;
62
66
  this.toolAbortController = null;
67
+ /** Pending cwd-recovery notice (set when getWorkdir() auto-recovers), consumed by tools */
68
+ this.pendingCwdRecovery = null;
63
69
  this.consecutiveCompactionFailures = 0;
64
70
  /** Tracks file mtime/hash at read time for staleness detection on Edit/Write */
65
71
  this.readFileState = new Map();
@@ -151,7 +157,45 @@ export class AIManager {
151
157
  return this.configurationService.resolveWorktreeBaseRef();
152
158
  }
153
159
  getWorkdir() {
154
- return this.container.get("Workdir") ?? process.cwd();
160
+ const workdir = this.container.get("Workdir") ?? process.cwd();
161
+ if (existsSync(workdir)) {
162
+ return workdir;
163
+ }
164
+ // The stored workdir no longer exists — e.g. the current worktree
165
+ // directory was removed manually with `git worktree remove` outside
166
+ // wave's exit flow. Node spawn() chdirs to `cwd` before spawning, so
167
+ // every tool that spawns a subprocess (Bash, Grep, background tasks)
168
+ // would fail with ENOENT and cripple the session. Recover to the
169
+ // original workdir (main repo) so the session keeps working.
170
+ if (!existsSync(this.originalWorkdir)) {
171
+ // No valid fallback — keep the stale value (sessions started in a
172
+ // directory that was deleted since cannot recover to anything real).
173
+ return workdir;
174
+ }
175
+ this.pendingCwdRecovery = { from: workdir, to: this.originalWorkdir };
176
+ // If the removed directory was this session's worktree, drop the stale
177
+ // worktree session state so ExitWorktree no-ops and EnterWorktree can
178
+ // start a fresh session.
179
+ const worktreeSession = this.getWorktreeSession();
180
+ if (worktreeSession &&
181
+ path.resolve(worktreeSession.worktreePath) === path.resolve(workdir)) {
182
+ this.setWorktreeSession(null);
183
+ }
184
+ logger.warn(`Working directory ${workdir} no longer exists; recovered session workdir to ${this.originalWorkdir}`);
185
+ this.container.register("Workdir", this.originalWorkdir);
186
+ this._onCwdChange?.(this.originalWorkdir);
187
+ return this.originalWorkdir;
188
+ }
189
+ /**
190
+ * Consume a pending cwd-recovery notice (if any). After the session
191
+ * workdir was auto-recovered because the previous directory disappeared,
192
+ * the first tool that calls this surfaces the notice to the model.
193
+ * Returns null when no recovery is pending.
194
+ */
195
+ consumeCwdRecovery() {
196
+ const recovery = this.pendingCwdRecovery;
197
+ this.pendingCwdRecovery = null;
198
+ return recovery;
155
199
  }
156
200
  getOriginalWorkdir() {
157
201
  return this.originalWorkdir;
@@ -628,6 +672,179 @@ export class AIManager {
628
672
  }
629
673
  return result;
630
674
  }
675
+ /**
676
+ * Fork subagent ("/subtask"): spawn a background agent that inherits the
677
+ * parent's full conversation context — the same system prompt, tools, model,
678
+ * and message prefix as the main loop — so the forked request prefix matches
679
+ * exactly and the prompt cache is reused. The fork runs to completion in the
680
+ * background via the shared BackgroundTaskManager (visible in /tasks, log at
681
+ * `os.tmpdir()/wave-subagent-<taskId>.log`); its final response is enqueued
682
+ * as a task notification carrying the `<result>` tag, so the main agent
683
+ * picks it up on the next turn and can act on it.
684
+ *
685
+ * Recursion is prevented by denying the Agent tool and the Task management
686
+ * tools (the fork cannot spawn further subagents or mutate the shared task
687
+ * list), and by honoring the parent's denied rules for everything else.
688
+ * Gate-approved tools execute locally in the stripped fork context (no
689
+ * permission prompts), mirroring the other fork paths.
690
+ *
691
+ * Returns the background task ID (empty string when no BackgroundTaskManager
692
+ * is registered, in which case the fork still runs but without task
693
+ * bookkeeping or notification).
694
+ */
695
+ async runForkSubagent(prompt, options, abortSignal) {
696
+ const modelConfig = this.getModelConfig();
697
+ // Snapshot the conversation synchronously: the /subtask handler records
698
+ // the command echo in the transcript after this call, so the fork never
699
+ // sees its own trigger message duplicated.
700
+ const historyMessages = convertMessagesForAPI(this.messageManager.getMessages(), { supportsVision: supportsVision(modelConfig.capabilities) });
701
+ const backgroundTaskManager = this.backgroundTaskManager;
702
+ const permissionManager = this.permissionManager;
703
+ const messageQueue = this.container.has("MessageQueue")
704
+ ? this.container.get("MessageQueue")
705
+ : undefined;
706
+ // Combine the caller's signal with an internal one so a task kill
707
+ // (BackgroundTaskManager.stopTask / cleanup) aborts the in-flight fork.
708
+ const abortController = new AbortController();
709
+ let abortCleanup;
710
+ if (abortSignal) {
711
+ abortCleanup = addConsolidatedAbortListener(abortSignal, [
712
+ () => abortController.abort(),
713
+ ]);
714
+ }
715
+ // Recursion prevention: deny the Agent tool and Task tools, then honor the
716
+ // parent's denied rules for everything else.
717
+ const deniedTools = new Set([
718
+ AGENT_TOOL_NAME,
719
+ TASK_CREATE_TOOL_NAME,
720
+ TASK_GET_TOOL_NAME,
721
+ TASK_UPDATE_TOOL_NAME,
722
+ TASK_LIST_TOOL_NAME,
723
+ ]);
724
+ const canUseTool = (name) => {
725
+ if (deniedTools.has(name))
726
+ return false;
727
+ if (permissionManager?.isToolDenied(name))
728
+ return false;
729
+ return true;
730
+ };
731
+ const deniedToolMessage = "The Agent tool and Task tools are not available in a fork subagent.";
732
+ // Background task bookkeeping mirrors subagentManager.executeAgent.
733
+ let taskId = "";
734
+ let logStream;
735
+ if (backgroundTaskManager) {
736
+ taskId = backgroundTaskManager.generateId();
737
+ const startTime = Date.now();
738
+ const logPath = path.join(os.tmpdir(), `wave-subagent-${taskId}.log`);
739
+ logStream = createWriteStream(logPath, { flags: "a" });
740
+ backgroundTaskManager.addTask({
741
+ id: taskId,
742
+ type: "subagent",
743
+ status: "running",
744
+ startTime,
745
+ description: truncateWithMarker(options.description, 1000),
746
+ stdout: "",
747
+ stderr: "",
748
+ outputPath: logPath,
749
+ onStop: () => {
750
+ logStream?.destroy();
751
+ logStream = undefined;
752
+ abortController.abort();
753
+ },
754
+ });
755
+ logStream.write(`[${new Date().toISOString()}] Fork subagent started: ${options.description}\n`);
756
+ }
757
+ const finish = async (status, content) => {
758
+ if (!backgroundTaskManager || !taskId)
759
+ return;
760
+ const task = backgroundTaskManager.getTask(taskId);
761
+ if (!task)
762
+ return;
763
+ const wasAlreadyKilled = task.status === "killed";
764
+ const endTime = Date.now();
765
+ if (status === "completed") {
766
+ task.status = "completed";
767
+ task.stdout = content;
768
+ logStream?.write(`[${new Date().toISOString()}] Final response:\n${content}\n`);
769
+ }
770
+ else {
771
+ task.status = "failed";
772
+ task.stderr = content;
773
+ logStream?.write(`[${new Date().toISOString()}] Fork subagent failed: ${content}\n`);
774
+ }
775
+ task.endTime = endTime;
776
+ if (task.startTime)
777
+ task.runtime = endTime - task.startTime;
778
+ logStream?.end();
779
+ logStream = undefined;
780
+ // Skip the notification when the task was already stopped (e.g. by main
781
+ // agent shutdown) — mirrors subagentManager's wasAlreadyKilled guard.
782
+ if (!wasAlreadyKilled && messageQueue) {
783
+ const description = truncateWithMarker(options.description, 1000);
784
+ const summary = status === "completed"
785
+ ? `Subtask "${description}" completed`
786
+ : `Subtask "${description}" failed: ${content}`;
787
+ messageQueue.enqueueNotification(taskNotificationToXml({
788
+ type: "task_notification",
789
+ taskId,
790
+ taskType: "agent",
791
+ status,
792
+ summary,
793
+ ...(status === "completed" && { result: content }),
794
+ }));
795
+ }
796
+ };
797
+ // Fire-and-forget: the fork runs to completion in the background; the
798
+ // caller gets the task ID immediately.
799
+ (async () => {
800
+ try {
801
+ const result = await this.runForkLoop(historyMessages, prompt, {
802
+ // /subtask aligns with Claude Code's fork subagent
803
+ // (forkSubagent.ts:65): a loose upper bound that practically
804
+ // never binds.
805
+ maxTurns: options.maxTurns ?? 200,
806
+ canUseTool,
807
+ deniedToolMessage,
808
+ }, abortController.signal);
809
+ if (result.usage && this.callbacks?.onUsageAdded) {
810
+ this.callbacks.onUsageAdded({
811
+ ...result.usage,
812
+ model: modelConfig.model,
813
+ operation_type: "agent",
814
+ });
815
+ }
816
+ if (result.content?.trim()) {
817
+ await finish("completed", result.content);
818
+ }
819
+ else {
820
+ await finish("failed", "Fork subagent produced no text response");
821
+ }
822
+ }
823
+ catch (error) {
824
+ // Abort is the expected exit for a killed task; stopTask has already
825
+ // marked it "killed", so no notification is sent.
826
+ if (abortController.signal.aborted) {
827
+ const task = backgroundTaskManager?.getTask(taskId);
828
+ if (task && task.status === "running") {
829
+ task.status = "killed";
830
+ task.endTime = Date.now();
831
+ if (task.startTime)
832
+ task.runtime = task.endTime - task.startTime;
833
+ }
834
+ logStream?.end();
835
+ logStream = undefined;
836
+ }
837
+ else {
838
+ logger?.error("Fork subagent execution failed:", error);
839
+ await finish("failed", error instanceof Error ? error.message : String(error));
840
+ }
841
+ }
842
+ finally {
843
+ abortCleanup?.();
844
+ }
845
+ })();
846
+ return taskId;
847
+ }
631
848
  /**
632
849
  * Parse a fork tool call's JSON arguments, recovering truncated JSON the
633
850
  * same way the main loop does. Unparseable arguments fall back to `{}` and
@@ -1348,9 +1565,15 @@ ${question}`;
1348
1565
  await this.messageManager.saveSession();
1349
1566
  // Clear temporary rules
1350
1567
  this.permissionManager?.clearTemporaryRules();
1351
- // Clear abort controllers
1352
- this.abortController = null;
1353
- this.toolAbortController = null;
1568
+ // Clear abort controllers (only if we still own them — a concurrent
1569
+ // turn may have already registered its own controller, and clearing
1570
+ // it would orphan that turn and make it uninterruptible)
1571
+ if (this.abortController === abortController) {
1572
+ this.abortController = null;
1573
+ }
1574
+ if (this.toolAbortController === toolAbortController) {
1575
+ this.toolAbortController = null;
1576
+ }
1354
1577
  // Execute Stop/SubagentStop hooks only if the operation was not aborted
1355
1578
  const isCurrentlyAborted = abortController.signal.aborted || toolAbortController.signal.aborted;
1356
1579
  if (!isCurrentlyAborted) {
@@ -1373,11 +1596,19 @@ ${question}`;
1373
1596
  }
1374
1597
  }
1375
1598
  // Inject pending notifications from background tasks (after Stop hooks,
1376
- // aligned with Claude Code which fires Stop hooks unconditionally)
1599
+ // aligned with Claude Code which fires Stop hooks unconditionally).
1600
+ // Skip injection when this turn was aborted: folding a pending
1601
+ // notification in would restart the loop and resurrect the aborted
1602
+ // turn — if a concurrent sendMessage is already running (e.g. the
1603
+ // queue's "send now" button), it would spawn a second, orphaned,
1604
+ // uninterruptible agent loop. The notification stays queued for the
1605
+ // next turn.
1377
1606
  const messageQueue = this.container.has("MessageQueue")
1378
1607
  ? this.container.get("MessageQueue")
1379
1608
  : undefined;
1380
- if (messageQueue && messageQueue.hasNotifications()) {
1609
+ if (messageQueue &&
1610
+ messageQueue.hasNotifications() &&
1611
+ !isCurrentlyAborted) {
1381
1612
  const notifications = messageQueue.drainNotifications();
1382
1613
  for (const notification of notifications) {
1383
1614
  const block = parseTaskNotificationXml(notification);
@@ -1388,6 +1619,7 @@ ${question}`;
1388
1619
  status: block.status,
1389
1620
  summary: block.summary,
1390
1621
  outputFile: block.outputFile,
1622
+ result: block.result,
1391
1623
  });
1392
1624
  }
1393
1625
  }
@@ -13,13 +13,11 @@ export interface MessageManagerCallbacks {
13
13
  onAssistantContentUpdated?: (params: {
14
14
  messageId: string;
15
15
  chunk: string;
16
- accumulated: string;
17
16
  stage: "streaming" | "end";
18
17
  }) => void;
19
18
  onAssistantReasoningUpdated?: (params: {
20
19
  messageId: string;
21
20
  chunk: string;
22
- accumulated: string;
23
21
  stage: "streaming" | "end";
24
22
  }) => void;
25
23
  onToolBlockUpdated?: (params: ToolBlockUpdateCallbackParams) => void;
@@ -180,7 +178,7 @@ export declare class MessageManager {
180
178
  /**
181
179
  * Update the current assistant message content during streaming
182
180
  * This method updates the last assistant message's content without creating a new message
183
- * FR-001: Tracks and provides both chunk (new content) and accumulated (total content)
181
+ * FR-001: Tracks and provides chunk (new content since last update)
184
182
  */
185
183
  updateCurrentMessageContent(newAccumulatedContent: string): void;
186
184
  /**
@@ -366,6 +366,16 @@ export class MessageManager {
366
366
  async compactMessagesAndUpdateSession(compactedContent, usage) {
367
367
  // Get last 2 API rounds to preserve (structurally safe boundary)
368
368
  const lastThreeMessages = getLastApiRounds(this.messages, 2);
369
+ // The turn that triggered compaction has already finished streaming, so
370
+ // finalize any still-'streaming' text/reasoning blocks on the preserved
371
+ // last assistant message. Otherwise hosts that pull a full snapshot after
372
+ // compaction (or the loop's own finalizeStreamingBlocks, which no-ops once
373
+ // a plan-mode reminder is appended) would keep showing the streaming dot.
374
+ const lastPreserved = lastThreeMessages[lastThreeMessages.length - 1];
375
+ if (lastPreserved && lastPreserved.role === "assistant") {
376
+ this.finalizeStreamingBlock(lastPreserved, "text");
377
+ this.finalizeStreamingBlock(lastPreserved, "reasoning");
378
+ }
369
379
  // Create compacted message
370
380
  const compactMessage = {
371
381
  id: generateMessageId(),
@@ -526,7 +536,6 @@ export class MessageManager {
526
536
  const callbackParams = {
527
537
  messageId: lastMessage.id,
528
538
  chunk: "",
529
- accumulated: block.content,
530
539
  stage: "end",
531
540
  };
532
541
  if (type === "text") {
@@ -540,7 +549,7 @@ export class MessageManager {
540
549
  /**
541
550
  * Update the current assistant message content during streaming
542
551
  * This method updates the last assistant message's content without creating a new message
543
- * FR-001: Tracks and provides both chunk (new content) and accumulated (total content)
552
+ * FR-001: Tracks and provides chunk (new content since last update)
544
553
  */
545
554
  updateCurrentMessageContent(newAccumulatedContent) {
546
555
  if (this.messages.length === 0)
@@ -573,11 +582,10 @@ export class MessageManager {
573
582
  stage: "streaming",
574
583
  });
575
584
  }
576
- // FR-001: Trigger callbacks with chunk and accumulated content
585
+ // FR-001: Trigger callbacks with chunk delta and stage
577
586
  this.callbacks.onAssistantContentUpdated?.({
578
587
  messageId: lastMessage.id,
579
588
  chunk,
580
- accumulated: newAccumulatedContent,
581
589
  stage: "streaming",
582
590
  });
583
591
  // Note: Subagent-specific callbacks are now handled by SubagentManager
@@ -620,11 +628,10 @@ export class MessageManager {
620
628
  startTime: Date.now(),
621
629
  });
622
630
  }
623
- // Trigger callbacks with chunk and accumulated reasoning content
631
+ // Trigger callbacks with chunk delta and stage
624
632
  this.callbacks.onAssistantReasoningUpdated?.({
625
633
  messageId: lastMessage.id,
626
634
  chunk,
627
- accumulated: newAccumulatedReasoning,
628
635
  stage: "streaming",
629
636
  });
630
637
  }
@@ -14,6 +14,34 @@ export class SlashCommandManager {
14
14
  }
15
15
  initialize() {
16
16
  this.loadCustomCommands();
17
+ // Builtin /subtask: manual fork subagent (aligned with Claude Code's
18
+ // /subtask — inherits the parent's full context, runs in the background,
19
+ // result returns to the main conversation via a task notification).
20
+ this.registerCommand({
21
+ id: "subtask",
22
+ name: "subtask",
23
+ description: "Start a fork subagent with the current conversation context in the background",
24
+ handler: async (args, signal) => {
25
+ const taskDescription = args?.trim();
26
+ if (!taskDescription) {
27
+ this.messageManager.addErrorBlock("Usage: /subtask <task description>");
28
+ return;
29
+ }
30
+ try {
31
+ // The fork snapshots its context synchronously on entry, so the
32
+ // transcript echo below never duplicates the prompt inside the fork.
33
+ await this.aiManager.runForkSubagent(taskDescription, { description: taskDescription }, signal);
34
+ this.messageManager.addUserMessage({
35
+ content: `/subtask ${taskDescription}`,
36
+ });
37
+ }
38
+ catch (error) {
39
+ this.aiManager.setIsLoading(false);
40
+ logger?.error("Failed to execute /subtask:", error);
41
+ this.messageManager.addErrorBlock(`Failed to execute /subtask: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ },
44
+ });
17
45
  // Listen for skill refreshes and update skill commands
18
46
  const skillManager = this.container.get("SkillManager");
19
47
  if (skillManager) {
@@ -19,7 +19,6 @@ export interface SubagentManagerCallbacks {
19
19
  subagentId: string;
20
20
  messageId: string;
21
21
  chunk: string;
22
- accumulated: string;
23
22
  stage: "streaming" | "end";
24
23
  }) => void;
25
24
  /** Triggered during subagent reasoning streaming updates */
@@ -27,7 +26,6 @@ export interface SubagentManagerCallbacks {
27
26
  subagentId: string;
28
27
  messageId: string;
29
28
  chunk: string;
30
- accumulated: string;
31
29
  stage: "streaming" | "end";
32
30
  }) => void;
33
31
  /** Triggered when subagent tool block is updated */
@@ -5,7 +5,7 @@
5
5
  * and graceful shutdown for Wave's OpenTelemetry integration.
6
6
  */
7
7
  import type { TelemetryConfig } from "../types/telemetry.js";
8
- import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-node";
8
+ import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base";
9
9
  import type { LogRecordExporter, ReadableLogRecord } from "@opentelemetry/sdk-logs";
10
10
  /**
11
11
  * Resolve the full TelemetryConfig from env vars + settings.json.