wave-agent-sdk 0.19.9 → 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 (60) 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.js +5 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/managers/aiManager.d.ts +8 -0
  9. package/dist/managers/aiManager.js +20 -5
  10. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  11. package/dist/managers/backgroundTaskManager.js +11 -0
  12. package/dist/managers/bangManager.d.ts +6 -0
  13. package/dist/managers/bangManager.js +11 -0
  14. package/dist/managers/hookManager.d.ts +8 -2
  15. package/dist/managers/hookManager.js +14 -4
  16. package/dist/managers/mcpManager.d.ts +18 -4
  17. package/dist/managers/mcpManager.js +40 -18
  18. package/dist/managers/toolManager.js +5 -0
  19. package/dist/services/configurationService.d.ts +21 -2
  20. package/dist/services/configurationService.js +72 -23
  21. package/dist/services/initializationService.js +14 -4
  22. package/dist/services/interactionService.js +35 -7
  23. package/dist/services/remoteSettingsService.d.ts +12 -0
  24. package/dist/services/remoteSettingsService.js +15 -1
  25. package/dist/services/taskManager.js +7 -1
  26. package/dist/tools/bashTool.js +1 -0
  27. package/dist/tools/enterWorktreeTool.js +14 -3
  28. package/dist/tools/exitWorktreeTool.js +11 -10
  29. package/dist/tools/types.d.ts +7 -0
  30. package/dist/types/config.d.ts +2 -0
  31. package/dist/types/hooks.d.ts +2 -2
  32. package/dist/utils/containerSetup.js +1 -1
  33. package/dist/utils/openaiClient.js +2 -1
  34. package/dist/utils/pathEncoder.js +7 -2
  35. package/dist/utils/worktreeUtils.d.ts +17 -0
  36. package/dist/utils/worktreeUtils.js +339 -1
  37. package/package.json +1 -1
  38. package/src/agent.ts +7 -2
  39. package/src/index.ts +1 -0
  40. package/src/managers/aiManager.ts +23 -5
  41. package/src/managers/backgroundTaskManager.ts +15 -0
  42. package/src/managers/bangManager.ts +15 -0
  43. package/src/managers/hookManager.ts +20 -5
  44. package/src/managers/mcpManager.ts +60 -18
  45. package/src/managers/toolManager.ts +7 -0
  46. package/src/services/configurationService.ts +84 -23
  47. package/src/services/initializationService.ts +17 -4
  48. package/src/services/interactionService.ts +49 -6
  49. package/src/services/remoteSettingsService.ts +16 -1
  50. package/src/services/taskManager.ts +10 -1
  51. package/src/tools/bashTool.ts +1 -0
  52. package/src/tools/enterWorktreeTool.ts +19 -2
  53. package/src/tools/exitWorktreeTool.ts +15 -12
  54. package/src/tools/types.ts +7 -0
  55. package/src/types/config.ts +2 -0
  56. package/src/types/hooks.ts +2 -2
  57. package/src/utils/containerSetup.ts +3 -1
  58. package/src/utils/openaiClient.ts +2 -0
  59. package/src/utils/pathEncoder.ts +7 -2
  60. package/src/utils/worktreeUtils.ts +401 -1
@@ -14,7 +14,7 @@ const guidance = [
14
14
  "Spec-First Workflow(规格优先工作流):",
15
15
  "- 需求增加或变更时,优先更新 spec:先更新对应规格说明(新增用户故事、验收场景),待用户确认 spec 后再实现代码。spec 是功能设计的权威来源,不是 changelog。",
16
16
  "- 边界模糊时也先写 spec 草稿请用户确认,不要直接改代码。",
17
- "- 使用 /sdd:specify 技能创建或更新规格文件。",
17
+ "- 规格编写技能(specify)由 AI 自动触发:对话中涉及新需求或需求变更时主动创建或更新规格文件,不需要用户手动调用(不出现在斜杠命令列表中)。",
18
18
  `- 新增或修改 spec 后运行校验:${specCount}(自动检测 docs/specs/,否则 specs/,否则退出)。`,
19
19
  ].join("\n");
20
20
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: specify
3
3
  description: 根据自然语言描述创建或更新功能规格说明,生成包含用户故事与验收场景的规格文件。
4
+ user-invocable: false
4
5
  ---
5
6
 
6
7
  ## 用户输入
@@ -9,13 +10,11 @@ description: 根据自然语言描述创建或更新功能规格说明,生成
9
10
  $ARGUMENTS
10
11
  ```
11
12
 
12
- 你**必须**在继续之前考虑用户输入(如果不为空)。
13
-
14
13
  ## 流程
15
14
 
16
- 用户在 `/sdd:specify` 后输入的文本就是功能描述。不要让用户重复,除非他们提供了空命令。
15
+ 本技能由 AI 在会话中自动触发(不占用手动斜杠命令)。触发时机:用户提出新的需求、修改需求或涉及功能边界时,若对应规格尚未创建或已过期,则主动创建或更新规格说明。$ARGUMENTS 通常为空——需求描述直接来自对话上下文,不要让用户重复。
17
16
 
18
- 根据功能描述,执行以下步骤:
17
+ 根据对话中的功能描述,执行以下步骤:
19
18
 
20
19
  1. **确定规格文件路径**:
21
20
  - **确定规格根目录**:优先复用项目中已有的规格目录——若 `docs/specs/` 存在则用之,否则若 `specs/` 存在则用之,否则默认 `specs/`(并在完成报告中说明所选目录,便于用户纠正)。
@@ -17,13 +17,13 @@ Environment variables are configured in the `env` field of `settings.json`. It i
17
17
 
18
18
  ## Supported `WAVE_*` Environment Variables
19
19
 
20
- Wave uses several environment variables to control its core functionality.
20
+ Wave uses several environment variables to control its core functionality. Variables marked **OS env only** are read from the OS environment (or constructor / stdio `initialize` params) and are **NOT** read from settings.json `env` — set them in your shell, not in the `env` field.
21
21
 
22
22
  | Variable | Description | Default |
23
23
  | :--- | :--- | :--- |
24
24
  | `WAVE_API_KEY` | API key for the AI gateway. | - |
25
25
  | `WAVE_BASE_URL` | Base URL for the AI gateway. | - |
26
- | `WAVE_SERVER_URL` | Server URL for SSO authentication. | `https://codechat.codewave.163.com` |
26
+ | `WAVE_SERVER_URL` | Server URL for SSO authentication. **OS env only** — set via OS env or `options.serverUrl`; not read from settings.json `env` (avoids a startup 401 race). | `https://codechat.codewave.163.com` |
27
27
  | `WAVE_CUSTOM_HEADERS` | Custom HTTP headers for the AI gateway. Newline-separated `Key: Value` pairs (e.g., `"X-Foo: bar\nAuthorization: Bearer xxx"`). | - |
28
28
  | `WAVE_MODEL` | The primary AI model to use for the agent. | `gemini-3-flash` |
29
29
  | `WAVE_FAST_MODEL` | The fast AI model to use for quick tasks. | `gemini-2.5-flash` |
@@ -32,23 +32,29 @@ Wave uses several environment variables to control its core functionality.
32
32
  | `WAVE_DISABLE_AUTO_MEMORY` | Set to `1` or `true` to disable the auto-memory feature. | `false` |
33
33
  | `WAVE_AUTO_MEMORY_FREQUENCY` | Auto memory update frequency. `1` = every turn, `2` = every 2 turns, etc. | `1` |
34
34
  | `WAVE_TASK_LIST_ID` | Explicitly set the task list ID for the session. | (Session ID) |
35
- | `WAVE_PLUGIN_GIT_TIMEOUT_MS` | Timeout in milliseconds for git operations when installing plugins. | `300000` |
35
+ | `WAVE_PLUGIN_GIT_TIMEOUT_MS` | Timeout in milliseconds for git operations when installing plugins. **OS env only** (infrastructure). | `300000` |
36
36
 
37
37
  ## Configuration Scopes
38
38
 
39
- Environment variables can be set in different scopes, with the following precedence (highest to lowest):
39
+ Environment variables can be set in different scopes. Wave merges scopes from lowest to highest priority and stores the result in the agent's **per-session environment snapshot**. The snapshot takes priority over OS environment variables but is **NOT written to `process.env`** — this keeps multiple sessions in one `wave --stdio` process from polluting each other.
40
+
41
+ Precedence (highest to lowest):
40
42
 
41
43
  1. **Local Scope**: `.wave/settings.local.json` (Local overrides, ignored by git)
42
44
  2. **Project Scope**: `.wave/settings.json` (Project-specific settings, shared via git)
43
45
  3. **User Scope**: `~/.wave/settings.json` (Global settings for all projects)
44
- 4. **System Environment**: Variables set in your shell (e.g., `export WAVE_API_KEY=...`)
46
+ 4. **System Environment**: Variables set in your shell (e.g., `export WAVE_API_KEY=...`). Used as a fallback when a key is absent from the settings snapshot.
47
+
48
+ > Settings `env` shadows (does not mutate) OS env: a key set in both settings.json `env` and the OS environment resolves to the settings value for that session, while the OS value remains untouched and visible to unrelated processes.
45
49
 
46
50
  ## Custom Environment Variables
47
51
 
48
- You can also define custom environment variables in the `env` field. These variables will be available to:
52
+ You can also define custom environment variables in the `env` field. These variables are stored in the session's environment snapshot and will be available to:
53
+
54
+ - **Hooks**: Any shell command executed as a hook will have these variables in its environment (merged on top of OS env).
55
+ - **Tools**: Tools like `Bash` will have access to these variables (merged on top of OS env).
49
56
 
50
- - **Hooks**: Any shell command executed as a hook will have these variables in its environment.
51
- - **Tools**: Tools like `Bash` will have access to these variables.
57
+ In `wave --stdio` mode one process hosts multiple sessions; each session keeps its own snapshot, so sessions with different `env` do not pollute each other (no "last session wins").
52
58
 
53
59
  Example:
54
60
  ```json
@@ -62,7 +68,7 @@ Example:
62
68
 
63
69
  ## Live Reload
64
70
 
65
- Environment variables configured in `settings.json` support **live reload**. When you modify the `env` field in any `settings.json` file (user, project, or local scope), the changes take effect immediately without requiring a Wave session restart.
71
+ Environment variables configured in `settings.json` support **live reload**. When you modify the `env` field in any `settings.json` file (user, project, or local scope), the changes take effect immediately without requiring a Wave session restart — the session's environment snapshot is refreshed and subsequent resolve calls / subprocess spawns use the new values.
66
72
 
67
73
  ## Best Practices
68
74
 
@@ -13,7 +13,7 @@ Wave supports the following hook events:
13
13
  - `Stop`: Triggered when Wave finishes its response cycle (no more tool calls).
14
14
  - `SubagentStop`: Triggered when a subagent finishes its response cycle.
15
15
  - `WorktreeCreate`: Triggered when a new worktree is created.
16
- - `WorktreeRemove`: Triggered when a worktree is removed (e.g., via ExitWorktree with `action: "remove"`). Non-blocking. The hook receives `worktree_path` in the JSON input. Useful for cleanup tasks (e.g., `docker compose -p $(basename "$worktree_path") down`) after worktree deletion.
16
+ - `WorktreeRemove`: Triggered before a worktree is removed (e.g., via ExitWorktree with `action: "remove"`). Non-blocking. Fires **before** the worktree directory is deleted so hooks can still read files inside it. The hook receives `worktree_path` in the JSON input. Useful for cleanup tasks (e.g., `docker compose -p $(basename "$worktree_path") down`).
17
17
  - `CwdChanged`: Triggered when the working directory changes (e.g., entering/exiting a worktree). Non-blocking.
18
18
  - `SessionStart`: Triggered during session initialization. Hooks can inject `additionalContext` and `initialUserMessage` via stdout.
19
19
  - `SessionEnd`: Triggered during agent destruction (fire-and-forget, non-blocking). Useful for cleanup, resource teardown, and analytics.
@@ -79,7 +79,7 @@ Wave provides detailed context to hook processes via `stdin` as a JSON object. T
79
79
  - `user_prompt`: (UserPromptSubmit) The text submitted by the user.
80
80
  - `subagent_type`: (If executed by a subagent) The type of the subagent.
81
81
  - `name`: (WorktreeCreate) The name of the new worktree.
82
- - `worktree_path`: (WorktreeRemove) The absolute path to the removed worktree.
82
+ - `worktree_path`: (WorktreeRemove) The absolute path of the worktree about to be removed. Derive the worktree name with `basename "$worktree_path"`.
83
83
  - `old_cwd`: (CwdChanged) The previous working directory.
84
84
  - `new_cwd`: (CwdChanged) The new working directory.
85
85
  - `compact_instructions`: (PreCompact) Custom instructions for the compaction, if any.
@@ -165,6 +165,31 @@ SessionEnd hooks receive `end_source` in the JSON input indicating how the sessi
165
165
  }
166
166
  ```
167
167
 
168
+ ## WorktreeRemove Hooks
169
+
170
+ `WorktreeRemove` hooks fire **before** the worktree directory is deleted, so they can still read files inside it. They are non-blocking (Notification type): the hook never replaces `git worktree remove` itself. Useful for cleaning up external resources that were provisioned for the worktree (databases, containers, etc.).
171
+
172
+ ### Input
173
+ WorktreeRemove hooks receive `worktree_path` in the JSON input (alongside the common fields `session_id`, `transcript_path`, `cwd`, `hook_event_name`). The worktree name can be derived via `basename "$worktree_path"`.
174
+
175
+ ### Example Configuration
176
+ ```json
177
+ {
178
+ "hooks": {
179
+ "WorktreeRemove": [
180
+ {
181
+ "hooks": [
182
+ {
183
+ "command": "worktree_path=$(jq -r '.worktree_path') && docker compose -p \"$(basename \"$worktree_path\")\" down || true",
184
+ "description": "Tear down the worktree's docker compose project before removal"
185
+ }
186
+ ]
187
+ }
188
+ ]
189
+ }
190
+ }
191
+ ```
192
+
168
193
  ## Live Reload
169
194
 
170
195
  Hook configurations support **live reload**. When you modify hooks in `settings.json`, the changes take effect immediately without restarting Wave.
package/dist/agent.js CHANGED
@@ -14,7 +14,10 @@ import { remoteSettingsService } from "./services/remoteSettingsService.js";
14
14
  export class Agent {
15
15
  // Dynamic configuration getter methods
16
16
  getGatewayConfig() {
17
- return this.configurationService.resolveGatewayConfig();
17
+ return {
18
+ ...this.configurationService.resolveGatewayConfig(),
19
+ sessionId: this.messageManager.getSessionId(),
20
+ };
18
21
  }
19
22
  getModelConfig() {
20
23
  return this.configurationService.resolveModelConfig(undefined, undefined, undefined, this.getPermissionMode());
@@ -702,7 +705,7 @@ export class Agent {
702
705
  transcriptPath,
703
706
  cwd: this.workdir,
704
707
  worktreePath,
705
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
708
+ env: Object.fromEntries(Object.entries(this.configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
706
709
  });
707
710
  // Process results via messageManager (may not be visible during shutdown)
708
711
  this.hookManager.processHookResults("WorktreeRemove", hookResults, this.messageManager);
package/dist/index.d.ts CHANGED
@@ -22,6 +22,7 @@ export * from "./utils/tokenCalculation.js";
22
22
  export * from "./utils/gitUtils.js";
23
23
  export * from "./utils/nameGenerator.js";
24
24
  export * from "./utils/worktreeSession.js";
25
+ export * from "./utils/worktreeUtils.js";
25
26
  export { loadMergedWaveConfig } from "./services/configurationService.js";
26
27
  export * from "./types/index.js";
27
28
  export * from "./tools/buildTool.js";
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export * from "./utils/tokenCalculation.js";
26
26
  export * from "./utils/gitUtils.js";
27
27
  export * from "./utils/nameGenerator.js";
28
28
  export * from "./utils/worktreeSession.js";
29
+ export * from "./utils/worktreeUtils.js";
29
30
  export { loadMergedWaveConfig } from "./services/configurationService.js";
30
31
  export * from "./types/index.js";
31
32
  // Export tool building utilities
@@ -53,6 +53,14 @@ export declare class AIManager {
53
53
  private get permissionManager();
54
54
  private get planManager();
55
55
  private get configurationService();
56
+ /**
57
+ * OS env merged with the per-session env snapshot. Falls back to process.env
58
+ * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
59
+ * unit tests with partial mocks), so hook-context env construction never
60
+ * throws. Use this (not the non-null `configurationService` getter) when
61
+ * building hook context env.
62
+ */
63
+ private get mergedEnv();
56
64
  getGatewayConfig(): GatewayConfig;
57
65
  getModelConfig(): ModelConfig;
58
66
  getMaxInputTokens(): number;
@@ -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")
@@ -1262,7 +1277,7 @@ export class AIManager {
1262
1277
  sessionCrons, // Stop-only: session cron jobs snapshot
1263
1278
  lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
1264
1279
  // 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
1280
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1266
1281
  };
1267
1282
  const results = await this.hookManager.executeHooks(hookName, context);
1268
1283
  // Process hook results to handle exit codes and appropriate responses
@@ -1417,7 +1432,7 @@ export class AIManager {
1417
1432
  if (this.hookManager) {
1418
1433
  const sessionId = this.messageManager.getSessionId();
1419
1434
  const transcriptPath = this.messageManager.getTranscriptPath();
1420
- 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));
1421
1436
  await this.hookManager.executeCwdChangedHooks(oldCwd, newCwd, sessionId, transcriptPath, env);
1422
1437
  }
1423
1438
  },
@@ -1484,7 +1499,7 @@ export class AIManager {
1484
1499
  cwd: this.getWorkdir(),
1485
1500
  toolInput,
1486
1501
  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
1502
+ env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
1488
1503
  };
1489
1504
  const results = await this.hookManager.executeHooks("PreToolUse", context);
1490
1505
  // Process hook results to handle exit codes and determine if tool should be blocked
@@ -1537,7 +1552,7 @@ export class AIManager {
1537
1552
  toolResponse,
1538
1553
  subagentType: this.subagentType, // Include subagent type in hook context
1539
1554
  planFilePath: this.permissionManager?.getPlanFilePath(),
1540
- 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
1541
1556
  };
1542
1557
  const results = await this.hookManager.executeHooks("PostToolUse", context);
1543
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
  */
@@ -11,7 +11,15 @@ import { logger } from "../utils/globalLogger.js";
11
11
  * Supports ${VAR} and ${VAR:-default} patterns.
12
12
  */
13
13
  const WAVE_TEMPLATE_VARS = ["WAVE_PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"];
14
- export function expandEnvVars(value) {
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 function expandEnvVars(value, env = process.env) {
15
23
  return value.replace(/\$\{([^}]+)\}/g, (_match, expr) => {
16
24
  const [varName, ...rest] = expr.split(":-");
17
25
  const defaultValue = rest.join(":-");
@@ -19,38 +27,38 @@ export function expandEnvVars(value) {
19
27
  if (WAVE_TEMPLATE_VARS.includes(varName)) {
20
28
  return _match; // return original ${...} string untouched
21
29
  }
22
- return process.env[varName] ?? defaultValue;
30
+ return env[varName] ?? process.env[varName] ?? defaultValue;
23
31
  });
24
32
  }
25
33
  /**
26
34
  * Walk an MCP config and resolve environment variables in all string fields.
27
- * Only expands ${VAR} from process.env (skipping WAVE_PLUGIN_ROOT which is
28
- * handled at spawn time).
35
+ * Expands ${VAR} against the session env snapshot (over OS env); falls back to
36
+ * process.env. WAVE_PLUGIN_ROOT is skipped — handled at spawn time.
29
37
  */
30
- export function resolveMcpConfig(config) {
38
+ export function resolveMcpConfig(config, env = process.env) {
31
39
  const resolved = { mcpServers: {} };
32
40
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
33
41
  const resolvedServer = { ...serverConfig };
34
42
  if (resolvedServer.command) {
35
- resolvedServer.command = expandEnvVars(resolvedServer.command);
43
+ resolvedServer.command = expandEnvVars(resolvedServer.command, env);
36
44
  }
37
45
  if (resolvedServer.args) {
38
- resolvedServer.args = resolvedServer.args.map(expandEnvVars);
46
+ resolvedServer.args = resolvedServer.args.map((a) => expandEnvVars(a, env));
39
47
  }
40
48
  if (resolvedServer.env) {
41
49
  const resolvedEnv = {};
42
50
  for (const [key, val] of Object.entries(resolvedServer.env)) {
43
- resolvedEnv[key] = expandEnvVars(val);
51
+ resolvedEnv[key] = expandEnvVars(val, env);
44
52
  }
45
53
  resolvedServer.env = resolvedEnv;
46
54
  }
47
55
  if (resolvedServer.url) {
48
- resolvedServer.url = expandEnvVars(resolvedServer.url);
56
+ resolvedServer.url = expandEnvVars(resolvedServer.url, env);
49
57
  }
50
58
  if (resolvedServer.headers) {
51
59
  const resolvedHeaders = {};
52
60
  for (const [key, val] of Object.entries(resolvedServer.headers)) {
53
- resolvedHeaders[key] = expandEnvVars(val);
61
+ resolvedHeaders[key] = expandEnvVars(val, env);
54
62
  }
55
63
  resolvedServer.headers = resolvedHeaders;
56
64
  }
@@ -71,6 +79,16 @@ export class McpManager {
71
79
  this.callbacks = options.callbacks || {};
72
80
  this.mcpServers = options.mcpServers;
73
81
  }
82
+ /**
83
+ * Merged env for this session: OS env overlaid with the per-session settings
84
+ * snapshot. MCP env-var expansion (${VAR}) resolves against this so multiple
85
+ * sessions in one `wave --stdio` process don't read each other's settings env.
86
+ */
87
+ get envSnapshot() {
88
+ return (this.container
89
+ .get("ConfigurationService")
90
+ ?.getMergedEnv?.() ?? process.env);
91
+ }
74
92
  /**
75
93
  * Initialize MCP manager with working directory and optionally auto-connect
76
94
  */
@@ -125,7 +143,7 @@ export class McpManager {
125
143
  try {
126
144
  const configContent = await fs.readFile(this.configPath, "utf-8");
127
145
  const rawConfig = JSON.parse(configContent);
128
- const workspaceConfig = resolveMcpConfig(rawConfig);
146
+ const workspaceConfig = resolveMcpConfig(rawConfig, this.envSnapshot);
129
147
  // Extract original (pre-resolution) URLs for safe display
130
148
  const originalUrls = {};
131
149
  for (const [name, serverConfig] of Object.entries(rawConfig.mcpServers)) {
@@ -210,28 +228,29 @@ export class McpManager {
210
228
  }
211
229
  // Capture original URL before any resolution for safe display
212
230
  const originalUrl = config.url;
213
- // Expand env vars from process.env (e.g. ${TAVILY_API_KEY})
231
+ // Expand env vars against the session snapshot (over OS env, e.g. ${TAVILY_API_KEY})
232
+ const env = this.envSnapshot;
214
233
  const resolvedConfig = { ...config };
215
234
  if (resolvedConfig.command) {
216
- resolvedConfig.command = expandEnvVars(resolvedConfig.command);
235
+ resolvedConfig.command = expandEnvVars(resolvedConfig.command, env);
217
236
  }
218
237
  if (resolvedConfig.args) {
219
- resolvedConfig.args = resolvedConfig.args.map(expandEnvVars);
238
+ resolvedConfig.args = resolvedConfig.args.map((a) => expandEnvVars(a, env));
220
239
  }
221
240
  if (resolvedConfig.env) {
222
241
  const resolvedEnv = {};
223
242
  for (const [key, val] of Object.entries(resolvedConfig.env)) {
224
- resolvedEnv[key] = expandEnvVars(val);
243
+ resolvedEnv[key] = expandEnvVars(val, env);
225
244
  }
226
245
  resolvedConfig.env = resolvedEnv;
227
246
  }
228
247
  if (resolvedConfig.url) {
229
- resolvedConfig.url = expandEnvVars(resolvedConfig.url);
248
+ resolvedConfig.url = expandEnvVars(resolvedConfig.url, env);
230
249
  }
231
250
  if (resolvedConfig.headers) {
232
251
  const resolvedHeaders = {};
233
252
  for (const [key, val] of Object.entries(resolvedConfig.headers)) {
234
- resolvedHeaders[key] = expandEnvVars(val);
253
+ resolvedHeaders[key] = expandEnvVars(val, env);
235
254
  }
236
255
  resolvedConfig.headers = resolvedHeaders;
237
256
  }
@@ -332,8 +351,11 @@ export class McpManager {
332
351
  if (!server.config.command) {
333
352
  throw new Error(`MCP server ${name} with type "stdio" requires a 'command'`);
334
353
  }
354
+ // Base env = OS env overlaid with this session's settings snapshot, so
355
+ // custom env vars from settings.json reach the MCP server subprocess
356
+ // without polluting other sessions sharing one `wave --stdio` process.
335
357
  const env = {
336
- ...process.env,
358
+ ...this.envSnapshot,
337
359
  ...(server.config.env || {}),
338
360
  };
339
361
  // For plugin servers, substitute ${WAVE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_ROOT}
@@ -179,6 +179,11 @@ class ToolManager {
179
179
  workflowManager: this.container.has("WorkflowManager")
180
180
  ? this.container.get("WorkflowManager")
181
181
  : undefined,
182
+ sessionEnv: this.container.has("ConfigurationService")
183
+ ? this.container
184
+ .get("ConfigurationService")
185
+ ?.getMergedEnv?.()
186
+ : undefined,
182
187
  sessionId: context.sessionId,
183
188
  toolCallId: context.toolCallId,
184
189
  };