wave-agent-sdk 1.1.2 → 1.1.4
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/builtin/skills/settings.js +7 -2
- package/dist/managers/aiManager.js +8 -8
- package/dist/managers/liveConfigManager.d.ts +1 -1
- package/dist/managers/liveConfigManager.js +5 -2
- package/dist/managers/permissionManager.js +16 -7
- package/dist/services/MarketplaceService.js +7 -7
- package/dist/services/configurationService.d.ts +6 -0
- package/dist/services/configurationService.js +178 -155
- package/dist/services/session.d.ts +0 -7
- package/dist/services/session.js +0 -58
- package/dist/types/configuration.d.ts +7 -0
- package/dist/types/core.d.ts +2 -2
- package/dist/utils/containerSetup.js +11 -3
- package/dist/utils/convertMessagesForAPI.js +1 -1
- package/dist/utils/globalLogger.d.ts +13 -0
- package/dist/utils/globalLogger.js +27 -0
- package/dist/utils/messageOperations.js +1 -0
- package/dist/utils/sessionCleanup.d.ts +47 -0
- package/dist/utils/sessionCleanup.js +169 -0
- package/dist/utils/tokenCalculation.d.ts +5 -17
- package/dist/utils/tokenCalculation.js +6 -23
- package/package.json +4 -5
|
@@ -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
|
-
- \`
|
|
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 \`"
|
|
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
|
{
|
|
@@ -3,7 +3,7 @@ import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
|
|
|
3
3
|
import { supportsVision } from "../utils/modelCapabilities.js";
|
|
4
4
|
import { persistToolImages } from "../utils/toolImagePersistence.js";
|
|
5
5
|
import { parseTaskNotificationXml, taskNotificationToXml, } from "../utils/notificationXml.js";
|
|
6
|
-
import {
|
|
6
|
+
import { estimateContextTokens } from "../utils/tokenCalculation.js";
|
|
7
7
|
import { estimateTokens } from "../utils/tokenEstimate.js";
|
|
8
8
|
import { getTaskReminderTurnCounts, maybeInjectTaskReminder, TASK_REMINDER_CONFIG, } from "../utils/taskReminder.js";
|
|
9
9
|
import { createWriteStream, existsSync } from "node:fs";
|
|
@@ -370,16 +370,16 @@ export class AIManager {
|
|
|
370
370
|
return "";
|
|
371
371
|
}
|
|
372
372
|
// Private method to update the displayed token statistics from a response.
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
373
|
+
// Uses only total_tokens — OpenAI-compatible usage already includes cache
|
|
374
|
+
// hits there, so adding cache fields would double-count and show an
|
|
375
|
+
// inflated context percentage. Same semantics as the auto-compaction
|
|
376
|
+
// threshold (maybeAutoCompactBeforeRequest), keeping display and
|
|
377
|
+
// compaction judgment consistent.
|
|
377
378
|
updateLatestTotalTokens(usage) {
|
|
378
379
|
if (!usage)
|
|
379
380
|
return;
|
|
380
|
-
// Update token statistics - display
|
|
381
|
-
|
|
382
|
-
this.messageManager.setlatestTotalTokens(comprehensiveTotalTokens);
|
|
381
|
+
// Update token statistics - display total_tokens (cache fields excluded)
|
|
382
|
+
this.messageManager.setlatestTotalTokens(usage.total_tokens);
|
|
383
383
|
}
|
|
384
384
|
/**
|
|
385
385
|
* Pre-request auto-compaction check (aligned with Claude Code's
|
|
@@ -10,7 +10,7 @@ import { Container } from "../utils/container.js";
|
|
|
10
10
|
import type { WaveConfiguration } from "../types/configuration.js";
|
|
11
11
|
export interface LiveConfigManagerOptions {
|
|
12
12
|
workdir: string;
|
|
13
|
-
onReload?: (config: WaveConfiguration) => void
|
|
13
|
+
onReload?: (config: WaveConfiguration) => void | Promise<void>;
|
|
14
14
|
}
|
|
15
15
|
export declare class LiveConfigManager {
|
|
16
16
|
private container;
|
|
@@ -177,8 +177,11 @@ export class LiveConfigManager {
|
|
|
177
177
|
this.permissionManager.updateDeniedRules(this.currentConfiguration.permissions?.deny || []);
|
|
178
178
|
this.permissionManager.updateAdditionalDirectories(this.currentConfiguration.permissions?.additionalDirectories || []);
|
|
179
179
|
}
|
|
180
|
-
// Trigger reload callback
|
|
181
|
-
|
|
180
|
+
// Trigger reload callback. Awaited so fire-and-forget work spawned by the
|
|
181
|
+
// callback (e.g. skill rediscovery) completes before the reload resolves —
|
|
182
|
+
// otherwise Agent.create() can return with a cleared skill map (see race
|
|
183
|
+
// where refreshSkills() clears skillMetadata before async discoverSkills).
|
|
184
|
+
await this.options.onReload?.(this.currentConfiguration);
|
|
182
185
|
return this.currentConfiguration;
|
|
183
186
|
}
|
|
184
187
|
catch (error) {
|
|
@@ -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 =
|
|
291
|
-
? path.resolve(
|
|
292
|
-
: path.resolve(
|
|
298
|
+
const absolutePath = normalizedWorkdir && !path.isAbsolute(normalizedTarget)
|
|
299
|
+
? path.resolve(normalizedWorkdir, normalizedTarget)
|
|
300
|
+
: path.resolve(normalizedTarget);
|
|
293
301
|
// Check workdir
|
|
294
|
-
if (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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/dist/services/session.js
CHANGED
|
@@ -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
|
package/dist/types/core.d.ts
CHANGED
|
@@ -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
|
|
43
|
-
constructor(message: string, field: string, provided?: unknown
|
|
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.";
|
|
@@ -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";
|
|
@@ -160,6 +161,9 @@ export function setupAgentContainer(setupOptions) {
|
|
|
160
161
|
.catch((error) => {
|
|
161
162
|
logger.error("Failed to cleanup meta-only session files:", error);
|
|
162
163
|
});
|
|
164
|
+
// Global session retention cleanup (cleanupPeriodDays, default 30 days).
|
|
165
|
+
// Once per process; reads settings itself since config isn't loaded yet.
|
|
166
|
+
runSessionCleanupInBackground(workdir);
|
|
163
167
|
const reversionManager = new ReversionManager(container);
|
|
164
168
|
container.register("ReversionManager", reversionManager);
|
|
165
169
|
const canUseToolWithPermissionRequest = options.canUseTool
|
|
@@ -221,15 +225,19 @@ export function setupAgentContainer(setupOptions) {
|
|
|
221
225
|
container.register("CanUseToolCallback", canUseToolWithPermissionRequest);
|
|
222
226
|
const liveConfigManager = new LiveConfigManager(container, {
|
|
223
227
|
workdir,
|
|
224
|
-
onReload: () => {
|
|
228
|
+
onReload: async () => {
|
|
225
229
|
const models = configurationService.getConfiguredModels();
|
|
226
230
|
callbacks.onConfiguredModelsChange?.(models);
|
|
227
231
|
// Re-evaluate feature-gated tools (e.g. Artifact behind
|
|
228
232
|
// enableArtifact) so toggling the flag applies without a restart.
|
|
229
233
|
toolManager.reloadFeatureGatedTools();
|
|
230
234
|
// Same gate for the builtin /artifact skill: refresh emits "refreshed"
|
|
231
|
-
// so slash-command registration follows enableArtifact.
|
|
232
|
-
|
|
235
|
+
// so slash-command registration follows enableArtifact. Awaited (via the
|
|
236
|
+
// awaited onReload) so skills are re-populated before Agent.create()
|
|
237
|
+
// returns — reloadFeatureGatedSkills() clears the skill map before
|
|
238
|
+
// rediscovering asynchronously, which would otherwise expose an empty
|
|
239
|
+
// skill list to callers right after create().
|
|
240
|
+
await skillManager.reloadFeatureGatedSkills().catch((error) => {
|
|
233
241
|
logger.error("Failed to reload feature-gated skills:", error);
|
|
234
242
|
});
|
|
235
243
|
},
|
|
@@ -299,7 +299,7 @@ export function convertMessagesForAPI(messages, options) {
|
|
|
299
299
|
if (block.type === "task_notification") {
|
|
300
300
|
contentParts.push({
|
|
301
301
|
type: "text",
|
|
302
|
-
text: taskNotificationToXml(block)
|
|
302
|
+
text: `A background agent completed a task:\n${taskNotificationToXml(block)}`,
|
|
303
303
|
});
|
|
304
304
|
}
|
|
305
305
|
});
|
|
@@ -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
|
+
}
|
|
@@ -482,6 +482,7 @@ export const addNotificationMessageToMessages = ({ messages, taskId, taskType, s
|
|
|
482
482
|
id: generateMessageId(),
|
|
483
483
|
role: "user",
|
|
484
484
|
blocks: [block],
|
|
485
|
+
isMeta: true,
|
|
485
486
|
timestamp: new Date().toISOString(),
|
|
486
487
|
};
|
|
487
488
|
return [...messages, notificationMessage];
|
|
@@ -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
|
+
}
|
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
import type { Message, Usage } from "../types/index.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* - Cache read tokens (cost savings indicator)
|
|
8
|
-
* - Cache creation tokens (cache investment)
|
|
9
|
-
*
|
|
10
|
-
* For accurate cost tracking with Claude models that support cache control.
|
|
11
|
-
*
|
|
12
|
-
* @param usage - Usage statistics from AI operation
|
|
13
|
-
* @returns Comprehensive total including all cache-related tokens
|
|
14
|
-
*/
|
|
15
|
-
export declare function calculateComprehensiveTotalTokens(usage: Usage): number;
|
|
16
|
-
/**
|
|
17
|
-
* Extract the latest total tokens from the last message with usage data
|
|
18
|
-
* Uses comprehensive calculation that includes cache tokens for accurate tracking
|
|
3
|
+
* Extract the latest total tokens from the last message with usage data.
|
|
4
|
+
* Uses only `total_tokens` — OpenAI-compatible usage already includes cache
|
|
5
|
+
* hits there, so adding cache fields would double-count (same semantics as
|
|
6
|
+
* the auto-compaction threshold; keeps UI usage display aligned with it).
|
|
19
7
|
*
|
|
20
8
|
* @param messages - Array of messages to search
|
|
21
|
-
* @returns
|
|
9
|
+
* @returns Total tokens from the most recent usage data, or 0 if none found
|
|
22
10
|
*/
|
|
23
11
|
export declare function extractLatestTotalTokens(messages: Array<{
|
|
24
12
|
usage?: Usage;
|
|
@@ -1,36 +1,19 @@
|
|
|
1
1
|
import { estimateTokens } from "./tokenEstimate.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* - Cache read tokens (cost savings indicator)
|
|
8
|
-
* - Cache creation tokens (cache investment)
|
|
9
|
-
*
|
|
10
|
-
* For accurate cost tracking with Claude models that support cache control.
|
|
11
|
-
*
|
|
12
|
-
* @param usage - Usage statistics from AI operation
|
|
13
|
-
* @returns Comprehensive total including all cache-related tokens
|
|
14
|
-
*/
|
|
15
|
-
export function calculateComprehensiveTotalTokens(usage) {
|
|
16
|
-
const baseTokens = usage.total_tokens;
|
|
17
|
-
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
18
|
-
const cacheCreateTokens = usage.cache_creation_input_tokens || 0;
|
|
19
|
-
return baseTokens + cacheReadTokens + cacheCreateTokens;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Extract the latest total tokens from the last message with usage data
|
|
23
|
-
* Uses comprehensive calculation that includes cache tokens for accurate tracking
|
|
3
|
+
* Extract the latest total tokens from the last message with usage data.
|
|
4
|
+
* Uses only `total_tokens` — OpenAI-compatible usage already includes cache
|
|
5
|
+
* hits there, so adding cache fields would double-count (same semantics as
|
|
6
|
+
* the auto-compaction threshold; keeps UI usage display aligned with it).
|
|
24
7
|
*
|
|
25
8
|
* @param messages - Array of messages to search
|
|
26
|
-
* @returns
|
|
9
|
+
* @returns Total tokens from the most recent usage data, or 0 if none found
|
|
27
10
|
*/
|
|
28
11
|
export function extractLatestTotalTokens(messages) {
|
|
29
12
|
// Find the last message with usage data (iterate backwards for efficiency)
|
|
30
13
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
31
14
|
const message = messages[i];
|
|
32
15
|
if (message.usage) {
|
|
33
|
-
return
|
|
16
|
+
return message.usage.total_tokens;
|
|
34
17
|
}
|
|
35
18
|
}
|
|
36
19
|
return 0; // No usage data found
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-agent-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
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
|
|
72
|
+
"build": "rimraf dist && tsc -p tsconfig.build.json",
|
|
74
73
|
"type-check": "tsc --noEmit --incremental",
|
|
75
|
-
"watch": "tsc -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": "
|
|
80
|
+
"lint": "oxlint",
|
|
82
81
|
"format": "prettier --write ."
|
|
83
82
|
}
|
|
84
83
|
}
|