@cueloop/daemon 0.1.0-alpha.80 → 0.1.0-alpha.81

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.
@@ -0,0 +1,355 @@
1
+ import {
2
+ detectHerdr,
3
+ type HerdrEnv,
4
+ type Thread,
5
+ type ThreadSurfaceOpenStatus,
6
+ } from "@cueloop/schema";
7
+ import { spawnSync } from "node:child_process";
8
+ import * as v from "valibot";
9
+ import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
10
+ import { loadHerdrThreadSurface, type HerdrThreadSurface } from "./thread-surface-config";
11
+
12
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
13
+
14
+ interface HerdrCommandResult {
15
+ exitCode: number;
16
+ stdout: Buffer;
17
+ }
18
+
19
+ function runHerdrCommand(
20
+ command: string[],
21
+ options: { stdout: "pipe" | "ignore"; stderr: "ignore"; timeout: number },
22
+ ): HerdrCommandResult {
23
+ const result = spawnSync(command[0]!, command.slice(1), {
24
+ stdio: ["ignore", options.stdout, options.stderr],
25
+ timeout: options.timeout,
26
+ });
27
+
28
+ return { exitCode: result.status ?? 1, stdout: result.stdout ?? Buffer.alloc(0) };
29
+ }
30
+ const CreatedTabSchema = v.object({
31
+ result: v.optional(
32
+ v.object({
33
+ root_pane: v.optional(
34
+ v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
35
+ ),
36
+ }),
37
+ ),
38
+ });
39
+ const PaneResultSchema = v.object({
40
+ result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
41
+ });
42
+ const CreatedPaneSchema = v.object({
43
+ result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.string() })) })),
44
+ });
45
+ const PaneNeighborSchema = v.object({
46
+ result: v.object({
47
+ neighbor: v.object({ neighbor_pane_id: v.optional(v.nullable(v.string())) }),
48
+ }),
49
+ });
50
+ const PaneFocusSchema = v.object({
51
+ result: v.optional(
52
+ v.object({ focus: v.optional(v.object({ focused_pane_id: v.nullable(v.string()) })) }),
53
+ ),
54
+ });
55
+
56
+ /** Inputs for a new Herdr Thread tab. */
57
+ export interface OpenHerdrThreadTabOptions {
58
+ sessionId: string;
59
+ cwd: string;
60
+ binPath: string;
61
+ label: string;
62
+ workspaceId?: string;
63
+ }
64
+
65
+ /** Daemon-owned native handles, separate from canonical Thread records. */
66
+ export interface HerdrThreadSurfacePersistence {
67
+ herdrGetThreadSurface(sessionId: string): Promise<HerdrThreadSurfaceHandle | null>;
68
+ herdrSetThreadSurface(sessionId: string, handle: HerdrThreadSurfaceHandle): Promise<void>;
69
+ }
70
+
71
+ /** Open a focused Herdr tab; keep both native IDs for later liveness checks. */
72
+ export function openHerdrThreadTab(
73
+ options: OpenHerdrThreadTabOptions,
74
+ ): HerdrThreadSurfaceHandle | null {
75
+ const { sessionId, cwd, binPath, label, workspaceId } = options;
76
+
77
+ try {
78
+ const created = runHerdrCommand(
79
+ [
80
+ binPath,
81
+ "tab",
82
+ "create",
83
+ ...(workspaceId ? ["--workspace", workspaceId] : []),
84
+ "--cwd",
85
+ cwd,
86
+ "--label",
87
+ label,
88
+ "--focus",
89
+ ],
90
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
91
+ );
92
+
93
+ if (created.exitCode !== 0) return null;
94
+ const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
95
+
96
+ if (!parsed.success) return null;
97
+ const paneId = parsed.output.result?.root_pane?.pane_id;
98
+ const tabId = parsed.output.result?.root_pane?.tab_id;
99
+
100
+ if (!paneId || !tabId) return null;
101
+
102
+ if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
103
+ closeUnlaunchedHerdrSurface(binPath, "tab", tabId);
104
+
105
+ return null;
106
+ }
107
+
108
+ return { tabId, paneId };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /** Inputs for a focused right-hand Herdr Thread pane. */
115
+ export interface OpenHerdrThreadPaneOptions {
116
+ sessionId: string;
117
+ cwd: string;
118
+ binPath: string;
119
+ sourcePaneId: string;
120
+ tabId: string;
121
+ }
122
+
123
+ /** Split the calling Herdr pane right at 50 percent and launch cueloop in it. */
124
+ export function openHerdrThreadPane(
125
+ options: OpenHerdrThreadPaneOptions,
126
+ ): HerdrThreadSurfaceHandle | null {
127
+ const { sessionId, cwd, binPath, sourcePaneId, tabId } = options;
128
+
129
+ try {
130
+ const created = runHerdrCommand(
131
+ [
132
+ binPath,
133
+ "pane",
134
+ "split",
135
+ sourcePaneId,
136
+ "--direction",
137
+ "right",
138
+ "--ratio",
139
+ "0.5",
140
+ "--cwd",
141
+ cwd,
142
+ "--focus",
143
+ ],
144
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
145
+ );
146
+
147
+ if (created.exitCode !== 0) return null;
148
+ const parsed = v.safeParse(CreatedPaneSchema, JSON.parse(created.stdout.toString()));
149
+ const paneId = parsed.success ? parsed.output.result?.pane?.pane_id : undefined;
150
+
151
+ if (!paneId) return null;
152
+ if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
153
+ closeUnlaunchedHerdrSurface(binPath, "pane", paneId);
154
+
155
+ return null;
156
+ }
157
+
158
+ return { mode: "pane", tabId, paneId };
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ function closeUnlaunchedHerdrSurface(binPath: string, kind: "tab" | "pane", id: string): void {
165
+ try {
166
+ runHerdrCommand([binPath, kind, "close", id], {
167
+ stdout: "ignore",
168
+ stderr: "ignore",
169
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
170
+ });
171
+ } catch {
172
+ // The pending Thread remains available through the manual command.
173
+ }
174
+ }
175
+
176
+ function sendCueloopThreadCommand(binPath: string, paneId: string, sessionId: string): boolean {
177
+ const typed = runHerdrCommand([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
178
+ stdout: "ignore",
179
+ stderr: "ignore",
180
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
181
+ });
182
+
183
+ if (typed.exitCode !== 0) return false;
184
+ const entered = runHerdrCommand([binPath, "pane", "send-keys", paneId, "enter"], {
185
+ stdout: "ignore",
186
+ stderr: "ignore",
187
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
188
+ });
189
+
190
+ return entered.exitCode === 0;
191
+ }
192
+
193
+ /** Check the recorded pane ID before deciding whether to reopen. */
194
+ function herdrPaneAlive(binPath: string, paneId: string): boolean {
195
+ try {
196
+ const got = runHerdrCommand([binPath, "pane", "get", paneId], {
197
+ stdout: "pipe",
198
+ stderr: "ignore",
199
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
200
+ });
201
+
202
+ if (got.exitCode !== 0) return false;
203
+ const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
204
+
205
+ return parsed.success && parsed.output.result?.pane != null;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /** Focus a recorded Herdr tab. */
212
+ function focusHerdrTab(binPath: string, tabId: string): boolean {
213
+ try {
214
+ const focused = runHerdrCommand([binPath, "tab", "focus", tabId], {
215
+ stdout: "ignore",
216
+ stderr: "ignore",
217
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
218
+ });
219
+
220
+ return focused.exitCode === 0;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ function focusHerdrThreadSurface(binPath: string, handle: HerdrThreadSurfaceHandle): boolean {
227
+ if (!focusHerdrTab(binPath, handle.tabId)) return false;
228
+
229
+ if (handle.mode !== "pane") return true;
230
+
231
+ try {
232
+ const directions = [
233
+ ["left", "right"],
234
+ ["right", "left"],
235
+ ["up", "down"],
236
+ ["down", "up"],
237
+ ] as const;
238
+ let hasNeighbor = false;
239
+
240
+ for (const [direction, opposite] of directions) {
241
+ const neighbor = herdrPaneNeighbor(binPath, handle.paneId, direction);
242
+
243
+ if (neighbor === undefined) return false;
244
+ if (neighbor === null) continue;
245
+ hasNeighbor = true;
246
+
247
+ if (herdrPaneNeighbor(binPath, neighbor, opposite) !== handle.paneId) continue;
248
+ const focused = runHerdrCommand(
249
+ [binPath, "pane", "focus", "--pane", neighbor, "--direction", opposite],
250
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
251
+ );
252
+ const parsed = v.safeParse(PaneFocusSchema, JSON.parse(focused.stdout.toString()));
253
+
254
+ if (
255
+ focused.exitCode === 0 &&
256
+ parsed.success &&
257
+ parsed.output.result?.focus?.focused_pane_id === handle.paneId
258
+ ) {
259
+ return true;
260
+ }
261
+ }
262
+
263
+ return !hasNeighbor;
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ function herdrPaneNeighbor(
270
+ binPath: string,
271
+ paneId: string,
272
+ direction: string,
273
+ ): string | null | undefined {
274
+ try {
275
+ const neighbor = runHerdrCommand(
276
+ [binPath, "pane", "neighbor", "--pane", paneId, "--direction", direction],
277
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
278
+ );
279
+
280
+ if (neighbor.exitCode !== 0) return undefined;
281
+ const parsed = v.safeParse(PaneNeighborSchema, JSON.parse(neighbor.stdout.toString()));
282
+
283
+ return parsed.success ? (parsed.output.result.neighbor.neighbor_pane_id ?? null) : undefined;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ /** Reuse a live Thread surface, reopen a closed one, or report a manual fallback. */
290
+ export async function openHerdrThreadSurface(
291
+ session: Thread,
292
+ persistence: HerdrThreadSurfacePersistence,
293
+ env: HerdrEnv = process.env,
294
+ mode: HerdrThreadSurface = loadHerdrThreadSurface(),
295
+ ): Promise<ThreadSurfaceOpenStatus> {
296
+ const herdr = detectHerdr(env);
297
+
298
+ if (!herdr) return "unavailable";
299
+
300
+ if (mode === "none") return "disabled";
301
+ const recorded = await recallHerdrThreadSurface(persistence, session.id);
302
+
303
+ if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
304
+ return focusHerdrThreadSurface(herdr.binPath, recorded) ? "focused" : "failed";
305
+ }
306
+ const cwd = session.artifact.meta.cwd ?? session.workspace.repoRoot;
307
+ const opened =
308
+ mode === "pane"
309
+ ? env.HERDR_TAB_ID
310
+ ? openHerdrThreadPane({
311
+ sessionId: session.id,
312
+ cwd,
313
+ binPath: herdr.binPath,
314
+ sourcePaneId: herdr.paneId,
315
+ tabId: env.HERDR_TAB_ID,
316
+ })
317
+ : null
318
+ : openHerdrThreadTab({
319
+ sessionId: session.id,
320
+ cwd,
321
+ binPath: herdr.binPath,
322
+ label: session.artifact.meta.title ?? session.id,
323
+ workspaceId: env.HERDR_WORKSPACE_ID,
324
+ });
325
+
326
+ if (!opened) return "failed";
327
+ await rememberHerdrThreadSurface(persistence, session.id, opened);
328
+
329
+ return "opened";
330
+ }
331
+
332
+ /** A stale daemon cannot prevent the reviewer from opening the pending Thread. */
333
+ async function recallHerdrThreadSurface(
334
+ persistence: HerdrThreadSurfacePersistence,
335
+ sessionId: string,
336
+ ): Promise<HerdrThreadSurfaceHandle | null> {
337
+ try {
338
+ return await persistence.herdrGetThreadSurface(sessionId);
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+
344
+ /** Handle persistence is best-effort after the new terminal has opened. */
345
+ async function rememberHerdrThreadSurface(
346
+ persistence: HerdrThreadSurfacePersistence,
347
+ sessionId: string,
348
+ handle: HerdrThreadSurfaceHandle,
349
+ ): Promise<void> {
350
+ try {
351
+ await persistence.herdrSetThreadSurface(sessionId, handle);
352
+ } catch {
353
+ // The Thread remains pending even if the old daemon cannot store this handle.
354
+ }
355
+ }
package/src/paths.ts CHANGED
@@ -60,11 +60,16 @@ export function ownerTokenPath(home = cueloopHome()): string {
60
60
  return join(home, "owner.token");
61
61
  }
62
62
 
63
- /** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
64
- export function herdrTabsPath(home = cueloopHome()): string {
63
+ /** Adapter scratch for Herdr handles; the disk name stays stable across tab and pane support. */
64
+ export function herdrThreadSurfacesPath(home = cueloopHome()): string {
65
65
  return join(home, "herdr-tabs.json");
66
66
  }
67
67
 
68
+ /** Ghostty-native terminal IDs are adapter scratch, not Thread state. */
69
+ export function ghosttyThreadSurfacesPath(home = cueloopHome()): string {
70
+ return join(home, "ghostty-thread-surfaces.json");
71
+ }
72
+
68
73
  /** Harness bindings and Message deliveries, stored independently of Thread records. */
69
74
  export function harnessStatePath(home = cueloopHome()): string {
70
75
  return join(home, "harness-state.json");
@@ -59,6 +59,8 @@ describe("socket round-trip", () => {
59
59
  const binding = await client.harnessBind(thread.id, "fake", "fake_1");
60
60
 
61
61
  expect(await client.harnessGetBinding(binding.id)).toEqual(binding);
62
+ expect(await client.harnessBindingsForSession("fake", "fake_1")).toEqual([binding]);
63
+ expect(await client.harnessBindingsForSession("fake", "different")).toEqual([]);
62
64
 
63
65
  const sent = await client.sessionSendMessage(thread.id, "approved", "Ready.");
64
66
  const pending = await client.deliveryPending(binding.id);
package/src/server.ts CHANGED
@@ -8,10 +8,13 @@ import {
8
8
  chmodSync,
9
9
  closeSync,
10
10
  existsSync,
11
+ fstatSync,
12
+ linkSync,
11
13
  mkdirSync,
12
14
  openSync,
13
15
  readFileSync,
14
16
  rmSync,
17
+ statSync,
15
18
  writeFileSync,
16
19
  } from "node:fs";
17
20
  import { DaemonCore, type DaemonEvent } from "./api";
@@ -55,6 +58,7 @@ export interface DaemonOptions {
55
58
  * so in-process ownership is tracked separately.
56
59
  */
57
60
  const HELD_HOMES = new Set<string>();
61
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
58
62
 
59
63
  export class DaemonServer {
60
64
  readonly core: DaemonCore;
@@ -91,20 +95,35 @@ export class DaemonServer {
91
95
  const path = lockPath(this.home);
92
96
 
93
97
  for (let attempt = 0; attempt < 3; attempt++) {
94
- try {
95
- // "wx" fails when the file exists - the atomic part of the handshake
96
- const fd = openSync(path, "wx");
98
+ const preparedPath = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
99
+ const fd = openSync(preparedPath, "wx", 0o600);
97
100
 
101
+ try {
102
+ // Publish the complete pid atomically. A competing daemon must never
103
+ // mistake a newly created, still-empty lock for a crashed owner.
98
104
  writeFileSync(fd, String(process.pid));
105
+ linkSync(preparedPath, path);
99
106
  this.lockFd = fd;
100
107
  HELD_HOMES.add(this.home);
101
108
 
102
109
  return true;
103
- } catch {
110
+ } catch (error) {
111
+ closeSync(fd);
112
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
113
+
104
114
  let ownerPid = 0;
105
115
 
106
116
  try {
107
- ownerPid = Number(readFileSync(path, "utf8").trim());
117
+ let ownerText = readFileSync(path, "utf8").trim();
118
+
119
+ // Older daemon builds published the lock before writing their pid.
120
+ // Give that live owner time to finish instead of stealing its socket.
121
+ for (let probe = 0; !ownerText && probe < 25; probe++) {
122
+ Atomics.wait(LOCK_WAIT, 0, 0, 20);
123
+ ownerText = readFileSync(path, "utf8").trim();
124
+ }
125
+ if (!ownerText && Date.now() - statSync(path).mtimeMs < 5_000) return false;
126
+ ownerPid = Number(ownerText);
108
127
  } catch {
109
128
  // the owner vanished between open and read; retry
110
129
  continue;
@@ -125,6 +144,8 @@ export class DaemonServer {
125
144
  } catch {
126
145
  return false;
127
146
  }
147
+ } finally {
148
+ rmSync(preparedPath, { force: true });
128
149
  }
129
150
  }
130
151
 
@@ -134,6 +155,12 @@ export class DaemonServer {
134
155
  private releaseLock(): void {
135
156
  HELD_HOMES.delete(this.home);
136
157
  if (this.lockFd !== null) {
158
+ try {
159
+ if (statSync(lockPath(this.home)).ino === fstatSync(this.lockFd).ino)
160
+ rmSync(lockPath(this.home), { force: true });
161
+ } catch {
162
+ // the lock was already removed or replaced
163
+ }
137
164
  try {
138
165
  closeSync(this.lockFd);
139
166
  } catch {
@@ -141,7 +168,6 @@ export class DaemonServer {
141
168
  }
142
169
  this.lockFd = null;
143
170
  }
144
- rmSync(lockPath(this.home), { force: true });
145
171
  }
146
172
 
147
173
  /**
@@ -205,6 +231,7 @@ export class DaemonServer {
205
231
 
206
232
  stop(): void {
207
233
  this.core.dispose();
234
+ if (this.lockFd === null) return;
208
235
  this.server?.stop(true);
209
236
  this.server = null;
210
237
  rmSync(socketPath(this.home), { force: true });
@@ -469,6 +496,11 @@ export class DaemonServer {
469
496
 
470
497
  return this.core.harnessGetBinding(params.bindingId);
471
498
  },
499
+ "harness.bindingsForSession": (_connection, request) => {
500
+ const params = parseParams("harness.bindingsForSession", request.params);
501
+
502
+ return this.core.harnessBindingsForSession(params.harness, params.harnessSessionId);
503
+ },
472
504
  "harness.consumeApprovedRetry": (_connection, request) => {
473
505
  const params = parseParams("harness.consumeApprovedRetry", request.params);
474
506
 
@@ -498,12 +530,36 @@ export class DaemonServer {
498
530
  params.files,
499
531
  );
500
532
  },
501
- "herdr.getTab": (_connection, request) =>
502
- this.core.herdrGetTab(parseParams("herdr.getTab", request.params).id),
503
- "herdr.setTab": (_connection, request) => {
504
- const params = parseParams("herdr.setTab", request.params);
533
+ "herdr.getThreadSurface": (_connection, request) =>
534
+ this.core.herdrGetThreadSurface(parseParams("herdr.getThreadSurface", request.params).id),
535
+ "herdr.setThreadSurface": (_connection, request) => {
536
+ const params = parseParams("herdr.setThreadSurface", request.params);
537
+
538
+ this.core.herdrSetThreadSurface(params.id, {
539
+ tabId: params.tabId,
540
+ paneId: params.paneId,
541
+ mode: params.mode,
542
+ });
543
+
544
+ return {};
545
+ },
546
+ "ghostty.getThreadSurface": (_connection, request) =>
547
+ this.core.ghosttyGetThreadSurface(parseParams("ghostty.getThreadSurface", request.params).id),
548
+ "ghostty.setThreadSurface": (_connection, request) => {
549
+ const params = parseParams("ghostty.setThreadSurface", request.params);
550
+
551
+ this.core.ghosttySetThreadSurface(params.id, { terminalId: params.terminalId });
505
552
 
506
- this.core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
553
+ return {};
554
+ },
555
+ "ghostty.claimThreadSurface": (_connection, request) =>
556
+ this.core.ghosttyClaimThreadSurface(
557
+ parseParams("ghostty.claimThreadSurface", request.params).id,
558
+ ),
559
+ "ghostty.releaseThreadSurface": (_connection, request) => {
560
+ this.core.ghosttyReleaseThreadSurface(
561
+ parseParams("ghostty.releaseThreadSurface", request.params).id,
562
+ );
507
563
 
508
564
  return {};
509
565
  },
@@ -5,12 +5,13 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
+ import { createServer } from "node:net";
11
12
  import { DaemonServer } from "./server";
12
13
  import { DaemonClient } from "./client";
13
- import { lockPath, socketPath } from "./paths";
14
+ import { lockPath, ownerTokenPath, socketPath } from "./paths";
14
15
  import type { Artifact, WorkspaceKey } from "@cueloop/schema";
15
16
 
16
17
  const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
@@ -50,6 +51,23 @@ describe("one daemon per home", () => {
50
51
  expect(existsSync(path!)).toBe(true);
51
52
  });
52
53
 
54
+ test("stopping a refused server leaves the lock owner's socket intact", async () => {
55
+ const first = server();
56
+ const path = first.start();
57
+ const refused = server();
58
+
59
+ expect(refused.start()).toBeNull();
60
+ refused.stop();
61
+ expect(existsSync(path!)).toBe(true);
62
+ const client = await DaemonClient.connect({ home });
63
+
64
+ try {
65
+ expect((await client.ping()).pid).toBe(process.pid);
66
+ } finally {
67
+ client.close();
68
+ }
69
+ });
70
+
53
71
  test("clients keep talking to the one live daemon after a refused start", async () => {
54
72
  // Arrange
55
73
  const first = server();
@@ -81,6 +99,32 @@ describe("one daemon per home", () => {
81
99
  expect(daemonServer.start()).toBe(socketPath(home));
82
100
  });
83
101
 
102
+ test("a daemon does not steal a lock while its owner writes the pid", async () => {
103
+ const path = lockPath(home);
104
+ const script = `
105
+ import { openSync, writeFileSync } from "node:fs";
106
+ const fd = openSync(${JSON.stringify(path)}, "wx");
107
+ console.log("lock created");
108
+ setTimeout(() => writeFileSync(fd, String(process.pid)), 100);
109
+ setTimeout(() => {}, 2_000);
110
+ `;
111
+ const owner = Bun.spawn([process.execPath, "-e", script], {
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ });
115
+
116
+ try {
117
+ const firstOutput = await owner.stdout.getReader().read();
118
+
119
+ expect(new TextDecoder().decode(firstOutput.value)).toContain("lock created");
120
+ expect(server().start()).toBeNull();
121
+ expect(existsSync(socketPath(home))).toBe(false);
122
+ } finally {
123
+ owner.kill();
124
+ await owner.exited;
125
+ }
126
+ });
127
+
84
128
  test("an autostarted daemon reclaims a stale socket after taking the lock", async () => {
85
129
  writeFileSync(socketPath(home), "stale socket");
86
130
  const client = await DaemonClient.connect({ home, autostart: true });
@@ -93,6 +137,20 @@ describe("one daemon per home", () => {
93
137
  }
94
138
  });
95
139
 
140
+ test("autostart reports the daemon error when the child cannot start", async () => {
141
+ mkdirSync(ownerTokenPath(home));
142
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
143
+
144
+ // This checks error reporting, not startup speed; allow the child to start on a loaded runner.
145
+ process.env.CUELOOP_START_TIMEOUT_MS = "5000";
146
+ try {
147
+ await expect(DaemonClient.connect({ home, autostart: true })).rejects.toThrow("EISDIR");
148
+ } finally {
149
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
150
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
151
+ }
152
+ }, 10_000);
153
+
96
154
  test("stopping releases the lock so a restart works", () => {
97
155
  // Arrange
98
156
  const first = server();
@@ -159,4 +217,34 @@ describe("one daemon per home", () => {
159
217
  else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
160
218
  }
161
219
  });
220
+
221
+ test("autostart retries after a socket closes during the handshake", async () => {
222
+ const path = socketPath(home);
223
+ const replacement = server();
224
+ const transient = createServer((socket) => {
225
+ socket.destroy();
226
+ transient.close(() => {
227
+ rmSync(path, { force: true });
228
+ rmSync(lockPath(home), { force: true });
229
+ replacement.start();
230
+ });
231
+ });
232
+
233
+ writeFileSync(lockPath(home), String(process.pid));
234
+ await new Promise<void>((resolve) => transient.listen(path, resolve));
235
+ const client = new DaemonClient();
236
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
237
+
238
+ client["home"] = home;
239
+ process.env.CUELOOP_START_TIMEOUT_MS = "1000";
240
+ try {
241
+ await client["attachFreshDaemon"](home, path);
242
+ expect((await client.ping()).pid).toBe(process.pid);
243
+ } finally {
244
+ client.close();
245
+ if (transient.listening) transient.close();
246
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
247
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
248
+ }
249
+ });
162
250
  });
@@ -1,12 +1,3 @@
1
- /**
2
- * The shared Thread review core: one open/wait/message path for every consumer -
3
- * CLI commands and agent adapters. openReview resolves the workspace, shapes
4
- * the artifact, and opens-or-revises by agentSessionId; awaitMessage maps the
5
- * wait contract onto the agent contract through messageResponse. An
6
- * adapter keeps only two bespoke parts: parsing its host's event shape and
7
- * serializing the decision in its host's contract.
8
- */
9
-
10
1
  import {
11
2
  isMarkdownArtifact,
12
3
  newAnnotationId,
@@ -18,6 +9,8 @@ import {
18
9
  type WorkspaceKey,
19
10
  type WorkflowKind,
20
11
  } from "@cueloop/schema";
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
21
14
  import { messageResponse } from "./api";
22
15
  import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
23
16
 
@@ -27,12 +20,9 @@ export { messageResponse };
27
20
 
28
21
  async function git(args: string[], cwd: string): Promise<string | null> {
29
22
  try {
30
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
31
- const out = await new Response(proc.stdout).text();
32
-
33
- if ((await proc.exited) !== 0) return null;
23
+ const { stdout } = await promisify(execFile)("git", args, { cwd });
34
24
 
35
- return out.trim();
25
+ return stdout.trim();
36
26
  } catch {
37
27
  return null;
38
28
  }