wave-agent-sdk 1.0.0 → 1.0.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.
Files changed (43) hide show
  1. package/dist/agent.d.ts +9 -20
  2. package/dist/agent.js +35 -97
  3. package/dist/managers/aiManager.d.ts +63 -8
  4. package/dist/managers/aiManager.js +274 -80
  5. package/dist/managers/messageManager.d.ts +9 -5
  6. package/dist/managers/messageManager.js +36 -12
  7. package/dist/managers/permissionManager.js +13 -11
  8. package/dist/managers/subagentManager.d.ts +6 -0
  9. package/dist/managers/subagentManager.js +33 -22
  10. package/dist/prompts/index.d.ts +0 -2
  11. package/dist/prompts/index.js +71 -47
  12. package/dist/services/aiService.d.ts +1 -34
  13. package/dist/services/aiService.js +18 -130
  14. package/dist/services/autoMemoryService.d.ts +27 -2
  15. package/dist/services/autoMemoryService.js +124 -36
  16. package/dist/services/configurationService.js +14 -1
  17. package/dist/services/session.d.ts +13 -0
  18. package/dist/services/session.js +64 -0
  19. package/dist/types/agent.d.ts +0 -2
  20. package/dist/types/config.d.ts +7 -0
  21. package/dist/types/core.d.ts +1 -1
  22. package/dist/utils/containerSetup.js +12 -3
  23. package/package.json +1 -1
  24. package/src/agent.ts +50 -110
  25. package/src/managers/aiManager.ts +370 -105
  26. package/src/managers/messageManager.ts +51 -23
  27. package/src/managers/permissionManager.ts +15 -13
  28. package/src/managers/subagentManager.ts +36 -25
  29. package/src/prompts/index.ts +75 -56
  30. package/src/services/aiService.ts +25 -203
  31. package/src/services/autoMemoryService.ts +145 -39
  32. package/src/services/configurationService.ts +16 -1
  33. package/src/services/session.ts +68 -0
  34. package/src/types/agent.ts +0 -6
  35. package/src/types/config.ts +7 -0
  36. package/src/types/core.ts +1 -1
  37. package/src/utils/containerSetup.ts +12 -4
  38. package/dist/constants/goalPrompts.d.ts +0 -1
  39. package/dist/constants/goalPrompts.js +0 -10
  40. package/dist/managers/goalManager.d.ts +0 -42
  41. package/dist/managers/goalManager.js +0 -177
  42. package/src/constants/goalPrompts.ts +0 -10
  43. package/src/managers/goalManager.ts +0 -232
@@ -3,6 +3,14 @@ import * as fs from "node:fs/promises";
3
3
  import { logger } from "../utils/globalLogger.js";
4
4
  import { isPathInside } from "../utils/pathSafety.js";
5
5
  import { buildAutoMemoryExtractionPrompt } from "../prompts/autoMemoryExtraction.js";
6
+ import { READ_ONLY_COMMANDS, splitBashCommand, hasWriteRedirections, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, isDangerousFind, } from "../utils/bashParser.js";
7
+ /**
8
+ * Message fed back to the extraction fork when the model requests a tool
9
+ * outside the allowed set (Bash rm, MCP tools, Agent, out-of-dir writes...).
10
+ */
11
+ const DENIED_TOOL_MESSAGE = "This tool call was denied during auto-memory extraction. Available tools: " +
12
+ "Read, Grep, Glob, read-only Bash commands, and Write/Edit inside the " +
13
+ "memory directory only.";
6
14
  /**
7
15
  * Service responsible for managing the auto-memory background agent lifecycle.
8
16
  * Extracts and updates persistent project-level memory from conversation history.
@@ -12,12 +20,14 @@ export class AutoMemoryService {
12
20
  this.container = container;
13
21
  this.lastMemoryMessageId = null;
14
22
  this.turnsSinceLastExtraction = 0;
23
+ this.extractionInProgress = false;
24
+ this.pendingExtraction = null;
15
25
  }
16
26
  get messageManager() {
17
27
  return this.container.get("MessageManager");
18
28
  }
19
- get forkedAgentManager() {
20
- return this.container.get("ForkedAgentManager");
29
+ get aiManager() {
30
+ return this.container.get("AIManager");
21
31
  }
22
32
  get memoryService() {
23
33
  return this.container.get("MemoryService");
@@ -54,7 +64,12 @@ export class AutoMemoryService {
54
64
  const recentMessages = messages.slice(startIndex);
55
65
  const hasManualMemoryWrite = recentMessages.some((m) => m.role === "assistant" &&
56
66
  m.blocks.some((b) => {
57
- if (b.type === "tool" && (b.name === "Write" || b.name === "Edit")) {
67
+ if (b.type === "tool" &&
68
+ (b.name === "Write" || b.name === "Edit") &&
69
+ // Only a successful manual write counts as a manual update. A
70
+ // denied/failed write didn't touch memory, so the extraction fork
71
+ // must still run or the information is lost.
72
+ b.success !== false) {
58
73
  try {
59
74
  const params = b.parameters ? JSON.parse(b.parameters) : null;
60
75
  const filePath = params?.file_path || params?.path;
@@ -77,20 +92,51 @@ export class AutoMemoryService {
77
92
  this.turnsSinceLastExtraction = 0;
78
93
  return;
79
94
  }
80
- // 3. Trigger background extraction using a forked subagent
81
- try {
82
- await this.runExtraction(workdir, messages);
83
- this.lastMemoryMessageId = messages[messages.length - 1].id || null;
84
- this.turnsSinceLastExtraction = 0;
85
- }
86
- catch (error) {
87
- logger.error("Auto-memory extraction failed to trigger:", error);
95
+ // 3. Concurrency guard: if an extraction is already in flight, skip this
96
+ // turn. turnsSinceLastExtraction is intentionally NOT reset so the next
97
+ // eligible turn retriggers the extraction.
98
+ if (this.extractionInProgress) {
99
+ logger.debug("Skipping auto-memory extraction: another extraction is still in progress.");
100
+ return;
88
101
  }
102
+ // 4. Trigger the perfect-fork extraction fire-and-forget. The message
103
+ // snapshot and new-message count are computed now; lastMemoryMessageId
104
+ // advances at trigger time so the next extraction starts from a later
105
+ // window even while this one runs.
106
+ const lastExtractedIndex = this.lastMemoryMessageId
107
+ ? messages.findIndex((m) => m.id === this.lastMemoryMessageId)
108
+ : -1;
109
+ const newMessageCount = lastExtractedIndex === -1
110
+ ? messages.length
111
+ : messages.length - 1 - lastExtractedIndex;
112
+ this.turnsSinceLastExtraction = 0;
113
+ this.lastMemoryMessageId = messages[messages.length - 1].id || null;
114
+ this.extractionInProgress = true;
115
+ const extraction = this.runExtraction(workdir, messages, newMessageCount)
116
+ .catch((error) => {
117
+ logger.error("Auto-memory extraction failed:", error);
118
+ })
119
+ .finally(() => {
120
+ this.extractionInProgress = false;
121
+ this.pendingExtraction = null;
122
+ });
123
+ this.pendingExtraction = extraction;
124
+ }
125
+ /**
126
+ * Wait for an in-flight extraction to settle. Called from Agent.dispose so
127
+ * the process doesn't exit while the extraction fork is mid-flight.
128
+ */
129
+ async drain() {
130
+ await this.pendingExtraction;
89
131
  }
90
132
  /**
91
- * Initialize and execute the background extraction subagent.
133
+ * Initialize and execute the extraction in a perfect fork: same system
134
+ * prompt, tools, model, and message prefix as the main conversation, so the
135
+ * prompt cache is reused. A tool gate confines the fork to read-only
136
+ * inspection and memory-directory writes. Runs in-process; callers treat it
137
+ * as fire-and-forget.
92
138
  */
93
- async runExtraction(workdir, messages) {
139
+ async runExtraction(workdir, messages, newMessageCount) {
94
140
  const memoryDir = this.memoryService.getAutoMemoryDirectory(workdir);
95
141
  // Ensure memory directory exists before starting
96
142
  await this.memoryService.ensureAutoMemoryDirectory(workdir);
@@ -106,30 +152,72 @@ export class AutoMemoryService {
106
152
  catch {
107
153
  // Ignore if directory doesn't exist yet
108
154
  }
109
- // Calculate how many new messages to analyze
110
- let newMessageCount = messages.length;
111
- if (this.lastMemoryMessageId) {
112
- const lastIndex = messages.findIndex((m) => m.id === this.lastMemoryMessageId);
113
- if (lastIndex !== -1) {
114
- newMessageCount = messages.length - 1 - lastIndex;
115
- }
116
- }
117
155
  const prompt = buildAutoMemoryExtractionPrompt(newMessageCount, existingMemoriesManifest);
118
- // Execute the forked agent in background (fire-and-forget, decoupled from BackgroundTaskManager)
119
- await this.forkedAgentManager.forkAndExecute("general-purpose", messages, {
120
- description: "Auto-memory extraction background agent",
121
- allowedTools: [
122
- "Read",
123
- "Glob",
124
- "Grep",
125
- `Write(${memoryDir}/**/*)`,
126
- `Edit(${memoryDir}/**/*)`,
127
- `Bash(rm ${memoryDir}/**/*)`,
128
- ],
129
- model: "fastModel", // Use fast model for background tasks to reduce latency and cost
130
- permissionModeOverride: "dontAsk", // Auto-deny out-of-scope writes without prompting user
156
+ await this.aiManager.runAutoMemoryFork(messages, `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`, {
131
157
  maxTurns: 5, // Limit turns to prevent verification rabbit-holes
132
- }, `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`);
133
- logger.debug("Auto-memory extraction started in background.");
158
+ canUseTool: (name, args) => this.isAllowedForkTool(name, args, memoryDir, workdir),
159
+ deniedToolMessage: DENIED_TOOL_MESSAGE,
160
+ });
161
+ logger.debug("Auto-memory extraction completed.");
162
+ }
163
+ /**
164
+ * Tool gate for the extraction fork: Read/Grep/Glob are always allowed;
165
+ * Write/Edit only when the target path is inside the memory directory; Bash
166
+ * only for read-only commands (aligned with the permission manager's
167
+ * read-only bash classification). Everything else — Bash rm, MCP tools,
168
+ * Agent, out-of-dir writes — is denied.
169
+ */
170
+ isAllowedForkTool(name, args, memoryDir, workdir) {
171
+ if (name === "Read" || name === "Grep" || name === "Glob") {
172
+ return true;
173
+ }
174
+ if (name === "Write" || name === "Edit") {
175
+ const filePath = args.file_path ?? args.path;
176
+ if (typeof filePath !== "string" || !filePath)
177
+ return false;
178
+ const absolutePath = path.isAbsolute(filePath)
179
+ ? filePath
180
+ : path.resolve(workdir, filePath);
181
+ return isPathInside(absolutePath, memoryDir);
182
+ }
183
+ if (name === "Bash") {
184
+ const command = typeof args.command === "string" ? args.command : "";
185
+ return this.isReadOnlyBashCommand(command);
186
+ }
187
+ return false;
188
+ }
189
+ /**
190
+ * A bash command is read-only when every part is a READ_ONLY_COMMANDS entry
191
+ * without write redirections, command/process substitution, sed -i, or
192
+ * dangerous find flags. Mirrors PermissionManager.isAutoAllowedPart.
193
+ */
194
+ isReadOnlyBashCommand(command) {
195
+ if (!command.trim())
196
+ return false;
197
+ if (hasWriteRedirections(command))
198
+ return false;
199
+ if (hasCommandSubstitution(command))
200
+ return false;
201
+ if (hasProcessSubstitution(command))
202
+ return false;
203
+ if (hasSedInPlace(command))
204
+ return false;
205
+ const parts = splitBashCommand(command);
206
+ if (parts.length === 0)
207
+ return false;
208
+ return parts.every((part) => {
209
+ const trimmed = part.trim();
210
+ if (!trimmed)
211
+ return true;
212
+ const commandMatch = trimmed.match(/^(\w+)(\s+.*)?$/);
213
+ if (!commandMatch)
214
+ return false;
215
+ const cmd = commandMatch[1];
216
+ if (!READ_ONLY_COMMANDS.includes(cmd))
217
+ return false;
218
+ if (cmd === "find" && isDangerousFind(part))
219
+ return false;
220
+ return true;
221
+ });
134
222
  }
135
223
  }
@@ -488,14 +488,27 @@ export class ConfigurationService {
488
488
  if (fastModelSource && fastModelSource.options) {
489
489
  baseConfig.fastModelOptions = fastModelSource.options;
490
490
  }
491
+ // Resolve fast-model disable-thinking params from models[fastModel].disableThinkingOptions
492
+ const fastModelDisableThinking = fastModelSource && fastModelSource.disableThinkingOptions
493
+ ? fastModelSource.disableThinkingOptions
494
+ : undefined;
495
+ if (fastModelDisableThinking) {
496
+ baseConfig.disableThinkingOptions = fastModelDisableThinking;
497
+ }
491
498
  // Merge model-specific settings from configuration
492
499
  const modelSpecificConfig = resolvedAgentModel &&
493
500
  this.currentConfiguration?.models?.[resolvedAgentModel];
494
501
  if (modelSpecificConfig) {
495
- return {
502
+ const resolved = {
496
503
  ...baseConfig,
497
504
  ...modelSpecificConfig,
498
505
  };
506
+ // Re-apply after the spread so the agent model's own
507
+ // disableThinkingOptions cannot clobber the fast-model value.
508
+ if (fastModelDisableThinking) {
509
+ resolved.disableThinkingOptions = fastModelDisableThinking;
510
+ }
511
+ return resolved;
499
512
  }
500
513
  return baseConfig;
501
514
  }
@@ -139,6 +139,19 @@ export declare function cleanupExpiredSessionsFromJsonl(workdir: string): Promis
139
139
  * Clean up empty project directories in the session directory
140
140
  */
141
141
  export declare function cleanupEmptyProjectDirectories(): Promise<void>;
142
+ /**
143
+ * Clean up "ghost" session files that contain only meta messages
144
+ * (isMeta: true) — e.g. sessions where a SessionStart hook injected a
145
+ * system-reminder but no real user/assistant message was ever sent.
146
+ *
147
+ * Such sessions are no longer created thanks to lazy materialization in
148
+ * saveSession(); this one-time sweep removes files that predate that change
149
+ * so they stop showing up as "0 tokens / No content" entries in the resume
150
+ * list.
151
+ *
152
+ * @returns Promise that resolves to the number of files deleted
153
+ */
154
+ export declare function cleanupMetaOnlySessions(): Promise<number>;
142
155
  /**
143
156
  * Check if a session exists in JSONL storage (new approach)
144
157
  *
@@ -481,6 +481,70 @@ export async function cleanupEmptyProjectDirectories() {
481
481
  // Ignore errors if base directory doesn't exist or can't be accessed
482
482
  }
483
483
  }
484
+ /**
485
+ * Clean up "ghost" session files that contain only meta messages
486
+ * (isMeta: true) — e.g. sessions where a SessionStart hook injected a
487
+ * system-reminder but no real user/assistant message was ever sent.
488
+ *
489
+ * Such sessions are no longer created thanks to lazy materialization in
490
+ * saveSession(); this one-time sweep removes files that predate that change
491
+ * so they stop showing up as "0 tokens / No content" entries in the resume
492
+ * list.
493
+ *
494
+ * @returns Promise that resolves to the number of files deleted
495
+ */
496
+ export async function cleanupMetaOnlySessions() {
497
+ // Do not perform cleanup operations in test environment
498
+ if (process.env.NODE_ENV === "test") {
499
+ return 0;
500
+ }
501
+ let deletedCount = 0;
502
+ try {
503
+ const projectDirs = await fs.readdir(SESSION_DIR);
504
+ for (const projectDirName of projectDirs) {
505
+ const projectPath = join(SESSION_DIR, projectDirName);
506
+ try {
507
+ const stat = await fs.stat(projectPath);
508
+ if (!stat.isDirectory()) {
509
+ continue;
510
+ }
511
+ const files = await fs.readdir(projectPath);
512
+ for (const file of files) {
513
+ if (!file.endsWith(".jsonl")) {
514
+ continue;
515
+ }
516
+ const filePath = join(projectPath, file);
517
+ try {
518
+ // Fast path: a file whose last message is not meta contains a
519
+ // real message, so it can never be meta-only.
520
+ const jsonlHandler = new JsonlHandler();
521
+ const lastMessage = await jsonlHandler.getLastMessage(filePath);
522
+ if (!lastMessage?.isMeta) {
523
+ continue;
524
+ }
525
+ const messages = await jsonlHandler.read(filePath);
526
+ if (messages.length > 0 && messages.every((m) => m.isMeta)) {
527
+ await fs.unlink(filePath);
528
+ deletedCount++;
529
+ }
530
+ }
531
+ catch {
532
+ // Skip corrupted or unreadable files
533
+ continue;
534
+ }
535
+ }
536
+ }
537
+ catch {
538
+ // Skip directories we can't access
539
+ continue;
540
+ }
541
+ }
542
+ }
543
+ catch {
544
+ // Ignore errors if base directory doesn't exist or can't be accessed
545
+ }
546
+ return deletedCount;
547
+ }
484
548
  /**
485
549
  * Check if a session exists in JSONL storage (new approach)
486
550
  *
@@ -98,6 +98,4 @@ export interface AgentCallbacks extends MessageManagerCallbacks, BackgroundTaskM
98
98
  onCommandRunningChange?: (running: boolean) => void;
99
99
  onWorkdirChange?: (newCwd: string) => void;
100
100
  onQueuedMessagesChange?: (messages: QueuedMessage[]) => void;
101
- onGoalStateChange?: (active: boolean, condition?: string, elapsed?: string) => void;
102
- onGoalEvaluating?: (evaluating: boolean) => void;
103
101
  }
@@ -29,4 +29,11 @@ export interface ModelConfig {
29
29
  options?: Record<string, unknown>;
30
30
  /** Fast model generation params (resolved from models[fastModel].options) */
31
31
  fastModelOptions?: Record<string, unknown>;
32
+ /**
33
+ * Fast-model-only disable-thinking params passed through verbatim
34
+ * (e.g. `{ thinking: { type: "disabled" } }`). Applied only in fast-model
35
+ * scenarios (webFetch content processing, `model: fastModel` subagents),
36
+ * never in the agent loop. `{}` clears the default.
37
+ */
38
+ disableThinkingOptions?: Record<string, unknown>;
32
39
  }
@@ -22,7 +22,7 @@ export interface Usage {
22
22
  completion_tokens: number;
23
23
  total_tokens: number;
24
24
  model?: string;
25
- operation_type?: "agent" | "compact" | "goal_evaluation";
25
+ operation_type?: "agent" | "compact";
26
26
  cache_read_input_tokens?: number;
27
27
  cache_creation_input_tokens?: number;
28
28
  cache_creation?: {
@@ -16,7 +16,6 @@ import { SlashCommandManager } from "../managers/slashCommandManager.js";
16
16
  import { PluginManager } from "../managers/pluginManager.js";
17
17
  import { BangManager } from "../managers/bangManager.js";
18
18
  import { CronManager } from "../managers/cronManager.js";
19
- import { GoalManager } from "../managers/goalManager.js";
20
19
  import { WorkflowManager } from "../managers/workflowManager.js";
21
20
  import { MemoryRuleManager } from "../managers/MemoryRuleManager.js";
22
21
  import { ReversionManager } from "../managers/reversionManager.js";
@@ -24,6 +23,7 @@ import { SubagentManager } from "../managers/subagentManager.js";
24
23
  import { ForkedAgentManager } from "../managers/forkedAgentManager.js";
25
24
  import { LiveConfigManager } from "../managers/liveConfigManager.js";
26
25
  import { ReversionService } from "../services/reversionService.js";
26
+ import { cleanupMetaOnlySessions } from "../services/session.js";
27
27
  import { MemoryService } from "../services/memory.js";
28
28
  import { AutoMemoryService } from "../services/autoMemoryService.js";
29
29
  import { USER_MEMORY_FILE } from "./constants.js";
@@ -144,6 +144,17 @@ export function setupAgentContainer(setupOptions) {
144
144
  reversionService.cleanupOldSessions(30).catch((error) => {
145
145
  logger.error("Failed to cleanup old file history:", error);
146
146
  });
147
+ // Remove pre-existing meta-only session files (SessionStart hook context
148
+ // with no real messages) that were persisted before lazy materialization.
149
+ cleanupMetaOnlySessions()
150
+ .then((count) => {
151
+ if (count > 0) {
152
+ logger.debug(`Removed ${count} meta-only session file(s)`);
153
+ }
154
+ })
155
+ .catch((error) => {
156
+ logger.error("Failed to cleanup meta-only session files:", error);
157
+ });
147
158
  const reversionManager = new ReversionManager(container);
148
159
  container.register("ReversionManager", reversionManager);
149
160
  const canUseToolWithPermissionRequest = options.canUseTool
@@ -255,8 +266,6 @@ export function setupAgentContainer(setupOptions) {
255
266
  const cronManager = new CronManager(container, messageManager.getSessionId());
256
267
  container.register("CronManager", cronManager);
257
268
  cronManager.start();
258
- const goalManager = new GoalManager(container);
259
- container.register("GoalManager", goalManager);
260
269
  const workflowManager = new WorkflowManager(container);
261
270
  container.register("WorkflowManager", workflowManager);
262
271
  return container;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",
package/src/agent.ts CHANGED
@@ -8,7 +8,6 @@ import { McpManager } from "./managers/mcpManager.js";
8
8
  import { LspManager } from "./managers/lspManager.js";
9
9
  import { BangManager } from "./managers/bangManager.js";
10
10
  import { CronManager } from "./managers/cronManager.js";
11
- import { GoalManager } from "./managers/goalManager.js";
12
11
  import { BackgroundTaskManager } from "./managers/backgroundTaskManager.js";
13
12
  import { MessageQueue, type QueuedMessage } from "./managers/messageQueue.js";
14
13
  import { SlashCommandManager } from "./managers/slashCommandManager.js";
@@ -38,9 +37,6 @@ import { LiveConfigManager } from "./managers/liveConfigManager.js";
38
37
  import { configValidator } from "./utils/configValidator.js";
39
38
  import { SkillManager } from "./managers/skillManager.js";
40
39
  import { TaskManager } from "./services/taskManager.js";
41
- import { btw } from "./services/aiService.js";
42
- import { convertMessagesForAPI } from "./utils/convertMessagesForAPI.js";
43
- import { supportsVision } from "./utils/modelCapabilities.js";
44
40
  import type { WorktreeSession } from "./utils/worktreeSession.js";
45
41
  import { parseTaskNotificationXml } from "./utils/notificationXml.js";
46
42
  import { InitializationService } from "./services/initializationService.js";
@@ -73,7 +69,6 @@ export class Agent {
73
69
  private pluginManager: PluginManager; // Add plugin manager instance
74
70
  private skillManager: SkillManager; // Add skill manager instance
75
71
  private cronManager: CronManager; // Add cron manager instance
76
- private goalManager: GoalManager; // Add goal manager instance
77
72
  private hookManager: HookManager; // Add hooks manager instance
78
73
  private reversionManager: ReversionManager;
79
74
  private messageQueue: MessageQueue; // Unified queue for messages, bang commands, and notifications
@@ -208,7 +203,6 @@ export class Agent {
208
203
  this.pluginManager = this.container.get("PluginManager")!;
209
204
  this.bangManager = this.container.get("BangManager")!;
210
205
  this.cronManager = this.container.get("CronManager")!;
211
- this.goalManager = this.container.get("GoalManager")!;
212
206
  this.messageQueue = this.container.get("MessageQueue")!;
213
207
 
214
208
  // Wire up CWD change callback from AIManager to sync Agent's workdir
@@ -238,16 +232,6 @@ export class Agent {
238
232
  if (options.permissionMode) {
239
233
  this.setPermissionMode(options.permissionMode);
240
234
  }
241
-
242
- // Wire up goal state change callback
243
- this.goalManager.setOnGoalStateChange((active, condition, elapsed) => {
244
- this.options.callbacks?.onGoalStateChange?.(active, condition, elapsed);
245
- });
246
-
247
- // Wire up goal evaluating callback
248
- this.goalManager.setOnGoalEvaluating((evaluating) => {
249
- this.options.callbacks?.onGoalEvaluating?.(evaluating);
250
- });
251
235
  }
252
236
 
253
237
  // Public getter methods
@@ -340,16 +324,6 @@ export class Agent {
340
324
  .filter((m) => m.type !== "notification");
341
325
  }
342
326
 
343
- /** Get goal status string */
344
- public get goalStatus(): string {
345
- return this.goalManager.getStatusString();
346
- }
347
-
348
- /** Check if a goal is active */
349
- public get isGoalActive(): boolean {
350
- return this.goalManager.isGoalActive();
351
- }
352
-
353
327
  /**
354
328
  * Remove a queued message by index
355
329
  * @param index - The index of the message to remove
@@ -707,10 +681,15 @@ export class Agent {
707
681
  }
708
682
 
709
683
  public async clearMessages(): Promise<void> {
710
- this.aiManager.abortAIMessage();
684
+ // Aligned with webview (ChatApp handleClearChat): /clear is ignored
685
+ // while the agent is running, instead of aborting the AI mid-turn
686
+ // (which used to inject a late "Request was aborted" error into the
687
+ // newly cleared session).
688
+ if (this.aiManager.isLoading) {
689
+ return;
690
+ }
711
691
 
712
- // Clear any active goal
713
- this.goalManager.clearGoal();
692
+ this.aiManager.abortAIMessage();
714
693
 
715
694
  // Capture old session info before clearing
716
695
  const oldSessionId = this.messageManager.getSessionId();
@@ -772,6 +751,12 @@ export class Agent {
772
751
  * @param customInstructions - Optional custom instructions for compaction
773
752
  */
774
753
  public async compact(customInstructions?: string): Promise<void> {
754
+ // Aligned with webview: /compact is ignored while the agent is running,
755
+ // instead of aborting the AI mid-turn before compacting.
756
+ if (this.aiManager.isLoading) {
757
+ return;
758
+ }
759
+
775
760
  this.aiManager.abortAIMessage();
776
761
 
777
762
  await this.aiManager.compactConversation({
@@ -781,75 +766,6 @@ export class Agent {
781
766
  await this.messageManager.saveSession();
782
767
  }
783
768
 
784
- /**
785
- * Set an autonomous goal for the session
786
- * @param condition - The goal condition to achieve
787
- */
788
- public async setGoal(condition: string): Promise<void> {
789
- // Check plan mode
790
- if (this.getPermissionMode() === "plan") {
791
- this.messageManager.addUserMessage({
792
- content:
793
- "<system-reminder>Cannot set a goal in plan mode. Exit plan mode first.</system-reminder>",
794
- isMeta: true,
795
- });
796
- return;
797
- }
798
-
799
- this.goalManager.setGoal(condition);
800
- this.messageManager.addUserMessage({
801
- content: `<system-reminder>Goal set: ${condition}. The agent will work autonomously until this goal is achieved.</system-reminder>`,
802
- isMeta: true,
803
- });
804
- // Add the goal as a user directive to start working
805
- this.messageManager.addUserMessage({
806
- content: condition,
807
- });
808
- this.aiManager.sendAIMessage();
809
-
810
- await this.messageManager.saveSession();
811
- }
812
-
813
- /**
814
- * Clear the current autonomous goal
815
- */
816
- public async clearGoal(): Promise<void> {
817
- if (this.goalManager.isGoalActive()) {
818
- this.goalManager.clearGoal();
819
- this.messageManager.addUserMessage({
820
- content: "<system-reminder>Goal cleared.</system-reminder>",
821
- isMeta: true,
822
- });
823
- } else {
824
- this.messageManager.addUserMessage({
825
- content: "<system-reminder>No active goal to clear.</system-reminder>",
826
- isMeta: true,
827
- });
828
- }
829
-
830
- await this.messageManager.saveSession();
831
- }
832
-
833
- /**
834
- * Show the current goal status
835
- */
836
- public async showGoalStatus(): Promise<void> {
837
- if (this.goalManager.isGoalActive()) {
838
- this.messageManager.addUserMessage({
839
- content: `<system-reminder>${this.goalManager.getStatusString()}</system-reminder>`,
840
- isMeta: true,
841
- });
842
- } else {
843
- this.messageManager.addUserMessage({
844
- content:
845
- "<system-reminder>No active goal. Use /goal <condition> to set one.</system-reminder>",
846
- isMeta: true,
847
- });
848
- }
849
-
850
- await this.messageManager.saveSession();
851
- }
852
-
853
769
  /** Unified interrupt method, interrupts both AI messages and command execution */
854
770
  public abortMessage(): void {
855
771
  // Guard: prevent tryDispatch (triggered by abortAIMessage → setIsLoading(false))
@@ -994,6 +910,21 @@ export class Agent {
994
910
  this.subagentManager.cleanup();
995
911
  // Cleanup forked agent manager
996
912
  await this.forkedAgentManager.cleanup();
913
+ // Drain an in-flight auto-memory extraction fork so the process doesn't
914
+ // exit mid-extraction
915
+ try {
916
+ const autoMemoryService =
917
+ this.container.get<
918
+ import("./services/autoMemoryService.js").AutoMemoryService
919
+ >("AutoMemoryService");
920
+ if (autoMemoryService) {
921
+ await autoMemoryService.drain();
922
+ }
923
+ } catch (error) {
924
+ this.logger?.warn(
925
+ `Auto-memory extraction drain failed: ${(error as Error).message}`,
926
+ );
927
+ }
997
928
  // Cleanup skill manager
998
929
  await this.skillManager.destroy();
999
930
  // Cleanup live configuration reload
@@ -1018,21 +949,30 @@ export class Agent {
1018
949
  }
1019
950
 
1020
951
  /**
1021
- * Ask a side question without tool use
952
+ * Ask a side question without interrupting the main agent.
953
+ *
954
+ * Runs a single-turn fork of the conversation that reuses the main loop's
955
+ * system prompt, tools, and memory injection (prompt-cache friendly) and
956
+ * never executes tools. The abort signal lets the UI cancel a pending
957
+ * question (loading dismiss); aborts propagate as thrown errors.
958
+ *
1022
959
  * @param question - The side question to ask
960
+ * @param abortSignal - Optional signal to abort the pending request
1023
961
  * @returns Promise that resolves to the AI's answer
1024
962
  */
1025
- public async askBtw(question: string): Promise<string> {
1026
- const messages = convertMessagesForAPI(this.messageManager.getMessages(), {
1027
- supportsVision: supportsVision(this.getModelConfig().capabilities),
1028
- });
1029
- const result = await btw({
1030
- gatewayConfig: this.getGatewayConfig(),
1031
- modelConfig: this.getModelConfig(),
1032
- messages,
963
+ public async askBtw(
964
+ question: string,
965
+ abortSignal?: AbortSignal,
966
+ onContent?: (content: string) => void,
967
+ onReasoning?: (content: string) => void,
968
+ ): Promise<string> {
969
+ const result = await this.aiManager.runBtwFork(
1033
970
  question,
1034
- });
1035
- return result.content;
971
+ abortSignal,
972
+ onContent,
973
+ onReasoning,
974
+ );
975
+ return result.content ?? result.error ?? "No response received";
1036
976
  }
1037
977
 
1038
978
  /**
@@ -1066,7 +1006,7 @@ export class Agent {
1066
1006
  images?: Array<{ path: string; mimeType: string }>,
1067
1007
  ): Promise<void> {
1068
1008
  // If the agent is busy, enqueue the message — unless it's an immediate
1069
- // slash command (e.g., /goal clear, /clear, /compact) that should execute
1009
+ // slash command (e.g., /clear, /compact) that should execute
1070
1010
  // right away even while AI is processing
1071
1011
  if (this.aiManager.isLoading || this.isCommandRunning) {
1072
1012
  const trimmed = content.trim();