wave-agent-sdk 0.19.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/builtin/plugins/sdd/scripts/session-start.js +1 -1
  2. package/builtin/plugins/sdd/skills/specify/SKILL.md +3 -4
  3. package/builtin/skills/settings/ENV.md +15 -9
  4. package/builtin/skills/settings/HOOKS.md +27 -2
  5. package/dist/agent.d.ts +9 -20
  6. package/dist/agent.js +28 -99
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/managers/aiManager.d.ts +71 -8
  10. package/dist/managers/aiManager.js +290 -85
  11. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  12. package/dist/managers/backgroundTaskManager.js +11 -0
  13. package/dist/managers/bangManager.d.ts +6 -0
  14. package/dist/managers/bangManager.js +11 -0
  15. package/dist/managers/hookManager.d.ts +8 -2
  16. package/dist/managers/hookManager.js +14 -4
  17. package/dist/managers/mcpManager.d.ts +18 -4
  18. package/dist/managers/mcpManager.js +40 -18
  19. package/dist/managers/messageManager.d.ts +9 -5
  20. package/dist/managers/messageManager.js +36 -12
  21. package/dist/managers/subagentManager.d.ts +6 -0
  22. package/dist/managers/subagentManager.js +33 -22
  23. package/dist/managers/toolManager.js +5 -0
  24. package/dist/prompts/index.d.ts +0 -1
  25. package/dist/prompts/index.js +0 -4
  26. package/dist/services/aiService.d.ts +1 -34
  27. package/dist/services/aiService.js +18 -130
  28. package/dist/services/autoMemoryService.d.ts +27 -2
  29. package/dist/services/autoMemoryService.js +124 -36
  30. package/dist/services/configurationService.d.ts +21 -2
  31. package/dist/services/configurationService.js +86 -24
  32. package/dist/services/initializationService.js +14 -4
  33. package/dist/services/interactionService.js +35 -7
  34. package/dist/services/remoteSettingsService.d.ts +12 -0
  35. package/dist/services/remoteSettingsService.js +15 -1
  36. package/dist/services/session.d.ts +13 -0
  37. package/dist/services/session.js +64 -0
  38. package/dist/services/taskManager.js +7 -1
  39. package/dist/tools/bashTool.js +1 -0
  40. package/dist/tools/enterWorktreeTool.js +14 -3
  41. package/dist/tools/exitWorktreeTool.js +11 -10
  42. package/dist/tools/types.d.ts +7 -0
  43. package/dist/types/agent.d.ts +0 -2
  44. package/dist/types/config.d.ts +9 -0
  45. package/dist/types/core.d.ts +1 -1
  46. package/dist/types/hooks.d.ts +2 -2
  47. package/dist/utils/containerSetup.js +13 -4
  48. package/dist/utils/openaiClient.js +2 -1
  49. package/dist/utils/pathEncoder.js +7 -2
  50. package/dist/utils/worktreeUtils.d.ts +17 -0
  51. package/dist/utils/worktreeUtils.js +339 -1
  52. package/package.json +1 -1
  53. package/src/agent.ts +43 -112
  54. package/src/index.ts +1 -0
  55. package/src/managers/aiManager.ts +389 -110
  56. package/src/managers/backgroundTaskManager.ts +15 -0
  57. package/src/managers/bangManager.ts +15 -0
  58. package/src/managers/hookManager.ts +20 -5
  59. package/src/managers/mcpManager.ts +60 -18
  60. package/src/managers/messageManager.ts +51 -23
  61. package/src/managers/subagentManager.ts +36 -25
  62. package/src/managers/toolManager.ts +7 -0
  63. package/src/prompts/index.ts +0 -4
  64. package/src/services/aiService.ts +25 -203
  65. package/src/services/autoMemoryService.ts +145 -39
  66. package/src/services/configurationService.ts +100 -24
  67. package/src/services/initializationService.ts +17 -4
  68. package/src/services/interactionService.ts +49 -6
  69. package/src/services/remoteSettingsService.ts +16 -1
  70. package/src/services/session.ts +68 -0
  71. package/src/services/taskManager.ts +10 -1
  72. package/src/tools/bashTool.ts +1 -0
  73. package/src/tools/enterWorktreeTool.ts +19 -2
  74. package/src/tools/exitWorktreeTool.ts +15 -12
  75. package/src/tools/types.ts +7 -0
  76. package/src/types/agent.ts +0 -6
  77. package/src/types/config.ts +9 -0
  78. package/src/types/core.ts +1 -1
  79. package/src/types/hooks.ts +2 -2
  80. package/src/utils/containerSetup.ts +15 -5
  81. package/src/utils/openaiClient.ts +2 -0
  82. package/src/utils/pathEncoder.ts +7 -2
  83. package/src/utils/worktreeUtils.ts +401 -1
  84. package/dist/constants/goalPrompts.d.ts +0 -1
  85. package/dist/constants/goalPrompts.js +0 -10
  86. package/dist/managers/goalManager.d.ts +0 -42
  87. package/dist/managers/goalManager.js +0 -177
  88. package/src/constants/goalPrompts.ts +0 -10
  89. package/src/managers/goalManager.ts +0 -232
@@ -62,6 +62,22 @@ import {
62
62
  import { logOTelEvent } from "../telemetry/events.js";
63
63
  import type { BackgroundTask } from "../types/processes.js";
64
64
 
65
+ /** Result of a fork-path agent loop (compaction or auto-memory extraction). */
66
+ interface ForkLoopResult {
67
+ content?: string;
68
+ usage?: {
69
+ prompt_tokens: number;
70
+ completion_tokens: number;
71
+ total_tokens: number;
72
+ };
73
+ }
74
+
75
+ /** Max turns for the compaction fork: the model should summarize, not act. */
76
+ const MAX_FORK_TURNS = 3;
77
+
78
+ /** Max turns for the auto-memory extraction fork. */
79
+ const MAX_AUTO_MEMORY_FORK_TURNS = 5;
80
+
65
81
  // Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
66
82
  // Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
67
83
  function truncateWithMarker(text: string, max: number): string {
@@ -214,9 +230,27 @@ export class AIManager {
214
230
  return this.container.get<ConfigurationService>("ConfigurationService")!;
215
231
  }
216
232
 
233
+ /**
234
+ * OS env merged with the per-session env snapshot. Falls back to process.env
235
+ * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
236
+ * unit tests with partial mocks), so hook-context env construction never
237
+ * throws. Use this (not the non-null `configurationService` getter) when
238
+ * building hook context env.
239
+ */
240
+ private get mergedEnv(): Record<string, string> {
241
+ return (
242
+ this.container
243
+ .get<ConfigurationService>("ConfigurationService")
244
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
245
+ );
246
+ }
247
+
217
248
  // Getter methods for accessing dynamic configuration
218
249
  public getGatewayConfig(): GatewayConfig {
219
- return this.configurationService.resolveGatewayConfig();
250
+ return {
251
+ ...this.configurationService.resolveGatewayConfig(),
252
+ sessionId: this.messageManager.getSessionId(),
253
+ };
220
254
  }
221
255
 
222
256
  public getModelConfig(): ModelConfig {
@@ -676,8 +710,7 @@ export class AIManager {
676
710
  } catch (compactError) {
677
711
  this.consecutiveCompactionFailures++;
678
712
  logger?.error(
679
- `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`,
680
- compactError,
713
+ `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`,
681
714
  );
682
715
  this.messageManager.addErrorBlock(
683
716
  `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
@@ -727,27 +760,26 @@ export class AIManager {
727
760
  }
728
761
 
729
762
  /**
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.
763
+ * Fork-path loop: run a bounded agent loop over a copy of the conversation
764
+ * using the same system prompt, tools, model, and generation params as the
765
+ * main loop, so the forked request prefix matches exactly and the prompt
766
+ * cache is reused. A `canUseTool` gate decides whether each tool call
767
+ * executes locally (with a stripped context) or is denied and fed back to
768
+ * the model for another turn. Returns undefined content when the model never
769
+ * produces text; the caller treats that as a failure.
737
770
  */
738
- private async runCompactFork(
771
+ private async runForkLoop(
739
772
  historyMessages: ChatCompletionMessageParam[],
740
- compactPrompt: string,
773
+ prompt: string,
774
+ options: {
775
+ maxTurns: number;
776
+ /** Gate deciding which tool calls execute locally. When undefined, every tool call is denied. */
777
+ canUseTool?: (name: string, args: Record<string, unknown>) => boolean;
778
+ /** Message fed back to the model when a tool call is denied. */
779
+ deniedToolMessage?: string;
780
+ },
741
781
  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;
782
+ ): Promise<ForkLoopResult> {
751
783
  const modelConfig = this.getModelConfig();
752
784
  const gatewayConfig = this.getGatewayConfig();
753
785
  const sessionId = this.messageManager.getSessionId();
@@ -765,21 +797,22 @@ export class AIManager {
765
797
  });
766
798
  }
767
799
 
768
- forkMessages.push({ role: "user", content: compactPrompt });
800
+ forkMessages.push({ role: "user", content: prompt });
769
801
 
770
802
  const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
771
803
  const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
772
804
 
773
- let totalUsage:
774
- | {
775
- prompt_tokens: number;
776
- completion_tokens: number;
777
- total_tokens: number;
778
- }
779
- | undefined;
805
+ // Fresh read-state map so Read/Edit state built up inside the fork never
806
+ // leaks into the main session's dedup and staleness tracking.
807
+ const forkReadFileState = new Map<
808
+ string,
809
+ { mtime: number; hash: string; offset?: number; limit?: number }
810
+ >();
811
+
812
+ let totalUsage: ForkLoopResult["usage"];
780
813
  let content: string | undefined;
781
814
 
782
- for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
815
+ for (let turn = 0; turn < options.maxTurns; turn++) {
783
816
  const result = await aiService.callAgent({
784
817
  gatewayConfig,
785
818
  modelConfig,
@@ -792,7 +825,7 @@ export class AIManager {
792
825
  toolChoice: this.toolChoiceOverride,
793
826
  // Stream so a slow reasoning model emits first bytes before the
794
827
  // gateway's idle timeout fires (non-streaming waits for the full
795
- // summary, which exceeds the timeout on large contexts).
828
+ // response, which exceeds the timeout on large contexts).
796
829
  stream: true,
797
830
  });
798
831
 
@@ -814,31 +847,329 @@ export class AIManager {
814
847
  }
815
848
 
816
849
  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.
850
+ const functionCalls = result.tool_calls.filter(
851
+ (tc) => tc.type === "function",
852
+ );
853
+ if (functionCalls.length === 0) break;
854
+
819
855
  forkMessages.push({
820
856
  role: "assistant",
821
857
  content: result.content ?? null,
822
- tool_calls: result.tool_calls,
858
+ tool_calls: functionCalls,
823
859
  });
824
- for (const toolCall of result.tool_calls) {
860
+
861
+ for (const toolCall of functionCalls) {
862
+ const name = toolCall.function?.name || "";
863
+ const args = this.parseForkToolArgs(toolCall.function?.arguments);
864
+ let toolContent: string;
865
+ if (options.canUseTool && options.canUseTool(name, args)) {
866
+ toolContent = await this.executeForkTool(
867
+ name,
868
+ args,
869
+ workdir,
870
+ sessionId,
871
+ abortSignal,
872
+ forkReadFileState,
873
+ );
874
+ } else {
875
+ toolContent =
876
+ options.deniedToolMessage ??
877
+ "Tool use is not allowed in this context";
878
+ }
825
879
  forkMessages.push({
826
880
  role: "tool",
827
881
  tool_call_id: toolCall.id,
828
- content: "Tool use is not allowed during compaction",
882
+ content: toolContent,
829
883
  });
830
884
  }
831
885
  continue;
832
886
  }
833
887
 
834
888
  // Neither text nor tool calls: retrying the identical request is
835
- // pointless, bail out and let the caller fail the compaction.
889
+ // pointless, bail out and let the caller fail.
836
890
  break;
837
891
  }
838
892
 
839
893
  return { content, usage: totalUsage };
840
894
  }
841
895
 
896
+ /**
897
+ * Fork-path compaction: deny all tool calls locally (the model is told to
898
+ * summarize, not act) and feed the rejections back for another turn.
899
+ */
900
+ private async runCompactFork(
901
+ historyMessages: ChatCompletionMessageParam[],
902
+ compactPrompt: string,
903
+ abortSignal?: AbortSignal,
904
+ ): Promise<ForkLoopResult> {
905
+ return this.runForkLoop(
906
+ historyMessages,
907
+ compactPrompt,
908
+ {
909
+ maxTurns: MAX_FORK_TURNS,
910
+ deniedToolMessage: "Tool use is not allowed during compaction",
911
+ },
912
+ abortSignal,
913
+ );
914
+ }
915
+
916
+ /**
917
+ * Auto-memory extraction via the perfect fork: the extraction prompt is run
918
+ * against the same request prefix as the main conversation (same system
919
+ * prompt, tools, model, and message history) so the prompt cache is reused.
920
+ * Gate-approved tools execute locally in a stripped context; everything else
921
+ * is denied. Usage is reported with operation_type "agent" so extraction
922
+ * token costs stay visible in session accounting.
923
+ */
924
+ public async runAutoMemoryFork(
925
+ messages: Message[],
926
+ prompt: string,
927
+ options: {
928
+ canUseTool: (name: string, args: Record<string, unknown>) => boolean;
929
+ deniedToolMessage?: string;
930
+ maxTurns?: number;
931
+ },
932
+ abortSignal?: AbortSignal,
933
+ ): Promise<ForkLoopResult> {
934
+ const modelConfig = this.getModelConfig();
935
+ const historyMessages = convertMessagesForAPI(messages, {
936
+ supportsVision: supportsVision(modelConfig.capabilities),
937
+ });
938
+ // Give the fork a real signal even when the caller has none, so tools
939
+ // (e.g. Bash's foreground path) always receive a well-formed context.
940
+ const signal = abortSignal ?? new AbortController().signal;
941
+ const result = await this.runForkLoop(
942
+ historyMessages,
943
+ prompt,
944
+ {
945
+ maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
946
+ canUseTool: options.canUseTool,
947
+ deniedToolMessage: options.deniedToolMessage,
948
+ },
949
+ signal,
950
+ );
951
+
952
+ if (result.usage && this.callbacks?.onUsageAdded) {
953
+ this.callbacks.onUsageAdded({
954
+ ...result.usage,
955
+ model: modelConfig.model,
956
+ operation_type: "agent",
957
+ });
958
+ }
959
+ return result;
960
+ }
961
+
962
+ /**
963
+ * Parse a fork tool call's JSON arguments, recovering truncated JSON the
964
+ * same way the main loop does. Unparseable arguments fall back to `{}` and
965
+ * are rejected by the gate or the tool's own parameter validation.
966
+ */
967
+ private parseForkToolArgs(
968
+ argsString: string | undefined,
969
+ ): Record<string, unknown> {
970
+ if (!argsString?.trim()) return {};
971
+ try {
972
+ return JSON.parse(argsString) as Record<string, unknown>;
973
+ } catch {
974
+ try {
975
+ return JSON.parse(recoverTruncatedJson(argsString)) as Record<
976
+ string,
977
+ unknown
978
+ >;
979
+ } catch {
980
+ return {};
981
+ }
982
+ }
983
+ }
984
+
985
+ /**
986
+ * Execute a single tool call inside a fork with a stripped context: no
987
+ * permission manager (never prompts the user), no message manager (no
988
+ * conditional-rule triggering), no messageId (no file-history snapshots),
989
+ * and no background task manager (commands run in the foreground). Only
990
+ * gate-approved tool names reach this path.
991
+ */
992
+ private async executeForkTool(
993
+ name: string,
994
+ args: Record<string, unknown>,
995
+ workdir: string,
996
+ sessionId: string | undefined,
997
+ abortSignal: AbortSignal | undefined,
998
+ readFileState: ToolContext["readFileState"],
999
+ ): Promise<string> {
1000
+ const plugin = this.toolManager.getTools().find((t) => t.name === name);
1001
+ if (!plugin) {
1002
+ return `Tool '${name}' not found`;
1003
+ }
1004
+ const context: ToolContext = {
1005
+ abortSignal,
1006
+ workdir,
1007
+ originalWorkdir: this.originalWorkdir,
1008
+ sessionId,
1009
+ taskManager: this.taskManager,
1010
+ readFileState,
1011
+ onShortResultUpdate: () => {},
1012
+ onResultUpdate: () => {},
1013
+ onCwdChange: () => {},
1014
+ };
1015
+ try {
1016
+ const result = await plugin.execute(args, context);
1017
+ if (result.content) return result.content;
1018
+ if (result.error) return `Error: ${result.error}`;
1019
+ return "";
1020
+ } catch (error) {
1021
+ const message = error instanceof Error ? error.message : String(error);
1022
+ logger?.error(`Fork tool execution failed for ${name}:`, error);
1023
+ return `Tool execution failed: ${message}`;
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Fork-path side question ("/btw"): run a single-turn fork of the
1029
+ * conversation using the same system prompt, tools, model, and generation
1030
+ * params as the main loop, so the forked request prefix matches exactly and
1031
+ * the prompt cache is reused. The in-progress assistant message (if any) is
1032
+ * stripped so the fork starts from the last completed request prefix. Tools
1033
+ * are never executed — the wrapped question instructs the model to answer
1034
+ * directly; an attempted tool call is surfaced as an error string.
1035
+ */
1036
+ async runBtwFork(
1037
+ question: string,
1038
+ abortSignal?: AbortSignal,
1039
+ onContent?: (content: string) => void,
1040
+ onReasoning?: (content: string) => void,
1041
+ ): Promise<{ content?: string; error?: string }> {
1042
+ const modelConfig = this.getModelConfig();
1043
+ const gatewayConfig = this.getGatewayConfig();
1044
+ const sessionId = this.messageManager.getSessionId();
1045
+ const workdir = this.getWorkdir();
1046
+
1047
+ const rawMessages = this.messageManager.getMessages();
1048
+
1049
+ // Strip the in-progress assistant message (a block still in "streaming"
1050
+ // stage) so the fork's request prefix matches the last completed
1051
+ // main-loop request and the prompt cache is reused.
1052
+ const lastMessage = rawMessages[rawMessages.length - 1];
1053
+ const hasInProgressMessage =
1054
+ lastMessage?.role === "assistant" &&
1055
+ lastMessage.blocks.some(
1056
+ (b) => "stage" in b && (b as { stage?: string }).stage === "streaming",
1057
+ );
1058
+
1059
+ const forkMessages: ChatCompletionMessageParam[] = convertMessagesForAPI(
1060
+ hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages,
1061
+ { supportsVision: supportsVision(modelConfig.capabilities) },
1062
+ );
1063
+
1064
+ // Mirror the main loop's memory injection so the request prefix matches.
1065
+ const { prependContent } =
1066
+ await this.messageManager.getMemoryForInjection();
1067
+ if (prependContent.trim()) {
1068
+ forkMessages.unshift({
1069
+ role: "user",
1070
+ content: wrapInSystemReminder(prependContent),
1071
+ });
1072
+ }
1073
+
1074
+ // Wrap the question with the side-question instructions (verbatim
1075
+ // Claude Code sideQuestion.ts) so the model answers directly.
1076
+ const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
1077
+
1078
+ IMPORTANT CONTEXT:
1079
+ - You are a separate, lightweight agent spawned to answer this one question
1080
+ - The main agent is NOT interrupted - it continues working independently in the background
1081
+ - You share the conversation context but are a completely separate instance
1082
+ - Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
1083
+
1084
+ CRITICAL CONSTRAINTS:
1085
+ - You have NO tools available - you cannot read files, run commands, search, or take any actions
1086
+ - This is a one-off response - there will be no follow-up turns
1087
+ - You can ONLY provide information based on what you already know from the conversation context
1088
+ - NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
1089
+ - If you don't know the answer, say so - do not offer to look it up or investigate
1090
+
1091
+ Simply answer the question with the information you have.</system-reminder>
1092
+
1093
+ ${question}`;
1094
+ forkMessages.push({ role: "user", content: wrappedQuestion });
1095
+
1096
+ const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
1097
+ const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
1098
+
1099
+ try {
1100
+ // Surface partial output to the caller (e.g. the /btw overlay's
1101
+ // streaming display) as it arrives. Reasoning chunks from thinking
1102
+ // models stream through a separate channel when the caller supplies
1103
+ // one (webview hosts distinguish thinking from content so the panel
1104
+ // can drop thinking text once content starts); otherwise they fall
1105
+ // back to the content channel (CLI overlay mixes both).
1106
+ const streamToOverlay = (text: string) => {
1107
+ if (text.trim()) {
1108
+ onContent?.(text);
1109
+ }
1110
+ };
1111
+ const streamReasoning = (text: string) => {
1112
+ if (text.trim()) {
1113
+ if (onReasoning) {
1114
+ onReasoning(text);
1115
+ } else {
1116
+ onContent?.(text);
1117
+ }
1118
+ }
1119
+ };
1120
+ const result = await aiService.callAgent({
1121
+ gatewayConfig,
1122
+ modelConfig,
1123
+ messages: forkMessages,
1124
+ sessionId,
1125
+ abortSignal,
1126
+ workdir,
1127
+ tools: toolsConfig,
1128
+ systemPrompt,
1129
+ toolChoice: this.toolChoiceOverride,
1130
+ // Stream so a slow reasoning model emits first bytes before the
1131
+ // gateway's idle timeout fires (same rationale as runCompactFork).
1132
+ stream: true,
1133
+ onContentUpdate: streamToOverlay,
1134
+ onReasoningUpdate: streamReasoning,
1135
+ });
1136
+
1137
+ if (result.content?.trim()) {
1138
+ return { content: result.content };
1139
+ }
1140
+
1141
+ // A thinking model may emit only reasoning content (e.g. the stream
1142
+ // is truncated before the final answer); surface that instead of
1143
+ // falling through to "No response received".
1144
+ if (result.reasoning_content?.trim()) {
1145
+ return { content: result.reasoning_content };
1146
+ }
1147
+
1148
+ if (result.tool_calls && result.tool_calls.length > 0) {
1149
+ const firstFunctionCall = result.tool_calls.find(
1150
+ (call) => call.type === "function",
1151
+ );
1152
+ const toolName = firstFunctionCall?.function?.name ?? "a tool";
1153
+ return {
1154
+ error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
1155
+ };
1156
+ }
1157
+
1158
+ // Neither text nor tool calls.
1159
+ return { error: "No response received" };
1160
+ } catch (error) {
1161
+ // aiService.callAgent converts AbortError into a plain Error, so the
1162
+ // abort is detected via the signal itself; rethrow so the UI can
1163
+ // silently dismiss instead of showing an error.
1164
+ if (abortSignal?.aborted) {
1165
+ throw error;
1166
+ }
1167
+ return {
1168
+ error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
1169
+ };
1170
+ }
1171
+ }
1172
+
842
1173
  /**
843
1174
  * Build post-compact context restoration content.
844
1175
  * Restores file reads, working directory, plan mode, skills, and background tasks.
@@ -1132,6 +1463,12 @@ export class AIManager {
1132
1463
  systemPrompt: mainSystemPrompt, // Pass custom system prompt
1133
1464
  maxTokens: maxTokens, // Pass max tokens override
1134
1465
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
1466
+ // Fast-model subagents send disable-thinking params only when
1467
+ // explicitly configured (never in the agent loop).
1468
+ disableThinkingOptions:
1469
+ this.modelOverride === "fastModel"
1470
+ ? this.getModelConfig().disableThinkingOptions
1471
+ : undefined,
1135
1472
  };
1136
1473
 
1137
1474
  // Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
@@ -1560,77 +1897,19 @@ export class AIManager {
1560
1897
  }
1561
1898
  }
1562
1899
 
1563
- // Goal evaluation — supersedes Stop hooks when active
1564
- const goalManager = this.container.has("GoalManager")
1565
- ? this.container.get<import("./goalManager.js").GoalManager>(
1566
- "GoalManager",
1567
- )
1568
- : undefined;
1569
-
1570
- let goalContinuing = false;
1571
-
1572
- if (goalManager?.isGoalActive() && !this.subagentType) {
1573
- // 1. Increment turn count and check circuit breakers
1574
- goalManager.incrementTurnCount();
1575
- const circuitBreaker = goalManager.checkCircuitBreakers();
1576
-
1577
- if (circuitBreaker) {
1578
- goalManager.clearGoal();
1579
- logger?.info(`[Goal] ${circuitBreaker}`);
1580
- this.messageManager.addUserMessage({
1581
- content: `<system-reminder>${circuitBreaker}</system-reminder>`,
1582
- isMeta: true,
1583
- });
1584
- // Fall through to normal Stop hooks on the final turn
1585
- } else {
1586
- // 2. Evaluate goal
1587
- const evaluation = await goalManager.evaluateGoal(
1588
- abortController.signal,
1589
- );
1590
-
1591
- if (evaluation.isMet) {
1592
- goalManager.clearGoal();
1593
- logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
1594
- this.messageManager.addUserMessage({
1595
- content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
1596
- isMeta: true,
1597
- });
1598
- // Fall through to normal Stop hooks on the final turn
1599
- } else {
1600
- const goal = goalManager.getGoal()!;
1601
- goal.lastReason = evaluation.reason;
1602
- logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
1603
- this.messageManager.addUserMessage({
1604
- content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
1605
- isMeta: true,
1606
- });
1607
- // Keep loading state active to prevent UI flicker
1608
- this.setIsLoading(true);
1609
- goalContinuing = true;
1610
- // Restart outer loop to continue goal pursuit
1611
- shouldRestart = true;
1612
- turnOffset = 0;
1613
- }
1614
- }
1615
- }
1616
-
1617
- // Skip Stop hooks when goal evaluator is continuing the conversation
1618
- if (goalContinuing) {
1619
- // Goal evaluator supersedes Stop hooks
1620
- } else {
1621
- const shouldContinue = await this.executeStopHooks();
1900
+ // Execute Stop hooks
1901
+ const shouldContinue = await this.executeStopHooks();
1622
1902
 
1623
- // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1624
- // restart the AI conversation cycle
1625
- if (shouldContinue) {
1626
- logger?.info(
1627
- `${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
1628
- );
1903
+ // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1904
+ // restart the AI conversation cycle
1905
+ if (shouldContinue) {
1906
+ logger?.info(
1907
+ `${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
1908
+ );
1629
1909
 
1630
- // Restart the conversation to let AI fix the issues
1631
- shouldRestart = true;
1632
- turnOffset = 0;
1633
- }
1910
+ // Restart the conversation to let AI fix the issues
1911
+ shouldRestart = true;
1912
+ turnOffset = 0;
1634
1913
  }
1635
1914
  }
1636
1915
 
@@ -1739,7 +2018,7 @@ export class AIManager {
1739
2018
  lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
1740
2019
  // Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
1741
2020
  env: Object.fromEntries(
1742
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2021
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1743
2022
  ) as Record<string, string>, // Include environment variables
1744
2023
  };
1745
2024
 
@@ -1933,7 +2212,7 @@ export class AIManager {
1933
2212
  const sessionId = this.messageManager.getSessionId();
1934
2213
  const transcriptPath = this.messageManager.getTranscriptPath();
1935
2214
  const env = Object.fromEntries(
1936
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2215
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1937
2216
  ) as Record<string, string>;
1938
2217
  await this.hookManager.executeCwdChangedHooks(
1939
2218
  oldCwd,
@@ -2028,7 +2307,7 @@ export class AIManager {
2028
2307
  toolInput,
2029
2308
  subagentType: this.subagentType, // Include subagent type in hook context
2030
2309
  env: Object.fromEntries(
2031
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2310
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2032
2311
  ) as Record<string, string>, // Include environment variables
2033
2312
  };
2034
2313
 
@@ -2104,7 +2383,7 @@ export class AIManager {
2104
2383
  subagentType: this.subagentType, // Include subagent type in hook context
2105
2384
  planFilePath: this.permissionManager?.getPlanFilePath(),
2106
2385
  env: Object.fromEntries(
2107
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2386
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2108
2387
  ) as Record<string, string>, // Include environment variables
2109
2388
  };
2110
2389
 
@@ -8,6 +8,7 @@ import { logger } from "../utils/globalLogger.js";
8
8
  import { Container } from "../utils/container.js";
9
9
  import { MessageQueue } from "./messageQueue.js";
10
10
  import { resolveShellPath } from "../utils/shellResolver.js";
11
+ import type { ConfigurationService } from "../services/configurationService.js";
11
12
 
12
13
  export interface BackgroundTaskManagerCallbacks {
13
14
  onBackgroundTasksChange?: (tasks: BackgroundTask[]) => void;
@@ -32,6 +33,19 @@ export class BackgroundTaskManager {
32
33
  this.workdir = options.workdir;
33
34
  }
34
35
 
36
+ /**
37
+ * Merged env (OS env overlaid with this session's settings snapshot) for
38
+ * background-task subprocesses, so settings `env` vars reach them without
39
+ * polluting other sessions in one `wave --stdio` process.
40
+ */
41
+ private get sessionEnv(): Record<string, string> {
42
+ return (
43
+ this.container
44
+ .get<ConfigurationService>("ConfigurationService")
45
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
46
+ );
47
+ }
48
+
35
49
  /**
36
50
  * Fire the onBackgroundTasksChange callback so UI consumers refresh.
37
51
  * Public so other managers (e.g. WorkflowManager) can trigger a refresh
@@ -73,6 +87,7 @@ export class BackgroundTaskManager {
73
87
  cwd: cwd ?? this.workdir,
74
88
  env: {
75
89
  ...process.env,
90
+ ...this.sessionEnv,
76
91
  },
77
92
  });
78
93
 
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "child_process";
2
2
  import type { MessageManager } from "./messageManager.js";
3
3
  import { Container } from "../utils/container.js";
4
4
  import { resolveShellPath } from "../utils/shellResolver.js";
5
+ import type { ConfigurationService } from "../services/configurationService.js";
5
6
 
6
7
  export interface BangManagerOptions {
7
8
  workdir: string;
@@ -29,6 +30,19 @@ export class BangManager {
29
30
  return this.container.get<MessageManager>("MessageManager")!;
30
31
  }
31
32
 
33
+ /**
34
+ * Merged env (OS env overlaid with this session's settings snapshot) for
35
+ * bang-command subprocesses, so settings `env` vars reach them without
36
+ * polluting other sessions in one `wave --stdio` process.
37
+ */
38
+ private get sessionEnv(): Record<string, string> {
39
+ return (
40
+ this.container
41
+ .get<ConfigurationService>("ConfigurationService")
42
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
43
+ );
44
+ }
45
+
32
46
  private setCommandRunning(isRunning: boolean): void {
33
47
  this.isCommandRunning = isRunning;
34
48
  this.onCommandRunningChange?.(isRunning);
@@ -51,6 +65,7 @@ export class BangManager {
51
65
  cwd: this.workdir,
52
66
  env: {
53
67
  ...process.env,
68
+ ...this.sessionEnv,
54
69
  },
55
70
  });
56
71