wave-agent-sdk 1.0.10 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +16 -7
- package/dist/agent.js +44 -34
- package/dist/builtin/skills/settings.js +48 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/managers/aiManager.d.ts +23 -1
- package/dist/managers/aiManager.js +80 -30
- package/dist/managers/backgroundTaskManager.js +12 -0
- package/dist/managers/pluginScopeManager.js +13 -5
- package/dist/managers/subagentManager.d.ts +1 -0
- package/dist/managers/subagentManager.js +5 -1
- package/dist/services/GitService.js +6 -6
- package/dist/services/aiService.js +6 -2
- package/dist/services/configurationService.d.ts +4 -4
- package/dist/services/configurationService.js +14 -7
- package/dist/services/hook.js +41 -7
- package/dist/services/initializationService.js +0 -21
- package/dist/services/jsonlHandler.d.ts +37 -2
- package/dist/services/jsonlHandler.js +55 -6
- package/dist/services/session.d.ts +35 -4
- package/dist/services/session.js +233 -36
- package/dist/services/worktreeHooks.d.ts +45 -0
- package/dist/services/worktreeHooks.js +133 -0
- package/dist/tools/agentTool.js +36 -1
- package/dist/tools/bashTool.js +129 -58
- package/dist/tools/enterWorktreeTool.d.ts +1 -1
- package/dist/tools/enterWorktreeTool.js +44 -31
- package/dist/tools/exitWorktreeTool.js +21 -26
- package/dist/types/agent.d.ts +5 -0
- package/dist/types/config.d.ts +2 -1
- package/dist/types/hooks.d.ts +3 -2
- package/dist/types/skills.d.ts +0 -1
- package/dist/types/skills.js +0 -1
- package/dist/utils/asyncWorkRegistry.d.ts +32 -0
- package/dist/utils/asyncWorkRegistry.js +81 -0
- package/dist/utils/containerSetup.js +5 -0
- package/dist/utils/markdownParser.js +23 -5
- package/dist/utils/path.d.ts +9 -0
- package/dist/utils/path.js +32 -0
- package/dist/utils/skillParser.js +3 -6
- package/dist/utils/tokenCalculation.d.ts +19 -1
- package/dist/utils/tokenCalculation.js +64 -0
- package/dist/utils/windowsPaths.d.ts +28 -0
- package/dist/utils/windowsPaths.js +47 -0
- package/dist/utils/worktreeSession.d.ts +6 -0
- package/dist/utils/worktreeUtils.d.ts +7 -0
- package/dist/utils/worktreeUtils.js +88 -40
- package/package.json +5 -3
|
@@ -61,6 +61,13 @@ export class BackgroundTaskManager {
|
|
|
61
61
|
// Create log file
|
|
62
62
|
const logPath = path.join(os.tmpdir(), `wave-task-${id}.log`);
|
|
63
63
|
const logStream = fs.createWriteStream(logPath, { flags: "w" });
|
|
64
|
+
// A failed open/write of the log file (e.g. EBUSY on Windows when a stale
|
|
65
|
+
// handle from a previous run still locks the temp file) must not surface as
|
|
66
|
+
// an uncaught stream error — the log is best-effort side output and the
|
|
67
|
+
// task itself keeps running regardless.
|
|
68
|
+
logStream.on("error", (error) => {
|
|
69
|
+
logger.warn(`Failed to write background task log ${logPath}:`, error);
|
|
70
|
+
});
|
|
64
71
|
const shell = {
|
|
65
72
|
id,
|
|
66
73
|
type: "shell",
|
|
@@ -232,6 +239,11 @@ export class BackgroundTaskManager {
|
|
|
232
239
|
// Create log file
|
|
233
240
|
const logPath = path.join(os.tmpdir(), `wave-task-${id}.log`);
|
|
234
241
|
const logStream = fs.createWriteStream(logPath, { flags: "w" });
|
|
242
|
+
// Same best-effort handling as startShell: a locked temp file (EBUSY on
|
|
243
|
+
// Windows) must not become an uncaught stream error.
|
|
244
|
+
logStream.on("error", (error) => {
|
|
245
|
+
logger.warn(`Failed to write background task log ${logPath}:`, error);
|
|
246
|
+
});
|
|
235
247
|
// Write initial output to log file
|
|
236
248
|
if (initialStdout) {
|
|
237
249
|
logStream.write(stripAnsiColors(initialStdout));
|
|
@@ -32,15 +32,23 @@ export class PluginScopeManager {
|
|
|
32
32
|
* Priority: local > project > user
|
|
33
33
|
*/
|
|
34
34
|
findPluginScope(pluginId) {
|
|
35
|
-
const projectPaths = this.configurationService.getConfigurationPaths(this.workdir)
|
|
36
|
-
const
|
|
35
|
+
const { projectPaths, userPaths } = this.configurationService.getConfigurationPaths(this.workdir);
|
|
36
|
+
const userPathSet = new Set(userPaths);
|
|
37
|
+
// When the workdir is the user's home directory, projectPaths overlaps
|
|
38
|
+
// userPaths (both point at ~/.wave/settings.json). Treat such a file as the
|
|
39
|
+
// user config, otherwise user-scope plugins would be mislabeled "project".
|
|
37
40
|
const checkPaths = [
|
|
38
41
|
{ path: projectPaths[0], scope: "local" },
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
...(userPathSet.has(projectPaths[1])
|
|
43
|
+
? []
|
|
44
|
+
: [{ path: projectPaths[1], scope: "project" }]),
|
|
45
|
+
...userPaths.map((path) => ({ path, scope: "user" })),
|
|
42
46
|
];
|
|
47
|
+
const seen = new Set();
|
|
43
48
|
for (const { path, scope } of checkPaths) {
|
|
49
|
+
if (!path || seen.has(path))
|
|
50
|
+
continue;
|
|
51
|
+
seen.add(path);
|
|
44
52
|
const config = this.configurationService.loadWaveConfigFromFile(path);
|
|
45
53
|
if (config?.enabledPlugins && pluginId in config.enabledPlugins) {
|
|
46
54
|
return scope;
|
|
@@ -75,6 +75,7 @@ export declare class SubagentManager {
|
|
|
75
75
|
private stream;
|
|
76
76
|
constructor(container: Container, options: SubagentManagerOptions);
|
|
77
77
|
private get configurationService();
|
|
78
|
+
private get asyncWorkRegistry();
|
|
78
79
|
/**
|
|
79
80
|
* Initialize the SubagentManager by loading and caching configurations
|
|
80
81
|
*/
|
|
@@ -23,6 +23,9 @@ export class SubagentManager {
|
|
|
23
23
|
get configurationService() {
|
|
24
24
|
return this.container.get("ConfigurationService");
|
|
25
25
|
}
|
|
26
|
+
get asyncWorkRegistry() {
|
|
27
|
+
return this.container.get("AsyncWorkRegistry");
|
|
28
|
+
}
|
|
26
29
|
/**
|
|
27
30
|
* Initialize the SubagentManager by loading and caching configurations
|
|
28
31
|
*/
|
|
@@ -331,7 +334,7 @@ export class SubagentManager {
|
|
|
331
334
|
instance.backgroundTaskId = taskId;
|
|
332
335
|
// Execute in background
|
|
333
336
|
// Note: notification enqueueing is handled by internalExecute when instance.backgroundTaskId is set
|
|
334
|
-
(async () => {
|
|
337
|
+
const backgroundPromise = (async () => {
|
|
335
338
|
try {
|
|
336
339
|
const result = await this.internalExecute(instance, prompt, abortSignal);
|
|
337
340
|
const task = backgroundTaskManager?.getTask(taskId);
|
|
@@ -360,6 +363,7 @@ export class SubagentManager {
|
|
|
360
363
|
this.releaseInstance(instance.subagentId);
|
|
361
364
|
}
|
|
362
365
|
})();
|
|
366
|
+
this.asyncWorkRegistry?.track(backgroundPromise);
|
|
363
367
|
return taskId;
|
|
364
368
|
}
|
|
365
369
|
const result = await this.internalExecute(instance, prompt, abortSignal);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
|
-
const
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
4
|
export class GitService {
|
|
5
5
|
getTimeout() {
|
|
6
6
|
const envTimeout = process.env.WAVE_PLUGIN_GIT_TIMEOUT_MS;
|
|
@@ -17,7 +17,7 @@ export class GitService {
|
|
|
17
17
|
*/
|
|
18
18
|
async isGitAvailable() {
|
|
19
19
|
try {
|
|
20
|
-
await
|
|
20
|
+
await execFileAsync("git", ["--version"]);
|
|
21
21
|
return true;
|
|
22
22
|
}
|
|
23
23
|
catch {
|
|
@@ -43,8 +43,8 @@ export class GitService {
|
|
|
43
43
|
url = `https://github.com/${urlOrRepo}.git`;
|
|
44
44
|
}
|
|
45
45
|
try {
|
|
46
|
-
const refArgs = ref ?
|
|
47
|
-
await
|
|
46
|
+
const refArgs = ref ? ["-b", ref] : ["--depth", "1"];
|
|
47
|
+
await execFileAsync("git", ["clone", ...refArgs, url, targetPath], {
|
|
48
48
|
env: { ...process.env, LC_ALL: "C" },
|
|
49
49
|
timeout: this.getTimeout(),
|
|
50
50
|
});
|
|
@@ -64,7 +64,7 @@ export class GitService {
|
|
|
64
64
|
throw new Error("Git is not installed or not found in PATH. Please install Git to use Git/GitHub marketplaces.");
|
|
65
65
|
}
|
|
66
66
|
try {
|
|
67
|
-
await
|
|
67
|
+
await execFileAsync("git", ["-C", targetPath, "pull"], {
|
|
68
68
|
env: { ...process.env, LC_ALL: "C" },
|
|
69
69
|
timeout: this.getTimeout(),
|
|
70
70
|
});
|
|
@@ -4,6 +4,7 @@ import { addOnceAbortListener } from "../utils/abortUtils.js";
|
|
|
4
4
|
import { ConfigurationError, CONFIG_ERRORS } from "../types/index.js";
|
|
5
5
|
import { transformMessagesForExplicitCache, extendUsageWithCacheMetrics, } from "../utils/cacheControlUtils.js";
|
|
6
6
|
import { supportsPromptCaching } from "../utils/modelCapabilities.js";
|
|
7
|
+
import { DEFAULT_WAVE_MAX_OUTPUT_TOKENS } from "../utils/constants.js";
|
|
7
8
|
import * as os from "os";
|
|
8
9
|
import * as fs from "fs";
|
|
9
10
|
import * as path from "path";
|
|
@@ -110,8 +111,11 @@ export async function callAgent(options) {
|
|
|
110
111
|
fetchOptions: gatewayConfig.fetchOptions,
|
|
111
112
|
fetch: gatewayConfig.fetch,
|
|
112
113
|
});
|
|
113
|
-
// Determine model early (needed for system prompt construction)
|
|
114
|
-
|
|
114
|
+
// Determine model early (needed for system prompt construction).
|
|
115
|
+
// Per-model overrides come from modelConfig.options.max_tokens (spread
|
|
116
|
+
// after this default below); callers pass the resolved global value
|
|
117
|
+
// (AgentOptions.maxTokens / WAVE_MAX_OUTPUT_TOKENS) via options.maxTokens.
|
|
118
|
+
const resolvedMaxTokens = options.maxTokens ?? DEFAULT_WAVE_MAX_OUTPUT_TOKENS;
|
|
115
119
|
// Build system message content
|
|
116
120
|
let systemMessage;
|
|
117
121
|
if (Array.isArray(systemPrompt)) {
|
|
@@ -79,19 +79,19 @@ export declare class ConfigurationService {
|
|
|
79
79
|
* Resolution priority: override > options > env (from settings.json) > process.env > default
|
|
80
80
|
* @param model - Agent model override (optional)
|
|
81
81
|
* @param fastModel - Fast model override (optional)
|
|
82
|
-
* @param maxTokens - Max output tokens override (optional)
|
|
83
82
|
* @param permissionMode - Permission mode override (optional)
|
|
84
83
|
* @param visionModel - Vision model override (optional)
|
|
85
84
|
* @returns Resolved model configuration with defaults
|
|
86
85
|
*/
|
|
87
|
-
resolveModelConfig(model?: string, fastModel?: string,
|
|
86
|
+
resolveModelConfig(model?: string, fastModel?: string, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
|
|
88
87
|
/**
|
|
89
88
|
* Resolves token limit with fallbacks
|
|
90
|
-
* Resolution priority: override > options > env (from settings.json) > process.env > default
|
|
89
|
+
* Resolution priority: override > options > models[model].maxInputTokens > env (from settings.json) > process.env > default
|
|
91
90
|
* @param constructorLimit - Token limit override (optional)
|
|
91
|
+
* @param model - Model to resolve per-model maxInputTokens for (optional; falls back to the resolved agent model chain)
|
|
92
92
|
* @returns Resolved token limit
|
|
93
93
|
*/
|
|
94
|
-
resolveMaxInputTokens(constructorLimit?: number): number;
|
|
94
|
+
resolveMaxInputTokens(constructorLimit?: number, model?: string): number;
|
|
95
95
|
/**
|
|
96
96
|
* Resolves preferred language with fallbacks
|
|
97
97
|
* Resolution priority: override > options > settings.json > undefined
|
|
@@ -457,12 +457,11 @@ export class ConfigurationService {
|
|
|
457
457
|
* Resolution priority: override > options > env (from settings.json) > process.env > default
|
|
458
458
|
* @param model - Agent model override (optional)
|
|
459
459
|
* @param fastModel - Fast model override (optional)
|
|
460
|
-
* @param maxTokens - Max output tokens override (optional)
|
|
461
460
|
* @param permissionMode - Permission mode override (optional)
|
|
462
461
|
* @param visionModel - Vision model override (optional)
|
|
463
462
|
* @returns Resolved model configuration with defaults
|
|
464
463
|
*/
|
|
465
|
-
resolveModelConfig(model, fastModel,
|
|
464
|
+
resolveModelConfig(model, fastModel, permissionMode, visionModel) {
|
|
466
465
|
// Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env
|
|
467
466
|
// Priority: user's explicit model field > admin's env.WAVE_MODEL default.
|
|
468
467
|
// If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings).
|
|
@@ -478,13 +477,10 @@ export class ConfigurationService {
|
|
|
478
477
|
const resolvedVisionModel = visionModel ||
|
|
479
478
|
this.options.visionModel ||
|
|
480
479
|
(this.envSnapshot.WAVE_VISION_MODEL ?? process.env.WAVE_VISION_MODEL);
|
|
481
|
-
// Resolve max output tokens
|
|
482
|
-
const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
|
|
483
480
|
const baseConfig = {
|
|
484
481
|
model: resolvedAgentModel,
|
|
485
482
|
fastModel: resolvedFastModel,
|
|
486
483
|
visionModel: resolvedVisionModel,
|
|
487
|
-
maxTokens: resolvedMaxTokens,
|
|
488
484
|
permissionMode: permissionMode ?? this.options.permissionMode,
|
|
489
485
|
};
|
|
490
486
|
// Resolve fast model generation params from models[fastModel].options.
|
|
@@ -520,11 +516,12 @@ export class ConfigurationService {
|
|
|
520
516
|
}
|
|
521
517
|
/**
|
|
522
518
|
* Resolves token limit with fallbacks
|
|
523
|
-
* Resolution priority: override > options > env (from settings.json) > process.env > default
|
|
519
|
+
* Resolution priority: override > options > models[model].maxInputTokens > env (from settings.json) > process.env > default
|
|
524
520
|
* @param constructorLimit - Token limit override (optional)
|
|
521
|
+
* @param model - Model to resolve per-model maxInputTokens for (optional; falls back to the resolved agent model chain)
|
|
525
522
|
* @returns Resolved token limit
|
|
526
523
|
*/
|
|
527
|
-
resolveMaxInputTokens(constructorLimit) {
|
|
524
|
+
resolveMaxInputTokens(constructorLimit, model) {
|
|
528
525
|
// If override value provided, use it
|
|
529
526
|
if (constructorLimit !== undefined) {
|
|
530
527
|
return constructorLimit;
|
|
@@ -533,6 +530,16 @@ export class ConfigurationService {
|
|
|
533
530
|
if (this.options.maxInputTokens !== undefined) {
|
|
534
531
|
return this.options.maxInputTokens;
|
|
535
532
|
}
|
|
533
|
+
// Per-model config (models[X].maxInputTokens). Resolve the model with the
|
|
534
|
+
// same chain as resolveModelConfig: caller > options > currentConfiguration > env.
|
|
535
|
+
const resolvedModel = model ||
|
|
536
|
+
this.options.model ||
|
|
537
|
+
this.currentConfiguration?.model ||
|
|
538
|
+
(this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
|
|
539
|
+
const modelConfig = resolvedModel && this.currentConfiguration?.models?.[resolvedModel];
|
|
540
|
+
if (modelConfig && modelConfig.maxInputTokens !== undefined) {
|
|
541
|
+
return modelConfig.maxInputTokens;
|
|
542
|
+
}
|
|
536
543
|
// Try env (settings.json snapshot) first, then process.env
|
|
537
544
|
const envMaxInputTokens = this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
|
|
538
545
|
process.env.WAVE_MAX_INPUT_TOKENS;
|
package/dist/services/hook.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "child_process";
|
|
9
9
|
import { generateSessionFilePath } from "./session.js";
|
|
10
|
+
import { resolveShellPath } from "../utils/shellResolver.js";
|
|
11
|
+
import { toPosixCommand } from "../utils/windowsPaths.js";
|
|
10
12
|
// =============================================================================
|
|
11
13
|
// Hook Execution Functions
|
|
12
14
|
// =============================================================================
|
|
@@ -56,10 +58,10 @@ async function buildHookJsonInput(context) {
|
|
|
56
58
|
if (context.subagentType !== undefined) {
|
|
57
59
|
jsonInput.subagent_type = context.subagentType;
|
|
58
60
|
}
|
|
59
|
-
// Add name field for WorktreeCreate events
|
|
61
|
+
// Add name field for WorktreeCreate events (aligned with Claude Code)
|
|
60
62
|
if (context.event === "WorktreeCreate") {
|
|
61
|
-
if (context.
|
|
62
|
-
jsonInput.name = context.
|
|
63
|
+
if (context.name !== undefined) {
|
|
64
|
+
jsonInput.name = context.name;
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
// Add worktree_path field for WorktreeRemove events
|
|
@@ -133,11 +135,43 @@ export async function executeCommand(command, context, options) {
|
|
|
133
135
|
let stdout = "";
|
|
134
136
|
let stderr = "";
|
|
135
137
|
let timedOut = false;
|
|
136
|
-
// Parse command for shell execution
|
|
138
|
+
// Parse command for shell execution.
|
|
139
|
+
//
|
|
140
|
+
// Windows uses Git Bash (POSIX shell) instead of cmd.exe: cmd.exe's `/c`
|
|
141
|
+
// does not strip quotes from arguments after the first token, so commands
|
|
142
|
+
// like `node "C:\path\script.js"` silently fail (issue #1773). Git Bash
|
|
143
|
+
// parses quotes correctly, and Windows paths in the command are converted
|
|
144
|
+
// to POSIX form first (Git Bash resolves `/c/Users/...` and translates it
|
|
145
|
+
// back to a Windows path when spawning native executables like node.exe).
|
|
146
|
+
// Falls back to cmd.exe when no Git Bash is installed.
|
|
137
147
|
const isWindows = process.platform === "win32";
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
const bashPath = isWindows ? resolveShellPath() : undefined;
|
|
149
|
+
let shell;
|
|
150
|
+
let shellFlag;
|
|
151
|
+
let finalCommand;
|
|
152
|
+
if (bashPath) {
|
|
153
|
+
shell = bashPath;
|
|
154
|
+
shellFlag = "-c";
|
|
155
|
+
finalCommand = toPosixCommand(command);
|
|
156
|
+
// Windows .sh scripts aren't directly executable — run them via bash
|
|
157
|
+
// when the command itself is a .sh file (e.g. `${WAVE_PLUGIN_ROOT}/x.sh`).
|
|
158
|
+
const trimmed = finalCommand.trim();
|
|
159
|
+
const firstToken = trimmed.match(/^("([^"]*)"|'([^']*)'|\S+)/)?.[0] ?? "";
|
|
160
|
+
if (firstToken.endsWith(".sh") && !trimmed.startsWith("bash ")) {
|
|
161
|
+
finalCommand = `bash ${finalCommand}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (isWindows) {
|
|
165
|
+
shell = "cmd.exe";
|
|
166
|
+
shellFlag = "/c";
|
|
167
|
+
finalCommand = command;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
shell = "/bin/sh";
|
|
171
|
+
shellFlag = "-c";
|
|
172
|
+
finalCommand = command;
|
|
173
|
+
}
|
|
174
|
+
const childProcess = spawn(shell, [shellFlag, finalCommand], {
|
|
141
175
|
stdio: ["pipe", "pipe", "pipe"],
|
|
142
176
|
cwd: context.projectDir,
|
|
143
177
|
env: {
|
|
@@ -127,27 +127,6 @@ export class InitializationService {
|
|
|
127
127
|
catch (error) {
|
|
128
128
|
logger?.warn("SessionStart hooks execution failed:", error);
|
|
129
129
|
}
|
|
130
|
-
// Trigger WorktreeCreate hook if this is a new worktree
|
|
131
|
-
if (agentOptions.isNewWorktree && hookManager) {
|
|
132
|
-
try {
|
|
133
|
-
logger?.info(`Triggering WorktreeCreate hook for ${agentOptions.worktreeName}...`);
|
|
134
|
-
const hookResults = await hookManager.executeHooks("WorktreeCreate", {
|
|
135
|
-
event: "WorktreeCreate",
|
|
136
|
-
projectDir: workdir,
|
|
137
|
-
timestamp: new Date(),
|
|
138
|
-
sessionId: messageManager.getSessionId(),
|
|
139
|
-
transcriptPath: messageManager.getTranscriptPath(),
|
|
140
|
-
cwd: workdir,
|
|
141
|
-
worktreeName: agentOptions.worktreeName,
|
|
142
|
-
env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
|
|
143
|
-
});
|
|
144
|
-
// Process hook results
|
|
145
|
-
hookManager.processHookResults("WorktreeCreate", hookResults, messageManager);
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
logger?.warn("WorktreeCreate hooks execution failed:", error);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
130
|
// Resolve and validate configuration after loading settings.json
|
|
152
131
|
resolveAndValidateConfig();
|
|
153
132
|
// Initialize auto-memory directory
|
|
@@ -10,6 +10,20 @@ import type { SessionFilename } from "../types/session.js";
|
|
|
10
10
|
export interface JsonlWriteOptions {
|
|
11
11
|
atomic?: boolean;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Creation-time metadata persisted in the session file's header line
|
|
15
|
+
* (`{"type":"metadata",...}`). The header is append-only — written once on
|
|
16
|
+
* session creation and never rewritten — so only fields that are fixed at
|
|
17
|
+
* creation time belong here.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionMetadataHeader {
|
|
20
|
+
/** Real working directory (the encoded project dir name is lossy for paths containing "-"). */
|
|
21
|
+
workdir?: string;
|
|
22
|
+
/** ISO 8601 creation timestamp. */
|
|
23
|
+
createdAt?: string;
|
|
24
|
+
/** Git branch at creation time (`git branch --show-current`), when the directory is a git repo. */
|
|
25
|
+
gitBranch?: string;
|
|
26
|
+
}
|
|
13
27
|
/**
|
|
14
28
|
* JSONL handler class for message persistence operations
|
|
15
29
|
*/
|
|
@@ -17,9 +31,20 @@ export declare class JsonlHandler {
|
|
|
17
31
|
private readonly defaultWriteOptions;
|
|
18
32
|
constructor();
|
|
19
33
|
/**
|
|
20
|
-
* Create a new session file
|
|
34
|
+
* Create a new session file.
|
|
35
|
+
*
|
|
36
|
+
* When `metadata` is provided, the first line is a metadata header
|
|
37
|
+
* recording creation-time facts about the session:
|
|
38
|
+
* `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`. The
|
|
39
|
+
* encoded project dir name is lossy for paths containing "-", so persisting
|
|
40
|
+
* the real path lets session listing show it without decoding; `createdAt`
|
|
41
|
+
* and `gitBranch` similarly avoid lossy/fabricated reconstruction later.
|
|
42
|
+
* The header carries no `timestamp`, so message readers filter it out
|
|
43
|
+
* naturally.
|
|
44
|
+
*
|
|
45
|
+
* Legacy callers that omit `metadata` still get an empty file.
|
|
21
46
|
*/
|
|
22
|
-
createSession(filePath: string): Promise<void>;
|
|
47
|
+
createSession(filePath: string, metadata?: SessionMetadataHeader): Promise<void>;
|
|
23
48
|
/**
|
|
24
49
|
* Append a single message to JSONL file
|
|
25
50
|
*/
|
|
@@ -40,6 +65,16 @@ export declare class JsonlHandler {
|
|
|
40
65
|
* Get the last message from JSONL file using efficient file reading (simplified)
|
|
41
66
|
*/
|
|
42
67
|
getLastMessage(filePath: string): Promise<Message | null>;
|
|
68
|
+
/**
|
|
69
|
+
* Read the creation-time metadata from the session file's header line.
|
|
70
|
+
*
|
|
71
|
+
* Newer session files start with a `{"type":"metadata",...}` line (see
|
|
72
|
+
* `createSession`). Legacy files have no header.
|
|
73
|
+
*
|
|
74
|
+
* @param filePath - Path to the session JSONL file
|
|
75
|
+
* @returns The persisted metadata, or null when the file has no header
|
|
76
|
+
*/
|
|
77
|
+
readMetadata(filePath: string): Promise<SessionMetadataHeader | null>;
|
|
43
78
|
/**
|
|
44
79
|
* Validate messages before writing
|
|
45
80
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { appendFile, readFile, writeFile, stat, mkdir } from "fs/promises";
|
|
6
6
|
import { dirname } from "path";
|
|
7
|
-
import { getLastLine } from "../utils/fileUtils.js";
|
|
7
|
+
import { getLastLine, readFirstNLines } from "../utils/fileUtils.js";
|
|
8
8
|
/**
|
|
9
9
|
* JSONL handler class for message persistence operations
|
|
10
10
|
*/
|
|
@@ -15,13 +15,26 @@ export class JsonlHandler {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Create a new session file
|
|
18
|
+
* Create a new session file.
|
|
19
|
+
*
|
|
20
|
+
* When `metadata` is provided, the first line is a metadata header
|
|
21
|
+
* recording creation-time facts about the session:
|
|
22
|
+
* `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`. The
|
|
23
|
+
* encoded project dir name is lossy for paths containing "-", so persisting
|
|
24
|
+
* the real path lets session listing show it without decoding; `createdAt`
|
|
25
|
+
* and `gitBranch` similarly avoid lossy/fabricated reconstruction later.
|
|
26
|
+
* The header carries no `timestamp`, so message readers filter it out
|
|
27
|
+
* naturally.
|
|
28
|
+
*
|
|
29
|
+
* Legacy callers that omit `metadata` still get an empty file.
|
|
19
30
|
*/
|
|
20
|
-
async createSession(filePath) {
|
|
31
|
+
async createSession(filePath, metadata) {
|
|
21
32
|
// Ensure directory exists
|
|
22
33
|
await this.ensureDirectory(dirname(filePath));
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
const content = metadata && Object.keys(metadata).length > 0
|
|
35
|
+
? `${JSON.stringify({ type: "metadata", ...metadata })}\n`
|
|
36
|
+
: "";
|
|
37
|
+
await writeFile(filePath, content, "utf8");
|
|
25
38
|
}
|
|
26
39
|
/**
|
|
27
40
|
* Append a single message to JSONL file
|
|
@@ -83,11 +96,14 @@ export class JsonlHandler {
|
|
|
83
96
|
return [];
|
|
84
97
|
}
|
|
85
98
|
const allMessages = [];
|
|
86
|
-
// Parse all messages
|
|
99
|
+
// Parse all messages, skipping the metadata header line (if any)
|
|
87
100
|
for (let i = 0; i < lines.length; i++) {
|
|
88
101
|
const line = lines[i];
|
|
89
102
|
try {
|
|
90
103
|
const message = JSON.parse(line);
|
|
104
|
+
// Metadata header line: not a message, skip
|
|
105
|
+
if (message.type === "metadata")
|
|
106
|
+
continue;
|
|
91
107
|
if (message.timestamp)
|
|
92
108
|
allMessages.push(message);
|
|
93
109
|
}
|
|
@@ -127,6 +143,10 @@ export class JsonlHandler {
|
|
|
127
143
|
}
|
|
128
144
|
try {
|
|
129
145
|
const parsed = JSON.parse(lastLine);
|
|
146
|
+
// A file whose only line is the metadata header has no messages yet
|
|
147
|
+
if (parsed.type === "metadata") {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
130
150
|
return parsed;
|
|
131
151
|
}
|
|
132
152
|
catch (error) {
|
|
@@ -137,6 +157,35 @@ export class JsonlHandler {
|
|
|
137
157
|
throw new Error(`Failed to get last message from "${filePath}": ${error}`);
|
|
138
158
|
}
|
|
139
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Read the creation-time metadata from the session file's header line.
|
|
162
|
+
*
|
|
163
|
+
* Newer session files start with a `{"type":"metadata",...}` line (see
|
|
164
|
+
* `createSession`). Legacy files have no header.
|
|
165
|
+
*
|
|
166
|
+
* @param filePath - Path to the session JSONL file
|
|
167
|
+
* @returns The persisted metadata, or null when the file has no header
|
|
168
|
+
*/
|
|
169
|
+
async readMetadata(filePath) {
|
|
170
|
+
try {
|
|
171
|
+
const lines = await readFirstNLines(filePath, 1);
|
|
172
|
+
if (lines.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const header = JSON.parse(lines[0]);
|
|
176
|
+
if (header?.type === "metadata") {
|
|
177
|
+
return {
|
|
178
|
+
workdir: header.workdir,
|
|
179
|
+
createdAt: header.createdAt,
|
|
180
|
+
gitBranch: header.gitBranch,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Unreadable or invalid first line — treat as a legacy file
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
140
189
|
/**
|
|
141
190
|
* Validate messages before writing
|
|
142
191
|
*/
|
|
@@ -33,6 +33,8 @@ export interface SessionMetadata {
|
|
|
33
33
|
lastActiveAt: Date;
|
|
34
34
|
latestTotalTokens: number;
|
|
35
35
|
firstMessage?: string;
|
|
36
|
+
/** Git branch at session creation time (from the metadata header). */
|
|
37
|
+
branch?: string;
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
38
40
|
* Generate a new session ID using Node.js native crypto.randomUUID()
|
|
@@ -123,11 +125,31 @@ export declare function listSessions(workdir: string): Promise<SessionMetadata[]
|
|
|
123
125
|
*/
|
|
124
126
|
export declare function listSessionsFromJsonl(workdir: string): Promise<SessionMetadata[]>;
|
|
125
127
|
/**
|
|
126
|
-
* List all sessions across all project directories
|
|
128
|
+
* List all sessions across all project directories.
|
|
127
129
|
*
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
* When `worktreePaths` is provided, only project directories that match a
|
|
131
|
+
* same-repo git worktree (plus the current working directory's own project
|
|
132
|
+
* dir) are scanned — used by the `wave -r` picker's worktree aggregation
|
|
133
|
+
* (`Ctrl+W`). When omitted, every project directory under the sessions dir
|
|
134
|
+
* is scanned (all-projects mode, `Ctrl+A`).
|
|
135
|
+
*
|
|
136
|
+
* No time-based filtering is applied — all sessions whose files still exist
|
|
137
|
+
* are listed, regardless of how long ago they were last active.
|
|
138
|
+
*
|
|
139
|
+
* @param options.worktreePaths - Absolute paths of same-repo worktrees
|
|
140
|
+
* (from `git worktree list`). When provided, scanning is limited to
|
|
141
|
+
* matching project directories.
|
|
142
|
+
* @param options.workdir - Current working directory. Its own project dir is
|
|
143
|
+
* always included in worktree mode so sessions created from a subdirectory
|
|
144
|
+
* inside a worktree are not missed.
|
|
145
|
+
* @returns Promise that resolves to array of session metadata objects,
|
|
146
|
+
* deduplicated by sessionId (newest lastActiveAt wins), sorted by
|
|
147
|
+
* lastActiveAt descending.
|
|
148
|
+
*/
|
|
149
|
+
export declare function listAllSessions(options?: {
|
|
150
|
+
worktreePaths?: string[];
|
|
151
|
+
workdir?: string;
|
|
152
|
+
}): Promise<SessionMetadata[]>;
|
|
131
153
|
/**
|
|
132
154
|
* Clean up expired sessions older than 14 days based on file modification time
|
|
133
155
|
*
|
|
@@ -161,6 +183,15 @@ export declare function cleanupMetaOnlySessions(): Promise<number>;
|
|
|
161
183
|
* @returns Promise that resolves to true if session exists, false otherwise
|
|
162
184
|
*/
|
|
163
185
|
export declare function sessionExistsInJsonl(sessionId: string, workdir: string, sessionType?: "main" | "subagent"): Promise<boolean>;
|
|
186
|
+
/**
|
|
187
|
+
* Get the content of the first non-meta message in a session file
|
|
188
|
+
* For user role: get text block content
|
|
189
|
+
* For assistant role: get compact block content
|
|
190
|
+
* Skips meta messages (isMeta: true) to find the first meaningful message
|
|
191
|
+
* @param filePath - Path to the session JSONL file
|
|
192
|
+
* @returns Promise that resolves to the first non-meta message content or null if not found
|
|
193
|
+
*/
|
|
194
|
+
export declare function getFirstMessageContentFromFile(filePath: string): Promise<string | null>;
|
|
164
195
|
/**
|
|
165
196
|
* Get the content of the first non-meta message in a session
|
|
166
197
|
* For user role: get text block content
|