@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.90
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.
- package/package.json +39 -22
- package/src/api.test.ts +1060 -65
- package/src/api.ts +1398 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +89 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +669 -86
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.ts +293 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +268 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +453 -42
- package/src/server.ts +433 -88
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +398 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +282 -37
- package/src/validate.ts +398 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
|
@@ -0,0 +1,398 @@
|
|
|
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
|
+
claimed = true;
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
ghosttyReleaseThreadSurface: async () => {
|
|
62
|
+
claimed = false;
|
|
63
|
+
},
|
|
64
|
+
ghosttyGetThreadSurface: async () => saved,
|
|
65
|
+
ghosttySetThreadSurface: async (_id, handle) => {
|
|
66
|
+
saved = handle;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return { port, saved: () => saved };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stub(
|
|
74
|
+
name: string,
|
|
75
|
+
focusResult = "focused",
|
|
76
|
+
version = "1.3.1",
|
|
77
|
+
openFails = false,
|
|
78
|
+
closeFails = false,
|
|
79
|
+
) {
|
|
80
|
+
const binPath = join(dir, `${name}.sh`);
|
|
81
|
+
const logPath = join(dir, `${name}.log`);
|
|
82
|
+
const scriptPath = join(dir, `${name}.applescript`);
|
|
83
|
+
const shellPath = join(dir, `${name}.shell-command`);
|
|
84
|
+
|
|
85
|
+
writeFileSync(
|
|
86
|
+
binPath,
|
|
87
|
+
`#!/bin/sh
|
|
88
|
+
if [ "$2" = 'tell application "Ghostty" to get version' ]; then
|
|
89
|
+
printf 'version\\n' >> "${logPath}"
|
|
90
|
+
printf '${version}\\n'
|
|
91
|
+
exit 0
|
|
92
|
+
fi
|
|
93
|
+
printf '%s\\n' "$2" > "${scriptPath}"
|
|
94
|
+
case "$2" in
|
|
95
|
+
*"close candidate"*)
|
|
96
|
+
printf 'close:%s\\n' "$3" >> "${logPath}"
|
|
97
|
+
${closeFails ? "exit 1" : ""}
|
|
98
|
+
printf 'closed\\n'
|
|
99
|
+
exit 0
|
|
100
|
+
;;
|
|
101
|
+
esac
|
|
102
|
+
if [ "$3" = "ses_previous" ] || [ "$3" = "term-1" ]; then
|
|
103
|
+
printf 'focus:%s\\n' "$3" >> "${logPath}"
|
|
104
|
+
printf '${focusResult}\\n'
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
107
|
+
printf 'open:%s:%s\\n' "$3" "$4" >> "${logPath}"
|
|
108
|
+
printf '%s\\n' "$5" > "${shellPath}"
|
|
109
|
+
${openFails ? "exit 1" : "printf 'term-1\\n'"}
|
|
110
|
+
`,
|
|
111
|
+
);
|
|
112
|
+
chmodSync(binPath, 0o755);
|
|
113
|
+
|
|
114
|
+
return { binPath, logPath, scriptPath, shellPath };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function log(path: string): string[] {
|
|
118
|
+
return readFileSync(path, "utf8").trim().split("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("Ghostty AppleScript capability", () => {
|
|
122
|
+
test.skipIf(process.platform !== "darwin" || !existsSync("/Applications/Ghostty.app"))(
|
|
123
|
+
"open script compiles against the installed Ghostty dictionary",
|
|
124
|
+
() => {
|
|
125
|
+
const compiled = Bun.spawnSync(
|
|
126
|
+
["osacompile", "-e", GHOSTTY_OPEN_APPLESCRIPT, "-o", join(dir, "ghostty-open.scpt")],
|
|
127
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(compiled.stderr.toString()).toBe("");
|
|
131
|
+
expect(compiled.exitCode).toBe(0);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
test("requires the documented 1.3 API", () => {
|
|
136
|
+
expect(supportsGhosttyAppleScript("1.2.3")).toBe(false);
|
|
137
|
+
expect(supportsGhosttyAppleScript("1.3.0")).toBe(true);
|
|
138
|
+
expect(supportsGhosttyAppleScript("1.3.1")).toBe(true);
|
|
139
|
+
expect(supportsGhosttyAppleScript(null)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("detects Ghostty's own environment, not TERM alone", () => {
|
|
143
|
+
expect(insideGhostty({ TERM_PROGRAM: "ghostty" })).toBe(true);
|
|
144
|
+
expect(insideGhostty({ GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app" })).toBe(true);
|
|
145
|
+
expect(insideGhostty({ TERM_PROGRAM: "other" })).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("source CLI invocation uses its absolute entry when no cueloop binary is installed", () => {
|
|
149
|
+
const entry = join(import.meta.dir, "../../cli/src/main.ts");
|
|
150
|
+
|
|
151
|
+
expect(resolveCueloopLaunchCommand(process.execPath, entry, "")).toBe(
|
|
152
|
+
`'${process.execPath}' run '${entry}'`,
|
|
153
|
+
);
|
|
154
|
+
expect(resolveCueloopLaunchCommand(process.execPath, "/other", "")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("openGhosttyThreadSurface", () => {
|
|
159
|
+
test.each(["tab", "pane", "window"] as const)("opens a focused %s", async (mode) => {
|
|
160
|
+
const native = stub(`open-${mode}`);
|
|
161
|
+
const store = persistence();
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
await openGhosttyThreadSurface(
|
|
165
|
+
thread(),
|
|
166
|
+
store.port,
|
|
167
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
168
|
+
mode,
|
|
169
|
+
native.binPath,
|
|
170
|
+
"darwin",
|
|
171
|
+
),
|
|
172
|
+
).toBe("opened");
|
|
173
|
+
expect(store.saved()).toEqual({ terminalId: "term-1" });
|
|
174
|
+
expect(log(native.logPath)).toEqual(["version", `open:${mode}:/repo/work`]);
|
|
175
|
+
const script = readFileSync(native.scriptPath, "utf8");
|
|
176
|
+
|
|
177
|
+
expect(script).toContain(
|
|
178
|
+
"set initial working directory of surfaceConfiguration to threadDirectory",
|
|
179
|
+
);
|
|
180
|
+
expect(script).toContain("input text shellCommand to createdTerminal");
|
|
181
|
+
expect(script).toContain("set targetWindow to front window");
|
|
182
|
+
expect(script).toContain(
|
|
183
|
+
"split sourceTerminal direction right with configuration surfaceConfiguration",
|
|
184
|
+
);
|
|
185
|
+
expect(script).toContain('perform action "new_tab" on sourceTerminal');
|
|
186
|
+
expect(script).toContain("focus createdTerminal");
|
|
187
|
+
expect(readFileSync(native.shellPath, "utf8").trim()).toBe(
|
|
188
|
+
"cd -- '/repo/work' && '/usr/local/bin/cueloop' 'ses_ghostty1'",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("focuses a live terminal without creating another", async () => {
|
|
193
|
+
const native = stub("focus");
|
|
194
|
+
const store = persistence({ terminalId: "ses_previous" });
|
|
195
|
+
|
|
196
|
+
expect(
|
|
197
|
+
await openGhosttyThreadSurface(
|
|
198
|
+
thread(),
|
|
199
|
+
store.port,
|
|
200
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
201
|
+
"tab",
|
|
202
|
+
native.binPath,
|
|
203
|
+
"darwin",
|
|
204
|
+
),
|
|
205
|
+
).toBe("focused");
|
|
206
|
+
expect(log(native.logPath)).toEqual(["version", "focus:ses_previous"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("focuses a live terminal even if cueloop is no longer on PATH", async () => {
|
|
210
|
+
const native = stub("focus-no-binary");
|
|
211
|
+
const store = persistence({ terminalId: "ses_previous" });
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
await openActualGhosttyThreadSurface(
|
|
215
|
+
thread(),
|
|
216
|
+
store.port,
|
|
217
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
218
|
+
"tab",
|
|
219
|
+
native.binPath,
|
|
220
|
+
"darwin",
|
|
221
|
+
null,
|
|
222
|
+
),
|
|
223
|
+
).toBe("focused");
|
|
224
|
+
expect(log(native.logPath)).toEqual(["version", "focus:ses_previous"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("quotes a workspace path before entering it in a Ghostty shell", async () => {
|
|
228
|
+
const native = stub("quoted-cwd");
|
|
229
|
+
const reviewed = thread();
|
|
230
|
+
|
|
231
|
+
reviewed.artifact.meta.cwd = "/repo/it's $(unsafe)";
|
|
232
|
+
expect(
|
|
233
|
+
await openGhosttyThreadSurface(
|
|
234
|
+
reviewed,
|
|
235
|
+
persistence().port,
|
|
236
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
237
|
+
"tab",
|
|
238
|
+
native.binPath,
|
|
239
|
+
"darwin",
|
|
240
|
+
),
|
|
241
|
+
).toBe("opened");
|
|
242
|
+
expect(readFileSync(native.shellPath, "utf8").trim()).toBe(
|
|
243
|
+
"cd -- '/repo/it'\\''s $(unsafe)' && '/usr/local/bin/cueloop' 'ses_ghostty1'",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("reopens a closed terminal and replaces its handle", async () => {
|
|
248
|
+
const native = stub("closed", "closed");
|
|
249
|
+
const store = persistence({ terminalId: "ses_previous" });
|
|
250
|
+
|
|
251
|
+
expect(
|
|
252
|
+
await openGhosttyThreadSurface(
|
|
253
|
+
thread(),
|
|
254
|
+
store.port,
|
|
255
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
256
|
+
"pane",
|
|
257
|
+
native.binPath,
|
|
258
|
+
"darwin",
|
|
259
|
+
),
|
|
260
|
+
).toBe("opened");
|
|
261
|
+
expect(store.saved()).toEqual({ terminalId: "term-1" });
|
|
262
|
+
expect(log(native.logPath)).toEqual(["version", "focus:ses_previous", "open:pane:/repo/work"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("closes an untracked terminal when daemon persistence fails", async () => {
|
|
266
|
+
const native = stub("persist-failed");
|
|
267
|
+
const store: GhosttyThreadSurfacePersistence = {
|
|
268
|
+
ghosttyClaimThreadSurface: async () => true,
|
|
269
|
+
ghosttyReleaseThreadSurface: async () => {},
|
|
270
|
+
ghosttyGetThreadSurface: async () => null,
|
|
271
|
+
ghosttySetThreadSurface: async () => {
|
|
272
|
+
throw new Error("daemon unavailable");
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
expect(
|
|
277
|
+
await openGhosttyThreadSurface(
|
|
278
|
+
thread(),
|
|
279
|
+
store,
|
|
280
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
281
|
+
"tab",
|
|
282
|
+
native.binPath,
|
|
283
|
+
"darwin",
|
|
284
|
+
),
|
|
285
|
+
).toBe("failed");
|
|
286
|
+
expect(log(native.logPath)).toEqual(["version", "open:tab:/repo/work", "close:term-1"]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("does not open another tab when native identity lookup fails", async () => {
|
|
290
|
+
const native = stub("lookup-failed");
|
|
291
|
+
const store: GhosttyThreadSurfacePersistence = {
|
|
292
|
+
ghosttyClaimThreadSurface: async () => true,
|
|
293
|
+
ghosttyReleaseThreadSurface: async () => {},
|
|
294
|
+
ghosttyGetThreadSurface: async () => {
|
|
295
|
+
throw new Error("daemon unavailable");
|
|
296
|
+
},
|
|
297
|
+
ghosttySetThreadSurface: async () => {},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
expect(
|
|
301
|
+
await openGhosttyThreadSurface(
|
|
302
|
+
thread(),
|
|
303
|
+
store,
|
|
304
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
305
|
+
"tab",
|
|
306
|
+
native.binPath,
|
|
307
|
+
"darwin",
|
|
308
|
+
),
|
|
309
|
+
).toBe("failed");
|
|
310
|
+
expect(log(native.logPath)).toEqual(["version"]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("retains the claim if an untracked native terminal cannot be closed", async () => {
|
|
314
|
+
const native = stub("close-failed", "focused", "1.3.1", false, true);
|
|
315
|
+
let claimed = false;
|
|
316
|
+
const store: GhosttyThreadSurfacePersistence = {
|
|
317
|
+
ghosttyClaimThreadSurface: async () => {
|
|
318
|
+
if (claimed) return false;
|
|
319
|
+
claimed = true;
|
|
320
|
+
|
|
321
|
+
return true;
|
|
322
|
+
},
|
|
323
|
+
ghosttyReleaseThreadSurface: async () => {
|
|
324
|
+
claimed = false;
|
|
325
|
+
},
|
|
326
|
+
ghosttyGetThreadSurface: async () => null,
|
|
327
|
+
ghosttySetThreadSurface: async () => {
|
|
328
|
+
throw new Error("daemon write failed");
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
const launch = () =>
|
|
332
|
+
openGhosttyThreadSurface(
|
|
333
|
+
thread(),
|
|
334
|
+
store,
|
|
335
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
336
|
+
"tab",
|
|
337
|
+
native.binPath,
|
|
338
|
+
"darwin",
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
expect(await launch()).toBe("failed");
|
|
342
|
+
expect(await launch()).toBe("failed");
|
|
343
|
+
expect(log(native.logPath).filter((line) => line.startsWith("open:"))).toEqual([
|
|
344
|
+
"open:tab:/repo/work",
|
|
345
|
+
]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("concurrent callers cannot open duplicate live surfaces", async () => {
|
|
349
|
+
const native = stub("concurrent");
|
|
350
|
+
const store = persistence();
|
|
351
|
+
const launch = () =>
|
|
352
|
+
openGhosttyThreadSurface(
|
|
353
|
+
thread(),
|
|
354
|
+
store.port,
|
|
355
|
+
{ TERM_PROGRAM: "ghostty" },
|
|
356
|
+
"tab",
|
|
357
|
+
native.binPath,
|
|
358
|
+
"darwin",
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect((await Promise.all([launch(), launch()])).toSorted()).toEqual(["failed", "opened"]);
|
|
362
|
+
expect(log(native.logPath).filter((line) => line.startsWith("open:"))).toEqual([
|
|
363
|
+
"open:tab:/repo/work",
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("missing capability, denied automation, and disabled placement never resolve a Thread", async () => {
|
|
368
|
+
const old = stub("old", "focused", "1.2.3");
|
|
369
|
+
const denied = stub("denied", "focused", "1.3.1", true);
|
|
370
|
+
const store = persistence();
|
|
371
|
+
const env = { TERM_PROGRAM: "ghostty" };
|
|
372
|
+
|
|
373
|
+
expect(
|
|
374
|
+
await openGhosttyThreadSurface(thread(), store.port, env, "tab", old.binPath, "darwin"),
|
|
375
|
+
).toBe("failed");
|
|
376
|
+
expect(
|
|
377
|
+
await openGhosttyThreadSurface(thread(), store.port, env, "tab", denied.binPath, "darwin"),
|
|
378
|
+
).toBe("failed");
|
|
379
|
+
expect(
|
|
380
|
+
await openGhosttyThreadSurface(thread(), store.port, env, "none", denied.binPath, "darwin"),
|
|
381
|
+
).toBe("disabled");
|
|
382
|
+
expect(
|
|
383
|
+
await openGhosttyThreadSurface(thread(), store.port, {}, "tab", denied.binPath, "darwin"),
|
|
384
|
+
).toBe("unavailable");
|
|
385
|
+
expect(
|
|
386
|
+
await openActualGhosttyThreadSurface(
|
|
387
|
+
thread(),
|
|
388
|
+
store.port,
|
|
389
|
+
env,
|
|
390
|
+
"tab",
|
|
391
|
+
denied.binPath,
|
|
392
|
+
"darwin",
|
|
393
|
+
null,
|
|
394
|
+
),
|
|
395
|
+
).toBe("failed");
|
|
396
|
+
expect(store.saved()).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, delimiter, isAbsolute, join } from "node:path";
|
|
4
|
+
import type { Thread, ThreadSurfaceOpenStatus } from "@cueloop/schema";
|
|
5
|
+
import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
|
|
6
|
+
import { loadGhosttyThreadSurface, type GhosttyThreadSurface } from "./thread-surface-config";
|
|
7
|
+
|
|
8
|
+
const APPLESCRIPT_TIMEOUT_MS = 10000;
|
|
9
|
+
const VERSION_SCRIPT = 'tell application "Ghostty" to get version';
|
|
10
|
+
export const GHOSTTY_OPEN_APPLESCRIPT = `on run argv
|
|
11
|
+
set placement to item 1 of argv
|
|
12
|
+
set threadDirectory to item 2 of argv
|
|
13
|
+
set shellCommand to item 3 of argv
|
|
14
|
+
tell application "Ghostty"
|
|
15
|
+
if placement is "tab" then
|
|
16
|
+
set targetWindow to front window
|
|
17
|
+
set beforeCount to count of tabs of targetWindow
|
|
18
|
+
set beforeIds to id of every tab of targetWindow
|
|
19
|
+
set sourceTerminal to focused terminal of selected tab of targetWindow
|
|
20
|
+
if not (perform action "new_tab" on sourceTerminal) then error "Ghostty rejected new_tab"
|
|
21
|
+
repeat 50 times
|
|
22
|
+
if (count of tabs of targetWindow) > beforeCount then exit repeat
|
|
23
|
+
delay 0.1
|
|
24
|
+
end repeat
|
|
25
|
+
if (count of tabs of targetWindow) <= beforeCount then error "Ghostty did not create a tab"
|
|
26
|
+
set newTabs to {}
|
|
27
|
+
repeat with candidateTab in tabs of targetWindow
|
|
28
|
+
if (id of candidateTab) is not in beforeIds then set end of newTabs to candidateTab
|
|
29
|
+
end repeat
|
|
30
|
+
if (count of newTabs) is not 1 then error "Ghostty tab identity is ambiguous"
|
|
31
|
+
set createdTab to item 1 of newTabs
|
|
32
|
+
set createdTerminal to focused terminal of createdTab
|
|
33
|
+
else
|
|
34
|
+
set surfaceConfiguration to new surface configuration
|
|
35
|
+
set initial working directory of surfaceConfiguration to threadDirectory
|
|
36
|
+
if placement is "window" then
|
|
37
|
+
set createdWindow to new window with configuration surfaceConfiguration
|
|
38
|
+
set createdTerminal to focused terminal of selected tab of createdWindow
|
|
39
|
+
else
|
|
40
|
+
set targetWindow to front window
|
|
41
|
+
set sourceTerminal to focused terminal of selected tab of targetWindow
|
|
42
|
+
set createdTerminal to split sourceTerminal direction right with configuration surfaceConfiguration
|
|
43
|
+
end if
|
|
44
|
+
end if
|
|
45
|
+
try
|
|
46
|
+
repeat 50 times
|
|
47
|
+
if (name of createdTerminal) is not "👻" and (name of createdTerminal) is not "" then exit repeat
|
|
48
|
+
delay 0.1
|
|
49
|
+
end repeat
|
|
50
|
+
if (name of createdTerminal) is "👻" or (name of createdTerminal) is "" then error "Ghostty surface has no shell"
|
|
51
|
+
input text shellCommand to createdTerminal
|
|
52
|
+
send key "enter" to createdTerminal
|
|
53
|
+
focus createdTerminal
|
|
54
|
+
return id of createdTerminal
|
|
55
|
+
on error
|
|
56
|
+
if placement is "tab" then
|
|
57
|
+
close tab createdTab
|
|
58
|
+
else if placement is "window" then
|
|
59
|
+
close window createdWindow
|
|
60
|
+
else
|
|
61
|
+
close createdTerminal
|
|
62
|
+
end if
|
|
63
|
+
error "Ghostty surface did not launch the Thread"
|
|
64
|
+
end try
|
|
65
|
+
end tell
|
|
66
|
+
end run`;
|
|
67
|
+
const FOCUS_SCRIPT = `on run argv
|
|
68
|
+
set targetId to item 1 of argv
|
|
69
|
+
tell application "Ghostty"
|
|
70
|
+
repeat with candidate in terminals
|
|
71
|
+
if (id of candidate) is targetId then
|
|
72
|
+
focus candidate
|
|
73
|
+
return "focused"
|
|
74
|
+
end if
|
|
75
|
+
end repeat
|
|
76
|
+
end tell
|
|
77
|
+
return "closed"
|
|
78
|
+
end run`;
|
|
79
|
+
const CLOSE_SCRIPT = `on run argv
|
|
80
|
+
set targetId to item 1 of argv
|
|
81
|
+
tell application "Ghostty"
|
|
82
|
+
repeat with candidate in terminals
|
|
83
|
+
if (id of candidate) is targetId then
|
|
84
|
+
close candidate
|
|
85
|
+
return "closed"
|
|
86
|
+
end if
|
|
87
|
+
end repeat
|
|
88
|
+
end tell
|
|
89
|
+
return "absent"
|
|
90
|
+
end run`;
|
|
91
|
+
|
|
92
|
+
export interface GhosttyThreadSurfacePersistence {
|
|
93
|
+
ghosttyClaimThreadSurface(threadId: string): Promise<boolean>;
|
|
94
|
+
ghosttyReleaseThreadSurface(threadId: string): Promise<void>;
|
|
95
|
+
ghosttyGetThreadSurface(threadId: string): Promise<GhosttyThreadSurfaceHandle | null>;
|
|
96
|
+
ghosttySetThreadSurface(threadId: string, handle: GhosttyThreadSurfaceHandle): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface GhosttyEnv {
|
|
100
|
+
[name: string]: string | undefined;
|
|
101
|
+
TERM_PROGRAM?: string;
|
|
102
|
+
GHOSTTY_RESOURCES_DIR?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface GhosttyLaunchResult {
|
|
106
|
+
status: ThreadSurfaceOpenStatus;
|
|
107
|
+
retainClaim: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function runAppleScript(binPath: string, script: string, args: string[] = []): string | null {
|
|
111
|
+
try {
|
|
112
|
+
const result = spawnSync(binPath, ["-e", script, ...args], {
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
115
|
+
timeout: APPLESCRIPT_TIMEOUT_MS,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveCueloopLaunchCommand(
|
|
125
|
+
executable = process.execPath,
|
|
126
|
+
entry = process.argv[1],
|
|
127
|
+
path = process.env.PATH,
|
|
128
|
+
): string | null {
|
|
129
|
+
if (basename(executable) === "cueloop") return shellQuote(executable);
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
entry &&
|
|
133
|
+
isAbsolute(entry) &&
|
|
134
|
+
entry.endsWith(join("packages", "cli", "src", "main.ts")) &&
|
|
135
|
+
existsSync(entry)
|
|
136
|
+
) {
|
|
137
|
+
return `${shellQuote(executable)} run ${shellQuote(entry)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const directory of path?.split(delimiter) ?? []) {
|
|
141
|
+
if (!isAbsolute(directory)) continue;
|
|
142
|
+
const candidate = join(directory, "cueloop");
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
accessSync(candidate, constants.X_OK);
|
|
146
|
+
if (statSync(candidate).isFile()) return shellQuote(candidate);
|
|
147
|
+
} catch {
|
|
148
|
+
// A PATH entry without an executable cannot launch the Thread.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function shellQuote(value: string): string {
|
|
156
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Ghostty 1.3.0 introduced the native AppleScript commands used here. */
|
|
160
|
+
export function supportsGhosttyAppleScript(version: string | null): boolean {
|
|
161
|
+
const parsed = version?.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
162
|
+
|
|
163
|
+
if (!parsed) return false;
|
|
164
|
+
const major = Number(parsed[1]);
|
|
165
|
+
const minor = Number(parsed[2]);
|
|
166
|
+
|
|
167
|
+
return major > 1 || (major === 1 && minor >= 3);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Herdr selection happens before this adapter, even when it runs inside Ghostty. */
|
|
171
|
+
export function insideGhostty(env: GhosttyEnv = process.env): boolean {
|
|
172
|
+
return env.TERM_PROGRAM?.toLowerCase() === "ghostty" || Boolean(env.GHOSTTY_RESOURCES_DIR);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Reuse a live Ghostty terminal, reopen a closed one, or leave the Thread pending. */
|
|
176
|
+
export async function openGhosttyThreadSurface(
|
|
177
|
+
thread: Thread,
|
|
178
|
+
persistence: GhosttyThreadSurfacePersistence,
|
|
179
|
+
env: GhosttyEnv = process.env,
|
|
180
|
+
mode: GhosttyThreadSurface = loadGhosttyThreadSurface(),
|
|
181
|
+
binPath = "osascript",
|
|
182
|
+
platform: NodeJS.Platform = process.platform,
|
|
183
|
+
cueloopBinPath?: string | null,
|
|
184
|
+
): Promise<ThreadSurfaceOpenStatus> {
|
|
185
|
+
if (platform !== "darwin" || !insideGhostty(env)) return "unavailable";
|
|
186
|
+
if (mode === "none") return "disabled";
|
|
187
|
+
if (!supportsGhosttyAppleScript(runAppleScript(binPath, VERSION_SCRIPT))) return "failed";
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
if (!(await persistence.ghosttyClaimThreadSurface(thread.id))) return "failed";
|
|
191
|
+
} catch {
|
|
192
|
+
return "failed";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let result: GhosttyLaunchResult;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
result = await openClaimedGhosttyThreadSurface(
|
|
199
|
+
thread,
|
|
200
|
+
persistence,
|
|
201
|
+
mode,
|
|
202
|
+
binPath,
|
|
203
|
+
cueloopBinPath,
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
result = { status: "failed", retainClaim: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (result.retainClaim) return "failed";
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await persistence.ghosttyReleaseThreadSurface(thread.id);
|
|
213
|
+
} catch {
|
|
214
|
+
return "failed";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return result.status;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function openClaimedGhosttyThreadSurface(
|
|
221
|
+
thread: Thread,
|
|
222
|
+
persistence: GhosttyThreadSurfacePersistence,
|
|
223
|
+
mode: Exclude<GhosttyThreadSurface, "none">,
|
|
224
|
+
binPath: string,
|
|
225
|
+
cueloopBinPath?: string | null,
|
|
226
|
+
): Promise<GhosttyLaunchResult> {
|
|
227
|
+
let recorded: GhosttyThreadSurfaceHandle | null;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
recorded = await persistence.ghosttyGetThreadSurface(thread.id);
|
|
231
|
+
} catch {
|
|
232
|
+
return { status: "failed", retainClaim: false };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (recorded) {
|
|
236
|
+
const result = runAppleScript(binPath, FOCUS_SCRIPT, [recorded.terminalId]);
|
|
237
|
+
|
|
238
|
+
if (result === "focused") return { status: "focused", retainClaim: false };
|
|
239
|
+
if (result !== "closed") return { status: "failed", retainClaim: false };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const launchCommand =
|
|
243
|
+
cueloopBinPath === undefined
|
|
244
|
+
? resolveCueloopLaunchCommand()
|
|
245
|
+
: cueloopBinPath && shellQuote(cueloopBinPath);
|
|
246
|
+
|
|
247
|
+
if (!launchCommand) return { status: "failed", retainClaim: false };
|
|
248
|
+
|
|
249
|
+
const cwd = thread.artifact.meta.cwd ?? thread.workspace.repoRoot;
|
|
250
|
+
const terminalId = runAppleScript(binPath, GHOSTTY_OPEN_APPLESCRIPT, [
|
|
251
|
+
mode,
|
|
252
|
+
cwd,
|
|
253
|
+
`cd -- ${shellQuote(cwd)} && ${launchCommand} ${shellQuote(thread.id)}`,
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
if (!terminalId) return { status: "failed", retainClaim: false };
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await persistence.ghosttySetThreadSurface(thread.id, { terminalId });
|
|
260
|
+
} catch {
|
|
261
|
+
const closed = runAppleScript(binPath, CLOSE_SCRIPT, [terminalId]);
|
|
262
|
+
|
|
263
|
+
return { status: "failed", retainClaim: closed !== "closed" && closed !== "absent" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { status: "opened", retainClaim: false };
|
|
267
|
+
}
|