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
@@ -18,10 +18,23 @@ export declare class ConfigurationService {
18
18
  private currentConfiguration;
19
19
  private options;
20
20
  private _configuredEnvKeys;
21
+ private envSnapshot;
21
22
  /**
22
23
  * Set agent options for configuration resolution
23
24
  */
24
25
  setOptions(options: AgentOptions): void;
26
+ /**
27
+ * Returns a copy of the per-session environment snapshot (settings.json `env`).
28
+ * Priority over OS env; does NOT include OS env. For subprocess spawning use
29
+ * {@link getMergedEnv} instead.
30
+ */
31
+ getEnvSnapshot(): Record<string, string>;
32
+ /**
33
+ * Returns OS env merged with the session snapshot (snapshot wins). Use this
34
+ * when spawning user-facing subprocesses (bash, hooks, bang, background, MCP)
35
+ * so they inherit both OS env and the session's settings env.
36
+ */
37
+ getMergedEnv(): Record<string, string>;
25
38
  /**
26
39
  * Load and merge configuration with comprehensive validation
27
40
  */
@@ -35,8 +48,14 @@ export declare class ConfigurationService {
35
48
  */
36
49
  validateConfigurationFile(filePath: string): ValidationResult;
37
50
  /**
38
- * Set environment variables from configuration
39
- * This replaces direct process.env modification
51
+ * Store environment variables from configuration into the per-session
52
+ * snapshot (NOT process.env). Settings `env` shadows OS env for this session
53
+ * only — multiple sessions in one stdio process stay isolated.
54
+ *
55
+ * Exception: `WAVE_SERVER_URL` is also mirrored to `process.env` because the
56
+ * process-level singletons (AuthService, remoteSettingsService background
57
+ * fetch) need to read it and don't hold a per-session snapshot. Same value
58
+ * across sessions ⇒ no cross-pollution. See docs/specs/core/agent-config.md.
40
59
  */
41
60
  setEnvironmentVars(env: Record<string, string>): void;
42
61
  /**
@@ -28,6 +28,10 @@ export class ConfigurationService {
28
28
  this.currentConfiguration = null;
29
29
  this.options = {};
30
30
  this._configuredEnvKeys = new Set();
31
+ // Per-session environment snapshot: settings.json `env` is stored here (NOT
32
+ // written to process.env) so multiple sessions in one `wave --stdio` process
33
+ // don't cross-pollute. Resolve methods read `this.envSnapshot ?? process.env`.
34
+ this.envSnapshot = {};
31
35
  }
32
36
  /**
33
37
  * Set agent options for configuration resolution
@@ -35,6 +39,22 @@ export class ConfigurationService {
35
39
  setOptions(options) {
36
40
  this.options = options;
37
41
  }
42
+ /**
43
+ * Returns a copy of the per-session environment snapshot (settings.json `env`).
44
+ * Priority over OS env; does NOT include OS env. For subprocess spawning use
45
+ * {@link getMergedEnv} instead.
46
+ */
47
+ getEnvSnapshot() {
48
+ return { ...this.envSnapshot };
49
+ }
50
+ /**
51
+ * Returns OS env merged with the session snapshot (snapshot wins). Use this
52
+ * when spawning user-facing subprocesses (bash, hooks, bang, background, MCP)
53
+ * so they inherit both OS env and the session's settings env.
54
+ */
55
+ getMergedEnv() {
56
+ return Object.fromEntries(Object.entries({ ...process.env, ...this.envSnapshot }).filter(([, v]) => v !== undefined));
57
+ }
38
58
  // Core loading operations
39
59
  /**
40
60
  * Load and merge configuration with comprehensive validation
@@ -292,16 +312,31 @@ export class ConfigurationService {
292
312
  }
293
313
  // Utility operations
294
314
  /**
295
- * Set environment variables from configuration
296
- * This replaces direct process.env modification
315
+ * Store environment variables from configuration into the per-session
316
+ * snapshot (NOT process.env). Settings `env` shadows OS env for this session
317
+ * only — multiple sessions in one stdio process stay isolated.
318
+ *
319
+ * Exception: `WAVE_SERVER_URL` is also mirrored to `process.env` because the
320
+ * process-level singletons (AuthService, remoteSettingsService background
321
+ * fetch) need to read it and don't hold a per-session snapshot. Same value
322
+ * across sessions ⇒ no cross-pollution. See docs/specs/core/agent-config.md.
297
323
  */
298
324
  setEnvironmentVars(env) {
299
325
  for (const [key, value] of Object.entries(env)) {
300
- if (process.env[key] !== undefined && !this._configuredEnvKeys.has(key)) {
326
+ if (process.env[key] !== undefined &&
327
+ !this._configuredEnvKeys.has(key) &&
328
+ process.env[key] !== value) {
301
329
  logger.warn(`Overriding environment variable: ${key}`);
302
330
  }
303
- process.env[key] = value;
331
+ this.envSnapshot[key] = value;
304
332
  this._configuredEnvKeys.add(key);
333
+ // WAVE_SERVER_URL is consumed by process-level singletons (AuthService,
334
+ // remoteSettingsService) that can't see the per-session snapshot — mirror
335
+ // it to process.env so they read the settings value. Same value across
336
+ // sessions ⇒ no last-session-wins cross-pollution.
337
+ if (key === "WAVE_SERVER_URL") {
338
+ process.env[key] = value;
339
+ }
305
340
  }
306
341
  }
307
342
  // =============================================================================
@@ -337,8 +372,11 @@ export class ConfigurationService {
337
372
  * @returns Resolved model configuration (model/fastModel may be undefined if not yet configured)
338
373
  */
339
374
  resolveGatewayConfig(apiKey, baseURL, defaultHeaders, fetchOptions, fetch) {
340
- // Check for SSO token first - if present and server URL is available, use SSO mode
341
- // Server URL resolution: options > process.env > default
375
+ // Check for SSO token first - if present and server URL is available, use SSO mode.
376
+ // Server URL resolution: options.serverUrl > process.env.WAVE_SERVER_URL > default.
377
+ // settings.json `env` WAVE_SERVER_URL is mirrored to process.env by
378
+ // setEnvironmentVars (so process-level singletons AuthService / remoteSettings
379
+ // can read it), then read here. See docs/specs/core/agent-config.md.
342
380
  const ssoToken = this.readSSOToken();
343
381
  const serverUrl = this.options.serverUrl ||
344
382
  process.env.WAVE_SERVER_URL ||
@@ -366,7 +404,8 @@ export class ConfigurationService {
366
404
  resolvedApiKey = this.options.apiKey;
367
405
  }
368
406
  else {
369
- resolvedApiKey = process.env.WAVE_API_KEY;
407
+ resolvedApiKey =
408
+ this.envSnapshot.WAVE_API_KEY ?? process.env.WAVE_API_KEY;
370
409
  }
371
410
  // Resolve base URL: override > options > env (settings.json) > process.env
372
411
  // Note: Explicitly provided empty strings should be treated as invalid, not fall back to env
@@ -378,21 +417,26 @@ export class ConfigurationService {
378
417
  resolvedBaseURL = this.options.baseURL;
379
418
  }
380
419
  else {
381
- resolvedBaseURL = process.env.WAVE_BASE_URL;
420
+ resolvedBaseURL =
421
+ this.envSnapshot.WAVE_BASE_URL ?? process.env.WAVE_BASE_URL;
382
422
  }
383
423
  // Fallback to process.env if still not resolved (for dynamic updates in tests)
384
424
  if (resolvedApiKey === undefined) {
385
- resolvedApiKey = process.env.WAVE_API_KEY;
425
+ resolvedApiKey =
426
+ this.envSnapshot.WAVE_API_KEY ?? process.env.WAVE_API_KEY;
386
427
  }
387
428
  if (!resolvedBaseURL) {
388
- resolvedBaseURL = process.env.WAVE_BASE_URL;
429
+ resolvedBaseURL =
430
+ this.envSnapshot.WAVE_BASE_URL ?? process.env.WAVE_BASE_URL;
389
431
  }
390
432
  // Treat empty string as not provided
391
433
  if (resolvedBaseURL?.trim() === "") {
392
434
  resolvedBaseURL = undefined;
393
435
  }
394
436
  // Resolve custom headers from environment: env (settings.json) > process.env
395
- const envCustomHeaders = process.env.WAVE_CUSTOM_HEADERS || "";
437
+ const envCustomHeaders = this.envSnapshot.WAVE_CUSTOM_HEADERS ??
438
+ process.env.WAVE_CUSTOM_HEADERS ??
439
+ "";
396
440
  const parsedEnvHeaders = parseCustomHeaders(envCustomHeaders);
397
441
  // Merge headers: env headers < options < override
398
442
  const resolvedHeaders = {
@@ -424,9 +468,11 @@ export class ConfigurationService {
424
468
  const resolvedAgentModel = model ||
425
469
  this.options.model ||
426
470
  this.currentConfiguration?.model ||
427
- process.env.WAVE_MODEL;
471
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
428
472
  // Resolve fast model: override > options > process.env (includes settings.json env)
429
- const resolvedFastModel = fastModel || this.options.fastModel || process.env.WAVE_FAST_MODEL;
473
+ const resolvedFastModel = fastModel ||
474
+ this.options.fastModel ||
475
+ (this.envSnapshot.WAVE_FAST_MODEL ?? process.env.WAVE_FAST_MODEL);
430
476
  // Resolve max output tokens
431
477
  const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
432
478
  const baseConfig = {
@@ -468,8 +514,9 @@ export class ConfigurationService {
468
514
  if (this.options.maxInputTokens !== undefined) {
469
515
  return this.options.maxInputTokens;
470
516
  }
471
- // Try env (settings.json) first, then process.env
472
- const envMaxInputTokens = process.env.WAVE_MAX_INPUT_TOKENS;
517
+ // Try env (settings.json snapshot) first, then process.env
518
+ const envMaxInputTokens = this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
519
+ process.env.WAVE_MAX_INPUT_TOKENS;
473
520
  if (envMaxInputTokens) {
474
521
  const parsed = parseInt(envMaxInputTokens, 10);
475
522
  if (!isNaN(parsed)) {
@@ -510,8 +557,8 @@ export class ConfigurationService {
510
557
  if (this.currentConfiguration?.autoMemoryEnabled !== undefined) {
511
558
  return this.currentConfiguration.autoMemoryEnabled;
512
559
  }
513
- // 2. WAVE_DISABLE_AUTO_MEMORY environment variable
514
- const disableAutoMemory = process.env.WAVE_DISABLE_AUTO_MEMORY ||
560
+ // 2. WAVE_DISABLE_AUTO_MEMORY environment variable (settings snapshot > OS env)
561
+ const disableAutoMemory = this.envSnapshot.WAVE_DISABLE_AUTO_MEMORY ??
515
562
  process.env.WAVE_DISABLE_AUTO_MEMORY;
516
563
  if (disableAutoMemory === "1" || disableAutoMemory === "true") {
517
564
  return false;
@@ -541,8 +588,8 @@ export class ConfigurationService {
541
588
  if (this.currentConfiguration?.autoMemoryFrequency !== undefined) {
542
589
  return this.currentConfiguration.autoMemoryFrequency;
543
590
  }
544
- // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable
545
- const envFrequency = process.env.WAVE_AUTO_MEMORY_FREQUENCY ||
591
+ // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable (settings snapshot > OS env)
592
+ const envFrequency = this.envSnapshot.WAVE_AUTO_MEMORY_FREQUENCY ??
546
593
  process.env.WAVE_AUTO_MEMORY_FREQUENCY;
547
594
  if (envFrequency) {
548
595
  const parsed = parseInt(envFrequency, 10);
@@ -568,8 +615,9 @@ export class ConfigurationService {
568
615
  if (this.options.maxTokens !== undefined) {
569
616
  return this.options.maxTokens;
570
617
  }
571
- // Try env (settings.json) first, then process.env
572
- const envMaxOutputTokens = process.env.WAVE_MAX_OUTPUT_TOKENS;
618
+ // Try env (settings.json snapshot) first, then process.env
619
+ const envMaxOutputTokens = this.envSnapshot.WAVE_MAX_OUTPUT_TOKENS ??
620
+ process.env.WAVE_MAX_OUTPUT_TOKENS;
573
621
  if (envMaxOutputTokens) {
574
622
  const parsed = parseInt(envMaxOutputTokens, 10);
575
623
  if (!isNaN(parsed) && parsed > 0) {
@@ -610,8 +658,9 @@ export class ConfigurationService {
610
658
  */
611
659
  getConfiguredModels() {
612
660
  const models = new Set();
613
- // Add current model from options or environment
614
- const currentModel = this.options.model || process.env.WAVE_MODEL;
661
+ // Add current model from options or environment (settings snapshot > OS env)
662
+ const currentModel = this.options.model ||
663
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
615
664
  if (currentModel) {
616
665
  models.add(currentModel);
617
666
  }
@@ -44,12 +44,16 @@ export class InitializationService {
44
44
  logger?.error("Failed to initialize MCP servers:", error);
45
45
  // Don't throw error to prevent app startup failure
46
46
  }
47
- // Initialize remote settings (load disk cache synchronously, then fetch in background)
48
- // Must happen BEFORE loadMergedConfiguration so remote env vars are available
47
+ // Load remote settings disk cache synchronously.
48
+ // Must happen BEFORE loadMergedConfiguration so cached managed settings
49
+ // (env, model, disallowedTools) are merged into the config. Settings `env`
50
+ // is stored in the per-session env snapshot (NOT process.env), except
51
+ // WAVE_SERVER_URL which is mirrored to process.env so the network fetch
52
+ // (below) can read it via authService.getServerUrl(); no race.
49
53
  try {
50
54
  const phaseStart = performance.now();
51
55
  await remoteSettingsService.initialize();
52
- logger?.debug(`Initialization Phase [Remote Settings] took ${(performance.now() - phaseStart).toFixed(2)}ms`);
56
+ logger?.debug(`Initialization Phase [Remote Settings Cache] took ${(performance.now() - phaseStart).toFixed(2)}ms`);
53
57
  }
54
58
  catch (error) {
55
59
  logger?.error("Failed to initialize remote settings:", error);
@@ -89,6 +93,12 @@ export class InitializationService {
89
93
  logger?.error("Failed to initialize hooks system:", error);
90
94
  // Don't throw error to prevent app startup failure
91
95
  }
96
+ // Start remote settings network fetch + polling now that the config is
97
+ // merged. Settings `env` WAVE_SERVER_URL was mirrored to process.env by
98
+ // loadMergedConfiguration → setEnvironmentVars, so the fetch reads it via
99
+ // authService.getServerUrl(); fire-and-forget, failures fall back to the
100
+ // cached/merged settings.
101
+ remoteSettingsService.startBackgroundFetch();
92
102
  // Execute SessionStart hooks
93
103
  try {
94
104
  const phaseStart = performance.now();
@@ -124,7 +134,7 @@ export class InitializationService {
124
134
  transcriptPath: messageManager.getTranscriptPath(),
125
135
  cwd: workdir,
126
136
  worktreeName: agentOptions.worktreeName,
127
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
137
+ env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
128
138
  });
129
139
  // Process hook results
130
140
  hookManager.processHookResults("WorktreeCreate", hookResults, messageManager);
@@ -48,7 +48,7 @@ export class InteractionService {
48
48
  transcriptPath: messageManager.getTranscriptPath(),
49
49
  cwd: workdir,
50
50
  userPrompt: content,
51
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
51
+ env: Object.fromEntries(Object.entries(context.configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)), // Include environment variables
52
52
  });
53
53
  // Process hook results and determine if we should continue
54
54
  const processResult = hookManager.processHookResults("UserPromptSubmit", hookResults, messageManager);
@@ -76,7 +76,7 @@ export class InteractionService {
76
76
  }
77
77
  }
78
78
  static async restoreSession(context, sessionId) {
79
- const { messageManager, logger, subagentManager, taskManager, options, abortMessage, } = context;
79
+ const { messageManager, hookManager, logger, subagentManager, taskManager, options, abortMessage, } = context;
80
80
  // 1. Validation
81
81
  if (!sessionId || sessionId === messageManager.getSessionId()) {
82
82
  return; // No-op if session ID is invalid or already current
@@ -89,21 +89,49 @@ export class InteractionService {
89
89
  logger?.warn("Failed to save current session before restore:", error);
90
90
  // Continue with restoration even if save fails
91
91
  }
92
- // 3. Load target session
92
+ // 3. Run SessionEnd hooks for the current session (cleanup before switching)
93
+ const currentSessionId = messageManager.getSessionId();
94
+ const currentTranscriptPath = messageManager.getTranscriptPath();
95
+ if (hookManager) {
96
+ try {
97
+ await hookManager.executeSessionEndHooks("resume", currentSessionId, currentTranscriptPath);
98
+ }
99
+ catch (error) {
100
+ logger?.warn(`SessionEnd hooks on restore failed: ${error.message}`);
101
+ }
102
+ }
103
+ // 4. Load target session
93
104
  const sessionData = await loadSessionFromJsonl(sessionId, messageManager.getWorkdir());
94
105
  if (!sessionData) {
95
106
  throw new Error(`Session not found: ${sessionId}`);
96
107
  }
97
- // 4. Clean current state
108
+ // 5. Clean current state
98
109
  abortMessage(); // Abort any running operations
99
110
  subagentManager.cleanup(); // Clean up active subagents
100
- // 5. Rebuild usage (in correct order)
111
+ // 6. Rebuild usage (in correct order)
101
112
  messageManager.rebuildUsageFromMessages(sessionData.messages);
102
- // 6. Initialize session state last
113
+ // 7. Initialize session state last
103
114
  messageManager.initializeFromSession(sessionData);
115
+ // 8. Run SessionStart hooks for the restored session and inject additional
116
+ // context as a meta user message (matches Claude Code's resume behavior:
117
+ // SessionEnd then SessionStart, hook messages appended to the conversation)
118
+ if (hookManager) {
119
+ try {
120
+ const sessionStartResult = await hookManager.executeSessionStartHooks("resume", sessionData.id, messageManager.getTranscriptPath());
121
+ if (sessionStartResult.additionalContext) {
122
+ messageManager.addUserMessage({
123
+ content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
124
+ isMeta: true,
125
+ });
126
+ }
127
+ }
128
+ catch (error) {
129
+ logger?.warn(`SessionStart hooks on restore failed: ${error.message}`);
130
+ }
131
+ }
104
132
  // Update task manager with the root session ID to ensure continuity across compactions
105
133
  taskManager.setTaskListId(sessionData.id);
106
- // 7. Load tasks for the restored session
134
+ // 9. Load tasks for the restored session
107
135
  const tasks = await taskManager.listTasks();
108
136
  options.callbacks?.onTasksChange?.(tasks);
109
137
  }
@@ -6,6 +6,17 @@ import type { WaveConfiguration } from "../types/configuration.js";
6
6
  */
7
7
  export declare function onSettingsUpdate(callback: () => void | Promise<void>): () => void;
8
8
  export declare function initialize(): void;
9
+ /**
10
+ * Start the fire-and-forget initial network fetch + background polling.
11
+ *
12
+ * Must be called AFTER loadMergedConfiguration() so disk-cached managed
13
+ * settings are merged before the fetch, and so settings `env` WAVE_SERVER_URL
14
+ * is mirrored to process.env (by setEnvironmentVars) before the fetch. The
15
+ * fetch uses authService.getServerUrl(), which reads process.env.WAVE_SERVER_URL
16
+ * — the settings value is visible there, so there is no init-ordering race
17
+ * that would fall back to DEFAULT_SERVER_URL (prod) and hit a test endpoint (401).
18
+ */
19
+ export declare function startBackgroundFetch(): void;
9
20
  export declare function getRemoteSettingsSync(): WaveConfiguration | null;
10
21
  export declare function refresh(): Promise<RemoteSettingsFetchResult>;
11
22
  export declare function clear(): void;
@@ -17,6 +28,7 @@ export declare function mergeRemoteSettings(localMerged: WaveConfiguration, remo
17
28
  */
18
29
  export declare const remoteSettingsService: {
19
30
  readonly initialize: typeof initialize;
31
+ readonly startBackgroundFetch: typeof startBackgroundFetch;
20
32
  readonly getRemoteSettingsSync: typeof getRemoteSettingsSync;
21
33
  readonly refresh: typeof refresh;
22
34
  readonly clear: typeof clear;
@@ -174,8 +174,21 @@ function startPolling() {
174
174
  _pollingTimer.unref();
175
175
  }
176
176
  export function initialize() {
177
+ // Load disk cache synchronously so getRemoteSettingsSync() returns cached
178
+ // managed settings during loadMergedConfiguration() (must run BEFORE it).
177
179
  loadCacheFromDisk();
178
- // Fire-and-forget the initial fetch, then start background polling
180
+ }
181
+ /**
182
+ * Start the fire-and-forget initial network fetch + background polling.
183
+ *
184
+ * Must be called AFTER loadMergedConfiguration() so disk-cached managed
185
+ * settings are merged before the fetch, and so settings `env` WAVE_SERVER_URL
186
+ * is mirrored to process.env (by setEnvironmentVars) before the fetch. The
187
+ * fetch uses authService.getServerUrl(), which reads process.env.WAVE_SERVER_URL
188
+ * — the settings value is visible there, so there is no init-ordering race
189
+ * that would fall back to DEFAULT_SERVER_URL (prod) and hit a test endpoint (401).
190
+ */
191
+ export function startBackgroundFetch() {
179
192
  fetchRemoteSettings()
180
193
  .then(() => startPolling())
181
194
  .catch((err) => {
@@ -302,6 +315,7 @@ export function mergeRemoteSettings(localMerged, remote) {
302
315
  */
303
316
  export const remoteSettingsService = {
304
317
  initialize,
318
+ startBackgroundFetch,
305
319
  getRemoteSettingsSync,
306
320
  refresh,
307
321
  clear,
@@ -32,7 +32,13 @@ export class TaskManager extends EventEmitter {
32
32
  if (!messageManager)
33
33
  return;
34
34
  const rootSessionId = messageManager.getRootSessionId();
35
- if (this.taskListId !== rootSessionId && !process.env.WAVE_TASK_LIST_ID) {
35
+ // Read the per-session snapshot (not process.env) so multiple sessions in
36
+ // one `wave --stdio` process don't cross-pollute this flag.
37
+ const envSnap = this.container
38
+ .get("ConfigurationService")
39
+ ?.getEnvSnapshot() ?? {};
40
+ const pinnedTaskListId = envSnap.WAVE_TASK_LIST_ID ?? process.env.WAVE_TASK_LIST_ID;
41
+ if (this.taskListId !== rootSessionId && !pinnedTaskListId) {
36
42
  this.setTaskListId(rootSessionId);
37
43
  await this.refreshTasks();
38
44
  }
@@ -238,6 +238,7 @@ The working directory persists between commands. Try to maintain your current wo
238
238
  cwd: context.workdir,
239
239
  env: {
240
240
  ...process.env,
241
+ ...context.sessionEnv,
241
242
  },
242
243
  });
243
244
  let outputBuffer = "";
@@ -2,7 +2,7 @@
2
2
  * EnterWorktree tool - creates an isolated git worktree and switches the session into it.
3
3
  * Mirrors Claude Code's EnterWorktree tool behavior and prompt.
4
4
  */
5
- import { createWorktree, validateWorktreeName, generateWorktreeName, } from "../utils/worktreeUtils.js";
5
+ import { createWorktree, validateWorktreeName, generateWorktreeName, performPostCreationSetup, } from "../utils/worktreeUtils.js";
6
6
  import { getGitMainRepoRoot } from "../utils/gitUtils.js";
7
7
  import { ENTER_WORKTREE_TOOL_NAME } from "../constants/tools.js";
8
8
  import { logger } from "../utils/globalLogger.js";
@@ -85,6 +85,17 @@ export const enterWorktreeTool = {
85
85
  // Create the worktree (captures originalHeadCommit internally)
86
86
  const baseRef = context.aiManager?.getWorktreeBaseRef?.();
87
87
  const worktreeInfo = createWorktree(name, mainRepoRoot, { baseRef });
88
+ // Copy local settings (.wave/settings.local.json) and gitignored project
89
+ // files (.worktreeinclude, e.g. .env/.mcp.json) into a new worktree —
90
+ // mirrors the CLI createWorktree path. Best-effort, never fails the tool.
91
+ if (worktreeInfo.isNew) {
92
+ try {
93
+ await performPostCreationSetup(worktreeInfo.path, worktreeInfo.repoRoot);
94
+ }
95
+ catch (error) {
96
+ logger?.warn("Worktree post-creation setup failed:", error);
97
+ }
98
+ }
88
99
  // Build session state
89
100
  const session = {
90
101
  originalCwd: context.workdir,
@@ -110,10 +121,10 @@ export const enterWorktreeTool = {
110
121
  projectDir: worktreeInfo.path,
111
122
  timestamp: new Date(),
112
123
  sessionId: context.sessionId ?? "",
113
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
124
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
114
125
  cwd: worktreeInfo.path,
115
126
  worktreeName: worktreeInfo.name,
116
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
127
+ env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
117
128
  });
118
129
  if (context.messageManager) {
119
130
  context.hookManager.processHookResults("WorktreeCreate", hookResults, context.messageManager);
@@ -128,14 +128,8 @@ export const exitWorktreeTool = {
128
128
  };
129
129
  // Count changes BEFORE removing the worktree (directory will be gone after)
130
130
  const summary = countWorktreeChanges(worktreePath, session.originalHeadCommit) ?? { changedFiles: 0, commits: 0 };
131
- removeWorktree(worktreeInfo);
132
- // Clear session state and restore CWD
133
- const aiManager = context.aiManager;
134
- if (aiManager) {
135
- aiManager.setWorktreeSession(null);
136
- aiManager.setWorkdir(originalCwd);
137
- }
138
- // Trigger WorktreeRemove hook (non-blocking)
131
+ // Trigger WorktreeRemove hook (non-blocking) BEFORE git removal so hooks
132
+ // can still read files inside the worktree to clean up external resources.
139
133
  let hookTriggered = false;
140
134
  if (context.hookManager) {
141
135
  try {
@@ -144,10 +138,10 @@ export const exitWorktreeTool = {
144
138
  projectDir: originalCwd,
145
139
  timestamp: new Date(),
146
140
  sessionId: context.sessionId ?? "",
147
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
141
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
148
142
  cwd: originalCwd,
149
143
  worktreePath,
150
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
144
+ env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
151
145
  });
152
146
  if (context.messageManager) {
153
147
  context.hookManager.processHookResults("WorktreeRemove", hookResults, context.messageManager);
@@ -159,6 +153,13 @@ export const exitWorktreeTool = {
159
153
  logger?.warn("WorktreeRemove hooks execution failed:", error);
160
154
  }
161
155
  }
156
+ removeWorktree(worktreeInfo);
157
+ // Clear session state and restore CWD
158
+ const aiManager = context.aiManager;
159
+ if (aiManager) {
160
+ aiManager.setWorktreeSession(null);
161
+ aiManager.setWorkdir(originalCwd);
162
+ }
162
163
  const discardParts = [];
163
164
  if (summary.commits > 0) {
164
165
  discardParts.push(`${summary.commits} ${summary.commits === 1 ? "commit" : "commits"}`);
@@ -104,4 +104,11 @@ export interface ToolContext {
104
104
  originalWorkdir?: string;
105
105
  /** Workflow manager instance for workflow orchestration */
106
106
  workflowManager?: import("../managers/workflowManager.js").WorkflowManager;
107
+ /**
108
+ * Per-session merged environment (OS env overlaid with the settings env
109
+ * snapshot) for this session. Tools that spawn subprocesses (Bash, hooks)
110
+ * should merge this on top of `process.env` so settings `env` vars reach
111
+ * the subprocess without polluting other sessions in one stdio process.
112
+ */
113
+ sessionEnv?: Record<string, string>;
107
114
  }
@@ -10,6 +10,8 @@ export interface GatewayConfig {
10
10
  defaultHeaders?: Record<string, string>;
11
11
  fetchOptions?: OpenAI["fetchOptions"];
12
12
  fetch?: OpenAI["fetch"];
13
+ /** Session identifier, sent as the `x-session-id` request header for backend correlation. */
14
+ sessionId?: string;
13
15
  }
14
16
  export interface ModelCapabilities {
15
17
  /** Whether the model supports image/vision input. Default: true. */
@@ -54,8 +54,8 @@ export declare class HookConfigurationError extends Error {
54
54
  readonly validationErrors: string[];
55
55
  constructor(configPath: string, validationErrors: string[]);
56
56
  }
57
- export type SessionStartSource = "startup" | "compact" | "clear";
58
- export type SessionEndSource = "exit" | "stop" | "compact" | "clear";
57
+ export type SessionStartSource = "startup" | "resume" | "compact" | "clear";
58
+ export type SessionEndSource = "exit" | "resume" | "stop" | "compact" | "clear";
59
59
  export declare function isValidHookEvent(event: string): event is HookEvent;
60
60
  export declare function isValidHookCommand(cmd: unknown): cmd is HookCommand;
61
61
  export declare function isValidHookEventConfig(config: unknown): config is HookEventConfig;
@@ -159,7 +159,7 @@ export function setupAgentContainer(setupOptions) {
159
159
  toolName: context.toolName,
160
160
  toolInput: context.toolInput,
161
161
  planFilePath: permissionManager.getPlanFilePath(),
162
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
162
+ env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
163
163
  });
164
164
  if (results.length > 0) {
165
165
  const processResult = hookManager.processHookResults("PermissionRequest", results, messageManager);
@@ -36,11 +36,12 @@ export class OpenAIClient {
36
36
  };
37
37
  }
38
38
  async _create(params, options) {
39
- const { baseURL, apiKey, defaultHeaders, fetchOptions, fetch: customFetch, } = this.config;
39
+ const { baseURL, apiKey, sessionId, defaultHeaders, fetchOptions, fetch: customFetch, } = this.config;
40
40
  const url = `${baseURL}/chat/completions`;
41
41
  const headers = {
42
42
  "Content-Type": "application/json",
43
43
  ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
44
+ ...(sessionId ? { "x-session-id": sessionId } : {}),
44
45
  ...defaultHeaders,
45
46
  };
46
47
  const fetchFn = customFetch || fetch;
@@ -127,8 +127,13 @@ export class PathEncoder {
127
127
  // If realpath fails, use the absolute path
128
128
  resolvedPath = absolutePath;
129
129
  }
130
- // Encode the resolved path
131
- const encodedName = await this.encode(resolvedPath);
130
+ // Encode the resolved path. Use encodeSync (pure string transform) rather
131
+ // than encode(): realpath was already attempted above with its own fallback,
132
+ // so re-resolving via encode()->resolvePath() would throw ENOENT again when
133
+ // the workdir no longer exists (e.g. a deleted worktree during agent
134
+ // shutdown). Session files live under <baseSessionDir>/<encoded>/ regardless
135
+ // of the workdir's existence, so encoding must not require it to exist.
136
+ const encodedName = this.encodeSync(resolvedPath);
132
137
  const encodedPath = join(baseSessionDir, encodedName);
133
138
  // Generate hash if encoding resulted in truncation
134
139
  let pathHash;
@@ -33,10 +33,27 @@ export declare function getHeadCommit(cwd: string): string;
33
33
  export declare function createWorktree(name: string, cwd: string, options?: {
34
34
  baseRef?: "fresh" | "head";
35
35
  }): WorktreeInfo;
36
+ /**
37
+ * Set up a freshly created worktree: copy local settings and gitignored
38
+ * project files (via .worktreeinclude) from the main repo. Best-effort — any
39
+ * failure only logs a warning and never fails worktree creation.
40
+ */
41
+ export declare function performPostCreationSetup(worktreePath: string, repoRoot: string): Promise<void>;
36
42
  /**
37
43
  * Remove a git worktree and its branch.
38
44
  */
39
45
  export declare function removeWorktree(info: WorktreeInfo): void;
46
+ /**
47
+ * Validate that a worktree path is safe to remove before running git removal.
48
+ * Aligns with Claude Code v2.1.216+ background-session checks:
49
+ * - rejects a path whose final component is a symlink;
50
+ * - rejects a path that resolves outside the repo root.
51
+ *
52
+ * A path that no longer exists (worktree already removed) is allowed so that
53
+ * removal stays best-effort/idempotent; its nearest existing ancestor is used
54
+ * for the containment check. Throws an Error on invalid paths.
55
+ */
56
+ export declare function validateWorktreeRemovalPath(worktreePath: string, repoRoot: string): void;
40
57
  /**
41
58
  * Count uncommitted files and new commits in a worktree.
42
59
  * Returns null if git commands fail (fail-closed).