wave-agent-sdk 1.1.3 → 1.1.5

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.
package/dist/agent.d.ts CHANGED
@@ -31,6 +31,8 @@ export declare class Agent {
31
31
  private taskManager;
32
32
  private foregroundTaskManager;
33
33
  private container;
34
+ /** Unregister module-level listeners registered during container setup; invoked at the end of destroy(). */
35
+ private teardown;
34
36
  private configurationService;
35
37
  private workdir;
36
38
  private systemPrompt?;
package/dist/agent.js CHANGED
@@ -56,6 +56,8 @@ export class Agent {
56
56
  this.isAborting = false; // Transient guard: prevents tryDispatch from firing during abortMessage (reset when the abort completes)
57
57
  this.isDestroyed = false; // Terminal guard: set in destroy(), never reset — no dispatch may ever start after destroy
58
58
  this.dispatchAborted = false; // Set on abort while a dispatch is running: suppress the .finally re-check so preserved notifications don't get dispatched after abort
59
+ /** Unregister module-level listeners registered during container setup; invoked at the end of destroy(). */
60
+ this.teardown = () => { };
59
61
  this.sessionStartTime = Date.now();
60
62
  const { logger, workdir, systemPrompt, stream = true } = options;
61
63
  // Set working directory early as we need it for loading configuration
@@ -68,7 +70,7 @@ export class Agent {
68
70
  this.stream = stream; // Save streaming mode flag
69
71
  // Store options for dynamic configuration resolution
70
72
  this.options = options;
71
- this.container = setupAgentContainer({
73
+ const { container, teardown } = setupAgentContainer({
72
74
  options,
73
75
  workdir: this.workdir,
74
76
  configurationService: this.configurationService,
@@ -92,6 +94,8 @@ export class Agent {
92
94
  addPermissionRule: (rule) => this.addPermissionRule(rule),
93
95
  addUsage: (usage) => this.messageManager.addUsage(usage),
94
96
  });
97
+ this.container = container;
98
+ this.teardown = teardown;
95
99
  // Retrieve managers from container
96
100
  this.foregroundTaskManager = this.container.get("ForegroundTaskManager");
97
101
  this.memoryRuleManager = this.container.get("MemoryRuleManager");
@@ -717,6 +721,14 @@ export class Agent {
717
721
  if (!drained) {
718
722
  this.logger?.error(`Async work did not drain: ${this.asyncWorkRegistry.size} live work item(s) remain after destroy`);
719
723
  }
724
+ // Unregister module-level listeners (remote settings hot-update, auth
725
+ // change) so the agent's object graph becomes collectable. Without this,
726
+ // the module-level callback arrays pin every created agent via the
727
+ // per-agent closure contexts, even after the host drops its references.
728
+ this.teardown();
729
+ // Break the DI container's internal references (services/factories) so no
730
+ // per-agent manager is retained through it after destroy.
731
+ this.container.clear();
720
732
  }
721
733
  /**
722
734
  * Trigger the rewind UI callback
@@ -41,6 +41,9 @@ const guidance = [
41
41
  "- 边界模糊时也先写 spec 草稿请用户确认,不要直接改代码。",
42
42
  "- 规格编写技能(specify)由 AI 自动触发:对话中涉及新需求或需求变更时主动创建或更新规格文件,不需要用户手动调用(不出现在斜杠命令列表中)。",
43
43
  \`- 新增或修改 spec 后运行校验:\${specCount}(自动检测 docs/specs/,否则 specs/,否则退出)。\`,
44
+ "- 阶段衔接一律用 AskUserQuestion 单选让用户点击决策,不要求自然语言:spec 确认(确认通过/需要修改)、是否制作原型、是否制定技术方案(plan)均通过单选推进。",
45
+ "- 可选原型阶段(在规格之后、plan 之前):仅实现前端界面,数据全部使用 mock;可选 plan 阶段:进入 plan 模式制定技术方案,批准后再编码。二者均可跳过——最短流程=规格+编码,最长=规格+原型+plan+编码。仅当需求涉及前端界面时才询问是否制作原型,无前端界面的需求(后端服务、CLI、算法库等)直接跳过原型询问。",
46
+ "- 用 task 工具追踪进度:规格、原型、plan、编码各阶段开始前用 TaskCreate 创建任务并标记进行中(TaskUpdate),完成/批准/确认后标记完成,让用户在任务列表中看到当前所处阶段。",
44
47
  ].join("\\n");
45
48
 
46
49
  // JSON form → parsed as hookSpecificOutput.additionalContext by the hook manager.
@@ -128,7 +131,7 @@ if (warnings.length) {
128
131
  `,
129
132
  "plugins/sdd/skills/specify/SKILL.md": `---
130
133
  name: specify
131
- description: 根据自然语言描述创建或更新功能规格说明,生成包含用户故事与验收场景的规格文件。
134
+ description: 根据自然语言描述创建或更新功能规格说明,并通过单选衔接可选原型、技术方案(plan)与编码阶段。
132
135
  user-invocable: false
133
136
  ---
134
137
 
@@ -144,6 +147,8 @@ $ARGUMENTS
144
147
 
145
148
  根据对话中的功能描述,执行以下步骤:
146
149
 
150
+ 0. **创建进度任务**:用 TaskCreate 创建「编写功能规格」任务,并用 TaskUpdate 标记进行中。后续每个阶段(原型、plan、编码)同样在开始前创建任务、结束后更新状态,让用户在任务列表中看到当前进度。
151
+
147
152
  1. **确定规格文件路径**:
148
153
  - **确定规格根目录**:优先复用项目中已有的规格目录——若 \`docs/specs/\` 存在则用之,否则若 \`specs/\` 存在则用之,否则默认 \`specs/\`(并在完成报告中说明所选目录,便于用户纠正)。
149
154
  - **选择分组**:若规格目录下已有分组子目录,沿用其既有分组约定;否则默认扁平结构(直接放在规格根目录下)。
@@ -165,7 +170,26 @@ $ARGUMENTS
165
170
  - 将每个标记作为问题展示,附带建议答案
166
171
  - 等待用户回复后更新规格文件
167
172
 
168
- 5. 报告完成,输出规格文件路径。
173
+ 5. **校验并确认规格**:
174
+ - 运行会话引导中给出的 spec-count 校验命令(自动检测 docs/specs/,否则 specs/,否则跳过)
175
+ - 输出规格文件路径,并通过 AskUserQuestion 单选请求确认(选项:确认通过 / 需要修改)
176
+ - 选「需要修改」→ 按用户反馈更新规格后重新校验,并再次单选确认;选「确认通过」→ 将「编写功能规格」任务标记完成
177
+
178
+ 6. **询问是否制作原型(可选阶段,位于规格之后、plan 之前)**:
179
+ - 仅当需求涉及前端界面时弹出询问;需求不涉及前端界面(如后端服务、CLI 工具、算法库)时,跳过本阶段直接进入下一步,不弹出原型选择
180
+ - 通过 AskUserQuestion 单选询问(选项:制作原型 / 跳过)
181
+ - 选「制作原型」→ 用 TaskCreate 创建「制作原型」任务并标记进行中;仅实现前端界面,数据全部使用 mock(不接后端、不接真实数据);完成后展示可交互原型供用户查看,并将任务标记完成
182
+ - 选「跳过」→ 直接进入下一步
183
+
184
+ 7. **询问是否制定技术方案(可选 plan 阶段)**:
185
+ - 通过 AskUserQuestion 单选询问(选项:进入 plan 模式 / 跳过)
186
+ - 选「进入 plan 模式」→ 用 TaskCreate 创建「制定技术方案」任务并标记进行中;调用 EnterPlanMode 进入 plan 模式,制定技术方案(技术选型、架构设计、实现步骤)并写入计划文件;用 ExitPlanMode 请求批准——被拒绝则按反馈更新方案后重新请求,批准后标记任务完成
187
+ - 选「跳过」→ 直接进入下一步
188
+
189
+ 8. **编码阶段**:
190
+ - 用 TaskCreate 创建「实现功能」任务并标记进行中
191
+ - 按已确认的规格实现;若制作了原型则遵循其交互设计,若批准了技术方案则遵循其架构
192
+ - 实现完成后将任务标记完成
169
193
 
170
194
  ## 指南
171
195
 
@@ -164,9 +164,13 @@ Wave provides detailed context to hook processes via \`stdin\` as a JSON object.
164
164
  - \`new_cwd\`: (CwdChanged) The new working directory.
165
165
  - \`compact_instructions\`: (PreCompact) Custom instructions for the compaction, if any.
166
166
  - \`compact_summary\`: (PostCompact) The AI-generated compaction summary text.
167
- - \`source\`: (SessionStart) The session start source: \`"startup"\`, \`"resume"\`, or \`"compact"\`.
167
+ - \`background_tasks\`: (Stop) Snapshot of running background tasks (array of \`{id, type: "shell"|"subagent"|"workflow", status, description, command?, startedAt}\`).
168
+ - \`session_crons\`: (Stop) Snapshot of session-scoped cron jobs (array of \`{name, schedule, prompt}\`).
169
+ - \`last_assistant_message\`: (Stop, SubagentStop) Text content of the last assistant message.
170
+ - \`plan_file_path\`: (Present when in plan mode) Path to the active plan file.
171
+ - \`source\`: (SessionStart) The session start source: \`"startup"\`, \`"resume"\`, \`"compact"\`, or \`"clear"\`.
168
172
  - \`agent_type\`: (SessionStart) The agent type identifier.
169
- - \`end_source\`: (SessionEnd) The session end source: \`"exit"\`, \`"stop"\`, or \`"compact"\`.
173
+ - \`end_source\`: (SessionEnd) The session end source: \`"exit"\`, \`"resume"\`, \`"stop"\`, \`"compact"\`, or \`"clear"\`.
170
174
 
171
175
  ## Hook Exit Codes
172
176
 
@@ -1049,6 +1053,7 @@ For detailed guidance on creating plugins and marketplaces, see [PLUGINS.md](\${
1049
1053
  - \`autoMemoryEnabled\`: Enable or disable auto-memory (default: \`true\`).
1050
1054
  - \`autoMemoryFrequency\`: Frequency of auto-memory extraction turns (default: \`1\`).
1051
1055
  - \`enableArtifact\`: Enable the Artifact tool, which publishes local \`.html\`/\`.md\` files as shareable (default-private) web pages. Defaults to \`false\` while the frame backend is not live; set to \`true\` to register the tool and enable WebFetch interception for artifact URLs. Toggling it hot-reloads the tool registry.
1056
+ - \`worktree.baseRef\`: Base ref for new worktrees. \`"fresh"\` (default) creates a new branch from \`origin/<default branch>\`; \`"head"\` branches from the current local HEAD, skipping origin resolution and network fetch. Use \`"head"\` when working from un-pushed local branches.
1052
1057
 
1053
1058
  \`\`\`json
1054
1059
  {
@@ -1353,6 +1353,9 @@ ${question}`;
1353
1353
  // Add streaming callbacks only if streaming is enabled
1354
1354
  if (this.stream) {
1355
1355
  callAgentOptions.onContentUpdate = (content) => {
1356
+ // Agent may have been destroyed mid-stream; ignore in-flight updates.
1357
+ if (!this.messageManager)
1358
+ return;
1356
1359
  // Create assistant message on first chunk if not already created
1357
1360
  if (!assistantMessageCreated) {
1358
1361
  this.messageManager.addAssistantMessage();
@@ -1361,6 +1364,9 @@ ${question}`;
1361
1364
  this.messageManager.updateCurrentMessageContent(content);
1362
1365
  };
1363
1366
  callAgentOptions.onToolUpdate = (toolCall) => {
1367
+ // Agent may have been destroyed mid-stream; ignore in-flight updates.
1368
+ if (!this.messageManager)
1369
+ return;
1364
1370
  // Create assistant message on first tool update if not already created
1365
1371
  if (!assistantMessageCreated) {
1366
1372
  this.messageManager.addAssistantMessage();
@@ -1383,6 +1389,9 @@ ${question}`;
1383
1389
  });
1384
1390
  };
1385
1391
  callAgentOptions.onReasoningUpdate = (reasoning) => {
1392
+ // Agent may have been destroyed mid-stream; ignore in-flight updates.
1393
+ if (!this.messageManager)
1394
+ return;
1386
1395
  // Create assistant message on first reasoning update if not already created
1387
1396
  if (!assistantMessageCreated) {
1388
1397
  this.messageManager.addAssistantMessage();
@@ -10,6 +10,7 @@ import { minimatch } from "minimatch";
10
10
  import { RESTRICTED_TOOLS } from "../types/permissions.js";
11
11
  import { splitBashCommand, stripEnvVars, stripRedirections, hasWriteRedirections, getSmartPrefix, isDangerousFind, hasCommandSubstitution, hasProcessSubstitution, hasSedInPlace, stripGitScopePrefix, DANGEROUS_COMMANDS, READ_ONLY_COMMANDS, } from "../utils/bashParser.js";
12
12
  import { isPathInside } from "../utils/pathSafety.js";
13
+ import { toWindowsPath } from "../utils/path.js";
13
14
  import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, READ_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "../constants/tools.js";
14
15
  const DEFAULT_ALLOWED_RULES = [
15
16
  "Bash(git status*)",
@@ -286,29 +287,37 @@ export class PermissionManager {
286
287
  */
287
288
  isInsideSafeZone(targetPath, workdir) {
288
289
  const effectiveWorkdir = this.workdir || workdir;
290
+ // Convert MSYS/git-bash style paths (/c/Users/...) to native Windows form
291
+ // so `cd /c/...` and file args resolve correctly on win32 instead of
292
+ // becoming bogus C:\c\... paths that never match the Safe Zone.
293
+ const normalizedTarget = toWindowsPath(targetPath);
294
+ const normalizedWorkdir = effectiveWorkdir
295
+ ? toWindowsPath(effectiveWorkdir)
296
+ : undefined;
289
297
  // Resolve the target path relative to effectiveWorkdir if it's not absolute
290
- const absolutePath = effectiveWorkdir && !path.isAbsolute(targetPath)
291
- ? path.resolve(effectiveWorkdir, targetPath)
292
- : path.resolve(targetPath);
298
+ const absolutePath = normalizedWorkdir && !path.isAbsolute(normalizedTarget)
299
+ ? path.resolve(normalizedWorkdir, normalizedTarget)
300
+ : path.resolve(normalizedTarget);
293
301
  // Check workdir
294
- if (effectiveWorkdir && isPathInside(absolutePath, effectiveWorkdir)) {
302
+ if (effectiveWorkdir &&
303
+ isPathInside(absolutePath, toWindowsPath(effectiveWorkdir))) {
295
304
  return { isInside: true, resolvedPath: absolutePath };
296
305
  }
297
306
  // Check additional directories
298
307
  for (const dir of this.additionalDirectories) {
299
- if (isPathInside(absolutePath, dir)) {
308
+ if (isPathInside(absolutePath, toWindowsPath(dir))) {
300
309
  return { isInside: true, resolvedPath: absolutePath };
301
310
  }
302
311
  }
303
312
  // Check instance additional directories
304
313
  for (const dir of this.instanceAdditionalDirectories) {
305
- if (isPathInside(absolutePath, dir)) {
314
+ if (isPathInside(absolutePath, toWindowsPath(dir))) {
306
315
  return { isInside: true, resolvedPath: absolutePath };
307
316
  }
308
317
  }
309
318
  // Check system additional directories
310
319
  for (const dir of this.systemAdditionalDirectories) {
311
- if (isPathInside(absolutePath, dir)) {
320
+ if (isPathInside(absolutePath, toWindowsPath(dir))) {
312
321
  return { isInside: true, resolvedPath: absolutePath };
313
322
  }
314
323
  }
@@ -4,7 +4,7 @@ import * as crypto from "crypto";
4
4
  import { getPluginsDir } from "../utils/configPaths.js";
5
5
  import { GitService } from "./GitService.js";
6
6
  import { ConfigurationService } from "./configurationService.js";
7
- import { logger } from "../utils/globalLogger.js";
7
+ import { logger, logError, logWarn } from "../utils/globalLogger.js";
8
8
  /**
9
9
  * Marketplace Service
10
10
  *
@@ -270,7 +270,7 @@ export class MarketplaceService {
270
270
  return JSON.parse(content);
271
271
  }
272
272
  catch (error) {
273
- console.error("Failed to load installed plugins:", error);
273
+ logError("Failed to load installed plugins:", error);
274
274
  return { plugins: [] };
275
275
  }
276
276
  }
@@ -513,7 +513,7 @@ export class MarketplaceService {
513
513
  if (marketplace.source.source === "github" ||
514
514
  marketplace.source.source === "git") {
515
515
  if (!isGitAvailable) {
516
- console.warn(`Skipping update for Git/GitHub marketplace "${marketplace.name}" because Git is not installed.`);
516
+ logWarn(`Skipping update for Git/GitHub marketplace "${marketplace.name}" because Git is not installed.`);
517
517
  continue;
518
518
  }
519
519
  const targetPath = this.getMarketplacePath(marketplace.source);
@@ -548,7 +548,7 @@ export class MarketplaceService {
548
548
  await this.uninstallPlugin(`${plugin.name}@${plugin.marketplace}`, plugin.projectPath);
549
549
  }
550
550
  catch (error) {
551
- console.error(`Failed to uninstall orphaned plugin "${plugin.name}" from marketplace "${marketplace.name}":`, error);
551
+ logError(`Failed to uninstall orphaned plugin "${plugin.name}" from marketplace "${marketplace.name}":`, error);
552
552
  }
553
553
  continue;
554
554
  }
@@ -556,14 +556,14 @@ export class MarketplaceService {
556
556
  await this.installPlugin(`${plugin.name}@${plugin.marketplace}`, plugin.projectPath);
557
557
  }
558
558
  catch (error) {
559
- console.error(`Failed to update plugin "${plugin.name}" from marketplace "${marketplace.name}":`, error);
559
+ logError(`Failed to update plugin "${plugin.name}" from marketplace "${marketplace.name}":`, error);
560
560
  }
561
561
  }
562
562
  }
563
563
  }
564
564
  catch (error) {
565
565
  const msg = `Failed to update marketplace "${marketplace.name}": ${error instanceof Error ? error.message : String(error)}`;
566
- console.error(msg);
566
+ logError(msg);
567
567
  errors.push(msg);
568
568
  }
569
569
  }
@@ -588,7 +588,7 @@ export class MarketplaceService {
588
588
  });
589
589
  }
590
590
  catch (error) {
591
- console.error(`Auto-update failed for marketplace "${marketplaceName}":`, error);
591
+ logError(`Auto-update failed for marketplace "${marketplaceName}":`, error);
592
592
  }
593
593
  }
594
594
  });
@@ -8,6 +8,12 @@ import type { ConfigurationLoadResult, ValidationResult, ConfigurationPaths, Wav
8
8
  import { type EnvironmentValidationResult, type MergedEnvironmentContext, type EnvironmentMergeOptions } from "../types/environment.js";
9
9
  import { GatewayConfig, ModelConfig, PermissionMode, AgentOptions } from "../types/index.js";
10
10
  import { ClientOptions } from "openai";
11
+ /**
12
+ * Validate a configuration object's structure and values. Module-level so it
13
+ * can be reused without instantiating ConfigurationService (e.g. background
14
+ * session cleanup runs before config is loaded).
15
+ */
16
+ export declare function validateConfigurationObject(config: WaveConfiguration): ValidationResult;
11
17
  /**
12
18
  * Default ConfigurationService implementation
13
19
  *
@@ -17,6 +17,175 @@ import { getRemoteSettingsSync, mergeRemoteSettings, } from "./remoteSettingsSer
17
17
  import { createAuthAwareFetch } from "./authService.js";
18
18
  import { ensureWaveRuntimeFilesExcluded } from "../utils/gitUtils.js";
19
19
  import { atomicWriteFile } from "../utils/atomicWrite.js";
20
+ /**
21
+ * Validate a configuration object's structure and values. Module-level so it
22
+ * can be reused without instantiating ConfigurationService (e.g. background
23
+ * session cleanup runs before config is loaded).
24
+ */
25
+ export function validateConfigurationObject(config) {
26
+ const result = {
27
+ isValid: true,
28
+ errors: [],
29
+ warnings: [],
30
+ };
31
+ // Validate basic structure
32
+ if (!config || typeof config !== "object") {
33
+ result.isValid = false;
34
+ result.errors.push("Configuration must be a valid object");
35
+ return result;
36
+ }
37
+ // Validate hooks if present
38
+ if (config.hooks !== undefined) {
39
+ if (typeof config.hooks !== "object" || config.hooks === null) {
40
+ result.isValid = false;
41
+ result.errors.push("Hooks configuration must be an object");
42
+ }
43
+ else {
44
+ for (const [event, eventConfigs] of Object.entries(config.hooks)) {
45
+ if (!isValidHookEvent(event)) {
46
+ result.warnings.push(`Unknown hook event: ${event}`);
47
+ continue;
48
+ }
49
+ if (!Array.isArray(eventConfigs)) {
50
+ result.isValid = false;
51
+ result.errors.push(`Hook event '${event}' must be an array`);
52
+ continue;
53
+ }
54
+ // Validate individual hook configurations
55
+ for (let i = 0; i < eventConfigs.length; i++) {
56
+ const hookConfig = eventConfigs[i];
57
+ if (!hookConfig || typeof hookConfig !== "object") {
58
+ result.isValid = false;
59
+ result.errors.push(`Hook configuration ${i} for event '${event}' must be an object`);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ // Validate enabledPlugins if present
66
+ if (config.enabledPlugins !== undefined) {
67
+ if (typeof config.enabledPlugins !== "object" ||
68
+ config.enabledPlugins === null) {
69
+ result.isValid = false;
70
+ result.errors.push("enabledPlugins configuration must be an object");
71
+ }
72
+ else {
73
+ for (const [pluginId, enabled] of Object.entries(config.enabledPlugins)) {
74
+ if (typeof enabled !== "boolean") {
75
+ result.isValid = false;
76
+ result.errors.push(`Value for plugin '${pluginId}' in enabledPlugins must be a boolean`);
77
+ }
78
+ if (!pluginId.includes("@")) {
79
+ result.warnings.push(`Plugin ID '${pluginId}' in enabledPlugins should follow 'name@marketplace' format`);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ // Validate environment variables if present
85
+ if (config.env !== undefined) {
86
+ const envValidation = validateEnvironmentConfig(config.env);
87
+ if (!envValidation.isValid) {
88
+ result.isValid = false;
89
+ result.errors.push(...envValidation.errors);
90
+ }
91
+ result.warnings.push(...envValidation.warnings);
92
+ }
93
+ // Validate permissions if present
94
+ if (config.permissions !== undefined) {
95
+ if (typeof config.permissions !== "object" || config.permissions === null) {
96
+ result.isValid = false;
97
+ result.errors.push("Permissions configuration must be an object");
98
+ }
99
+ else {
100
+ // Validate allow if present
101
+ if (config.permissions.allow !== undefined) {
102
+ if (!Array.isArray(config.permissions.allow)) {
103
+ result.isValid = false;
104
+ result.errors.push("Permissions allow must be an array of strings");
105
+ }
106
+ else if (!config.permissions.allow.every((rule) => typeof rule === "string")) {
107
+ result.isValid = false;
108
+ result.errors.push("All permission allow rules must be strings");
109
+ }
110
+ }
111
+ // Validate deny if present
112
+ if (config.permissions.deny !== undefined) {
113
+ if (!Array.isArray(config.permissions.deny)) {
114
+ result.isValid = false;
115
+ result.errors.push("Permissions deny must be an array of strings");
116
+ }
117
+ else if (!config.permissions.deny.every((rule) => typeof rule === "string")) {
118
+ result.isValid = false;
119
+ result.errors.push("All permission deny rules must be strings");
120
+ }
121
+ }
122
+ // Validate permissionMode if present
123
+ if (config.permissions.permissionMode !== undefined) {
124
+ const validModes = [
125
+ "default",
126
+ "bypassPermissions",
127
+ "acceptEdits",
128
+ "plan",
129
+ "dontAsk",
130
+ ];
131
+ if (!validModes.includes(config.permissions.permissionMode)) {
132
+ result.isValid = false;
133
+ result.errors.push(`Invalid permissionMode: "${config.permissions.permissionMode}". Must be one of: ${validModes.join(", ")}`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ // Validate autoMemoryEnabled if present
139
+ if (config.autoMemoryEnabled !== undefined &&
140
+ typeof config.autoMemoryEnabled !== "boolean") {
141
+ result.isValid = false;
142
+ result.errors.push("autoMemoryEnabled configuration must be a boolean");
143
+ }
144
+ // Validate autoMemoryFrequency if present
145
+ if (config.autoMemoryFrequency !== undefined &&
146
+ (typeof config.autoMemoryFrequency !== "number" ||
147
+ config.autoMemoryFrequency <= 0)) {
148
+ result.isValid = false;
149
+ result.errors.push("autoMemoryFrequency configuration must be a positive number");
150
+ }
151
+ // Validate cleanupPeriodDays if present
152
+ if (config.cleanupPeriodDays !== undefined &&
153
+ (typeof config.cleanupPeriodDays !== "number" ||
154
+ !Number.isInteger(config.cleanupPeriodDays) ||
155
+ config.cleanupPeriodDays < 0)) {
156
+ result.isValid = false;
157
+ result.errors.push("cleanupPeriodDays configuration must be a non-negative integer");
158
+ }
159
+ // Validate models if present
160
+ if (config.models !== undefined) {
161
+ if (typeof config.models !== "object" || config.models === null) {
162
+ result.isValid = false;
163
+ result.errors.push("models configuration must be an object");
164
+ }
165
+ else {
166
+ for (const [modelName, modelConfig] of Object.entries(config.models)) {
167
+ if (typeof modelConfig !== "object" || modelConfig === null) {
168
+ result.isValid = false;
169
+ result.errors.push(`Configuration for model '${modelName}' must be an object`);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ // Validate worktree if present
175
+ if (config.worktree !== undefined) {
176
+ if (typeof config.worktree !== "object" || config.worktree === null) {
177
+ result.isValid = false;
178
+ result.errors.push("worktree configuration must be an object");
179
+ }
180
+ else if (config.worktree.baseRef !== undefined &&
181
+ config.worktree.baseRef !== "fresh" &&
182
+ config.worktree.baseRef !== "head") {
183
+ result.isValid = false;
184
+ result.errors.push(`Invalid worktree.baseRef: "${config.worktree.baseRef}". Must be "fresh" or "head".`);
185
+ }
186
+ }
187
+ return result;
188
+ }
20
189
  /**
21
190
  * Default ConfigurationService implementation
22
191
  *
@@ -120,161 +289,7 @@ export class ConfigurationService {
120
289
  * Validate configuration object structure and values
121
290
  */
122
291
  validateConfiguration(config) {
123
- const result = {
124
- isValid: true,
125
- errors: [],
126
- warnings: [],
127
- };
128
- // Validate basic structure
129
- if (!config || typeof config !== "object") {
130
- result.isValid = false;
131
- result.errors.push("Configuration must be a valid object");
132
- return result;
133
- }
134
- // Validate hooks if present
135
- if (config.hooks !== undefined) {
136
- if (typeof config.hooks !== "object" || config.hooks === null) {
137
- result.isValid = false;
138
- result.errors.push("Hooks configuration must be an object");
139
- }
140
- else {
141
- for (const [event, eventConfigs] of Object.entries(config.hooks)) {
142
- if (!isValidHookEvent(event)) {
143
- result.warnings.push(`Unknown hook event: ${event}`);
144
- continue;
145
- }
146
- if (!Array.isArray(eventConfigs)) {
147
- result.isValid = false;
148
- result.errors.push(`Hook event '${event}' must be an array`);
149
- continue;
150
- }
151
- // Validate individual hook configurations
152
- for (let i = 0; i < eventConfigs.length; i++) {
153
- const hookConfig = eventConfigs[i];
154
- if (!hookConfig || typeof hookConfig !== "object") {
155
- result.isValid = false;
156
- result.errors.push(`Hook configuration ${i} for event '${event}' must be an object`);
157
- }
158
- }
159
- }
160
- }
161
- }
162
- // Validate enabledPlugins if present
163
- if (config.enabledPlugins !== undefined) {
164
- if (typeof config.enabledPlugins !== "object" ||
165
- config.enabledPlugins === null) {
166
- result.isValid = false;
167
- result.errors.push("enabledPlugins configuration must be an object");
168
- }
169
- else {
170
- for (const [pluginId, enabled] of Object.entries(config.enabledPlugins)) {
171
- if (typeof enabled !== "boolean") {
172
- result.isValid = false;
173
- result.errors.push(`Value for plugin '${pluginId}' in enabledPlugins must be a boolean`);
174
- }
175
- if (!pluginId.includes("@")) {
176
- result.warnings.push(`Plugin ID '${pluginId}' in enabledPlugins should follow 'name@marketplace' format`);
177
- }
178
- }
179
- }
180
- }
181
- // Validate environment variables if present
182
- if (config.env !== undefined) {
183
- const envValidation = validateEnvironmentConfig(config.env);
184
- if (!envValidation.isValid) {
185
- result.isValid = false;
186
- result.errors.push(...envValidation.errors);
187
- }
188
- result.warnings.push(...envValidation.warnings);
189
- }
190
- // Validate permissions if present
191
- if (config.permissions !== undefined) {
192
- if (typeof config.permissions !== "object" ||
193
- config.permissions === null) {
194
- result.isValid = false;
195
- result.errors.push("Permissions configuration must be an object");
196
- }
197
- else {
198
- // Validate allow if present
199
- if (config.permissions.allow !== undefined) {
200
- if (!Array.isArray(config.permissions.allow)) {
201
- result.isValid = false;
202
- result.errors.push("Permissions allow must be an array of strings");
203
- }
204
- else if (!config.permissions.allow.every((rule) => typeof rule === "string")) {
205
- result.isValid = false;
206
- result.errors.push("All permission allow rules must be strings");
207
- }
208
- }
209
- // Validate deny if present
210
- if (config.permissions.deny !== undefined) {
211
- if (!Array.isArray(config.permissions.deny)) {
212
- result.isValid = false;
213
- result.errors.push("Permissions deny must be an array of strings");
214
- }
215
- else if (!config.permissions.deny.every((rule) => typeof rule === "string")) {
216
- result.isValid = false;
217
- result.errors.push("All permission deny rules must be strings");
218
- }
219
- }
220
- // Validate permissionMode if present
221
- if (config.permissions.permissionMode !== undefined) {
222
- const validModes = [
223
- "default",
224
- "bypassPermissions",
225
- "acceptEdits",
226
- "plan",
227
- "dontAsk",
228
- ];
229
- if (!validModes.includes(config.permissions.permissionMode)) {
230
- result.isValid = false;
231
- result.errors.push(`Invalid permissionMode: "${config.permissions.permissionMode}". Must be one of: ${validModes.join(", ")}`);
232
- }
233
- }
234
- }
235
- }
236
- // Validate autoMemoryEnabled if present
237
- if (config.autoMemoryEnabled !== undefined &&
238
- typeof config.autoMemoryEnabled !== "boolean") {
239
- result.isValid = false;
240
- result.errors.push("autoMemoryEnabled configuration must be a boolean");
241
- }
242
- // Validate autoMemoryFrequency if present
243
- if (config.autoMemoryFrequency !== undefined &&
244
- (typeof config.autoMemoryFrequency !== "number" ||
245
- config.autoMemoryFrequency <= 0)) {
246
- result.isValid = false;
247
- result.errors.push("autoMemoryFrequency configuration must be a positive number");
248
- }
249
- // Validate models if present
250
- if (config.models !== undefined) {
251
- if (typeof config.models !== "object" || config.models === null) {
252
- result.isValid = false;
253
- result.errors.push("models configuration must be an object");
254
- }
255
- else {
256
- for (const [modelName, modelConfig] of Object.entries(config.models)) {
257
- if (typeof modelConfig !== "object" || modelConfig === null) {
258
- result.isValid = false;
259
- result.errors.push(`Configuration for model '${modelName}' must be an object`);
260
- }
261
- }
262
- }
263
- }
264
- // Validate worktree if present
265
- if (config.worktree !== undefined) {
266
- if (typeof config.worktree !== "object" || config.worktree === null) {
267
- result.isValid = false;
268
- result.errors.push("worktree configuration must be an object");
269
- }
270
- else if (config.worktree.baseRef !== undefined &&
271
- config.worktree.baseRef !== "fresh" &&
272
- config.worktree.baseRef !== "head") {
273
- result.isValid = false;
274
- result.errors.push(`Invalid worktree.baseRef: "${config.worktree.baseRef}". Must be "fresh" or "head".`);
275
- }
276
- }
277
- return result;
292
+ return validateConfigurationObject(config);
278
293
  }
279
294
  /**
280
295
  * Validate configuration file without loading
@@ -1078,6 +1093,9 @@ export function loadWaveConfigFromFile(filePath) {
1078
1093
  autoMemoryFrequency: config.autoMemoryFrequency !== undefined
1079
1094
  ? config.autoMemoryFrequency
1080
1095
  : undefined,
1096
+ cleanupPeriodDays: config.cleanupPeriodDays !== undefined
1097
+ ? config.cleanupPeriodDays
1098
+ : undefined,
1081
1099
  models: config.models || undefined,
1082
1100
  marketplaces: config.marketplaces || undefined,
1083
1101
  worktree: config.worktree || undefined,
@@ -1216,6 +1234,10 @@ export function loadMergedWaveConfig(workdir) {
1216
1234
  if (config.autoMemoryFrequency !== undefined) {
1217
1235
  mergedConfig.autoMemoryFrequency = config.autoMemoryFrequency;
1218
1236
  }
1237
+ // Merge cleanupPeriodDays (last one wins)
1238
+ if (config.cleanupPeriodDays !== undefined) {
1239
+ mergedConfig.cleanupPeriodDays = config.cleanupPeriodDays;
1240
+ }
1219
1241
  // Merge marketplaces (last one wins for same key)
1220
1242
  if (config.marketplaces) {
1221
1243
  if (!mergedConfig.marketplaces)
@@ -1260,6 +1282,7 @@ export function loadMergedWaveConfig(workdir) {
1260
1282
  language: mergedConfig.language,
1261
1283
  model: mergedConfig.model,
1262
1284
  autoMemoryEnabled: mergedConfig.autoMemoryEnabled,
1285
+ cleanupPeriodDays: mergedConfig.cleanupPeriodDays,
1263
1286
  marketplaces: mergedConfig.marketplaces &&
1264
1287
  Object.keys(mergedConfig.marketplaces).length > 0
1265
1288
  ? mergedConfig.marketplaces
@@ -150,13 +150,6 @@ export declare function listAllSessions(options?: {
150
150
  worktreePaths?: string[];
151
151
  workdir?: string;
152
152
  }): Promise<SessionMetadata[]>;
153
- /**
154
- * Clean up expired sessions older than 14 days based on file modification time
155
- *
156
- * @param workdir - Working directory to clean up sessions for
157
- * @returns Promise that resolves to the number of sessions that were deleted
158
- */
159
- export declare function cleanupExpiredSessionsFromJsonl(workdir: string): Promise<number>;
160
153
  /**
161
154
  * Clean up empty project directories in the session directory
162
155
  */
@@ -43,7 +43,6 @@ export function generateSubagentFilename(sessionId) {
43
43
  }
44
44
  // Constants
45
45
  export const SESSION_DIR = join(homedir(), ".wave", "projects");
46
- const MAX_SESSION_AGE_DAYS = 14;
47
46
  /**
48
47
  * Ensure session directory exists
49
48
  */
@@ -568,59 +567,6 @@ export async function listAllSessions(options) {
568
567
  throw new Error(`Failed to list all sessions: ${error}`);
569
568
  }
570
569
  }
571
- /**
572
- * Clean up expired sessions older than 14 days based on file modification time
573
- *
574
- * @param workdir - Working directory to clean up sessions for
575
- * @returns Promise that resolves to the number of sessions that were deleted
576
- */
577
- export async function cleanupExpiredSessionsFromJsonl(workdir) {
578
- // Do not perform cleanup operations in test environment
579
- if (process.env.NODE_ENV === "test") {
580
- return 0;
581
- }
582
- try {
583
- const encoder = new PathEncoder();
584
- const projectDir = await encoder.getProjectDirectory(workdir, SESSION_DIR);
585
- const files = await fs.readdir(projectDir.encodedPath);
586
- const now = new Date();
587
- const maxAge = MAX_SESSION_AGE_DAYS * 24 * 60 * 60 * 1000; // Convert to milliseconds
588
- let deletedCount = 0;
589
- for (const file of files) {
590
- if (!file.endsWith(".jsonl")) {
591
- continue;
592
- }
593
- const filePath = join(projectDir.encodedPath, file);
594
- try {
595
- const stat = await fs.stat(filePath);
596
- const fileAge = now.getTime() - stat.mtime.getTime();
597
- if (fileAge > maxAge) {
598
- await fs.unlink(filePath);
599
- deletedCount++;
600
- }
601
- }
602
- catch {
603
- // Skip failed operations and continue processing other files
604
- continue;
605
- }
606
- }
607
- // Clean up empty project directory if no files remain
608
- try {
609
- const remainingFiles = await fs.readdir(projectDir.encodedPath);
610
- if (remainingFiles.length === 0) {
611
- await fs.rmdir(projectDir.encodedPath);
612
- }
613
- }
614
- catch {
615
- // Ignore errors if directory is not empty or can't be removed
616
- }
617
- return deletedCount;
618
- }
619
- catch {
620
- // Return 0 if project directory doesn't exist or can't be accessed
621
- return 0;
622
- }
623
- }
624
570
  /**
625
571
  * Clean up empty project directories in the session directory
626
572
  */
@@ -859,10 +805,6 @@ export async function handleSessionRestoration(restoreSessionId, continueLastSes
859
805
  if (!workdir) {
860
806
  throw new Error("Working directory is required for session restoration");
861
807
  }
862
- // Clean up expired sessions first
863
- cleanupExpiredSessionsFromJsonl(workdir).catch((error) => {
864
- logger.warn("Failed to cleanup expired sessions:", error);
865
- });
866
808
  if (!restoreSessionId && !continueLastSession) {
867
809
  return;
868
810
  }
@@ -57,6 +57,13 @@ export interface WaveConfiguration {
57
57
  };
58
58
  /** Whether the Artifact tool is enabled. Unset follows the code default constant (ARTIFACT_DEFAULT_ENABLED). */
59
59
  enableArtifact?: boolean;
60
+ /**
61
+ * Session transcript retention in days (aligned with Claude Code's
62
+ * cleanupPeriodDays). Session jsonl files in ~/.wave/projects older than
63
+ * this many days are cleaned up in the background at startup.
64
+ * Default: 30. 0 disables cleanup entirely.
65
+ */
66
+ cleanupPeriodDays?: number;
60
67
  }
61
68
  /**
62
69
  * Legacy alias for backward compatibility - will be deprecated
@@ -39,8 +39,8 @@ export interface Change {
39
39
  }
40
40
  export declare class ConfigurationError extends Error {
41
41
  readonly field: string;
42
- readonly provided?: unknown | undefined;
43
- constructor(message: string, field: string, provided?: unknown | undefined);
42
+ readonly provided?: unknown;
43
+ constructor(message: string, field: string, provided?: unknown);
44
44
  }
45
45
  export declare const CONFIG_ERRORS: {
46
46
  readonly MISSING_MODEL: "Agent configuration requires model. Provide via constructor or WAVE_MODEL environment variable.";
@@ -27,4 +27,10 @@ export declare class Container {
27
27
  * Check if a service exists
28
28
  */
29
29
  has(token: string): boolean;
30
+ /**
31
+ * Remove all registered services and factories. Call during teardown so the
32
+ * container does not retain references to per-agent services after the
33
+ * agent is destroyed (breaking the agent's object graph).
34
+ */
35
+ clear(): void;
30
36
  }
@@ -52,6 +52,15 @@ export class Container {
52
52
  this.factories.has(token) ||
53
53
  (this.parent ? this.parent.has(token) : false));
54
54
  }
55
+ /**
56
+ * Remove all registered services and factories. Call during teardown so the
57
+ * container does not retain references to per-agent services after the
58
+ * agent is destroyed (breaking the agent's object graph).
59
+ */
60
+ clear() {
61
+ this.services.clear();
62
+ this.factories.clear();
63
+ }
55
64
  }
56
65
  // Example usage for ToolManager:
57
66
  /*
@@ -16,4 +16,14 @@ export interface AgentContainerSetupOptions {
16
16
  addPermissionRule: (rule: string) => Promise<void>;
17
17
  addUsage: (usage: Usage) => void;
18
18
  }
19
- export declare function setupAgentContainer(setupOptions: AgentContainerSetupOptions): Container;
19
+ export interface AgentContainerSetupResult {
20
+ container: Container;
21
+ /**
22
+ * Unregister module-level listeners registered during setup (remote settings
23
+ * hot-update, auth change). Must be called when the agent is destroyed;
24
+ * without it the module-level callback arrays pin the agent's object graph
25
+ * (the callback closures capture per-agent managers).
26
+ */
27
+ teardown: () => void;
28
+ }
29
+ export declare function setupAgentContainer(setupOptions: AgentContainerSetupOptions): AgentContainerSetupResult;
@@ -23,6 +23,7 @@ import { SubagentManager } from "../managers/subagentManager.js";
23
23
  import { LiveConfigManager } from "../managers/liveConfigManager.js";
24
24
  import { ReversionService } from "../services/reversionService.js";
25
25
  import { cleanupMetaOnlySessions } from "../services/session.js";
26
+ import { runSessionCleanupInBackground } from "./sessionCleanup.js";
26
27
  import { MemoryService } from "../services/memory.js";
27
28
  import { AutoMemoryService } from "../services/autoMemoryService.js";
28
29
  import { USER_MEMORY_FILE } from "./constants.js";
@@ -37,6 +38,12 @@ export function setupAgentContainer(setupOptions) {
37
38
  const container = new Container();
38
39
  container.register("AgentOptions", options);
39
40
  container.register("Workdir", workdir);
41
+ // Module-level listener teardowns collected during setup and returned to the
42
+ // agent, which invokes them in destroy(). The remote-settings callback
43
+ // strongly captures the per-agent LiveConfigManager: without unsubscribing
44
+ // it in destroy(), the module-level callback array keeps every created
45
+ // agent's object graph alive even after the host drops its references.
46
+ const teardowns = [];
40
47
  if (options.worktreeName) {
41
48
  container.register("WorktreeName", options.worktreeName);
42
49
  container.register("MainRepoRoot", getGitMainRepoRoot(workdir));
@@ -106,14 +113,14 @@ export function setupAgentContainer(setupOptions) {
106
113
  });
107
114
  container.register("McpManager", mcpManager);
108
115
  // Wire up auth change callback to refresh/clear remote settings
109
- authService.onAuthChange(async (event) => {
116
+ teardowns.push(authService.onAuthChange(async (event) => {
110
117
  if (event === "login") {
111
118
  await remoteSettingsService.refresh();
112
119
  }
113
120
  else if (event === "logout") {
114
121
  remoteSettingsService.clear();
115
122
  }
116
- });
123
+ }));
117
124
  const lspManager = options.lspManager || new LspManager(container);
118
125
  container.register("LspManager", lspManager);
119
126
  const permissionManager = new PermissionManager(container, {
@@ -160,6 +167,9 @@ export function setupAgentContainer(setupOptions) {
160
167
  .catch((error) => {
161
168
  logger.error("Failed to cleanup meta-only session files:", error);
162
169
  });
170
+ // Global session retention cleanup (cleanupPeriodDays, default 30 days).
171
+ // Once per process; reads settings itself since config isn't loaded yet.
172
+ runSessionCleanupInBackground(workdir);
163
173
  const reversionManager = new ReversionManager(container);
164
174
  container.register("ReversionManager", reversionManager);
165
175
  const canUseToolWithPermissionRequest = options.canUseTool
@@ -241,9 +251,12 @@ export function setupAgentContainer(setupOptions) {
241
251
  container.register("LiveConfigManager", liveConfigManager);
242
252
  // Wire up remote settings hot-update: when polling detects changed settings,
243
253
  // reload configuration so admin changes propagate to the running agent.
244
- remoteSettingsService.onSettingsUpdate(async () => {
254
+ // The callback strongly captures the per-agent LiveConfigManager, so it MUST
255
+ // be unsubscribed in destroy() via teardown — otherwise the module-level
256
+ // callback array pins the whole agent object graph.
257
+ teardowns.push(remoteSettingsService.onSettingsUpdate(async () => {
245
258
  await liveConfigManager.reload();
246
- });
259
+ }));
247
260
  const subagentManager = new SubagentManager(container, {
248
261
  workdir,
249
262
  callbacks: {
@@ -283,5 +296,12 @@ export function setupAgentContainer(setupOptions) {
283
296
  cronManager.start();
284
297
  const workflowManager = new WorkflowManager(container);
285
298
  container.register("WorkflowManager", workflowManager);
286
- return container;
299
+ return {
300
+ container,
301
+ teardown: () => {
302
+ for (const unsubscribe of teardowns) {
303
+ unsubscribe();
304
+ }
305
+ },
306
+ };
287
307
  }
@@ -85,3 +85,16 @@ export declare const logger: {
85
85
  */
86
86
  readonly error: (...args: unknown[]) => void;
87
87
  };
88
+ /**
89
+ * Log an error through the channel matching the execution context:
90
+ * - Interactive terminals (stdout is a TTY) with a configured global logger
91
+ * route through the logger (e.g. a log file), keeping the terminal UI clean.
92
+ * - Non-interactive hosts (stdio pipes, scripts) and contexts without a
93
+ * configured logger fall back to stderr via console.error.
94
+ */
95
+ export declare function logError(...args: unknown[]): void;
96
+ /**
97
+ * Log a warning through the channel matching the execution context
98
+ * (see logError for the routing rule).
99
+ */
100
+ export declare function logWarn(...args: unknown[]): void;
@@ -118,3 +118,30 @@ export const logger = {
118
118
  globalLogger.error(...args);
119
119
  },
120
120
  };
121
+ /**
122
+ * Log an error through the channel matching the execution context:
123
+ * - Interactive terminals (stdout is a TTY) with a configured global logger
124
+ * route through the logger (e.g. a log file), keeping the terminal UI clean.
125
+ * - Non-interactive hosts (stdio pipes, scripts) and contexts without a
126
+ * configured logger fall back to stderr via console.error.
127
+ */
128
+ export function logError(...args) {
129
+ if (process.stdout.isTTY && isLoggerConfigured()) {
130
+ logger.error(...args);
131
+ }
132
+ else {
133
+ console.error(...args);
134
+ }
135
+ }
136
+ /**
137
+ * Log a warning through the channel matching the execution context
138
+ * (see logError for the routing rule).
139
+ */
140
+ export function logWarn(...args) {
141
+ if (process.stdout.isTTY && isLoggerConfigured()) {
142
+ logger.warn(...args);
143
+ }
144
+ else {
145
+ console.warn(...args);
146
+ }
147
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Session retention cleanup — aligned with Claude Code's cleanupOldSessionFiles()
3
+ * (~/.claude-code/src/utils/cleanup.ts).
4
+ *
5
+ * Scans ~/.wave/projects for session .jsonl files (main `<uuid>.jsonl` and
6
+ * `subagent-<uuid>.jsonl`) whose mtime is older than the retention cutoff and
7
+ * deletes them, then removes project directories left empty. Auto-memory
8
+ * (`memory/` subdirectories) is never touched.
9
+ *
10
+ * Retention is configurable via settings `cleanupPeriodDays` (default 30).
11
+ * `0` disables cleanup. If settings are corrupt or fail validation while the
12
+ * user explicitly set `cleanupPeriodDays`, cleanup is skipped entirely — the
13
+ * same guard Claude Code uses to avoid deleting files when the configured
14
+ * retention period cannot be trusted.
15
+ */
16
+ /** Default retention period in days (Claude Code DEFAULT_CLEANUP_PERIOD_DAYS). */
17
+ export declare const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
18
+ export interface SessionCleanupResult {
19
+ /** Number of session files deleted */
20
+ deleted: number;
21
+ /** Number of files/directories that failed to process */
22
+ errors: number;
23
+ }
24
+ /**
25
+ * Resolve the effective cleanup period in days, or null to skip cleanup.
26
+ *
27
+ * null (skip) happens when:
28
+ * - settings files exist but cannot be parsed/loaded (corrupt JSON, mid-write)
29
+ * - settings validation fails AND the user explicitly set cleanupPeriodDays
30
+ *
31
+ * Missing settings entirely is NOT a skip: the default 30 days applies.
32
+ */
33
+ export declare function resolveCleanupPeriodDays(workdir: string): number | null;
34
+ /**
35
+ * Delete session .jsonl files in ~/.wave/projects older than periodDays,
36
+ * then remove project directories left empty. Directories that still contain
37
+ * anything (e.g. `memory/` auto-memory) are preserved. Never throws; errors
38
+ * are counted and skipped, and a missing/unreadable projects dir is a silent
39
+ * no-op.
40
+ */
41
+ export declare function cleanupOldSessionFiles(periodDays: number): Promise<SessionCleanupResult>;
42
+ /**
43
+ * Kick off session cleanup in the background, once per process. Fire-and-forget:
44
+ * never throws, never blocks agent startup. In test environments cleanup is a
45
+ * no-op (same convention as the other startup cleanups in session.ts).
46
+ */
47
+ export declare function runSessionCleanupInBackground(workdir: string): void;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Session retention cleanup — aligned with Claude Code's cleanupOldSessionFiles()
3
+ * (~/.claude-code/src/utils/cleanup.ts).
4
+ *
5
+ * Scans ~/.wave/projects for session .jsonl files (main `<uuid>.jsonl` and
6
+ * `subagent-<uuid>.jsonl`) whose mtime is older than the retention cutoff and
7
+ * deletes them, then removes project directories left empty. Auto-memory
8
+ * (`memory/` subdirectories) is never touched.
9
+ *
10
+ * Retention is configurable via settings `cleanupPeriodDays` (default 30).
11
+ * `0` disables cleanup. If settings are corrupt or fail validation while the
12
+ * user explicitly set `cleanupPeriodDays`, cleanup is skipped entirely — the
13
+ * same guard Claude Code uses to avoid deleting files when the configured
14
+ * retention period cannot be trusted.
15
+ */
16
+ import { existsSync, promises as fs } from "fs";
17
+ import { join } from "path";
18
+ import { logger } from "./globalLogger.js";
19
+ import { SESSION_DIR } from "../services/session.js";
20
+ import { loadMergedWaveConfig, validateConfigurationObject, } from "../services/configurationService.js";
21
+ import { getProjectConfigPaths, getUserConfigPaths } from "./configPaths.js";
22
+ /** Default retention period in days (Claude Code DEFAULT_CLEANUP_PERIOD_DAYS). */
23
+ export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
24
+ // Module-level flag: session cleanup runs once per process, on first agent
25
+ // container setup — aligned with CC's once-per-process startup housekeeping.
26
+ let cleanupScheduled = false;
27
+ /**
28
+ * Resolve the effective cleanup period in days, or null to skip cleanup.
29
+ *
30
+ * null (skip) happens when:
31
+ * - settings files exist but cannot be parsed/loaded (corrupt JSON, mid-write)
32
+ * - settings validation fails AND the user explicitly set cleanupPeriodDays
33
+ *
34
+ * Missing settings entirely is NOT a skip: the default 30 days applies.
35
+ */
36
+ export function resolveCleanupPeriodDays(workdir) {
37
+ let merged;
38
+ try {
39
+ merged = loadMergedWaveConfig(workdir);
40
+ }
41
+ catch (error) {
42
+ logger.debug(`Session cleanup: skipping (failed to load settings: ${error.message})`);
43
+ return null;
44
+ }
45
+ // No config file at all → default retention. Config files exist but merged
46
+ // config is null (corrupt JSON / empty file) → skip conservatively rather
47
+ // than deleting based on a partial config.
48
+ if (merged === null) {
49
+ if (hasAnySettingsFile(workdir)) {
50
+ logger.debug("Session cleanup: skipping (settings file exists but could not be parsed)");
51
+ return null;
52
+ }
53
+ return DEFAULT_CLEANUP_PERIOD_DAYS;
54
+ }
55
+ // Guard (CC): validation errors + explicit cleanupPeriodDays → skip entirely.
56
+ const validation = validateConfigurationObject(merged);
57
+ if (validation.errors.length > 0 && merged.cleanupPeriodDays !== undefined) {
58
+ logger.debug("Session cleanup: skipping (settings have validation errors but cleanupPeriodDays was explicitly set). Fix settings errors to enable cleanup.");
59
+ return null;
60
+ }
61
+ return merged.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS;
62
+ }
63
+ /**
64
+ * Delete session .jsonl files in ~/.wave/projects older than periodDays,
65
+ * then remove project directories left empty. Directories that still contain
66
+ * anything (e.g. `memory/` auto-memory) are preserved. Never throws; errors
67
+ * are counted and skipped, and a missing/unreadable projects dir is a silent
68
+ * no-op.
69
+ */
70
+ export async function cleanupOldSessionFiles(periodDays) {
71
+ const result = { deleted: 0, errors: 0 };
72
+ const cutoffDate = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000);
73
+ let projectEntries;
74
+ try {
75
+ projectEntries = await fs.readdir(SESSION_DIR, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ // Projects dir doesn't exist or is unreadable — nothing to clean
79
+ return result;
80
+ }
81
+ for (const projectEntry of projectEntries) {
82
+ if (!projectEntry.isDirectory())
83
+ continue;
84
+ const projectDir = join(SESSION_DIR, projectEntry.name);
85
+ let entries;
86
+ try {
87
+ entries = await fs.readdir(projectDir, { withFileTypes: true });
88
+ }
89
+ catch {
90
+ result.errors++;
91
+ continue;
92
+ }
93
+ for (const entry of entries) {
94
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
95
+ continue;
96
+ try {
97
+ if (await unlinkIfOld(join(projectDir, entry.name), cutoffDate)) {
98
+ result.deleted++;
99
+ }
100
+ }
101
+ catch {
102
+ result.errors++;
103
+ }
104
+ }
105
+ // Removes the project dir only if it is now empty; dirs still containing
106
+ // files (e.g. memory/) or subdirectories are left untouched.
107
+ await tryRmdir(projectDir);
108
+ }
109
+ return result;
110
+ }
111
+ /**
112
+ * Kick off session cleanup in the background, once per process. Fire-and-forget:
113
+ * never throws, never blocks agent startup. In test environments cleanup is a
114
+ * no-op (same convention as the other startup cleanups in session.ts).
115
+ */
116
+ export function runSessionCleanupInBackground(workdir) {
117
+ if (process.env.NODE_ENV === "test")
118
+ return;
119
+ if (cleanupScheduled)
120
+ return;
121
+ cleanupScheduled = true;
122
+ void (async () => {
123
+ try {
124
+ const periodDays = resolveCleanupPeriodDays(workdir);
125
+ if (periodDays === null) {
126
+ return; // skip reason already logged in resolveCleanupPeriodDays
127
+ }
128
+ if (periodDays === 0) {
129
+ logger.debug("Session cleanup: disabled (cleanupPeriodDays is 0)");
130
+ return;
131
+ }
132
+ const result = await cleanupOldSessionFiles(periodDays);
133
+ if (result.deleted > 0) {
134
+ logger.debug(`Session cleanup: removed ${result.deleted} session file(s)`);
135
+ }
136
+ if (result.errors > 0) {
137
+ logger.warn(`Session cleanup: encountered ${result.errors} error(s) while cleaning session files`);
138
+ }
139
+ }
140
+ catch (error) {
141
+ logger.warn(`Session cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
142
+ }
143
+ })();
144
+ }
145
+ /**
146
+ * Whether any settings file exists that loadMergedWaveConfig would consider
147
+ * (user settings.json + project settings.json/local.json).
148
+ */
149
+ function hasAnySettingsFile(workdir) {
150
+ const userPaths = getUserConfigPaths();
151
+ const projectPaths = getProjectConfigPaths(workdir);
152
+ return [userPaths[0], projectPaths[1], projectPaths[0]].some((p) => existsSync(p));
153
+ }
154
+ async function unlinkIfOld(filePath, cutoffDate) {
155
+ const stats = await fs.stat(filePath);
156
+ if (stats.mtime < cutoffDate) {
157
+ await fs.unlink(filePath);
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ async function tryRmdir(dirPath) {
163
+ try {
164
+ await fs.rmdir(dirPath);
165
+ }
166
+ catch {
167
+ // Not empty or doesn't exist
168
+ }
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",
@@ -62,7 +62,6 @@
62
62
  "@types/turndown": "^5.0.6",
63
63
  "@vitest/coverage-v8": "^4.1.7",
64
64
  "rimraf": "^6.1.2",
65
- "tsc-alias": "^1.8.16",
66
65
  "vitest": "^4.1.7"
67
66
  },
68
67
  "engines": {
@@ -70,15 +69,15 @@
70
69
  },
71
70
  "license": "MIT",
72
71
  "scripts": {
73
- "build": "rimraf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
72
+ "build": "rimraf dist && tsc -p tsconfig.build.json",
74
73
  "type-check": "tsc --noEmit --incremental",
75
- "watch": "tsc -p tsconfig.build.json --watch & tsc-alias -p tsconfig.build.json --watch",
74
+ "watch": "tsc -p tsconfig.build.json --watch",
76
75
  "test": "vitest run --reporter=dot",
77
76
  "test:coverage": "vitest run --coverage --reporter=dot",
78
77
  "test:unit": "vitest run --reporter=dot --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
79
78
  "test:unit:coverage": "vitest run --coverage --reporter=dot --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
80
79
  "test:integration": "vitest run --reporter=dot tests/integration .integration.test",
81
- "lint": "eslint --cache",
80
+ "lint": "oxlint",
82
81
  "format": "prettier --write ."
83
82
  }
84
83
  }