wave-agent-sdk 0.19.7 → 0.19.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
  2. package/builtin/plugins/sdd/hooks/hooks.json +14 -0
  3. package/builtin/plugins/sdd/scripts/session-start.js +24 -0
  4. package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
  5. package/builtin/plugins/sdd/skills/specify/SKILL.md +48 -0
  6. package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
  7. package/dist/agent.d.ts +8 -0
  8. package/dist/agent.js +30 -10
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/managers/aiManager.d.ts +18 -0
  12. package/dist/managers/aiManager.js +155 -46
  13. package/dist/managers/permissionManager.d.ts +7 -0
  14. package/dist/managers/permissionManager.js +102 -142
  15. package/dist/managers/pluginManager.d.ts +7 -0
  16. package/dist/managers/pluginManager.js +31 -0
  17. package/dist/managers/subagentManager.js +6 -0
  18. package/dist/prompts/index.d.ts +12 -1
  19. package/dist/prompts/index.js +133 -45
  20. package/dist/services/aiService.d.ts +1 -17
  21. package/dist/services/aiService.js +3 -85
  22. package/dist/services/configurationService.d.ts +6 -0
  23. package/dist/services/configurationService.js +31 -0
  24. package/dist/services/remoteSettingsService.js +2 -0
  25. package/dist/services/session.d.ts +3 -1
  26. package/dist/services/session.js +12 -4
  27. package/dist/services/taskManager.d.ts +1 -0
  28. package/dist/services/taskManager.js +34 -5
  29. package/dist/tools/editTool.js +24 -10
  30. package/dist/tools/enterWorktreeTool.js +2 -1
  31. package/dist/tools/grepTool.js +8 -2
  32. package/dist/tools/writeTool.js +36 -0
  33. package/dist/types/configuration.d.ts +5 -0
  34. package/dist/types/permissions.d.ts +0 -2
  35. package/dist/types/processes.d.ts +27 -0
  36. package/dist/types/workflow.d.ts +1 -1
  37. package/dist/utils/bashParser.d.ts +25 -0
  38. package/dist/utils/bashParser.js +103 -0
  39. package/dist/utils/configPaths.d.ts +4 -0
  40. package/dist/utils/configPaths.js +6 -0
  41. package/dist/utils/containerSetup.js +0 -9
  42. package/dist/utils/fileSearch.js +4 -2
  43. package/dist/utils/worktreeSession.d.ts +1 -1
  44. package/dist/utils/worktreeSession.js +1 -1
  45. package/dist/utils/worktreeUtils.d.ts +7 -1
  46. package/dist/utils/worktreeUtils.js +10 -4
  47. package/dist/workflow/types.d.ts +5 -0
  48. package/package.json +1 -1
  49. package/src/agent.ts +29 -10
  50. package/src/index.ts +1 -0
  51. package/src/managers/aiManager.ts +219 -61
  52. package/src/managers/permissionManager.ts +116 -168
  53. package/src/managers/pluginManager.ts +29 -0
  54. package/src/managers/subagentManager.ts +6 -0
  55. package/src/prompts/index.ts +144 -37
  56. package/src/services/aiService.ts +9 -128
  57. package/src/services/configurationService.ts +37 -0
  58. package/src/services/remoteSettingsService.ts +1 -0
  59. package/src/services/session.ts +18 -4
  60. package/src/services/taskManager.ts +46 -7
  61. package/src/tools/editTool.ts +29 -11
  62. package/src/tools/enterWorktreeTool.ts +2 -1
  63. package/src/tools/grepTool.ts +11 -2
  64. package/src/tools/writeTool.ts +43 -0
  65. package/src/types/configuration.ts +5 -0
  66. package/src/types/permissions.ts +0 -2
  67. package/src/types/processes.ts +29 -0
  68. package/src/types/workflow.ts +1 -0
  69. package/src/utils/bashParser.ts +106 -0
  70. package/src/utils/configPaths.ts +7 -0
  71. package/src/utils/containerSetup.ts +0 -11
  72. package/src/utils/fileSearch.ts +6 -2
  73. package/src/utils/worktreeSession.ts +1 -1
  74. package/src/utils/worktreeUtils.ts +14 -4
  75. package/src/workflow/types.ts +6 -0
@@ -18,6 +18,10 @@ export declare function getBuiltinSkillsDir(): string;
18
18
  * Get the builtin subagents directory path
19
19
  */
20
20
  export declare function getBuiltinSubagentsDir(): string;
21
+ /**
22
+ * Get the builtin plugins directory path
23
+ */
24
+ export declare function getBuiltinPluginsDir(): string;
21
25
  /**
22
26
  * Get the user-specific configuration file path (legacy function)
23
27
  * @deprecated Use getUserConfigPaths() for better priority support
@@ -46,6 +46,12 @@ export function getBuiltinSkillsDir() {
46
46
  export function getBuiltinSubagentsDir() {
47
47
  return join(getPackageRoot(), "builtin", "subagents");
48
48
  }
49
+ /**
50
+ * Get the builtin plugins directory path
51
+ */
52
+ export function getBuiltinPluginsDir() {
53
+ return join(getPackageRoot(), "builtin", "plugins");
54
+ }
49
55
  /**
50
56
  * Get the user-specific configuration file path (legacy function)
51
57
  * @deprecated Use getUserConfigPaths() for better priority support
@@ -179,21 +179,12 @@ export function setupAgentContainer(setupOptions) {
179
179
  });
180
180
  }
181
181
  const decision = await options.canUseTool(context);
182
- const planFilePath = permissionManager.getPlanFilePath();
183
182
  if (decision.newPermissionMode) {
184
183
  setPermissionMode(decision.newPermissionMode);
185
184
  }
186
185
  if (decision.newPermissionRule) {
187
186
  await addPermissionRule(decision.newPermissionRule);
188
187
  }
189
- if (decision.clearContext) {
190
- messageManager.clearMessages();
191
- if (planFilePath) {
192
- messageManager.addUserMessage({
193
- content: `Implement the plan at ${planFilePath}`,
194
- });
195
- }
196
- }
197
188
  return decision;
198
189
  }
199
190
  : undefined;
@@ -25,9 +25,11 @@ async function getAllFiles(workingDirectory) {
25
25
  stderr += data.toString();
26
26
  });
27
27
  child.on("close", (code) => {
28
+ // Exit 2 = some files were unreadable (e.g. device-name files like
29
+ // "nul" on Windows); stdout still holds usable partial results.
30
+ // Spawn failures surface via the 'error' listener instead.
28
31
  if (code !== 0 && code !== 1) {
29
- reject(new Error(`ripgrep failed: ${stderr}`));
30
- return;
32
+ logger.warn(`ripgrep exited with code ${code}, keeping partial results: ${stderr.trim()}`);
31
33
  }
32
34
  const files = stdout
33
35
  .trim()
@@ -5,7 +5,7 @@
5
5
  * "WorktreeSession" container slot registered in containerSetup.ts and accessed via
6
6
  * AIManager.getWorktreeSession()/setWorktreeSession()). This keeps worktree state
7
7
  * isolated per session in stdio multi-agent mode — a process-level singleton would
8
- * leak state across concurrent sessions (see specs/047-worktree.md FR-042).
8
+ * leak state across concurrent sessions (see docs/specs/multi-agent/worktree.md FR-042).
9
9
  */
10
10
  export interface WorktreeSession {
11
11
  /** The working directory the session was in before EnterWorktree */
@@ -5,6 +5,6 @@
5
5
  * "WorktreeSession" container slot registered in containerSetup.ts and accessed via
6
6
  * AIManager.getWorktreeSession()/setWorktreeSession()). This keeps worktree state
7
7
  * isolated per session in stdio multi-agent mode — a process-level singleton would
8
- * leak state across concurrent sessions (see specs/047-worktree.md FR-042).
8
+ * leak state across concurrent sessions (see docs/specs/multi-agent/worktree.md FR-042).
9
9
  */
10
10
  export {};
@@ -25,8 +25,14 @@ export declare function generateWorktreeName(): string;
25
25
  export declare function getHeadCommit(cwd: string): string;
26
26
  /**
27
27
  * Create a git worktree for use during a session.
28
+ * @param name Worktree name
29
+ * @param cwd Current working directory (will be resolved to main repo root)
30
+ * @param options Optional creation options
31
+ * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
28
32
  */
29
- export declare function createWorktree(name: string, cwd: string): WorktreeInfo;
33
+ export declare function createWorktree(name: string, cwd: string, options?: {
34
+ baseRef?: "fresh" | "head";
35
+ }): WorktreeInfo;
30
36
  /**
31
37
  * Remove a git worktree and its branch.
32
38
  */
@@ -68,8 +68,12 @@ export function getHeadCommit(cwd) {
68
68
  }
69
69
  /**
70
70
  * Create a git worktree for use during a session.
71
+ * @param name Worktree name
72
+ * @param cwd Current working directory (will be resolved to main repo root)
73
+ * @param options Optional creation options
74
+ * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
71
75
  */
72
- export function createWorktree(name, cwd) {
76
+ export function createWorktree(name, cwd, options) {
73
77
  const repoRoot = getGitMainRepoRoot(cwd);
74
78
  if (!repoRoot) {
75
79
  throw new Error("Cannot create a worktree: not in a git repository. Configure WorktreeCreate and WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.");
@@ -78,7 +82,8 @@ export function createWorktree(name, cwd) {
78
82
  const originalHeadCommit = getHeadCommit(cwd);
79
83
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
80
84
  const branchName = `worktree-${name}`;
81
- const baseBranch = getDefaultRemoteBranch(cwd);
85
+ const useHead = options?.baseRef === "head";
86
+ const baseBranch = useHead ? "HEAD" : getDefaultRemoteBranch(cwd);
82
87
  // Ensure Wave runtime files are git-excluded in this repo
83
88
  ensureWaveRuntimeFilesExcluded(cwd);
84
89
  // Ensure parent directory exists
@@ -144,8 +149,9 @@ export function createWorktree(name, cwd) {
144
149
  throw new Error(`Failed to add worktree: ${innerError.message}`);
145
150
  }
146
151
  }
147
- if (stderr.includes("not a valid object name") ||
148
- stderr.includes("unknown revision")) {
152
+ if (!useHead &&
153
+ (stderr.includes("not a valid object name") ||
154
+ stderr.includes("unknown revision"))) {
149
155
  // Base branch not fetched yet — try fetching then retrying
150
156
  const branchNameOnly = baseBranch.split("/").pop();
151
157
  try {
@@ -38,6 +38,11 @@ export interface WorkflowRun {
38
38
  /** Error message from the failed agent */
39
39
  failedAgentError?: string;
40
40
  }
41
+ /**
42
+ * Serializable workflow run for stdio transport, with the non-serializable
43
+ * `completionPromise` stripped. Returned by the `getWorkflowRuns` RPC.
44
+ */
45
+ export type SerializableWorkflowRun = Omit<WorkflowRun, "completionPromise">;
41
46
  export interface JournalEntry {
42
47
  agentIndex: number;
43
48
  prompt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "0.19.7",
3
+ "version": "0.19.9",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",
package/src/agent.ts CHANGED
@@ -78,6 +78,7 @@ export class Agent {
78
78
  private reversionManager: ReversionManager;
79
79
  private messageQueue: MessageQueue; // Unified queue for messages, bang commands, and notifications
80
80
  private dispatchPromise: Promise<void> | null = null; // Track current dispatch for teardown
81
+ private isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
81
82
  private memoryRuleManager: MemoryRuleManager; // Add memory rule manager instance
82
83
  private liveConfigManager: LiveConfigManager; // Add live configuration manager
83
84
  private taskManager: TaskManager;
@@ -412,6 +413,7 @@ export class Agent {
412
413
  * onLoadingChange(false), and onCommandRunningChange(false).
413
414
  */
414
415
  private tryDispatch(): void {
416
+ if (this.isAborting) return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
415
417
  if (this.messageQueue.state !== "idle") return;
416
418
  if (!this.messageQueue.hasPending()) return;
417
419
  if (this.aiManager.isLoading || this.isCommandRunning) return;
@@ -847,17 +849,24 @@ export class Agent {
847
849
 
848
850
  /** Unified interrupt method, interrupts both AI messages and command execution */
849
851
  public abortMessage(): void {
850
- if (this.aiManager.isLoading || this.isCommandRunning) {
851
- // Clear user-facing queue items first to prevent processQueuedMessage
852
- // from dequeuing when abortAIMessage triggers onLoadingChange(false).
853
- // Notifications are preserved so background task results aren't lost.
854
- this.messageQueue.clear();
855
- this.options.callbacks?.onQueuedMessagesChange?.(this.queuedMessages);
852
+ // Guard: prevent tryDispatch (triggered by abortAIMessage → setIsLoading(false))
853
+ // from dispatching preserved notifications as a new AI turn during the abort.
854
+ this.isAborting = true;
855
+ try {
856
+ if (this.aiManager.isLoading || this.isCommandRunning) {
857
+ // Clear user-facing queue items first to prevent processQueuedMessage
858
+ // from dequeuing when abortAIMessage triggers onLoadingChange(false).
859
+ // Notifications are preserved so background task results aren't lost.
860
+ this.messageQueue.clear();
861
+ this.options.callbacks?.onQueuedMessagesChange?.(this.queuedMessages);
862
+ }
863
+ this.messageQueue.transitionTo("idle"); // Reset state on abort
864
+ this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
865
+ this.abortBashCommand();
866
+ this.abortSlashCommand();
867
+ } finally {
868
+ this.isAborting = false;
856
869
  }
857
- this.messageQueue.transitionTo("idle"); // Reset state on abort
858
- this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
859
- this.abortBashCommand();
860
- this.abortSlashCommand();
861
870
  }
862
871
 
863
872
  /** Interrupt bash command execution */
@@ -1266,4 +1275,14 @@ export class Agent {
1266
1275
  this.subagentManager.getActiveInstances().length > 0;
1267
1276
  return runningTasks || activeSubagents;
1268
1277
  }
1278
+
1279
+ /**
1280
+ * Check if there are pending items (messages, bang commands, or background
1281
+ * task notifications) in the message queue. Background task completion
1282
+ * notifications are enqueued before the main agent's dispatch consumes them,
1283
+ * so callers waiting for the agent to fully settle must also wait on this.
1284
+ */
1285
+ public get hasPendingMessages(): boolean {
1286
+ return this.messageQueue.hasPending();
1287
+ }
1269
1288
  }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./utils/tokenCalculation.js";
29
29
  export * from "./utils/gitUtils.js";
30
30
  export * from "./utils/nameGenerator.js";
31
31
  export * from "./utils/worktreeSession.js";
32
+ export { loadMergedWaveConfig } from "./services/configurationService.js";
32
33
  export * from "./types/index.js";
33
34
 
34
35
  // Export tool building utilities
@@ -22,7 +22,10 @@ import type { ToolManager } from "./toolManager.js";
22
22
  import type { ToolContext, ToolResult } from "../tools/types.js";
23
23
  import type { MessageManager } from "./messageManager.js";
24
24
  import type { BackgroundTaskManager } from "./backgroundTaskManager.js";
25
- import { ChatCompletionMessageFunctionToolCall } from "openai/resources.js";
25
+ import {
26
+ ChatCompletionMessageFunctionToolCall,
27
+ type ChatCompletionMessageParam,
28
+ } from "openai/resources.js";
26
29
 
27
30
  import type { HookManager } from "./hookManager.js";
28
31
  import type { ExtendedHookExecutionContext } from "../types/hooks.js";
@@ -31,11 +34,16 @@ import type { PermissionManager } from "./permissionManager.js";
31
34
  import type { SubagentManager } from "./subagentManager.js";
32
35
  import type { CronManager } from "./cronManager.js";
33
36
  import type { SkillManager } from "./skillManager.js";
34
- import { buildSystemPrompt } from "../prompts/index.js";
37
+ import {
38
+ buildSystemPrompt,
39
+ formatCompactSummary,
40
+ getCompactPrompt,
41
+ } from "../prompts/index.js";
35
42
  import {
36
43
  buildPlanModeReminder,
37
44
  buildPlanModeReEntryReminder,
38
45
  buildExitedPlanModeReminder,
46
+ wrapInSystemReminder,
39
47
  } from "../prompts/planModeReminders.js";
40
48
  import { Container } from "../utils/container.js";
41
49
  import type { WorktreeSession } from "../utils/worktreeSession.js";
@@ -252,6 +260,10 @@ export class AIManager {
252
260
  return this.configurationService.resolveAutoMemoryEnabled();
253
261
  }
254
262
 
263
+ public getWorktreeBaseRef(): "fresh" | "head" {
264
+ return this.configurationService.resolveWorktreeBaseRef();
265
+ }
266
+
255
267
  public getWorkdir(): string {
256
268
  return this.container.get<string>("Workdir") ?? process.cwd();
257
269
  }
@@ -533,36 +545,47 @@ export class AIManager {
533
545
 
534
546
  this.setIsCompacting(true);
535
547
  try {
536
- const recentChatMessages = convertMessagesForAPI(messagesToCompact);
537
-
538
- // 4. Call compactMessages with optional custom instructions
539
- const compactResult = await aiService.compactMessages({
540
- gatewayConfig: this.getGatewayConfig(),
541
- modelConfig: this.getModelConfig(),
542
- messages: recentChatMessages,
543
- abortSignal: options.abortSignal,
544
- model: this.getModelConfig().fastModel,
545
- customInstructions: mergedInstructions,
548
+ const modelConfig = this.getModelConfig();
549
+ const recentChatMessages = convertMessagesForAPI(messagesToCompact, {
550
+ supportsVision: supportsVision(modelConfig.capabilities),
546
551
  });
552
+ const compactPrompt = getCompactPrompt(mergedInstructions);
553
+
554
+ // 4. Fork path: fork the conversation with the same system prompt,
555
+ // tools, model, and generation params as the main loop so the forked
556
+ // request prefix matches exactly and the prompt cache is reused.
557
+ const forkResult = await this.runCompactFork(
558
+ recentChatMessages,
559
+ compactPrompt,
560
+ options.abortSignal,
561
+ );
562
+ const summaryContent = forkResult.content;
563
+ const compactTokens = forkResult.usage;
564
+ if (!summaryContent) {
565
+ throw new Error(
566
+ "Compaction failed: the model produced no summary output",
567
+ );
568
+ }
569
+ const compactModel = modelConfig.model;
547
570
 
548
571
  // 5. Handle usage tracking
549
572
  let compactUsage: Usage | undefined;
550
- if (compactResult.usage) {
573
+ if (compactTokens) {
551
574
  compactUsage = {
552
- prompt_tokens: compactResult.usage.prompt_tokens,
553
- completion_tokens: compactResult.usage.completion_tokens,
554
- total_tokens: compactResult.usage.total_tokens,
555
- model: this.getModelConfig().fastModel,
575
+ ...compactTokens,
576
+ model: compactModel,
556
577
  operation_type: "compact",
557
578
  };
558
579
  }
559
580
 
560
- // 6. Build post-compact context restoration
561
- const enhancedSummary = await this.buildPostCompactContext(
562
- compactResult.content,
563
- );
581
+ // 6. Strip the <analysis> scratchpad and extract the <summary> body
582
+ const formattedSummary = formatCompactSummary(summaryContent);
583
+
584
+ // 7. Build post-compact context restoration
585
+ const enhancedSummary =
586
+ await this.buildPostCompactContext(formattedSummary);
564
587
 
565
- // 7. Execute message reconstruction
588
+ // 8. Execute message reconstruction
566
589
  await this.messageManager.compactMessagesAndUpdateSession(
567
590
  enhancedSummary,
568
591
  compactUsage,
@@ -587,7 +610,7 @@ export class AIManager {
587
610
  }
588
611
  }
589
612
 
590
- // 8. Track usage
613
+ // 9. Track usage
591
614
  if (compactUsage && this.callbacks?.onUsageAdded) {
592
615
  this.callbacks.onUsageAdded(compactUsage);
593
616
  }
@@ -597,14 +620,14 @@ export class AIManager {
597
620
  // Reset incremental tracing state after compaction
598
621
  resetTracingState();
599
622
 
600
- // 9. Log OTEL event
623
+ // 10. Log OTEL event
601
624
  logOTelEvent("compaction", {
602
625
  beforeTokens: String(messagesToCompact.length),
603
626
  afterTokens: "1",
604
- model: this.getModelConfig().fastModel,
627
+ model: compactModel,
605
628
  }).catch(() => {});
606
629
 
607
- // 10. Run SessionStart hooks (existing behavior)
630
+ // 11. Run SessionStart hooks (existing behavior)
608
631
  if (this.hookManager) {
609
632
  try {
610
633
  const newSessionId = this.messageManager.getSessionId();
@@ -634,13 +657,13 @@ export class AIManager {
634
657
  }
635
658
  }
636
659
 
637
- // 11. Run PostCompact hooks
660
+ // 12. Run PostCompact hooks
638
661
  if (this.hookManager) {
639
662
  try {
640
663
  await this.hookManager.executePostCompactHooks(
641
664
  this.messageManager.getSessionId(),
642
665
  this.messageManager.getTranscriptPath(),
643
- compactResult.content,
666
+ formattedSummary,
644
667
  );
645
668
  } catch (error) {
646
669
  logger?.warn(`PostCompact hooks failed: ${(error as Error).message}`);
@@ -664,6 +687,158 @@ export class AIManager {
664
687
  }
665
688
  }
666
689
 
690
+ /**
691
+ * Build the system prompt used by the main agent loop. Extracted so the
692
+ * compaction fork can mirror it exactly — the forked request prefix must
693
+ * match the main conversation's for the prompt cache to be reused.
694
+ */
695
+ private async buildMainSystemPrompt(
696
+ filteredToolPlugins: ReturnType<ToolManager["getTools"]>,
697
+ ) {
698
+ let autoMemoryOptions: { directory: string; content: string } | undefined;
699
+
700
+ if (this.getAutoMemoryEnabled()) {
701
+ const directory = this.memoryService.getAutoMemoryDirectory(
702
+ this.getWorkdir(),
703
+ );
704
+ const content = await this.memoryService.getAutoMemoryContent(
705
+ this.getWorkdir(),
706
+ );
707
+ autoMemoryOptions = { directory, content };
708
+ }
709
+
710
+ return buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
711
+ workdir: this.getWorkdir(),
712
+ originalWorkdir: this.getOriginalWorkdir(),
713
+ language: this.getLanguage(),
714
+ isSubagent: !!this.subagentType,
715
+ worktreeSession: this.getWorktreeSession(),
716
+ autoMemory: autoMemoryOptions,
717
+ });
718
+ }
719
+
720
+ private resolveFilteredTools() {
721
+ const toolsConfig = this.getFilteredToolsConfig();
722
+ const toolNames = new Set(toolsConfig.map((t) => t.function.name));
723
+ const filteredToolPlugins = this.toolManager
724
+ .getTools()
725
+ .filter((t) => toolNames.has(t.name));
726
+ return { toolsConfig, toolNames, filteredToolPlugins };
727
+ }
728
+
729
+ /**
730
+ * Fork-path compaction: run a bounded agent loop over a copy of the
731
+ * conversation using the same system prompt, tools, model, and generation
732
+ * params as the main loop, so the forked request prefix matches exactly
733
+ * and the prompt cache is reused. Tool calls are denied locally (the model
734
+ * is told to summarize, not act) and their rejections are fed back for
735
+ * another turn. Returns undefined content when the model never produces
736
+ * text; the caller treats that as a compaction failure.
737
+ */
738
+ private async runCompactFork(
739
+ historyMessages: ChatCompletionMessageParam[],
740
+ compactPrompt: string,
741
+ abortSignal?: AbortSignal,
742
+ ): Promise<{
743
+ content?: string;
744
+ usage?: {
745
+ prompt_tokens: number;
746
+ completion_tokens: number;
747
+ total_tokens: number;
748
+ };
749
+ }> {
750
+ const MAX_FORK_TURNS = 3;
751
+ const modelConfig = this.getModelConfig();
752
+ const gatewayConfig = this.getGatewayConfig();
753
+ const sessionId = this.messageManager.getSessionId();
754
+ const workdir = this.getWorkdir();
755
+
756
+ const forkMessages: ChatCompletionMessageParam[] = [...historyMessages];
757
+
758
+ // Mirror the main loop's memory injection so the request prefix matches.
759
+ const { prependContent } =
760
+ await this.messageManager.getMemoryForInjection();
761
+ if (prependContent.trim()) {
762
+ forkMessages.unshift({
763
+ role: "user",
764
+ content: wrapInSystemReminder(prependContent),
765
+ });
766
+ }
767
+
768
+ forkMessages.push({ role: "user", content: compactPrompt });
769
+
770
+ const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
771
+ const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
772
+
773
+ let totalUsage:
774
+ | {
775
+ prompt_tokens: number;
776
+ completion_tokens: number;
777
+ total_tokens: number;
778
+ }
779
+ | undefined;
780
+ let content: string | undefined;
781
+
782
+ for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
783
+ const result = await aiService.callAgent({
784
+ gatewayConfig,
785
+ modelConfig,
786
+ messages: forkMessages,
787
+ sessionId,
788
+ abortSignal,
789
+ workdir,
790
+ tools: toolsConfig,
791
+ systemPrompt,
792
+ toolChoice: this.toolChoiceOverride,
793
+ // Stream so a slow reasoning model emits first bytes before the
794
+ // gateway's idle timeout fires (non-streaming waits for the full
795
+ // summary, which exceeds the timeout on large contexts).
796
+ stream: true,
797
+ });
798
+
799
+ if (result.usage) {
800
+ totalUsage = {
801
+ prompt_tokens:
802
+ (totalUsage?.prompt_tokens ?? 0) + result.usage.prompt_tokens,
803
+ completion_tokens:
804
+ (totalUsage?.completion_tokens ?? 0) +
805
+ result.usage.completion_tokens,
806
+ total_tokens:
807
+ (totalUsage?.total_tokens ?? 0) + result.usage.total_tokens,
808
+ };
809
+ }
810
+
811
+ if (result.content?.trim()) {
812
+ content = result.content;
813
+ break;
814
+ }
815
+
816
+ if (result.tool_calls && result.tool_calls.length > 0) {
817
+ // Deny all tool calls locally and feed the rejections back so the
818
+ // model gets another turn to produce the summary text.
819
+ forkMessages.push({
820
+ role: "assistant",
821
+ content: result.content ?? null,
822
+ tool_calls: result.tool_calls,
823
+ });
824
+ for (const toolCall of result.tool_calls) {
825
+ forkMessages.push({
826
+ role: "tool",
827
+ tool_call_id: toolCall.id,
828
+ content: "Tool use is not allowed during compaction",
829
+ });
830
+ }
831
+ continue;
832
+ }
833
+
834
+ // Neither text nor tool calls: retrying the identical request is
835
+ // pointless, bail out and let the caller fail the compaction.
836
+ break;
837
+ }
838
+
839
+ return { content, usage: totalUsage };
840
+ }
841
+
667
842
  /**
668
843
  * Build post-compact context restoration content.
669
844
  * Restores file reads, working directory, plan mode, skills, and background tasks.
@@ -829,7 +1004,7 @@ export class AIManager {
829
1004
  // has superseded us. setIsLoading(true) here also closes the "idle but
830
1005
  // previous turn not finished" race window by marking us busy immediately.
831
1006
  this.turnGeneration++;
832
- const myGeneration = this.turnGeneration;
1007
+ let myGeneration = this.turnGeneration;
833
1008
  this.setIsLoading(true);
834
1009
 
835
1010
  outer: while (true) {
@@ -934,30 +1109,16 @@ export class AIManager {
934
1109
 
935
1110
  logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
936
1111
 
937
- const toolsConfig = this.getFilteredToolsConfig();
938
- const toolNames = new Set(toolsConfig.map((t) => t.function.name));
939
- const filteredToolPlugins = this.toolManager
940
- .getTools()
941
- .filter((t) => toolNames.has(t.name));
942
-
943
- let autoMemoryOptions:
944
- | { directory: string; content: string }
945
- | undefined;
946
-
947
- if (this.getAutoMemoryEnabled()) {
948
- const directory = this.memoryService.getAutoMemoryDirectory(
949
- this.getWorkdir(),
950
- );
951
- const content = await this.memoryService.getAutoMemoryContent(
952
- this.getWorkdir(),
953
- );
954
- autoMemoryOptions = { directory, content };
955
- }
1112
+ const { toolsConfig, toolNames, filteredToolPlugins } =
1113
+ this.resolveFilteredTools();
956
1114
 
957
1115
  // Get memory for message-array injection (not system prompt)
958
1116
  const { prependContent } =
959
1117
  await this.messageManager.getMemoryForInjection();
960
1118
 
1119
+ const mainSystemPrompt =
1120
+ await this.buildMainSystemPrompt(filteredToolPlugins);
1121
+
961
1122
  // Call AI service with streaming callbacks if enabled
962
1123
  const callAgentOptions: CallAgentOptions = {
963
1124
  gatewayConfig: this.getGatewayConfig(),
@@ -968,18 +1129,7 @@ export class AIManager {
968
1129
  workdir: this.getWorkdir(), // Pass working directory
969
1130
  tools: toolsConfig, // Pass filtered tool configuration
970
1131
  model: model, // Use passed model
971
- systemPrompt: buildSystemPrompt(
972
- this.systemPrompt,
973
- filteredToolPlugins,
974
- {
975
- workdir: this.getWorkdir(),
976
- originalWorkdir: this.getOriginalWorkdir(),
977
- language: this.getLanguage(),
978
- isSubagent: !!this.subagentType,
979
- worktreeSession: this.getWorktreeSession(),
980
- autoMemory: autoMemoryOptions,
981
- },
982
- ), // Pass custom system prompt
1132
+ systemPrompt: mainSystemPrompt, // Pass custom system prompt
983
1133
  maxTokens: maxTokens, // Pass max tokens override
984
1134
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
985
1135
  };
@@ -988,7 +1138,7 @@ export class AIManager {
988
1138
  if (prependContent.trim()) {
989
1139
  callAgentOptions.messages.unshift({
990
1140
  role: "user",
991
- content: `<system-reminder>\n${prependContent}\n</system-reminder>`,
1141
+ content: wrapInSystemReminder(prependContent),
992
1142
  });
993
1143
  }
994
1144
 
@@ -1503,6 +1653,14 @@ export class AIManager {
1503
1653
  });
1504
1654
  }
1505
1655
  }
1656
+ // Re-assert loading state before restarting: if this turn was
1657
+ // aborted, abortAIMessage already reset loading to false, and the
1658
+ // restart below continues the conversation — the UI must show
1659
+ // streaming again (cursor, stop button, ESC handling).
1660
+ this.setIsLoading(true);
1661
+ // Adopt the current (possibly abort-bumped) generation so this
1662
+ // continued turn's end-of-turn cleanup is not skipped as superseded.
1663
+ myGeneration = this.turnGeneration;
1506
1664
  // Restart outer loop to process the notifications
1507
1665
  shouldRestart = true;
1508
1666
  turnOffset = 0;