wave-agent-sdk 1.0.10 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +9 -6
- package/dist/agent.js +35 -33
- package/dist/builtin/skills/settings.js +31 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/managers/aiManager.d.ts +10 -0
- package/dist/managers/aiManager.js +31 -4
- package/dist/managers/subagentManager.d.ts +1 -0
- package/dist/managers/subagentManager.js +5 -1
- 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 +120 -57
- 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/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/skillParser.js +3 -6
- 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
|
@@ -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
|
package/dist/services/session.js
CHANGED
|
@@ -18,11 +18,14 @@ import { promises as fs } from "fs";
|
|
|
18
18
|
import { join } from "path";
|
|
19
19
|
import { homedir } from "os";
|
|
20
20
|
import { randomUUID } from "crypto";
|
|
21
|
+
import { execFile } from "child_process";
|
|
22
|
+
import { promisify } from "util";
|
|
21
23
|
import { PathEncoder } from "../utils/pathEncoder.js";
|
|
22
24
|
import { JsonlHandler } from "../services/jsonlHandler.js";
|
|
23
25
|
import { extractLatestTotalTokens } from "../utils/tokenCalculation.js";
|
|
24
26
|
import { logger } from "../utils/globalLogger.js";
|
|
25
27
|
import { getMessageContent, sliceFromLastCompact, } from "../utils/messageOperations.js";
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
26
29
|
/**
|
|
27
30
|
* Generate a new session ID using Node.js native crypto.randomUUID()
|
|
28
31
|
* @returns UUID string for session identification
|
|
@@ -82,6 +85,22 @@ export async function getSessionFilePath(sessionId, workdir, sessionType = "main
|
|
|
82
85
|
const filename = jsonlHandler.generateSessionFilename(sessionId, sessionType);
|
|
83
86
|
return join(projectDir.encodedPath, filename);
|
|
84
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the current git branch for a working directory, if any.
|
|
90
|
+
*
|
|
91
|
+
* Returns undefined when the directory is not inside a git repo, git is
|
|
92
|
+
* unavailable, or the command fails/times out — the session can still be
|
|
93
|
+
* created, its metadata header just omits the branch.
|
|
94
|
+
*/
|
|
95
|
+
async function getGitBranch(workdir) {
|
|
96
|
+
try {
|
|
97
|
+
const { stdout } = await execFileAsync("git", ["-C", workdir, "branch", "--show-current"], { timeout: 2000, windowsHide: true });
|
|
98
|
+
return stdout.trim() || undefined;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
85
104
|
/**
|
|
86
105
|
* Create a new session
|
|
87
106
|
* @param sessionId - UUID session identifier
|
|
@@ -91,7 +110,15 @@ export async function getSessionFilePath(sessionId, workdir, sessionType = "main
|
|
|
91
110
|
export async function createSession(sessionId, workdir, sessionType = "main") {
|
|
92
111
|
const jsonlHandler = new JsonlHandler();
|
|
93
112
|
const filePath = await getSessionFilePath(sessionId, workdir, sessionType);
|
|
94
|
-
|
|
113
|
+
// Persist creation-time metadata in the header line: the real workdir
|
|
114
|
+
// (lossy via decodeSync), the real creation timestamp, and the git branch
|
|
115
|
+
// at creation time. The header is append-only — written once, never
|
|
116
|
+
// rewritten — so only creation-time-constant fields belong here.
|
|
117
|
+
await jsonlHandler.createSession(filePath, {
|
|
118
|
+
workdir,
|
|
119
|
+
createdAt: new Date().toISOString(),
|
|
120
|
+
gitBranch: await getGitBranch(workdir),
|
|
121
|
+
});
|
|
95
122
|
}
|
|
96
123
|
/**
|
|
97
124
|
* Append messages to session using JSONL format (new approach)
|
|
@@ -124,6 +151,38 @@ export async function appendMessages(sessionId, newMessages, workdir, sessionTyp
|
|
|
124
151
|
atomic: false,
|
|
125
152
|
});
|
|
126
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Scan all project directories for a session file by ID. Used as a fallback
|
|
156
|
+
* when the session is not found in the given working directory (e.g. resuming
|
|
157
|
+
* a session created in another project or a sibling git worktree via
|
|
158
|
+
* `wave --restore <id>`).
|
|
159
|
+
*
|
|
160
|
+
* @param sessionId - UUID session identifier
|
|
161
|
+
* @param sessionType - Type of session ("main" or "subagent")
|
|
162
|
+
* @returns Full path to the session file, or null if not found anywhere
|
|
163
|
+
*/
|
|
164
|
+
async function findSessionFileAcrossProjects(sessionId, sessionType) {
|
|
165
|
+
const targetFile = sessionType === "subagent"
|
|
166
|
+
? `subagent-${sessionId}.jsonl`
|
|
167
|
+
: `${sessionId}.jsonl`;
|
|
168
|
+
try {
|
|
169
|
+
const projectDirs = await fs.readdir(SESSION_DIR);
|
|
170
|
+
for (const projectDirName of projectDirs) {
|
|
171
|
+
const candidate = join(SESSION_DIR, projectDirName, targetFile);
|
|
172
|
+
try {
|
|
173
|
+
await fs.access(candidate);
|
|
174
|
+
return candidate;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// ENOENT/ENOTDIR — keep scanning other project dirs
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Sessions base dir missing or unreadable — nothing to scan
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
127
186
|
/**
|
|
128
187
|
* Load session data from JSONL file (new approach)
|
|
129
188
|
*
|
|
@@ -138,16 +197,25 @@ export async function loadSessionFromJsonl(sessionId, workdir, sessionType = "ma
|
|
|
138
197
|
// Generate the session file path directly using known session type
|
|
139
198
|
const filePath = await generateSessionFilePath(sessionId, workdir, sessionType);
|
|
140
199
|
// Check if file exists
|
|
200
|
+
let resolvedPath = filePath;
|
|
141
201
|
try {
|
|
142
|
-
await fs.access(
|
|
202
|
+
await fs.access(resolvedPath);
|
|
143
203
|
}
|
|
144
204
|
catch (error) {
|
|
145
205
|
if (error.code === "ENOENT") {
|
|
146
|
-
|
|
206
|
+
// Cross-project fallback: the session may live in a different project
|
|
207
|
+
// directory (e.g. `wave --restore <id>` run from another cwd).
|
|
208
|
+
const fallbackPath = await findSessionFileAcrossProjects(sessionId, sessionType);
|
|
209
|
+
if (!fallbackPath) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
resolvedPath = fallbackPath;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
throw error;
|
|
147
216
|
}
|
|
148
|
-
throw error;
|
|
149
217
|
}
|
|
150
|
-
const allMessages = await jsonlHandler.read(
|
|
218
|
+
const allMessages = await jsonlHandler.read(resolvedPath);
|
|
151
219
|
// Find the last compact boundary — only return messages from there forward
|
|
152
220
|
const messages = sliceFromLastCompact(allMessages);
|
|
153
221
|
// Extract metadata from messages
|
|
@@ -227,8 +295,6 @@ export async function listSessionsFromJsonl(workdir) {
|
|
|
227
295
|
const encoder = new PathEncoder();
|
|
228
296
|
const baseDir = SESSION_DIR;
|
|
229
297
|
const projectDir = await encoder.getProjectDirectory(workdir, baseDir);
|
|
230
|
-
const sevenDaysAgo = new Date();
|
|
231
|
-
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
232
298
|
let files;
|
|
233
299
|
try {
|
|
234
300
|
files = await fs.readdir(projectDir.encodedPath);
|
|
@@ -260,6 +326,9 @@ export async function listSessionsFromJsonl(workdir) {
|
|
|
260
326
|
// PERFORMANCE OPTIMIZATION: Only read the last message for timestamps and tokens
|
|
261
327
|
const jsonlHandler = new JsonlHandler();
|
|
262
328
|
const lastMessage = await jsonlHandler.getLastMessage(filePath);
|
|
329
|
+
// Creation-time metadata (workdir is not needed here — the project
|
|
330
|
+
// dir gives the original path — but createdAt / branch are).
|
|
331
|
+
const header = await jsonlHandler.readMetadata(filePath);
|
|
263
332
|
// Handle timing information efficiently
|
|
264
333
|
let lastActiveAt;
|
|
265
334
|
if (lastMessage) {
|
|
@@ -270,20 +339,20 @@ export async function listSessionsFromJsonl(workdir) {
|
|
|
270
339
|
const stats = await fs.stat(filePath);
|
|
271
340
|
lastActiveAt = stats.mtime;
|
|
272
341
|
}
|
|
273
|
-
if (lastActiveAt < sevenDaysAgo) {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
342
|
// Return inline object for performance (no interface instantiation overhead)
|
|
277
343
|
const sessionMeta = {
|
|
278
344
|
id: sessionId,
|
|
279
345
|
sessionType: "main",
|
|
280
346
|
subagentType: undefined,
|
|
281
347
|
workdir: projectDir.originalPath,
|
|
282
|
-
createdAt:
|
|
348
|
+
createdAt: header?.createdAt
|
|
349
|
+
? new Date(header.createdAt)
|
|
350
|
+
: new Date(),
|
|
283
351
|
lastActiveAt,
|
|
284
352
|
latestTotalTokens: lastMessage?.usage
|
|
285
353
|
? extractLatestTotalTokens([lastMessage])
|
|
286
354
|
: 0,
|
|
355
|
+
branch: header?.gitBranch,
|
|
287
356
|
};
|
|
288
357
|
// Try to get first message content for display
|
|
289
358
|
try {
|
|
@@ -310,12 +379,30 @@ export async function listSessionsFromJsonl(workdir) {
|
|
|
310
379
|
}
|
|
311
380
|
}
|
|
312
381
|
/**
|
|
313
|
-
* List all sessions across all project directories
|
|
382
|
+
* List all sessions across all project directories.
|
|
314
383
|
*
|
|
315
|
-
*
|
|
384
|
+
* When `worktreePaths` is provided, only project directories that match a
|
|
385
|
+
* same-repo git worktree (plus the current working directory's own project
|
|
386
|
+
* dir) are scanned — used by the `wave -r` picker's worktree aggregation
|
|
387
|
+
* (`Ctrl+W`). When omitted, every project directory under the sessions dir
|
|
388
|
+
* is scanned (all-projects mode, `Ctrl+A`).
|
|
389
|
+
*
|
|
390
|
+
* No time-based filtering is applied — all sessions whose files still exist
|
|
391
|
+
* are listed, regardless of how long ago they were last active.
|
|
392
|
+
*
|
|
393
|
+
* @param options.worktreePaths - Absolute paths of same-repo worktrees
|
|
394
|
+
* (from `git worktree list`). When provided, scanning is limited to
|
|
395
|
+
* matching project directories.
|
|
396
|
+
* @param options.workdir - Current working directory. Its own project dir is
|
|
397
|
+
* always included in worktree mode so sessions created from a subdirectory
|
|
398
|
+
* inside a worktree are not missed.
|
|
399
|
+
* @returns Promise that resolves to array of session metadata objects,
|
|
400
|
+
* deduplicated by sessionId (newest lastActiveAt wins), sorted by
|
|
401
|
+
* lastActiveAt descending.
|
|
316
402
|
*/
|
|
317
|
-
export async function listAllSessions() {
|
|
403
|
+
export async function listAllSessions(options) {
|
|
318
404
|
try {
|
|
405
|
+
const { worktreePaths, workdir } = options ?? {};
|
|
319
406
|
const baseDir = SESSION_DIR;
|
|
320
407
|
let projectDirs;
|
|
321
408
|
try {
|
|
@@ -327,9 +414,54 @@ export async function listAllSessions() {
|
|
|
327
414
|
}
|
|
328
415
|
throw error;
|
|
329
416
|
}
|
|
417
|
+
// Worktree-aware mode: only scan project dirs that match a same-repo
|
|
418
|
+
// worktree path. Prefixes are sorted longest-first so a short prefix like
|
|
419
|
+
// "home-user-repo" cannot swallow "home-user-repo-wt" (short paths require
|
|
420
|
+
// exact match; prefix + "-" matching only kicks in for paths truncated by
|
|
421
|
+
// the 200-char encoding limit, mirroring Claude Code's session listing).
|
|
422
|
+
// Each entry also carries the real path so matching dirs can display it
|
|
423
|
+
// instead of the lossy decodeSync of the encoded name.
|
|
424
|
+
const encoder = new PathEncoder();
|
|
425
|
+
const caseInsensitive = process.platform === "win32";
|
|
426
|
+
let indexed = null;
|
|
427
|
+
if (worktreePaths && worktreePaths.length > 0) {
|
|
428
|
+
indexed = [];
|
|
429
|
+
const seen = new Set();
|
|
430
|
+
for (const wt of worktreePaths) {
|
|
431
|
+
let encoded;
|
|
432
|
+
try {
|
|
433
|
+
encoded = await encoder.encode(wt);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
encoded = encoder.encodeSync(wt);
|
|
437
|
+
}
|
|
438
|
+
const prefix = caseInsensitive ? encoded.toLowerCase() : encoded;
|
|
439
|
+
if (seen.has(prefix))
|
|
440
|
+
continue;
|
|
441
|
+
seen.add(prefix);
|
|
442
|
+
indexed.push({ prefix, path: wt });
|
|
443
|
+
}
|
|
444
|
+
indexed.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
445
|
+
// Always include the current working directory's own project dir —
|
|
446
|
+
// the cwd may be a subdirectory inside a worktree whose root prefix
|
|
447
|
+
// does not match (short-path matching is exact-only).
|
|
448
|
+
if (workdir) {
|
|
449
|
+
let encodedCwd;
|
|
450
|
+
try {
|
|
451
|
+
encodedCwd = await encoder.encode(workdir);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
encodedCwd = encoder.encodeSync(workdir);
|
|
455
|
+
}
|
|
456
|
+
const prefix = caseInsensitive ? encodedCwd.toLowerCase() : encodedCwd;
|
|
457
|
+
if (!seen.has(prefix)) {
|
|
458
|
+
seen.add(prefix);
|
|
459
|
+
indexed.push({ prefix, path: workdir });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
330
463
|
const allSessions = [];
|
|
331
|
-
const
|
|
332
|
-
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
464
|
+
const MAX_ENCODED_PREFIX_LENGTH = 200;
|
|
333
465
|
for (const projectDirName of projectDirs) {
|
|
334
466
|
const projectPath = join(baseDir, projectDirName);
|
|
335
467
|
try {
|
|
@@ -337,6 +469,23 @@ export async function listAllSessions() {
|
|
|
337
469
|
if (!stat.isDirectory()) {
|
|
338
470
|
continue;
|
|
339
471
|
}
|
|
472
|
+
// The indexed match (worktree mode) carries the real path of the
|
|
473
|
+
// worktree / cwd that encoded to this dir name.
|
|
474
|
+
let matchedIndexed;
|
|
475
|
+
if (indexed) {
|
|
476
|
+
const dirName = caseInsensitive
|
|
477
|
+
? projectDirName.toLowerCase()
|
|
478
|
+
: projectDirName;
|
|
479
|
+
matchedIndexed = indexed.find(({ prefix }) => dirName === prefix ||
|
|
480
|
+
(prefix.length >= MAX_ENCODED_PREFIX_LENGTH &&
|
|
481
|
+
dirName.startsWith(prefix + "-")));
|
|
482
|
+
if (!matchedIndexed)
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
// Decode the encoded directory name back to the original project
|
|
486
|
+
// path for display; fall back to the encoded name when the path was
|
|
487
|
+
// truncated with a hash suffix (lossy, cannot be reliably reversed).
|
|
488
|
+
const decodedWorkdir = encoder.decodeSync(projectDirName) ?? projectDirName;
|
|
340
489
|
// Scan .jsonl files in the project directory
|
|
341
490
|
const files = await fs.readdir(projectPath);
|
|
342
491
|
for (const file of files) {
|
|
@@ -351,6 +500,9 @@ export async function listAllSessions() {
|
|
|
351
500
|
const sessionId = uuidMatch[1];
|
|
352
501
|
const jsonlHandler = new JsonlHandler();
|
|
353
502
|
const lastMessage = await jsonlHandler.getLastMessage(filePath);
|
|
503
|
+
// Creation-time metadata — read per file: branch can differ
|
|
504
|
+
// between sessions created in the same directory over time.
|
|
505
|
+
const header = await jsonlHandler.readMetadata(filePath);
|
|
354
506
|
let lastActiveAt;
|
|
355
507
|
if (lastMessage) {
|
|
356
508
|
lastActiveAt = new Date(lastMessage.timestamp);
|
|
@@ -359,18 +511,36 @@ export async function listAllSessions() {
|
|
|
359
511
|
const stats = await fs.stat(filePath);
|
|
360
512
|
lastActiveAt = stats.mtime;
|
|
361
513
|
}
|
|
362
|
-
|
|
363
|
-
|
|
514
|
+
// Populate the first-message preview like listSessionsFromJsonl
|
|
515
|
+
// does, so aggregated modes (Ctrl+A / Ctrl+W) show real content
|
|
516
|
+
// instead of the "No content" fallback.
|
|
517
|
+
let firstMessage;
|
|
518
|
+
try {
|
|
519
|
+
const firstContent = await getFirstMessageContentFromFile(filePath);
|
|
520
|
+
if (firstContent) {
|
|
521
|
+
firstMessage = firstContent;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// Ignore errors getting first message
|
|
526
|
+
}
|
|
527
|
+
// Prefer the real worktree path, then the persisted metadata
|
|
528
|
+
// header, then the lossy decodeSync fallback for legacy files.
|
|
529
|
+
const workdir = matchedIndexed?.path ?? header?.workdir ?? decodedWorkdir;
|
|
364
530
|
allSessions.push({
|
|
365
531
|
id: sessionId,
|
|
366
532
|
sessionType: "main",
|
|
367
533
|
subagentType: undefined,
|
|
368
|
-
workdir
|
|
369
|
-
createdAt:
|
|
534
|
+
workdir,
|
|
535
|
+
createdAt: header?.createdAt
|
|
536
|
+
? new Date(header.createdAt)
|
|
537
|
+
: new Date(),
|
|
370
538
|
lastActiveAt,
|
|
371
539
|
latestTotalTokens: lastMessage?.usage
|
|
372
540
|
? extractLatestTotalTokens([lastMessage])
|
|
373
541
|
: 0,
|
|
542
|
+
firstMessage,
|
|
543
|
+
branch: header?.gitBranch,
|
|
374
544
|
});
|
|
375
545
|
}
|
|
376
546
|
catch {
|
|
@@ -382,7 +552,17 @@ export async function listAllSessions() {
|
|
|
382
552
|
// Skip if stat/readdir fails
|
|
383
553
|
}
|
|
384
554
|
}
|
|
385
|
-
|
|
555
|
+
// Deduplicate by sessionId — the same session can appear in multiple
|
|
556
|
+
// project dirs (e.g. worktree branches). Keep the newest lastActiveAt.
|
|
557
|
+
const byId = new Map();
|
|
558
|
+
for (const session of allSessions) {
|
|
559
|
+
const existing = byId.get(session.id);
|
|
560
|
+
if (!existing ||
|
|
561
|
+
session.lastActiveAt.getTime() > existing.lastActiveAt.getTime()) {
|
|
562
|
+
byId.set(session.id, session);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return [...byId.values()].sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
|
|
386
566
|
}
|
|
387
567
|
catch (error) {
|
|
388
568
|
throw new Error(`Failed to list all sessions: ${error}`);
|
|
@@ -578,28 +758,23 @@ export async function sessionExistsInJsonl(sessionId, workdir, sessionType) {
|
|
|
578
758
|
}
|
|
579
759
|
}
|
|
580
760
|
/**
|
|
581
|
-
* Get the content of the first non-meta message in a session
|
|
761
|
+
* Get the content of the first non-meta message in a session file
|
|
582
762
|
* For user role: get text block content
|
|
583
763
|
* For assistant role: get compact block content
|
|
584
764
|
* Skips meta messages (isMeta: true) to find the first meaningful message
|
|
585
|
-
* @param
|
|
586
|
-
* @param workdir - Working directory for session operations
|
|
765
|
+
* @param filePath - Path to the session JSONL file
|
|
587
766
|
* @returns Promise that resolves to the first non-meta message content or null if not found
|
|
588
767
|
*/
|
|
589
|
-
export async function
|
|
768
|
+
export async function getFirstMessageContentFromFile(filePath) {
|
|
590
769
|
try {
|
|
591
|
-
const encoder = new PathEncoder();
|
|
592
|
-
const baseDir = SESSION_DIR;
|
|
593
|
-
const projectDir = await encoder.getProjectDirectory(workdir, baseDir);
|
|
594
|
-
const filePath = join(projectDir.encodedPath, `${sessionId}.jsonl`);
|
|
595
770
|
// Read first N lines to skip meta messages
|
|
596
771
|
const { readFirstNLines } = await import("../utils/fileUtils.js");
|
|
597
772
|
const lines = await readFirstNLines(filePath, 10);
|
|
598
773
|
for (const line of lines) {
|
|
599
774
|
try {
|
|
600
775
|
const message = JSON.parse(line);
|
|
601
|
-
// Skip meta messages
|
|
602
|
-
if (message.isMeta) {
|
|
776
|
+
// Skip the metadata header line and meta messages
|
|
777
|
+
if (message.type === "metadata" || message.isMeta) {
|
|
603
778
|
continue;
|
|
604
779
|
}
|
|
605
780
|
const content = getMessageContent(message);
|
|
@@ -608,11 +783,33 @@ export async function getFirstMessageContent(sessionId, workdir) {
|
|
|
608
783
|
}
|
|
609
784
|
}
|
|
610
785
|
catch (error) {
|
|
611
|
-
logger.warn(`Failed to parse message in session ${
|
|
786
|
+
logger.warn(`Failed to parse message in session file ${filePath}:`, error);
|
|
612
787
|
}
|
|
613
788
|
}
|
|
614
789
|
return null;
|
|
615
790
|
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
logger.warn(`Failed to get first message content from ${filePath}:`, error);
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Get the content of the first non-meta message in a session
|
|
798
|
+
* For user role: get text block content
|
|
799
|
+
* For assistant role: get compact block content
|
|
800
|
+
* Skips meta messages (isMeta: true) to find the first meaningful message
|
|
801
|
+
* @param sessionId - Session ID to get first message from
|
|
802
|
+
* @param workdir - Working directory for session operations
|
|
803
|
+
* @returns Promise that resolves to the first non-meta message content or null if not found
|
|
804
|
+
*/
|
|
805
|
+
export async function getFirstMessageContent(sessionId, workdir) {
|
|
806
|
+
try {
|
|
807
|
+
const encoder = new PathEncoder();
|
|
808
|
+
const baseDir = SESSION_DIR;
|
|
809
|
+
const projectDir = await encoder.getProjectDirectory(workdir, baseDir);
|
|
810
|
+
const filePath = join(projectDir.encodedPath, `${sessionId}.jsonl`);
|
|
811
|
+
return getFirstMessageContentFromFile(filePath);
|
|
812
|
+
}
|
|
616
813
|
catch (error) {
|
|
617
814
|
logger.warn(`Failed to get first message content for session ${sessionId}:`, error);
|
|
618
815
|
return null;
|
|
@@ -675,10 +872,10 @@ export async function handleSessionRestoration(restoreSessionId, continueLastSes
|
|
|
675
872
|
// Use only JSONL format - no legacy support
|
|
676
873
|
sessionToRestore = await loadSessionFromJsonl(restoreSessionId, workdir);
|
|
677
874
|
if (!sessionToRestore) {
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
875
|
+
// loadSessionFromJsonl already scans every project directory as a
|
|
876
|
+
// fallback — reaching here means the session does not exist anywhere.
|
|
877
|
+
// Surface a clear error instead of silently starting a fresh session.
|
|
878
|
+
throw new Error(`Session ${restoreSessionId} not found on disk`);
|
|
682
879
|
}
|
|
683
880
|
}
|
|
684
881
|
else if (continueLastSession) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree hooks — replace semantics (aligned with Claude Code).
|
|
3
|
+
*
|
|
4
|
+
* When `WorktreeCreate` / `WorktreeRemove` hooks are configured, wave delegates
|
|
5
|
+
* worktree creation/removal to the hooks instead of running git itself:
|
|
6
|
+
* - `WorktreeCreate`: the first successful hook's stdout (trimmed) is the
|
|
7
|
+
* worktree path. All failures / empty output block creation.
|
|
8
|
+
* - `WorktreeRemove`: only fires for hook-based worktrees; wave never runs
|
|
9
|
+
* `git worktree remove` for them. Failures are logged, never blocking.
|
|
10
|
+
*
|
|
11
|
+
* Unlike HookManager (DI-bound, per-agent), this module operates on a merged
|
|
12
|
+
* `PartialHookConfiguration` so it can be used from standalone CLI paths
|
|
13
|
+
* (packages/code) that load settings via `loadMergedWaveConfig`.
|
|
14
|
+
* See docs/specs/multi-agent/worktree.md.
|
|
15
|
+
*/
|
|
16
|
+
import type { PartialHookConfiguration } from "../types/hooks.js";
|
|
17
|
+
/** Minimal context required to run worktree hooks standalone. */
|
|
18
|
+
export interface WorktreeHookContext {
|
|
19
|
+
/** Absolute project directory (also the hook cwd / $WAVE_PROJECT_DIR) */
|
|
20
|
+
projectDir: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
transcriptPath?: string;
|
|
23
|
+
/** Additional environment variables for the hook process */
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
export declare function hasWorktreeCreateHook(configuration: PartialHookConfiguration | undefined): boolean;
|
|
27
|
+
export declare function hasWorktreeRemoveHook(configuration: PartialHookConfiguration | undefined): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Execute WorktreeCreate hooks and return the worktree path from hook stdout.
|
|
30
|
+
*
|
|
31
|
+
* The first successful hook (exit code 0) with non-empty stdout provides the
|
|
32
|
+
* worktree path (trimmed). Throws if every hook fails or none emits output —
|
|
33
|
+
* creation is blocked.
|
|
34
|
+
*
|
|
35
|
+
* Callers should check hasWorktreeCreateHook() before calling.
|
|
36
|
+
*/
|
|
37
|
+
export declare function executeWorktreeCreateHook(name: string, configuration: PartialHookConfiguration | undefined, context: WorktreeHookContext): Promise<{
|
|
38
|
+
worktreePath: string;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Execute WorktreeRemove hooks for a hook-based worktree.
|
|
42
|
+
* Returns true if hooks ran, false if none were configured.
|
|
43
|
+
* Failures are logged but never throw (non-blocking, aligned with Claude Code).
|
|
44
|
+
*/
|
|
45
|
+
export declare function executeWorktreeRemoveHook(worktreePath: string, configuration: PartialHookConfiguration | undefined, context: WorktreeHookContext): Promise<boolean>;
|