@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.110

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 (85) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +104 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1483 -90
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +191 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +844 -95
  15. package/src/curate.ts +111 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +405 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +423 -0
  21. package/src/diff-watcher.test.ts +165 -0
  22. package/src/diff-watcher.ts +363 -0
  23. package/src/examples/daemon-sdk.ts +57 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +78 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +104 -0
  28. package/src/ghostty-thread-surface.test.ts +400 -0
  29. package/src/ghostty-thread-surface.ts +273 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +31 -0
  33. package/src/harness-handoff.test.ts +725 -0
  34. package/src/harness-handoff.ts +27 -0
  35. package/src/harness-state-store.test.ts +93 -0
  36. package/src/harness-state-store.ts +218 -0
  37. package/src/herdr-split.test.ts +106 -0
  38. package/src/herdr-split.ts +90 -0
  39. package/src/herdr-thread-surface-store.test.ts +81 -0
  40. package/src/herdr-thread-surface-store.ts +66 -0
  41. package/src/herdr-thread-surface.test.ts +465 -0
  42. package/src/herdr-thread-surface.ts +392 -0
  43. package/src/index.ts +8 -9
  44. package/src/interruptible-wait.test.ts +127 -0
  45. package/src/interruptible-wait.ts +69 -0
  46. package/src/jj-working-tree.ts +140 -0
  47. package/src/main.ts +5 -1
  48. package/src/operation-receipts.test.ts +111 -0
  49. package/src/operation-receipts.ts +104 -0
  50. package/src/paths.ts +49 -0
  51. package/src/pr-poller.test.ts +102 -0
  52. package/src/pr-poller.ts +80 -0
  53. package/src/project-files.test.ts +134 -0
  54. package/src/project-files.ts +72 -0
  55. package/src/protocol.ts +82 -7
  56. package/src/retention.test.ts +191 -0
  57. package/src/retention.ts +131 -0
  58. package/src/sdk-identities.ts +35 -0
  59. package/src/server.test.ts +502 -42
  60. package/src/server.ts +660 -88
  61. package/src/share-blob.test.ts +142 -0
  62. package/src/share-blob.ts +57 -0
  63. package/src/shared-agent-protocol.ts +30 -0
  64. package/src/single-instance.test.ts +165 -13
  65. package/src/store.conformance.test.ts +41 -0
  66. package/src/store.jsonl.test.ts +173 -0
  67. package/src/store.ts +278 -34
  68. package/src/testing/store-conformance.ts +192 -0
  69. package/src/thread-agent-validation.ts +141 -0
  70. package/src/thread-agent.test.ts +732 -0
  71. package/src/thread-agent.ts +1171 -0
  72. package/src/thread-review.test.ts +487 -0
  73. package/src/thread-review.ts +394 -0
  74. package/src/thread-subscription.test.ts +177 -0
  75. package/src/thread-subscription.ts +138 -0
  76. package/src/thread-surface-config.test.ts +63 -0
  77. package/src/thread-surface-config.ts +61 -0
  78. package/src/validate.test.ts +290 -37
  79. package/src/validate.ts +427 -33
  80. package/src/vcs-source.test.ts +180 -0
  81. package/src/vcs-source.ts +235 -0
  82. package/src/version.ts +6 -0
  83. package/src/working-tree.ts +154 -0
  84. package/src/review.test.ts +0 -167
  85. package/src/review.ts +0 -174
@@ -0,0 +1,104 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import * as v from "valibot";
3
+ import { ghosttyThreadSurfacesPath } from "./paths";
4
+
5
+ export interface GhosttyThreadSurfaceHandle {
6
+ terminalId: string;
7
+ }
8
+
9
+ interface GhosttyThreadSurfaceRecord {
10
+ terminalId?: string;
11
+ opening?: true;
12
+ }
13
+
14
+ const RecordSchema = v.object({
15
+ terminalId: v.optional(v.string()),
16
+ opening: v.optional(v.literal(true)),
17
+ });
18
+ const MapSchema = v.record(v.string(), v.unknown());
19
+
20
+ export class GhosttyThreadSurfaceStore {
21
+ private surfaces = new Map<string, GhosttyThreadSurfaceRecord>();
22
+ private readonly path: string;
23
+
24
+ constructor(home: string) {
25
+ this.path = ghosttyThreadSurfacesPath(home);
26
+ this.load();
27
+ }
28
+
29
+ private load(): void {
30
+ try {
31
+ const parsed = v.parse(MapSchema, JSON.parse(readFileSync(this.path, "utf8")));
32
+
33
+ for (const [threadId, value] of Object.entries(parsed)) {
34
+ const handle = v.safeParse(RecordSchema, value);
35
+
36
+ if (handle.success) {
37
+ const record = handle.output;
38
+
39
+ this.surfaces.set(
40
+ threadId,
41
+ record.opening && record.terminalId ? { terminalId: record.terminalId } : record,
42
+ );
43
+ }
44
+ }
45
+ } catch {
46
+ // Scratch corruption cannot change a pending Thread.
47
+ }
48
+ }
49
+
50
+ private persist(): void {
51
+ const tempPath = this.path + ".tmp";
52
+
53
+ writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.surfaces), null, 2));
54
+ renameSync(tempPath, this.path);
55
+ }
56
+
57
+ get(threadId: string): GhosttyThreadSurfaceHandle | null {
58
+ const terminalId = this.surfaces.get(threadId)?.terminalId;
59
+
60
+ return terminalId ? { terminalId } : null;
61
+ }
62
+
63
+ /** Persist a launch reservation before any AppleScript surface can be created. */
64
+ claim(threadId: string): boolean {
65
+ const previous = this.surfaces.get(threadId);
66
+
67
+ if (previous?.opening) return false;
68
+
69
+ this.surfaces.set(threadId, { ...previous, opening: true });
70
+
71
+ try {
72
+ this.persist();
73
+
74
+ return true;
75
+ } catch (error) {
76
+ if (previous) this.surfaces.set(threadId, previous);
77
+ else this.surfaces.delete(threadId);
78
+
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ release(threadId: string): void {
84
+ const record = this.surfaces.get(threadId);
85
+
86
+ if (!record?.opening) return;
87
+
88
+ if (record.terminalId) this.surfaces.set(threadId, { terminalId: record.terminalId });
89
+ else this.surfaces.delete(threadId);
90
+
91
+ this.persist();
92
+ }
93
+
94
+ set(threadId: string, handle: GhosttyThreadSurfaceHandle): void {
95
+ const opening = this.surfaces.get(threadId)?.opening;
96
+
97
+ this.surfaces.set(threadId, opening ? { ...handle, opening } : handle);
98
+ this.persist();
99
+ }
100
+
101
+ delete(threadId: string): void {
102
+ if (this.surfaces.delete(threadId)) this.persist();
103
+ }
104
+ }
@@ -0,0 +1,400 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Thread } from "@cueloop/schema";
6
+ import {
7
+ insideGhostty,
8
+ openGhosttyThreadSurface as openActualGhosttyThreadSurface,
9
+ resolveCueloopLaunchCommand,
10
+ supportsGhosttyAppleScript,
11
+ GHOSTTY_OPEN_APPLESCRIPT,
12
+ type GhosttyThreadSurfacePersistence,
13
+ } from "./ghostty-thread-surface";
14
+ import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
15
+
16
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-ghostty-thread-surface-"));
17
+ const openGhosttyThreadSurface: typeof openActualGhosttyThreadSurface = (
18
+ reviewed,
19
+ persistence,
20
+ env,
21
+ mode,
22
+ binPath,
23
+ platform,
24
+ ) =>
25
+ openActualGhosttyThreadSurface(
26
+ reviewed,
27
+ persistence,
28
+ env,
29
+ mode,
30
+ binPath,
31
+ platform,
32
+ "/usr/local/bin/cueloop",
33
+ );
34
+
35
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
36
+
37
+ function thread(): Thread {
38
+ return {
39
+ schemaVersion: "1",
40
+ id: "ses_ghostty1",
41
+ workspace: { repoRoot: "/repo", branch: "main" },
42
+ artifact: { type: "plan", content: "# Plan", meta: { cwd: "/repo/work" } },
43
+ revisions: [{ revision: 1, content: "# Plan", submittedAt: "now" }],
44
+ annotations: [],
45
+ message: null,
46
+ status: "pending",
47
+ createdAt: "now",
48
+ };
49
+ }
50
+
51
+ function persistence(initial: GhosttyThreadSurfaceHandle | null = null) {
52
+ let saved = initial;
53
+ let claimed = false;
54
+ const port: GhosttyThreadSurfacePersistence = {
55
+ ghosttyClaimThreadSurface: async () => {
56
+ if (claimed) return false;
57
+
58
+ claimed = true;
59
+
60
+ return true;
61
+ },
62
+ ghosttyReleaseThreadSurface: async () => {
63
+ claimed = false;
64
+ },
65
+ ghosttyGetThreadSurface: async () => saved,
66
+ ghosttySetThreadSurface: async (_id, handle) => {
67
+ saved = handle;
68
+ },
69
+ };
70
+
71
+ return { port, saved: () => saved };
72
+ }
73
+
74
+ function stub(
75
+ name: string,
76
+ focusResult = "focused",
77
+ version = "1.3.1",
78
+ openFails = false,
79
+ closeFails = false,
80
+ ) {
81
+ const binPath = join(dir, `${name}.sh`);
82
+ const logPath = join(dir, `${name}.log`);
83
+ const scriptPath = join(dir, `${name}.applescript`);
84
+ const shellPath = join(dir, `${name}.shell-command`);
85
+
86
+ writeFileSync(
87
+ binPath,
88
+ `#!/bin/sh
89
+ if [ "$2" = 'tell application "Ghostty" to get version' ]; then
90
+ printf 'version\\n' >> "${logPath}"
91
+ printf '${version}\\n'
92
+ exit 0
93
+ fi
94
+ printf '%s\\n' "$2" > "${scriptPath}"
95
+ case "$2" in
96
+ *"close candidate"*)
97
+ printf 'close:%s\\n' "$3" >> "${logPath}"
98
+ ${closeFails ? "exit 1" : ""}
99
+ printf 'closed\\n'
100
+ exit 0
101
+ ;;
102
+ esac
103
+ if [ "$3" = "ses_previous" ] || [ "$3" = "term-1" ]; then
104
+ printf 'focus:%s\\n' "$3" >> "${logPath}"
105
+ printf '${focusResult}\\n'
106
+ exit 0
107
+ fi
108
+ printf 'open:%s:%s\\n' "$3" "$4" >> "${logPath}"
109
+ printf '%s\\n' "$5" > "${shellPath}"
110
+ ${openFails ? "exit 1" : "printf 'term-1\\n'"}
111
+ `,
112
+ );
113
+ chmodSync(binPath, 0o755);
114
+
115
+ return { binPath, logPath, scriptPath, shellPath };
116
+ }
117
+
118
+ function log(path: string): string[] {
119
+ return readFileSync(path, "utf8").trim().split("\n");
120
+ }
121
+
122
+ describe("Ghostty AppleScript capability", () => {
123
+ test.skipIf(process.platform !== "darwin" || !existsSync("/Applications/Ghostty.app"))(
124
+ "open script compiles against the installed Ghostty dictionary",
125
+ () => {
126
+ const compiled = Bun.spawnSync(
127
+ ["osacompile", "-e", GHOSTTY_OPEN_APPLESCRIPT, "-o", join(dir, "ghostty-open.scpt")],
128
+ { stdout: "pipe", stderr: "pipe" },
129
+ );
130
+
131
+ expect(compiled.stderr.toString()).toBe("");
132
+ expect(compiled.exitCode).toBe(0);
133
+ },
134
+ );
135
+
136
+ test("requires the documented 1.3 API", () => {
137
+ expect(supportsGhosttyAppleScript("1.2.3")).toBe(false);
138
+ expect(supportsGhosttyAppleScript("1.3.0")).toBe(true);
139
+ expect(supportsGhosttyAppleScript("1.3.1")).toBe(true);
140
+ expect(supportsGhosttyAppleScript(null)).toBe(false);
141
+ });
142
+
143
+ test("detects Ghostty's own environment, not TERM alone", () => {
144
+ expect(insideGhostty({ TERM_PROGRAM: "ghostty" })).toBe(true);
145
+ expect(insideGhostty({ GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app" })).toBe(true);
146
+ expect(insideGhostty({ TERM_PROGRAM: "other" })).toBe(false);
147
+ });
148
+
149
+ test("source CLI invocation uses its absolute entry when no cueloop binary is installed", () => {
150
+ const entry = join(import.meta.dir, "../../cli/src/main.ts");
151
+
152
+ expect(resolveCueloopLaunchCommand(process.execPath, entry, "")).toBe(
153
+ `'${process.execPath}' run '${entry}'`,
154
+ );
155
+ expect(resolveCueloopLaunchCommand(process.execPath, "/other", "")).toBeNull();
156
+ });
157
+ });
158
+
159
+ describe("openGhosttyThreadSurface", () => {
160
+ test.each(["tab", "pane", "window"] as const)("opens a focused %s", async (mode) => {
161
+ const native = stub(`open-${mode}`);
162
+ const store = persistence();
163
+
164
+ expect(
165
+ await openGhosttyThreadSurface(
166
+ thread(),
167
+ store.port,
168
+ { TERM_PROGRAM: "ghostty" },
169
+ mode,
170
+ native.binPath,
171
+ "darwin",
172
+ ),
173
+ ).toBe("opened");
174
+ expect(store.saved()).toEqual({ terminalId: "term-1" });
175
+ expect(log(native.logPath)).toEqual(["version", `open:${mode}:/repo/work`]);
176
+ const script = readFileSync(native.scriptPath, "utf8");
177
+
178
+ expect(script).toContain(
179
+ "set initial working directory of surfaceConfiguration to threadDirectory",
180
+ );
181
+ expect(script).toContain("input text shellCommand to createdTerminal");
182
+ expect(script).toContain("set targetWindow to front window");
183
+ expect(script).toContain(
184
+ "split sourceTerminal direction right with configuration surfaceConfiguration",
185
+ );
186
+ expect(script).toContain('perform action "new_tab" on sourceTerminal');
187
+ expect(script).toContain("focus createdTerminal");
188
+ expect(readFileSync(native.shellPath, "utf8").trim()).toBe(
189
+ "cd -- '/repo/work' && '/usr/local/bin/cueloop' 'ses_ghostty1'",
190
+ );
191
+ });
192
+
193
+ test("focuses a live terminal without creating another", async () => {
194
+ const native = stub("focus");
195
+ const store = persistence({ terminalId: "ses_previous" });
196
+
197
+ expect(
198
+ await openGhosttyThreadSurface(
199
+ thread(),
200
+ store.port,
201
+ { TERM_PROGRAM: "ghostty" },
202
+ "tab",
203
+ native.binPath,
204
+ "darwin",
205
+ ),
206
+ ).toBe("focused");
207
+ expect(log(native.logPath)).toEqual(["version", "focus:ses_previous"]);
208
+ });
209
+
210
+ test("focuses a live terminal even if cueloop is no longer on PATH", async () => {
211
+ const native = stub("focus-no-binary");
212
+ const store = persistence({ terminalId: "ses_previous" });
213
+
214
+ expect(
215
+ await openActualGhosttyThreadSurface(
216
+ thread(),
217
+ store.port,
218
+ { TERM_PROGRAM: "ghostty" },
219
+ "tab",
220
+ native.binPath,
221
+ "darwin",
222
+ null,
223
+ ),
224
+ ).toBe("focused");
225
+ expect(log(native.logPath)).toEqual(["version", "focus:ses_previous"]);
226
+ });
227
+
228
+ test("quotes a workspace path before entering it in a Ghostty shell", async () => {
229
+ const native = stub("quoted-cwd");
230
+ const reviewed = thread();
231
+
232
+ reviewed.artifact.meta.cwd = "/repo/it's $(unsafe)";
233
+ expect(
234
+ await openGhosttyThreadSurface(
235
+ reviewed,
236
+ persistence().port,
237
+ { TERM_PROGRAM: "ghostty" },
238
+ "tab",
239
+ native.binPath,
240
+ "darwin",
241
+ ),
242
+ ).toBe("opened");
243
+ expect(readFileSync(native.shellPath, "utf8").trim()).toBe(
244
+ "cd -- '/repo/it'\\''s $(unsafe)' && '/usr/local/bin/cueloop' 'ses_ghostty1'",
245
+ );
246
+ });
247
+
248
+ test("reopens a closed terminal and replaces its handle", async () => {
249
+ const native = stub("closed", "closed");
250
+ const store = persistence({ terminalId: "ses_previous" });
251
+
252
+ expect(
253
+ await openGhosttyThreadSurface(
254
+ thread(),
255
+ store.port,
256
+ { TERM_PROGRAM: "ghostty" },
257
+ "pane",
258
+ native.binPath,
259
+ "darwin",
260
+ ),
261
+ ).toBe("opened");
262
+ expect(store.saved()).toEqual({ terminalId: "term-1" });
263
+ expect(log(native.logPath)).toEqual(["version", "focus:ses_previous", "open:pane:/repo/work"]);
264
+ });
265
+
266
+ test("closes an untracked terminal when daemon persistence fails", async () => {
267
+ const native = stub("persist-failed");
268
+ const store: GhosttyThreadSurfacePersistence = {
269
+ ghosttyClaimThreadSurface: async () => true,
270
+ ghosttyReleaseThreadSurface: async () => {},
271
+ ghosttyGetThreadSurface: async () => null,
272
+ ghosttySetThreadSurface: async () => {
273
+ throw new Error("daemon unavailable");
274
+ },
275
+ };
276
+
277
+ expect(
278
+ await openGhosttyThreadSurface(
279
+ thread(),
280
+ store,
281
+ { TERM_PROGRAM: "ghostty" },
282
+ "tab",
283
+ native.binPath,
284
+ "darwin",
285
+ ),
286
+ ).toBe("failed");
287
+ expect(log(native.logPath)).toEqual(["version", "open:tab:/repo/work", "close:term-1"]);
288
+ });
289
+
290
+ test("does not open another tab when native identity lookup fails", async () => {
291
+ const native = stub("lookup-failed");
292
+ const store: GhosttyThreadSurfacePersistence = {
293
+ ghosttyClaimThreadSurface: async () => true,
294
+ ghosttyReleaseThreadSurface: async () => {},
295
+ ghosttyGetThreadSurface: async () => {
296
+ throw new Error("daemon unavailable");
297
+ },
298
+ ghosttySetThreadSurface: async () => {},
299
+ };
300
+
301
+ expect(
302
+ await openGhosttyThreadSurface(
303
+ thread(),
304
+ store,
305
+ { TERM_PROGRAM: "ghostty" },
306
+ "tab",
307
+ native.binPath,
308
+ "darwin",
309
+ ),
310
+ ).toBe("failed");
311
+ expect(log(native.logPath)).toEqual(["version"]);
312
+ });
313
+
314
+ test("retains the claim if an untracked native terminal cannot be closed", async () => {
315
+ const native = stub("close-failed", "focused", "1.3.1", false, true);
316
+ let claimed = false;
317
+ const store: GhosttyThreadSurfacePersistence = {
318
+ ghosttyClaimThreadSurface: async () => {
319
+ if (claimed) return false;
320
+
321
+ claimed = true;
322
+
323
+ return true;
324
+ },
325
+ ghosttyReleaseThreadSurface: async () => {
326
+ claimed = false;
327
+ },
328
+ ghosttyGetThreadSurface: async () => null,
329
+ ghosttySetThreadSurface: async () => {
330
+ throw new Error("daemon write failed");
331
+ },
332
+ };
333
+ const launch = () =>
334
+ openGhosttyThreadSurface(
335
+ thread(),
336
+ store,
337
+ { TERM_PROGRAM: "ghostty" },
338
+ "tab",
339
+ native.binPath,
340
+ "darwin",
341
+ );
342
+
343
+ expect(await launch()).toBe("failed");
344
+ expect(await launch()).toBe("failed");
345
+ expect(log(native.logPath).filter((line) => line.startsWith("open:"))).toEqual([
346
+ "open:tab:/repo/work",
347
+ ]);
348
+ });
349
+
350
+ test("concurrent callers cannot open duplicate live surfaces", async () => {
351
+ const native = stub("concurrent");
352
+ const store = persistence();
353
+ const launch = () =>
354
+ openGhosttyThreadSurface(
355
+ thread(),
356
+ store.port,
357
+ { TERM_PROGRAM: "ghostty" },
358
+ "tab",
359
+ native.binPath,
360
+ "darwin",
361
+ );
362
+
363
+ expect((await Promise.all([launch(), launch()])).toSorted()).toEqual(["failed", "opened"]);
364
+ expect(log(native.logPath).filter((line) => line.startsWith("open:"))).toEqual([
365
+ "open:tab:/repo/work",
366
+ ]);
367
+ });
368
+
369
+ test("missing capability, denied automation, and disabled placement never resolve a Thread", async () => {
370
+ const old = stub("old", "focused", "1.2.3");
371
+ const denied = stub("denied", "focused", "1.3.1", true);
372
+ const store = persistence();
373
+ const env = { TERM_PROGRAM: "ghostty" };
374
+
375
+ expect(
376
+ await openGhosttyThreadSurface(thread(), store.port, env, "tab", old.binPath, "darwin"),
377
+ ).toBe("failed");
378
+ expect(
379
+ await openGhosttyThreadSurface(thread(), store.port, env, "tab", denied.binPath, "darwin"),
380
+ ).toBe("failed");
381
+ expect(
382
+ await openGhosttyThreadSurface(thread(), store.port, env, "none", denied.binPath, "darwin"),
383
+ ).toBe("disabled");
384
+ expect(
385
+ await openGhosttyThreadSurface(thread(), store.port, {}, "tab", denied.binPath, "darwin"),
386
+ ).toBe("unavailable");
387
+ expect(
388
+ await openActualGhosttyThreadSurface(
389
+ thread(),
390
+ store.port,
391
+ env,
392
+ "tab",
393
+ denied.binPath,
394
+ "darwin",
395
+ null,
396
+ ),
397
+ ).toBe("failed");
398
+ expect(store.saved()).toBeNull();
399
+ });
400
+ });