libretto 0.6.9 → 0.6.11

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.
Files changed (60) hide show
  1. package/dist/cli/cli.js +2 -0
  2. package/dist/cli/commands/auth.js +535 -0
  3. package/dist/cli/commands/billing.js +74 -0
  4. package/dist/cli/commands/browser.js +8 -3
  5. package/dist/cli/commands/deploy.js +2 -7
  6. package/dist/cli/commands/execution.js +99 -136
  7. package/dist/cli/commands/snapshot.js +38 -126
  8. package/dist/cli/core/ai-model.js +0 -3
  9. package/dist/cli/core/auth-fetch.js +195 -0
  10. package/dist/cli/core/auth-storage.js +52 -0
  11. package/dist/cli/core/browser.js +128 -202
  12. package/dist/cli/core/daemon/config.js +6 -0
  13. package/dist/cli/core/daemon/daemon.js +298 -0
  14. package/dist/cli/core/daemon/exec.js +86 -0
  15. package/dist/cli/core/daemon/index.js +16 -0
  16. package/dist/cli/core/daemon/ipc.js +171 -0
  17. package/dist/cli/core/daemon/pages.js +15 -0
  18. package/dist/cli/core/daemon/snapshot.js +86 -0
  19. package/dist/cli/core/daemon/spawn.js +90 -0
  20. package/dist/cli/core/exec-compiler.js +111 -0
  21. package/dist/cli/core/prompt.js +72 -0
  22. package/dist/cli/core/providers/libretto-cloud.js +2 -6
  23. package/dist/cli/core/readonly-exec.js +1 -1
  24. package/dist/cli/router.js +4 -0
  25. package/dist/cli/workers/run-integration-runtime.js +0 -5
  26. package/dist/shared/state/session-state.d.ts +1 -0
  27. package/dist/shared/state/session-state.js +2 -1
  28. package/docs/browser-automation-approaches.md +435 -0
  29. package/docs/releasing.md +117 -0
  30. package/package.json +4 -3
  31. package/skills/libretto/SKILL.md +14 -1
  32. package/skills/libretto-readonly/SKILL.md +1 -1
  33. package/src/cli/cli.ts +2 -0
  34. package/src/cli/commands/auth.ts +787 -0
  35. package/src/cli/commands/billing.ts +133 -0
  36. package/src/cli/commands/browser.ts +8 -2
  37. package/src/cli/commands/deploy.ts +2 -7
  38. package/src/cli/commands/execution.ts +126 -186
  39. package/src/cli/commands/snapshot.ts +46 -143
  40. package/src/cli/core/ai-model.ts +4 -5
  41. package/src/cli/core/auth-fetch.ts +283 -0
  42. package/src/cli/core/auth-storage.ts +102 -0
  43. package/src/cli/core/browser.ts +159 -242
  44. package/src/cli/core/daemon/config.ts +46 -0
  45. package/src/cli/core/daemon/daemon.ts +429 -0
  46. package/src/cli/core/daemon/exec.ts +128 -0
  47. package/src/cli/core/daemon/index.ts +24 -0
  48. package/src/cli/core/daemon/ipc.ts +294 -0
  49. package/src/cli/core/daemon/pages.ts +21 -0
  50. package/src/cli/core/daemon/snapshot.ts +114 -0
  51. package/src/cli/core/daemon/spawn.ts +171 -0
  52. package/src/cli/core/exec-compiler.ts +169 -0
  53. package/src/cli/core/prompt.ts +94 -0
  54. package/src/cli/core/providers/libretto-cloud.ts +2 -6
  55. package/src/cli/core/readonly-exec.ts +2 -1
  56. package/src/cli/router.ts +4 -0
  57. package/src/cli/workers/run-integration-runtime.ts +0 -6
  58. package/src/shared/state/session-state.ts +1 -0
  59. package/dist/cli/core/browser-daemon.js +0 -122
  60. package/src/cli/core/browser-daemon.ts +0 -198
@@ -0,0 +1,294 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createServer, connect as netConnect, type Server } from "node:net";
3
+ import { unlink } from "node:fs/promises";
4
+ import { REPO_ROOT } from "../context.js";
5
+
6
+ export type DaemonExecOutput = { stdout: string; stderr: string };
7
+
8
+ type ErrorWithOutput = Error & { output?: DaemonExecOutput };
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Request types — one shape per daemon command
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export type DaemonRequest =
15
+ | { id: string; command: "ping" }
16
+ | { id: string; command: "pages" }
17
+ | { id: string; command: "snapshot"; pageId?: string }
18
+ | {
19
+ id: string;
20
+ command: "exec";
21
+ code: string;
22
+ pageId?: string;
23
+ visualize?: boolean;
24
+ }
25
+ | { id: string; command: "readonly-exec"; code: string; pageId?: string };
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Response types — success or error, keyed by the originating request id
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type DaemonResponse =
32
+ | { id: string; type: "result"; data: unknown }
33
+ | {
34
+ id: string;
35
+ type: "error";
36
+ message: string;
37
+ output?: DaemonExecOutput;
38
+ };
39
+
40
+ export class DaemonClientError extends Error {
41
+ constructor(
42
+ message: string,
43
+ readonly output?: DaemonExecOutput,
44
+ ) {
45
+ super(message);
46
+ this.name = "DaemonClientError";
47
+ }
48
+ }
49
+
50
+ export type DaemonCommandResult<T> =
51
+ | { ok: true; data: T }
52
+ | { ok: false; message: string; output?: DaemonExecOutput };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Socket path resolution
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Deterministic Unix domain socket path for a given session.
60
+ *
61
+ * The path lives in `/tmp` to stay well under the macOS 104-byte Unix socket
62
+ * path limit. The hash combines `REPO_ROOT` and the session name so different
63
+ * repos (or sessions within the same repo) never collide.
64
+ */
65
+ export function getDaemonSocketPath(session: string): string {
66
+ const hash = createHash("sha256")
67
+ .update(`${REPO_ROOT}:${session}`)
68
+ .digest("hex")
69
+ .slice(0, 12);
70
+ return `/tmp/libretto-${process.getuid!()}-${hash}.sock`;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // DaemonServer — Unix domain socket server, NDJSON, one request per connection
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export type RequestHandler = (request: DaemonRequest) => Promise<unknown>;
78
+
79
+ export class DaemonServer {
80
+ private server: Server | null = null;
81
+
82
+ constructor(
83
+ private readonly socketPath: string,
84
+ private readonly handler: RequestHandler,
85
+ ) {}
86
+
87
+ async listen(): Promise<void> {
88
+ // Remove stale socket file if present.
89
+ try {
90
+ await unlink(this.socketPath);
91
+ } catch (err) {
92
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
93
+ }
94
+
95
+ const server = createServer((socket) => {
96
+ let buffer = "";
97
+ socket.on("data", (chunk) => {
98
+ buffer += chunk.toString();
99
+ const newlineIndex = buffer.indexOf("\n");
100
+ if (newlineIndex === -1) return;
101
+
102
+ const line = buffer.slice(0, newlineIndex);
103
+ buffer = buffer.slice(newlineIndex + 1);
104
+
105
+ void (async () => {
106
+ let response: DaemonResponse;
107
+ try {
108
+ const request = JSON.parse(line) as DaemonRequest;
109
+ const data = await this.handler(request);
110
+ response = { id: request.id, type: "result", data };
111
+ } catch (err) {
112
+ const id = (() => {
113
+ try {
114
+ return (JSON.parse(line) as { id?: string }).id ?? "unknown";
115
+ } catch {
116
+ return "unknown";
117
+ }
118
+ })();
119
+ response = {
120
+ id,
121
+ type: "error",
122
+ message: err instanceof Error ? err.message : String(err),
123
+ output:
124
+ err instanceof Error
125
+ ? (err as ErrorWithOutput).output
126
+ : undefined,
127
+ };
128
+ }
129
+ socket.end(JSON.stringify(response) + "\n");
130
+ })();
131
+ });
132
+ });
133
+
134
+ this.server = server;
135
+
136
+ await new Promise<void>((resolve, reject) => {
137
+ server.on("error", reject);
138
+ server.listen(this.socketPath, () => resolve());
139
+ });
140
+ }
141
+
142
+ async close(): Promise<void> {
143
+ const server = this.server;
144
+ if (!server) return;
145
+ this.server = null;
146
+
147
+ await new Promise<void>((resolve, reject) => {
148
+ server.close((err) => (err ? reject(err) : resolve()));
149
+ });
150
+
151
+ try {
152
+ await unlink(this.socketPath);
153
+ } catch (err) {
154
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
155
+ }
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Response data types — maps command name to the shape returned on success
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export type DaemonResultMap = {
164
+ ping: { protocolVersion: number };
165
+ pages: Array<{ id: string; url: string; active: boolean }>;
166
+ exec: { result: unknown; output?: DaemonExecOutput };
167
+ "readonly-exec": {
168
+ result: unknown;
169
+ output?: DaemonExecOutput;
170
+ };
171
+ snapshot: {
172
+ pngPath: string;
173
+ htmlPath: string;
174
+ snapshotRunId: string;
175
+ pageUrl: string;
176
+ title: string;
177
+ };
178
+ };
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // DaemonClient — connects to UDS, sends NDJSON request, reads response
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export class DaemonClient {
185
+ constructor(private readonly socketPath: string) {}
186
+
187
+ private async send(request: DaemonRequest): Promise<DaemonResponse> {
188
+ return new Promise<DaemonResponse>((resolve, reject) => {
189
+ const socket = netConnect(this.socketPath);
190
+ let buffer = "";
191
+
192
+ socket.on("connect", () => {
193
+ socket.write(JSON.stringify(request) + "\n");
194
+ });
195
+
196
+ socket.on("data", (chunk) => {
197
+ buffer += chunk.toString();
198
+ });
199
+
200
+ socket.on("end", () => {
201
+ try {
202
+ const response = JSON.parse(buffer.trim()) as DaemonResponse;
203
+ resolve(response);
204
+ } catch (err) {
205
+ reject(
206
+ new Error(
207
+ `Failed to parse daemon response: ${err instanceof Error ? err.message : String(err)}`,
208
+ ),
209
+ );
210
+ }
211
+ });
212
+
213
+ socket.on("error", (err) => {
214
+ reject(err);
215
+ });
216
+ });
217
+ }
218
+
219
+ private generateId(): string {
220
+ return Math.random().toString(36).slice(2, 10);
221
+ }
222
+
223
+ private async sendOrThrow<C extends DaemonRequest["command"]>(
224
+ request: DaemonRequest & { command: C },
225
+ ): Promise<DaemonResultMap[C]> {
226
+ const response = await this.send(request);
227
+ if (response.type === "error") {
228
+ throw new DaemonClientError(response.message, response.output);
229
+ }
230
+ return response.data as DaemonResultMap[C];
231
+ }
232
+
233
+ private async sendResult<C extends DaemonRequest["command"]>(
234
+ request: DaemonRequest & { command: C },
235
+ ): Promise<DaemonCommandResult<DaemonResultMap[C]>> {
236
+ const response = await this.send(request);
237
+ if (response.type === "error") {
238
+ return {
239
+ ok: false,
240
+ message: response.message,
241
+ output: response.output,
242
+ };
243
+ }
244
+ return { ok: true, data: response.data as DaemonResultMap[C] };
245
+ }
246
+
247
+ async ping(): Promise<boolean> {
248
+ try {
249
+ await this.sendOrThrow({ id: this.generateId(), command: "ping" });
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ async pages(): Promise<DaemonResultMap["pages"]> {
257
+ return this.sendOrThrow({ id: this.generateId(), command: "pages" });
258
+ }
259
+
260
+ async exec(args: {
261
+ code: string;
262
+ pageId?: string;
263
+ visualize?: boolean;
264
+ }): Promise<DaemonCommandResult<DaemonResultMap["exec"]>> {
265
+ return this.sendResult({
266
+ id: this.generateId(),
267
+ command: "exec",
268
+ ...args,
269
+ });
270
+ }
271
+
272
+ async readonlyExec(args: {
273
+ code: string;
274
+ pageId?: string;
275
+ }): Promise<DaemonCommandResult<DaemonResultMap["readonly-exec"]>> {
276
+ return this.sendResult({
277
+ id: this.generateId(),
278
+ command: "readonly-exec",
279
+ ...args,
280
+ });
281
+ }
282
+
283
+ async snapshot(
284
+ args: {
285
+ pageId?: string;
286
+ } = {},
287
+ ): Promise<DaemonResultMap["snapshot"]> {
288
+ return this.sendOrThrow({
289
+ id: this.generateId(),
290
+ command: "snapshot",
291
+ ...args,
292
+ });
293
+ }
294
+ }
@@ -0,0 +1,21 @@
1
+ import type { Page } from "playwright";
2
+
3
+ export function handlePages(
4
+ pageById: Map<string, Page>,
5
+ activePage: Page,
6
+ ): Array<{ id: string; url: string; active: boolean }> {
7
+ const results: Array<{ id: string; url: string; active: boolean }> = [];
8
+ // If the original active page has been closed (no longer in the map),
9
+ // fall back to the last tracked page.
10
+ const isActiveTracked = [...pageById.values()].includes(activePage);
11
+ const effectiveActive = isActiveTracked
12
+ ? activePage
13
+ : [...pageById.values()].at(-1);
14
+ for (const [id, page] of pageById) {
15
+ const url = page.url();
16
+ if (url.startsWith("devtools://") || url.startsWith("chrome-error://"))
17
+ continue;
18
+ results.push({ id, url, active: page === effectiveActive });
19
+ }
20
+ return results;
21
+ }
@@ -0,0 +1,114 @@
1
+ import type { Page } from "playwright";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import type { LoggerApi } from "../../../shared/logger/index.js";
4
+ import { getSessionSnapshotRunDir } from "../context.js";
5
+ import {
6
+ resolveSnapshotViewport,
7
+ readSnapshotViewportMetrics,
8
+ shouldForceSnapshotViewport,
9
+ isZeroWidthScreenshotError,
10
+ forceSnapshotViewport,
11
+ } from "../../commands/snapshot.js";
12
+
13
+ const RENDER_SETTLE_TIMEOUT_MS = 10_000;
14
+
15
+ export async function handleSnapshot(
16
+ targetPage: Page,
17
+ session: string,
18
+ logger: LoggerApi,
19
+ pageId?: string,
20
+ ): Promise<{
21
+ pngPath: string;
22
+ htmlPath: string;
23
+ snapshotRunId: string;
24
+ pageUrl: string;
25
+ title: string;
26
+ }> {
27
+ const snapshotRunId = `snapshot-${Date.now()}`;
28
+ const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
29
+ mkdirSync(snapshotRunDir, { recursive: true });
30
+
31
+ // Capture title and URL early, before viewport normalization
32
+ // (matches captureScreenshot ordering).
33
+ let title: string | null = null;
34
+ try {
35
+ title = await targetPage.title();
36
+ } catch (error) {
37
+ logger.warn("screenshot-title-read-failed", { session, pageId, error });
38
+ }
39
+
40
+ let pageUrl: string | null = null;
41
+ try {
42
+ pageUrl = targetPage.url();
43
+ } catch (error) {
44
+ logger.warn("screenshot-url-read-failed", { session, pageId, error });
45
+ }
46
+
47
+ const pngPath = `${snapshotRunDir}/page.png`;
48
+ const htmlPath = `${snapshotRunDir}/page.html`;
49
+
50
+ // Wait for network to settle before capturing.
51
+ await Promise.race([
52
+ targetPage.waitForLoadState("networkidle").catch(() => {}),
53
+ new Promise((resolve) => setTimeout(resolve, RENDER_SETTLE_TIMEOUT_MS)),
54
+ ]);
55
+
56
+ // Viewport normalization — uses shared helpers from snapshot.ts.
57
+ const restoreViewport = resolveSnapshotViewport(session, logger);
58
+ const viewportMetrics = await readSnapshotViewportMetrics(targetPage);
59
+ logger.info("screenshot-viewport-metrics", {
60
+ session,
61
+ pageId,
62
+ restoreViewport,
63
+ ...viewportMetrics,
64
+ });
65
+ await forceSnapshotViewport(
66
+ targetPage,
67
+ restoreViewport,
68
+ logger,
69
+ session,
70
+ pageId,
71
+ shouldForceSnapshotViewport(viewportMetrics)
72
+ ? "preflight-invalid-viewport"
73
+ : "preflight-normalize-viewport",
74
+ );
75
+
76
+ // Screenshot with zero-width retry.
77
+ try {
78
+ await targetPage.screenshot({ path: pngPath });
79
+ } catch (error) {
80
+ if (!isZeroWidthScreenshotError(error)) {
81
+ throw error;
82
+ }
83
+ await forceSnapshotViewport(
84
+ targetPage,
85
+ restoreViewport,
86
+ logger,
87
+ session,
88
+ pageId,
89
+ "retry-after-zero-width-screenshot-error",
90
+ );
91
+ await targetPage.screenshot({ path: pngPath });
92
+ }
93
+
94
+ // Capture HTML content.
95
+ const htmlContent = await targetPage.content();
96
+ writeFileSync(htmlPath, htmlContent);
97
+
98
+ logger.info("screenshot-success", {
99
+ session,
100
+ pageUrl,
101
+ title,
102
+ pngPath,
103
+ htmlPath,
104
+ snapshotRunId,
105
+ });
106
+
107
+ return {
108
+ pngPath,
109
+ htmlPath,
110
+ snapshotRunId,
111
+ pageUrl: pageUrl ?? "",
112
+ title: title ?? "",
113
+ };
114
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Spawn and wait for a browser daemon process.
3
+ *
4
+ * Shared by `runOpen`, `runConnect`, and `runOpenWithProvider` in
5
+ * `browser.ts`. Encapsulates the child-process lifecycle and IPC
6
+ * readiness polling so callers only need to provide config and
7
+ * handle session-state persistence.
8
+ */
9
+
10
+ import { openSync, closeSync } from "node:fs";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import { createRequire } from "node:module";
13
+ import { spawn } from "node:child_process";
14
+ import type { LoggerApi } from "../../../shared/logger/index.js";
15
+ import { getDaemonSocketPath } from "./ipc.js";
16
+ import { DaemonClient } from "./ipc.js";
17
+ import type { DaemonConfig } from "./config.js";
18
+
19
+ // ── Public types ─────────────────────────────────────────────────────
20
+
21
+ export type SpawnSessionDaemonOptions = {
22
+ /** Daemon config — serialized as JSON and passed to the child process. */
23
+ config: DaemonConfig;
24
+ session: string;
25
+ logger: LoggerApi;
26
+ /** Path for the child's stderr log file. */
27
+ logPath: string;
28
+ /** How long to wait for the daemon's IPC server (default: 10 000 ms). */
29
+ ipcTimeoutMs?: number;
30
+ /**
31
+ * Called before throwing when the daemon fails to start (spawn error,
32
+ * early exit, or IPC timeout). Use for cleanup — e.g. closing a cloud
33
+ * provider session. Return value is ignored.
34
+ */
35
+ onFailure?: () => Promise<unknown>;
36
+ };
37
+
38
+ export type SpawnSessionDaemonResult = {
39
+ /** PID of the detached daemon child process. */
40
+ pid: number;
41
+ /** Unix domain socket path for daemon IPC. */
42
+ socketPath: string;
43
+ /** Ready-to-use IPC client (already confirmed reachable via ping). */
44
+ client: DaemonClient;
45
+ };
46
+
47
+ // ── Implementation ───────────────────────────────────────────────────
48
+
49
+ const DEFAULT_IPC_TIMEOUT_MS = 10_000;
50
+ const IPC_POLL_INTERVAL_MS = 250;
51
+
52
+ /**
53
+ * Spawn a daemon child process with the given config and wait for its
54
+ * IPC server to become reachable.
55
+ *
56
+ * The daemon entry point is resolved relative to this module so the
57
+ * caller doesn't need to know where the daemon script lives.
58
+ */
59
+ export async function spawnSessionDaemon(
60
+ options: SpawnSessionDaemonOptions,
61
+ ): Promise<SpawnSessionDaemonResult> {
62
+ const {
63
+ config,
64
+ session,
65
+ logger,
66
+ logPath,
67
+ ipcTimeoutMs = DEFAULT_IPC_TIMEOUT_MS,
68
+ onFailure,
69
+ } = options;
70
+
71
+ // Resolve paths for the daemon entry point and tsx loader.
72
+ const daemonEntryPath = fileURLToPath(
73
+ new URL("./daemon.js", import.meta.url),
74
+ );
75
+ const require = createRequire(import.meta.url);
76
+ const tsxImportPath = pathToFileURL(require.resolve("tsx/esm")).href;
77
+
78
+ // Spawn detached child process with stderr going to the log file.
79
+ const childStderrFd = openSync(logPath, "a");
80
+ const child = spawn(
81
+ process.execPath,
82
+ ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(config)],
83
+ {
84
+ detached: true,
85
+ stdio: ["ignore", "ignore", childStderrFd],
86
+ },
87
+ );
88
+ child.unref();
89
+ closeSync(childStderrFd);
90
+
91
+ const pid = child.pid!;
92
+ logger.info("daemon-spawned", { pid, session });
93
+
94
+ // Track spawn errors and early exits so the polling loop can fail fast.
95
+ let childSpawnError: Error | null = null;
96
+ let childEarlyExit: {
97
+ code: number | null;
98
+ signal: NodeJS.Signals | null;
99
+ } | null = null;
100
+
101
+ child.on("error", (err) => {
102
+ childSpawnError = err;
103
+ logger.error("daemon-spawn-error", { error: err, session });
104
+ });
105
+
106
+ child.on("exit", (code, signal) => {
107
+ childEarlyExit = { code, signal };
108
+ logger.warn("daemon-early-exit", { code, signal, session, pid });
109
+ });
110
+
111
+ // Poll the daemon's IPC server until it responds to a ping.
112
+ const socketPath = getDaemonSocketPath(session);
113
+ const client = new DaemonClient(socketPath);
114
+ const maxAttempts = Math.ceil(ipcTimeoutMs / IPC_POLL_INTERVAL_MS);
115
+ let ipcReady = false;
116
+
117
+ for (let i = 0; i < maxAttempts; i++) {
118
+ // Fail fast on spawn errors. The cast is needed because TypeScript
119
+ // doesn't track that the variable is mutated asynchronously by the
120
+ // child's "error" event handler.
121
+ const spawnError = childSpawnError as Error | null;
122
+ if (spawnError !== null) {
123
+ await onFailure?.();
124
+ const errWithCode = spawnError as Error & { code?: string };
125
+ const hint =
126
+ errWithCode.code === "ENOENT"
127
+ ? " Ensure Node.js is available in PATH for child processes."
128
+ : "";
129
+ throw new Error(
130
+ `Failed to spawn daemon: ${spawnError.message}.${hint} Check logs: ${logPath}`,
131
+ );
132
+ }
133
+
134
+ // Fail fast on early exit.
135
+ const earlyExit = childEarlyExit as {
136
+ code: number | null;
137
+ signal: NodeJS.Signals | null;
138
+ } | null;
139
+ if (earlyExit !== null) {
140
+ await onFailure?.();
141
+ const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
142
+ throw new Error(
143
+ `Daemon exited before startup (status: ${status}). Check logs: ${logPath}`,
144
+ );
145
+ }
146
+
147
+ await new Promise((r) => setTimeout(r, IPC_POLL_INTERVAL_MS));
148
+ ipcReady = await client.ping();
149
+ if (ipcReady) break;
150
+
151
+ if (i > 0 && i % 10 === 0) {
152
+ logger.info("daemon-waiting-for-ipc", { attempt: i, session });
153
+ }
154
+ }
155
+
156
+ if (!ipcReady) {
157
+ // Kill the orphaned daemon process before reporting failure.
158
+ try {
159
+ process.kill(pid, "SIGTERM");
160
+ } catch {
161
+ // Process may have already exited.
162
+ }
163
+ await onFailure?.();
164
+ throw new Error(
165
+ `Daemon failed to start within ${Math.ceil(ipcTimeoutMs / 1000)}s. Check logs: ${logPath}`,
166
+ );
167
+ }
168
+
169
+ logger.info("daemon-ipc-ready", { session, socketPath });
170
+ return { pid, socketPath, client };
171
+ }