wave-code 1.1.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/wave.mjs +546 -525
- package/package.json +2 -2
- package/src/commands/plugin/install.ts +5 -5
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/HooksManager.tsx +286 -0
- package/src/components/InputBox.tsx +86 -49
- package/src/components/MessageBlockItem.tsx +0 -5
- package/src/components/MessageList.tsx +0 -1
- package/src/components/PlanView.tsx +141 -0
- package/src/constants/commands.ts +12 -0
- package/src/contexts/useChat.tsx +94 -70
- package/src/daemon/commands.ts +486 -30
- package/src/hooks/useInputManager.ts +14 -0
- package/src/hooks/useLineScroll.ts +58 -0
- package/src/index.ts +94 -8
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +10 -0
- package/src/reducers/hooksManagerReducer.ts +92 -0
- package/src/stdio/agentBridge.ts +552 -55
- package/src/stdio/daemonServer.ts +26 -82
- package/src/stdio/protocol.ts +14 -4
- package/src/stdio-cli.ts +54 -0
- package/src/utils/rewindCheckpoints.ts +2 -2
- package/src/utils/worktree.ts +175 -50
- package/src/components/BangDisplay.tsx +0 -41
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DaemonServer — JSON-RPC server over a unix socket for remote background
|
|
3
|
-
* sessions (spec: docs/specs/
|
|
3
|
+
* sessions (spec: docs/specs/desktop/desktop-sessions.md 「SSH 远程后台会话」).
|
|
4
4
|
*
|
|
5
5
|
* The desktop app launches `wave --daemon <socket>` on the remote host via
|
|
6
6
|
* nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
|
|
7
7
|
* share one AgentBridge, so sessions and pending tool permissions survive
|
|
8
|
-
* client detach/attach
|
|
9
|
-
* desktop is connected
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* transcripts), closes the socket, and exits, so the remote process doesn't
|
|
16
|
-
* linger forever after background work completes.
|
|
8
|
+
* client detach/attach. A launched daemon is resident: it keeps running (and
|
|
9
|
+
* generating) while no desktop is connected and never auto-exits once the
|
|
10
|
+
* work settles. It goes away when told to shut down gracefully (`wave daemon
|
|
11
|
+
* stop` / `restart`, destroying sessions so transcripts flush), when killed
|
|
12
|
+
* externally (desktop CLI 升级重启 pkill, remote reboot / machine reboot), and
|
|
13
|
+
* the next client that finds the socket absent starts a fresh daemon on
|
|
14
|
+
* demand.
|
|
17
15
|
*/
|
|
18
16
|
|
|
19
17
|
import net from "net";
|
|
@@ -24,26 +22,17 @@ import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
|
24
22
|
export interface DaemonServerOptions {
|
|
25
23
|
socketPath: string;
|
|
26
24
|
bridgeOptions?: AgentBridgeOptions;
|
|
27
|
-
/** Idle grace period before the daemon auto-exits (default 60s). */
|
|
28
|
-
graceMs?: number;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
export class DaemonServer {
|
|
32
|
-
static readonly DEFAULT_IDLE_GRACE_MS = 60_000;
|
|
33
|
-
|
|
34
28
|
private socketPath: string;
|
|
35
29
|
private server: net.Server | undefined;
|
|
36
30
|
private bridge: AgentBridge;
|
|
37
31
|
private connections = new Set<JsonRpcConnection>();
|
|
38
32
|
private sockets = new Set<net.Socket>();
|
|
39
|
-
private graceMs: number;
|
|
40
|
-
private idleTimer: NodeJS.Timeout | undefined;
|
|
41
|
-
private shuttingDown = false;
|
|
42
|
-
private stopped = false;
|
|
43
33
|
|
|
44
34
|
constructor(options: DaemonServerOptions) {
|
|
45
35
|
this.socketPath = options.socketPath;
|
|
46
|
-
this.graceMs = options.graceMs ?? DaemonServer.DEFAULT_IDLE_GRACE_MS;
|
|
47
36
|
this.bridge = new AgentBridge({
|
|
48
37
|
...options.bridgeOptions,
|
|
49
38
|
// Notifications go to every attached client; a fully detached daemon
|
|
@@ -53,20 +42,15 @@ export class DaemonServer {
|
|
|
53
42
|
for (const conn of this.connections) {
|
|
54
43
|
conn.sendNotification(method, params, sessionId);
|
|
55
44
|
}
|
|
56
|
-
// Any session activity can change the idle state — re-evaluate.
|
|
57
|
-
this.evaluateIdle();
|
|
58
45
|
},
|
|
46
|
+
// The `shutdown` RPC (wave daemon stop/restart) destroys every session in
|
|
47
|
+
// the bridge, then hands off here to tear the process down.
|
|
48
|
+
onShutdownRequest: () => this.shutdown(),
|
|
59
49
|
});
|
|
60
50
|
this.server = net.createServer((socket) => {
|
|
61
|
-
if (this.shuttingDown) {
|
|
62
|
-
socket.destroy();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
51
|
const conn = new JsonRpcConnection(socket, socket, this.bridge);
|
|
66
52
|
this.connections.add(conn);
|
|
67
53
|
this.sockets.add(socket);
|
|
68
|
-
// A (re)attached client cancels a pending idle exit.
|
|
69
|
-
this.evaluateIdle();
|
|
70
54
|
socket.on("error", () => {
|
|
71
55
|
// The client (ssh tunnel) can reset the socket mid-detach; the daemon
|
|
72
56
|
// must keep running — 'close' below cleans up the connection.
|
|
@@ -74,8 +58,6 @@ export class DaemonServer {
|
|
|
74
58
|
socket.on("close", () => {
|
|
75
59
|
this.connections.delete(conn);
|
|
76
60
|
this.sockets.delete(socket);
|
|
77
|
-
// A client detach may leave the daemon idle — re-evaluate.
|
|
78
|
-
this.evaluateIdle();
|
|
79
61
|
});
|
|
80
62
|
conn.start();
|
|
81
63
|
});
|
|
@@ -137,15 +119,10 @@ export class DaemonServer {
|
|
|
137
119
|
server.listen(this.socketPath, () => {
|
|
138
120
|
server.removeListener("error", reject);
|
|
139
121
|
resolve();
|
|
140
|
-
// A freshly started daemon may already be idle (no sessions) — start the
|
|
141
|
-
// idle watch so a zero-session daemon also auto-exits.
|
|
142
|
-
this.evaluateIdle();
|
|
143
122
|
});
|
|
144
123
|
}
|
|
145
124
|
|
|
146
125
|
stop(): Promise<void> {
|
|
147
|
-
this.stopped = true;
|
|
148
|
-
this.clearIdleTimer();
|
|
149
126
|
return new Promise((resolve) => {
|
|
150
127
|
const server = this.server;
|
|
151
128
|
if (!server) return resolve();
|
|
@@ -154,61 +131,28 @@ export class DaemonServer {
|
|
|
154
131
|
});
|
|
155
132
|
}
|
|
156
133
|
|
|
157
|
-
// ── Idle auto-exit ────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
private clearIdleTimer(): void {
|
|
160
|
-
if (this.idleTimer) {
|
|
161
|
-
clearTimeout(this.idleTimer);
|
|
162
|
-
this.idleTimer = undefined;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
134
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* conservative: a missed transition just leaves the daemon running.
|
|
135
|
+
* Graceful process exit for the `shutdown` RPC (`wave daemon stop`/restart).
|
|
136
|
+
* The bridge has already destroyed every session (each agent saved its
|
|
137
|
+
* transcript and drained auto-memory); here we drop all client sockets, close
|
|
138
|
+
* the listener, remove the socket file, then exit. The exit is guarded so a
|
|
139
|
+
* test process that mocks process.exit (throwing or no-op) still observes the
|
|
140
|
+
* teardown.
|
|
174
141
|
*/
|
|
175
|
-
private
|
|
176
|
-
// A stopped/shutting-down daemon never (re)arms the idle timer — late
|
|
177
|
-
// socket 'close' events (which fire after server.close resolves) must not
|
|
178
|
-
// resurrect a timer after stop().
|
|
179
|
-
if (this.shuttingDown || this.stopped) return;
|
|
180
|
-
if (this.connections.size > 0 || !this.bridge.isIdle()) {
|
|
181
|
-
this.clearIdleTimer();
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
if (this.idleTimer) return;
|
|
185
|
-
this.idleTimer = setTimeout(() => {
|
|
186
|
-
this.idleTimer = undefined;
|
|
187
|
-
void this.shutdown();
|
|
188
|
-
}, this.graceMs);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Destroy the sessions (each agent saves its transcript and drains
|
|
193
|
-
* auto-memory), close the listener, unlink the socket file, then exit.
|
|
194
|
-
* `shuttingDown` guards against re-entry: new connections are refused and
|
|
195
|
-
* further idle evaluations become no-ops.
|
|
196
|
-
*/
|
|
197
|
-
private async shutdown(): Promise<void> {
|
|
198
|
-
if (this.shuttingDown) return;
|
|
199
|
-
this.shuttingDown = true;
|
|
200
|
-
this.clearIdleTimer();
|
|
201
|
-
// Destroy client sockets first so server.close() can complete (an open
|
|
202
|
-
// socket keeps the close callback pending).
|
|
142
|
+
private shutdown(): void {
|
|
203
143
|
for (const socket of this.sockets) socket.destroy();
|
|
204
144
|
this.sockets.clear();
|
|
205
|
-
|
|
206
|
-
|
|
145
|
+
this.server?.close();
|
|
146
|
+
this.server = undefined;
|
|
207
147
|
try {
|
|
208
148
|
fs.unlinkSync(this.socketPath);
|
|
209
149
|
} catch {
|
|
210
|
-
// Already gone — a stale file
|
|
150
|
+
// Already gone — a stale file is probed/unlinked on next start.
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
process.exit(0);
|
|
154
|
+
} catch {
|
|
155
|
+
// process.exit is mocked in tests — the teardown above is the effect.
|
|
211
156
|
}
|
|
212
|
-
process.exit(0);
|
|
213
157
|
}
|
|
214
158
|
}
|
package/src/stdio/protocol.ts
CHANGED
|
@@ -66,12 +66,19 @@ export type RequestMethod =
|
|
|
66
66
|
| "getFullMessageThread"
|
|
67
67
|
| "setPermissionMode"
|
|
68
68
|
| "getPermissionMode"
|
|
69
|
+
| "getPlanFile"
|
|
69
70
|
| "getMcpServers"
|
|
70
71
|
| "connectMcpServer"
|
|
71
72
|
| "disconnectMcpServer"
|
|
73
|
+
| "removeMcpServer"
|
|
74
|
+
| "getMcpConfigPaths"
|
|
72
75
|
| "getSlashCommands"
|
|
73
76
|
| "getSubagentConfigurations"
|
|
74
77
|
| "getSkillMetadata"
|
|
78
|
+
| "deleteSkill"
|
|
79
|
+
| "deleteSubagent"
|
|
80
|
+
| "getHooksByScope"
|
|
81
|
+
| "deleteHook"
|
|
75
82
|
| "searchFiles"
|
|
76
83
|
| "writeArtifactFile"
|
|
77
84
|
| "getPromptHistory"
|
|
@@ -85,8 +92,12 @@ export type RequestMethod =
|
|
|
85
92
|
| "listDaemonSessions"
|
|
86
93
|
// Auth
|
|
87
94
|
| "getAuthStatus"
|
|
95
|
+
| "getAccountInfo"
|
|
88
96
|
| "login"
|
|
89
97
|
| "logout"
|
|
98
|
+
// Memory files (user-level ~/.wave/AGENTS.md and project-level <workdir>/AGENTS.md)
|
|
99
|
+
| "getAgentsContent"
|
|
100
|
+
| "setAgentsContent"
|
|
90
101
|
// Plugins
|
|
91
102
|
| "listPlugins"
|
|
92
103
|
| "installPlugin"
|
|
@@ -106,6 +117,7 @@ export type RequestMethod =
|
|
|
106
117
|
// Git / worktree (global — no session required)
|
|
107
118
|
| "listGitBranches"
|
|
108
119
|
| "createWorktree"
|
|
120
|
+
| "getWorktreeChanges"
|
|
109
121
|
| "removeWorktree";
|
|
110
122
|
|
|
111
123
|
// ── Client → Server notification methods ────────────────────────
|
|
@@ -129,9 +141,6 @@ export type ServerNotificationMethod =
|
|
|
129
141
|
| "permissionModeChange"
|
|
130
142
|
| "mcpServersChange"
|
|
131
143
|
| "workdirChange"
|
|
132
|
-
| "bangMessageAdded"
|
|
133
|
-
| "bangMessageUpdated"
|
|
134
|
-
| "bangMessageCompleted"
|
|
135
144
|
| "notificationMessageAdded"
|
|
136
145
|
| "permissionRequest"
|
|
137
146
|
| "authUrl"
|
|
@@ -139,7 +148,8 @@ export type ServerNotificationMethod =
|
|
|
139
148
|
| "compactionStateChange"
|
|
140
149
|
| "compactionContentUpdate"
|
|
141
150
|
| "backgroundTasksChange"
|
|
142
|
-
| "btwContent"
|
|
151
|
+
| "btwContent"
|
|
152
|
+
| "contextUsage";
|
|
143
153
|
|
|
144
154
|
// ── Helper: is this a request (has id)? ─────────────────────────
|
|
145
155
|
|
package/src/stdio-cli.ts
CHANGED
|
@@ -7,8 +7,62 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { StdioServer } from "./stdio/stdioServer.js";
|
|
10
|
+
import { logger } from "./utils/logger.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Uncaught exceptions / unhandled rejections only print to stderr by
|
|
14
|
+
* default, which the host truncates to a small tail (and the process exits
|
|
15
|
+
* right after, so queued stderr can be lost entirely) — the real crash
|
|
16
|
+
* reason often never reaches the user. Log the full error to cli.log
|
|
17
|
+
* (synchronous file append, never truncated), best-effort it to stderr,
|
|
18
|
+
* then keep the exit(1) semantics.
|
|
19
|
+
*/
|
|
20
|
+
function crashHandler(kind: string, error: unknown): void {
|
|
21
|
+
logger.error(`[stdio] ${kind}:`, error);
|
|
22
|
+
try {
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
`[stdio] ${kind}: ${
|
|
25
|
+
error instanceof Error ? error.stack || error.message : String(error)
|
|
26
|
+
}\n`,
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
// stderr failure must not mask the log-file entry above.
|
|
30
|
+
}
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* stdout carries the JSON-RPC channel in --stdio mode (one JSON object per
|
|
36
|
+
* line); stderr is reserved for logger output (see stdioServer.ts). A single
|
|
37
|
+
* stray `console.log` anywhere in the SDK/host dependency graph therefore
|
|
38
|
+
* corrupts that channel: the host skips the unparseable line, losing whatever
|
|
39
|
+
* JSON-RPC payload shared it (this is how `SessionService`'s
|
|
40
|
+
* `console.log("Restoring session: …")` showed up as
|
|
41
|
+
* `[wave-jsonrpc] Failed to parse: …` on every restore).
|
|
42
|
+
*
|
|
43
|
+
* Fixing individual call sites is a losing race against a graph this big, so
|
|
44
|
+
* stdio mode forces the contract instead: only `console.error`/`console.warn`
|
|
45
|
+
* (already stderr) may print, everything else is redirected to stderr.
|
|
46
|
+
*/
|
|
47
|
+
export function guardStdoutForJsonRpc(): void {
|
|
48
|
+
for (const level of ["log", "info", "debug"] as const) {
|
|
49
|
+
console[level] = (...args: unknown[]) => {
|
|
50
|
+
console.error(...args);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
10
54
|
|
|
11
55
|
export async function startStdioCli(): Promise<void> {
|
|
56
|
+
guardStdoutForJsonRpc();
|
|
57
|
+
// Registered here (stdio mode only): the interactive CLI keeps Node's
|
|
58
|
+
// default behavior so the terminal shows the crash stack directly.
|
|
59
|
+
process.on("uncaughtException", (error) =>
|
|
60
|
+
crashHandler("uncaughtException", error),
|
|
61
|
+
);
|
|
62
|
+
process.on("unhandledRejection", (reason) =>
|
|
63
|
+
crashHandler("unhandledRejection", reason),
|
|
64
|
+
);
|
|
65
|
+
|
|
12
66
|
const server = new StdioServer();
|
|
13
67
|
server.start();
|
|
14
68
|
|
|
@@ -3,7 +3,8 @@ import type { Message } from "wave-agent-sdk";
|
|
|
3
3
|
/**
|
|
4
4
|
* 判断一条 user 消息能否作为 /rewind 检查点。
|
|
5
5
|
* 后台任务通知(task_notification)、hook 注入的消息(source: "hook")
|
|
6
|
-
*
|
|
6
|
+
* 都是系统生成、用户不可见的,不能作为回滚点。bash 模式命令消息
|
|
7
|
+
* (`!ls`)是用户真正输入,与 fork skill 命令消息一致,可作为回滚点。
|
|
7
8
|
* CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
|
|
8
9
|
*/
|
|
9
10
|
export function isUserCheckpointMessage(m: Message): boolean {
|
|
@@ -11,6 +12,5 @@ export function isUserCheckpointMessage(m: Message): boolean {
|
|
|
11
12
|
if (m.blocks.some((b) => b.type === "task_notification")) return false;
|
|
12
13
|
if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
|
|
13
14
|
return false;
|
|
14
|
-
if (m.blocks.some((b) => b.type === "bang")) return false;
|
|
15
15
|
return true;
|
|
16
16
|
}
|
package/src/utils/worktree.ts
CHANGED
|
@@ -323,6 +323,132 @@ function toExtendedLengthPath(worktreePath: string): string {
|
|
|
323
323
|
return `\\\\?\\${absolute}`;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/** First line of a captured stderr/message, trimmed and length-capped. */
|
|
327
|
+
function firstLine(text: string | undefined, max = 200): string {
|
|
328
|
+
if (!text) return "";
|
|
329
|
+
const line = text.trim().split("\n")[0].trim();
|
|
330
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Summarise why `git worktree remove` failed. execFile rejections carry the
|
|
335
|
+
* exit code and the child's stderr; without them a removal failure is
|
|
336
|
+
* indistinguishable from a MAX_PATH refusal, a locking process, or a git error.
|
|
337
|
+
*/
|
|
338
|
+
function describeGitFailure(error: unknown): string {
|
|
339
|
+
const e = error as {
|
|
340
|
+
code?: number | string;
|
|
341
|
+
signal?: string;
|
|
342
|
+
stderr?: string;
|
|
343
|
+
message?: string;
|
|
344
|
+
};
|
|
345
|
+
const parts: string[] = [];
|
|
346
|
+
if (e.code !== undefined) parts.push(`code=${e.code}`);
|
|
347
|
+
if (e.signal) parts.push(`signal=${e.signal}`);
|
|
348
|
+
const stderr = firstLine(e.stderr);
|
|
349
|
+
if (stderr) parts.push(`stderr=${stderr}`);
|
|
350
|
+
if (parts.length === 0) parts.push(`message=${firstLine(e.message)}`);
|
|
351
|
+
return parts.join(" ");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Summarise why the `fs.rmSync` fallback failed (code + offending path). */
|
|
355
|
+
function describeFsFailure(error: unknown): string {
|
|
356
|
+
const e = error as {
|
|
357
|
+
code?: string;
|
|
358
|
+
syscall?: string;
|
|
359
|
+
path?: string;
|
|
360
|
+
message?: string;
|
|
361
|
+
};
|
|
362
|
+
const parts: string[] = [];
|
|
363
|
+
if (e.code) parts.push(`code=${e.code}`);
|
|
364
|
+
if (e.syscall) parts.push(`syscall=${e.syscall}`);
|
|
365
|
+
if (e.path) parts.push(`at=${e.path}`);
|
|
366
|
+
if (parts.length === 0) parts.push(`message=${firstLine(e.message)}`);
|
|
367
|
+
return parts.join(" ");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Delete the worktree directory with `fs.rmSync`, returning the error when it
|
|
372
|
+
* fails (null on success). Node removes directory symlinks/junctions properly,
|
|
373
|
+
* which is exactly where git gives up on Windows.
|
|
374
|
+
*/
|
|
375
|
+
function rmWorktreeDirWithFs(worktreePath: string): unknown | null {
|
|
376
|
+
try {
|
|
377
|
+
fs.rmSync(toExtendedLengthPath(worktreePath), {
|
|
378
|
+
recursive: true,
|
|
379
|
+
force: true,
|
|
380
|
+
});
|
|
381
|
+
return null;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Describe what is still on disk after a failed removal. Without this a
|
|
389
|
+
* failure log cannot distinguish "directory already gone" from "the whole
|
|
390
|
+
* checkout is still sitting there".
|
|
391
|
+
*/
|
|
392
|
+
function probeResidue(worktreePath: string): string {
|
|
393
|
+
try {
|
|
394
|
+
if (!fs.existsSync(worktreePath)) return "none";
|
|
395
|
+
const entries = fs.readdirSync(worktreePath) as string[];
|
|
396
|
+
const head = entries.slice(0, 5).join(",");
|
|
397
|
+
return `${entries.length}[${head}${entries.length > 5 ? ",…" : ""}]`;
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return `unknown(${(error as { code?: string }).code ?? "error"})`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface WorktreeChanges {
|
|
404
|
+
/** Uncommitted (tracked-modified or untracked) files in the worktree. */
|
|
405
|
+
files: number;
|
|
406
|
+
/** Commits on the checked-out branch that the base branch does not have. */
|
|
407
|
+
commits: number;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Count what deleting a worktree would throw away, so the caller can warn
|
|
412
|
+
* before it happens. Both counts are best-effort: an unknown base branch (never
|
|
413
|
+
* fetched, branch rewritten) only zeroes the commit count, and a worktree that
|
|
414
|
+
* is gone or not a git repository reports `null` instead of a misleading 0.
|
|
415
|
+
*
|
|
416
|
+
* @param worktreePath Worktree directory to inspect
|
|
417
|
+
* @param baseBranch Branch the worktree was created from (defaults to the
|
|
418
|
+
* repository's default remote branch)
|
|
419
|
+
*/
|
|
420
|
+
export async function getWorktreeChanges(
|
|
421
|
+
worktreePath: string,
|
|
422
|
+
baseBranch?: string,
|
|
423
|
+
): Promise<WorktreeChanges | null> {
|
|
424
|
+
let files: number;
|
|
425
|
+
try {
|
|
426
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
427
|
+
cwd: worktreePath,
|
|
428
|
+
encoding: "utf8",
|
|
429
|
+
});
|
|
430
|
+
files = stdout.split("\n").filter((line) => line.trim().length > 0).length;
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let commits = 0;
|
|
436
|
+
try {
|
|
437
|
+
const base = baseBranch ?? getDefaultRemoteBranch(worktreePath);
|
|
438
|
+
const { stdout } = await execFileAsync(
|
|
439
|
+
"git",
|
|
440
|
+
["rev-list", "--count", `${base}..HEAD`],
|
|
441
|
+
{ cwd: worktreePath, encoding: "utf8" },
|
|
442
|
+
);
|
|
443
|
+
commits = Number.parseInt(stdout.trim(), 10) || 0;
|
|
444
|
+
} catch {
|
|
445
|
+
// Unknown base (e.g. the branch was never fetched): the commit count is
|
|
446
|
+
// unknowable, but the uncommitted files below are still real.
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return { files, commits };
|
|
450
|
+
}
|
|
451
|
+
|
|
326
452
|
/**
|
|
327
453
|
* Remove a git worktree and its associated branch
|
|
328
454
|
* @param session Worktree session details
|
|
@@ -332,7 +458,8 @@ function toExtendedLengthPath(worktreePath: string): string {
|
|
|
332
458
|
* is MAX_PATH-limited — deep paths (e.g. node_modules) can fail with "Filename
|
|
333
459
|
* too long", leaving an orphan directory. When git fails we fall back to
|
|
334
460
|
* fs.rmSync with an extended-length path (bypasses MAX_PATH) and prune stale
|
|
335
|
-
* metadata. Failures are logged
|
|
461
|
+
* metadata. Failures are logged; when the directory survives, its branch is
|
|
462
|
+
* deliberately kept so the leftover checkout stays reachable through git.
|
|
336
463
|
*/
|
|
337
464
|
export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
338
465
|
// Hook-based worktrees are removed by the WorktreeRemove hook; wave never
|
|
@@ -359,22 +486,6 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
359
486
|
|
|
360
487
|
const repoRoot = session.repoRoot;
|
|
361
488
|
|
|
362
|
-
// Get current branch in worktree before removing it
|
|
363
|
-
let currentBranch: string | undefined;
|
|
364
|
-
try {
|
|
365
|
-
const { stdout } = await execFileAsync(
|
|
366
|
-
"git",
|
|
367
|
-
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
368
|
-
{
|
|
369
|
-
cwd: session.path,
|
|
370
|
-
encoding: "utf8",
|
|
371
|
-
},
|
|
372
|
-
);
|
|
373
|
-
currentBranch = stdout.trim();
|
|
374
|
-
} catch {
|
|
375
|
-
// Ignore errors getting current branch
|
|
376
|
-
}
|
|
377
|
-
|
|
378
489
|
// Remove worktree
|
|
379
490
|
try {
|
|
380
491
|
await execFileAsync(
|
|
@@ -384,18 +495,42 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
384
495
|
cwd: repoRoot,
|
|
385
496
|
},
|
|
386
497
|
);
|
|
498
|
+
// git exits 0 even when it did not delete the directory: on Windows it
|
|
499
|
+
// cannot remove directory symlinks/junctions, which is exactly what pnpm's
|
|
500
|
+
// node_modules is made of — so it deletes the files, leaves the skeleton
|
|
501
|
+
// behind and still reports success. Trust the filesystem, not the exit code.
|
|
502
|
+
if (fs.existsSync(session.path)) {
|
|
503
|
+
logger.warn(
|
|
504
|
+
`git worktree remove reported success but the directory survived: ` +
|
|
505
|
+
`path=${session.path} residue=${probeResidue(session.path)} — ` +
|
|
506
|
+
`deleting with fs.rmSync instead`,
|
|
507
|
+
);
|
|
508
|
+
const rmError = rmWorktreeDirWithFs(session.path);
|
|
509
|
+
if (rmError) {
|
|
510
|
+
logger.error(
|
|
511
|
+
`Failed to remove worktree or branch: path=${session.path} ` +
|
|
512
|
+
`stage=fs(after git success) ${describeFsFailure(rmError)} ` +
|
|
513
|
+
`residue=${probeResidue(session.path)}`,
|
|
514
|
+
rmError,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
387
518
|
} catch (error: unknown) {
|
|
388
519
|
logger.warn(
|
|
389
|
-
|
|
520
|
+
`git worktree remove failed, falling back to fs.rmSync: ${describeGitFailure(error)}`,
|
|
390
521
|
error,
|
|
391
522
|
);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
523
|
+
const rmError = rmWorktreeDirWithFs(session.path);
|
|
524
|
+
if (rmError) {
|
|
525
|
+
// Removal failures are best-effort, so this line is often the only trace
|
|
526
|
+
// of an orphaned worktree directory: record which stage failed, why, and
|
|
527
|
+
// whether anything was left behind.
|
|
528
|
+
logger.error(
|
|
529
|
+
`Failed to remove worktree or branch: path=${session.path} stage=fs ` +
|
|
530
|
+
`${describeFsFailure(rmError)} residue=${probeResidue(session.path)} ` +
|
|
531
|
+
`git(${describeGitFailure(error)})`,
|
|
532
|
+
rmError,
|
|
533
|
+
);
|
|
399
534
|
}
|
|
400
535
|
// git removes worktree metadata before the working directory; prune any
|
|
401
536
|
// leftovers in case git failed before deleting them.
|
|
@@ -408,7 +543,21 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
408
543
|
}
|
|
409
544
|
}
|
|
410
545
|
|
|
411
|
-
//
|
|
546
|
+
// The directory, not any exit code, decides whether the removal happened. A
|
|
547
|
+
// surviving directory means its checkout (and any uncommitted work in it) is
|
|
548
|
+
// still on disk, and the branch is the only ref still leading back to it.
|
|
549
|
+
if (fs.existsSync(session.path)) {
|
|
550
|
+
logger.warn(
|
|
551
|
+
`Worktree directory survived removal — keeping ${session.branch} so the ` +
|
|
552
|
+
`leftover checkout stays reachable: path=${session.path} ` +
|
|
553
|
+
`residue=${probeResidue(session.path)}`,
|
|
554
|
+
);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Delete the worktree's own branch. Branches the user checked out inside the
|
|
559
|
+
// worktree are never touched — they may hold commits that are not reachable
|
|
560
|
+
// from anywhere else (aligned with Claude Code).
|
|
412
561
|
try {
|
|
413
562
|
await execFileAsync("git", ["branch", "-D", session.branch], {
|
|
414
563
|
cwd: repoRoot,
|
|
@@ -416,28 +565,4 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
|
416
565
|
} catch {
|
|
417
566
|
// Ignore errors deleting original branch
|
|
418
567
|
}
|
|
419
|
-
|
|
420
|
-
// Delete current branch if it's different and not a protected branch
|
|
421
|
-
if (
|
|
422
|
-
currentBranch &&
|
|
423
|
-
currentBranch !== session.branch &&
|
|
424
|
-
currentBranch !== "HEAD"
|
|
425
|
-
) {
|
|
426
|
-
const defaultRemoteBranch = getDefaultRemoteBranch(repoRoot);
|
|
427
|
-
const defaultBranchName = defaultRemoteBranch.split("/").pop();
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
currentBranch !== defaultBranchName &&
|
|
431
|
-
currentBranch !== "main" &&
|
|
432
|
-
currentBranch !== "master"
|
|
433
|
-
) {
|
|
434
|
-
try {
|
|
435
|
-
await execFileAsync("git", ["branch", "-D", currentBranch], {
|
|
436
|
-
cwd: repoRoot,
|
|
437
|
-
});
|
|
438
|
-
} catch {
|
|
439
|
-
// Ignore errors deleting current branch
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
568
|
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { Box, Text } from "ink";
|
|
3
|
-
import type { BangBlock } from "wave-agent-sdk";
|
|
4
|
-
import { getLastLines } from "wave-agent-sdk";
|
|
5
|
-
|
|
6
|
-
interface BangDisplayProps {
|
|
7
|
-
block: BangBlock;
|
|
8
|
-
isExpanded?: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export const BangDisplay: React.FC<BangDisplayProps> = ({
|
|
12
|
-
block,
|
|
13
|
-
isExpanded = false,
|
|
14
|
-
}) => {
|
|
15
|
-
const { command, output, stage, exitCode } = block;
|
|
16
|
-
const MAX_LINES = 3; // Set maximum display lines
|
|
17
|
-
|
|
18
|
-
const getStatusColor = () => {
|
|
19
|
-
if (stage === "running") return "yellow";
|
|
20
|
-
if (exitCode === 0) return "green";
|
|
21
|
-
if (exitCode !== null && exitCode !== 0) return "red";
|
|
22
|
-
return "gray"; // Unknown state
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<Box flexDirection="column">
|
|
27
|
-
<Box>
|
|
28
|
-
<Text color={getStatusColor()}>! </Text>
|
|
29
|
-
<Text color="white">{command}</Text>
|
|
30
|
-
</Box>
|
|
31
|
-
|
|
32
|
-
{output && (
|
|
33
|
-
<Box paddingLeft={2} overflow="hidden">
|
|
34
|
-
<Text color="gray">
|
|
35
|
-
{isExpanded ? output : getLastLines(output, MAX_LINES)}
|
|
36
|
-
</Text>
|
|
37
|
-
</Box>
|
|
38
|
-
)}
|
|
39
|
-
</Box>
|
|
40
|
-
);
|
|
41
|
-
};
|