wave-agent-sdk 0.19.8 → 1.0.0

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 (95) 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 +47 -0
  6. package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
  7. package/builtin/skills/settings/ENV.md +15 -9
  8. package/builtin/skills/settings/HOOKS.md +27 -2
  9. package/dist/agent.d.ts +1 -0
  10. package/dist/agent.js +26 -12
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/managers/aiManager.d.ts +25 -0
  14. package/dist/managers/aiManager.js +172 -51
  15. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  16. package/dist/managers/backgroundTaskManager.js +11 -0
  17. package/dist/managers/bangManager.d.ts +6 -0
  18. package/dist/managers/bangManager.js +11 -0
  19. package/dist/managers/hookManager.d.ts +8 -2
  20. package/dist/managers/hookManager.js +14 -4
  21. package/dist/managers/mcpManager.d.ts +18 -4
  22. package/dist/managers/mcpManager.js +40 -18
  23. package/dist/managers/permissionManager.d.ts +7 -0
  24. package/dist/managers/permissionManager.js +102 -142
  25. package/dist/managers/pluginManager.d.ts +7 -0
  26. package/dist/managers/pluginManager.js +31 -0
  27. package/dist/managers/toolManager.js +5 -0
  28. package/dist/prompts/index.d.ts +12 -1
  29. package/dist/prompts/index.js +133 -45
  30. package/dist/services/aiService.d.ts +1 -17
  31. package/dist/services/aiService.js +3 -85
  32. package/dist/services/configurationService.d.ts +21 -2
  33. package/dist/services/configurationService.js +72 -23
  34. package/dist/services/initializationService.js +14 -4
  35. package/dist/services/interactionService.js +35 -7
  36. package/dist/services/remoteSettingsService.d.ts +12 -0
  37. package/dist/services/remoteSettingsService.js +15 -1
  38. package/dist/services/session.d.ts +3 -1
  39. package/dist/services/session.js +12 -4
  40. package/dist/services/taskManager.d.ts +1 -0
  41. package/dist/services/taskManager.js +41 -6
  42. package/dist/tools/bashTool.js +1 -0
  43. package/dist/tools/editTool.js +24 -10
  44. package/dist/tools/enterWorktreeTool.js +14 -3
  45. package/dist/tools/exitWorktreeTool.js +11 -10
  46. package/dist/tools/grepTool.js +8 -2
  47. package/dist/tools/types.d.ts +7 -0
  48. package/dist/tools/writeTool.js +36 -0
  49. package/dist/types/config.d.ts +2 -0
  50. package/dist/types/hooks.d.ts +2 -2
  51. package/dist/utils/bashParser.d.ts +25 -0
  52. package/dist/utils/bashParser.js +103 -0
  53. package/dist/utils/configPaths.d.ts +4 -0
  54. package/dist/utils/configPaths.js +6 -0
  55. package/dist/utils/containerSetup.js +1 -1
  56. package/dist/utils/fileSearch.js +4 -2
  57. package/dist/utils/openaiClient.js +2 -1
  58. package/dist/utils/pathEncoder.js +7 -2
  59. package/dist/utils/worktreeUtils.d.ts +17 -0
  60. package/dist/utils/worktreeUtils.js +339 -1
  61. package/package.json +1 -1
  62. package/src/agent.ts +26 -12
  63. package/src/index.ts +1 -0
  64. package/src/managers/aiManager.ts +238 -66
  65. package/src/managers/backgroundTaskManager.ts +15 -0
  66. package/src/managers/bangManager.ts +15 -0
  67. package/src/managers/hookManager.ts +20 -5
  68. package/src/managers/mcpManager.ts +60 -18
  69. package/src/managers/permissionManager.ts +116 -168
  70. package/src/managers/pluginManager.ts +29 -0
  71. package/src/managers/toolManager.ts +7 -0
  72. package/src/prompts/index.ts +144 -37
  73. package/src/services/aiService.ts +9 -128
  74. package/src/services/configurationService.ts +84 -23
  75. package/src/services/initializationService.ts +17 -4
  76. package/src/services/interactionService.ts +49 -6
  77. package/src/services/remoteSettingsService.ts +16 -1
  78. package/src/services/session.ts +18 -4
  79. package/src/services/taskManager.ts +56 -8
  80. package/src/tools/bashTool.ts +1 -0
  81. package/src/tools/editTool.ts +29 -11
  82. package/src/tools/enterWorktreeTool.ts +19 -2
  83. package/src/tools/exitWorktreeTool.ts +15 -12
  84. package/src/tools/grepTool.ts +11 -2
  85. package/src/tools/types.ts +7 -0
  86. package/src/tools/writeTool.ts +43 -0
  87. package/src/types/config.ts +2 -0
  88. package/src/types/hooks.ts +2 -2
  89. package/src/utils/bashParser.ts +106 -0
  90. package/src/utils/configPaths.ts +7 -0
  91. package/src/utils/containerSetup.ts +3 -1
  92. package/src/utils/fileSearch.ts +6 -2
  93. package/src/utils/openaiClient.ts +2 -0
  94. package/src/utils/pathEncoder.ts +7 -2
  95. package/src/utils/worktreeUtils.ts +401 -1
@@ -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";
@@ -206,9 +214,27 @@ export class AIManager {
206
214
  return this.container.get<ConfigurationService>("ConfigurationService")!;
207
215
  }
208
216
 
217
+ /**
218
+ * OS env merged with the per-session env snapshot. Falls back to process.env
219
+ * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
220
+ * unit tests with partial mocks), so hook-context env construction never
221
+ * throws. Use this (not the non-null `configurationService` getter) when
222
+ * building hook context env.
223
+ */
224
+ private get mergedEnv(): Record<string, string> {
225
+ return (
226
+ this.container
227
+ .get<ConfigurationService>("ConfigurationService")
228
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
229
+ );
230
+ }
231
+
209
232
  // Getter methods for accessing dynamic configuration
210
233
  public getGatewayConfig(): GatewayConfig {
211
- return this.configurationService.resolveGatewayConfig();
234
+ return {
235
+ ...this.configurationService.resolveGatewayConfig(),
236
+ sessionId: this.messageManager.getSessionId(),
237
+ };
212
238
  }
213
239
 
214
240
  public getModelConfig(): ModelConfig {
@@ -537,36 +563,47 @@ export class AIManager {
537
563
 
538
564
  this.setIsCompacting(true);
539
565
  try {
540
- const recentChatMessages = convertMessagesForAPI(messagesToCompact);
541
-
542
- // 4. Call compactMessages with optional custom instructions
543
- const compactResult = await aiService.compactMessages({
544
- gatewayConfig: this.getGatewayConfig(),
545
- modelConfig: this.getModelConfig(),
546
- messages: recentChatMessages,
547
- abortSignal: options.abortSignal,
548
- model: this.getModelConfig().fastModel,
549
- customInstructions: mergedInstructions,
566
+ const modelConfig = this.getModelConfig();
567
+ const recentChatMessages = convertMessagesForAPI(messagesToCompact, {
568
+ supportsVision: supportsVision(modelConfig.capabilities),
550
569
  });
570
+ const compactPrompt = getCompactPrompt(mergedInstructions);
571
+
572
+ // 4. Fork path: fork the conversation with the same system prompt,
573
+ // tools, model, and generation params as the main loop so the forked
574
+ // request prefix matches exactly and the prompt cache is reused.
575
+ const forkResult = await this.runCompactFork(
576
+ recentChatMessages,
577
+ compactPrompt,
578
+ options.abortSignal,
579
+ );
580
+ const summaryContent = forkResult.content;
581
+ const compactTokens = forkResult.usage;
582
+ if (!summaryContent) {
583
+ throw new Error(
584
+ "Compaction failed: the model produced no summary output",
585
+ );
586
+ }
587
+ const compactModel = modelConfig.model;
551
588
 
552
589
  // 5. Handle usage tracking
553
590
  let compactUsage: Usage | undefined;
554
- if (compactResult.usage) {
591
+ if (compactTokens) {
555
592
  compactUsage = {
556
- prompt_tokens: compactResult.usage.prompt_tokens,
557
- completion_tokens: compactResult.usage.completion_tokens,
558
- total_tokens: compactResult.usage.total_tokens,
559
- model: this.getModelConfig().fastModel,
593
+ ...compactTokens,
594
+ model: compactModel,
560
595
  operation_type: "compact",
561
596
  };
562
597
  }
563
598
 
564
- // 6. Build post-compact context restoration
565
- const enhancedSummary = await this.buildPostCompactContext(
566
- compactResult.content,
567
- );
599
+ // 6. Strip the <analysis> scratchpad and extract the <summary> body
600
+ const formattedSummary = formatCompactSummary(summaryContent);
568
601
 
569
- // 7. Execute message reconstruction
602
+ // 7. Build post-compact context restoration
603
+ const enhancedSummary =
604
+ await this.buildPostCompactContext(formattedSummary);
605
+
606
+ // 8. Execute message reconstruction
570
607
  await this.messageManager.compactMessagesAndUpdateSession(
571
608
  enhancedSummary,
572
609
  compactUsage,
@@ -591,7 +628,7 @@ export class AIManager {
591
628
  }
592
629
  }
593
630
 
594
- // 8. Track usage
631
+ // 9. Track usage
595
632
  if (compactUsage && this.callbacks?.onUsageAdded) {
596
633
  this.callbacks.onUsageAdded(compactUsage);
597
634
  }
@@ -601,14 +638,14 @@ export class AIManager {
601
638
  // Reset incremental tracing state after compaction
602
639
  resetTracingState();
603
640
 
604
- // 9. Log OTEL event
641
+ // 10. Log OTEL event
605
642
  logOTelEvent("compaction", {
606
643
  beforeTokens: String(messagesToCompact.length),
607
644
  afterTokens: "1",
608
- model: this.getModelConfig().fastModel,
645
+ model: compactModel,
609
646
  }).catch(() => {});
610
647
 
611
- // 10. Run SessionStart hooks (existing behavior)
648
+ // 11. Run SessionStart hooks (existing behavior)
612
649
  if (this.hookManager) {
613
650
  try {
614
651
  const newSessionId = this.messageManager.getSessionId();
@@ -638,13 +675,13 @@ export class AIManager {
638
675
  }
639
676
  }
640
677
 
641
- // 11. Run PostCompact hooks
678
+ // 12. Run PostCompact hooks
642
679
  if (this.hookManager) {
643
680
  try {
644
681
  await this.hookManager.executePostCompactHooks(
645
682
  this.messageManager.getSessionId(),
646
683
  this.messageManager.getTranscriptPath(),
647
- compactResult.content,
684
+ formattedSummary,
648
685
  );
649
686
  } catch (error) {
650
687
  logger?.warn(`PostCompact hooks failed: ${(error as Error).message}`);
@@ -668,6 +705,158 @@ export class AIManager {
668
705
  }
669
706
  }
670
707
 
708
+ /**
709
+ * Build the system prompt used by the main agent loop. Extracted so the
710
+ * compaction fork can mirror it exactly — the forked request prefix must
711
+ * match the main conversation's for the prompt cache to be reused.
712
+ */
713
+ private async buildMainSystemPrompt(
714
+ filteredToolPlugins: ReturnType<ToolManager["getTools"]>,
715
+ ) {
716
+ let autoMemoryOptions: { directory: string; content: string } | undefined;
717
+
718
+ if (this.getAutoMemoryEnabled()) {
719
+ const directory = this.memoryService.getAutoMemoryDirectory(
720
+ this.getWorkdir(),
721
+ );
722
+ const content = await this.memoryService.getAutoMemoryContent(
723
+ this.getWorkdir(),
724
+ );
725
+ autoMemoryOptions = { directory, content };
726
+ }
727
+
728
+ return buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
729
+ workdir: this.getWorkdir(),
730
+ originalWorkdir: this.getOriginalWorkdir(),
731
+ language: this.getLanguage(),
732
+ isSubagent: !!this.subagentType,
733
+ worktreeSession: this.getWorktreeSession(),
734
+ autoMemory: autoMemoryOptions,
735
+ });
736
+ }
737
+
738
+ private resolveFilteredTools() {
739
+ const toolsConfig = this.getFilteredToolsConfig();
740
+ const toolNames = new Set(toolsConfig.map((t) => t.function.name));
741
+ const filteredToolPlugins = this.toolManager
742
+ .getTools()
743
+ .filter((t) => toolNames.has(t.name));
744
+ return { toolsConfig, toolNames, filteredToolPlugins };
745
+ }
746
+
747
+ /**
748
+ * Fork-path compaction: run a bounded agent loop over a copy of the
749
+ * conversation using the same system prompt, tools, model, and generation
750
+ * params as the main loop, so the forked request prefix matches exactly
751
+ * and the prompt cache is reused. Tool calls are denied locally (the model
752
+ * is told to summarize, not act) and their rejections are fed back for
753
+ * another turn. Returns undefined content when the model never produces
754
+ * text; the caller treats that as a compaction failure.
755
+ */
756
+ private async runCompactFork(
757
+ historyMessages: ChatCompletionMessageParam[],
758
+ compactPrompt: string,
759
+ abortSignal?: AbortSignal,
760
+ ): Promise<{
761
+ content?: string;
762
+ usage?: {
763
+ prompt_tokens: number;
764
+ completion_tokens: number;
765
+ total_tokens: number;
766
+ };
767
+ }> {
768
+ const MAX_FORK_TURNS = 3;
769
+ const modelConfig = this.getModelConfig();
770
+ const gatewayConfig = this.getGatewayConfig();
771
+ const sessionId = this.messageManager.getSessionId();
772
+ const workdir = this.getWorkdir();
773
+
774
+ const forkMessages: ChatCompletionMessageParam[] = [...historyMessages];
775
+
776
+ // Mirror the main loop's memory injection so the request prefix matches.
777
+ const { prependContent } =
778
+ await this.messageManager.getMemoryForInjection();
779
+ if (prependContent.trim()) {
780
+ forkMessages.unshift({
781
+ role: "user",
782
+ content: wrapInSystemReminder(prependContent),
783
+ });
784
+ }
785
+
786
+ forkMessages.push({ role: "user", content: compactPrompt });
787
+
788
+ const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
789
+ const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
790
+
791
+ let totalUsage:
792
+ | {
793
+ prompt_tokens: number;
794
+ completion_tokens: number;
795
+ total_tokens: number;
796
+ }
797
+ | undefined;
798
+ let content: string | undefined;
799
+
800
+ for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
801
+ const result = await aiService.callAgent({
802
+ gatewayConfig,
803
+ modelConfig,
804
+ messages: forkMessages,
805
+ sessionId,
806
+ abortSignal,
807
+ workdir,
808
+ tools: toolsConfig,
809
+ systemPrompt,
810
+ toolChoice: this.toolChoiceOverride,
811
+ // Stream so a slow reasoning model emits first bytes before the
812
+ // gateway's idle timeout fires (non-streaming waits for the full
813
+ // summary, which exceeds the timeout on large contexts).
814
+ stream: true,
815
+ });
816
+
817
+ if (result.usage) {
818
+ totalUsage = {
819
+ prompt_tokens:
820
+ (totalUsage?.prompt_tokens ?? 0) + result.usage.prompt_tokens,
821
+ completion_tokens:
822
+ (totalUsage?.completion_tokens ?? 0) +
823
+ result.usage.completion_tokens,
824
+ total_tokens:
825
+ (totalUsage?.total_tokens ?? 0) + result.usage.total_tokens,
826
+ };
827
+ }
828
+
829
+ if (result.content?.trim()) {
830
+ content = result.content;
831
+ break;
832
+ }
833
+
834
+ if (result.tool_calls && result.tool_calls.length > 0) {
835
+ // Deny all tool calls locally and feed the rejections back so the
836
+ // model gets another turn to produce the summary text.
837
+ forkMessages.push({
838
+ role: "assistant",
839
+ content: result.content ?? null,
840
+ tool_calls: result.tool_calls,
841
+ });
842
+ for (const toolCall of result.tool_calls) {
843
+ forkMessages.push({
844
+ role: "tool",
845
+ tool_call_id: toolCall.id,
846
+ content: "Tool use is not allowed during compaction",
847
+ });
848
+ }
849
+ continue;
850
+ }
851
+
852
+ // Neither text nor tool calls: retrying the identical request is
853
+ // pointless, bail out and let the caller fail the compaction.
854
+ break;
855
+ }
856
+
857
+ return { content, usage: totalUsage };
858
+ }
859
+
671
860
  /**
672
861
  * Build post-compact context restoration content.
673
862
  * Restores file reads, working directory, plan mode, skills, and background tasks.
@@ -833,7 +1022,7 @@ export class AIManager {
833
1022
  // has superseded us. setIsLoading(true) here also closes the "idle but
834
1023
  // previous turn not finished" race window by marking us busy immediately.
835
1024
  this.turnGeneration++;
836
- const myGeneration = this.turnGeneration;
1025
+ let myGeneration = this.turnGeneration;
837
1026
  this.setIsLoading(true);
838
1027
 
839
1028
  outer: while (true) {
@@ -938,30 +1127,16 @@ export class AIManager {
938
1127
 
939
1128
  logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
940
1129
 
941
- const toolsConfig = this.getFilteredToolsConfig();
942
- const toolNames = new Set(toolsConfig.map((t) => t.function.name));
943
- const filteredToolPlugins = this.toolManager
944
- .getTools()
945
- .filter((t) => toolNames.has(t.name));
946
-
947
- let autoMemoryOptions:
948
- | { directory: string; content: string }
949
- | undefined;
950
-
951
- if (this.getAutoMemoryEnabled()) {
952
- const directory = this.memoryService.getAutoMemoryDirectory(
953
- this.getWorkdir(),
954
- );
955
- const content = await this.memoryService.getAutoMemoryContent(
956
- this.getWorkdir(),
957
- );
958
- autoMemoryOptions = { directory, content };
959
- }
1130
+ const { toolsConfig, toolNames, filteredToolPlugins } =
1131
+ this.resolveFilteredTools();
960
1132
 
961
1133
  // Get memory for message-array injection (not system prompt)
962
1134
  const { prependContent } =
963
1135
  await this.messageManager.getMemoryForInjection();
964
1136
 
1137
+ const mainSystemPrompt =
1138
+ await this.buildMainSystemPrompt(filteredToolPlugins);
1139
+
965
1140
  // Call AI service with streaming callbacks if enabled
966
1141
  const callAgentOptions: CallAgentOptions = {
967
1142
  gatewayConfig: this.getGatewayConfig(),
@@ -972,18 +1147,7 @@ export class AIManager {
972
1147
  workdir: this.getWorkdir(), // Pass working directory
973
1148
  tools: toolsConfig, // Pass filtered tool configuration
974
1149
  model: model, // Use passed model
975
- systemPrompt: buildSystemPrompt(
976
- this.systemPrompt,
977
- filteredToolPlugins,
978
- {
979
- workdir: this.getWorkdir(),
980
- originalWorkdir: this.getOriginalWorkdir(),
981
- language: this.getLanguage(),
982
- isSubagent: !!this.subagentType,
983
- worktreeSession: this.getWorktreeSession(),
984
- autoMemory: autoMemoryOptions,
985
- },
986
- ), // Pass custom system prompt
1150
+ systemPrompt: mainSystemPrompt, // Pass custom system prompt
987
1151
  maxTokens: maxTokens, // Pass max tokens override
988
1152
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
989
1153
  };
@@ -992,7 +1156,7 @@ export class AIManager {
992
1156
  if (prependContent.trim()) {
993
1157
  callAgentOptions.messages.unshift({
994
1158
  role: "user",
995
- content: `<system-reminder>\n${prependContent}\n</system-reminder>`,
1159
+ content: wrapInSystemReminder(prependContent),
996
1160
  });
997
1161
  }
998
1162
 
@@ -1507,6 +1671,14 @@ export class AIManager {
1507
1671
  });
1508
1672
  }
1509
1673
  }
1674
+ // Re-assert loading state before restarting: if this turn was
1675
+ // aborted, abortAIMessage already reset loading to false, and the
1676
+ // restart below continues the conversation — the UI must show
1677
+ // streaming again (cursor, stop button, ESC handling).
1678
+ this.setIsLoading(true);
1679
+ // Adopt the current (possibly abort-bumped) generation so this
1680
+ // continued turn's end-of-turn cleanup is not skipped as superseded.
1681
+ myGeneration = this.turnGeneration;
1510
1682
  // Restart outer loop to process the notifications
1511
1683
  shouldRestart = true;
1512
1684
  turnOffset = 0;
@@ -1585,7 +1757,7 @@ export class AIManager {
1585
1757
  lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
1586
1758
  // Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
1587
1759
  env: Object.fromEntries(
1588
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1760
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1589
1761
  ) as Record<string, string>, // Include environment variables
1590
1762
  };
1591
1763
 
@@ -1779,7 +1951,7 @@ export class AIManager {
1779
1951
  const sessionId = this.messageManager.getSessionId();
1780
1952
  const transcriptPath = this.messageManager.getTranscriptPath();
1781
1953
  const env = Object.fromEntries(
1782
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1954
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1783
1955
  ) as Record<string, string>;
1784
1956
  await this.hookManager.executeCwdChangedHooks(
1785
1957
  oldCwd,
@@ -1874,7 +2046,7 @@ export class AIManager {
1874
2046
  toolInput,
1875
2047
  subagentType: this.subagentType, // Include subagent type in hook context
1876
2048
  env: Object.fromEntries(
1877
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2049
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1878
2050
  ) as Record<string, string>, // Include environment variables
1879
2051
  };
1880
2052
 
@@ -1950,7 +2122,7 @@ export class AIManager {
1950
2122
  subagentType: this.subagentType, // Include subagent type in hook context
1951
2123
  planFilePath: this.permissionManager?.getPlanFilePath(),
1952
2124
  env: Object.fromEntries(
1953
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2125
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1954
2126
  ) as Record<string, string>, // Include environment variables
1955
2127
  };
1956
2128
 
@@ -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
 
@@ -13,6 +13,7 @@ import {
13
13
  type HookExecutionResult,
14
14
  type HookValidationResult,
15
15
  type SessionEndSource,
16
+ type SessionStartSource,
16
17
  HookConfigurationError,
17
18
  isValidHookEvent,
18
19
  isValidHookEventConfig,
@@ -26,6 +27,7 @@ import { executeCommand, isCommandSafe } from "../services/hook.js";
26
27
  import { MessageSource } from "../types/index.js";
27
28
  import type { MessageManager } from "./messageManager.js";
28
29
  import { Container } from "../utils/container.js";
30
+ import type { ConfigurationService } from "../services/configurationService.js";
29
31
 
30
32
  import { logger } from "../utils/globalLogger.js";
31
33
 
@@ -46,6 +48,19 @@ export class HookManager {
46
48
  this.matcher = matcher;
47
49
  }
48
50
 
51
+ /**
52
+ * Merged env for this session (OS env overlaid with the per-session settings
53
+ * snapshot). Hook subprocesses spawn with this env so settings.json `env`
54
+ * vars reach hooks without polluting other sessions in one stdio process.
55
+ */
56
+ private get sessionEnv(): Record<string, string> {
57
+ return (
58
+ this.container
59
+ .get<ConfigurationService>("ConfigurationService")
60
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
61
+ );
62
+ }
63
+
49
64
  /**
50
65
  * Load hook configuration from programmatic source (AgentOptions.hooks)
51
66
  */
@@ -916,7 +931,7 @@ export class HookManager {
916
931
  * Collects additionalContext and initialUserMessage from hook stdout.
917
932
  */
918
933
  async executeSessionStartHooks(
919
- source: "startup" | "compact" | "clear",
934
+ source: SessionStartSource,
920
935
  sessionId: string,
921
936
  transcriptPath: string,
922
937
  agentType?: string,
@@ -935,7 +950,7 @@ export class HookManager {
935
950
  source,
936
951
  agentType,
937
952
  env: Object.fromEntries(
938
- Object.entries(process.env).filter((e) => e[1] !== undefined),
953
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
939
954
  ) as Record<string, string>,
940
955
  };
941
956
 
@@ -989,7 +1004,7 @@ export class HookManager {
989
1004
  cwd: this.workdir,
990
1005
  endSource: source,
991
1006
  env: Object.fromEntries(
992
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1007
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
993
1008
  ) as Record<string, string>,
994
1009
  };
995
1010
 
@@ -1024,7 +1039,7 @@ export class HookManager {
1024
1039
  cwd: this.workdir,
1025
1040
  compactInstructions: customInstructions,
1026
1041
  env: Object.fromEntries(
1027
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1042
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
1028
1043
  ) as Record<string, string>,
1029
1044
  };
1030
1045
 
@@ -1061,7 +1076,7 @@ export class HookManager {
1061
1076
  cwd: this.workdir,
1062
1077
  compactSummary,
1063
1078
  env: Object.fromEntries(
1064
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1079
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
1065
1080
  ) as Record<string, string>,
1066
1081
  };
1067
1082