wave-agent-sdk 1.0.9 → 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/index.d.ts +1 -0
- package/dist/builtin/index.js +20 -0
- package/dist/builtin/plugins.d.ts +1 -0
- package/dist/builtin/plugins.js +225 -0
- package/dist/builtin/skills/artifact.d.ts +1 -0
- package/dist/builtin/skills/artifact.js +18 -0
- package/dist/builtin/skills/code-review.d.ts +1 -0
- package/{builtin/skills/code-review/SKILL.md → dist/builtin/skills/code-review.js} +23 -19
- package/dist/builtin/skills/deep-research.d.ts +1 -0
- package/{builtin/skills/deep-research/SKILL.md → dist/builtin/skills/deep-research.js} +18 -14
- package/dist/builtin/skills/init.d.ts +1 -0
- package/{builtin/skills/init/SKILL.md → dist/builtin/skills/init.js} +6 -3
- package/dist/builtin/skills/loop.d.ts +1 -0
- package/dist/builtin/skills/loop.js +83 -0
- package/dist/builtin/skills/settings.d.ts +1 -0
- package/dist/builtin/skills/settings.js +1249 -0
- package/dist/builtin/skills/simplify.d.ts +1 -0
- package/{builtin/skills/simplify/SKILL.md → dist/builtin/skills/simplify.js} +7 -3
- package/dist/builtin/subagents.d.ts +1 -0
- package/dist/builtin/subagents.js +164 -0
- 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 +49 -5
- package/dist/managers/mcpManager.js +1 -1
- package/dist/managers/messageManager.d.ts +6 -0
- package/dist/managers/messageManager.js +33 -0
- package/dist/managers/subagentManager.d.ts +9 -0
- package/dist/managers/subagentManager.js +62 -3
- 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/editTool.js +1 -0
- package/dist/tools/enterWorktreeTool.d.ts +1 -1
- package/dist/tools/enterWorktreeTool.js +44 -31
- package/dist/tools/exitWorktreeTool.js +21 -26
- package/dist/tools/readTool.js +6 -3
- package/dist/tools/types.d.ts +1 -0
- package/dist/tools/writeTool.js +1 -0
- package/dist/types/agent.d.ts +5 -0
- package/dist/types/hooks.d.ts +3 -2
- package/dist/types/messaging.d.ts +1 -0
- 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/builtinEmbed.d.ts +21 -0
- package/dist/utils/builtinEmbed.js +53 -0
- package/dist/utils/configPaths.d.ts +0 -1
- package/dist/utils/configPaths.js +5 -26
- package/dist/utils/containerSetup.js +5 -0
- package/dist/utils/convertMessagesForAPI.js +21 -2
- package/dist/utils/messageOperations.d.ts +1 -0
- package/dist/utils/skillParser.js +3 -6
- package/dist/utils/toolImagePersistence.d.ts +25 -0
- package/dist/utils/toolImagePersistence.js +56 -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 -5
- package/builtin/plugins/sdd/.wave-plugin/plugin.json +0 -8
- package/builtin/plugins/sdd/hooks/hooks.json +0 -14
- package/builtin/plugins/sdd/scripts/session-start.js +0 -24
- package/builtin/plugins/sdd/scripts/spec-count.js +0 -77
- package/builtin/plugins/sdd/skills/specify/SKILL.md +0 -47
- package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +0 -47
- package/builtin/skills/artifact/SKILL.md +0 -14
- package/builtin/skills/loop/SKILL.md +0 -79
- package/builtin/skills/settings/ENV.md +0 -78
- package/builtin/skills/settings/HOOKS.md +0 -227
- package/builtin/skills/settings/MCP.md +0 -137
- package/builtin/skills/settings/MEMORY.md +0 -76
- package/builtin/skills/settings/MODELS.md +0 -119
- package/builtin/skills/settings/PERMISSIONS.md +0 -88
- package/builtin/skills/settings/PLUGINS.md +0 -171
- package/builtin/skills/settings/SKILL.md +0 -132
- package/builtin/skills/settings/SKILLS.md +0 -107
- package/builtin/skills/settings/SUBAGENTS.md +0 -77
- package/builtin/subagents/bash.md +0 -19
- package/builtin/subagents/explore.md +0 -43
- package/builtin/subagents/general-purpose.md +0 -20
- package/builtin/subagents/plan.md +0 -56
- package/builtin/subagents/vision.md +0 -18
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>;
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { executeCommand } from "./hook.js";
|
|
17
|
+
import { logger } from "../utils/globalLogger.js";
|
|
18
|
+
export function hasWorktreeCreateHook(configuration) {
|
|
19
|
+
return hasWorktreeHook(configuration, "WorktreeCreate");
|
|
20
|
+
}
|
|
21
|
+
export function hasWorktreeRemoveHook(configuration) {
|
|
22
|
+
return hasWorktreeHook(configuration, "WorktreeRemove");
|
|
23
|
+
}
|
|
24
|
+
function hasWorktreeHook(configuration, event) {
|
|
25
|
+
return (configuration?.[event]?.some((config) => config.hooks.length > 0) ?? false);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Execute WorktreeCreate hooks and return the worktree path from hook stdout.
|
|
29
|
+
*
|
|
30
|
+
* The first successful hook (exit code 0) with non-empty stdout provides the
|
|
31
|
+
* worktree path (trimmed). Throws if every hook fails or none emits output —
|
|
32
|
+
* creation is blocked.
|
|
33
|
+
*
|
|
34
|
+
* Callers should check hasWorktreeCreateHook() before calling.
|
|
35
|
+
*/
|
|
36
|
+
export async function executeWorktreeCreateHook(name, configuration, context) {
|
|
37
|
+
const hookConfigs = configuration?.["WorktreeCreate"] ?? [];
|
|
38
|
+
const results = await runWorktreeHooks("WorktreeCreate", hookConfigs, context, { name });
|
|
39
|
+
const successful = results.find((r) => r.success && (r.stdout ?? "").trim().length > 0);
|
|
40
|
+
if (!successful) {
|
|
41
|
+
const failedOutputs = results
|
|
42
|
+
.filter((r) => !r.success)
|
|
43
|
+
.map((r) => `${r.command}: ${r.stderr || r.stdout || "no output"}`);
|
|
44
|
+
throw new Error(`WorktreeCreate hook failed: ${failedOutputs.join("; ") || "no successful output"}`);
|
|
45
|
+
}
|
|
46
|
+
return { worktreePath: successful.stdout.trim() };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Execute WorktreeRemove hooks for a hook-based worktree.
|
|
50
|
+
* Returns true if hooks ran, false if none were configured.
|
|
51
|
+
* Failures are logged but never throw (non-blocking, aligned with Claude Code).
|
|
52
|
+
*/
|
|
53
|
+
export async function executeWorktreeRemoveHook(worktreePath, configuration, context) {
|
|
54
|
+
const hookConfigs = configuration?.["WorktreeRemove"] ?? [];
|
|
55
|
+
if (hookConfigs.length === 0) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const results = await runWorktreeHooks("WorktreeRemove", hookConfigs, context, { worktreePath });
|
|
59
|
+
for (const result of results) {
|
|
60
|
+
if (!result.success) {
|
|
61
|
+
logger?.error(`WorktreeRemove hook failed [${result.command}]: ${result.stderr || "no output"}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run every configured hook command for a worktree event, mirroring
|
|
68
|
+
* HookManager.executeHooks' per-command handling: `${WAVE_PLUGIN_ROOT}` /
|
|
69
|
+
* `${CLAUDE_PLUGIN_ROOT}` substitution, plugin env injection, per-command
|
|
70
|
+
* timeout, and fire-and-forget async hooks. Results are tagged with the
|
|
71
|
+
* command for error messages.
|
|
72
|
+
*/
|
|
73
|
+
async function runWorktreeHooks(event, eventConfigs, context, extra) {
|
|
74
|
+
const baseContext = {
|
|
75
|
+
event,
|
|
76
|
+
projectDir: context.projectDir,
|
|
77
|
+
timestamp: new Date(),
|
|
78
|
+
sessionId: context.sessionId,
|
|
79
|
+
transcriptPath: context.transcriptPath,
|
|
80
|
+
cwd: context.projectDir,
|
|
81
|
+
env: context.env,
|
|
82
|
+
...extra,
|
|
83
|
+
};
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const config of eventConfigs) {
|
|
86
|
+
for (const hookCommand of config.hooks) {
|
|
87
|
+
const options = hookCommand.timeout
|
|
88
|
+
? { timeout: hookCommand.timeout * 1000 }
|
|
89
|
+
: undefined;
|
|
90
|
+
// Build execution context with WAVE_PLUGIN_ROOT if this is a plugin hook
|
|
91
|
+
let command = hookCommand.command;
|
|
92
|
+
const execContext = hookCommand.pluginRoot
|
|
93
|
+
? {
|
|
94
|
+
...baseContext,
|
|
95
|
+
env: {
|
|
96
|
+
...(baseContext.env ?? {}),
|
|
97
|
+
WAVE_PLUGIN_ROOT: hookCommand.pluginRoot,
|
|
98
|
+
CLAUDE_PLUGIN_ROOT: hookCommand.pluginRoot,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: baseContext;
|
|
102
|
+
if (hookCommand.pluginRoot) {
|
|
103
|
+
command = command.replace(/\$\{WAVE_PLUGIN_ROOT\}/g, hookCommand.pluginRoot);
|
|
104
|
+
command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, hookCommand.pluginRoot);
|
|
105
|
+
}
|
|
106
|
+
if (hookCommand.async) {
|
|
107
|
+
// Async hooks are fire-and-forget (never block, never contribute a
|
|
108
|
+
// path for WorktreeCreate).
|
|
109
|
+
executeCommand(command, execContext, options).catch((error) => {
|
|
110
|
+
const message = error instanceof Error ? error.message : "Unknown execution error";
|
|
111
|
+
logger?.error(`[worktreeHooks] Async ${event} hook failed: ${message}`);
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const result = await executeCommand(command, execContext, options);
|
|
117
|
+
results.push({ ...result, command });
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// Rare — executeCommand resolves rather than rejects on failure.
|
|
121
|
+
const message = error instanceof Error ? error.message : "Unknown execution error";
|
|
122
|
+
results.push({
|
|
123
|
+
success: false,
|
|
124
|
+
stderr: message,
|
|
125
|
+
duration: 0,
|
|
126
|
+
timedOut: false,
|
|
127
|
+
command,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
package/dist/tools/agentTool.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { EXPLORE_SUBAGENT_TYPE } from "../constants/subagents.js";
|
|
2
2
|
import { AGENT_TOOL_NAME } from "../constants/tools.js";
|
|
3
3
|
import { countToolBlocks, formatToolTokenSummary, } from "../utils/messageOperations.js";
|
|
4
|
+
/**
|
|
5
|
+
* Find the last streaming text/reasoning block across the subagent's messages.
|
|
6
|
+
* The message manager finalizes streaming content blocks (stage → "end")
|
|
7
|
+
* before a tool call begins, so at most one block is "streaming" at a time:
|
|
8
|
+
* its presence means the subagent is currently generating content (thinking or
|
|
9
|
+
* answering), its absence means it is in a tool phase (streaming/running/idle).
|
|
10
|
+
*/
|
|
11
|
+
function findActiveStreamingBlock(messages) {
|
|
12
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
13
|
+
const blocks = messages[i].blocks;
|
|
14
|
+
for (let j = blocks.length - 1; j >= 0; j--) {
|
|
15
|
+
const block = blocks[j];
|
|
16
|
+
if ((block.type === "text" || block.type === "reasoning") &&
|
|
17
|
+
block.stage === "streaming") {
|
|
18
|
+
return block;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
4
24
|
/**
|
|
5
25
|
* Agent tool plugin for launching specialized agents to handle complex tasks
|
|
6
26
|
*/
|
|
@@ -132,7 +152,13 @@ When using the Agent tool, you must specify a subagent_type parameter to select
|
|
|
132
152
|
return;
|
|
133
153
|
const messages = instance.messageManager.getMessages();
|
|
134
154
|
const tokens = instance.messageManager.getLatestTotalTokens();
|
|
135
|
-
|
|
155
|
+
// While the subagent is streaming content (reasoning or reply text),
|
|
156
|
+
// show the last ONE tool plus the streaming content tail; otherwise
|
|
157
|
+
// (tool streaming/running/idle) keep the last two tools.
|
|
158
|
+
const activeStreaming = findActiveStreamingBlock(messages);
|
|
159
|
+
const usedTools = activeStreaming
|
|
160
|
+
? instance.usedTools.slice(-1)
|
|
161
|
+
: instance.usedTools;
|
|
136
162
|
const toolCount = countToolBlocks(messages);
|
|
137
163
|
const summary = formatToolTokenSummary(toolCount, tokens);
|
|
138
164
|
const getDisplayParam = (t) => {
|
|
@@ -155,6 +181,15 @@ When using the Agent tool, you must specify a subagent_type parameter to select
|
|
|
155
181
|
.map((t) => `${t.name} ${getDisplayParam(t)}`)
|
|
156
182
|
.join("\n");
|
|
157
183
|
}
|
|
184
|
+
// Streaming content tail, on its own line below the tool lines
|
|
185
|
+
// (compact style, mirroring the tool's truncated compact params).
|
|
186
|
+
if (activeStreaming) {
|
|
187
|
+
const flat = activeStreaming.content.replace(/\s+/g, " ").trim();
|
|
188
|
+
if (flat) {
|
|
189
|
+
const tail = flat.length > 30 ? `…${flat.slice(-30)}` : flat;
|
|
190
|
+
shortResult += (shortResult ? "\n" : "") + tail;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
158
193
|
context.onShortResultUpdate?.(shortResult);
|
|
159
194
|
});
|
|
160
195
|
return new Promise((resolve) => {
|