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
package/src/daemon/commands.ts
CHANGED
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* All subcommands are non-interactive: results go to stdout, diagnostics to
|
|
14
14
|
* stderr, and every handler calls process.exit() itself (yargs would fall
|
|
15
15
|
* through to the TUI otherwise). Every command connects to the fixed default
|
|
16
|
-
* socket `~/.wave/daemon.sock`
|
|
17
|
-
* `--socket` override is offered
|
|
16
|
+
* socket — `~/.wave/daemon.sock` on POSIX, its named-pipe form on Windows (see
|
|
17
|
+
* ./socketPath.ts) — and no `--socket` override is offered, so the same commands
|
|
18
|
+
* address this machine's daemon whether it hosts remote-background sessions or
|
|
19
|
+
* the local delegation sessions the wave-daemon skill creates
|
|
20
|
+
* (spec: daemon-command.md).
|
|
18
21
|
*
|
|
19
22
|
* Attach semantics: `initialize {workdir, restoreSessionId}` + `restoreSession`
|
|
20
23
|
* re-attach to a live session in the daemon's in-memory registry, or reload a
|
|
@@ -25,9 +28,9 @@
|
|
|
25
28
|
* must be destroyed via the envelope sessionId returned by `initialize`.
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
import { execFile, spawn } from "node:child_process";
|
|
31
|
+
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
32
|
+
import * as fs from "node:fs";
|
|
29
33
|
import net from "node:net";
|
|
30
|
-
import os from "node:os";
|
|
31
34
|
import path from "node:path";
|
|
32
35
|
import {
|
|
33
36
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
@@ -44,13 +47,22 @@ import {
|
|
|
44
47
|
type ToolPermissionContext,
|
|
45
48
|
} from "wave-agent-sdk";
|
|
46
49
|
import { SocketClient } from "./socketClient.js";
|
|
50
|
+
import {
|
|
51
|
+
openDaemonLogForChild,
|
|
52
|
+
readDaemonLogTail,
|
|
53
|
+
type DaemonLogHandle,
|
|
54
|
+
} from "./daemonLog.js";
|
|
55
|
+
import {
|
|
56
|
+
describeWorktreeSurvival,
|
|
57
|
+
worktreeCreationBranch,
|
|
58
|
+
} from "../utils/worktree.js";
|
|
47
59
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Fixed default daemon socket (spec: 默认 socket 固定,无 --socket 覆盖). The
|
|
62
|
+
* shape is platform-dependent — a file socket on POSIX, a named pipe on Windows —
|
|
63
|
+
* and both sides derive it from the same function (see ./socketPath.ts).
|
|
64
|
+
*/
|
|
65
|
+
export { DEFAULT_DAEMON_SOCKET } from "./socketPath.js";
|
|
54
66
|
|
|
55
67
|
const PERMISSION_MODES: PermissionMode[] = [
|
|
56
68
|
"default",
|
|
@@ -64,6 +76,16 @@ const PERMISSION_MODES: PermissionMode[] = [
|
|
|
64
76
|
export const daemonStartTimeout = { ms: 10_000 };
|
|
65
77
|
const DAEMON_POLL_INTERVAL_MS = 500;
|
|
66
78
|
|
|
79
|
+
/**
|
|
80
|
+
* After the launched daemon has already exited nonzero, how much longer to keep
|
|
81
|
+
* trying the socket before giving up. Concurrent clients each launch a daemon and
|
|
82
|
+
* exactly one wins — the loser sees its own child refused ("another daemon is
|
|
83
|
+
* already listening") while the winner may still be coming up, so this window
|
|
84
|
+
* lets it join instead of reporting a failure for a daemon that is fine.
|
|
85
|
+
* (Mutable so tests can shorten it.)
|
|
86
|
+
*/
|
|
87
|
+
export const daemonExitGrace = { ms: 1_500 };
|
|
88
|
+
|
|
67
89
|
// ── Connection helpers ─────────────────────────────────────────
|
|
68
90
|
|
|
69
91
|
function connectDaemon(socketPath: string): Promise<SocketClient> {
|
|
@@ -77,6 +99,12 @@ function connectDaemon(socketPath: string): Promise<SocketClient> {
|
|
|
77
99
|
});
|
|
78
100
|
}
|
|
79
101
|
|
|
102
|
+
/** The launched daemon child plus the log handle its stderr was sent to. */
|
|
103
|
+
interface LaunchedDaemon {
|
|
104
|
+
child: ChildProcess;
|
|
105
|
+
log: DaemonLogHandle | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
/**
|
|
81
109
|
* Start a wave daemon on demand, detached — the local equivalent of the remote
|
|
82
110
|
* nohup launcher `nohup <wave> --daemon <socket> </dev/null >/dev/null 2>&1 &`
|
|
@@ -84,24 +112,64 @@ function connectDaemon(socketPath: string): Promise<SocketClient> {
|
|
|
84
112
|
* <this script> --daemon <socket>`) with no stdio and unrefs the child so the
|
|
85
113
|
* client exits without waiting; the daemon cleans stale socket files itself on
|
|
86
114
|
* start.
|
|
115
|
+
*
|
|
116
|
+
* stderr goes to `~/.wave/logs/daemon.log` rather than to a pipe: the daemon is
|
|
117
|
+
* resident and outlives this client, and a write to a closed pipe (EPIPE) could
|
|
118
|
+
* take it down. The file is what makes a failed start diagnosable — the real
|
|
119
|
+
* cause lives in the dead child's stderr.
|
|
87
120
|
*/
|
|
88
|
-
function startDaemon(socketPath: string):
|
|
121
|
+
function startDaemon(socketPath: string): LaunchedDaemon {
|
|
89
122
|
const entry = process.argv[1];
|
|
90
123
|
if (!entry) {
|
|
91
124
|
fail("Cannot start the wave daemon: unknown CLI entry path");
|
|
92
125
|
}
|
|
126
|
+
const log = openDaemonLogForChild();
|
|
93
127
|
const child = spawn(process.execPath, [entry, "--daemon", socketPath], {
|
|
94
128
|
detached: true,
|
|
95
|
-
stdio: "ignore",
|
|
129
|
+
stdio: ["ignore", "ignore", log ? log.fd : "ignore"],
|
|
96
130
|
});
|
|
131
|
+
if (log) {
|
|
132
|
+
// The child holds its own duplicate of the descriptor.
|
|
133
|
+
try {
|
|
134
|
+
fs.closeSync(log.fd);
|
|
135
|
+
} catch {
|
|
136
|
+
// Already closed — nothing to hand back to the caller either way.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
97
139
|
child.unref();
|
|
140
|
+
return { child, log };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The client-side failure text for an on-demand start that never became
|
|
145
|
+
* reachable. When the child exited we report *that* (exit code plus the last line
|
|
146
|
+
* it wrote to the log); otherwise the daemon is still alive but slow to come up,
|
|
147
|
+
* and the text keeps the original shape plus the first connect error code.
|
|
148
|
+
*/
|
|
149
|
+
function describeStartFailure(
|
|
150
|
+
socketPath: string,
|
|
151
|
+
firstErrorCode: string | undefined,
|
|
152
|
+
exitCode: number | null | undefined,
|
|
153
|
+
logTail: string,
|
|
154
|
+
): string {
|
|
155
|
+
const reason =
|
|
156
|
+
exitCode === undefined
|
|
157
|
+
? `started a daemon but it did not come up${
|
|
158
|
+
firstErrorCode ? ` (${firstErrorCode})` : ""
|
|
159
|
+
}`
|
|
160
|
+
: `the daemon process exited with code ${exitCode}`;
|
|
161
|
+
return `Cannot connect to daemon socket ${socketPath}: ${reason}${
|
|
162
|
+
logTail ? ` — ${logTail}` : ""
|
|
163
|
+
}`;
|
|
98
164
|
}
|
|
99
165
|
|
|
100
166
|
/**
|
|
101
167
|
* Connect or start the daemon on demand: try the socket, and when the daemon is
|
|
102
168
|
* not running, launch one detached and retry until its socket accepts a
|
|
103
|
-
* connection (or daemonStartTimeout.ms elapses).
|
|
104
|
-
*
|
|
169
|
+
* connection (or daemonStartTimeout.ms elapses). A child that exits during the
|
|
170
|
+
* wait fails fast (after a short grace window for the concurrent-start race) and
|
|
171
|
+
* its own stderr is reported. Exits (nonzero) with the spec'd error when the
|
|
172
|
+
* daemon cannot be started/reached.
|
|
105
173
|
*/
|
|
106
174
|
export async function connectDaemonOrExit(
|
|
107
175
|
socketPath: string,
|
|
@@ -109,8 +177,12 @@ export async function connectDaemonOrExit(
|
|
|
109
177
|
try {
|
|
110
178
|
return await connectDaemon(socketPath);
|
|
111
179
|
} catch (err) {
|
|
112
|
-
const
|
|
113
|
-
startDaemon(socketPath);
|
|
180
|
+
const firstErrorCode = (err as NodeJS.ErrnoException).code;
|
|
181
|
+
const { child, log } = startDaemon(socketPath);
|
|
182
|
+
let exitCode: number | null | undefined;
|
|
183
|
+
child.once("exit", (code) => {
|
|
184
|
+
exitCode = code;
|
|
185
|
+
});
|
|
114
186
|
const deadline = Date.now() + daemonStartTimeout.ms;
|
|
115
187
|
while (Date.now() < deadline) {
|
|
116
188
|
await sleep(DAEMON_POLL_INTERVAL_MS);
|
|
@@ -119,10 +191,29 @@ export async function connectDaemonOrExit(
|
|
|
119
191
|
} catch {
|
|
120
192
|
// Daemon still coming up — keep polling.
|
|
121
193
|
}
|
|
194
|
+
if (exitCode !== undefined) {
|
|
195
|
+
// The process that was supposed to serve this socket is gone. Keep
|
|
196
|
+
// trying for a moment (another client's daemon may be the one coming
|
|
197
|
+
// up), then fail immediately instead of waiting out the full timeout.
|
|
198
|
+
const graceEnd = Date.now() + daemonExitGrace.ms;
|
|
199
|
+
while (Date.now() < graceEnd) {
|
|
200
|
+
try {
|
|
201
|
+
return await connectDaemon(socketPath);
|
|
202
|
+
} catch {
|
|
203
|
+
// Nothing listening yet.
|
|
204
|
+
}
|
|
205
|
+
await sleep(DAEMON_POLL_INTERVAL_MS);
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
122
209
|
}
|
|
123
210
|
console.error(
|
|
124
|
-
|
|
125
|
-
|
|
211
|
+
describeStartFailure(
|
|
212
|
+
socketPath,
|
|
213
|
+
firstErrorCode,
|
|
214
|
+
exitCode,
|
|
215
|
+
readDaemonLogTail(log),
|
|
216
|
+
),
|
|
126
217
|
);
|
|
127
218
|
process.exit(1);
|
|
128
219
|
}
|
|
@@ -883,6 +974,57 @@ export async function daemonWaitCommand(
|
|
|
883
974
|
});
|
|
884
975
|
};
|
|
885
976
|
|
|
977
|
+
// The second way `--from-busy` learns that the busy phase HAPPENED: a turn
|
|
978
|
+
// that both started and ended before this client attached leaves nothing to
|
|
979
|
+
// observe — the replayed snapshot is idle, no `loadingChange:true` arrives,
|
|
980
|
+
// and every registry read says idle — so `sawBusy` can never become true.
|
|
981
|
+
// `wave daemon send <id> …; sleep 8; wave daemon wait <id> --from-busy`, the
|
|
982
|
+
// ordinary way to fetch a delegated report, hung until `--timeout` (default:
|
|
983
|
+
// never) because of exactly that (issue #2744). The finished turn is still
|
|
984
|
+
// visible in the session's own transcript, so ask it: a dispatched turn that
|
|
985
|
+
// produced anything (reply, tool call, or the notice a failed turn ends
|
|
986
|
+
// with — all of them live in an assistant message) leaves that assistant
|
|
987
|
+
// message last, while an unstarted dispatch leaves the user message last.
|
|
988
|
+
//
|
|
989
|
+
// The transcript, not a counter in the registry, because: re-hosting a
|
|
990
|
+
// session (`initialize` + `restoreSession` on attach, after a daemon
|
|
991
|
+
// restart) rebuilds the agent from disk, so anything the daemon counts in
|
|
992
|
+
// memory restarts at zero while the transcript still answers the question;
|
|
993
|
+
// and the daemon is long-lived, so a new CLI has to work against one started
|
|
994
|
+
// by an older version — a transcript read needs no new field on the wire.
|
|
995
|
+
// Memoized on the registry's `messageCount` (thread-wide, so it moves
|
|
996
|
+
// whenever a message is appended) because the loop asks again on every idle
|
|
997
|
+
// read while it holds: copying a long conversation every 2s buys nothing.
|
|
998
|
+
let finishedTurn:
|
|
999
|
+
| { sessionId: string; messageCount: number; value: boolean }
|
|
1000
|
+
| undefined;
|
|
1001
|
+
const turnAlreadyFinished = async (
|
|
1002
|
+
entry: DaemonSessionEntry,
|
|
1003
|
+
): Promise<boolean> => {
|
|
1004
|
+
const known = finishedTurn;
|
|
1005
|
+
if (
|
|
1006
|
+
known &&
|
|
1007
|
+
known.sessionId === watch.id &&
|
|
1008
|
+
known.messageCount === entry.messageCount
|
|
1009
|
+
) {
|
|
1010
|
+
return known.value;
|
|
1011
|
+
}
|
|
1012
|
+
const result = (await read(
|
|
1013
|
+
(id) =>
|
|
1014
|
+
socket.request("getMessages", undefined, id) as Promise<{
|
|
1015
|
+
messages: Message[];
|
|
1016
|
+
}>,
|
|
1017
|
+
)) as { messages?: Message[] };
|
|
1018
|
+
const newest = (result.messages ?? []).at(-1);
|
|
1019
|
+
const value = newest?.role === "assistant";
|
|
1020
|
+
finishedTurn = {
|
|
1021
|
+
sessionId: watch.id,
|
|
1022
|
+
messageCount: entry.messageCount,
|
|
1023
|
+
value,
|
|
1024
|
+
};
|
|
1025
|
+
return value;
|
|
1026
|
+
};
|
|
1027
|
+
|
|
886
1028
|
const deadline =
|
|
887
1029
|
timeoutMs !== undefined ? Date.now() + timeoutMs : undefined;
|
|
888
1030
|
let announced = false;
|
|
@@ -935,10 +1077,17 @@ export async function daemonWaitCommand(
|
|
|
935
1077
|
}
|
|
936
1078
|
// Idle comes from the registry, never from a single push: already idle at
|
|
937
1079
|
// call time settles immediately — never hanging beats winning the race.
|
|
938
|
-
// --from-busy holds out for
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
1080
|
+
// --from-busy holds out for evidence that the busy phase happened first:
|
|
1081
|
+
// either it was observed (see `sawBusy`), or the turn it was waiting on
|
|
1082
|
+
// has already finished and left its reply in the transcript (see
|
|
1083
|
+
// `turnAlreadyFinished` — the `send` → `sleep` → `wait` shape). Without
|
|
1084
|
+
// that second signal, a turn that started and ended before the attach
|
|
1085
|
+
// reads as "busy never happened" and the waiter rejects the idle in front
|
|
1086
|
+
// of it forever.
|
|
1087
|
+
if (
|
|
1088
|
+
idle &&
|
|
1089
|
+
(!fromBusy || sawBusy || (await turnAlreadyFinished(hosted)))
|
|
1090
|
+
) {
|
|
942
1091
|
await settle("idle", []);
|
|
943
1092
|
break;
|
|
944
1093
|
}
|
|
@@ -1276,7 +1425,7 @@ export interface DestroyOptions {
|
|
|
1276
1425
|
/** Run one git command and return trimmed stdout (rejects on git failure). */
|
|
1277
1426
|
function runGit(cwd: string, args: string[]): Promise<string> {
|
|
1278
1427
|
return new Promise((resolve, reject) => {
|
|
1279
|
-
execFile("git", args, { cwd }, (err, stdout) => {
|
|
1428
|
+
execFile("git", args, { cwd, windowsHide: true }, (err, stdout) => {
|
|
1280
1429
|
if (err) reject(err);
|
|
1281
1430
|
else resolve(stdout.trim());
|
|
1282
1431
|
});
|
|
@@ -1286,28 +1435,33 @@ function runGit(cwd: string, args: string[]): Promise<string> {
|
|
|
1286
1435
|
/**
|
|
1287
1436
|
* Resolve the worktree hosting `sessionCwd` for removal, mirroring the
|
|
1288
1437
|
* values protocol createWorktree returns: worktree path via
|
|
1289
|
-
* `git rev-parse --show-toplevel
|
|
1290
|
-
* repoRoot = the MAIN repo root (first entry of `git worktree list` — where
|
|
1438
|
+
* `git rev-parse --show-toplevel` and repoRoot = the MAIN repo root (where
|
|
1291
1439
|
* git worktree/branch operations run from). Refuses to remove the main working
|
|
1292
1440
|
* tree: a plain session is not a worktree, and the protocol's path-containment
|
|
1293
1441
|
* check would pass trivially while removeWorktree's fs.rmSync fallback could
|
|
1294
1442
|
* delete the whole repository.
|
|
1443
|
+
*
|
|
1444
|
+
* The branch comes from `createdBranch` (the session's creation-time snapshot,
|
|
1445
|
+
* see `SessionEntry.createdWorktreeBranch`) or from the worktree's path layout
|
|
1446
|
+
* — never from `git branch --show-current`. That last one answers "what is
|
|
1447
|
+
* checked out here now", and an agent that ran `git checkout <other-branch>`
|
|
1448
|
+
* inside the worktree makes it name the user's branch: deleting that would
|
|
1449
|
+
* throw away commits reachable from nowhere else while the `worktree-<name>`
|
|
1450
|
+
* branch stays behind as the orphan (aligned with Claude Code, which deletes
|
|
1451
|
+
* the branch it recorded when it created the worktree).
|
|
1295
1452
|
*/
|
|
1296
|
-
async function resolveWorktreeForRemoval(
|
|
1453
|
+
async function resolveWorktreeForRemoval(
|
|
1454
|
+
sessionCwd: string,
|
|
1455
|
+
createdBranch?: string,
|
|
1456
|
+
): Promise<{
|
|
1297
1457
|
path: string;
|
|
1298
1458
|
branch: string;
|
|
1299
1459
|
repoRoot: string;
|
|
1300
1460
|
hookBased: boolean;
|
|
1301
1461
|
}> {
|
|
1302
1462
|
let worktreePath: string;
|
|
1303
|
-
let branch: string;
|
|
1304
1463
|
try {
|
|
1305
|
-
|
|
1306
|
-
runGit(sessionCwd, ["rev-parse", "--show-toplevel"]),
|
|
1307
|
-
runGit(sessionCwd, ["branch", "--show-current"]),
|
|
1308
|
-
]);
|
|
1309
|
-
worktreePath = toplevel;
|
|
1310
|
-
branch = current;
|
|
1464
|
+
worktreePath = await runGit(sessionCwd, ["rev-parse", "--show-toplevel"]);
|
|
1311
1465
|
} catch {
|
|
1312
1466
|
throw new Error(
|
|
1313
1467
|
`Cannot remove worktree: ${sessionCwd} is not inside a git repository`,
|
|
@@ -1324,17 +1478,55 @@ async function resolveWorktreeForRemoval(sessionCwd: string): Promise<{
|
|
|
1324
1478
|
// removal never runs `git worktree remove` on a hook-managed worktree.
|
|
1325
1479
|
const config = loadMergedWaveConfig(repoRoot);
|
|
1326
1480
|
const hookBased = hasWorktreeCreateHook(config?.hooks);
|
|
1481
|
+
// Empty when neither record can name the branch: removal then deletes the
|
|
1482
|
+
// worktree and keeps every branch, rather than guessing at one. A name that
|
|
1483
|
+
// came from the path layout is only a candidate — `removeWorktree` asks git
|
|
1484
|
+
// whether this repository really has that branch before deleting anything.
|
|
1485
|
+
const branch =
|
|
1486
|
+
createdBranch ||
|
|
1487
|
+
worktreeCreationBranch({ path: worktreePath, repoRoot }) ||
|
|
1488
|
+
"";
|
|
1327
1489
|
return { path: worktreePath, branch, repoRoot, hookBased };
|
|
1328
1490
|
}
|
|
1329
1491
|
|
|
1492
|
+
/**
|
|
1493
|
+
* Report a worktree that removal left on disk.
|
|
1494
|
+
*
|
|
1495
|
+
* Two things must be true at once: the session IS destroyed (so this cannot read
|
|
1496
|
+
* as a failed command), and the leftover directory must be visible without
|
|
1497
|
+
* digging through `~/.wave/logs/daemon.log` (which is where `removeWorktree`
|
|
1498
|
+
* puts its only trace of it). Stderr carries the warning and the manual cleanup
|
|
1499
|
+
* commands, because the branch is deliberately kept for the leftover checkout.
|
|
1500
|
+
*/
|
|
1501
|
+
function reportSurvivingWorktree(worktree: {
|
|
1502
|
+
path: string;
|
|
1503
|
+
branch: string;
|
|
1504
|
+
repoRoot: string;
|
|
1505
|
+
}): void {
|
|
1506
|
+
console.error(describeWorktreeSurvival(worktree));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1330
1509
|
/**
|
|
1331
1510
|
* Destroy a hosted session (protocol destroy, idempotent — a session not in
|
|
1332
1511
|
* the daemon's in-memory registry is a harmless no-op). Unlike status / send /
|
|
1333
1512
|
* abort there is no attach step: destroy is a pure registry operation keyed by
|
|
1334
1513
|
* the envelope sessionId, so unknown sessions succeed without touching disk.
|
|
1514
|
+
*
|
|
1335
1515
|
* With --remove-worktree the session's git worktree is resolved from its
|
|
1336
|
-
* sessionCwd (getSessionInfo) and
|
|
1337
|
-
*
|
|
1516
|
+
* sessionCwd (getSessionInfo) and validated **first** — the refusals (not a
|
|
1517
|
+
* repository / this is the main working tree) must leave the session intact —
|
|
1518
|
+
* but the session is destroyed **before** the worktree directory is removed.
|
|
1519
|
+
* The order is the fix for issue #2763: the session's own processes (a Bash
|
|
1520
|
+
* shell, a test runner, `node --watch`) hold the worktree as their cwd, and
|
|
1521
|
+
* Windows refuses to delete a directory a live process sits in — so removing it
|
|
1522
|
+
* first "succeeded" in the report while the directory and its branch were still
|
|
1523
|
+
* there. `agent.destroy()` kills those processes; only then is the directory
|
|
1524
|
+
* deletable. On POSIX both orders work, which is exactly why this went unnoticed.
|
|
1525
|
+
*
|
|
1526
|
+
* The worktree line reports what happened, not what was attempted: `removeWorktree`
|
|
1527
|
+
* is best-effort, so a surviving directory (and the branch kept for it) is
|
|
1528
|
+
* reported on stderr with the commands that finish the job instead of a
|
|
1529
|
+
* "Removed worktree" line for something that is still on disk.
|
|
1338
1530
|
*/
|
|
1339
1531
|
export async function daemonDestroyCommand(
|
|
1340
1532
|
socketPath: string,
|
|
@@ -1344,23 +1536,53 @@ export async function daemonDestroyCommand(
|
|
|
1344
1536
|
let client: SocketClient | undefined;
|
|
1345
1537
|
try {
|
|
1346
1538
|
client = await connectDaemonOrExit(socketPath);
|
|
1539
|
+
// Resolve before destroying: both refusals inside mean "do nothing at
|
|
1540
|
+
// all", and a session that is already gone cannot be resolved any more.
|
|
1541
|
+
let worktree:
|
|
1542
|
+
| Awaited<ReturnType<typeof resolveWorktreeForRemoval>>
|
|
1543
|
+
| undefined;
|
|
1347
1544
|
if (options.removeWorktree) {
|
|
1348
1545
|
const info = (await client.request(
|
|
1349
1546
|
"getSessionInfo",
|
|
1350
1547
|
undefined,
|
|
1351
1548
|
sessionId,
|
|
1352
|
-
)) as { sessionCwd: string };
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
repoRoot: wt.repoRoot,
|
|
1358
|
-
hookBased: wt.hookBased,
|
|
1359
|
-
});
|
|
1360
|
-
console.log(`Removed worktree: ${wt.path} (branch: ${wt.branch})`);
|
|
1549
|
+
)) as { sessionCwd: string; worktreeBranch?: string };
|
|
1550
|
+
worktree = await resolveWorktreeForRemoval(
|
|
1551
|
+
info.sessionCwd,
|
|
1552
|
+
info.worktreeBranch,
|
|
1553
|
+
);
|
|
1361
1554
|
}
|
|
1362
1555
|
await client.request("destroy", undefined, sessionId);
|
|
1363
1556
|
console.log(`Destroyed session: ${sessionId}`);
|
|
1557
|
+
if (worktree) {
|
|
1558
|
+
try {
|
|
1559
|
+
// `removed` is undefined when talking to a daemon from before it
|
|
1560
|
+
// reported an outcome (the daemon outlives CLI upgrades): then the old
|
|
1561
|
+
// unconditional line is all this client can honestly say.
|
|
1562
|
+
const reply = (await client.request("removeWorktree", {
|
|
1563
|
+
path: worktree.path,
|
|
1564
|
+
branch: worktree.branch,
|
|
1565
|
+
repoRoot: worktree.repoRoot,
|
|
1566
|
+
hookBased: worktree.hookBased,
|
|
1567
|
+
})) as { removed?: boolean } | undefined;
|
|
1568
|
+
if (reply?.removed === false) {
|
|
1569
|
+
reportSurvivingWorktree(worktree);
|
|
1570
|
+
} else {
|
|
1571
|
+
// An empty branch means nothing could name the branch the worktree
|
|
1572
|
+
// was created on — the worktree is gone and every branch was kept,
|
|
1573
|
+
// which the caller must be able to see rather than guess at.
|
|
1574
|
+
console.log(
|
|
1575
|
+
`Removed worktree: ${worktree.path} (branch: ${worktree.branch || "none deleted"})`,
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
// The session is already gone, so reporting a failed `destroy` here
|
|
1580
|
+
// would send the caller to retry something that cannot succeed (the id
|
|
1581
|
+
// no longer resolves). Say what is true and how to finish by hand.
|
|
1582
|
+
console.error(`Worktree removal failed: ${(err as Error).message}`);
|
|
1583
|
+
reportSurvivingWorktree(worktree);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1364
1586
|
} catch (err) {
|
|
1365
1587
|
fail(`wave daemon destroy failed: ${(err as Error).message}`);
|
|
1366
1588
|
} finally {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's stderr log — the only channel back from a process whose stdio the
|
|
3
|
+
* client deliberately does not hold open.
|
|
4
|
+
*
|
|
5
|
+
* `wave daemon <subcommand>` launches the daemon detached and unref'd; the daemon
|
|
6
|
+
* outlives the client by design (it is resident). Keeping a pipe on its stderr
|
|
7
|
+
* would therefore be wrong: once the client exits, the daemon's next write hits
|
|
8
|
+
* EPIPE and can take a long-lived process down. A log file has no such coupling,
|
|
9
|
+
* and it is also what makes an on-demand start failure diagnosable at all — the
|
|
10
|
+
* real cause (`listen EACCES`, a dependency install failure, …) lives in the dead
|
|
11
|
+
* child's stderr, and without the file the client could only report the error
|
|
12
|
+
* code of its own first connect attempt.
|
|
13
|
+
*
|
|
14
|
+
* The tail is read from the offset captured at launch, so the reported line is
|
|
15
|
+
* this launch's stderr rather than whatever a previous launch left behind.
|
|
16
|
+
*
|
|
17
|
+
* `WAVE_LOGS_DIR` redirects the directory (same convention as the desktop and VS
|
|
18
|
+
* Code host logs), which is what tests and isolated-HOME runs use.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { getLastLines, stripAnsiColors } from "wave-agent-sdk";
|
|
25
|
+
|
|
26
|
+
/** Rotate one generation (to `daemon.log.1`) once the log passes this size. */
|
|
27
|
+
export const DAEMON_LOG_MAX_BYTES = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** How many trailing bytes are read to find the last diagnostic line. */
|
|
30
|
+
const TAIL_READ_BYTES = 8 * 1024;
|
|
31
|
+
|
|
32
|
+
export function daemonLogDir(): string {
|
|
33
|
+
return process.env.WAVE_LOGS_DIR || path.join(os.homedir(), ".wave", "logs");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function daemonLogFile(): string {
|
|
37
|
+
return path.join(daemonLogDir(), "daemon.log");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The single retained previous generation (`daemon.log.1`). */
|
|
41
|
+
function rotatedLogFile(file: string): string {
|
|
42
|
+
return `${file}.1`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Best-effort one-generation rotation before appending. Failures are swallowed:
|
|
47
|
+
* the log is a diagnostic aid, never a reason for the daemon not to start.
|
|
48
|
+
*/
|
|
49
|
+
export function rotateDaemonLog(
|
|
50
|
+
file = daemonLogFile(),
|
|
51
|
+
maxBytes = DAEMON_LOG_MAX_BYTES,
|
|
52
|
+
): void {
|
|
53
|
+
try {
|
|
54
|
+
if (fs.statSync(file).size <= maxBytes) return;
|
|
55
|
+
// Windows refuses to rename over an existing file — drop the old generation
|
|
56
|
+
// first (and POSIX does not mind).
|
|
57
|
+
fs.rmSync(rotatedLogFile(file), { force: true });
|
|
58
|
+
fs.renameSync(file, rotatedLogFile(file));
|
|
59
|
+
} catch {
|
|
60
|
+
// Nothing to rotate yet, or the directory is not writable.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An open append handle for a launched daemon child, plus the offset its output
|
|
66
|
+
* starts at (the file is shared across launches).
|
|
67
|
+
*/
|
|
68
|
+
export interface DaemonLogHandle {
|
|
69
|
+
fd: number;
|
|
70
|
+
file: string;
|
|
71
|
+
offset: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Open (and if needed rotate) the daemon log, returning the handle to pass as
|
|
76
|
+
* the child's stderr — or `undefined` when it cannot be opened, in which case the
|
|
77
|
+
* caller falls back to `"ignore"` and simply loses the diagnostic.
|
|
78
|
+
*/
|
|
79
|
+
export function openDaemonLogForChild(
|
|
80
|
+
file = daemonLogFile(),
|
|
81
|
+
): DaemonLogHandle | undefined {
|
|
82
|
+
try {
|
|
83
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
84
|
+
rotateDaemonLog(file);
|
|
85
|
+
const offset = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
|
86
|
+
const fd = fs.openSync(file, "a");
|
|
87
|
+
return { fd, file, offset };
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The last non-empty line this launch wrote, de-ANSI'd, collapsed to one line and
|
|
95
|
+
* capped — meant to be appended to a client-side error message. Empty when there
|
|
96
|
+
* is no handle or nothing was written.
|
|
97
|
+
*/
|
|
98
|
+
export function readDaemonLogTail(
|
|
99
|
+
handle: DaemonLogHandle | undefined,
|
|
100
|
+
maxChars = 200,
|
|
101
|
+
): string {
|
|
102
|
+
if (!handle) return "";
|
|
103
|
+
let fd: number | undefined;
|
|
104
|
+
try {
|
|
105
|
+
fd = fs.openSync(handle.file, "r");
|
|
106
|
+
const size = fs.fstatSync(fd).size;
|
|
107
|
+
const from = Math.max(handle.offset, size - TAIL_READ_BYTES);
|
|
108
|
+
const bytes = size - from;
|
|
109
|
+
if (bytes <= 0) return "";
|
|
110
|
+
const buf = Buffer.alloc(bytes);
|
|
111
|
+
fs.readSync(fd, buf, 0, bytes, from);
|
|
112
|
+
const lines = getLastLines(buf.toString("utf8"), 20).split("\n");
|
|
113
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
114
|
+
const line = stripAnsiColors(lines[i] ?? "")
|
|
115
|
+
.replace(/\s+/g, " ")
|
|
116
|
+
.trim();
|
|
117
|
+
if (!line) continue;
|
|
118
|
+
return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line;
|
|
119
|
+
}
|
|
120
|
+
return "";
|
|
121
|
+
} catch {
|
|
122
|
+
return "";
|
|
123
|
+
} finally {
|
|
124
|
+
if (fd !== undefined) {
|
|
125
|
+
try {
|
|
126
|
+
fs.closeSync(fd);
|
|
127
|
+
} catch {
|
|
128
|
+
// Already closed.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|