wave-agent-sdk 0.19.9 → 1.0.1

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 (89) 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.d.ts +9 -20
  6. package/dist/agent.js +28 -99
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/managers/aiManager.d.ts +71 -8
  10. package/dist/managers/aiManager.js +290 -85
  11. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  12. package/dist/managers/backgroundTaskManager.js +11 -0
  13. package/dist/managers/bangManager.d.ts +6 -0
  14. package/dist/managers/bangManager.js +11 -0
  15. package/dist/managers/hookManager.d.ts +8 -2
  16. package/dist/managers/hookManager.js +14 -4
  17. package/dist/managers/mcpManager.d.ts +18 -4
  18. package/dist/managers/mcpManager.js +40 -18
  19. package/dist/managers/messageManager.d.ts +9 -5
  20. package/dist/managers/messageManager.js +36 -12
  21. package/dist/managers/subagentManager.d.ts +6 -0
  22. package/dist/managers/subagentManager.js +33 -22
  23. package/dist/managers/toolManager.js +5 -0
  24. package/dist/prompts/index.d.ts +0 -1
  25. package/dist/prompts/index.js +0 -4
  26. package/dist/services/aiService.d.ts +1 -34
  27. package/dist/services/aiService.js +18 -130
  28. package/dist/services/autoMemoryService.d.ts +27 -2
  29. package/dist/services/autoMemoryService.js +124 -36
  30. package/dist/services/configurationService.d.ts +21 -2
  31. package/dist/services/configurationService.js +86 -24
  32. package/dist/services/initializationService.js +14 -4
  33. package/dist/services/interactionService.js +35 -7
  34. package/dist/services/remoteSettingsService.d.ts +12 -0
  35. package/dist/services/remoteSettingsService.js +15 -1
  36. package/dist/services/session.d.ts +13 -0
  37. package/dist/services/session.js +64 -0
  38. package/dist/services/taskManager.js +7 -1
  39. package/dist/tools/bashTool.js +1 -0
  40. package/dist/tools/enterWorktreeTool.js +14 -3
  41. package/dist/tools/exitWorktreeTool.js +11 -10
  42. package/dist/tools/types.d.ts +7 -0
  43. package/dist/types/agent.d.ts +0 -2
  44. package/dist/types/config.d.ts +9 -0
  45. package/dist/types/core.d.ts +1 -1
  46. package/dist/types/hooks.d.ts +2 -2
  47. package/dist/utils/containerSetup.js +13 -4
  48. package/dist/utils/openaiClient.js +2 -1
  49. package/dist/utils/pathEncoder.js +7 -2
  50. package/dist/utils/worktreeUtils.d.ts +17 -0
  51. package/dist/utils/worktreeUtils.js +339 -1
  52. package/package.json +1 -1
  53. package/src/agent.ts +43 -112
  54. package/src/index.ts +1 -0
  55. package/src/managers/aiManager.ts +389 -110
  56. package/src/managers/backgroundTaskManager.ts +15 -0
  57. package/src/managers/bangManager.ts +15 -0
  58. package/src/managers/hookManager.ts +20 -5
  59. package/src/managers/mcpManager.ts +60 -18
  60. package/src/managers/messageManager.ts +51 -23
  61. package/src/managers/subagentManager.ts +36 -25
  62. package/src/managers/toolManager.ts +7 -0
  63. package/src/prompts/index.ts +0 -4
  64. package/src/services/aiService.ts +25 -203
  65. package/src/services/autoMemoryService.ts +145 -39
  66. package/src/services/configurationService.ts +100 -24
  67. package/src/services/initializationService.ts +17 -4
  68. package/src/services/interactionService.ts +49 -6
  69. package/src/services/remoteSettingsService.ts +16 -1
  70. package/src/services/session.ts +68 -0
  71. package/src/services/taskManager.ts +10 -1
  72. package/src/tools/bashTool.ts +1 -0
  73. package/src/tools/enterWorktreeTool.ts +19 -2
  74. package/src/tools/exitWorktreeTool.ts +15 -12
  75. package/src/tools/types.ts +7 -0
  76. package/src/types/agent.ts +0 -6
  77. package/src/types/config.ts +9 -0
  78. package/src/types/core.ts +1 -1
  79. package/src/types/hooks.ts +2 -2
  80. package/src/utils/containerSetup.ts +15 -5
  81. package/src/utils/openaiClient.ts +2 -0
  82. package/src/utils/pathEncoder.ts +7 -2
  83. package/src/utils/worktreeUtils.ts +401 -1
  84. package/dist/constants/goalPrompts.d.ts +0 -1
  85. package/dist/constants/goalPrompts.js +0 -10
  86. package/dist/managers/goalManager.d.ts +0 -42
  87. package/dist/managers/goalManager.js +0 -177
  88. package/src/constants/goalPrompts.ts +0 -10
  89. package/src/managers/goalManager.ts +0 -232
@@ -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);
@@ -564,16 +619,31 @@ export class ConfigurationService {
564
619
  baseConfig.fastModelOptions = fastModelSource.options;
565
620
  }
566
621
 
622
+ // Resolve fast-model disable-thinking params from models[fastModel].disableThinkingOptions
623
+ const fastModelDisableThinking =
624
+ fastModelSource && fastModelSource.disableThinkingOptions
625
+ ? fastModelSource.disableThinkingOptions
626
+ : undefined;
627
+ if (fastModelDisableThinking) {
628
+ baseConfig.disableThinkingOptions = fastModelDisableThinking;
629
+ }
630
+
567
631
  // Merge model-specific settings from configuration
568
632
  const modelSpecificConfig =
569
633
  resolvedAgentModel &&
570
634
  this.currentConfiguration?.models?.[resolvedAgentModel];
571
635
 
572
636
  if (modelSpecificConfig) {
573
- return {
637
+ const resolved: ModelConfig = {
574
638
  ...baseConfig,
575
639
  ...modelSpecificConfig,
576
640
  };
641
+ // Re-apply after the spread so the agent model's own
642
+ // disableThinkingOptions cannot clobber the fast-model value.
643
+ if (fastModelDisableThinking) {
644
+ resolved.disableThinkingOptions = fastModelDisableThinking;
645
+ }
646
+ return resolved;
577
647
  }
578
648
 
579
649
  return baseConfig;
@@ -596,8 +666,10 @@ export class ConfigurationService {
596
666
  return this.options.maxInputTokens;
597
667
  }
598
668
 
599
- // Try env (settings.json) first, then process.env
600
- const envMaxInputTokens = process.env.WAVE_MAX_INPUT_TOKENS;
669
+ // Try env (settings.json snapshot) first, then process.env
670
+ const envMaxInputTokens =
671
+ this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
672
+ process.env.WAVE_MAX_INPUT_TOKENS;
601
673
  if (envMaxInputTokens) {
602
674
  const parsed = parseInt(envMaxInputTokens, 10);
603
675
  if (!isNaN(parsed)) {
@@ -645,9 +717,9 @@ export class ConfigurationService {
645
717
  return this.currentConfiguration.autoMemoryEnabled;
646
718
  }
647
719
 
648
- // 2. WAVE_DISABLE_AUTO_MEMORY environment variable
720
+ // 2. WAVE_DISABLE_AUTO_MEMORY environment variable (settings snapshot > OS env)
649
721
  const disableAutoMemory =
650
- process.env.WAVE_DISABLE_AUTO_MEMORY ||
722
+ this.envSnapshot.WAVE_DISABLE_AUTO_MEMORY ??
651
723
  process.env.WAVE_DISABLE_AUTO_MEMORY;
652
724
  if (disableAutoMemory === "1" || disableAutoMemory === "true") {
653
725
  return false;
@@ -681,9 +753,9 @@ export class ConfigurationService {
681
753
  return this.currentConfiguration.autoMemoryFrequency;
682
754
  }
683
755
 
684
- // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable
756
+ // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable (settings snapshot > OS env)
685
757
  const envFrequency =
686
- process.env.WAVE_AUTO_MEMORY_FREQUENCY ||
758
+ this.envSnapshot.WAVE_AUTO_MEMORY_FREQUENCY ??
687
759
  process.env.WAVE_AUTO_MEMORY_FREQUENCY;
688
760
  if (envFrequency) {
689
761
  const parsed = parseInt(envFrequency, 10);
@@ -713,8 +785,10 @@ export class ConfigurationService {
713
785
  return this.options.maxTokens;
714
786
  }
715
787
 
716
- // Try env (settings.json) first, then process.env
717
- const envMaxOutputTokens = process.env.WAVE_MAX_OUTPUT_TOKENS;
788
+ // Try env (settings.json snapshot) first, then process.env
789
+ const envMaxOutputTokens =
790
+ this.envSnapshot.WAVE_MAX_OUTPUT_TOKENS ??
791
+ process.env.WAVE_MAX_OUTPUT_TOKENS;
718
792
  if (envMaxOutputTokens) {
719
793
  const parsed = parseInt(envMaxOutputTokens, 10);
720
794
  if (!isNaN(parsed) && parsed > 0) {
@@ -761,8 +835,10 @@ export class ConfigurationService {
761
835
  getConfiguredModels(): string[] {
762
836
  const models = new Set<string>();
763
837
 
764
- // Add current model from options or environment
765
- const currentModel = this.options.model || process.env.WAVE_MODEL;
838
+ // Add current model from options or environment (settings snapshot > OS env)
839
+ const currentModel =
840
+ this.options.model ||
841
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
766
842
  if (currentModel) {
767
843
  models.add(currentModel);
768
844
  }
@@ -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,
@@ -612,6 +612,74 @@ export async function cleanupEmptyProjectDirectories(): Promise<void> {
612
612
  }
613
613
  }
614
614
 
615
+ /**
616
+ * Clean up "ghost" session files that contain only meta messages
617
+ * (isMeta: true) — e.g. sessions where a SessionStart hook injected a
618
+ * system-reminder but no real user/assistant message was ever sent.
619
+ *
620
+ * Such sessions are no longer created thanks to lazy materialization in
621
+ * saveSession(); this one-time sweep removes files that predate that change
622
+ * so they stop showing up as "0 tokens / No content" entries in the resume
623
+ * list.
624
+ *
625
+ * @returns Promise that resolves to the number of files deleted
626
+ */
627
+ export async function cleanupMetaOnlySessions(): Promise<number> {
628
+ // Do not perform cleanup operations in test environment
629
+ if (process.env.NODE_ENV === "test") {
630
+ return 0;
631
+ }
632
+
633
+ let deletedCount = 0;
634
+ try {
635
+ const projectDirs = await fs.readdir(SESSION_DIR);
636
+
637
+ for (const projectDirName of projectDirs) {
638
+ const projectPath = join(SESSION_DIR, projectDirName);
639
+ try {
640
+ const stat = await fs.stat(projectPath);
641
+ if (!stat.isDirectory()) {
642
+ continue;
643
+ }
644
+
645
+ const files = await fs.readdir(projectPath);
646
+ for (const file of files) {
647
+ if (!file.endsWith(".jsonl")) {
648
+ continue;
649
+ }
650
+
651
+ const filePath = join(projectPath, file);
652
+ try {
653
+ // Fast path: a file whose last message is not meta contains a
654
+ // real message, so it can never be meta-only.
655
+ const jsonlHandler = new JsonlHandler();
656
+ const lastMessage = await jsonlHandler.getLastMessage(filePath);
657
+ if (!lastMessage?.isMeta) {
658
+ continue;
659
+ }
660
+
661
+ const messages = await jsonlHandler.read(filePath);
662
+ if (messages.length > 0 && messages.every((m) => m.isMeta)) {
663
+ await fs.unlink(filePath);
664
+ deletedCount++;
665
+ }
666
+ } catch {
667
+ // Skip corrupted or unreadable files
668
+ continue;
669
+ }
670
+ }
671
+ } catch {
672
+ // Skip directories we can't access
673
+ continue;
674
+ }
675
+ }
676
+ } catch {
677
+ // Ignore errors if base directory doesn't exist or can't be accessed
678
+ }
679
+
680
+ return deletedCount;
681
+ }
682
+
615
683
  /**
616
684
  * Check if a session exists in JSONL storage (new approach)
617
685
  *
@@ -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
  );
@@ -162,16 +162,8 @@ export const exitWorktreeTool: ToolPlugin = {
162
162
  session.originalHeadCommit,
163
163
  ) ?? { changedFiles: 0, commits: 0 };
164
164
 
165
- removeWorktree(worktreeInfo);
166
-
167
- // Clear session state and restore CWD
168
- const aiManager = context.aiManager;
169
- if (aiManager) {
170
- aiManager.setWorktreeSession(null);
171
- aiManager.setWorkdir(originalCwd);
172
- }
173
-
174
- // Trigger WorktreeRemove hook (non-blocking)
165
+ // Trigger WorktreeRemove hook (non-blocking) BEFORE git removal so hooks
166
+ // can still read files inside the worktree to clean up external resources.
175
167
  let hookTriggered = false;
176
168
  if (context.hookManager) {
177
169
  try {
@@ -182,11 +174,13 @@ export const exitWorktreeTool: ToolPlugin = {
182
174
  projectDir: originalCwd,
183
175
  timestamp: new Date(),
184
176
  sessionId: context.sessionId ?? "",
185
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
177
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
186
178
  cwd: originalCwd,
187
179
  worktreePath,
188
180
  env: Object.fromEntries(
189
- Object.entries(process.env).filter((e) => e[1] !== undefined),
181
+ Object.entries(context.sessionEnv ?? process.env).filter(
182
+ (e) => e[1] !== undefined,
183
+ ),
190
184
  ) as Record<string, string>,
191
185
  },
192
186
  );
@@ -206,6 +200,15 @@ export const exitWorktreeTool: ToolPlugin = {
206
200
  }
207
201
  }
208
202
 
203
+ removeWorktree(worktreeInfo);
204
+
205
+ // Clear session state and restore CWD
206
+ const aiManager = context.aiManager;
207
+ if (aiManager) {
208
+ aiManager.setWorktreeSession(null);
209
+ aiManager.setWorkdir(originalCwd);
210
+ }
211
+
209
212
  const discardParts: string[] = [];
210
213
  if (summary.commits > 0) {
211
214
  discardParts.push(
@@ -127,4 +127,11 @@ export interface ToolContext {
127
127
  originalWorkdir?: string;
128
128
  /** Workflow manager instance for workflow orchestration */
129
129
  workflowManager?: import("../managers/workflowManager.js").WorkflowManager;
130
+ /**
131
+ * Per-session merged environment (OS env overlaid with the settings env
132
+ * snapshot) for this session. Tools that spawn subprocesses (Bash, hooks)
133
+ * should merge this on top of `process.env` so settings `env` vars reach
134
+ * the subprocess without polluting other sessions in one stdio process.
135
+ */
136
+ sessionEnv?: Record<string, string>;
130
137
  }
@@ -119,10 +119,4 @@ export interface AgentCallbacks
119
119
  onCommandRunningChange?: (running: boolean) => void;
120
120
  onWorkdirChange?: (newCwd: string) => void;
121
121
  onQueuedMessagesChange?: (messages: QueuedMessage[]) => void;
122
- onGoalStateChange?: (
123
- active: boolean,
124
- condition?: string,
125
- elapsed?: string,
126
- ) => void;
127
- onGoalEvaluating?: (evaluating: boolean) => void;
128
122
  }
@@ -12,6 +12,8 @@ export interface GatewayConfig {
12
12
  defaultHeaders?: Record<string, string>;
13
13
  fetchOptions?: OpenAI["fetchOptions"];
14
14
  fetch?: OpenAI["fetch"];
15
+ /** Session identifier, sent as the `x-session-id` request header for backend correlation. */
16
+ sessionId?: string;
15
17
  }
16
18
 
17
19
  export interface ModelCapabilities {
@@ -31,4 +33,11 @@ export interface ModelConfig {
31
33
  options?: Record<string, unknown>;
32
34
  /** Fast model generation params (resolved from models[fastModel].options) */
33
35
  fastModelOptions?: Record<string, unknown>;
36
+ /**
37
+ * Fast-model-only disable-thinking params passed through verbatim
38
+ * (e.g. `{ thinking: { type: "disabled" } }`). Applied only in fast-model
39
+ * scenarios (webFetch content processing, `model: fastModel` subagents),
40
+ * never in the agent loop. `{}` clears the default.
41
+ */
42
+ disableThinkingOptions?: Record<string, unknown>;
34
43
  }
package/src/types/core.ts CHANGED
@@ -25,7 +25,7 @@ export interface Usage {
25
25
  completion_tokens: number; // Tokens generated in completions
26
26
  total_tokens: number; // Sum of prompt + completion tokens
27
27
  model?: string; // Model used for the operation (e.g., "gpt-4", "gpt-3.5-turbo")
28
- operation_type?: "agent" | "compact" | "goal_evaluation"; // Type of operation that generated usage
28
+ operation_type?: "agent" | "compact"; // Type of operation that generated usage
29
29
 
30
30
  // Cache-related tokens (Claude top-level + OpenAI prompt_tokens_details)
31
31
  cache_read_input_tokens?: number; // Tokens read from cache (Claude) or cached_tokens (OpenAI prompt_tokens_details)