@oh-my-pi/pi-coding-agent 16.4.8 → 16.5.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.
Files changed (134) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3502 -3444
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +17 -2
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  22. package/dist/types/modes/print-mode.d.ts +14 -8
  23. package/dist/types/modes/print-mode.test.d.ts +1 -0
  24. package/dist/types/modes/theme/theme.d.ts +2 -1
  25. package/dist/types/sdk.d.ts +5 -1
  26. package/dist/types/session/agent-session.d.ts +42 -0
  27. package/dist/types/session/session-context.d.ts +9 -0
  28. package/dist/types/session/session-entries.d.ts +6 -0
  29. package/dist/types/thinking.d.ts +2 -2
  30. package/dist/types/tiny/models.d.ts +1 -1
  31. package/dist/types/tools/browser/launch.d.ts +1 -0
  32. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  33. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  35. package/dist/types/tools/builtin-names.d.ts +1 -1
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/launch.d.ts +121 -0
  38. package/dist/types/tools/render-utils.d.ts +2 -0
  39. package/dist/types/tools/terminal-output.d.ts +5 -0
  40. package/dist/types/vibe/runtime.d.ts +2 -2
  41. package/dist/types/web/search/types.d.ts +0 -8
  42. package/package.json +20 -20
  43. package/src/cli/args.ts +11 -0
  44. package/src/cli/flag-tables.ts +9 -0
  45. package/src/cli/gallery-fixtures/shell.ts +82 -1
  46. package/src/cli.ts +10 -0
  47. package/src/commands/launch.ts +17 -0
  48. package/src/config/model-resolver.ts +110 -31
  49. package/src/config/model-roles.ts +14 -0
  50. package/src/config/settings-schema.ts +71 -7
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  53. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  54. package/src/eval/completion-bridge.ts +4 -4
  55. package/src/export/ttsr.ts +0 -3
  56. package/src/extensibility/extensions/model-api.ts +1 -1
  57. package/src/extensibility/extensions/types.ts +1 -1
  58. package/src/internal-urls/docs-index.ts +4 -4
  59. package/src/launch/broker.ts +1017 -0
  60. package/src/launch/client.ts +344 -0
  61. package/src/launch/paths.ts +17 -0
  62. package/src/launch/presence.ts +82 -0
  63. package/src/launch/protocol.ts +386 -0
  64. package/src/launch/terminal-output.ts +46 -0
  65. package/src/main.ts +50 -1
  66. package/src/modes/acp/acp-agent.ts +8 -1
  67. package/src/modes/components/agent-hub.ts +101 -31
  68. package/src/modes/components/compaction-summary-message.ts +8 -2
  69. package/src/modes/components/index.ts +1 -0
  70. package/src/modes/components/model-browser.ts +111 -21
  71. package/src/modes/components/model-hub.ts +23 -116
  72. package/src/modes/components/model-picker.ts +233 -0
  73. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  74. package/src/modes/components/status-line/component.test.ts +41 -2
  75. package/src/modes/components/status-line/component.ts +4 -0
  76. package/src/modes/components/status-line/segments.ts +6 -0
  77. package/src/modes/components/status-line/types.ts +3 -0
  78. package/src/modes/controllers/command-controller.ts +9 -1
  79. package/src/modes/controllers/event-controller.ts +31 -32
  80. package/src/modes/controllers/selector-controller.ts +96 -22
  81. package/src/modes/controllers/tan-command-controller.ts +40 -1
  82. package/src/modes/interactive-mode.ts +7 -3
  83. package/src/modes/print-mode.test.ts +71 -0
  84. package/src/modes/print-mode.ts +51 -2
  85. package/src/modes/theme/theme.ts +9 -0
  86. package/src/modes/utils/ui-helpers.ts +25 -4
  87. package/src/prompts/agents/designer.md +1 -1
  88. package/src/prompts/agents/librarian.md +1 -1
  89. package/src/prompts/agents/reviewer.md +1 -1
  90. package/src/prompts/agents/scout.md +1 -1
  91. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  92. package/src/prompts/system/prewalk-checklist.md +7 -0
  93. package/src/prompts/system/prewalk-continue.md +1 -0
  94. package/src/prompts/system/prewalk-plan.md +13 -0
  95. package/src/prompts/system/system-prompt.md +9 -7
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/bash.md +6 -4
  98. package/src/prompts/tools/browser.md +4 -4
  99. package/src/prompts/tools/launch.md +25 -0
  100. package/src/sdk.ts +8 -2
  101. package/src/session/agent-session.ts +550 -87
  102. package/src/session/session-context.test.ts +10 -5
  103. package/src/session/session-context.ts +25 -8
  104. package/src/session/session-entries.ts +6 -0
  105. package/src/slash-commands/builtin-registry.ts +25 -0
  106. package/src/task/agents.ts +2 -2
  107. package/src/thinking.ts +10 -3
  108. package/src/tiny/models.ts +7 -7
  109. package/src/tools/bash-interactive.ts +5 -8
  110. package/src/tools/bash.ts +38 -24
  111. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  112. package/src/tools/browser/launch.ts +8 -4
  113. package/src/tools/browser/run-cancellation.ts +66 -6
  114. package/src/tools/browser/tab-protocol.ts +6 -0
  115. package/src/tools/browser/tab-supervisor.ts +17 -1
  116. package/src/tools/browser/tab-worker.ts +140 -21
  117. package/src/tools/builtin-names.ts +1 -0
  118. package/src/tools/eval-render.ts +16 -14
  119. package/src/tools/index.ts +5 -0
  120. package/src/tools/inspect-image.ts +2 -2
  121. package/src/tools/launch.ts +643 -0
  122. package/src/tools/render-utils.ts +3 -0
  123. package/src/tools/renderers.ts +2 -0
  124. package/src/tools/terminal-output.ts +141 -0
  125. package/src/tts/speech-enhancer.ts +2 -2
  126. package/src/utils/image-vision-fallback.ts +3 -3
  127. package/src/vibe/runtime.ts +2 -2
  128. package/src/web/search/provider.ts +0 -10
  129. package/src/web/search/providers/public.ts +2 -4
  130. package/src/web/search/types.ts +0 -10
  131. package/dist/types/web/search/providers/bing.d.ts +0 -14
  132. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  133. package/src/web/search/providers/bing.ts +0 -197
  134. package/src/web/search/providers/yahoo.ts +0 -179
@@ -0,0 +1,1017 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { Process, type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives";
6
+ import { isEexist, isEnoent, logger, postmortem, sanitizeText } from "@oh-my-pi/pi-utils";
7
+ import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
8
+ import { workerEnvFromParent } from "../subprocess/worker-client";
9
+ import { daemonBrokerEndpoint } from "./paths";
10
+ import { hasLiveDaemonProjectPresence } from "./presence";
11
+ import {
12
+ DAEMON_IDLE_GRACE_ENV,
13
+ DAEMON_PROJECT_DIR_ENV,
14
+ DAEMON_PTY_COLUMNS,
15
+ DAEMON_PTY_ROWS,
16
+ DAEMON_RUNTIME_DIR_ENV,
17
+ type DaemonOperation,
18
+ type DaemonReadySpec,
19
+ type DaemonRpcResult,
20
+ type DaemonSignal,
21
+ type DaemonSnapshot,
22
+ type DaemonSpec,
23
+ parseDaemonSnapshot,
24
+ parseDaemonSpec,
25
+ parseDaemonWireRequest,
26
+ } from "./protocol";
27
+
28
+ const DEFAULT_IDLE_GRACE_MS = 3_000;
29
+ const MAX_REQUEST_BYTES = 1024 * 1024;
30
+ const MAX_LOG_BYTES = 25 * 1024 * 1024;
31
+ const LOG_READ_BYTES = 2 * 1024 * 1024;
32
+ const READINESS_BUFFER_CHARS = 64 * 1024;
33
+ const RESTART_MAX_DELAY_MS = 30_000;
34
+ const TOKEN_FILE = "broker.token";
35
+ const PID_FILE = "broker.pid";
36
+ const META_FILE = "meta.json";
37
+ const LOG_FILE = "output.log";
38
+ const PREVIOUS_LOG_FILE = "output.previous.log";
39
+
40
+ const SIGNAL_NUMBER: Record<DaemonSignal, number> = {
41
+ SIGINT: os.constants.signals.SIGINT,
42
+ SIGTERM: os.constants.signals.SIGTERM,
43
+ SIGHUP: os.constants.signals.SIGHUP,
44
+ SIGQUIT: os.constants.signals.SIGQUIT,
45
+ SIGKILL: os.constants.signals.SIGKILL,
46
+ };
47
+
48
+ interface ManagedProcess {
49
+ pid: number;
50
+ exited: Promise<number>;
51
+ unref(): void;
52
+ }
53
+
54
+ interface ManagedDaemon {
55
+ spec: DaemonSpec;
56
+ snapshot: DaemonSnapshot;
57
+ dir: string;
58
+ log?: DaemonLog;
59
+ process?: ManagedProcess;
60
+ input?: Bun.FileSink;
61
+ pty?: PtySession;
62
+ generation: number;
63
+ stopRequested: boolean;
64
+ logReady: boolean;
65
+ portReady: boolean;
66
+ readinessBuffer: string;
67
+ outputOffset: number;
68
+ readyPattern?: RegExp;
69
+ restartTimer?: NodeJS.Timeout;
70
+ consecutiveFailures: number;
71
+ persistQueue: Promise<void>;
72
+ }
73
+
74
+ interface BrokerLease {
75
+ path: string;
76
+ instanceId: string;
77
+ }
78
+
79
+ interface DaemonLogRead {
80
+ text: string;
81
+ terminalText: string;
82
+ }
83
+
84
+ function quoteShellArg(value: string): string {
85
+ return `'${value.replaceAll("'", `'\\''`)}'`;
86
+ }
87
+ function quoteCmdArg(value: string): string {
88
+ return `"${value.replaceAll('"', '""')}"`;
89
+ }
90
+
91
+ function terminalState(state: DaemonSnapshot["state"]): boolean {
92
+ return state === "exited" || state === "failed";
93
+ }
94
+
95
+ /** Mirror per-condition readiness progress into the snapshot so clients can see which condition is unmet. */
96
+ function syncReadyPending(record: ManagedDaemon): void {
97
+ if (record.snapshot.state !== "starting") {
98
+ record.snapshot.readyPending = undefined;
99
+ return;
100
+ }
101
+ const pending: ("log" | "port")[] = [];
102
+ if (!record.logReady) pending.push("log");
103
+ if (!record.portReady) pending.push("port");
104
+ record.snapshot.readyPending = pending.length > 0 ? pending : undefined;
105
+ }
106
+
107
+ async function fileTextSlice(filePath: string, head: boolean): Promise<string> {
108
+ try {
109
+ const stat = await fs.stat(filePath);
110
+ const file = Bun.file(filePath);
111
+ if (stat.size <= LOG_READ_BYTES) return await file.text();
112
+ return head
113
+ ? await file.slice(0, LOG_READ_BYTES).text()
114
+ : await file.slice(Math.max(0, stat.size - LOG_READ_BYTES)).text();
115
+ } catch (error) {
116
+ if (isEnoent(error)) return "";
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ class DaemonLog {
122
+ readonly #path: string;
123
+ readonly #previousPath: string;
124
+ readonly #file: Bun.BunFile;
125
+ #writer: Bun.FileSink;
126
+ #currentBytes = 0;
127
+ #queue: Promise<void> = Promise.resolve();
128
+ #closed = false;
129
+
130
+ constructor(logPath: string, previousPath: string, file: Bun.BunFile, writer: Bun.FileSink) {
131
+ this.#path = logPath;
132
+ this.#previousPath = previousPath;
133
+ this.#file = file;
134
+ this.#writer = writer;
135
+ }
136
+
137
+ static async open(dir: string): Promise<DaemonLog> {
138
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
139
+ const logPath = path.join(dir, LOG_FILE);
140
+ const previousPath = path.join(dir, PREVIOUS_LOG_FILE);
141
+ await fs.rm(previousPath, { force: true });
142
+ try {
143
+ await fs.rename(logPath, previousPath);
144
+ } catch (error) {
145
+ if (!isEnoent(error)) throw error;
146
+ }
147
+ const file = Bun.file(logPath);
148
+ return new DaemonLog(logPath, previousPath, file, file.writer());
149
+ }
150
+
151
+ append(text: string): string {
152
+ if (text.length === 0 || this.#closed) return text;
153
+ const bytes = Buffer.byteLength(text, "utf8");
154
+ this.#queue = this.#queue.then(async () => {
155
+ if (this.#currentBytes > 0 && this.#currentBytes + bytes > MAX_LOG_BYTES) await this.#rotate();
156
+ this.#writer.write(text);
157
+ this.#currentBytes += bytes;
158
+ await this.#writer.flush();
159
+ });
160
+ return text;
161
+ }
162
+
163
+ async read(head: boolean, lines: number, grep?: string): Promise<DaemonLogRead> {
164
+ await this.#queue;
165
+ await this.#writer.flush();
166
+ return DaemonLog.readFiles(this.#path, this.#previousPath, head, lines, grep);
167
+ }
168
+
169
+ async close(): Promise<void> {
170
+ if (this.#closed) return;
171
+ this.#closed = true;
172
+ await this.#queue;
173
+ await this.#writer.end();
174
+ }
175
+
176
+ static async readFiles(
177
+ logPath: string,
178
+ previousPath: string,
179
+ head: boolean,
180
+ lines: number,
181
+ grep?: string,
182
+ ): Promise<DaemonLogRead> {
183
+ const [previous, current] = await Promise.all([fileTextSlice(previousPath, head), fileTextSlice(logPath, head)]);
184
+ const combined = `${previous}${previous && current && !previous.endsWith("\n") ? "\n" : ""}${current}`;
185
+ const terminalText = head
186
+ ? truncateHeadBytes(combined, LOG_READ_BYTES).text
187
+ : truncateTailBytes(combined, LOG_READ_BYTES).text;
188
+ let text = sanitizeText(terminalText);
189
+ if (grep) {
190
+ let pattern: RegExp;
191
+ try {
192
+ pattern = new RegExp(grep, "u");
193
+ } catch (error) {
194
+ throw new Error(`Invalid log regex: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
196
+ text = text
197
+ .split("\n")
198
+ .filter(line => pattern.test(line))
199
+ .join("\n");
200
+ }
201
+ const options = { maxLines: lines, maxBytes: 256 * 1024 };
202
+ return {
203
+ text: head ? truncateHead(text, options).content : truncateTail(text, options).content,
204
+ terminalText,
205
+ };
206
+ }
207
+
208
+ async #rotate(): Promise<void> {
209
+ await this.#writer.end();
210
+ await fs.rm(this.#previousPath, { force: true });
211
+ await fs.rename(this.#path, this.#previousPath);
212
+ this.#writer = this.#file.writer();
213
+ this.#currentBytes = 0;
214
+ }
215
+ }
216
+
217
+ async function acquireBrokerLease(runtimeDir: string): Promise<BrokerLease | null> {
218
+ const pidPath = path.join(runtimeDir, PID_FILE);
219
+ for (let attempt = 0; attempt < 2; attempt++) {
220
+ try {
221
+ const handle = await fs.open(pidPath, "wx", 0o600);
222
+ const instanceId = crypto.randomUUID();
223
+ try {
224
+ await handle.writeFile(JSON.stringify({ pid: process.pid, instanceId }), "utf8");
225
+ } finally {
226
+ await handle.close();
227
+ }
228
+ return { path: pidPath, instanceId };
229
+ } catch (error) {
230
+ if (!isEexist(error)) throw error;
231
+ try {
232
+ const raw: unknown = await Bun.file(pidPath).json();
233
+ if (typeof raw === "object" && raw !== null && "pid" in raw && typeof raw.pid === "number") {
234
+ try {
235
+ process.kill(raw.pid, 0);
236
+ return null;
237
+ } catch {
238
+ // Stale PID file; the next loop iteration claims it.
239
+ }
240
+ }
241
+ } catch {
242
+ // Malformed or partially-written PID files are stale.
243
+ }
244
+ await fs.rm(pidPath, { force: true });
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ async function releaseBrokerLease(lease: BrokerLease): Promise<void> {
251
+ try {
252
+ const raw: unknown = await Bun.file(lease.path).json();
253
+ if (typeof raw === "object" && raw !== null && "instanceId" in raw && raw.instanceId === lease.instanceId) {
254
+ await fs.rm(lease.path, { force: true });
255
+ }
256
+ } catch (error) {
257
+ if (!isEnoent(error)) throw error;
258
+ }
259
+ }
260
+
261
+ function connectPort(host: string, port: number): Promise<boolean> {
262
+ const { promise, resolve } = Promise.withResolvers<boolean>();
263
+ const socket = net.createConnection({ host, port });
264
+ let settled = false;
265
+ const finish = (connected: boolean): void => {
266
+ if (settled) return;
267
+ settled = true;
268
+ socket.destroy();
269
+ resolve(connected);
270
+ };
271
+ socket.once("connect", () => finish(true));
272
+ socket.once("error", () => finish(false));
273
+ socket.setTimeout(250, () => finish(false));
274
+ return promise;
275
+ }
276
+
277
+ class DaemonBroker {
278
+ readonly #projectDir: string;
279
+ readonly #runtimeDir: string;
280
+ readonly #endpoint: string;
281
+ readonly #token: string;
282
+ readonly #idleGraceMs: number;
283
+ readonly #records = new Map<string, ManagedDaemon>();
284
+ readonly #clients = new Set<net.Socket>();
285
+ readonly #finished = Promise.withResolvers<void>();
286
+ readonly #sockets = new Set<net.Socket>();
287
+ #server: net.Server | undefined;
288
+ #idleTimer: NodeJS.Timeout | undefined;
289
+ #shuttingDown = false;
290
+
291
+ constructor(projectDir: string, runtimeDir: string, token: string, idleGraceMs: number) {
292
+ this.#projectDir = projectDir;
293
+ this.#runtimeDir = runtimeDir;
294
+ this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
295
+ this.#token = token;
296
+ this.#idleGraceMs = idleGraceMs;
297
+ }
298
+
299
+ async run(): Promise<void> {
300
+ await this.#recoverRecords();
301
+ if (process.platform !== "win32") await fs.rm(this.#endpoint, { force: true });
302
+ const server = net.createServer(socket => this.#accept(socket));
303
+ this.#server = server;
304
+ const { promise: listening, resolve, reject } = Promise.withResolvers<void>();
305
+ server.once("listening", resolve);
306
+ server.once("error", reject);
307
+ server.listen(this.#endpoint);
308
+ await listening;
309
+ if (process.platform !== "win32") await fs.chmod(this.#endpoint, 0o600);
310
+ this.#scheduleIdleShutdown();
311
+ await this.#finished.promise;
312
+ }
313
+
314
+ async shutdown(): Promise<void> {
315
+ if (this.#shuttingDown) return this.#finished.promise;
316
+ this.#shuttingDown = true;
317
+ clearTimeout(this.#idleTimer);
318
+ this.#idleTimer = undefined;
319
+ for (const record of this.#records.values()) {
320
+ const detached = record.spec.detached && !record.stopRequested && record.snapshot.pid !== undefined;
321
+ if (!detached && !terminalState(record.snapshot.state)) await this.#stopRecord(record, 2_000);
322
+ clearTimeout(record.restartTimer);
323
+ await record.log?.close();
324
+ await record.persistQueue;
325
+ }
326
+ for (const socket of this.#sockets) socket.destroy();
327
+ this.#sockets.clear();
328
+ this.#clients.clear();
329
+ if (this.#server) {
330
+ const { promise, resolve } = Promise.withResolvers<void>();
331
+ this.#server.close(() => resolve());
332
+ await promise;
333
+ }
334
+ if (process.platform !== "win32") await fs.rm(this.#endpoint, { force: true });
335
+ this.#finished.resolve();
336
+ }
337
+
338
+ #accept(socket: net.Socket): void {
339
+ this.#sockets.add(socket);
340
+ let authenticated = false;
341
+ let buffer = "";
342
+ socket.setEncoding("utf8");
343
+ socket.on("data", chunk => {
344
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
345
+ if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) {
346
+ socket.destroy(new Error("Daemon broker request exceeds size limit"));
347
+ return;
348
+ }
349
+ for (;;) {
350
+ const newline = buffer.indexOf("\n");
351
+ if (newline < 0) break;
352
+ const line = buffer.slice(0, newline);
353
+ buffer = buffer.slice(newline + 1);
354
+ if (!line) continue;
355
+ void this.#handleLine(socket, line, () => {
356
+ if (authenticated) return;
357
+ authenticated = true;
358
+ this.#clients.add(socket);
359
+ clearTimeout(this.#idleTimer);
360
+ this.#idleTimer = undefined;
361
+ });
362
+ }
363
+ });
364
+ socket.on("error", () => {
365
+ // Socket closure performs client accounting.
366
+ });
367
+ socket.on("close", () => {
368
+ this.#sockets.delete(socket);
369
+ if (!authenticated) return;
370
+ this.#clients.delete(socket);
371
+ this.#scheduleIdleShutdown();
372
+ });
373
+ }
374
+
375
+ async #handleLine(socket: net.Socket, line: string, onAuthenticated: () => void): Promise<void> {
376
+ let id = "unknown";
377
+ try {
378
+ const decoded: unknown = JSON.parse(line);
379
+ const request = parseDaemonWireRequest(decoded);
380
+ id = request.id;
381
+ if (request.token !== this.#token) throw new Error("Daemon broker authentication failed");
382
+ onAuthenticated();
383
+ const result = await this.#dispatch(request.operation);
384
+ socket.write(`${JSON.stringify({ id, ok: true, result })}\n`);
385
+ if (request.operation.op === "shutdown") setTimeout(() => void this.shutdown(), 10);
386
+ } catch (error) {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ socket.write(`${JSON.stringify({ id, ok: false, error: message })}\n`);
389
+ }
390
+ }
391
+
392
+ async #dispatch(operation: DaemonOperation): Promise<DaemonRpcResult> {
393
+ switch (operation.op) {
394
+ case "ping":
395
+ return { op: "ping", projectDir: this.#projectDir };
396
+ case "start":
397
+ return this.#start(operation.spec, operation.owner);
398
+ case "list": {
399
+ await Promise.all([...this.#records.values()].map(record => this.#refreshDetached(record)));
400
+ return {
401
+ op: "list",
402
+ daemons: [...this.#records.values()]
403
+ .sort((left, right) => left.snapshot.createdAt - right.snapshot.createdAt)
404
+ .map(record => record.snapshot),
405
+ };
406
+ }
407
+ case "logs":
408
+ return this.#logs(operation);
409
+ case "wait":
410
+ return this.#wait(operation);
411
+ case "send":
412
+ return this.#send(operation);
413
+ case "stop": {
414
+ const record = this.#record(operation.name);
415
+ await this.#stopRecord(record, operation.timeoutMs);
416
+ return { op: "stop", daemon: record.snapshot };
417
+ }
418
+ case "restart":
419
+ return this.#restart(operation.name);
420
+ case "describe": {
421
+ const record = this.#record(operation.name);
422
+ await this.#refreshDetached(record);
423
+ return { op: "describe", daemon: record.snapshot, spec: record.spec };
424
+ }
425
+ case "shutdown":
426
+ return { op: "shutdown" };
427
+ }
428
+ }
429
+
430
+ async #start(spec: DaemonSpec, owner?: string): Promise<DaemonRpcResult> {
431
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/.test(spec.name)) {
432
+ throw new Error("Daemon name must be 1-48 letters, numbers, dots, underscores, or hyphens");
433
+ }
434
+ if (spec.detached && spec.pty) {
435
+ throw new Error("A detached daemon cannot allocate a PTY");
436
+ }
437
+ const existing = this.#records.get(spec.name);
438
+ if (existing) await this.#refreshDetached(existing);
439
+ if (existing && !terminalState(existing.snapshot.state)) {
440
+ throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
441
+ }
442
+ if (spec.ready?.log) {
443
+ try {
444
+ new RegExp(spec.ready.log, "u");
445
+ } catch (error) {
446
+ throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
447
+ }
448
+ }
449
+ const stat = await fs.stat(spec.cwd);
450
+ if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
451
+ const dir = path.join(this.#runtimeDir, "daemons", spec.name);
452
+ const now = Date.now();
453
+ const record: ManagedDaemon = {
454
+ spec,
455
+ snapshot: {
456
+ name: spec.name,
457
+ id: crypto.randomUUID(),
458
+ state: "starting",
459
+ createdAt: now,
460
+ startedAt: now,
461
+ restartCount: 0,
462
+ outputBytes: 0,
463
+ owner,
464
+ persist: spec.persist,
465
+ detached: spec.detached,
466
+ },
467
+ dir,
468
+ log: await DaemonLog.open(dir),
469
+ generation: 0,
470
+ stopRequested: false,
471
+ logReady: !spec.ready?.log,
472
+ portReady: spec.ready?.port === undefined,
473
+ readinessBuffer: "",
474
+ outputOffset: 0,
475
+ readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
476
+ consecutiveFailures: 0,
477
+ persistQueue: Promise.resolve(),
478
+ };
479
+ syncReadyPending(record);
480
+ this.#records.set(spec.name, record);
481
+ await this.#launch(record);
482
+ let readyTimedOut = false;
483
+ if (spec.ready && !terminalState(record.snapshot.state)) {
484
+ const ready = await this.#waitUntil(record, () => record.snapshot.state === "ready", spec.ready.timeoutMs);
485
+ readyTimedOut = !ready && !terminalState(record.snapshot.state);
486
+ }
487
+ await record.persistQueue;
488
+ return { op: "start", daemon: record.snapshot, readyTimedOut };
489
+ }
490
+
491
+ async #launch(record: ManagedDaemon): Promise<void> {
492
+ record.generation++;
493
+ const generation = record.generation;
494
+ record.stopRequested = false;
495
+ record.snapshot.state = record.spec.ready ? "starting" : "running";
496
+ record.snapshot.startedAt = Date.now();
497
+ record.snapshot.readyAt = undefined;
498
+ record.snapshot.exitedAt = undefined;
499
+ record.snapshot.exitCode = undefined;
500
+ record.snapshot.exitReason = undefined;
501
+ record.snapshot.pid = undefined;
502
+ record.snapshot.readyMatch = undefined;
503
+ record.logReady = !record.spec.ready?.log;
504
+ record.portReady = record.spec.ready?.port === undefined;
505
+ syncReadyPending(record);
506
+ record.readinessBuffer = "";
507
+ record.outputOffset = 0;
508
+ this.#persist(record);
509
+ try {
510
+ if (record.spec.detached) await this.#launchDetached(record, generation);
511
+ else if (record.spec.pty) await this.#launchPty(record, generation);
512
+ else this.#launchPipe(record, generation);
513
+ if (record.spec.ready?.port !== undefined) void this.#pollPort(record, generation, record.spec.ready);
514
+ this.#markReady(record);
515
+ } catch (error) {
516
+ const message = error instanceof Error ? error.message : String(error);
517
+ record.log?.append(`Daemon launch failed: ${message}\n`);
518
+ await this.#settle(record, generation, undefined, message);
519
+ }
520
+ }
521
+
522
+ async #launchPty(record: ManagedDaemon, generation: number): Promise<void> {
523
+ const pidPath = path.join(record.dir, "process.pid");
524
+ await fs.rm(pidPath, { force: true });
525
+ const argv = [record.spec.application, ...record.spec.args];
526
+ const command =
527
+ process.platform === "win32"
528
+ ? argv.map(quoteCmdArg).join(" ")
529
+ : [`printf '%s' "$$" > ${quoteShellArg(pidPath)}`, `exec ${argv.map(quoteShellArg).join(" ")}`].join("; ");
530
+ const session = new PtySession();
531
+ record.pty = session;
532
+ const shell = process.platform === "win32" ? process.env.COMSPEC : process.env.SHELL;
533
+ void session
534
+ .start(
535
+ {
536
+ command,
537
+ cwd: record.spec.cwd,
538
+ env: workerEnvFromParent({ TERM: "xterm-256color", ...record.spec.env }),
539
+ cols: DAEMON_PTY_COLUMNS,
540
+ rows: DAEMON_PTY_ROWS,
541
+ shell,
542
+ },
543
+ (error, chunk) => {
544
+ if (generation !== record.generation) return;
545
+ if (error) record.log?.append(`PTY output error: ${error.message}\n`);
546
+ if (chunk) this.#onOutput(record, generation, chunk);
547
+ },
548
+ )
549
+ .then(result => this.#onPtyExit(record, generation, result))
550
+ .catch(error =>
551
+ this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)),
552
+ );
553
+
554
+ if (process.platform === "win32") return;
555
+ const deadline = Date.now() + 5_000;
556
+ const pidFile = Bun.file(pidPath);
557
+ while (Date.now() < deadline && generation === record.generation) {
558
+ try {
559
+ const pid = Number.parseInt((await pidFile.text()).trim(), 10);
560
+ if (Number.isSafeInteger(pid) && pid > 0) {
561
+ record.snapshot.pid = pid;
562
+ this.#persist(record);
563
+ return;
564
+ }
565
+ } catch (error) {
566
+ if (!isEnoent(error)) throw error;
567
+ }
568
+ if (terminalState(record.snapshot.state)) return;
569
+ await Bun.sleep(20);
570
+ }
571
+ }
572
+
573
+ #launchPipe(record: ManagedDaemon, generation: number): void {
574
+ const process = Bun.spawn([record.spec.application, ...record.spec.args], {
575
+ cwd: record.spec.cwd,
576
+ env: workerEnvFromParent(record.spec.env),
577
+ stdin: "pipe",
578
+ stdout: "pipe",
579
+ stderr: "pipe",
580
+ detached: true,
581
+ });
582
+ record.process = process;
583
+ record.input = process.stdin;
584
+ record.snapshot.pid = process.pid;
585
+ this.#persist(record);
586
+ const stdout = this.#drain(record, generation, process.stdout);
587
+ const stderr = this.#drain(record, generation, process.stderr);
588
+ void Promise.all([stdout, stderr, process.exited])
589
+ .then(([, , exitCode]) => this.#settle(record, generation, exitCode))
590
+ .catch(error =>
591
+ this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)),
592
+ );
593
+ }
594
+
595
+ async #launchDetached(record: ManagedDaemon, generation: number): Promise<void> {
596
+ const logPath = path.join(record.dir, LOG_FILE);
597
+ const output = await fs.open(logPath, "a", 0o600);
598
+ try {
599
+ const process = Bun.spawn([record.spec.application, ...record.spec.args], {
600
+ cwd: record.spec.cwd,
601
+ env: workerEnvFromParent(record.spec.env),
602
+ stdio: ["ignore", output.fd, output.fd],
603
+ detached: true,
604
+ });
605
+ record.process = process;
606
+ record.snapshot.pid = process.pid;
607
+ this.#persist(record);
608
+ process.unref();
609
+ void process.exited
610
+ .then(exitCode => this.#settle(record, generation, exitCode))
611
+ .catch(error =>
612
+ this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)),
613
+ );
614
+ } finally {
615
+ await output.close();
616
+ }
617
+ }
618
+
619
+ async #drain(record: ManagedDaemon, generation: number, stream: ReadableStream<Uint8Array>): Promise<void> {
620
+ const reader = stream.getReader();
621
+ const decoder = new TextDecoder();
622
+ try {
623
+ for (;;) {
624
+ const { done, value } = await reader.read();
625
+ if (done) break;
626
+ if (generation === record.generation)
627
+ this.#onOutput(record, generation, decoder.decode(value, { stream: true }));
628
+ }
629
+ const tail = decoder.decode();
630
+ if (tail && generation === record.generation) this.#onOutput(record, generation, tail);
631
+ } finally {
632
+ reader.releaseLock();
633
+ }
634
+ }
635
+
636
+ #onOutput(record: ManagedDaemon, generation: number, raw: string): void {
637
+ if (generation !== record.generation) return;
638
+ const output = raw.toWellFormed();
639
+ const text = record.log?.append(output) ?? output;
640
+ record.snapshot.outputBytes += Buffer.byteLength(text, "utf8");
641
+ this.#trackOutput(record, generation, sanitizeText(text));
642
+ }
643
+
644
+ async #readDetachedOutput(record: ManagedDaemon, generation: number): Promise<void> {
645
+ if (!record.spec.detached || generation !== record.generation) return;
646
+ const logPath = path.join(record.dir, LOG_FILE);
647
+ let size: number;
648
+ try {
649
+ size = (await fs.stat(logPath)).size;
650
+ } catch (error) {
651
+ if (isEnoent(error)) return;
652
+ throw error;
653
+ }
654
+ if (size < record.outputOffset) record.outputOffset = 0;
655
+ if (size === record.outputOffset) return;
656
+ const file = Bun.file(logPath);
657
+ const raw = await file.slice(record.outputOffset, size).text();
658
+ if (generation !== record.generation) return;
659
+ record.outputOffset = size;
660
+ record.snapshot.outputBytes = size;
661
+ this.#trackOutput(record, generation, sanitizeText(raw));
662
+ }
663
+
664
+ #trackOutput(record: ManagedDaemon, generation: number, text: string): void {
665
+ if (generation !== record.generation) return;
666
+ record.readinessBuffer = (record.readinessBuffer + text).slice(-READINESS_BUFFER_CHARS);
667
+ if (!record.logReady && record.readyPattern) {
668
+ const match = record.readyPattern.exec(record.readinessBuffer);
669
+ if (match) {
670
+ record.logReady = true;
671
+ record.snapshot.readyMatch = match[0].slice(0, 500);
672
+ syncReadyPending(record);
673
+ }
674
+ }
675
+ this.#markReady(record);
676
+ }
677
+
678
+ async #refreshDetached(record: ManagedDaemon): Promise<void> {
679
+ if (!record.spec.detached || terminalState(record.snapshot.state)) return;
680
+ const generation = record.generation;
681
+ await this.#readDetachedOutput(record, generation);
682
+ if (generation !== record.generation || record.process) return;
683
+ const processRef = record.snapshot.pid === undefined ? null : Process.fromPid(record.snapshot.pid);
684
+ if (processRef?.status() === "running") return;
685
+ await this.#settle(record, generation);
686
+ }
687
+
688
+ async #pollPort(record: ManagedDaemon, generation: number, ready: DaemonReadySpec): Promise<void> {
689
+ const host = ready.host ?? "127.0.0.1";
690
+ const port = ready.port;
691
+ if (port === undefined) return;
692
+ while (generation === record.generation && !terminalState(record.snapshot.state)) {
693
+ if (await connectPort(host, port)) {
694
+ record.portReady = true;
695
+ syncReadyPending(record);
696
+ this.#markReady(record);
697
+ return;
698
+ }
699
+ await Bun.sleep(100);
700
+ }
701
+ }
702
+
703
+ #markReady(record: ManagedDaemon): void {
704
+ if (!record.spec.ready || record.snapshot.state !== "starting") return;
705
+ if (!record.logReady || !record.portReady) return;
706
+ record.snapshot.state = "ready";
707
+ record.snapshot.readyAt = Date.now();
708
+ this.#persist(record);
709
+ }
710
+
711
+ async #onPtyExit(record: ManagedDaemon, generation: number, result: PtyRunResult): Promise<void> {
712
+ return this.#settle(record, generation, result.exitCode, result.timedOut ? "timed out" : undefined);
713
+ }
714
+
715
+ async #settle(record: ManagedDaemon, generation: number, exitCode?: number, error?: string): Promise<void> {
716
+ if (generation !== record.generation || terminalState(record.snapshot.state)) return;
717
+ await this.#readDetachedOutput(record, generation);
718
+ record.process = undefined;
719
+ record.input = undefined;
720
+ record.pty = undefined;
721
+ record.snapshot.pid = undefined;
722
+ record.snapshot.exitedAt = Date.now();
723
+ record.snapshot.exitCode = exitCode;
724
+ record.snapshot.exitReason = error;
725
+ record.snapshot.readyPending = undefined;
726
+ const failed = error !== undefined || (exitCode !== undefined && exitCode !== 0);
727
+ const shouldRestart =
728
+ !record.stopRequested &&
729
+ (record.spec.restart === "always" || (record.spec.restart === "on-failure" && failed));
730
+ if (shouldRestart && !this.#shuttingDown) {
731
+ const uptime = Date.now() - record.snapshot.startedAt;
732
+ record.consecutiveFailures = uptime >= 30_000 ? 0 : record.consecutiveFailures + 1;
733
+ record.snapshot.restartCount++;
734
+ record.snapshot.state = "restarting";
735
+ const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS);
736
+ record.log?.append(
737
+ `\n[daemon exited${exitCode === undefined ? "" : ` with code ${exitCode}`}; restarting in ${delay}ms]\n`,
738
+ );
739
+ this.#persist(record);
740
+ record.restartTimer = setTimeout(() => {
741
+ record.restartTimer = undefined;
742
+ void this.#launch(record);
743
+ }, delay);
744
+ return;
745
+ }
746
+ record.snapshot.state = failed && !record.stopRequested ? "failed" : "exited";
747
+ this.#persist(record);
748
+ await record.log?.close();
749
+ record.log = undefined;
750
+ }
751
+
752
+ async #logs(operation: Extract<DaemonOperation, { op: "logs" }>): Promise<DaemonRpcResult> {
753
+ const record = this.#record(operation.name);
754
+ await this.#refreshDetached(record);
755
+ const cursor = operation.cursor ?? record.snapshot.outputBytes;
756
+ let timedOut = false;
757
+ if (operation.follow && record.snapshot.outputBytes <= cursor && !terminalState(record.snapshot.state)) {
758
+ const changed = await this.#waitUntil(
759
+ record,
760
+ () => record.snapshot.outputBytes > cursor || terminalState(record.snapshot.state),
761
+ operation.timeoutMs,
762
+ );
763
+ timedOut = !changed;
764
+ }
765
+ const lines = Math.max(1, Math.min(1_000, Math.floor(operation.lines)));
766
+ const output = record.log
767
+ ? await record.log.read(operation.head, lines, operation.grep)
768
+ : await DaemonLog.readFiles(
769
+ path.join(record.dir, LOG_FILE),
770
+ path.join(record.dir, PREVIOUS_LOG_FILE),
771
+ operation.head,
772
+ lines,
773
+ operation.grep,
774
+ );
775
+ return {
776
+ op: "logs",
777
+ name: record.snapshot.name,
778
+ text: output.text,
779
+ terminalText: record.spec.pty && operation.grep === undefined ? output.terminalText : undefined,
780
+ cursor: record.snapshot.outputBytes,
781
+ timedOut,
782
+ state: record.snapshot.state,
783
+ };
784
+ }
785
+
786
+ async #wait(operation: Extract<DaemonOperation, { op: "wait" }>): Promise<DaemonRpcResult> {
787
+ const record = this.#record(operation.name);
788
+ await this.#refreshDetached(record);
789
+ let matched: string | undefined;
790
+ let pattern: RegExp | undefined;
791
+ if (operation.pattern) {
792
+ try {
793
+ pattern = new RegExp(operation.pattern, "u");
794
+ } catch (error) {
795
+ throw new Error(`Invalid wait regex: ${error instanceof Error ? error.message : String(error)}`);
796
+ }
797
+ }
798
+ const condition = (): boolean => {
799
+ if (pattern) {
800
+ const match = pattern.exec(record.readinessBuffer);
801
+ if (!match) return false;
802
+ matched = match[0].slice(0, 500);
803
+ return true;
804
+ }
805
+ if (operation.for === "exit") return terminalState(record.snapshot.state);
806
+ return record.snapshot.state === "ready" || (record.snapshot.state === "running" && !record.spec.ready);
807
+ };
808
+ const reached = condition() || (await this.#waitUntil(record, condition, operation.timeoutMs));
809
+ return { op: "wait", daemon: record.snapshot, matched, timedOut: !reached };
810
+ }
811
+
812
+ async #send(operation: Extract<DaemonOperation, { op: "send" }>): Promise<DaemonRpcResult> {
813
+ const record = this.#record(operation.name);
814
+ await this.#refreshDetached(record);
815
+ if (terminalState(record.snapshot.state) || record.snapshot.state === "stopping") {
816
+ throw new Error(`Daemon ${operation.name} is ${record.snapshot.state}`);
817
+ }
818
+ if (operation.data === undefined && operation.signal === undefined) {
819
+ throw new Error("send requires data or signal");
820
+ }
821
+ if (operation.data !== undefined) {
822
+ if (record.pty) record.pty.write(operation.data);
823
+ else if (record.input) {
824
+ record.input.write(operation.data);
825
+ await record.input.flush();
826
+ } else throw new Error(`Daemon ${operation.name} stdin is unavailable`);
827
+ }
828
+ if (operation.signal) {
829
+ if (process.platform === "win32" && record.pty) {
830
+ if (operation.signal === "SIGINT") record.pty.write("\u0003");
831
+ else record.pty.kill();
832
+ } else {
833
+ const processRef = record.snapshot.pid === undefined ? null : Process.fromPid(record.snapshot.pid);
834
+ if (!processRef) throw new Error(`Daemon ${operation.name} process is unavailable`);
835
+ processRef.killTree(SIGNAL_NUMBER[operation.signal]);
836
+ }
837
+ }
838
+ return { op: "send", daemon: record.snapshot };
839
+ }
840
+
841
+ async #stopRecord(record: ManagedDaemon, timeoutMs: number): Promise<void> {
842
+ await this.#refreshDetached(record);
843
+ if (terminalState(record.snapshot.state)) return;
844
+ record.stopRequested = true;
845
+ if (record.restartTimer) {
846
+ clearTimeout(record.restartTimer);
847
+ record.restartTimer = undefined;
848
+ record.snapshot.state = "exited";
849
+ record.snapshot.exitedAt = Date.now();
850
+ this.#persist(record);
851
+ await record.log?.close();
852
+ record.log = undefined;
853
+ return;
854
+ }
855
+ record.snapshot.state = "stopping";
856
+ this.#persist(record);
857
+ const processRef = record.snapshot.pid === undefined ? null : Process.fromPid(record.snapshot.pid);
858
+ if (processRef) await processRef.terminate({ group: true, gracefulMs: timeoutMs, timeoutMs: timeoutMs + 1_000 });
859
+ else record.pty?.kill();
860
+ const settled = await this.#waitUntil(record, () => terminalState(record.snapshot.state), timeoutMs + 1_000);
861
+ if (!settled && record.pty) record.pty.kill();
862
+ }
863
+
864
+ async #restart(name: string): Promise<DaemonRpcResult> {
865
+ const record = this.#record(name);
866
+ await this.#stopRecord(record, 2_000);
867
+ await record.log?.close();
868
+ record.log = await DaemonLog.open(record.dir);
869
+ record.stopRequested = false;
870
+ await this.#launch(record);
871
+ await record.persistQueue;
872
+ return { op: "restart", daemon: record.snapshot };
873
+ }
874
+
875
+ async #waitUntil(record: ManagedDaemon, condition: () => boolean, timeoutMs: number): Promise<boolean> {
876
+ const deadline = Date.now() + Math.max(0, timeoutMs);
877
+ while (Date.now() < deadline) {
878
+ await this.#refreshDetached(record);
879
+ if (condition()) return true;
880
+ if (this.#shuttingDown && terminalState(record.snapshot.state)) return condition();
881
+ await Bun.sleep(50);
882
+ }
883
+ await this.#refreshDetached(record);
884
+ return condition();
885
+ }
886
+
887
+ #record(name: string): ManagedDaemon {
888
+ const record = this.#records.get(name);
889
+ if (record) return record;
890
+ const names = [...this.#records.keys()];
891
+ throw new Error(`Unknown daemon ${name}${names.length ? `. Available: ${names.join(", ")}` : ""}`);
892
+ }
893
+
894
+ #persist(record: ManagedDaemon): void {
895
+ const metaPath = path.join(record.dir, META_FILE);
896
+ const tempPath = `${metaPath}.${process.pid}.tmp`;
897
+ record.persistQueue = record.persistQueue
898
+ .then(async () => {
899
+ await Bun.write(tempPath, JSON.stringify({ daemon: record.snapshot, spec: record.spec }));
900
+ await fs.rename(tempPath, metaPath);
901
+ })
902
+ .catch(error => {
903
+ logger.warn("Failed to persist daemon metadata", {
904
+ name: record.snapshot.name,
905
+ error: error instanceof Error ? error.message : String(error),
906
+ });
907
+ });
908
+ }
909
+
910
+ async #recoverRecords(): Promise<void> {
911
+ const root = path.join(this.#runtimeDir, "daemons");
912
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(error => {
913
+ if (isEnoent(error)) return [];
914
+ throw error;
915
+ });
916
+ for (const entry of entries) {
917
+ if (!entry.isDirectory()) continue;
918
+ const dir = path.join(root, entry.name);
919
+ try {
920
+ const decoded: unknown = await Bun.file(path.join(dir, META_FILE)).json();
921
+ if (typeof decoded !== "object" || decoded === null || !("daemon" in decoded) || !("spec" in decoded)) {
922
+ continue;
923
+ }
924
+ const snapshot = parseDaemonSnapshot(decoded.daemon);
925
+ const spec = parseDaemonSpec(decoded.spec);
926
+ const processRef = snapshot.pid === undefined ? null : Process.fromPid(snapshot.pid);
927
+ const detached =
928
+ spec.detached &&
929
+ !terminalState(snapshot.state) &&
930
+ snapshot.state !== "stopping" &&
931
+ processRef?.status() === "running";
932
+ if (!detached) {
933
+ if (processRef) await processRef.terminate({ group: true, gracefulMs: 500, timeoutMs: 2_000 });
934
+ snapshot.pid = undefined;
935
+ snapshot.state = "exited";
936
+ snapshot.exitedAt = Date.now();
937
+ snapshot.exitReason = "previous broker exited";
938
+ } else if (snapshot.state === "restarting") {
939
+ snapshot.state = spec.ready ? "starting" : "running";
940
+ }
941
+ snapshot.persist = spec.persist;
942
+ snapshot.detached = spec.detached;
943
+ const record: ManagedDaemon = {
944
+ spec,
945
+ snapshot,
946
+ dir,
947
+ generation: 0,
948
+ stopRequested: !detached || snapshot.state === "stopping",
949
+ logReady: detached && (!spec.ready?.log || snapshot.state === "ready"),
950
+ portReady: detached && (spec.ready?.port === undefined || snapshot.state === "ready"),
951
+ readinessBuffer: "",
952
+ outputOffset: detached ? snapshot.outputBytes : 0,
953
+ readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
954
+ consecutiveFailures: 0,
955
+ persistQueue: Promise.resolve(),
956
+ };
957
+ syncReadyPending(record);
958
+ this.#records.set(snapshot.name, record);
959
+ if (detached && spec.ready?.port !== undefined && snapshot.state !== "ready") {
960
+ void this.#pollPort(record, record.generation, spec.ready);
961
+ }
962
+ this.#persist(record);
963
+ } catch (error) {
964
+ logger.warn("Failed to recover daemon record", {
965
+ name: entry.name,
966
+ error: error instanceof Error ? error.message : String(error),
967
+ });
968
+ }
969
+ }
970
+ }
971
+
972
+ #scheduleIdleShutdown(): void {
973
+ if (this.#shuttingDown || this.#clients.size > 0) return;
974
+ clearTimeout(this.#idleTimer);
975
+ this.#idleTimer = setTimeout(() => {
976
+ this.#idleTimer = undefined;
977
+ void (async () => {
978
+ const livePersistent = [...this.#records.values()].some(
979
+ record => record.spec.persist && !terminalState(record.snapshot.state),
980
+ );
981
+ if (this.#clients.size > 0 || livePersistent) return;
982
+ if (await hasLiveDaemonProjectPresence(this.#runtimeDir)) {
983
+ this.#scheduleIdleShutdown();
984
+ return;
985
+ }
986
+ if (this.#clients.size === 0) await this.shutdown();
987
+ })();
988
+ }, this.#idleGraceMs);
989
+ }
990
+ }
991
+
992
+ /** Start the detached per-project daemon broker selected by the CLI worker host. */
993
+ export async function startDaemonBrokerFromEnvironment(): Promise<void> {
994
+ const projectDir = process.env[DAEMON_PROJECT_DIR_ENV];
995
+ const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV];
996
+ if (!projectDir || !runtimeDir) throw new Error("Daemon broker environment is incomplete");
997
+ delete process.env[DAEMON_PROJECT_DIR_ENV];
998
+ delete process.env[DAEMON_RUNTIME_DIR_ENV];
999
+ const rawGrace = process.env[DAEMON_IDLE_GRACE_ENV];
1000
+ delete process.env[DAEMON_IDLE_GRACE_ENV];
1001
+ const parsedGrace = rawGrace === undefined ? DEFAULT_IDLE_GRACE_MS : Number.parseInt(rawGrace, 10);
1002
+ const idleGraceMs = Number.isFinite(parsedGrace) && parsedGrace >= 0 ? parsedGrace : DEFAULT_IDLE_GRACE_MS;
1003
+ await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
1004
+ const lease = await acquireBrokerLease(runtimeDir);
1005
+ if (!lease) return;
1006
+ process.title = "omp daemon broker";
1007
+ const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
1008
+ if (!token) throw new Error("Daemon broker token is empty");
1009
+ const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs);
1010
+ const cancelCleanup = postmortem.register("daemon-broker", () => broker.shutdown());
1011
+ try {
1012
+ await broker.run();
1013
+ } finally {
1014
+ cancelCleanup();
1015
+ await releaseBrokerLease(lease);
1016
+ }
1017
+ }