wave-code 1.5.1 → 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.
@@ -1,6 +1,7 @@
1
1
  /**
2
- * DaemonServer — JSON-RPC server over a unix socket for remote background
3
- * sessions (spec: docs/specs/desktop/desktop-sessions.md 「SSH 远程后台会话」).
2
+ * DaemonServer — JSON-RPC server over a local socket (a unix domain socket on
3
+ * POSIX, a Windows named pipe) for remote background sessions (spec:
4
+ * docs/specs/desktop/desktop-remote.md 「SSH 远程后台会话」).
4
5
  *
5
6
  * The desktop app launches `wave --daemon <socket>` on the remote host via
6
7
  * nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
@@ -18,6 +19,10 @@ import net from "net";
18
19
  import * as fs from "fs";
19
20
  import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
20
21
  import { JsonRpcConnection } from "./jsonRpcConnection.js";
22
+ import {
23
+ describeListenError,
24
+ isWindowsPipeName,
25
+ } from "../daemon/socketPath.js";
21
26
  import type { RemoteControlSink } from "../remote/remoteControlSink.js";
22
27
 
23
28
  export interface DaemonServerOptions {
@@ -98,37 +103,43 @@ export class DaemonServer {
98
103
  * unlinked outright; socket files are probe-connected — ECONNREFUSED means
99
104
  * no listener (stale → unlink and listen), anything else means a live
100
105
  * daemon owns the path (reject).
106
+ *
107
+ * A Windows named pipe has no file to go stale: it disappears with its
108
+ * listener, so the probe/unlink pass is skipped and a name already in use
109
+ * surfaces as EADDRINUSE from `listen` (translated in `listen`).
101
110
  */
102
111
  start(): Promise<void> {
103
112
  return new Promise((resolve, reject) => {
104
113
  const server = this.server;
105
114
  if (!server) return resolve();
106
- try {
107
- const st = fs.statSync(this.socketPath);
108
- if (st.isSocket()) {
109
- const probe = net.connect(this.socketPath);
110
- probe.once("connect", () => {
111
- probe.destroy();
112
- reject(
113
- new Error(
114
- `Another wave daemon is already listening on ${this.socketPath}`,
115
- ),
116
- );
117
- });
118
- probe.once("error", (err: NodeJS.ErrnoException) => {
119
- probe.destroy();
120
- if (err.code === "ECONNREFUSED") {
121
- fs.unlinkSync(this.socketPath);
122
- this.listen(server, resolve, reject);
123
- } else {
124
- reject(err);
125
- }
126
- });
127
- return;
115
+ if (!isWindowsPipeName(this.socketPath)) {
116
+ try {
117
+ const st = fs.statSync(this.socketPath);
118
+ if (st.isSocket()) {
119
+ const probe = net.connect(this.socketPath);
120
+ probe.once("connect", () => {
121
+ probe.destroy();
122
+ reject(
123
+ new Error(
124
+ `Another wave daemon is already listening on ${this.socketPath}`,
125
+ ),
126
+ );
127
+ });
128
+ probe.once("error", (err: NodeJS.ErrnoException) => {
129
+ probe.destroy();
130
+ if (err.code === "ECONNREFUSED") {
131
+ fs.unlinkSync(this.socketPath);
132
+ this.listen(server, resolve, reject);
133
+ } else {
134
+ reject(err);
135
+ }
136
+ });
137
+ return;
138
+ }
139
+ fs.unlinkSync(this.socketPath);
140
+ } catch {
141
+ // ENOENT — no stale socket, listen directly.
128
142
  }
129
- fs.unlinkSync(this.socketPath);
130
- } catch {
131
- // ENOENT — no stale socket, listen directly.
132
143
  }
133
144
  this.listen(server, resolve, reject);
134
145
  });
@@ -139,9 +150,11 @@ export class DaemonServer {
139
150
  resolve: () => void,
140
151
  reject: (err: Error) => void,
141
152
  ): void {
142
- server.once("error", reject);
153
+ const onError = (err: NodeJS.ErrnoException) =>
154
+ reject(describeListenError(this.socketPath, err));
155
+ server.once("error", onError);
143
156
  server.listen(this.socketPath, () => {
144
- server.removeListener("error", reject);
157
+ server.removeListener("error", onError);
145
158
  resolve();
146
159
  });
147
160
  }
@@ -174,10 +187,13 @@ export class DaemonServer {
174
187
  this.sockets.clear();
175
188
  this.server?.close();
176
189
  this.server = undefined;
177
- try {
178
- fs.unlinkSync(this.socketPath);
179
- } catch {
180
- // Already gone — a stale file is probed/unlinked on next start.
190
+ // A pipe name disappears with its listener; only a file socket needs unlinking.
191
+ if (!isWindowsPipeName(this.socketPath)) {
192
+ try {
193
+ fs.unlinkSync(this.socketPath);
194
+ } catch {
195
+ // Already gone — a stale file is probed/unlinked on next start.
196
+ }
181
197
  }
182
198
  try {
183
199
  process.exit(0);
@@ -18,7 +18,7 @@
18
18
  * **What each entry claims**: `surfaces` is the set of front ends that really
19
19
  * implement it. It is a capability statement, not a label: an entry listed for
20
20
  * a surface that has no handling behind it produces exactly the defect spec
21
- * ui/remote-control.md 场景 24 forbids — a row in the popup that does nothing
21
+ * remote/remote-web.md 场景 21 forbids — a row in the popup that does nothing
22
22
  * when picked. `hostSlashCommands.test.ts` pins each surface's set, and the
23
23
  * remote-page set is also cross-checked there against the remote allowlist,
24
24
  * which is the other half of the same promise (popup lists it ⇒ picking it
@@ -96,7 +96,7 @@ export const HOST_SLASH_COMMANDS: readonly HostSlashCommandSpec[] = [
96
96
  description: "清除对话历史并重置会话",
97
97
  runOnSelect: true,
98
98
  // 桌面端整端移除了 `/clear`(2026-09-05 拍板,见 clear-command.md 与
99
- // ui/remote-control.md 场景 24)。
99
+ // remote/remote-web.md 场景 21)。
100
100
  surfaces: ["vscode", "jetbrains", "remote-page"],
101
101
  },
102
102
  {
@@ -148,8 +148,13 @@ export const HOST_SLASH_COMMANDS: readonly HostSlashCommandSpec[] = [
148
148
  id: "resume",
149
149
  description: "恢复历史对话",
150
150
  runOnSelect: true,
151
- // 页面的会话列表只报进程内的活会话,磁盘历史会话在那边不渲染(场景 24)。
152
- surfaces: GUIS,
151
+ // 桌面端 / IDE 与 daemon、GUI 的 `--stdio` 子进程都能执行它;**前台 CLI 那
152
+ // 个承接进程不能**(它只有一条会话槽位,恢复磁盘会话等于把用户正看着的那条
153
+ // 换掉)——所以这一条是表第一次出现「入口一致、宿主再收窄」:这里给的是
154
+ // `remote-page` 的上限,`RemoteControlCliHost` 在回包前删掉它(见
155
+ // `remoteControlCliHost.ts` 的 `getSlashCommands`)。桌面侧栏与远控页的取数
156
+ // 不同:侧栏只报进程内活会话,`/resume` 才是磁盘历史会话的入口。
157
+ surfaces: ALL,
153
158
  },
154
159
  {
155
160
  id: "btw",
@@ -15,6 +15,12 @@
15
15
  * them. This is a wire cut, not a memory model: stripping `images` where the
16
16
  * SDK writes the block would break the next turn, so do not move it there.
17
17
  *
18
+ * **User pasted images are different.** Their bytes live in the message block
19
+ * (spec ui/image-pasting.md「已发送图片的标签点开就能看」) so every GUI host can
20
+ * preview them without asking anybody for a file — those go out to local hosts
21
+ * untouched, and only the remote Web page gets them cut (`messageForRemote`,
22
+ * spec remote/remote-channel.md「远端通道只开一个白名单」场景 10).
23
+ *
18
24
  * Copies are shallow and made only where the field is actually present — the
19
25
  * common message (no images) keeps its identity and costs nothing.
20
26
  */
@@ -53,6 +59,49 @@ export function toolBlockUpdateForUi(
53
59
  return rest;
54
60
  }
55
61
 
62
+ /**
63
+ * The remote Web page's copy of a message: `messageForUi`, plus user image
64
+ * bytes blanked out (docs/specs/remote/remote-channel.md「远端通道只开一个白名单」
65
+ * 场景 10).
66
+ *
67
+ * Image bytes stay in the block for GUI hosts (they preview them in place), but
68
+ * they must not go over the relay: one screenshot is commonly 1–3 MB of base64,
69
+ * far past the 512 KiB frame budget, and the page could not use the bytes even
70
+ * if they arrived. The array keeps its **length and order** — the `[imageN]`
71
+ * tags in the text block are positional — so the page still shows a tag per
72
+ * image, just not a clickable one. `imagePaths` goes too: it is a path on the
73
+ * session host, which the page cannot read.
74
+ */
75
+ export function messageForRemote(message: Message): Message {
76
+ const forUi = messageForUi(message);
77
+ const { blocks } = forUi;
78
+ if (!Array.isArray(blocks)) return forUi;
79
+ let changed = false;
80
+ const blanked = blocks.map((block) => {
81
+ if (block.type !== "image") return block;
82
+ const { imagePaths, ...rest } = block;
83
+ if (!imagePaths && !rest.imageUrls?.length) return block;
84
+ changed = true;
85
+ return { ...rest, imageUrls: (rest.imageUrls ?? []).map(() => "") };
86
+ });
87
+ return changed ? { ...forUi, blocks: blanked } : forUi;
88
+ }
89
+
90
+ /**
91
+ * `messageForRemote` over the `getMessages` answer — the shape the remote
92
+ * service actually holds (an untyped JSON result). Anything that is not a
93
+ * message list passes through untouched.
94
+ */
95
+ export function resultForRemote(result: unknown): unknown {
96
+ if (!result || typeof result !== "object") return result;
97
+ const { messages, ...rest } = result as { messages?: unknown };
98
+ if (!Array.isArray(messages)) return result;
99
+ return {
100
+ ...rest,
101
+ messages: messages.map((message) => messageForRemote(message as Message)),
102
+ };
103
+ }
104
+
56
105
  function hasImages(block: ToolBlock): boolean {
57
106
  return Array.isArray(block.images) && block.images.length > 0;
58
107
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Keep yargs' `hideBin()` slicing argv the way a plain `node script.js` run
3
+ * does, even when the CLI is executed by the Electron runtime.
4
+ *
5
+ * Electron RUN_AS_NODE (the desktop / vscode bundled runtime, `process.execPath`
6
+ * + `ELECTRON_RUN_AS_NODE=1`) leaves `process.versions.electron` set, so
7
+ * `hideBin()` mistakes the process for a *packaged* Electron app, slices argv
8
+ * from index 0 instead of 1, and keeps the script path as a positional
9
+ * argument — yargs then prints its help and exits 1 with
10
+ * `Unknown argument: <script>`, i.e. the host sees a CLI that dies on launch.
11
+ * `process.defaultApp` is exactly the signal `hideBin()` reads as "plain node".
12
+ *
13
+ * `bin/wave-code.js` carries the same guard for the shim entry; this module
14
+ * covers the bundle entry (`dist/bundle/wave.mjs`), which a host runs directly
15
+ * whenever `WAVE_CLI_PATH` points at the workspace build — the documented dev
16
+ * setup — instead of the shim.
17
+ */
18
+ export function ensurePlainNodeArgv(): void {
19
+ if (process.versions.electron && process.env.ELECTRON_RUN_AS_NODE === "1") {
20
+ // `defaultApp` is an Electron-only property, absent from @types/node.
21
+ (process as NodeJS.Process & { defaultApp?: boolean }).defaultApp = true;
22
+ }
23
+ }
@@ -96,7 +96,7 @@ export class PrintOutput {
96
96
  * init 之前写出的事件先存这里,`init()` 时先写 init 再按序补上。
97
97
  *
98
98
  * `Agent.create()` 期间就可能落事件:初始化阶段加的错误块会走
99
- * `onErrorBlockAdded`(真机:`-r <非法 session id>` 时 `initializationService`
99
+ * `onNoticeAdded`(`level: "error"`)(真机:`-r <非法 session id>` 时 `initializationService`
100
100
  * 抛错前先落了一个错误块),而 init 只能等 agent 建好才有 session/model 可报。
101
101
  * 不缓冲的话首行会是 error,按首行判类型的消费方就判错了。
102
102
  */
@@ -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
  }