wave-code 1.5.0 → 1.6.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/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +777 -855
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/FileSelector.tsx +11 -8
- package/src/components/HelpView.tsx +1 -1
- package/src/components/InputBox.tsx +3 -5
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/components/RewindCommand.tsx +40 -46
- package/src/contexts/useChat.tsx +61 -49
- package/src/daemon/commands.ts +266 -44
- package/src/daemon/daemonLog.ts +132 -0
- package/src/daemon/socketPath.ts +123 -0
- package/src/daemon-cli.ts +19 -6
- package/src/hooks/useInputManager.ts +27 -25
- package/src/index.ts +36 -4
- package/src/managers/inputHandlers.ts +3 -13
- package/src/managers/inputReducer.ts +44 -13
- package/src/print-cli.ts +199 -49
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +15 -5
- package/src/remote/notificationCoalescer.ts +16 -3
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +26 -4
- package/src/remote/remoteControlService.ts +70 -19
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +450 -117
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +50 -2
- package/src/utils/agentSelector.ts +29 -0
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +403 -0
- package/src/utils/usageSummary.ts +74 -24
- package/src/utils/worktree.ts +222 -16
- package/src/utils/rewindCheckpoints.ts +0 -14
|
@@ -17,6 +17,45 @@ export interface TokenSummary {
|
|
|
17
17
|
cache_creation_input_tokens?: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Totals across every usage record — what the structured `usage` field reports.
|
|
22
|
+
*
|
|
23
|
+
* Claude Code's `input_tokens` excludes cached tokens; ours (`prompt_tokens`,
|
|
24
|
+
* OpenAI shape) already contains them, so the two are not interchangeable and
|
|
25
|
+
* this deliberately keeps our own field names.
|
|
26
|
+
*/
|
|
27
|
+
export interface UsageTotals {
|
|
28
|
+
prompt_tokens: number;
|
|
29
|
+
completion_tokens: number;
|
|
30
|
+
total_tokens: number;
|
|
31
|
+
cache_read_input_tokens?: number;
|
|
32
|
+
cache_creation_input_tokens?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sum of all usage records, cache fields included only when they appear. */
|
|
36
|
+
export function sumUsage(usages: Usage[]): UsageTotals {
|
|
37
|
+
const totals: UsageTotals = {
|
|
38
|
+
prompt_tokens: 0,
|
|
39
|
+
completion_tokens: 0,
|
|
40
|
+
total_tokens: 0,
|
|
41
|
+
};
|
|
42
|
+
for (const usage of usages) {
|
|
43
|
+
totals.prompt_tokens += usage.prompt_tokens;
|
|
44
|
+
totals.completion_tokens += usage.completion_tokens;
|
|
45
|
+
totals.total_tokens += usage.total_tokens;
|
|
46
|
+
if (usage.cache_read_input_tokens) {
|
|
47
|
+
totals.cache_read_input_tokens =
|
|
48
|
+
(totals.cache_read_input_tokens || 0) + usage.cache_read_input_tokens;
|
|
49
|
+
}
|
|
50
|
+
if (usage.cache_creation_input_tokens) {
|
|
51
|
+
totals.cache_creation_input_tokens =
|
|
52
|
+
(totals.cache_creation_input_tokens || 0) +
|
|
53
|
+
usage.cache_creation_input_tokens;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return totals;
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
/**
|
|
21
60
|
* Calculate token usage summary by model from usage array
|
|
22
61
|
* @param usages Array of usage data from agent operations
|
|
@@ -83,26 +122,41 @@ export function calculateTokenSummary(
|
|
|
83
122
|
return result;
|
|
84
123
|
}
|
|
85
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Where the summary lines go. Called with no argument for a blank line.
|
|
127
|
+
*/
|
|
128
|
+
export type UsageLineWriter = (line?: string) => void;
|
|
129
|
+
|
|
86
130
|
/**
|
|
87
131
|
* Display usage summary in a formatted way
|
|
88
132
|
* @param usages Array of usage data from agent operations
|
|
89
133
|
* @param sessionFilePath Optional session file path to display
|
|
134
|
+
* @param writer Where the lines go — stdout by default; structured print modes
|
|
135
|
+
* hand it stderr so their stdout stays pure JSON
|
|
90
136
|
*/
|
|
91
137
|
export function displayUsageSummary(
|
|
92
138
|
usages: Usage[],
|
|
93
139
|
sessionFilePath?: string,
|
|
140
|
+
writer?: UsageLineWriter,
|
|
94
141
|
): void {
|
|
95
142
|
if (usages.length === 0) {
|
|
96
143
|
return; // No usage data to display
|
|
97
144
|
}
|
|
98
145
|
|
|
146
|
+
const write: UsageLineWriter =
|
|
147
|
+
writer ??
|
|
148
|
+
((line) => {
|
|
149
|
+
if (line === undefined) console.log();
|
|
150
|
+
else console.log(line);
|
|
151
|
+
});
|
|
152
|
+
|
|
99
153
|
const summaries = calculateTokenSummary(usages);
|
|
100
154
|
|
|
101
|
-
|
|
102
|
-
|
|
155
|
+
write("\nToken Usage Summary:");
|
|
156
|
+
write("==================");
|
|
103
157
|
|
|
104
158
|
if (sessionFilePath) {
|
|
105
|
-
|
|
159
|
+
write(`Session: ${sessionFilePath}`);
|
|
106
160
|
}
|
|
107
161
|
|
|
108
162
|
let totalPrompt = 0;
|
|
@@ -115,12 +169,10 @@ export function displayUsageSummary(
|
|
|
115
169
|
let hasCacheData = false;
|
|
116
170
|
|
|
117
171
|
for (const [, summary] of Object.entries(summaries)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
123
|
-
console.log(` Total tokens: ${summary.total_tokens.toLocaleString()}`);
|
|
172
|
+
write(`Model: ${summary.model}`);
|
|
173
|
+
write(` Prompt tokens: ${summary.prompt_tokens.toLocaleString()}`);
|
|
174
|
+
write(` Completion tokens: ${summary.completion_tokens.toLocaleString()}`);
|
|
175
|
+
write(` Total tokens: ${summary.total_tokens.toLocaleString()}`);
|
|
124
176
|
|
|
125
177
|
// Display cache information if available
|
|
126
178
|
if (
|
|
@@ -128,13 +180,13 @@ export function displayUsageSummary(
|
|
|
128
180
|
summary.cache_creation_input_tokens
|
|
129
181
|
) {
|
|
130
182
|
hasCacheData = true;
|
|
131
|
-
|
|
183
|
+
write(" Cache Usage:");
|
|
132
184
|
|
|
133
185
|
if (
|
|
134
186
|
summary.cache_read_input_tokens &&
|
|
135
187
|
summary.cache_read_input_tokens > 0
|
|
136
188
|
) {
|
|
137
|
-
|
|
189
|
+
write(
|
|
138
190
|
` Read from cache: ${summary.cache_read_input_tokens.toLocaleString()} tokens`,
|
|
139
191
|
);
|
|
140
192
|
totalCacheRead += summary.cache_read_input_tokens;
|
|
@@ -144,17 +196,17 @@ export function displayUsageSummary(
|
|
|
144
196
|
summary.cache_creation_input_tokens &&
|
|
145
197
|
summary.cache_creation_input_tokens > 0
|
|
146
198
|
) {
|
|
147
|
-
|
|
199
|
+
write(
|
|
148
200
|
` Created cache: ${summary.cache_creation_input_tokens.toLocaleString()} tokens`,
|
|
149
201
|
);
|
|
150
202
|
totalCacheCreation += summary.cache_creation_input_tokens;
|
|
151
203
|
}
|
|
152
204
|
}
|
|
153
205
|
|
|
154
|
-
|
|
206
|
+
write(
|
|
155
207
|
` Operations: ${summary.operations.agent_calls} agent calls, ${summary.operations.compactions} compactions`,
|
|
156
208
|
);
|
|
157
|
-
|
|
209
|
+
write();
|
|
158
210
|
|
|
159
211
|
totalPrompt += summary.prompt_tokens;
|
|
160
212
|
totalCompletion += summary.completion_tokens;
|
|
@@ -164,26 +216,24 @@ export function displayUsageSummary(
|
|
|
164
216
|
}
|
|
165
217
|
|
|
166
218
|
if (Object.keys(summaries).length > 1) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
219
|
+
write("Overall Total:");
|
|
220
|
+
write(` Prompt tokens: ${totalPrompt.toLocaleString()}`);
|
|
221
|
+
write(` Completion tokens: ${totalCompletion.toLocaleString()}`);
|
|
222
|
+
write(` Total tokens: ${totalTokens.toLocaleString()}`);
|
|
171
223
|
|
|
172
224
|
if (hasCacheData) {
|
|
173
|
-
|
|
225
|
+
write(" Cache Usage:");
|
|
174
226
|
if (totalCacheRead > 0) {
|
|
175
|
-
|
|
176
|
-
` Read from cache: ${totalCacheRead.toLocaleString()} tokens`,
|
|
177
|
-
);
|
|
227
|
+
write(` Read from cache: ${totalCacheRead.toLocaleString()} tokens`);
|
|
178
228
|
}
|
|
179
229
|
if (totalCacheCreation > 0) {
|
|
180
|
-
|
|
230
|
+
write(
|
|
181
231
|
` Created cache: ${totalCacheCreation.toLocaleString()} tokens`,
|
|
182
232
|
);
|
|
183
233
|
}
|
|
184
234
|
}
|
|
185
235
|
|
|
186
|
-
|
|
236
|
+
write(
|
|
187
237
|
` Operations: ${totalAgentCalls} agent calls, ${totalCompactions} compactions`,
|
|
188
238
|
);
|
|
189
239
|
}
|
package/src/utils/worktree.ts
CHANGED
|
@@ -21,6 +21,12 @@ const execFileAsync = promisify(execFile);
|
|
|
21
21
|
export interface WorktreeSession {
|
|
22
22
|
name: string;
|
|
23
23
|
path: string;
|
|
24
|
+
/**
|
|
25
|
+
* The branch the worktree was CREATED on — `worktree-<name>` for a worktree
|
|
26
|
+
* wave created, and what callers must record and hand back to
|
|
27
|
+
* `removeWorktree`. It is not read back from the worktree: an agent may have
|
|
28
|
+
* checked another branch out in there (see `removeWorktree`).
|
|
29
|
+
*/
|
|
24
30
|
branch: string;
|
|
25
31
|
repoRoot: string;
|
|
26
32
|
hasUncommittedChanges: boolean;
|
|
@@ -39,6 +45,12 @@ export interface WorktreeSession {
|
|
|
39
45
|
const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
|
|
40
46
|
const MAX_WORKTREE_SLUG_LENGTH = 64;
|
|
41
47
|
|
|
48
|
+
/** Where `createWorktree` puts worktrees, relative to the repo root. */
|
|
49
|
+
const WORKTREE_PARENT_SEGMENTS = [".wave", "worktrees"];
|
|
50
|
+
|
|
51
|
+
/** Branch prefix `createWorktree` gives a worktree it creates. */
|
|
52
|
+
const WORKTREE_BRANCH_PREFIX = "worktree-";
|
|
53
|
+
|
|
42
54
|
/**
|
|
43
55
|
* Validate a worktree name before any side effects. Names are slash-separated
|
|
44
56
|
* slugs: every segment must be non-empty and contain only letters, digits,
|
|
@@ -164,8 +176,8 @@ async function createWorktreeInternal(
|
|
|
164
176
|
options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
|
|
165
177
|
): Promise<WorktreeSession> {
|
|
166
178
|
const repoRoot = getGitMainRepoRoot(cwd);
|
|
167
|
-
const worktreePath = path.join(repoRoot,
|
|
168
|
-
const branchName =
|
|
179
|
+
const worktreePath = path.join(repoRoot, ...WORKTREE_PARENT_SEGMENTS, name);
|
|
180
|
+
const branchName = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
169
181
|
const useHead = options?.baseRef === "head";
|
|
170
182
|
const resolvedBaseBranch =
|
|
171
183
|
options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
|
|
@@ -384,6 +396,43 @@ function rmWorktreeDirWithFs(worktreePath: string): unknown | null {
|
|
|
384
396
|
}
|
|
385
397
|
}
|
|
386
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Waits between `fs.rmSync` attempts on the worktree directory. Mutable so
|
|
401
|
+
* tests can zero them out.
|
|
402
|
+
*
|
|
403
|
+
* The first attempt still runs immediately; these only spread the retries.
|
|
404
|
+
* Windows keeps a directory alive for as long as any process holds it as its
|
|
405
|
+
* cwd (a killed shell, `node --watch`, a test runner), and that handle is
|
|
406
|
+
* released asynchronously — the caller's teardown may well have returned before
|
|
407
|
+
* the OS let go (issue #2763: the same `rm -rf` succeeds a few seconds later by
|
|
408
|
+
* hand). Three attempts over ~0.9s covers the release lag without turning a
|
|
409
|
+
* genuinely locked directory into a multi-second stall.
|
|
410
|
+
*/
|
|
411
|
+
export const worktreeRemoveRetry = { delaysMs: [300, 600] };
|
|
412
|
+
|
|
413
|
+
function sleep(ms: number): Promise<void> {
|
|
414
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `rmWorktreeDirWithFs` with the short retry above. Returns null once the
|
|
419
|
+
* directory is gone (or the deletion reported success), the last error
|
|
420
|
+
* otherwise. Existence is deliberately not probed here: the caller already
|
|
421
|
+
* decides "did it actually go" from the filesystem, and an extra stat per
|
|
422
|
+
* attempt would only make that harder to reason about.
|
|
423
|
+
*/
|
|
424
|
+
async function rmWorktreeDirWithRetries(
|
|
425
|
+
worktreePath: string,
|
|
426
|
+
): Promise<unknown | null> {
|
|
427
|
+
let error = rmWorktreeDirWithFs(worktreePath);
|
|
428
|
+
for (const delayMs of worktreeRemoveRetry.delaysMs) {
|
|
429
|
+
if (!error) return null;
|
|
430
|
+
await sleep(delayMs);
|
|
431
|
+
error = rmWorktreeDirWithFs(worktreePath);
|
|
432
|
+
}
|
|
433
|
+
return error;
|
|
434
|
+
}
|
|
435
|
+
|
|
387
436
|
/**
|
|
388
437
|
* Describe what is still on disk after a failed removal. Without this a
|
|
389
438
|
* failure log cannot distinguish "directory already gone" from "the whole
|
|
@@ -410,10 +459,19 @@ export interface WorktreeChanges {
|
|
|
410
459
|
/**
|
|
411
460
|
* The linked worktree a directory sits in, derived from the filesystem only.
|
|
412
461
|
* Same field semantics as `createWorktree`'s result, so a caller can hand it
|
|
413
|
-
* straight to `removeWorktree
|
|
462
|
+
* straight to `removeWorktree` — except for `branch`, which is read off the
|
|
463
|
+
* worktree's HEAD and is therefore **the branch it has checked out now**, not
|
|
464
|
+
* the branch it was created on. Removal must pass the creation branch (the
|
|
465
|
+
* session's snapshot, or `worktreeCreationBranch`); see `removeWorktree`.
|
|
414
466
|
*/
|
|
415
467
|
export interface DerivedWorktree {
|
|
416
468
|
path: string;
|
|
469
|
+
/**
|
|
470
|
+
* The branch checked out in the worktree right now (`<gitdir>/HEAD`), or `""`
|
|
471
|
+
* for a detached HEAD. Informational: never use it as "the worktree's branch"
|
|
472
|
+
* for deletion — an agent may have checked a branch of the user's out in
|
|
473
|
+
* there.
|
|
474
|
+
*/
|
|
417
475
|
branch: string;
|
|
418
476
|
repoRoot: string;
|
|
419
477
|
hookBased: boolean;
|
|
@@ -504,6 +562,62 @@ export function deriveWorktreeInfo(
|
|
|
504
562
|
}
|
|
505
563
|
}
|
|
506
564
|
|
|
565
|
+
/**
|
|
566
|
+
* The branch `createWorktree` created for a worktree, recovered from where that
|
|
567
|
+
* worktree sits: `<repoRoot>/.wave/worktrees/<name>` is on
|
|
568
|
+
* `worktree-<name>` (see `createWorktreeInternal`).
|
|
569
|
+
*
|
|
570
|
+
* This exists because git keeps no record of it. `git worktree list
|
|
571
|
+
* --porcelain` and `<gitdir>/HEAD` both report the branch a worktree has
|
|
572
|
+
* checked out *at the moment you ask*, so once an agent runs `git checkout
|
|
573
|
+
* <something-else>` inside the worktree they name that branch instead — the
|
|
574
|
+
* branch the user owns, possibly holding commits reachable from nowhere else.
|
|
575
|
+
* A removal must delete the branch wave created, and the path is the only
|
|
576
|
+
* surviving record of it besides a caller's own snapshot.
|
|
577
|
+
*
|
|
578
|
+
* The name is taken from the **tail** of `path` — every segment below
|
|
579
|
+
* `.wave/worktrees` — and the parent directory is never string-compared
|
|
580
|
+
* against `repoRoot`. The two paths reach us from different sources: `path` is
|
|
581
|
+
* whatever the OS or host handed over (`fs.mkdtempSync`, a session's cwd, a
|
|
582
|
+
* transcript header), while `repoRoot` is the path **git** canonicalized and
|
|
583
|
+
* wrote into `<gitdir>/commondir`. On Windows the same directory routinely
|
|
584
|
+
* arrives in two forms — 8.3 short name (`C:\Users\RUNNER~1\…`), unexpanded
|
|
585
|
+
* symlink/junction, drive-letter case — and Windows compares paths
|
|
586
|
+
* case-insensitively while a string compare does not, so the old
|
|
587
|
+
* `path.startsWith(parentDir)` called a legitimate worktree "not wave's" and
|
|
588
|
+
* the creation branch was never deleted. That is what turned `check-windows`
|
|
589
|
+
* red on main after #2721. Whether the path really sits in *this* repo's
|
|
590
|
+
* directory is not decidable from the strings anyway; `removeWorktree` asks git
|
|
591
|
+
* whether the branch it is about to delete exists there instead.
|
|
592
|
+
*
|
|
593
|
+
* Returns `undefined` for a path that is not laid out under a
|
|
594
|
+
* `.wave/worktrees` directory (a hand-made `git worktree add`, a hook-managed
|
|
595
|
+
* one): its branch is not wave's to name — and for an unknown `repoRoot`,
|
|
596
|
+
* because a caller that cannot resolve the repository has nothing to look a
|
|
597
|
+
* branch up in.
|
|
598
|
+
*/
|
|
599
|
+
export function worktreeCreationBranch(
|
|
600
|
+
worktree: Pick<DerivedWorktree, "path" | "repoRoot">,
|
|
601
|
+
): string | undefined {
|
|
602
|
+
if (!worktree.repoRoot || !worktree.path) return undefined;
|
|
603
|
+
const segments = path.resolve(worktree.path).split(path.sep).filter(Boolean);
|
|
604
|
+
// Deepest `.wave/worktrees` pair first: a repository checked out *inside*
|
|
605
|
+
// another wave worktree still names the innermost worktree.
|
|
606
|
+
for (let i = segments.length - 2; i >= 0; i--) {
|
|
607
|
+
if (
|
|
608
|
+
segments[i] !== WORKTREE_PARENT_SEGMENTS[0] ||
|
|
609
|
+
segments[i + 1] !== WORKTREE_PARENT_SEGMENTS[1]
|
|
610
|
+
) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
// Names are slug paths (see validateWorktreeSlug), so the branch keeps the
|
|
614
|
+
// slashes; a path that ends at `worktrees` itself names nothing.
|
|
615
|
+
const name = segments.slice(i + 2).join("/");
|
|
616
|
+
return name ? `${WORKTREE_BRANCH_PREFIX}${name}` : undefined;
|
|
617
|
+
}
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
507
621
|
/**
|
|
508
622
|
* Count what deleting a worktree would throw away, so the caller can warn
|
|
509
623
|
* before it happens. Both counts are best-effort: an unknown base branch (never
|
|
@@ -547,18 +661,35 @@ export async function getWorktreeChanges(
|
|
|
547
661
|
}
|
|
548
662
|
|
|
549
663
|
/**
|
|
550
|
-
* Remove a git worktree and
|
|
551
|
-
* @param session Worktree session details
|
|
664
|
+
* Remove a git worktree and the branch it was created on.
|
|
665
|
+
* @param session Worktree session details. `branch` must be the branch the
|
|
666
|
+
* worktree was CREATED on (a snapshot the caller recorded at creation time,
|
|
667
|
+
* or `worktreeCreationBranch`) — never the branch that directory happens to
|
|
668
|
+
* have checked out, which an in-session `git checkout` can change.
|
|
552
669
|
*
|
|
553
670
|
* Removal is best-effort: `git worktree remove --force` deletes the worktree
|
|
554
671
|
* metadata before the working directory, and on Windows its recursive deletion
|
|
555
672
|
* is MAX_PATH-limited — deep paths (e.g. node_modules) can fail with "Filename
|
|
556
673
|
* too long", leaving an orphan directory. When git fails we fall back to
|
|
557
674
|
* fs.rmSync with an extended-length path (bypasses MAX_PATH) and prune stale
|
|
558
|
-
* metadata.
|
|
559
|
-
*
|
|
675
|
+
* metadata. The fallback retries a couple of times (`worktreeRemoveRetry`): a
|
|
676
|
+
* directory locked by a process that is only now exiting — the usual Windows
|
|
677
|
+
* case — becomes deletable a moment later. Failures are logged; when the
|
|
678
|
+
* directory still survives, its branch is deliberately kept so the leftover
|
|
679
|
+
* checkout stays reachable through git.
|
|
680
|
+
*
|
|
681
|
+
* Returns whether the directory is actually gone (not whether the removal was
|
|
682
|
+
* attempted) — a caller that reports the outcome to a user must print it rather
|
|
683
|
+
* than assume success, because every failure mode above is silent by design.
|
|
684
|
+
*
|
|
685
|
+
* The branch is deleted only when `repoRoot` actually has it (`branchExists`),
|
|
686
|
+
* so a name inferred from the path — which no longer depends on how the two
|
|
687
|
+
* path strings are spelled, see `worktreeCreationBranch` — can never delete
|
|
688
|
+
* something else.
|
|
560
689
|
*/
|
|
561
|
-
export async function removeWorktree(
|
|
690
|
+
export async function removeWorktree(
|
|
691
|
+
session: WorktreeSession,
|
|
692
|
+
): Promise<boolean> {
|
|
562
693
|
// Hook-based worktrees are removed by the WorktreeRemove hook; wave never
|
|
563
694
|
// runs `git worktree remove` for them. If no WorktreeRemove hook is
|
|
564
695
|
// configured, the worktree is left in place with a warning (CC semantics).
|
|
@@ -578,7 +709,9 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
578
709
|
`No WorktreeRemove hook configured, hook-based worktree left at: ${session.path}`,
|
|
579
710
|
);
|
|
580
711
|
}
|
|
581
|
-
|
|
712
|
+
// The hook decides what it deletes (it may be a VCS wave knows nothing
|
|
713
|
+
// about), so the filesystem is the only honest answer.
|
|
714
|
+
return !fs.existsSync(session.path);
|
|
582
715
|
}
|
|
583
716
|
|
|
584
717
|
const repoRoot = session.repoRoot;
|
|
@@ -602,7 +735,7 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
602
735
|
`path=${session.path} residue=${probeResidue(session.path)} — ` +
|
|
603
736
|
`deleting with fs.rmSync instead`,
|
|
604
737
|
);
|
|
605
|
-
const rmError =
|
|
738
|
+
const rmError = await rmWorktreeDirWithRetries(session.path);
|
|
606
739
|
if (rmError) {
|
|
607
740
|
logger.error(
|
|
608
741
|
`Failed to remove worktree or branch: path=${session.path} ` +
|
|
@@ -617,7 +750,7 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
617
750
|
`git worktree remove failed, falling back to fs.rmSync: ${describeGitFailure(error)}`,
|
|
618
751
|
error,
|
|
619
752
|
);
|
|
620
|
-
const rmError =
|
|
753
|
+
const rmError = await rmWorktreeDirWithRetries(session.path);
|
|
621
754
|
if (rmError) {
|
|
622
755
|
// Removal failures are best-effort, so this line is often the only trace
|
|
623
756
|
// of an orphaned worktree directory: record which stage failed, why, and
|
|
@@ -645,16 +778,39 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
645
778
|
// still on disk, and the branch is the only ref still leading back to it.
|
|
646
779
|
if (fs.existsSync(session.path)) {
|
|
647
780
|
logger.warn(
|
|
648
|
-
`Worktree directory survived removal — keeping
|
|
649
|
-
|
|
650
|
-
`residue=${probeResidue(session.path)}`,
|
|
781
|
+
`Worktree directory survived removal — keeping ` +
|
|
782
|
+
`${session.branch || "its branches"} so the leftover checkout stays ` +
|
|
783
|
+
`reachable: path=${session.path} residue=${probeResidue(session.path)}`,
|
|
651
784
|
);
|
|
652
|
-
return;
|
|
785
|
+
return false;
|
|
653
786
|
}
|
|
654
787
|
|
|
655
788
|
// Delete the worktree's own branch. Branches the user checked out inside the
|
|
656
789
|
// worktree are never touched — they may hold commits that are not reachable
|
|
657
|
-
// from anywhere else (aligned with Claude Code).
|
|
790
|
+
// from anywhere else (aligned with Claude Code). That is why `session.branch`
|
|
791
|
+
// has to be the creation-time branch: reading it back from the worktree (its
|
|
792
|
+
// HEAD) would name whatever was checked out last, i.e. exactly the branch
|
|
793
|
+
// that must survive. An unknown branch deletes nothing at all.
|
|
794
|
+
if (!session.branch) {
|
|
795
|
+
logger.warn(
|
|
796
|
+
`No branch recorded for the worktree at ${session.path} — keeping every ` +
|
|
797
|
+
`branch rather than deleting the one it had checked out`,
|
|
798
|
+
);
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
// A name derived from the worktree's path layout (rather than snapshotted at
|
|
802
|
+
// creation) is only a candidate: the layout says what name wave *would* have
|
|
803
|
+
// used, not that this repository has that branch. Ask git before deleting —
|
|
804
|
+
// a name that does not resolve here means the guess was wrong or the branch
|
|
805
|
+
// is already gone, and either way the safe outcome is to delete nothing.
|
|
806
|
+
if (!(await branchExists(repoRoot, session.branch))) {
|
|
807
|
+
logger.warn(
|
|
808
|
+
`No branch ${session.branch} in ${repoRoot} — keeping every branch ` +
|
|
809
|
+
`(the worktree at ${session.path} was not created on it, or it is ` +
|
|
810
|
+
`already gone)`,
|
|
811
|
+
);
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
658
814
|
try {
|
|
659
815
|
await execFileAsync("git", ["branch", "-D", session.branch], {
|
|
660
816
|
cwd: repoRoot,
|
|
@@ -662,4 +818,54 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
662
818
|
} catch {
|
|
663
819
|
// Ignore errors deleting original branch
|
|
664
820
|
}
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* What to tell the user when `removeWorktree` returned `false`: where the
|
|
826
|
+
* directory still is, which branch was kept for it (removal keeps that ref so
|
|
827
|
+
* the leftover checkout stays reachable through git), and the two commands that
|
|
828
|
+
* finish the job by hand.
|
|
829
|
+
*
|
|
830
|
+
* Single-sourced because every CLI entry point that removes a worktree used to
|
|
831
|
+
* print a flat "Done." whether or not anything was deleted — TUI exit, `-p`, and
|
|
832
|
+
* `wave daemon destroy --remove-worktree` (issue #2763). One wording, one place.
|
|
833
|
+
*/
|
|
834
|
+
export function describeWorktreeSurvival(worktree: {
|
|
835
|
+
path: string;
|
|
836
|
+
branch: string;
|
|
837
|
+
repoRoot: string;
|
|
838
|
+
}): string {
|
|
839
|
+
const branch = worktree.branch ? `branch ${worktree.branch}` : "every branch";
|
|
840
|
+
return (
|
|
841
|
+
`Worktree directory survived at ${worktree.path}; kept ${branch} so the ` +
|
|
842
|
+
`leftover checkout stays reachable\n` +
|
|
843
|
+
`Clean up manually: rm -rf ${worktree.path}` +
|
|
844
|
+
(worktree.branch
|
|
845
|
+
? ` && git -C ${worktree.repoRoot} branch -D ${worktree.branch}`
|
|
846
|
+
: "")
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Does `repoRoot` have a local branch called exactly `branch`?
|
|
852
|
+
*
|
|
853
|
+
* `git branch --list` takes a glob pattern, so the listing is compared back
|
|
854
|
+
* against the exact name instead of trusting "it printed something". Any git
|
|
855
|
+
* failure counts as "no such branch" — the caller then keeps every branch.
|
|
856
|
+
*/
|
|
857
|
+
async function branchExists(
|
|
858
|
+
repoRoot: string,
|
|
859
|
+
branch: string,
|
|
860
|
+
): Promise<boolean> {
|
|
861
|
+
try {
|
|
862
|
+
const { stdout } = await execFileAsync(
|
|
863
|
+
"git",
|
|
864
|
+
["branch", "--list", "--format=%(refname:short)", branch],
|
|
865
|
+
{ cwd: repoRoot, encoding: "utf8" },
|
|
866
|
+
);
|
|
867
|
+
return stdout.trim() === branch;
|
|
868
|
+
} catch {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
665
871
|
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { Message } from "wave-agent-sdk";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 判断一条 user 消息能否作为 /rewind 检查点。
|
|
5
|
-
* 后台任务通知(task_notification)与 hook 注入的消息都是系统生成、
|
|
6
|
-
* 用户不可见的(meta),不能作为回滚点。bash 模式命令消息
|
|
7
|
-
* (`!ls`)是用户真正输入,与 fork skill 命令消息一致,可作为回滚点。
|
|
8
|
-
* CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
|
|
9
|
-
*/
|
|
10
|
-
export function isUserCheckpointMessage(m: Message): boolean {
|
|
11
|
-
if (m.role !== "user" || m.isMeta) return false;
|
|
12
|
-
if (m.blocks.some((b) => b.type === "task_notification")) return false;
|
|
13
|
-
return true;
|
|
14
|
-
}
|