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
@@ -2,11 +2,16 @@
2
2
  * Git worktree creation and removal utilities for the SDK.
3
3
  * Used by EnterWorktree and ExitWorktree tools.
4
4
  */
5
- import { execFileSync } from "node:child_process";
5
+ import { execFile, execFileSync } from "node:child_process";
6
+ import { promisify } from "node:util";
6
7
  import * as path from "node:path";
7
8
  import * as fs from "node:fs";
8
9
  import { getGitMainRepoRoot, getDefaultRemoteBranch, ensureWaveRuntimeFilesExcluded, } from "./gitUtils.js";
9
10
  import { logger } from "./globalLogger.js";
11
+ // Post-creation setup runs inside the shared `wave --stdio` process too
12
+ // (desktop sessions), so use the async execFile: a synchronous git call
13
+ // (e.g. `git ls-files` over a large working tree) would freeze every session.
14
+ const execFileAsync = promisify(execFile);
10
15
  /**
11
16
  * Validate a worktree name to prevent path traversal and invalid characters.
12
17
  */
@@ -211,6 +216,288 @@ export function createWorktree(name, cwd, options) {
211
216
  throw new Error(`Failed to create worktree: ${error.message}\n${stderr}`);
212
217
  }
213
218
  }
219
+ /** Translate a single gitignore glob into a RegExp (gitignore semantics). */
220
+ function globToRegExp(glob) {
221
+ let source = "^";
222
+ for (let i = 0; i < glob.length; i++) {
223
+ const ch = glob[i];
224
+ if (ch === "*") {
225
+ if (glob[i + 1] === "*") {
226
+ if (glob[i + 2] === "/") {
227
+ // "**/" matches zero or more leading directories
228
+ source += "(?:.*/)?";
229
+ i += 2;
230
+ }
231
+ else {
232
+ source += ".*";
233
+ i += 1;
234
+ }
235
+ }
236
+ else {
237
+ source += "[^/]*";
238
+ }
239
+ }
240
+ else if (ch === "?") {
241
+ source += "[^/]";
242
+ }
243
+ else if (ch === "[") {
244
+ const end = glob.indexOf("]", i + 1);
245
+ if (end === -1) {
246
+ source += "\\[";
247
+ }
248
+ else {
249
+ let charClass = glob.slice(i, end + 1);
250
+ if (charClass.startsWith("[!")) {
251
+ // gitignore uses "[!...]" for a negated character class
252
+ charClass = "[^" + charClass.slice(2);
253
+ }
254
+ source += charClass;
255
+ i = end;
256
+ }
257
+ }
258
+ else {
259
+ source += ch.replace(/[.+^${}()|\\]/g, "\\$&");
260
+ }
261
+ }
262
+ source += "$";
263
+ return new RegExp(source);
264
+ }
265
+ /**
266
+ * Parse .worktreeinclude content into patterns. Blank lines and "#" comments
267
+ * are skipped, matching gitignore conventions.
268
+ */
269
+ function parseWorktreeIncludePatterns(content) {
270
+ const patterns = [];
271
+ for (const rawLine of content.split(/\r?\n/)) {
272
+ const line = rawLine.trim();
273
+ if (!line || line.startsWith("#"))
274
+ continue;
275
+ let raw = line;
276
+ const negated = raw.startsWith("!");
277
+ if (negated)
278
+ raw = raw.slice(1);
279
+ const dirOnly = raw.endsWith("/");
280
+ if (dirOnly)
281
+ raw = raw.slice(0, -1);
282
+ const anchored = raw.startsWith("/");
283
+ if (anchored)
284
+ raw = raw.slice(1);
285
+ if (!raw)
286
+ continue;
287
+ patterns.push({
288
+ raw,
289
+ negated,
290
+ dirOnly,
291
+ anchored,
292
+ anyLevel: !anchored && !raw.includes("/"),
293
+ regex: globToRegExp(raw),
294
+ });
295
+ }
296
+ return patterns;
297
+ }
298
+ /** Test a single pattern against one candidate path. */
299
+ function testPatternAgainst(pattern, candidate) {
300
+ if (pattern.anyLevel) {
301
+ // Match the full path or any "/"-suffix (basename at any level)
302
+ if (pattern.regex.test(candidate))
303
+ return true;
304
+ for (let i = 0; i < candidate.length; i++) {
305
+ if (candidate[i] === "/" && pattern.regex.test(candidate.slice(i + 1))) {
306
+ return true;
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+ return pattern.regex.test(candidate);
312
+ }
313
+ /**
314
+ * Test a pattern against a path and every ancestor prefix. Excluding a
315
+ * directory excludes everything beneath it (gitignore semantics), so ancestor
316
+ * prefixes are always treated as directories.
317
+ */
318
+ function patternMatchesPath(pattern, relPath, isDir) {
319
+ let candidate = relPath;
320
+ for (;;) {
321
+ const candidateIsDir = candidate === relPath ? isDir : true;
322
+ if (!(pattern.dirOnly && !candidateIsDir) &&
323
+ testPatternAgainst(pattern, candidate)) {
324
+ return true;
325
+ }
326
+ const idx = candidate.lastIndexOf("/");
327
+ if (idx === -1)
328
+ break;
329
+ candidate = candidate.slice(0, idx);
330
+ }
331
+ return false;
332
+ }
333
+ /** Last-match-wins resolution with "!" negation. */
334
+ function worktreeIncludeMatches(patterns, relPath, isDir) {
335
+ let matched = false;
336
+ for (const pattern of patterns) {
337
+ if (patternMatchesPath(pattern, relPath, isDir)) {
338
+ matched = !pattern.negated;
339
+ }
340
+ }
341
+ return matched;
342
+ }
343
+ /**
344
+ * A positive pattern targets something inside a collapsed directory: either it
345
+ * literally starts with the directory path, or the directory path starts with
346
+ * the pattern's literal (pre-glob) prefix.
347
+ */
348
+ function needsExpansion(patterns, dir) {
349
+ return patterns.some((p) => {
350
+ if (p.negated)
351
+ return false;
352
+ if (p.raw.startsWith(dir + "/"))
353
+ return true;
354
+ const globIdx = p.raw.search(/[*?[]/);
355
+ if (globIdx > 0 && dir.startsWith(p.raw.slice(0, globIdx)))
356
+ return true;
357
+ return false;
358
+ });
359
+ }
360
+ // --- Post-creation setup -----------------------------------------------------
361
+ /**
362
+ * Copy <repoRoot>/.wave/settings.local.json into the worktree so local
363
+ * configuration (permissions, env, ...) carries over. Missing files are
364
+ * skipped silently; other failures only log a warning.
365
+ */
366
+ async function copyLocalSettingsToWorktree(repoRoot, worktreePath) {
367
+ const relativePath = path.join(".wave", "settings.local.json");
368
+ const sourcePath = path.join(repoRoot, relativePath);
369
+ const destPath = path.join(worktreePath, relativePath);
370
+ let content;
371
+ try {
372
+ content = await fs.promises.readFile(sourcePath, "utf8");
373
+ }
374
+ catch (error) {
375
+ if (error.code !== "ENOENT") {
376
+ logger.warn(`Failed to read ${sourcePath}: ${error.message}`);
377
+ }
378
+ return;
379
+ }
380
+ try {
381
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
382
+ await fs.promises.writeFile(destPath, content);
383
+ }
384
+ catch (error) {
385
+ logger.warn(`Failed to copy ${relativePath} into worktree: ${error.message}`);
386
+ }
387
+ }
388
+ /**
389
+ * Copy files listed in <repoRoot>/.worktreeinclude into the worktree. Each
390
+ * non-comment line is a gitignore pattern; matching gitignored files are
391
+ * copied at their relative paths.
392
+ */
393
+ async function copyWorktreeIncludeFiles(repoRoot, worktreePath) {
394
+ const includePath = path.join(repoRoot, ".worktreeinclude");
395
+ let content;
396
+ try {
397
+ content = await fs.promises.readFile(includePath, "utf8");
398
+ }
399
+ catch {
400
+ return [];
401
+ }
402
+ const patterns = parseWorktreeIncludePatterns(content);
403
+ if (patterns.length === 0)
404
+ return [];
405
+ const worktreeRelPath = path
406
+ .relative(repoRoot, worktreePath)
407
+ .split(path.sep)
408
+ .join("/");
409
+ // The worktree lives under .wave/worktrees/, which is gitignored — never
410
+ // copy the worktree (or anything inside it) into itself.
411
+ const isSelf = (entry) => entry === worktreeRelPath || entry.startsWith(worktreeRelPath + "/");
412
+ // "--directory" collapses fully-ignored directories into single entries,
413
+ // which keeps the listing fast on large repos.
414
+ let entries;
415
+ try {
416
+ const { stdout } = await execFileAsync("git", [
417
+ "ls-files",
418
+ "--others",
419
+ "--ignored",
420
+ "--exclude-standard",
421
+ "--directory",
422
+ ], { cwd: repoRoot, encoding: "utf8" });
423
+ entries = stdout.trim().split("\n").filter(Boolean);
424
+ }
425
+ catch {
426
+ return [];
427
+ }
428
+ const copied = [];
429
+ const dirsToExpand = [];
430
+ const copyToWorktree = async (relativePath) => {
431
+ const srcPath = path.join(repoRoot, relativePath);
432
+ const destPath = path.join(worktreePath, relativePath);
433
+ try {
434
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
435
+ await fs.promises.copyFile(srcPath, destPath);
436
+ copied.push(relativePath);
437
+ }
438
+ catch (error) {
439
+ logger.warn(`Failed to copy ${relativePath} into worktree: ${error.message}`);
440
+ }
441
+ };
442
+ for (const entry of entries) {
443
+ if (isSelf(entry))
444
+ continue;
445
+ if (entry.endsWith("/")) {
446
+ const dir = entry.slice(0, -1);
447
+ if (worktreeIncludeMatches(patterns, dir, true)) {
448
+ // The directory itself matches — copy it wholesale
449
+ try {
450
+ await fs.promises.cp(path.join(repoRoot, dir), path.join(worktreePath, dir), {
451
+ recursive: true,
452
+ });
453
+ copied.push(entry);
454
+ }
455
+ catch (error) {
456
+ logger.warn(`Failed to copy ${entry} into worktree: ${error.message}`);
457
+ }
458
+ }
459
+ else if (needsExpansion(patterns, dir)) {
460
+ dirsToExpand.push(dir);
461
+ }
462
+ }
463
+ else if (worktreeIncludeMatches(patterns, entry, false)) {
464
+ await copyToWorktree(entry);
465
+ }
466
+ }
467
+ if (dirsToExpand.length > 0) {
468
+ // List the files inside collapsed dirs whose contents are targeted
469
+ try {
470
+ const { stdout } = await execFileAsync("git", [
471
+ "ls-files",
472
+ "--others",
473
+ "--ignored",
474
+ "--exclude-standard",
475
+ "--",
476
+ ...dirsToExpand,
477
+ ], { cwd: repoRoot, encoding: "utf8" });
478
+ for (const file of stdout.trim().split("\n").filter(Boolean)) {
479
+ if (isSelf(file))
480
+ continue;
481
+ if (worktreeIncludeMatches(patterns, file, false)) {
482
+ await copyToWorktree(file);
483
+ }
484
+ }
485
+ }
486
+ catch {
487
+ // Expansion is best-effort; the worktree is already usable without it
488
+ }
489
+ }
490
+ return copied;
491
+ }
492
+ /**
493
+ * Set up a freshly created worktree: copy local settings and gitignored
494
+ * project files (via .worktreeinclude) from the main repo. Best-effort — any
495
+ * failure only logs a warning and never fails worktree creation.
496
+ */
497
+ export async function performPostCreationSetup(worktreePath, repoRoot) {
498
+ await copyLocalSettingsToWorktree(repoRoot, worktreePath);
499
+ await copyWorktreeIncludeFiles(repoRoot, worktreePath);
500
+ }
214
501
  /**
215
502
  * Remove a git worktree and its branch.
216
503
  */
@@ -273,6 +560,57 @@ export function removeWorktree(info) {
273
560
  throw error;
274
561
  }
275
562
  }
563
+ /**
564
+ * Validate that a worktree path is safe to remove before running git removal.
565
+ * Aligns with Claude Code v2.1.216+ background-session checks:
566
+ * - rejects a path whose final component is a symlink;
567
+ * - rejects a path that resolves outside the repo root.
568
+ *
569
+ * A path that no longer exists (worktree already removed) is allowed so that
570
+ * removal stays best-effort/idempotent; its nearest existing ancestor is used
571
+ * for the containment check. Throws an Error on invalid paths.
572
+ */
573
+ export function validateWorktreeRemovalPath(worktreePath, repoRoot) {
574
+ const SYMLINK_PREFIX = "Refusing to remove worktree at symlink path:";
575
+ // Reject a symlink as the final path component.
576
+ try {
577
+ if (fs.lstatSync(worktreePath).isSymbolicLink()) {
578
+ throw new Error(`${SYMLINK_PREFIX} ${worktreePath}`);
579
+ }
580
+ }
581
+ catch (error) {
582
+ if (error instanceof Error && error.message.startsWith(SYMLINK_PREFIX)) {
583
+ throw error;
584
+ }
585
+ // lstat failed (path missing) — the containment check below still applies.
586
+ }
587
+ const resolvedRepoRoot = fs.realpathSync(repoRoot);
588
+ // Resolve the path, following symlinks in existing components. If the path
589
+ // (or a parent) no longer exists, fall back to the deepest existing ancestor
590
+ // so the containment check still guards against traversal outside the repo.
591
+ let resolvedPath;
592
+ let existingAncestor = worktreePath;
593
+ for (;;) {
594
+ try {
595
+ resolvedPath = fs.realpathSync(existingAncestor);
596
+ break;
597
+ }
598
+ catch {
599
+ const parent = path.dirname(existingAncestor);
600
+ if (parent === existingAncestor) {
601
+ throw new Error(`Invalid worktree path for removal: ${worktreePath}`);
602
+ }
603
+ existingAncestor = parent;
604
+ }
605
+ }
606
+ if (existingAncestor !== worktreePath) {
607
+ resolvedPath = path.join(resolvedPath, path.relative(existingAncestor, worktreePath));
608
+ }
609
+ const relative = path.relative(resolvedRepoRoot, resolvedPath);
610
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
611
+ throw new Error(`Refusing to remove worktree outside repo root: ${worktreePath}`);
612
+ }
613
+ }
276
614
  /**
277
615
  * Count uncommitted files and new commits in a worktree.
278
616
  * Returns null if git commands fail (fail-closed).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "0.19.9",
3
+ "version": "1.0.0",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",
package/src/agent.ts CHANGED
@@ -95,7 +95,10 @@ export class Agent {
95
95
 
96
96
  // Dynamic configuration getter methods
97
97
  public getGatewayConfig(): GatewayConfig {
98
- return this.configurationService.resolveGatewayConfig();
98
+ return {
99
+ ...this.configurationService.resolveGatewayConfig(),
100
+ sessionId: this.messageManager.getSessionId(),
101
+ };
99
102
  }
100
103
 
101
104
  public getModelConfig(): ModelConfig {
@@ -924,7 +927,9 @@ export class Agent {
924
927
  cwd: this.workdir,
925
928
  worktreePath,
926
929
  env: Object.fromEntries(
927
- Object.entries(process.env).filter((e) => e[1] !== undefined),
930
+ Object.entries(this.configurationService.getMergedEnv()).filter(
931
+ (e) => e[1] !== undefined,
932
+ ),
928
933
  ) as Record<string, string>,
929
934
  },
930
935
  );
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./utils/tokenCalculation.js";
29
29
  export * from "./utils/gitUtils.js";
30
30
  export * from "./utils/nameGenerator.js";
31
31
  export * from "./utils/worktreeSession.js";
32
+ export * from "./utils/worktreeUtils.js";
32
33
  export { loadMergedWaveConfig } from "./services/configurationService.js";
33
34
  export * from "./types/index.js";
34
35
 
@@ -214,9 +214,27 @@ export class AIManager {
214
214
  return this.container.get<ConfigurationService>("ConfigurationService")!;
215
215
  }
216
216
 
217
+ /**
218
+ * OS env merged with the per-session env snapshot. Falls back to process.env
219
+ * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
220
+ * unit tests with partial mocks), so hook-context env construction never
221
+ * throws. Use this (not the non-null `configurationService` getter) when
222
+ * building hook context env.
223
+ */
224
+ private get mergedEnv(): Record<string, string> {
225
+ return (
226
+ this.container
227
+ .get<ConfigurationService>("ConfigurationService")
228
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
229
+ );
230
+ }
231
+
217
232
  // Getter methods for accessing dynamic configuration
218
233
  public getGatewayConfig(): GatewayConfig {
219
- return this.configurationService.resolveGatewayConfig();
234
+ return {
235
+ ...this.configurationService.resolveGatewayConfig(),
236
+ sessionId: this.messageManager.getSessionId(),
237
+ };
220
238
  }
221
239
 
222
240
  public getModelConfig(): ModelConfig {
@@ -1739,7 +1757,7 @@ export class AIManager {
1739
1757
  lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
1740
1758
  // Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
1741
1759
  env: Object.fromEntries(
1742
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1760
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1743
1761
  ) as Record<string, string>, // Include environment variables
1744
1762
  };
1745
1763
 
@@ -1933,7 +1951,7 @@ export class AIManager {
1933
1951
  const sessionId = this.messageManager.getSessionId();
1934
1952
  const transcriptPath = this.messageManager.getTranscriptPath();
1935
1953
  const env = Object.fromEntries(
1936
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1954
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
1937
1955
  ) as Record<string, string>;
1938
1956
  await this.hookManager.executeCwdChangedHooks(
1939
1957
  oldCwd,
@@ -2028,7 +2046,7 @@ export class AIManager {
2028
2046
  toolInput,
2029
2047
  subagentType: this.subagentType, // Include subagent type in hook context
2030
2048
  env: Object.fromEntries(
2031
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2049
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2032
2050
  ) as Record<string, string>, // Include environment variables
2033
2051
  };
2034
2052
 
@@ -2104,7 +2122,7 @@ export class AIManager {
2104
2122
  subagentType: this.subagentType, // Include subagent type in hook context
2105
2123
  planFilePath: this.permissionManager?.getPlanFilePath(),
2106
2124
  env: Object.fromEntries(
2107
- Object.entries(process.env).filter((e) => e[1] !== undefined),
2125
+ Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2108
2126
  ) as Record<string, string>, // Include environment variables
2109
2127
  };
2110
2128
 
@@ -8,6 +8,7 @@ import { logger } from "../utils/globalLogger.js";
8
8
  import { Container } from "../utils/container.js";
9
9
  import { MessageQueue } from "./messageQueue.js";
10
10
  import { resolveShellPath } from "../utils/shellResolver.js";
11
+ import type { ConfigurationService } from "../services/configurationService.js";
11
12
 
12
13
  export interface BackgroundTaskManagerCallbacks {
13
14
  onBackgroundTasksChange?: (tasks: BackgroundTask[]) => void;
@@ -32,6 +33,19 @@ export class BackgroundTaskManager {
32
33
  this.workdir = options.workdir;
33
34
  }
34
35
 
36
+ /**
37
+ * Merged env (OS env overlaid with this session's settings snapshot) for
38
+ * background-task subprocesses, so settings `env` vars reach them without
39
+ * polluting other sessions in one `wave --stdio` process.
40
+ */
41
+ private get sessionEnv(): Record<string, string> {
42
+ return (
43
+ this.container
44
+ .get<ConfigurationService>("ConfigurationService")
45
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
46
+ );
47
+ }
48
+
35
49
  /**
36
50
  * Fire the onBackgroundTasksChange callback so UI consumers refresh.
37
51
  * Public so other managers (e.g. WorkflowManager) can trigger a refresh
@@ -73,6 +87,7 @@ export class BackgroundTaskManager {
73
87
  cwd: cwd ?? this.workdir,
74
88
  env: {
75
89
  ...process.env,
90
+ ...this.sessionEnv,
76
91
  },
77
92
  });
78
93
 
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "child_process";
2
2
  import type { MessageManager } from "./messageManager.js";
3
3
  import { Container } from "../utils/container.js";
4
4
  import { resolveShellPath } from "../utils/shellResolver.js";
5
+ import type { ConfigurationService } from "../services/configurationService.js";
5
6
 
6
7
  export interface BangManagerOptions {
7
8
  workdir: string;
@@ -29,6 +30,19 @@ export class BangManager {
29
30
  return this.container.get<MessageManager>("MessageManager")!;
30
31
  }
31
32
 
33
+ /**
34
+ * Merged env (OS env overlaid with this session's settings snapshot) for
35
+ * bang-command subprocesses, so settings `env` vars reach them without
36
+ * polluting other sessions in one `wave --stdio` process.
37
+ */
38
+ private get sessionEnv(): Record<string, string> {
39
+ return (
40
+ this.container
41
+ .get<ConfigurationService>("ConfigurationService")
42
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
43
+ );
44
+ }
45
+
32
46
  private setCommandRunning(isRunning: boolean): void {
33
47
  this.isCommandRunning = isRunning;
34
48
  this.onCommandRunningChange?.(isRunning);
@@ -51,6 +65,7 @@ export class BangManager {
51
65
  cwd: this.workdir,
52
66
  env: {
53
67
  ...process.env,
68
+ ...this.sessionEnv,
54
69
  },
55
70
  });
56
71
 
@@ -13,6 +13,7 @@ import {
13
13
  type HookExecutionResult,
14
14
  type HookValidationResult,
15
15
  type SessionEndSource,
16
+ type SessionStartSource,
16
17
  HookConfigurationError,
17
18
  isValidHookEvent,
18
19
  isValidHookEventConfig,
@@ -26,6 +27,7 @@ import { executeCommand, isCommandSafe } from "../services/hook.js";
26
27
  import { MessageSource } from "../types/index.js";
27
28
  import type { MessageManager } from "./messageManager.js";
28
29
  import { Container } from "../utils/container.js";
30
+ import type { ConfigurationService } from "../services/configurationService.js";
29
31
 
30
32
  import { logger } from "../utils/globalLogger.js";
31
33
 
@@ -46,6 +48,19 @@ export class HookManager {
46
48
  this.matcher = matcher;
47
49
  }
48
50
 
51
+ /**
52
+ * Merged env for this session (OS env overlaid with the per-session settings
53
+ * snapshot). Hook subprocesses spawn with this env so settings.json `env`
54
+ * vars reach hooks without polluting other sessions in one stdio process.
55
+ */
56
+ private get sessionEnv(): Record<string, string> {
57
+ return (
58
+ this.container
59
+ .get<ConfigurationService>("ConfigurationService")
60
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
61
+ );
62
+ }
63
+
49
64
  /**
50
65
  * Load hook configuration from programmatic source (AgentOptions.hooks)
51
66
  */
@@ -916,7 +931,7 @@ export class HookManager {
916
931
  * Collects additionalContext and initialUserMessage from hook stdout.
917
932
  */
918
933
  async executeSessionStartHooks(
919
- source: "startup" | "compact" | "clear",
934
+ source: SessionStartSource,
920
935
  sessionId: string,
921
936
  transcriptPath: string,
922
937
  agentType?: string,
@@ -935,7 +950,7 @@ export class HookManager {
935
950
  source,
936
951
  agentType,
937
952
  env: Object.fromEntries(
938
- Object.entries(process.env).filter((e) => e[1] !== undefined),
953
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
939
954
  ) as Record<string, string>,
940
955
  };
941
956
 
@@ -989,7 +1004,7 @@ export class HookManager {
989
1004
  cwd: this.workdir,
990
1005
  endSource: source,
991
1006
  env: Object.fromEntries(
992
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1007
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
993
1008
  ) as Record<string, string>,
994
1009
  };
995
1010
 
@@ -1024,7 +1039,7 @@ export class HookManager {
1024
1039
  cwd: this.workdir,
1025
1040
  compactInstructions: customInstructions,
1026
1041
  env: Object.fromEntries(
1027
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1042
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
1028
1043
  ) as Record<string, string>,
1029
1044
  };
1030
1045
 
@@ -1061,7 +1076,7 @@ export class HookManager {
1061
1076
  cwd: this.workdir,
1062
1077
  compactSummary,
1063
1078
  env: Object.fromEntries(
1064
- Object.entries(process.env).filter((e) => e[1] !== undefined),
1079
+ Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined),
1065
1080
  ) as Record<string, string>,
1066
1081
  };
1067
1082