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.
Files changed (36) hide show
  1. package/dist/agent.d.ts +9 -6
  2. package/dist/agent.js +35 -33
  3. package/dist/builtin/skills/settings.js +31 -6
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/managers/aiManager.d.ts +10 -0
  7. package/dist/managers/aiManager.js +31 -4
  8. package/dist/managers/subagentManager.d.ts +1 -0
  9. package/dist/managers/subagentManager.js +5 -1
  10. package/dist/services/hook.js +41 -7
  11. package/dist/services/initializationService.js +0 -21
  12. package/dist/services/jsonlHandler.d.ts +37 -2
  13. package/dist/services/jsonlHandler.js +55 -6
  14. package/dist/services/session.d.ts +35 -4
  15. package/dist/services/session.js +233 -36
  16. package/dist/services/worktreeHooks.d.ts +45 -0
  17. package/dist/services/worktreeHooks.js +133 -0
  18. package/dist/tools/agentTool.js +36 -1
  19. package/dist/tools/bashTool.js +120 -57
  20. package/dist/tools/enterWorktreeTool.d.ts +1 -1
  21. package/dist/tools/enterWorktreeTool.js +44 -31
  22. package/dist/tools/exitWorktreeTool.js +21 -26
  23. package/dist/types/agent.d.ts +5 -0
  24. package/dist/types/hooks.d.ts +3 -2
  25. package/dist/types/skills.d.ts +0 -1
  26. package/dist/types/skills.js +0 -1
  27. package/dist/utils/asyncWorkRegistry.d.ts +32 -0
  28. package/dist/utils/asyncWorkRegistry.js +81 -0
  29. package/dist/utils/containerSetup.js +5 -0
  30. package/dist/utils/skillParser.js +3 -6
  31. package/dist/utils/windowsPaths.d.ts +28 -0
  32. package/dist/utils/windowsPaths.js +47 -0
  33. package/dist/utils/worktreeSession.d.ts +6 -0
  34. package/dist/utils/worktreeUtils.d.ts +7 -0
  35. package/dist/utils/worktreeUtils.js +88 -40
  36. 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 (simplified - no metadata header)
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
- // Create empty file (no metadata line needed)
24
- await writeFile(filePath, "", "utf8");
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 (no metadata line to skip)
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
- * @returns Promise that resolves to array of session metadata objects
129
- */
130
- export declare function listAllSessions(): Promise<SessionMetadata[]>;
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
@@ -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
- await jsonlHandler.createSession(filePath);
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(filePath);
202
+ await fs.access(resolvedPath);
143
203
  }
144
204
  catch (error) {
145
205
  if (error.code === "ENOENT") {
146
- return null;
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(filePath);
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: new Date(),
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
- * @returns Promise that resolves to array of session metadata objects
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 sevenDaysAgo = new Date();
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
- if (lastActiveAt < sevenDaysAgo)
363
- continue;
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: projectDirName,
369
- createdAt: new Date(),
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
- return allSessions.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
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 sessionId - Session ID to get first message from
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 getFirstMessageContent(sessionId, workdir) {
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 ${sessionId}:`, error);
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
- // Session doesn't exist on disk (e.g. new project with no messages saved yet).
679
- // Gracefully fall back to starting fresh instead of throwing.
680
- logger?.warn(`Session ${restoreSessionId} not found on disk, starting fresh session`);
681
- return;
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>;