wave-code 1.5.1 → 1.6.1
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/README.md +3 -2
- package/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +735 -686
- package/package.json +8 -10
- package/src/cli.tsx +59 -3
- package/src/components/App.tsx +13 -13
- package/src/components/ChatInterface.tsx +4 -2
- package/src/components/InputBox.tsx +6 -3
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/Notifications.tsx +6 -3
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/contexts/useChat.tsx +88 -43
- 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/useExitSignals.ts +83 -0
- package/src/hooks/useRemoteControl.ts +16 -4
- package/src/index.ts +6 -0
- package/src/print-cli.ts +20 -10
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +5 -4
- package/src/remote/notificationCoalescer.ts +2 -2
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +46 -8
- package/src/remote/remoteControlService.ts +75 -18
- package/src/remote/remoteNotifications.ts +31 -9
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +407 -72
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/startupStage.ts +22 -0
- package/src/stdio/uiPayload.ts +49 -0
- package/src/stdio-cli.ts +9 -1
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +1 -1
- package/src/utils/worktree.ts +222 -16
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DaemonServer — JSON-RPC server over a
|
|
3
|
-
* sessions (spec:
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
probe.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
probe.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
152
|
-
|
|
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",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首次启动的进度上报(spec `ui/stdio-transport.md`「首次启动的进度文案」)。
|
|
3
|
+
*
|
|
4
|
+
* 走的是同一条 stdout 上的 JSON-RPC 通知通道(一条一行),只是没有 `sessionId`
|
|
5
|
+
* ——这些阶段发生在任何会话存在之前。宿主侧 `NotificationRouter` 的全局处理器
|
|
6
|
+
* 收下它,把它变成加载动画下方那一行文案。
|
|
7
|
+
*
|
|
8
|
+
* **旁路**:通知发不出去(stdout 已关、宿主已经退出)不是启动错误,写失败一律
|
|
9
|
+
* 吞掉;上报也不参与任何完成条件,没有宿主消费时启动照旧。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StartupStage } from "wave-agent-sdk";
|
|
13
|
+
|
|
14
|
+
export function reportStartupStage(stage: StartupStage): void {
|
|
15
|
+
try {
|
|
16
|
+
process.stdout.write(
|
|
17
|
+
`${JSON.stringify({ method: "startupStage", params: { stage } })}\n`,
|
|
18
|
+
);
|
|
19
|
+
} catch {
|
|
20
|
+
// 见文件头:上报失败不影响启动。
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/stdio/uiPayload.ts
CHANGED
|
@@ -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
|
}
|
package/src/stdio-cli.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { ensureRuntimeDeps, setTelemetryStdoutProtected } from "wave-agent-sdk";
|
|
10
10
|
import { StdioServer } from "./stdio/stdioServer.js";
|
|
11
|
+
import { reportStartupStage } from "./stdio/startupStage.js";
|
|
11
12
|
import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
|
|
12
13
|
import { logger } from "./utils/logger.js";
|
|
13
14
|
|
|
@@ -64,7 +65,11 @@ export async function startStdioCli(): Promise<void> {
|
|
|
64
65
|
// answering any RPC (see cli.tsx). Blocking here is what closes the race: the
|
|
65
66
|
// host's `initialize` — and with it the webview's initial state — only lands
|
|
66
67
|
// once a pasted image can be handled. Failures are non-fatal.
|
|
67
|
-
|
|
68
|
+
//
|
|
69
|
+
// The ~25MB this can pull is the longest silent stretch of a first launch, so
|
|
70
|
+
// it is reported (a warm cache reports nothing: `ensureRuntimeDeps` only
|
|
71
|
+
// fires `onStage` when something is actually missing).
|
|
72
|
+
await ensureRuntimeDeps({ onStage: reportStartupStage });
|
|
68
73
|
// Registered here (stdio mode only): the interactive CLI keeps Node's
|
|
69
74
|
// default behavior so the terminal shows the crash stack directly.
|
|
70
75
|
process.on("uncaughtException", (error) =>
|
|
@@ -79,6 +84,9 @@ export async function startStdioCli(): Promise<void> {
|
|
|
79
84
|
// same machine — is what serves a paired phone.
|
|
80
85
|
createRemoteSink: createRemoteControlSinkFactory(process.cwd()),
|
|
81
86
|
});
|
|
87
|
+
// Last startup stage: from here the process is up and what the user is waiting
|
|
88
|
+
// for is `initialize` (and, on the restore path, the transcript replay).
|
|
89
|
+
reportStartupStage("startingSession");
|
|
82
90
|
server.start();
|
|
83
91
|
|
|
84
92
|
// Keep the process alive — readline on stdin handles the event loop.
|
|
@@ -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
|
+
}
|
package/src/utils/printOutput.ts
CHANGED
|
@@ -96,7 +96,7 @@ export class PrintOutput {
|
|
|
96
96
|
* init 之前写出的事件先存这里,`init()` 时先写 init 再按序补上。
|
|
97
97
|
*
|
|
98
98
|
* `Agent.create()` 期间就可能落事件:初始化阶段加的错误块会走
|
|
99
|
-
* `
|
|
99
|
+
* `onNoticeAdded`(`level: "error"`)(真机:`-r <非法 session id>` 时 `initializationService`
|
|
100
100
|
* 抛错前先落了一个错误块),而 init 只能等 agent 建好才有 session/model 可报。
|
|
101
101
|
* 不缓冲的话首行会是 error,按首行判类型的消费方就判错了。
|
|
102
102
|
*/
|
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
|
}
|