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.
Files changed (40) hide show
  1. package/bin/wave-code.js +2 -0
  2. package/dist/bundle/wave.mjs +777 -855
  3. package/package.json +7 -7
  4. package/src/cli.tsx +9 -3
  5. package/src/components/FileSelector.tsx +11 -8
  6. package/src/components/HelpView.tsx +1 -1
  7. package/src/components/InputBox.tsx +3 -5
  8. package/src/components/McpManager.tsx +26 -0
  9. package/src/components/MessageBlockItem.tsx +14 -2
  10. package/src/components/RemoteControlView.tsx +1 -1
  11. package/src/components/RewindCommand.tsx +40 -46
  12. package/src/contexts/useChat.tsx +61 -49
  13. package/src/daemon/commands.ts +266 -44
  14. package/src/daemon/daemonLog.ts +132 -0
  15. package/src/daemon/socketPath.ts +123 -0
  16. package/src/daemon-cli.ts +19 -6
  17. package/src/hooks/useInputManager.ts +27 -25
  18. package/src/index.ts +36 -4
  19. package/src/managers/inputHandlers.ts +3 -13
  20. package/src/managers/inputReducer.ts +44 -13
  21. package/src/print-cli.ts +199 -49
  22. package/src/remote/hostIdentity.ts +1 -0
  23. package/src/remote/imagePreview.ts +182 -0
  24. package/src/remote/messageWindow.ts +15 -5
  25. package/src/remote/notificationCoalescer.ts +16 -3
  26. package/src/remote/relayClient.ts +1 -1
  27. package/src/remote/remoteControlCliHost.ts +26 -4
  28. package/src/remote/remoteControlService.ts +70 -19
  29. package/src/remote/remoteNotifications.ts +5 -4
  30. package/src/remote/textPreview.ts +155 -0
  31. package/src/stdio/agentBridge.ts +450 -117
  32. package/src/stdio/daemonServer.ts +49 -33
  33. package/src/stdio/hostSlashCommands.ts +9 -4
  34. package/src/stdio/uiPayload.ts +50 -2
  35. package/src/utils/agentSelector.ts +29 -0
  36. package/src/utils/argvCompat.ts +23 -0
  37. package/src/utils/printOutput.ts +403 -0
  38. package/src/utils/usageSummary.ts +74 -24
  39. package/src/utils/worktree.ts +222 -16
  40. 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
- console.log("\nToken Usage Summary:");
102
- console.log("==================");
155
+ write("\nToken Usage Summary:");
156
+ write("==================");
103
157
 
104
158
  if (sessionFilePath) {
105
- console.log(`Session: ${sessionFilePath}`);
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
- console.log(`Model: ${summary.model}`);
119
- console.log(` Prompt tokens: ${summary.prompt_tokens.toLocaleString()}`);
120
- console.log(
121
- ` Completion tokens: ${summary.completion_tokens.toLocaleString()}`,
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
- console.log(" Cache Usage:");
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
- console.log(
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
- console.log(
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
- console.log(
206
+ write(
155
207
  ` Operations: ${summary.operations.agent_calls} agent calls, ${summary.operations.compactions} compactions`,
156
208
  );
157
- console.log();
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
- console.log("Overall Total:");
168
- console.log(` Prompt tokens: ${totalPrompt.toLocaleString()}`);
169
- console.log(` Completion tokens: ${totalCompletion.toLocaleString()}`);
170
- console.log(` Total tokens: ${totalTokens.toLocaleString()}`);
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
- console.log(" Cache Usage:");
225
+ write(" Cache Usage:");
174
226
  if (totalCacheRead > 0) {
175
- console.log(
176
- ` Read from cache: ${totalCacheRead.toLocaleString()} tokens`,
177
- );
227
+ write(` Read from cache: ${totalCacheRead.toLocaleString()} tokens`);
178
228
  }
179
229
  if (totalCacheCreation > 0) {
180
- console.log(
230
+ write(
181
231
  ` Created cache: ${totalCacheCreation.toLocaleString()} tokens`,
182
232
  );
183
233
  }
184
234
  }
185
235
 
186
- console.log(
236
+ write(
187
237
  ` Operations: ${totalAgentCalls} agent calls, ${totalCompactions} compactions`,
188
238
  );
189
239
  }
@@ -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, ".wave", "worktrees", name);
168
- const branchName = `worktree-${name}`;
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 its associated branch
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. Failures are logged; when the directory survives, its branch is
559
- * deliberately kept so the leftover checkout stays reachable through git.
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(session: WorktreeSession): Promise<void> {
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
- return;
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 = rmWorktreeDirWithFs(session.path);
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 = rmWorktreeDirWithFs(session.path);
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 ${session.branch} so the ` +
649
- `leftover checkout stays reachable: path=${session.path} ` +
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
- }