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
@@ -6,8 +6,8 @@ import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js"
6
6
  import { estimateTokens } from "../utils/tokenEstimate.js";
7
7
  import { getTaskReminderTurnCounts, maybeInjectTaskReminder, TASK_REMINDER_CONFIG, } from "../utils/taskReminder.js";
8
8
  import { existsSync } from "node:fs";
9
- import { buildSystemPrompt } from "../prompts/index.js";
10
- import { buildPlanModeReminder, buildPlanModeReEntryReminder, buildExitedPlanModeReminder, } from "../prompts/planModeReminders.js";
9
+ import { buildSystemPrompt, formatCompactSummary, getCompactPrompt, } from "../prompts/index.js";
10
+ import { buildPlanModeReminder, buildPlanModeReEntryReminder, buildExitedPlanModeReminder, wrapInSystemReminder, } from "../prompts/planModeReminders.js";
11
11
  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";
@@ -99,9 +99,24 @@ export class AIManager {
99
99
  get configurationService() {
100
100
  return this.container.get("ConfigurationService");
101
101
  }
102
+ /**
103
+ * OS env merged with the per-session env snapshot. Falls back to process.env
104
+ * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
105
+ * unit tests with partial mocks), so hook-context env construction never
106
+ * throws. Use this (not the non-null `configurationService` getter) when
107
+ * building hook context env.
108
+ */
109
+ get mergedEnv() {
110
+ return (this.container
111
+ .get("ConfigurationService")
112
+ ?.getMergedEnv?.() ?? process.env);
113
+ }
102
114
  // Getter methods for accessing dynamic configuration
103
115
  getGatewayConfig() {
104
- return this.configurationService.resolveGatewayConfig();
116
+ return {
117
+ ...this.configurationService.resolveGatewayConfig(),
118
+ sessionId: this.messageManager.getSessionId(),
119
+ };
105
120
  }
106
121
  getModelConfig() {
107
122
  const permissionMode = this.container.has("PermissionMode")
@@ -348,30 +363,35 @@ export class AIManager {
348
363
  await this.messageManager.saveSession();
349
364
  this.setIsCompacting(true);
350
365
  try {
351
- const recentChatMessages = convertMessagesForAPI(messagesToCompact);
352
- // 4. Call compactMessages with optional custom instructions
353
- const compactResult = await aiService.compactMessages({
354
- gatewayConfig: this.getGatewayConfig(),
355
- modelConfig: this.getModelConfig(),
356
- messages: recentChatMessages,
357
- abortSignal: options.abortSignal,
358
- model: this.getModelConfig().fastModel,
359
- customInstructions: mergedInstructions,
366
+ const modelConfig = this.getModelConfig();
367
+ const recentChatMessages = convertMessagesForAPI(messagesToCompact, {
368
+ supportsVision: supportsVision(modelConfig.capabilities),
360
369
  });
370
+ const compactPrompt = getCompactPrompt(mergedInstructions);
371
+ // 4. Fork path: fork the conversation with the same system prompt,
372
+ // tools, model, and generation params as the main loop so the forked
373
+ // request prefix matches exactly and the prompt cache is reused.
374
+ const forkResult = await this.runCompactFork(recentChatMessages, compactPrompt, options.abortSignal);
375
+ const summaryContent = forkResult.content;
376
+ const compactTokens = forkResult.usage;
377
+ if (!summaryContent) {
378
+ throw new Error("Compaction failed: the model produced no summary output");
379
+ }
380
+ const compactModel = modelConfig.model;
361
381
  // 5. Handle usage tracking
362
382
  let compactUsage;
363
- if (compactResult.usage) {
383
+ if (compactTokens) {
364
384
  compactUsage = {
365
- prompt_tokens: compactResult.usage.prompt_tokens,
366
- completion_tokens: compactResult.usage.completion_tokens,
367
- total_tokens: compactResult.usage.total_tokens,
368
- model: this.getModelConfig().fastModel,
385
+ ...compactTokens,
386
+ model: compactModel,
369
387
  operation_type: "compact",
370
388
  };
371
389
  }
372
- // 6. Build post-compact context restoration
373
- const enhancedSummary = await this.buildPostCompactContext(compactResult.content);
374
- // 7. Execute message reconstruction
390
+ // 6. Strip the <analysis> scratchpad and extract the <summary> body
391
+ const formattedSummary = formatCompactSummary(summaryContent);
392
+ // 7. Build post-compact context restoration
393
+ const enhancedSummary = await this.buildPostCompactContext(formattedSummary);
394
+ // 8. Execute message reconstruction
375
395
  await this.messageManager.compactMessagesAndUpdateSession(enhancedSummary, compactUsage);
376
396
  // Re-add plan mode reminder as persistent meta message after compaction
377
397
  const postCompactMode = this.permissionManager?.getCurrentEffectiveMode(this.getModelConfig().permissionMode);
@@ -385,20 +405,20 @@ export class AIManager {
385
405
  });
386
406
  }
387
407
  }
388
- // 8. Track usage
408
+ // 9. Track usage
389
409
  if (compactUsage && this.callbacks?.onUsageAdded) {
390
410
  this.callbacks.onUsageAdded(compactUsage);
391
411
  }
392
412
  this.consecutiveCompactionFailures = 0;
393
413
  // Reset incremental tracing state after compaction
394
414
  resetTracingState();
395
- // 9. Log OTEL event
415
+ // 10. Log OTEL event
396
416
  logOTelEvent("compaction", {
397
417
  beforeTokens: String(messagesToCompact.length),
398
418
  afterTokens: "1",
399
- model: this.getModelConfig().fastModel,
419
+ model: compactModel,
400
420
  }).catch(() => { });
401
- // 10. Run SessionStart hooks (existing behavior)
421
+ // 11. Run SessionStart hooks (existing behavior)
402
422
  if (this.hookManager) {
403
423
  try {
404
424
  const newSessionId = this.messageManager.getSessionId();
@@ -420,10 +440,10 @@ export class AIManager {
420
440
  logger?.warn(`SessionStart hooks on compact failed: ${error.message}`);
421
441
  }
422
442
  }
423
- // 11. Run PostCompact hooks
443
+ // 12. Run PostCompact hooks
424
444
  if (this.hookManager) {
425
445
  try {
426
- await this.hookManager.executePostCompactHooks(this.messageManager.getSessionId(), this.messageManager.getTranscriptPath(), compactResult.content);
446
+ await this.hookManager.executePostCompactHooks(this.messageManager.getSessionId(), this.messageManager.getTranscriptPath(), formattedSummary);
427
447
  }
428
448
  catch (error) {
429
449
  logger?.warn(`PostCompact hooks failed: ${error.message}`);
@@ -440,6 +460,115 @@ export class AIManager {
440
460
  this.setIsCompacting(false);
441
461
  }
442
462
  }
463
+ /**
464
+ * Build the system prompt used by the main agent loop. Extracted so the
465
+ * compaction fork can mirror it exactly — the forked request prefix must
466
+ * match the main conversation's for the prompt cache to be reused.
467
+ */
468
+ async buildMainSystemPrompt(filteredToolPlugins) {
469
+ let autoMemoryOptions;
470
+ if (this.getAutoMemoryEnabled()) {
471
+ const directory = this.memoryService.getAutoMemoryDirectory(this.getWorkdir());
472
+ const content = await this.memoryService.getAutoMemoryContent(this.getWorkdir());
473
+ autoMemoryOptions = { directory, content };
474
+ }
475
+ return buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
476
+ workdir: this.getWorkdir(),
477
+ originalWorkdir: this.getOriginalWorkdir(),
478
+ language: this.getLanguage(),
479
+ isSubagent: !!this.subagentType,
480
+ worktreeSession: this.getWorktreeSession(),
481
+ autoMemory: autoMemoryOptions,
482
+ });
483
+ }
484
+ resolveFilteredTools() {
485
+ const toolsConfig = this.getFilteredToolsConfig();
486
+ const toolNames = new Set(toolsConfig.map((t) => t.function.name));
487
+ const filteredToolPlugins = this.toolManager
488
+ .getTools()
489
+ .filter((t) => toolNames.has(t.name));
490
+ return { toolsConfig, toolNames, filteredToolPlugins };
491
+ }
492
+ /**
493
+ * Fork-path compaction: run a bounded agent loop over a copy of the
494
+ * conversation using the same system prompt, tools, model, and generation
495
+ * params as the main loop, so the forked request prefix matches exactly
496
+ * and the prompt cache is reused. Tool calls are denied locally (the model
497
+ * is told to summarize, not act) and their rejections are fed back for
498
+ * another turn. Returns undefined content when the model never produces
499
+ * text; the caller treats that as a compaction failure.
500
+ */
501
+ async runCompactFork(historyMessages, compactPrompt, abortSignal) {
502
+ const MAX_FORK_TURNS = 3;
503
+ const modelConfig = this.getModelConfig();
504
+ const gatewayConfig = this.getGatewayConfig();
505
+ const sessionId = this.messageManager.getSessionId();
506
+ const workdir = this.getWorkdir();
507
+ const forkMessages = [...historyMessages];
508
+ // Mirror the main loop's memory injection so the request prefix matches.
509
+ const { prependContent } = await this.messageManager.getMemoryForInjection();
510
+ if (prependContent.trim()) {
511
+ forkMessages.unshift({
512
+ role: "user",
513
+ content: wrapInSystemReminder(prependContent),
514
+ });
515
+ }
516
+ forkMessages.push({ role: "user", content: compactPrompt });
517
+ const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
518
+ const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
519
+ let totalUsage;
520
+ let content;
521
+ for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
522
+ const result = await aiService.callAgent({
523
+ gatewayConfig,
524
+ modelConfig,
525
+ messages: forkMessages,
526
+ sessionId,
527
+ abortSignal,
528
+ workdir,
529
+ tools: toolsConfig,
530
+ systemPrompt,
531
+ toolChoice: this.toolChoiceOverride,
532
+ // Stream so a slow reasoning model emits first bytes before the
533
+ // gateway's idle timeout fires (non-streaming waits for the full
534
+ // summary, which exceeds the timeout on large contexts).
535
+ stream: true,
536
+ });
537
+ if (result.usage) {
538
+ totalUsage = {
539
+ prompt_tokens: (totalUsage?.prompt_tokens ?? 0) + result.usage.prompt_tokens,
540
+ completion_tokens: (totalUsage?.completion_tokens ?? 0) +
541
+ result.usage.completion_tokens,
542
+ total_tokens: (totalUsage?.total_tokens ?? 0) + result.usage.total_tokens,
543
+ };
544
+ }
545
+ if (result.content?.trim()) {
546
+ content = result.content;
547
+ break;
548
+ }
549
+ if (result.tool_calls && result.tool_calls.length > 0) {
550
+ // Deny all tool calls locally and feed the rejections back so the
551
+ // model gets another turn to produce the summary text.
552
+ forkMessages.push({
553
+ role: "assistant",
554
+ content: result.content ?? null,
555
+ tool_calls: result.tool_calls,
556
+ });
557
+ for (const toolCall of result.tool_calls) {
558
+ forkMessages.push({
559
+ role: "tool",
560
+ tool_call_id: toolCall.id,
561
+ content: "Tool use is not allowed during compaction",
562
+ });
563
+ }
564
+ continue;
565
+ }
566
+ // Neither text nor tool calls: retrying the identical request is
567
+ // pointless, bail out and let the caller fail the compaction.
568
+ break;
569
+ }
570
+ return { content, usage: totalUsage };
571
+ }
443
572
  /**
444
573
  * Build post-compact context restoration content.
445
574
  * Restores file reads, working directory, plan mode, skills, and background tasks.
@@ -573,7 +702,7 @@ export class AIManager {
573
702
  // has superseded us. setIsLoading(true) here also closes the "idle but
574
703
  // previous turn not finished" race window by marking us busy immediately.
575
704
  this.turnGeneration++;
576
- const myGeneration = this.turnGeneration;
705
+ let myGeneration = this.turnGeneration;
577
706
  this.setIsLoading(true);
578
707
  outer: while (true) {
579
708
  let shouldRestart = false;
@@ -659,19 +788,10 @@ export class AIManager {
659
788
  // Track if assistant message has been created
660
789
  let assistantMessageCreated = false;
661
790
  logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
662
- const toolsConfig = this.getFilteredToolsConfig();
663
- const toolNames = new Set(toolsConfig.map((t) => t.function.name));
664
- const filteredToolPlugins = this.toolManager
665
- .getTools()
666
- .filter((t) => toolNames.has(t.name));
667
- let autoMemoryOptions;
668
- if (this.getAutoMemoryEnabled()) {
669
- const directory = this.memoryService.getAutoMemoryDirectory(this.getWorkdir());
670
- const content = await this.memoryService.getAutoMemoryContent(this.getWorkdir());
671
- autoMemoryOptions = { directory, content };
672
- }
791
+ const { toolsConfig, toolNames, filteredToolPlugins } = this.resolveFilteredTools();
673
792
  // Get memory for message-array injection (not system prompt)
674
793
  const { prependContent } = await this.messageManager.getMemoryForInjection();
794
+ const mainSystemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
675
795
  // Call AI service with streaming callbacks if enabled
676
796
  const callAgentOptions = {
677
797
  gatewayConfig: this.getGatewayConfig(),
@@ -682,14 +802,7 @@ export class AIManager {
682
802
  workdir: this.getWorkdir(), // Pass working directory
683
803
  tools: toolsConfig, // Pass filtered tool configuration
684
804
  model: model, // Use passed model
685
- systemPrompt: buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
686
- workdir: this.getWorkdir(),
687
- originalWorkdir: this.getOriginalWorkdir(),
688
- language: this.getLanguage(),
689
- isSubagent: !!this.subagentType,
690
- worktreeSession: this.getWorktreeSession(),
691
- autoMemory: autoMemoryOptions,
692
- }), // Pass custom system prompt
805
+ systemPrompt: mainSystemPrompt, // Pass custom system prompt
693
806
  maxTokens: maxTokens, // Pass max tokens override
694
807
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
695
808
  };
@@ -697,7 +810,7 @@ export class AIManager {
697
810
  if (prependContent.trim()) {
698
811
  callAgentOptions.messages.unshift({
699
812
  role: "user",
700
- content: `<system-reminder>\n${prependContent}\n</system-reminder>`,
813
+ content: wrapInSystemReminder(prependContent),
701
814
  });
702
815
  }
703
816
  // Task reminder: persist as meta message (conditional rules already persisted above)
@@ -1087,6 +1200,14 @@ export class AIManager {
1087
1200
  });
1088
1201
  }
1089
1202
  }
1203
+ // Re-assert loading state before restarting: if this turn was
1204
+ // aborted, abortAIMessage already reset loading to false, and the
1205
+ // restart below continues the conversation — the UI must show
1206
+ // streaming again (cursor, stop button, ESC handling).
1207
+ this.setIsLoading(true);
1208
+ // Adopt the current (possibly abort-bumped) generation so this
1209
+ // continued turn's end-of-turn cleanup is not skipped as superseded.
1210
+ myGeneration = this.turnGeneration;
1090
1211
  // Restart outer loop to process the notifications
1091
1212
  shouldRestart = true;
1092
1213
  turnOffset = 0;
@@ -1156,7 +1277,7 @@ export class AIManager {
1156
1277
  sessionCrons, // Stop-only: session cron jobs snapshot
1157
1278
  lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
1158
1279
  // Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
1159
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
1280
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1160
1281
  };
1161
1282
  const results = await this.hookManager.executeHooks(hookName, context);
1162
1283
  // Process hook results to handle exit codes and appropriate responses
@@ -1311,7 +1432,7 @@ export class AIManager {
1311
1432
  if (this.hookManager) {
1312
1433
  const sessionId = this.messageManager.getSessionId();
1313
1434
  const transcriptPath = this.messageManager.getTranscriptPath();
1314
- const env = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined));
1435
+ const env = Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined));
1315
1436
  await this.hookManager.executeCwdChangedHooks(oldCwd, newCwd, sessionId, transcriptPath, env);
1316
1437
  }
1317
1438
  },
@@ -1378,7 +1499,7 @@ export class AIManager {
1378
1499
  cwd: this.getWorkdir(),
1379
1500
  toolInput,
1380
1501
  subagentType: this.subagentType, // Include subagent type in hook context
1381
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
1502
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1382
1503
  };
1383
1504
  const results = await this.hookManager.executeHooks("PreToolUse", context);
1384
1505
  // Process hook results to handle exit codes and determine if tool should be blocked
@@ -1431,7 +1552,7 @@ export class AIManager {
1431
1552
  toolResponse,
1432
1553
  subagentType: this.subagentType, // Include subagent type in hook context
1433
1554
  planFilePath: this.permissionManager?.getPlanFilePath(),
1434
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
1555
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1435
1556
  };
1436
1557
  const results = await this.hookManager.executeHooks("PostToolUse", context);
1437
1558
  // 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) {
@@ -11,13 +11,21 @@ export interface McpManagerOptions {
11
11
  /** Pre-configured MCP servers passed from constructor options */
12
12
  mcpServers?: Record<string, McpServerConfig>;
13
13
  }
14
- export declare function expandEnvVars(value: string): string;
14
+ /**
15
+ * Expand environment variables in a string value.
16
+ * Supports ${VAR} and ${VAR:-default} patterns.
17
+ *
18
+ * @param env - Merged env to resolve against (session snapshot over OS env).
19
+ * Defaults to `process.env` for backward compatibility with tests
20
+ * and standalone callers.
21
+ */
22
+ export declare function expandEnvVars(value: string, env?: Record<string, string | undefined>): string;
15
23
  /**
16
24
  * Walk an MCP config and resolve environment variables in all string fields.
17
- * Only expands ${VAR} from process.env (skipping WAVE_PLUGIN_ROOT which is
18
- * handled at spawn time).
25
+ * Expands ${VAR} against the session env snapshot (over OS env); falls back to
26
+ * process.env. WAVE_PLUGIN_ROOT is skipped — handled at spawn time.
19
27
  */
20
- export declare function resolveMcpConfig(config: McpConfig): McpConfig;
28
+ export declare function resolveMcpConfig(config: McpConfig, env?: Record<string, string | undefined>): McpConfig;
21
29
  export declare class McpManager {
22
30
  private container;
23
31
  private config;
@@ -30,6 +38,12 @@ export declare class McpManager {
30
38
  private reconnectTimers;
31
39
  private reconnectAttempts;
32
40
  constructor(container: Container, options?: McpManagerOptions);
41
+ /**
42
+ * Merged env for this session: OS env overlaid with the per-session settings
43
+ * snapshot. MCP env-var expansion (${VAR}) resolves against this so multiple
44
+ * sessions in one `wave --stdio` process don't read each other's settings env.
45
+ */
46
+ private get envSnapshot();
33
47
  /**
34
48
  * Initialize MCP manager with working directory and optionally auto-connect
35
49
  */