@cueloop/daemon 0.1.0-alpha.8 → 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.
Files changed (56) hide show
  1. package/package.json +37 -22
  2. package/src/api.test.ts +940 -65
  3. package/src/api.ts +1095 -88
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/ghostty-thread-surface-store.test.ts +49 -0
  14. package/src/ghostty-thread-surface-store.ts +101 -0
  15. package/src/ghostty-thread-surface.test.ts +398 -0
  16. package/src/ghostty-thread-surface.ts +267 -0
  17. package/src/harness-delivery.test.ts +208 -0
  18. package/src/harness-state-store.test.ts +65 -0
  19. package/src/harness-state-store.ts +181 -0
  20. package/src/herdr-split.test.ts +106 -0
  21. package/src/herdr-split.ts +92 -0
  22. package/src/herdr-thread-surface-store.test.ts +81 -0
  23. package/src/herdr-thread-surface-store.ts +64 -0
  24. package/src/herdr-thread-surface.test.ts +411 -0
  25. package/src/herdr-thread-surface.ts +355 -0
  26. package/src/index.ts +8 -9
  27. package/src/interruptible-wait.test.ts +127 -0
  28. package/src/interruptible-wait.ts +66 -0
  29. package/src/main.ts +4 -1
  30. package/src/paths.ts +49 -0
  31. package/src/pr-poller.test.ts +76 -0
  32. package/src/pr-poller.ts +74 -0
  33. package/src/project-files.test.ts +124 -0
  34. package/src/project-files.ts +56 -0
  35. package/src/protocol.ts +79 -7
  36. package/src/retention.test.ts +191 -0
  37. package/src/retention.ts +123 -0
  38. package/src/server.test.ts +432 -42
  39. package/src/server.ts +423 -89
  40. package/src/share-blob.test.ts +112 -0
  41. package/src/share-blob.ts +55 -0
  42. package/src/single-instance.test.ts +162 -13
  43. package/src/store.conformance.test.ts +41 -0
  44. package/src/store.jsonl.test.ts +159 -0
  45. package/src/store.ts +264 -34
  46. package/src/testing/store-conformance.ts +194 -0
  47. package/src/thread-review.test.ts +394 -0
  48. package/src/thread-review.ts +314 -0
  49. package/src/thread-surface-config.test.ts +63 -0
  50. package/src/thread-surface-config.ts +61 -0
  51. package/src/validate.test.ts +214 -35
  52. package/src/validate.ts +306 -25
  53. package/src/version.ts +6 -0
  54. package/src/working-tree.ts +149 -0
  55. package/src/review.test.ts +0 -167
  56. package/src/review.ts +0 -174
@@ -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/index.ts CHANGED
@@ -1,9 +1,8 @@
1
- export * from "./api";
2
- export * from "./review";
3
- export * from "./errors";
4
- export * from "./validate";
5
- export * from "./store";
6
- export * from "./server";
7
- export * from "./protocol";
8
- export * from "./paths";
9
- export { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
1
+ /**
2
+ * The daemon's public surface. Consumers reach the client and the review
3
+ * helpers through the ./client and ./thread-review subpaths; this barrel carries
4
+ * only the two names imported bare: the server and the home-dir resolver.
5
+ */
6
+
7
+ export { DaemonServer, type DaemonOptions } from "./server";
8
+ export { cueloopHome } from "./paths";
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
3
+
4
+ function deferred<T>() {
5
+ let resolve!: (value: T) => void;
6
+ let reject!: (cause: unknown) => void;
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+
12
+ return { promise, resolve, reject };
13
+ }
14
+
15
+ describe("raceAbort", () => {
16
+ test("without a signal the promise passes straight through", async () => {
17
+ expect(await raceAbort(Promise.resolve("value"), undefined)).toBe("value");
18
+ });
19
+
20
+ test("resolves to the value when the promise settles before any abort", async () => {
21
+ const controller = new AbortController();
22
+
23
+ expect(await raceAbort(Promise.resolve(7), controller.signal)).toBe(7);
24
+ });
25
+
26
+ test("an already-aborted signal returns ABORTED without waiting", async () => {
27
+ const controller = new AbortController();
28
+
29
+ controller.abort();
30
+
31
+ expect(await raceAbort(new Promise<never>(() => {}), controller.signal)).toBe(ABORTED);
32
+ });
33
+
34
+ test("a signal that fires during the wait returns ABORTED", async () => {
35
+ const controller = new AbortController();
36
+ const pending = deferred<number>();
37
+ const raced = raceAbort(pending.promise, controller.signal);
38
+
39
+ controller.abort();
40
+
41
+ expect(await raced).toBe(ABORTED);
42
+ });
43
+
44
+ test("a rejection propagates the original error unchanged", async () => {
45
+ const controller = new AbortController();
46
+ const original = new Error("daemon socket closed");
47
+ let caught: unknown;
48
+
49
+ try {
50
+ await raceAbort(Promise.reject(original), controller.signal);
51
+ } catch (error) {
52
+ caught = error;
53
+ }
54
+
55
+ expect(caught).toBe(original);
56
+ });
57
+
58
+ test("a rejection that lands after an abort is swallowed, not surfaced", async () => {
59
+ const controller = new AbortController();
60
+ const pending = deferred<number>();
61
+ const raced = raceAbort(pending.promise, controller.signal);
62
+
63
+ controller.abort();
64
+ const result = await raced;
65
+
66
+ pending.reject(new Error("late daemon error"));
67
+ await Bun.sleep(10);
68
+
69
+ expect(result).toBe(ABORTED);
70
+ });
71
+ });
72
+
73
+ describe("pollUntilResolved", () => {
74
+ test("returns the first non-null attempt", async () => {
75
+ expect(await pollUntilResolved(() => Promise.resolve("done"), undefined)).toBe("done");
76
+ });
77
+
78
+ test("keeps polling past null attempts until one resolves", async () => {
79
+ let calls = 0;
80
+ const attempt = () => {
81
+ calls++;
82
+
83
+ return Promise.resolve(calls < 3 ? null : "third");
84
+ };
85
+
86
+ expect(await pollUntilResolved(attempt, undefined)).toBe("third");
87
+ expect(calls).toBe(3);
88
+ });
89
+
90
+ test("an already-aborted signal returns null before any attempt", async () => {
91
+ const controller = new AbortController();
92
+ let calls = 0;
93
+
94
+ controller.abort();
95
+ const result = await pollUntilResolved(() => {
96
+ calls++;
97
+
98
+ return Promise.resolve("value");
99
+ }, controller.signal);
100
+
101
+ expect(result).toBeNull();
102
+ expect(calls).toBe(0);
103
+ });
104
+
105
+ test("a signal that fires during a chunk returns null", async () => {
106
+ const controller = new AbortController();
107
+ const pending = deferred<string | null>();
108
+ const polling = pollUntilResolved(() => pending.promise, controller.signal);
109
+
110
+ controller.abort();
111
+
112
+ expect(await polling).toBeNull();
113
+ });
114
+
115
+ test("a rejected attempt propagates the original error", async () => {
116
+ const original = new Error("attempt failed");
117
+ let caught: unknown;
118
+
119
+ try {
120
+ await pollUntilResolved(() => Promise.reject(original), undefined);
121
+ } catch (error) {
122
+ caught = error;
123
+ }
124
+
125
+ expect(caught).toBe(original);
126
+ });
127
+ });
@@ -0,0 +1,66 @@
1
+ import { Cause, Effect, Exit } from "effect";
2
+
3
+ export const ABORTED: unique symbol = Symbol("aborted");
4
+
5
+ const whenSignalAborts = (signal: AbortSignal): Effect.Effect<void> =>
6
+ Effect.callback<void>((resume) => {
7
+ if (signal.aborted) {
8
+ resume(Effect.void);
9
+
10
+ return;
11
+ }
12
+ const onAbort = () => resume(Effect.void);
13
+
14
+ signal.addEventListener("abort", onAbort, { once: true });
15
+
16
+ return Effect.sync(() => signal.removeEventListener("abort", onAbort));
17
+ });
18
+
19
+ const fromSettledPromise = <T>(start: () => Promise<T>): Effect.Effect<T, unknown> =>
20
+ Effect.callback<T, unknown>((resume) => {
21
+ start().then(
22
+ (value) => resume(Effect.succeed(value)),
23
+ (cause: unknown) => resume(Effect.fail(cause)),
24
+ );
25
+ });
26
+
27
+ async function runToPromise<T>(effect: Effect.Effect<T, unknown>): Promise<T> {
28
+ const exit = await Effect.runPromiseExit(effect);
29
+
30
+ if (Exit.isSuccess(exit)) return exit.value;
31
+
32
+ throw Cause.squash(exit.cause);
33
+ }
34
+
35
+ export function raceAbort<T>(
36
+ promise: Promise<T>,
37
+ signal: AbortSignal | undefined,
38
+ ): Promise<T | typeof ABORTED> {
39
+ if (!signal) return promise;
40
+ if (signal.aborted) {
41
+ promise.catch(() => {});
42
+
43
+ return Promise.resolve(ABORTED);
44
+ }
45
+
46
+ return runToPromise(
47
+ Effect.raceFirst(
48
+ fromSettledPromise(() => promise),
49
+ whenSignalAborts(signal).pipe(Effect.as<T | typeof ABORTED>(ABORTED)),
50
+ ),
51
+ );
52
+ }
53
+
54
+ export function pollUntilResolved<T>(
55
+ attempt: () => Promise<T | null>,
56
+ signal: AbortSignal | undefined,
57
+ ): Promise<T | null> {
58
+ if (signal?.aborted) return Promise.resolve(null);
59
+ const loop = Effect.repeat(fromSettledPromise(attempt), {
60
+ until: (result): result is T => result !== null,
61
+ });
62
+
63
+ if (!signal) return runToPromise(loop);
64
+
65
+ return runToPromise(Effect.raceFirst(loop, whenSignalAborts(signal).pipe(Effect.as(null))));
66
+ }
package/src/main.ts CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { DaemonServer } from "./server";
4
4
 
5
- const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS ? Number(process.env.CUELOOP_IDLE_EXIT_MS) : undefined;
5
+ const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
6
+ ? Number(process.env.CUELOOP_IDLE_EXIT_MS)
7
+ : undefined;
6
8
  const server = new DaemonServer({ idleExitMs });
7
9
  const path = server.start();
10
+
8
11
  if (path === null) {
9
12
  // another daemon already owns this home; the client will attach to it
10
13
  console.log("cueloop daemon already running for this home");
package/src/paths.ts CHANGED
@@ -18,6 +18,35 @@ export function sessionsDir(home = cueloopHome()): string {
18
18
  return join(home, "sessions");
19
19
  }
20
20
 
21
+ /** Thread files live here, one JSONL per thread, under a per-project bucket. */
22
+ export function threadsDir(home = cueloopHome()): string {
23
+ return join(home, "threads");
24
+ }
25
+
26
+ /** A git commit is lowercase hex; anything else is not a real root commit. */
27
+ const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
28
+
29
+ /**
30
+ * The per-project bucket for a workspace: its durable root-commit SHA, or a shared standalone bucket.
31
+ * A value that is not a commit SHA (including path-traversal segments an IPC caller could smuggle in)
32
+ * is refused to the standalone bucket, so a bucket name can never escape the threads directory.
33
+ */
34
+ export function threadBucket(rootCommit: string | undefined, home = cueloopHome()): string {
35
+ const bucket =
36
+ rootCommit && COMMIT_SHA.test(rootCommit) ? rootCommit.slice(0, 12) : "_standalone";
37
+
38
+ return join(threadsDir(home), bucket);
39
+ }
40
+
41
+ /** Where the one-time migration parks the old whole-record JSON files, so a downgrade can still read them. */
42
+ export function migratedSessionsDir(home = cueloopHome()): string {
43
+ return join(home, "sessions.migrated");
44
+ }
45
+
46
+ export function reportsDir(home = cueloopHome()): string {
47
+ return join(home, "reports");
48
+ }
49
+
21
50
  export function pidPath(home = cueloopHome()): string {
22
51
  return join(home, "cueloop.pid");
23
52
  }
@@ -25,3 +54,23 @@ export function pidPath(home = cueloopHome()): string {
25
54
  export function lockPath(home = cueloopHome()): string {
26
55
  return join(home, "cueloop.lock");
27
56
  }
57
+
58
+ /** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
59
+ export function ownerTokenPath(home = cueloopHome()): string {
60
+ return join(home, "owner.token");
61
+ }
62
+
63
+ /** Adapter scratch for Herdr handles; the disk name stays stable across tab and pane support. */
64
+ export function herdrThreadSurfacesPath(home = cueloopHome()): string {
65
+ return join(home, "herdr-tabs.json");
66
+ }
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
+
73
+ /** Harness bindings and Message deliveries, stored independently of Thread records. */
74
+ export function harnessStatePath(home = cueloopHome()): string {
75
+ return join(home, "harness-state.json");
76
+ }