wave-code 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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` — the daemon only runs on remote hosts, so no
17
- * `--socket` override is offered (spec: daemon-command.md).
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
- /** Fixed default daemon socket (spec: 默认 socket 固定,无 --socket 覆盖). */
49
- export const DEFAULT_DAEMON_SOCKET = path.join(
50
- os.homedir(),
51
- ".wave",
52
- "daemon.sock",
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): void {
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). Exits (nonzero) with the
104
- * spec'd error when the daemon cannot be started/reached.
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 code = (err as NodeJS.ErrnoException).code;
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
- `Cannot connect to daemon socket ${socketPath}: started a daemon but it did not come up` +
125
- (code ? ` (${code})` : ""),
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 an observed busy phase first: right after an
939
- // async `send` the replayed snapshot can still be a stale loading:false
940
- // (the turn has not started yet).
941
- if (idle && (!fromBusy || sawBusy)) {
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`, branch via `git branch --show-current`, and
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(sessionCwd: string): Promise<{
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
- const [toplevel, current] = await Promise.all([
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 removed via protocol removeWorktree
1337
- * first, then the session is destroyed.
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
- const wt = await resolveWorktreeForRemoval(info.sessionCwd);
1354
- await client.request("removeWorktree", {
1355
- path: wt.path,
1356
- branch: wt.branch,
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
+ }