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
@@ -12,6 +12,10 @@ import { recoverTruncatedJson } from "../utils/stringUtils.js";
12
12
  import { logger } from "../utils/globalLogger.js";
13
13
  import { startInteractionSpan, endInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, resetTracingState, } from "../telemetry/sessionTracing.js";
14
14
  import { logOTelEvent } from "../telemetry/events.js";
15
+ /** Max turns for the compaction fork: the model should summarize, not act. */
16
+ const MAX_FORK_TURNS = 3;
17
+ /** Max turns for the auto-memory extraction fork. */
18
+ const MAX_AUTO_MEMORY_FORK_TURNS = 5;
15
19
  // Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
16
20
  // Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
17
21
  function truncateWithMarker(text, max) {
@@ -99,9 +103,24 @@ export class AIManager {
99
103
  get configurationService() {
100
104
  return this.container.get("ConfigurationService");
101
105
  }
106
+ /**
107
+ * OS env merged with the per-session env snapshot. Falls back to process.env
108
+ * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
109
+ * unit tests with partial mocks), so hook-context env construction never
110
+ * throws. Use this (not the non-null `configurationService` getter) when
111
+ * building hook context env.
112
+ */
113
+ get mergedEnv() {
114
+ return (this.container
115
+ .get("ConfigurationService")
116
+ ?.getMergedEnv?.() ?? process.env);
117
+ }
102
118
  // Getter methods for accessing dynamic configuration
103
119
  getGatewayConfig() {
104
- return this.configurationService.resolveGatewayConfig();
120
+ return {
121
+ ...this.configurationService.resolveGatewayConfig(),
122
+ sessionId: this.messageManager.getSessionId(),
123
+ };
105
124
  }
106
125
  getModelConfig() {
107
126
  const permissionMode = this.container.has("PermissionMode")
@@ -438,7 +457,7 @@ export class AIManager {
438
457
  }
439
458
  catch (compactError) {
440
459
  this.consecutiveCompactionFailures++;
441
- logger?.error(`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive):`, compactError);
460
+ logger?.error(`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`);
442
461
  this.messageManager.addErrorBlock(`Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`);
443
462
  }
444
463
  finally {
@@ -475,16 +494,15 @@ export class AIManager {
475
494
  return { toolsConfig, toolNames, filteredToolPlugins };
476
495
  }
477
496
  /**
478
- * Fork-path compaction: run a bounded agent loop over a copy of the
479
- * conversation using the same system prompt, tools, model, and generation
480
- * params as the main loop, so the forked request prefix matches exactly
481
- * and the prompt cache is reused. Tool calls are denied locally (the model
482
- * is told to summarize, not act) and their rejections are fed back for
483
- * another turn. Returns undefined content when the model never produces
484
- * text; the caller treats that as a compaction failure.
497
+ * Fork-path loop: run a bounded agent loop over a copy of the conversation
498
+ * using the same system prompt, tools, model, and generation params as the
499
+ * main loop, so the forked request prefix matches exactly and the prompt
500
+ * cache is reused. A `canUseTool` gate decides whether each tool call
501
+ * executes locally (with a stripped context) or is denied and fed back to
502
+ * the model for another turn. Returns undefined content when the model never
503
+ * produces text; the caller treats that as a failure.
485
504
  */
486
- async runCompactFork(historyMessages, compactPrompt, abortSignal) {
487
- const MAX_FORK_TURNS = 3;
505
+ async runForkLoop(historyMessages, prompt, options, abortSignal) {
488
506
  const modelConfig = this.getModelConfig();
489
507
  const gatewayConfig = this.getGatewayConfig();
490
508
  const sessionId = this.messageManager.getSessionId();
@@ -498,12 +516,15 @@ export class AIManager {
498
516
  content: wrapInSystemReminder(prependContent),
499
517
  });
500
518
  }
501
- forkMessages.push({ role: "user", content: compactPrompt });
519
+ forkMessages.push({ role: "user", content: prompt });
502
520
  const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
503
521
  const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
522
+ // Fresh read-state map so Read/Edit state built up inside the fork never
523
+ // leaks into the main session's dedup and staleness tracking.
524
+ const forkReadFileState = new Map();
504
525
  let totalUsage;
505
526
  let content;
506
- for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
527
+ for (let turn = 0; turn < options.maxTurns; turn++) {
507
528
  const result = await aiService.callAgent({
508
529
  gatewayConfig,
509
530
  modelConfig,
@@ -516,7 +537,7 @@ export class AIManager {
516
537
  toolChoice: this.toolChoiceOverride,
517
538
  // Stream so a slow reasoning model emits first bytes before the
518
539
  // gateway's idle timeout fires (non-streaming waits for the full
519
- // summary, which exceeds the timeout on large contexts).
540
+ // response, which exceeds the timeout on large contexts).
520
541
  stream: true,
521
542
  });
522
543
  if (result.usage) {
@@ -532,28 +553,259 @@ export class AIManager {
532
553
  break;
533
554
  }
534
555
  if (result.tool_calls && result.tool_calls.length > 0) {
535
- // Deny all tool calls locally and feed the rejections back so the
536
- // model gets another turn to produce the summary text.
556
+ const functionCalls = result.tool_calls.filter((tc) => tc.type === "function");
557
+ if (functionCalls.length === 0)
558
+ break;
537
559
  forkMessages.push({
538
560
  role: "assistant",
539
561
  content: result.content ?? null,
540
- tool_calls: result.tool_calls,
562
+ tool_calls: functionCalls,
541
563
  });
542
- for (const toolCall of result.tool_calls) {
564
+ for (const toolCall of functionCalls) {
565
+ const name = toolCall.function?.name || "";
566
+ const args = this.parseForkToolArgs(toolCall.function?.arguments);
567
+ let toolContent;
568
+ if (options.canUseTool && options.canUseTool(name, args)) {
569
+ toolContent = await this.executeForkTool(name, args, workdir, sessionId, abortSignal, forkReadFileState);
570
+ }
571
+ else {
572
+ toolContent =
573
+ options.deniedToolMessage ??
574
+ "Tool use is not allowed in this context";
575
+ }
543
576
  forkMessages.push({
544
577
  role: "tool",
545
578
  tool_call_id: toolCall.id,
546
- content: "Tool use is not allowed during compaction",
579
+ content: toolContent,
547
580
  });
548
581
  }
549
582
  continue;
550
583
  }
551
584
  // Neither text nor tool calls: retrying the identical request is
552
- // pointless, bail out and let the caller fail the compaction.
585
+ // pointless, bail out and let the caller fail.
553
586
  break;
554
587
  }
555
588
  return { content, usage: totalUsage };
556
589
  }
590
+ /**
591
+ * Fork-path compaction: deny all tool calls locally (the model is told to
592
+ * summarize, not act) and feed the rejections back for another turn.
593
+ */
594
+ async runCompactFork(historyMessages, compactPrompt, abortSignal) {
595
+ return this.runForkLoop(historyMessages, compactPrompt, {
596
+ maxTurns: MAX_FORK_TURNS,
597
+ deniedToolMessage: "Tool use is not allowed during compaction",
598
+ }, abortSignal);
599
+ }
600
+ /**
601
+ * Auto-memory extraction via the perfect fork: the extraction prompt is run
602
+ * against the same request prefix as the main conversation (same system
603
+ * prompt, tools, model, and message history) so the prompt cache is reused.
604
+ * Gate-approved tools execute locally in a stripped context; everything else
605
+ * is denied. Usage is reported with operation_type "agent" so extraction
606
+ * token costs stay visible in session accounting.
607
+ */
608
+ async runAutoMemoryFork(messages, prompt, options, abortSignal) {
609
+ const modelConfig = this.getModelConfig();
610
+ const historyMessages = convertMessagesForAPI(messages, {
611
+ supportsVision: supportsVision(modelConfig.capabilities),
612
+ });
613
+ // Give the fork a real signal even when the caller has none, so tools
614
+ // (e.g. Bash's foreground path) always receive a well-formed context.
615
+ const signal = abortSignal ?? new AbortController().signal;
616
+ const result = await this.runForkLoop(historyMessages, prompt, {
617
+ maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
618
+ canUseTool: options.canUseTool,
619
+ deniedToolMessage: options.deniedToolMessage,
620
+ }, signal);
621
+ if (result.usage && this.callbacks?.onUsageAdded) {
622
+ this.callbacks.onUsageAdded({
623
+ ...result.usage,
624
+ model: modelConfig.model,
625
+ operation_type: "agent",
626
+ });
627
+ }
628
+ return result;
629
+ }
630
+ /**
631
+ * Parse a fork tool call's JSON arguments, recovering truncated JSON the
632
+ * same way the main loop does. Unparseable arguments fall back to `{}` and
633
+ * are rejected by the gate or the tool's own parameter validation.
634
+ */
635
+ parseForkToolArgs(argsString) {
636
+ if (!argsString?.trim())
637
+ return {};
638
+ try {
639
+ return JSON.parse(argsString);
640
+ }
641
+ catch {
642
+ try {
643
+ return JSON.parse(recoverTruncatedJson(argsString));
644
+ }
645
+ catch {
646
+ return {};
647
+ }
648
+ }
649
+ }
650
+ /**
651
+ * Execute a single tool call inside a fork with a stripped context: no
652
+ * permission manager (never prompts the user), no message manager (no
653
+ * conditional-rule triggering), no messageId (no file-history snapshots),
654
+ * and no background task manager (commands run in the foreground). Only
655
+ * gate-approved tool names reach this path.
656
+ */
657
+ async executeForkTool(name, args, workdir, sessionId, abortSignal, readFileState) {
658
+ const plugin = this.toolManager.getTools().find((t) => t.name === name);
659
+ if (!plugin) {
660
+ return `Tool '${name}' not found`;
661
+ }
662
+ const context = {
663
+ abortSignal,
664
+ workdir,
665
+ originalWorkdir: this.originalWorkdir,
666
+ sessionId,
667
+ taskManager: this.taskManager,
668
+ readFileState,
669
+ onShortResultUpdate: () => { },
670
+ onResultUpdate: () => { },
671
+ onCwdChange: () => { },
672
+ };
673
+ try {
674
+ const result = await plugin.execute(args, context);
675
+ if (result.content)
676
+ return result.content;
677
+ if (result.error)
678
+ return `Error: ${result.error}`;
679
+ return "";
680
+ }
681
+ catch (error) {
682
+ const message = error instanceof Error ? error.message : String(error);
683
+ logger?.error(`Fork tool execution failed for ${name}:`, error);
684
+ return `Tool execution failed: ${message}`;
685
+ }
686
+ }
687
+ /**
688
+ * Fork-path side question ("/btw"): run a single-turn fork of the
689
+ * conversation using the same system prompt, tools, model, and generation
690
+ * params as the main loop, so the forked request prefix matches exactly and
691
+ * the prompt cache is reused. The in-progress assistant message (if any) is
692
+ * stripped so the fork starts from the last completed request prefix. Tools
693
+ * are never executed — the wrapped question instructs the model to answer
694
+ * directly; an attempted tool call is surfaced as an error string.
695
+ */
696
+ async runBtwFork(question, abortSignal, onContent, onReasoning) {
697
+ const modelConfig = this.getModelConfig();
698
+ const gatewayConfig = this.getGatewayConfig();
699
+ const sessionId = this.messageManager.getSessionId();
700
+ const workdir = this.getWorkdir();
701
+ const rawMessages = this.messageManager.getMessages();
702
+ // Strip the in-progress assistant message (a block still in "streaming"
703
+ // stage) so the fork's request prefix matches the last completed
704
+ // main-loop request and the prompt cache is reused.
705
+ const lastMessage = rawMessages[rawMessages.length - 1];
706
+ const hasInProgressMessage = lastMessage?.role === "assistant" &&
707
+ lastMessage.blocks.some((b) => "stage" in b && b.stage === "streaming");
708
+ const forkMessages = convertMessagesForAPI(hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages, { supportsVision: supportsVision(modelConfig.capabilities) });
709
+ // Mirror the main loop's memory injection so the request prefix matches.
710
+ const { prependContent } = await this.messageManager.getMemoryForInjection();
711
+ if (prependContent.trim()) {
712
+ forkMessages.unshift({
713
+ role: "user",
714
+ content: wrapInSystemReminder(prependContent),
715
+ });
716
+ }
717
+ // Wrap the question with the side-question instructions (verbatim
718
+ // Claude Code sideQuestion.ts) so the model answers directly.
719
+ const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
720
+
721
+ IMPORTANT CONTEXT:
722
+ - You are a separate, lightweight agent spawned to answer this one question
723
+ - The main agent is NOT interrupted - it continues working independently in the background
724
+ - You share the conversation context but are a completely separate instance
725
+ - Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
726
+
727
+ CRITICAL CONSTRAINTS:
728
+ - You have NO tools available - you cannot read files, run commands, search, or take any actions
729
+ - This is a one-off response - there will be no follow-up turns
730
+ - You can ONLY provide information based on what you already know from the conversation context
731
+ - NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
732
+ - If you don't know the answer, say so - do not offer to look it up or investigate
733
+
734
+ Simply answer the question with the information you have.</system-reminder>
735
+
736
+ ${question}`;
737
+ forkMessages.push({ role: "user", content: wrappedQuestion });
738
+ const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
739
+ const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
740
+ try {
741
+ // Surface partial output to the caller (e.g. the /btw overlay's
742
+ // streaming display) as it arrives. Reasoning chunks from thinking
743
+ // models stream through a separate channel when the caller supplies
744
+ // one (webview hosts distinguish thinking from content so the panel
745
+ // can drop thinking text once content starts); otherwise they fall
746
+ // back to the content channel (CLI overlay mixes both).
747
+ const streamToOverlay = (text) => {
748
+ if (text.trim()) {
749
+ onContent?.(text);
750
+ }
751
+ };
752
+ const streamReasoning = (text) => {
753
+ if (text.trim()) {
754
+ if (onReasoning) {
755
+ onReasoning(text);
756
+ }
757
+ else {
758
+ onContent?.(text);
759
+ }
760
+ }
761
+ };
762
+ const result = await aiService.callAgent({
763
+ gatewayConfig,
764
+ modelConfig,
765
+ messages: forkMessages,
766
+ sessionId,
767
+ abortSignal,
768
+ workdir,
769
+ tools: toolsConfig,
770
+ systemPrompt,
771
+ toolChoice: this.toolChoiceOverride,
772
+ // Stream so a slow reasoning model emits first bytes before the
773
+ // gateway's idle timeout fires (same rationale as runCompactFork).
774
+ stream: true,
775
+ onContentUpdate: streamToOverlay,
776
+ onReasoningUpdate: streamReasoning,
777
+ });
778
+ if (result.content?.trim()) {
779
+ return { content: result.content };
780
+ }
781
+ // A thinking model may emit only reasoning content (e.g. the stream
782
+ // is truncated before the final answer); surface that instead of
783
+ // falling through to "No response received".
784
+ if (result.reasoning_content?.trim()) {
785
+ return { content: result.reasoning_content };
786
+ }
787
+ if (result.tool_calls && result.tool_calls.length > 0) {
788
+ const firstFunctionCall = result.tool_calls.find((call) => call.type === "function");
789
+ const toolName = firstFunctionCall?.function?.name ?? "a tool";
790
+ return {
791
+ error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
792
+ };
793
+ }
794
+ // Neither text nor tool calls.
795
+ return { error: "No response received" };
796
+ }
797
+ catch (error) {
798
+ // aiService.callAgent converts AbortError into a plain Error, so the
799
+ // abort is detected via the signal itself; rethrow so the UI can
800
+ // silently dismiss instead of showing an error.
801
+ if (abortSignal?.aborted) {
802
+ throw error;
803
+ }
804
+ return {
805
+ error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
806
+ };
807
+ }
808
+ }
557
809
  /**
558
810
  * Build post-compact context restoration content.
559
811
  * Restores file reads, working directory, plan mode, skills, and background tasks.
@@ -790,6 +1042,11 @@ export class AIManager {
790
1042
  systemPrompt: mainSystemPrompt, // Pass custom system prompt
791
1043
  maxTokens: maxTokens, // Pass max tokens override
792
1044
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
1045
+ // Fast-model subagents send disable-thinking params only when
1046
+ // explicitly configured (never in the agent loop).
1047
+ disableThinkingOptions: this.modelOverride === "fastModel"
1048
+ ? this.getModelConfig().disableThinkingOptions
1049
+ : undefined,
793
1050
  };
794
1051
  // Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
795
1052
  if (prependContent.trim()) {
@@ -1103,67 +1360,15 @@ export class AIManager {
1103
1360
  this.messageManager.addFileHistoryBlock(snapshots);
1104
1361
  }
1105
1362
  }
1106
- // Goal evaluation — supersedes Stop hooks when active
1107
- const goalManager = this.container.has("GoalManager")
1108
- ? this.container.get("GoalManager")
1109
- : undefined;
1110
- let goalContinuing = false;
1111
- if (goalManager?.isGoalActive() && !this.subagentType) {
1112
- // 1. Increment turn count and check circuit breakers
1113
- goalManager.incrementTurnCount();
1114
- const circuitBreaker = goalManager.checkCircuitBreakers();
1115
- if (circuitBreaker) {
1116
- goalManager.clearGoal();
1117
- logger?.info(`[Goal] ${circuitBreaker}`);
1118
- this.messageManager.addUserMessage({
1119
- content: `<system-reminder>${circuitBreaker}</system-reminder>`,
1120
- isMeta: true,
1121
- });
1122
- // Fall through to normal Stop hooks on the final turn
1123
- }
1124
- else {
1125
- // 2. Evaluate goal
1126
- const evaluation = await goalManager.evaluateGoal(abortController.signal);
1127
- if (evaluation.isMet) {
1128
- goalManager.clearGoal();
1129
- logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
1130
- this.messageManager.addUserMessage({
1131
- content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
1132
- isMeta: true,
1133
- });
1134
- // Fall through to normal Stop hooks on the final turn
1135
- }
1136
- else {
1137
- const goal = goalManager.getGoal();
1138
- goal.lastReason = evaluation.reason;
1139
- logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
1140
- this.messageManager.addUserMessage({
1141
- content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
1142
- isMeta: true,
1143
- });
1144
- // Keep loading state active to prevent UI flicker
1145
- this.setIsLoading(true);
1146
- goalContinuing = true;
1147
- // Restart outer loop to continue goal pursuit
1148
- shouldRestart = true;
1149
- turnOffset = 0;
1150
- }
1151
- }
1152
- }
1153
- // Skip Stop hooks when goal evaluator is continuing the conversation
1154
- if (goalContinuing) {
1155
- // Goal evaluator supersedes Stop hooks
1156
- }
1157
- else {
1158
- const shouldContinue = await this.executeStopHooks();
1159
- // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1160
- // restart the AI conversation cycle
1161
- if (shouldContinue) {
1162
- logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
1163
- // Restart the conversation to let AI fix the issues
1164
- shouldRestart = true;
1165
- turnOffset = 0;
1166
- }
1363
+ // Execute Stop hooks
1364
+ const shouldContinue = await this.executeStopHooks();
1365
+ // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1366
+ // restart the AI conversation cycle
1367
+ if (shouldContinue) {
1368
+ logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
1369
+ // Restart the conversation to let AI fix the issues
1370
+ shouldRestart = true;
1371
+ turnOffset = 0;
1167
1372
  }
1168
1373
  }
1169
1374
  // Inject pending notifications from background tasks (after Stop hooks,
@@ -1262,7 +1467,7 @@ export class AIManager {
1262
1467
  sessionCrons, // Stop-only: session cron jobs snapshot
1263
1468
  lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
1264
1469
  // Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
1265
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
1470
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1266
1471
  };
1267
1472
  const results = await this.hookManager.executeHooks(hookName, context);
1268
1473
  // Process hook results to handle exit codes and appropriate responses
@@ -1417,7 +1622,7 @@ export class AIManager {
1417
1622
  if (this.hookManager) {
1418
1623
  const sessionId = this.messageManager.getSessionId();
1419
1624
  const transcriptPath = this.messageManager.getTranscriptPath();
1420
- const env = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined));
1625
+ const env = Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined));
1421
1626
  await this.hookManager.executeCwdChangedHooks(oldCwd, newCwd, sessionId, transcriptPath, env);
1422
1627
  }
1423
1628
  },
@@ -1484,7 +1689,7 @@ export class AIManager {
1484
1689
  cwd: this.getWorkdir(),
1485
1690
  toolInput,
1486
1691
  subagentType: this.subagentType, // Include subagent type in hook context
1487
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
1692
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1488
1693
  };
1489
1694
  const results = await this.hookManager.executeHooks("PreToolUse", context);
1490
1695
  // Process hook results to handle exit codes and determine if tool should be blocked
@@ -1537,7 +1742,7 @@ export class AIManager {
1537
1742
  toolResponse,
1538
1743
  subagentType: this.subagentType, // Include subagent type in hook context
1539
1744
  planFilePath: this.permissionManager?.getPlanFilePath(),
1540
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
1745
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1541
1746
  };
1542
1747
  const results = await this.hookManager.executeHooks("PostToolUse", context);
1543
1748
  // Process hook results to handle exit codes and update tool results
@@ -15,6 +15,12 @@ export declare class BackgroundTaskManager {
15
15
  private callbacks;
16
16
  private workdir;
17
17
  constructor(container: Container, options: BackgroundTaskManagerOptions);
18
+ /**
19
+ * Merged env (OS env overlaid with this session's settings snapshot) for
20
+ * background-task subprocesses, so settings `env` vars reach them without
21
+ * polluting other sessions in one `wave --stdio` process.
22
+ */
23
+ private get sessionEnv();
18
24
  /**
19
25
  * Fire the onBackgroundTasksChange callback so UI consumers refresh.
20
26
  * Public so other managers (e.g. WorkflowManager) can trigger a refresh
@@ -13,6 +13,16 @@ export class BackgroundTaskManager {
13
13
  this.callbacks = options.callbacks || {};
14
14
  this.workdir = options.workdir;
15
15
  }
16
+ /**
17
+ * Merged env (OS env overlaid with this session's settings snapshot) for
18
+ * background-task subprocesses, so settings `env` vars reach them without
19
+ * polluting other sessions in one `wave --stdio` process.
20
+ */
21
+ get sessionEnv() {
22
+ return (this.container
23
+ .get("ConfigurationService")
24
+ ?.getMergedEnv?.() ?? process.env);
25
+ }
16
26
  /**
17
27
  * Fire the onBackgroundTasksChange callback so UI consumers refresh.
18
28
  * Public so other managers (e.g. WorkflowManager) can trigger a refresh
@@ -44,6 +54,7 @@ export class BackgroundTaskManager {
44
54
  cwd: cwd ?? this.workdir,
45
55
  env: {
46
56
  ...process.env,
57
+ ...this.sessionEnv,
47
58
  },
48
59
  });
49
60
  // Create log file
@@ -14,6 +14,12 @@ export declare class BangManager {
14
14
  onCommandRunningChange?: (running: boolean) => void;
15
15
  constructor(container: Container, options: BangManagerOptions);
16
16
  private get messageManager();
17
+ /**
18
+ * Merged env (OS env overlaid with this session's settings snapshot) for
19
+ * bang-command subprocesses, so settings `env` vars reach them without
20
+ * polluting other sessions in one `wave --stdio` process.
21
+ */
22
+ private get sessionEnv();
17
23
  private setCommandRunning;
18
24
  executeCommand(command: string): Promise<number>;
19
25
  abortCommand(): void;
@@ -10,6 +10,16 @@ export class BangManager {
10
10
  get messageManager() {
11
11
  return this.container.get("MessageManager");
12
12
  }
13
+ /**
14
+ * Merged env (OS env overlaid with this session's settings snapshot) for
15
+ * bang-command subprocesses, so settings `env` vars reach them without
16
+ * polluting other sessions in one `wave --stdio` process.
17
+ */
18
+ get sessionEnv() {
19
+ return (this.container
20
+ .get("ConfigurationService")
21
+ ?.getMergedEnv?.() ?? process.env);
22
+ }
13
23
  setCommandRunning(isRunning) {
14
24
  this.isCommandRunning = isRunning;
15
25
  this.onCommandRunningChange?.(isRunning);
@@ -28,6 +38,7 @@ export class BangManager {
28
38
  cwd: this.workdir,
29
39
  env: {
30
40
  ...process.env,
41
+ ...this.sessionEnv,
31
42
  },
32
43
  });
33
44
  this.currentProcess = child;
@@ -4,7 +4,7 @@
4
4
  * Central orchestrator for the hooks system. Handles configuration loading,
5
5
  * validation, and hook execution across all supported events.
6
6
  */
7
- import { type HookEvent, type HookExecutionContext, type ExtendedHookExecutionContext, type HookExecutionResult, type HookValidationResult, type SessionEndSource } from "../types/hooks.js";
7
+ import { type HookEvent, type HookExecutionContext, type ExtendedHookExecutionContext, type HookExecutionResult, type HookValidationResult, type SessionEndSource, type SessionStartSource } from "../types/hooks.js";
8
8
  import type { WaveConfiguration, PartialHookConfiguration } from "../types/configuration.js";
9
9
  import { HookMatcher } from "../utils/hookMatcher.js";
10
10
  import type { MessageManager } from "./messageManager.js";
@@ -18,6 +18,12 @@ export declare class HookManager {
18
18
  private readonly matcher;
19
19
  private readonly workdir;
20
20
  constructor(container: Container, workdir: string, matcher?: HookMatcher);
21
+ /**
22
+ * Merged env for this session (OS env overlaid with the per-session settings
23
+ * snapshot). Hook subprocesses spawn with this env so settings.json `env`
24
+ * vars reach hooks without polluting other sessions in one stdio process.
25
+ */
26
+ private get sessionEnv();
21
27
  /**
22
28
  * Load hook configuration from programmatic source (AgentOptions.hooks)
23
29
  */
@@ -118,7 +124,7 @@ export declare class HookManager {
118
124
  * Execute SessionStart hooks during initialization.
119
125
  * Collects additionalContext and initialUserMessage from hook stdout.
120
126
  */
121
- executeSessionStartHooks(source: "startup" | "compact" | "clear", sessionId: string, transcriptPath: string, agentType?: string): Promise<{
127
+ executeSessionStartHooks(source: SessionStartSource, sessionId: string, transcriptPath: string, agentType?: string): Promise<{
122
128
  results: HookExecutionResult[];
123
129
  additionalContext?: string;
124
130
  initialUserMessage?: string;
@@ -18,6 +18,16 @@ export class HookManager {
18
18
  this.workdir = workdir;
19
19
  this.matcher = matcher;
20
20
  }
21
+ /**
22
+ * Merged env for this session (OS env overlaid with the per-session settings
23
+ * snapshot). Hook subprocesses spawn with this env so settings.json `env`
24
+ * vars reach hooks without polluting other sessions in one stdio process.
25
+ */
26
+ get sessionEnv() {
27
+ return (this.container
28
+ .get("ConfigurationService")
29
+ ?.getMergedEnv?.() ?? process.env);
30
+ }
21
31
  /**
22
32
  * Load hook configuration from programmatic source (AgentOptions.hooks)
23
33
  */
@@ -674,7 +684,7 @@ export class HookManager {
674
684
  cwd: this.workdir,
675
685
  source,
676
686
  agentType,
677
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
687
+ env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
678
688
  };
679
689
  const results = await this.executeHooks("SessionStart", context);
680
690
  let additionalContext;
@@ -718,7 +728,7 @@ export class HookManager {
718
728
  transcriptPath,
719
729
  cwd: this.workdir,
720
730
  endSource: source,
721
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
731
+ env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
722
732
  };
723
733
  const results = await this.executeHooks("SessionEnd", context);
724
734
  // Process results but never block shutdown
@@ -740,7 +750,7 @@ export class HookManager {
740
750
  transcriptPath,
741
751
  cwd: this.workdir,
742
752
  compactInstructions: customInstructions,
743
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
753
+ env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
744
754
  };
745
755
  const results = await this.executeHooks("PreCompact", context);
746
756
  let additionalInstructions;
@@ -767,7 +777,7 @@ export class HookManager {
767
777
  transcriptPath,
768
778
  cwd: this.workdir,
769
779
  compactSummary,
770
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
780
+ env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
771
781
  };
772
782
  const results = await this.executeHooks("PostCompact", context);
773
783
  if (results.length > 0) {