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
@@ -9,6 +9,7 @@ import { ChatCompletionFunctionTool } from "openai/resources.js";
9
9
  import { createMcpToolPlugin, findToolServer } from "../utils/mcpUtils.js";
10
10
  import type { ToolPlugin, ToolResult, ToolContext } from "../tools/types.js";
11
11
  import { Container } from "../utils/container.js";
12
+ import type { ConfigurationService } from "../services/configurationService.js";
12
13
  import type {
13
14
  Logger,
14
15
  McpServerConfig,
@@ -42,7 +43,21 @@ export interface McpManagerOptions {
42
43
  */
43
44
  const WAVE_TEMPLATE_VARS = ["WAVE_PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"];
44
45
 
45
- export function expandEnvVars(value: string): string {
46
+ /**
47
+ * Expand environment variables in a string value.
48
+ * Supports ${VAR} and ${VAR:-default} patterns.
49
+ *
50
+ * @param env - Merged env to resolve against (session snapshot over OS env).
51
+ * Defaults to `process.env` for backward compatibility with tests
52
+ * and standalone callers.
53
+ */
54
+ export function expandEnvVars(
55
+ value: string,
56
+ env: Record<string, string | undefined> = process.env as Record<
57
+ string,
58
+ string | undefined
59
+ >,
60
+ ): string {
46
61
  return value.replace(/\$\{([^}]+)\}/g, (_match, expr: string) => {
47
62
  const [varName, ...rest] = expr.split(":-");
48
63
  const defaultValue = rest.join(":-");
@@ -50,45 +65,53 @@ export function expandEnvVars(value: string): string {
50
65
  if (WAVE_TEMPLATE_VARS.includes(varName)) {
51
66
  return _match; // return original ${...} string untouched
52
67
  }
53
- return process.env[varName] ?? defaultValue;
68
+ return env[varName] ?? process.env[varName] ?? defaultValue;
54
69
  });
55
70
  }
56
71
 
57
72
  /**
58
73
  * Walk an MCP config and resolve environment variables in all string fields.
59
- * Only expands ${VAR} from process.env (skipping WAVE_PLUGIN_ROOT which is
60
- * handled at spawn time).
74
+ * Expands ${VAR} against the session env snapshot (over OS env); falls back to
75
+ * process.env. WAVE_PLUGIN_ROOT is skipped — handled at spawn time.
61
76
  */
62
- export function resolveMcpConfig(config: McpConfig): McpConfig {
77
+ export function resolveMcpConfig(
78
+ config: McpConfig,
79
+ env: Record<string, string | undefined> = process.env as Record<
80
+ string,
81
+ string | undefined
82
+ >,
83
+ ): McpConfig {
63
84
  const resolved: McpConfig = { mcpServers: {} };
64
85
 
65
86
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
66
87
  const resolvedServer: McpServerConfig = { ...serverConfig };
67
88
 
68
89
  if (resolvedServer.command) {
69
- resolvedServer.command = expandEnvVars(resolvedServer.command);
90
+ resolvedServer.command = expandEnvVars(resolvedServer.command, env);
70
91
  }
71
92
 
72
93
  if (resolvedServer.args) {
73
- resolvedServer.args = resolvedServer.args.map(expandEnvVars);
94
+ resolvedServer.args = resolvedServer.args.map((a) =>
95
+ expandEnvVars(a, env),
96
+ );
74
97
  }
75
98
 
76
99
  if (resolvedServer.env) {
77
100
  const resolvedEnv: Record<string, string> = {};
78
101
  for (const [key, val] of Object.entries(resolvedServer.env)) {
79
- resolvedEnv[key] = expandEnvVars(val);
102
+ resolvedEnv[key] = expandEnvVars(val, env);
80
103
  }
81
104
  resolvedServer.env = resolvedEnv;
82
105
  }
83
106
 
84
107
  if (resolvedServer.url) {
85
- resolvedServer.url = expandEnvVars(resolvedServer.url);
108
+ resolvedServer.url = expandEnvVars(resolvedServer.url, env);
86
109
  }
87
110
 
88
111
  if (resolvedServer.headers) {
89
112
  const resolvedHeaders: Record<string, string> = {};
90
113
  for (const [key, val] of Object.entries(resolvedServer.headers)) {
91
- resolvedHeaders[key] = expandEnvVars(val);
114
+ resolvedHeaders[key] = expandEnvVars(val, env);
92
115
  }
93
116
  resolvedServer.headers = resolvedHeaders;
94
117
  }
@@ -119,6 +142,19 @@ export class McpManager {
119
142
  this.mcpServers = options.mcpServers;
120
143
  }
121
144
 
145
+ /**
146
+ * Merged env for this session: OS env overlaid with the per-session settings
147
+ * snapshot. MCP env-var expansion (${VAR}) resolves against this so multiple
148
+ * sessions in one `wave --stdio` process don't read each other's settings env.
149
+ */
150
+ private get envSnapshot(): Record<string, string> {
151
+ return (
152
+ this.container
153
+ .get<ConfigurationService>("ConfigurationService")
154
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
155
+ );
156
+ }
157
+
122
158
  /**
123
159
  * Initialize MCP manager with working directory and optionally auto-connect
124
160
  */
@@ -189,7 +225,7 @@ export class McpManager {
189
225
  try {
190
226
  const configContent = await fs.readFile(this.configPath, "utf-8");
191
227
  const rawConfig: McpConfig = JSON.parse(configContent);
192
- const workspaceConfig = resolveMcpConfig(rawConfig);
228
+ const workspaceConfig = resolveMcpConfig(rawConfig, this.envSnapshot);
193
229
 
194
230
  // Extract original (pre-resolution) URLs for safe display
195
231
  const originalUrls: Record<string, string | undefined> = {};
@@ -284,28 +320,31 @@ export class McpManager {
284
320
  // Capture original URL before any resolution for safe display
285
321
  const originalUrl = config.url;
286
322
 
287
- // Expand env vars from process.env (e.g. ${TAVILY_API_KEY})
323
+ // Expand env vars against the session snapshot (over OS env, e.g. ${TAVILY_API_KEY})
324
+ const env = this.envSnapshot;
288
325
  const resolvedConfig: McpServerConfig = { ...config };
289
326
  if (resolvedConfig.command) {
290
- resolvedConfig.command = expandEnvVars(resolvedConfig.command);
327
+ resolvedConfig.command = expandEnvVars(resolvedConfig.command, env);
291
328
  }
292
329
  if (resolvedConfig.args) {
293
- resolvedConfig.args = resolvedConfig.args.map(expandEnvVars);
330
+ resolvedConfig.args = resolvedConfig.args.map((a) =>
331
+ expandEnvVars(a, env),
332
+ );
294
333
  }
295
334
  if (resolvedConfig.env) {
296
335
  const resolvedEnv: Record<string, string> = {};
297
336
  for (const [key, val] of Object.entries(resolvedConfig.env)) {
298
- resolvedEnv[key] = expandEnvVars(val);
337
+ resolvedEnv[key] = expandEnvVars(val, env);
299
338
  }
300
339
  resolvedConfig.env = resolvedEnv;
301
340
  }
302
341
  if (resolvedConfig.url) {
303
- resolvedConfig.url = expandEnvVars(resolvedConfig.url);
342
+ resolvedConfig.url = expandEnvVars(resolvedConfig.url, env);
304
343
  }
305
344
  if (resolvedConfig.headers) {
306
345
  const resolvedHeaders: Record<string, string> = {};
307
346
  for (const [key, val] of Object.entries(resolvedConfig.headers)) {
308
- resolvedHeaders[key] = expandEnvVars(val);
347
+ resolvedHeaders[key] = expandEnvVars(val, env);
309
348
  }
310
349
  resolvedConfig.headers = resolvedHeaders;
311
350
  }
@@ -433,8 +472,11 @@ export class McpManager {
433
472
  );
434
473
  }
435
474
 
475
+ // Base env = OS env overlaid with this session's settings snapshot, so
476
+ // custom env vars from settings.json reach the MCP server subprocess
477
+ // without polluting other sessions sharing one `wave --stdio` process.
436
478
  const env: Record<string, string> = {
437
- ...(process.env as Record<string, string>),
479
+ ...this.envSnapshot,
438
480
  ...(server.config.env || {}),
439
481
  };
440
482
 
@@ -253,6 +253,13 @@ class ToolManager {
253
253
  "WorkflowManager",
254
254
  )
255
255
  : undefined,
256
+ sessionEnv: this.container.has("ConfigurationService")
257
+ ? this.container
258
+ .get<
259
+ import("../services/configurationService.js").ConfigurationService
260
+ >("ConfigurationService")
261
+ ?.getMergedEnv?.()
262
+ : undefined,
256
263
  sessionId: context.sessionId,
257
264
  toolCallId: context.toolCallId,
258
265
  };
@@ -61,6 +61,10 @@ export class ConfigurationService {
61
61
  private currentConfiguration: WaveConfiguration | null = null;
62
62
  private options: AgentOptions = {};
63
63
  private _configuredEnvKeys = new Set<string>();
64
+ // Per-session environment snapshot: settings.json `env` is stored here (NOT
65
+ // written to process.env) so multiple sessions in one `wave --stdio` process
66
+ // don't cross-pollute. Resolve methods read `this.envSnapshot ?? process.env`.
67
+ private envSnapshot: Record<string, string> = {};
64
68
 
65
69
  /**
66
70
  * Set agent options for configuration resolution
@@ -69,6 +73,28 @@ export class ConfigurationService {
69
73
  this.options = options;
70
74
  }
71
75
 
76
+ /**
77
+ * Returns a copy of the per-session environment snapshot (settings.json `env`).
78
+ * Priority over OS env; does NOT include OS env. For subprocess spawning use
79
+ * {@link getMergedEnv} instead.
80
+ */
81
+ getEnvSnapshot(): Record<string, string> {
82
+ return { ...this.envSnapshot };
83
+ }
84
+
85
+ /**
86
+ * Returns OS env merged with the session snapshot (snapshot wins). Use this
87
+ * when spawning user-facing subprocesses (bash, hooks, bang, background, MCP)
88
+ * so they inherit both OS env and the session's settings env.
89
+ */
90
+ getMergedEnv(): Record<string, string> {
91
+ return Object.fromEntries(
92
+ Object.entries({ ...process.env, ...this.envSnapshot }).filter(
93
+ ([, v]) => v !== undefined,
94
+ ),
95
+ ) as Record<string, string>;
96
+ }
97
+
72
98
  // Core loading operations
73
99
 
74
100
  /**
@@ -384,16 +410,33 @@ export class ConfigurationService {
384
410
  // Utility operations
385
411
 
386
412
  /**
387
- * Set environment variables from configuration
388
- * This replaces direct process.env modification
413
+ * Store environment variables from configuration into the per-session
414
+ * snapshot (NOT process.env). Settings `env` shadows OS env for this session
415
+ * only — multiple sessions in one stdio process stay isolated.
416
+ *
417
+ * Exception: `WAVE_SERVER_URL` is also mirrored to `process.env` because the
418
+ * process-level singletons (AuthService, remoteSettingsService background
419
+ * fetch) need to read it and don't hold a per-session snapshot. Same value
420
+ * across sessions ⇒ no cross-pollution. See docs/specs/core/agent-config.md.
389
421
  */
390
422
  setEnvironmentVars(env: Record<string, string>): void {
391
423
  for (const [key, value] of Object.entries(env)) {
392
- if (process.env[key] !== undefined && !this._configuredEnvKeys.has(key)) {
424
+ if (
425
+ process.env[key] !== undefined &&
426
+ !this._configuredEnvKeys.has(key) &&
427
+ process.env[key] !== value
428
+ ) {
393
429
  logger.warn(`Overriding environment variable: ${key}`);
394
430
  }
395
- process.env[key] = value;
431
+ this.envSnapshot[key] = value;
396
432
  this._configuredEnvKeys.add(key);
433
+ // WAVE_SERVER_URL is consumed by process-level singletons (AuthService,
434
+ // remoteSettingsService) that can't see the per-session snapshot — mirror
435
+ // it to process.env so they read the settings value. Same value across
436
+ // sessions ⇒ no last-session-wins cross-pollution.
437
+ if (key === "WAVE_SERVER_URL") {
438
+ process.env[key] = value;
439
+ }
397
440
  }
398
441
  }
399
442
 
@@ -437,8 +480,11 @@ export class ConfigurationService {
437
480
  fetchOptions?: ClientOptions["fetchOptions"],
438
481
  fetch?: ClientOptions["fetch"],
439
482
  ): GatewayConfig {
440
- // Check for SSO token first - if present and server URL is available, use SSO mode
441
- // Server URL resolution: options > process.env > default
483
+ // Check for SSO token first - if present and server URL is available, use SSO mode.
484
+ // Server URL resolution: options.serverUrl > process.env.WAVE_SERVER_URL > default.
485
+ // settings.json `env` WAVE_SERVER_URL is mirrored to process.env by
486
+ // setEnvironmentVars (so process-level singletons AuthService / remoteSettings
487
+ // can read it), then read here. See docs/specs/core/agent-config.md.
442
488
  const ssoToken = this.readSSOToken();
443
489
  const serverUrl =
444
490
  this.options.serverUrl ||
@@ -469,7 +515,8 @@ export class ConfigurationService {
469
515
  } else if (this.options.apiKey !== undefined) {
470
516
  resolvedApiKey = this.options.apiKey;
471
517
  } else {
472
- resolvedApiKey = process.env.WAVE_API_KEY;
518
+ resolvedApiKey =
519
+ this.envSnapshot.WAVE_API_KEY ?? process.env.WAVE_API_KEY;
473
520
  }
474
521
 
475
522
  // Resolve base URL: override > options > env (settings.json) > process.env
@@ -480,15 +527,18 @@ export class ConfigurationService {
480
527
  } else if (this.options.baseURL !== undefined) {
481
528
  resolvedBaseURL = this.options.baseURL;
482
529
  } else {
483
- resolvedBaseURL = process.env.WAVE_BASE_URL;
530
+ resolvedBaseURL =
531
+ this.envSnapshot.WAVE_BASE_URL ?? process.env.WAVE_BASE_URL;
484
532
  }
485
533
 
486
534
  // Fallback to process.env if still not resolved (for dynamic updates in tests)
487
535
  if (resolvedApiKey === undefined) {
488
- resolvedApiKey = process.env.WAVE_API_KEY;
536
+ resolvedApiKey =
537
+ this.envSnapshot.WAVE_API_KEY ?? process.env.WAVE_API_KEY;
489
538
  }
490
539
  if (!resolvedBaseURL) {
491
- resolvedBaseURL = process.env.WAVE_BASE_URL;
540
+ resolvedBaseURL =
541
+ this.envSnapshot.WAVE_BASE_URL ?? process.env.WAVE_BASE_URL;
492
542
  }
493
543
 
494
544
  // Treat empty string as not provided
@@ -497,7 +547,10 @@ export class ConfigurationService {
497
547
  }
498
548
 
499
549
  // Resolve custom headers from environment: env (settings.json) > process.env
500
- const envCustomHeaders = process.env.WAVE_CUSTOM_HEADERS || "";
550
+ const envCustomHeaders =
551
+ this.envSnapshot.WAVE_CUSTOM_HEADERS ??
552
+ process.env.WAVE_CUSTOM_HEADERS ??
553
+ "";
501
554
  const parsedEnvHeaders = parseCustomHeaders(envCustomHeaders);
502
555
 
503
556
  // Merge headers: env headers < options < override
@@ -539,11 +592,13 @@ export class ConfigurationService {
539
592
  model ||
540
593
  this.options.model ||
541
594
  this.currentConfiguration?.model ||
542
- process.env.WAVE_MODEL;
595
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
543
596
 
544
597
  // Resolve fast model: override > options > process.env (includes settings.json env)
545
598
  const resolvedFastModel =
546
- fastModel || this.options.fastModel || process.env.WAVE_FAST_MODEL;
599
+ fastModel ||
600
+ this.options.fastModel ||
601
+ (this.envSnapshot.WAVE_FAST_MODEL ?? process.env.WAVE_FAST_MODEL);
547
602
 
548
603
  // Resolve max output tokens
549
604
  const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
@@ -596,8 +651,10 @@ export class ConfigurationService {
596
651
  return this.options.maxInputTokens;
597
652
  }
598
653
 
599
- // Try env (settings.json) first, then process.env
600
- const envMaxInputTokens = process.env.WAVE_MAX_INPUT_TOKENS;
654
+ // Try env (settings.json snapshot) first, then process.env
655
+ const envMaxInputTokens =
656
+ this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
657
+ process.env.WAVE_MAX_INPUT_TOKENS;
601
658
  if (envMaxInputTokens) {
602
659
  const parsed = parseInt(envMaxInputTokens, 10);
603
660
  if (!isNaN(parsed)) {
@@ -645,9 +702,9 @@ export class ConfigurationService {
645
702
  return this.currentConfiguration.autoMemoryEnabled;
646
703
  }
647
704
 
648
- // 2. WAVE_DISABLE_AUTO_MEMORY environment variable
705
+ // 2. WAVE_DISABLE_AUTO_MEMORY environment variable (settings snapshot > OS env)
649
706
  const disableAutoMemory =
650
- process.env.WAVE_DISABLE_AUTO_MEMORY ||
707
+ this.envSnapshot.WAVE_DISABLE_AUTO_MEMORY ??
651
708
  process.env.WAVE_DISABLE_AUTO_MEMORY;
652
709
  if (disableAutoMemory === "1" || disableAutoMemory === "true") {
653
710
  return false;
@@ -681,9 +738,9 @@ export class ConfigurationService {
681
738
  return this.currentConfiguration.autoMemoryFrequency;
682
739
  }
683
740
 
684
- // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable
741
+ // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable (settings snapshot > OS env)
685
742
  const envFrequency =
686
- process.env.WAVE_AUTO_MEMORY_FREQUENCY ||
743
+ this.envSnapshot.WAVE_AUTO_MEMORY_FREQUENCY ??
687
744
  process.env.WAVE_AUTO_MEMORY_FREQUENCY;
688
745
  if (envFrequency) {
689
746
  const parsed = parseInt(envFrequency, 10);
@@ -713,8 +770,10 @@ export class ConfigurationService {
713
770
  return this.options.maxTokens;
714
771
  }
715
772
 
716
- // Try env (settings.json) first, then process.env
717
- const envMaxOutputTokens = process.env.WAVE_MAX_OUTPUT_TOKENS;
773
+ // Try env (settings.json snapshot) first, then process.env
774
+ const envMaxOutputTokens =
775
+ this.envSnapshot.WAVE_MAX_OUTPUT_TOKENS ??
776
+ process.env.WAVE_MAX_OUTPUT_TOKENS;
718
777
  if (envMaxOutputTokens) {
719
778
  const parsed = parseInt(envMaxOutputTokens, 10);
720
779
  if (!isNaN(parsed) && parsed > 0) {
@@ -761,8 +820,10 @@ export class ConfigurationService {
761
820
  getConfiguredModels(): string[] {
762
821
  const models = new Set<string>();
763
822
 
764
- // Add current model from options or environment
765
- const currentModel = this.options.model || process.env.WAVE_MODEL;
823
+ // Add current model from options or environment (settings snapshot > OS env)
824
+ const currentModel =
825
+ this.options.model ||
826
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
766
827
  if (currentModel) {
767
828
  models.add(currentModel);
768
829
  }
@@ -126,13 +126,17 @@ export class InitializationService {
126
126
  // Don't throw error to prevent app startup failure
127
127
  }
128
128
 
129
- // Initialize remote settings (load disk cache synchronously, then fetch in background)
130
- // Must happen BEFORE loadMergedConfiguration so remote env vars are available
129
+ // Load remote settings disk cache synchronously.
130
+ // Must happen BEFORE loadMergedConfiguration so cached managed settings
131
+ // (env, model, disallowedTools) are merged into the config. Settings `env`
132
+ // is stored in the per-session env snapshot (NOT process.env), except
133
+ // WAVE_SERVER_URL which is mirrored to process.env so the network fetch
134
+ // (below) can read it via authService.getServerUrl(); no race.
131
135
  try {
132
136
  const phaseStart = performance.now();
133
137
  await remoteSettingsService.initialize();
134
138
  logger?.debug(
135
- `Initialization Phase [Remote Settings] took ${(performance.now() - phaseStart).toFixed(2)}ms`,
139
+ `Initialization Phase [Remote Settings Cache] took ${(performance.now() - phaseStart).toFixed(2)}ms`,
136
140
  );
137
141
  } catch (error) {
138
142
  logger?.error("Failed to initialize remote settings:", error);
@@ -190,6 +194,13 @@ export class InitializationService {
190
194
  // Don't throw error to prevent app startup failure
191
195
  }
192
196
 
197
+ // Start remote settings network fetch + polling now that the config is
198
+ // merged. Settings `env` WAVE_SERVER_URL was mirrored to process.env by
199
+ // loadMergedConfiguration → setEnvironmentVars, so the fetch reads it via
200
+ // authService.getServerUrl(); fire-and-forget, failures fall back to the
201
+ // cached/merged settings.
202
+ remoteSettingsService.startBackgroundFetch();
203
+
193
204
  // Execute SessionStart hooks
194
205
  try {
195
206
  const phaseStart = performance.now();
@@ -237,7 +248,9 @@ export class InitializationService {
237
248
  cwd: workdir,
238
249
  worktreeName: agentOptions.worktreeName,
239
250
  env: Object.fromEntries(
240
- Object.entries(process.env).filter((e) => e[1] !== undefined),
251
+ Object.entries(configurationService.getMergedEnv()).filter(
252
+ (e) => e[1] !== undefined,
253
+ ),
241
254
  ) as Record<string, string>,
242
255
  });
243
256
 
@@ -93,7 +93,9 @@ export class InteractionService {
93
93
  cwd: workdir,
94
94
  userPrompt: content,
95
95
  env: Object.fromEntries(
96
- Object.entries(process.env).filter((e) => e[1] !== undefined),
96
+ Object.entries(
97
+ context.configurationService.getMergedEnv(),
98
+ ).filter((e) => e[1] !== undefined),
97
99
  ) as Record<string, string>, // Include environment variables
98
100
  },
99
101
  );
@@ -137,6 +139,7 @@ export class InteractionService {
137
139
  ): Promise<void> {
138
140
  const {
139
141
  messageManager,
142
+ hookManager,
140
143
  logger,
141
144
  subagentManager,
142
145
  taskManager,
@@ -157,7 +160,24 @@ export class InteractionService {
157
160
  // Continue with restoration even if save fails
158
161
  }
159
162
 
160
- // 3. Load target session
163
+ // 3. Run SessionEnd hooks for the current session (cleanup before switching)
164
+ const currentSessionId = messageManager.getSessionId();
165
+ const currentTranscriptPath = messageManager.getTranscriptPath();
166
+ if (hookManager) {
167
+ try {
168
+ await hookManager.executeSessionEndHooks(
169
+ "resume",
170
+ currentSessionId,
171
+ currentTranscriptPath,
172
+ );
173
+ } catch (error) {
174
+ logger?.warn(
175
+ `SessionEnd hooks on restore failed: ${(error as Error).message}`,
176
+ );
177
+ }
178
+ }
179
+
180
+ // 4. Load target session
161
181
  const sessionData = await loadSessionFromJsonl(
162
182
  sessionId,
163
183
  messageManager.getWorkdir(),
@@ -166,20 +186,43 @@ export class InteractionService {
166
186
  throw new Error(`Session not found: ${sessionId}`);
167
187
  }
168
188
 
169
- // 4. Clean current state
189
+ // 5. Clean current state
170
190
  abortMessage(); // Abort any running operations
171
191
  subagentManager.cleanup(); // Clean up active subagents
172
192
 
173
- // 5. Rebuild usage (in correct order)
193
+ // 6. Rebuild usage (in correct order)
174
194
  messageManager.rebuildUsageFromMessages(sessionData.messages);
175
195
 
176
- // 6. Initialize session state last
196
+ // 7. Initialize session state last
177
197
  messageManager.initializeFromSession(sessionData);
178
198
 
199
+ // 8. Run SessionStart hooks for the restored session and inject additional
200
+ // context as a meta user message (matches Claude Code's resume behavior:
201
+ // SessionEnd then SessionStart, hook messages appended to the conversation)
202
+ if (hookManager) {
203
+ try {
204
+ const sessionStartResult = await hookManager.executeSessionStartHooks(
205
+ "resume",
206
+ sessionData.id,
207
+ messageManager.getTranscriptPath(),
208
+ );
209
+ if (sessionStartResult.additionalContext) {
210
+ messageManager.addUserMessage({
211
+ content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
212
+ isMeta: true,
213
+ });
214
+ }
215
+ } catch (error) {
216
+ logger?.warn(
217
+ `SessionStart hooks on restore failed: ${(error as Error).message}`,
218
+ );
219
+ }
220
+ }
221
+
179
222
  // Update task manager with the root session ID to ensure continuity across compactions
180
223
  taskManager.setTaskListId(sessionData.id);
181
224
 
182
- // 7. Load tasks for the restored session
225
+ // 9. Load tasks for the restored session
183
226
  const tasks = await taskManager.listTasks();
184
227
  options.callbacks?.onTasksChange?.(tasks);
185
228
  }
@@ -198,8 +198,22 @@ function startPolling(): void {
198
198
  }
199
199
 
200
200
  export function initialize(): void {
201
+ // Load disk cache synchronously so getRemoteSettingsSync() returns cached
202
+ // managed settings during loadMergedConfiguration() (must run BEFORE it).
201
203
  loadCacheFromDisk();
202
- // Fire-and-forget the initial fetch, then start background polling
204
+ }
205
+
206
+ /**
207
+ * Start the fire-and-forget initial network fetch + background polling.
208
+ *
209
+ * Must be called AFTER loadMergedConfiguration() so disk-cached managed
210
+ * settings are merged before the fetch, and so settings `env` WAVE_SERVER_URL
211
+ * is mirrored to process.env (by setEnvironmentVars) before the fetch. The
212
+ * fetch uses authService.getServerUrl(), which reads process.env.WAVE_SERVER_URL
213
+ * — the settings value is visible there, so there is no init-ordering race
214
+ * that would fall back to DEFAULT_SERVER_URL (prod) and hit a test endpoint (401).
215
+ */
216
+ export function startBackgroundFetch(): void {
203
217
  fetchRemoteSettings()
204
218
  .then(() => startPolling())
205
219
  .catch((err) => {
@@ -342,6 +356,7 @@ export function mergeRemoteSettings(
342
356
  */
343
357
  export const remoteSettingsService = {
344
358
  initialize,
359
+ startBackgroundFetch,
345
360
  getRemoteSettingsSync,
346
361
  refresh,
347
362
  clear,
@@ -6,6 +6,7 @@ import { Task } from "../types/tasks.js";
6
6
  import { logger } from "../utils/globalLogger.js";
7
7
  import { Container } from "../utils/container.js";
8
8
  import type { MessageManager } from "../managers/messageManager.js";
9
+ import type { ConfigurationService } from "./configurationService.js";
9
10
 
10
11
  function byIdAsc(a: Task, b: Task) {
11
12
  const aNum = parseInt(a.id, 10);
@@ -44,7 +45,15 @@ export class TaskManager extends EventEmitter {
44
45
  if (!messageManager) return;
45
46
 
46
47
  const rootSessionId = messageManager.getRootSessionId();
47
- if (this.taskListId !== rootSessionId && !process.env.WAVE_TASK_LIST_ID) {
48
+ // Read the per-session snapshot (not process.env) so multiple sessions in
49
+ // one `wave --stdio` process don't cross-pollute this flag.
50
+ const envSnap =
51
+ this.container
52
+ .get<ConfigurationService>("ConfigurationService")
53
+ ?.getEnvSnapshot() ?? {};
54
+ const pinnedTaskListId =
55
+ envSnap.WAVE_TASK_LIST_ID ?? process.env.WAVE_TASK_LIST_ID;
56
+ if (this.taskListId !== rootSessionId && !pinnedTaskListId) {
48
57
  this.setTaskListId(rootSessionId);
49
58
  await this.refreshTasks();
50
59
  }
@@ -285,6 +285,7 @@ The working directory persists between commands. Try to maintain your current wo
285
285
  cwd: context.workdir,
286
286
  env: {
287
287
  ...process.env,
288
+ ...context.sessionEnv,
288
289
  },
289
290
  });
290
291
 
@@ -9,6 +9,7 @@ import {
9
9
  createWorktree,
10
10
  validateWorktreeName,
11
11
  generateWorktreeName,
12
+ performPostCreationSetup,
12
13
  } from "../utils/worktreeUtils.js";
13
14
  import { getGitMainRepoRoot } from "../utils/gitUtils.js";
14
15
  import { ENTER_WORKTREE_TOOL_NAME } from "../constants/tools.js";
@@ -105,6 +106,20 @@ export const enterWorktreeTool: ToolPlugin = {
105
106
  const baseRef = context.aiManager?.getWorktreeBaseRef?.();
106
107
  const worktreeInfo = createWorktree(name, mainRepoRoot, { baseRef });
107
108
 
109
+ // Copy local settings (.wave/settings.local.json) and gitignored project
110
+ // files (.worktreeinclude, e.g. .env/.mcp.json) into a new worktree —
111
+ // mirrors the CLI createWorktree path. Best-effort, never fails the tool.
112
+ if (worktreeInfo.isNew) {
113
+ try {
114
+ await performPostCreationSetup(
115
+ worktreeInfo.path,
116
+ worktreeInfo.repoRoot,
117
+ );
118
+ } catch (error) {
119
+ logger?.warn("Worktree post-creation setup failed:", error);
120
+ }
121
+ }
122
+
108
123
  // Build session state
109
124
  const session: WorktreeSession = {
110
125
  originalCwd: context.workdir,
@@ -134,11 +149,13 @@ export const enterWorktreeTool: ToolPlugin = {
134
149
  projectDir: worktreeInfo.path,
135
150
  timestamp: new Date(),
136
151
  sessionId: context.sessionId ?? "",
137
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
152
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
138
153
  cwd: worktreeInfo.path,
139
154
  worktreeName: worktreeInfo.name,
140
155
  env: Object.fromEntries(
141
- Object.entries(process.env).filter((e) => e[1] !== undefined),
156
+ Object.entries(context.sessionEnv ?? process.env).filter(
157
+ (e) => e[1] !== undefined,
158
+ ),
142
159
  ) as Record<string, string>,
143
160
  },
144
161
  );