@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.
- package/package.json +6 -3
- package/src/api.test.ts +15 -0
- package/src/api.ts +45 -10
- package/src/capabilities.ts +7 -2
- package/src/client.ts +170 -43
- 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 +15 -0
- package/src/harness-state-store.ts +6 -7
- package/src/{herdr-tab-store.test.ts → herdr-thread-surface-store.test.ts} +16 -31
- 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/paths.ts +7 -2
- package/src/server.test.ts +2 -0
- package/src/server.ts +67 -11
- package/src/single-instance.test.ts +90 -2
- package/src/thread-review.ts +4 -14
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.ts +13 -3
- package/src/herdr-pane.test.ts +0 -240
- package/src/herdr-pane.ts +0 -167
- package/src/herdr-tab-store.ts +0 -66
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.81",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"./validate": "./src/validate.ts",
|
|
28
28
|
"./paths": "./src/paths.ts",
|
|
29
29
|
"./retention": "./src/retention.ts",
|
|
30
|
-
"./herdr-
|
|
30
|
+
"./herdr-thread-surface": "./src/herdr-thread-surface.ts",
|
|
31
|
+
"./ghostty-thread-surface": "./src/ghostty-thread-surface.ts",
|
|
32
|
+
"./thread-surface-config": "./src/thread-surface-config.ts",
|
|
31
33
|
"./herdr-split": "./src/herdr-split.ts",
|
|
32
34
|
"./share-blob": "./src/share-blob.ts",
|
|
33
35
|
"./curate": "./src/curate.ts"
|
|
@@ -36,9 +38,10 @@
|
|
|
36
38
|
"access": "public"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
41
|
+
"@cueloop/schema": "0.1.0-alpha.81",
|
|
40
42
|
"@pierre/diffs": "^1.4.1",
|
|
41
43
|
"effect": "^4.0.0-rc.115",
|
|
44
|
+
"smol-toml": "^1.8.0",
|
|
42
45
|
"valibot": "^1.4.2"
|
|
43
46
|
},
|
|
44
47
|
"engines": {
|
package/src/api.test.ts
CHANGED
|
@@ -24,6 +24,21 @@ beforeEach(() => {
|
|
|
24
24
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
25
25
|
|
|
26
26
|
describe("session lifecycle", () => {
|
|
27
|
+
test("startup retention clears native surface handles for expired Threads", () => {
|
|
28
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
29
|
+
|
|
30
|
+
core.store.upsert({ ...session, status: "resolved", createdAt: "2020-01-01T00:00:00Z" });
|
|
31
|
+
core.herdrSetThreadSurface(session.id, { tabId: "tab-old", paneId: "pane-old" });
|
|
32
|
+
core.ghosttySetThreadSurface(session.id, { terminalId: "terminal-old" });
|
|
33
|
+
|
|
34
|
+
const restarted = new DaemonCore(home);
|
|
35
|
+
|
|
36
|
+
expect(restarted.store.get(session.id)).toBeUndefined();
|
|
37
|
+
expect(restarted.herdrGetThreadSurface(session.id)).toBeNull();
|
|
38
|
+
expect(restarted.ghosttyGetThreadSurface(session.id)).toBeNull();
|
|
39
|
+
restarted.dispose();
|
|
40
|
+
});
|
|
41
|
+
|
|
27
42
|
test("create → get → list", () => {
|
|
28
43
|
// Act
|
|
29
44
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
package/src/api.ts
CHANGED
|
@@ -52,7 +52,14 @@ import {
|
|
|
52
52
|
import { curateDiff } from "./curate";
|
|
53
53
|
import { ThreadStore, withHistory } from "./store";
|
|
54
54
|
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
HerdrThreadSurfaceStore,
|
|
57
|
+
type HerdrThreadSurfaceHandle,
|
|
58
|
+
} from "./herdr-thread-surface-store";
|
|
59
|
+
import {
|
|
60
|
+
GhosttyThreadSurfaceStore,
|
|
61
|
+
type GhosttyThreadSurfaceHandle,
|
|
62
|
+
} from "./ghostty-thread-surface-store";
|
|
56
63
|
import { HarnessStateStore } from "./harness-state-store";
|
|
57
64
|
import { DiffWatcher } from "./diff-watcher";
|
|
58
65
|
import { PrReviewPoller } from "./pr-poller";
|
|
@@ -88,7 +95,8 @@ type EventListener = (event: DaemonEvent) => void;
|
|
|
88
95
|
|
|
89
96
|
export class DaemonCore {
|
|
90
97
|
readonly store: ThreadStore;
|
|
91
|
-
readonly
|
|
98
|
+
readonly herdrThreadSurfaces: HerdrThreadSurfaceStore;
|
|
99
|
+
readonly ghosttyThreadSurfaces: GhosttyThreadSurfaceStore;
|
|
92
100
|
readonly harnessState: HarnessStateStore;
|
|
93
101
|
private waiters = new Map<string, ((session: Thread) => void)[]>();
|
|
94
102
|
private listeners = new Set<EventListener>();
|
|
@@ -111,13 +119,19 @@ export class DaemonCore {
|
|
|
111
119
|
this.store.recover();
|
|
112
120
|
this.harnessState = new HarnessStateStore(home);
|
|
113
121
|
for (const session of this.store.list()) this.reconcileDeliveries(session);
|
|
114
|
-
|
|
122
|
+
this.herdrThreadSurfaces = new HerdrThreadSurfaceStore(home);
|
|
123
|
+
this.ghosttyThreadSurfaces = new GhosttyThreadSurfaceStore(home);
|
|
124
|
+
const expiredThreadIds = pruneExpiredSessions(
|
|
115
125
|
this.store,
|
|
116
126
|
resolveCleanupPeriodDays(),
|
|
117
127
|
Date.now(),
|
|
118
128
|
this.harnessState.pendingThreadIds(),
|
|
119
129
|
);
|
|
120
|
-
|
|
130
|
+
|
|
131
|
+
for (const threadId of expiredThreadIds) {
|
|
132
|
+
this.herdrThreadSurfaces.delete(threadId);
|
|
133
|
+
this.ghosttyThreadSurfaces.delete(threadId);
|
|
134
|
+
}
|
|
121
135
|
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
122
136
|
this.prPoller = new PrReviewPoller((sessionId) => void this.sessionRefreshPrDiff(sessionId));
|
|
123
137
|
// resume hot-reload for diff sessions that survived a daemon restart
|
|
@@ -132,13 +146,29 @@ export class DaemonCore {
|
|
|
132
146
|
this.prPoller.close();
|
|
133
147
|
}
|
|
134
148
|
|
|
135
|
-
/** The
|
|
136
|
-
|
|
137
|
-
return this.
|
|
149
|
+
/** The Herdr Thread surface handle, kept outside the canonical Thread. */
|
|
150
|
+
herdrGetThreadSurface(sessionId: string): HerdrThreadSurfaceHandle | null {
|
|
151
|
+
return this.herdrThreadSurfaces.get(sessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
herdrSetThreadSurface(sessionId: string, handle: HerdrThreadSurfaceHandle): void {
|
|
155
|
+
this.herdrThreadSurfaces.set(sessionId, handle);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ghosttyGetThreadSurface(sessionId: string): GhosttyThreadSurfaceHandle | null {
|
|
159
|
+
return this.ghosttyThreadSurfaces.get(sessionId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
ghosttySetThreadSurface(sessionId: string, handle: GhosttyThreadSurfaceHandle): void {
|
|
163
|
+
this.ghosttyThreadSurfaces.set(sessionId, handle);
|
|
138
164
|
}
|
|
139
165
|
|
|
140
|
-
|
|
141
|
-
this.
|
|
166
|
+
ghosttyClaimThreadSurface(sessionId: string): boolean {
|
|
167
|
+
return this.ghosttyThreadSurfaces.claim(sessionId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
ghosttyReleaseThreadSurface(sessionId: string): void {
|
|
171
|
+
this.ghosttyThreadSurfaces.release(sessionId);
|
|
142
172
|
}
|
|
143
173
|
|
|
144
174
|
harnessBind(input: {
|
|
@@ -162,6 +192,10 @@ export class DaemonCore {
|
|
|
162
192
|
return binding;
|
|
163
193
|
}
|
|
164
194
|
|
|
195
|
+
harnessBindingsForSession(harness: string, harnessSessionId: string): HarnessBinding[] {
|
|
196
|
+
return this.harnessState.bindingsForSession(harness, harnessSessionId);
|
|
197
|
+
}
|
|
198
|
+
|
|
165
199
|
harnessConsumeApprovedRetry(bindingId: string, messageId: string, content: string): boolean {
|
|
166
200
|
const binding = this.harnessState.binding(bindingId);
|
|
167
201
|
|
|
@@ -573,7 +607,8 @@ export class DaemonCore {
|
|
|
573
607
|
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
574
608
|
if (session) this.untrackLiveDiffSession(session);
|
|
575
609
|
this.diffRefreshGenerations.delete(id);
|
|
576
|
-
this.
|
|
610
|
+
this.herdrThreadSurfaces.delete(id);
|
|
611
|
+
this.ghosttyThreadSurfaces.delete(id);
|
|
577
612
|
this.emit("inbox.changed", id);
|
|
578
613
|
}
|
|
579
614
|
|
package/src/capabilities.ts
CHANGED
|
@@ -61,12 +61,17 @@ const PRIMITIVE_ROLES = {
|
|
|
61
61
|
"session.sendMessage": "owner",
|
|
62
62
|
"harness.bind": "owner",
|
|
63
63
|
"harness.getBinding": "owner",
|
|
64
|
+
"harness.bindingsForSession": "owner",
|
|
64
65
|
"harness.consumeApprovedRetry": "owner",
|
|
65
66
|
"delivery.pending": "owner",
|
|
66
67
|
"delivery.acknowledge": "owner",
|
|
67
68
|
"session.submitRevision": "owner",
|
|
68
|
-
"herdr.
|
|
69
|
-
"herdr.
|
|
69
|
+
"herdr.getThreadSurface": "owner",
|
|
70
|
+
"herdr.setThreadSurface": "owner",
|
|
71
|
+
"ghostty.getThreadSurface": "owner",
|
|
72
|
+
"ghostty.setThreadSurface": "owner",
|
|
73
|
+
"ghostty.claimThreadSurface": "owner",
|
|
74
|
+
"ghostty.releaseThreadSurface": "owner",
|
|
70
75
|
} as const satisfies Record<MethodName, Audience>;
|
|
71
76
|
|
|
72
77
|
/** Whether `role` may call `method`. */
|
package/src/client.ts
CHANGED
|
@@ -4,7 +4,19 @@
|
|
|
4
4
|
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
appendFileSync,
|
|
11
|
+
closeSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { createConnection } from "node:net";
|
|
19
|
+
import { join } from "node:path";
|
|
8
20
|
import * as v from "valibot";
|
|
9
21
|
import type {
|
|
10
22
|
Annotation,
|
|
@@ -29,7 +41,8 @@ import {
|
|
|
29
41
|
type Response,
|
|
30
42
|
} from "./protocol";
|
|
31
43
|
import type { DaemonRole } from "./capabilities";
|
|
32
|
-
import type {
|
|
44
|
+
import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
|
|
45
|
+
import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
|
|
33
46
|
import type { SharedMerge } from "./api";
|
|
34
47
|
|
|
35
48
|
export type { SharedMerge } from "./api";
|
|
@@ -67,7 +80,14 @@ const EmptyResultSchema = v.object({});
|
|
|
67
80
|
// undefined, which never equals this build - so it is treated as stale and replaced
|
|
68
81
|
const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
|
|
69
82
|
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
70
|
-
const
|
|
83
|
+
const HerdrThreadSurfaceResultSchema = v.nullable(
|
|
84
|
+
v.object({
|
|
85
|
+
tabId: v.string(),
|
|
86
|
+
paneId: v.string(),
|
|
87
|
+
mode: v.optional(v.picklist(["tab", "pane"])),
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
const GhosttyThreadSurfaceResultSchema = v.nullable(v.object({ terminalId: v.string() }));
|
|
71
91
|
|
|
72
92
|
/**
|
|
73
93
|
* The session primitives the review controller drives. DaemonClient is the local
|
|
@@ -146,6 +166,7 @@ export interface ThreadClient {
|
|
|
146
166
|
harnessSessionId: string,
|
|
147
167
|
): Promise<HarnessBinding>;
|
|
148
168
|
harnessGetBinding?(bindingId: string): Promise<HarnessBinding>;
|
|
169
|
+
harnessBindingsForSession?(harness: string, harnessSessionId: string): Promise<HarnessBinding[]>;
|
|
149
170
|
harnessConsumeApprovedRetry?(
|
|
150
171
|
bindingId: string,
|
|
151
172
|
messageId: string,
|
|
@@ -184,14 +205,16 @@ function readOwnerToken(home: string): string | undefined {
|
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
export class DaemonClient implements ThreadClient {
|
|
187
|
-
private socket:
|
|
208
|
+
private socket: { end(): void } | null = null;
|
|
188
209
|
private writer: BackpressureWriter | null = null;
|
|
210
|
+
private connectionEpoch = 0;
|
|
189
211
|
private pending = new Map<number, PendingRequest>();
|
|
190
212
|
/** The connected daemon's build version and pid, learned from the ping handshake. */
|
|
191
213
|
private daemonVersion: string | undefined;
|
|
192
214
|
private daemonPid: number | undefined;
|
|
193
215
|
private nextId = 1;
|
|
194
216
|
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
217
|
+
private disconnectListeners = new Set<() => void>();
|
|
195
218
|
private closed = false;
|
|
196
219
|
private role: DaemonRole = "owner";
|
|
197
220
|
private author: string | undefined;
|
|
@@ -252,11 +275,12 @@ export class DaemonClient implements ThreadClient {
|
|
|
252
275
|
// the old daemon removes its socket in stop(); wait so the new one binds cleanly
|
|
253
276
|
const deadline = Date.now() + 5_000;
|
|
254
277
|
|
|
255
|
-
while (Date.now() < deadline && existsSync(path)) await
|
|
278
|
+
while (Date.now() < deadline && existsSync(path)) await sleep(50);
|
|
256
279
|
}
|
|
257
280
|
|
|
258
281
|
/** Reset per-connection state so a fresh dial() can reuse this client instance. */
|
|
259
282
|
private resetConnection(): void {
|
|
283
|
+
this.connectionEpoch += 1;
|
|
260
284
|
this.socket = null;
|
|
261
285
|
this.writer = null;
|
|
262
286
|
this.closed = false;
|
|
@@ -267,47 +291,96 @@ export class DaemonClient implements ThreadClient {
|
|
|
267
291
|
|
|
268
292
|
/** Spawn a detached daemon and dial the lock owner until it answers. */
|
|
269
293
|
private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
|
|
270
|
-
spawnDaemon(home);
|
|
294
|
+
const startupLogPath = spawnDaemon(home);
|
|
271
295
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
272
296
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
273
297
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
274
298
|
let lastError: unknown;
|
|
275
299
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
300
|
+
try {
|
|
301
|
+
while (Date.now() < deadline) {
|
|
302
|
+
try {
|
|
303
|
+
await this.dial(path);
|
|
304
|
+
if (this.daemonVersion !== DAEMON_VERSION) {
|
|
305
|
+
this.close();
|
|
306
|
+
if (typeof Bun === "undefined") {
|
|
307
|
+
throw new DaemonClientError(
|
|
308
|
+
"version_mismatch",
|
|
309
|
+
`installed cueloop is ${this.daemonVersion ?? "unknown"}, but this adapter is ${DAEMON_VERSION}; run cueloop update`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
throw new Error("daemon autostarted an older build");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return this;
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (err instanceof DaemonClientError) throw err;
|
|
319
|
+
this.socket?.end();
|
|
320
|
+
this.resetConnection();
|
|
321
|
+
lastError = err;
|
|
322
|
+
await sleep(50);
|
|
323
|
+
}
|
|
284
324
|
}
|
|
325
|
+
const output = readFileSync(startupLogPath, "utf8").trim();
|
|
326
|
+
const detail = output ? `\ndaemon startup output:\n${output.slice(-4_096)}` : "";
|
|
327
|
+
|
|
328
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}${detail}`);
|
|
329
|
+
} finally {
|
|
330
|
+
rmSync(startupLogPath, { force: true });
|
|
285
331
|
}
|
|
286
|
-
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
287
332
|
}
|
|
288
333
|
|
|
289
334
|
private async dial(path: string): Promise<void> {
|
|
290
335
|
const buffer = new LineBuffer();
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
336
|
+
const epoch = ++this.connectionEpoch;
|
|
337
|
+
|
|
338
|
+
const close = () => {
|
|
339
|
+
if (this.connectionEpoch !== epoch) return;
|
|
340
|
+
this.closed = true;
|
|
341
|
+
for (const pendingRequest of this.pending.values())
|
|
342
|
+
pendingRequest.reject(new Error("daemon connection closed"));
|
|
343
|
+
this.pending.clear();
|
|
344
|
+
for (const listener of this.disconnectListeners) listener();
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
if (typeof Bun !== "undefined") {
|
|
348
|
+
const socket = await Bun.connect({
|
|
349
|
+
unix: path,
|
|
350
|
+
socket: {
|
|
351
|
+
data: (_socket, data) => {
|
|
352
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
353
|
+
},
|
|
354
|
+
drain: () => this.writer?.drain(),
|
|
355
|
+
close,
|
|
356
|
+
error() {},
|
|
297
357
|
},
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
this.socket = socket;
|
|
361
|
+
this.writer = new BackpressureWriter(socket);
|
|
362
|
+
} else {
|
|
363
|
+
const socket = createConnection(path);
|
|
364
|
+
|
|
365
|
+
await new Promise<void>((resolve, reject) => {
|
|
366
|
+
socket.once("connect", resolve);
|
|
367
|
+
socket.once("error", reject);
|
|
368
|
+
});
|
|
369
|
+
socket.on("data", (data) => {
|
|
370
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
371
|
+
});
|
|
372
|
+
socket.on("close", close);
|
|
373
|
+
socket.on("error", () => {});
|
|
374
|
+
this.socket = socket;
|
|
375
|
+
// Node queues the complete Buffer internally when write returns false.
|
|
376
|
+
this.writer = new BackpressureWriter({
|
|
377
|
+
write(data) {
|
|
378
|
+
socket.write(data);
|
|
379
|
+
|
|
380
|
+
return data.length;
|
|
306
381
|
},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
});
|
|
310
|
-
this.writer = new BackpressureWriter(this.socket);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
311
384
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
312
385
|
// only to fail later, so a ping is the actual handshake. It also carries the
|
|
313
386
|
// daemon's build version and pid, so connect() can replace a stale daemon.
|
|
@@ -339,6 +412,13 @@ export class DaemonClient implements ThreadClient {
|
|
|
339
412
|
return () => this.eventListeners.delete(listener);
|
|
340
413
|
}
|
|
341
414
|
|
|
415
|
+
/** Notify an adapter when its daemon socket closes so it can reconnect and replay Messages. */
|
|
416
|
+
onDisconnect(listener: () => void): () => void {
|
|
417
|
+
this.disconnectListeners.add(listener);
|
|
418
|
+
|
|
419
|
+
return () => this.disconnectListeners.delete(listener);
|
|
420
|
+
}
|
|
421
|
+
|
|
342
422
|
private routeInboundFrame(line: string): void {
|
|
343
423
|
let frame;
|
|
344
424
|
|
|
@@ -562,6 +642,13 @@ export class DaemonClient implements ThreadClient {
|
|
|
562
642
|
harnessGetBinding(bindingId: string): Promise<HarnessBinding> {
|
|
563
643
|
return this.request("harness.getBinding", { bindingId }, HarnessBindingSchema);
|
|
564
644
|
}
|
|
645
|
+
harnessBindingsForSession(harness: string, harnessSessionId: string): Promise<HarnessBinding[]> {
|
|
646
|
+
return this.request(
|
|
647
|
+
"harness.bindingsForSession",
|
|
648
|
+
{ harness, harnessSessionId },
|
|
649
|
+
v.array(HarnessBindingSchema),
|
|
650
|
+
);
|
|
651
|
+
}
|
|
565
652
|
harnessConsumeApprovedRetry(
|
|
566
653
|
bindingId: string,
|
|
567
654
|
messageId: string,
|
|
@@ -591,12 +678,24 @@ export class DaemonClient implements ThreadClient {
|
|
|
591
678
|
ThreadRecordSchema,
|
|
592
679
|
);
|
|
593
680
|
}
|
|
594
|
-
/**
|
|
595
|
-
|
|
596
|
-
return this.request("herdr.
|
|
681
|
+
/** Read Herdr's local Thread surface handle, outside the ThreadClient contract. */
|
|
682
|
+
herdrGetThreadSurface(id: string): Promise<HerdrThreadSurfaceHandle | null> {
|
|
683
|
+
return this.request("herdr.getThreadSurface", { id }, HerdrThreadSurfaceResultSchema);
|
|
684
|
+
}
|
|
685
|
+
async herdrSetThreadSurface(id: string, handle: HerdrThreadSurfaceHandle): Promise<void> {
|
|
686
|
+
await this.request("herdr.setThreadSurface", { id, ...handle }, EmptyResultSchema);
|
|
687
|
+
}
|
|
688
|
+
ghosttyGetThreadSurface(id: string): Promise<GhosttyThreadSurfaceHandle | null> {
|
|
689
|
+
return this.request("ghostty.getThreadSurface", { id }, GhosttyThreadSurfaceResultSchema);
|
|
690
|
+
}
|
|
691
|
+
async ghosttySetThreadSurface(id: string, handle: GhosttyThreadSurfaceHandle): Promise<void> {
|
|
692
|
+
await this.request("ghostty.setThreadSurface", { id, ...handle }, EmptyResultSchema);
|
|
597
693
|
}
|
|
598
|
-
|
|
599
|
-
|
|
694
|
+
ghosttyClaimThreadSurface(id: string): Promise<boolean> {
|
|
695
|
+
return this.request("ghostty.claimThreadSurface", { id }, v.boolean());
|
|
696
|
+
}
|
|
697
|
+
async ghosttyReleaseThreadSurface(id: string): Promise<void> {
|
|
698
|
+
await this.request("ghostty.releaseThreadSurface", { id }, EmptyResultSchema);
|
|
600
699
|
}
|
|
601
700
|
shutdown(): Promise<void> {
|
|
602
701
|
return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
|
|
@@ -631,9 +730,37 @@ export function daemonSpawnCommand(
|
|
|
631
730
|
return devWatch ? [execPath, "--watch", "run", mainPath] : [execPath, "run", mainPath];
|
|
632
731
|
}
|
|
633
732
|
|
|
634
|
-
function spawnDaemon(home: string):
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
733
|
+
function spawnDaemon(home: string): string {
|
|
734
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
735
|
+
const startupLogPath = join(
|
|
736
|
+
home,
|
|
737
|
+
`daemon-startup-${process.pid}-${randomBytes(6).toString("hex")}.log`,
|
|
738
|
+
);
|
|
739
|
+
const logFd = openSync(startupLogPath, "wx", 0o600);
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
if (typeof Bun === "undefined") {
|
|
743
|
+
const child = spawn(process.env.CUELOOP_EXECUTABLE ?? "cueloop", ["daemon", "--autostart"], {
|
|
744
|
+
env: { ...process.env, CUELOOP_HOME: home },
|
|
745
|
+
stdio: ["ignore", logFd, logFd],
|
|
746
|
+
detached: true,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
child.on("error", (error) => appendFileSync(startupLogPath, `${String(error)}\n`));
|
|
750
|
+
child.unref();
|
|
751
|
+
} else {
|
|
752
|
+
Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
|
|
753
|
+
env: { ...process.env, CUELOOP_HOME: home },
|
|
754
|
+
stdio: ["ignore", logFd, logFd],
|
|
755
|
+
}).unref();
|
|
756
|
+
}
|
|
757
|
+
} finally {
|
|
758
|
+
closeSync(logFd);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return startupLogPath;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function sleep(durationMs: number): Promise<void> {
|
|
765
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
639
766
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { GhosttyThreadSurfaceStore } from "./ghostty-thread-surface-store";
|
|
6
|
+
import { ghosttyThreadSurfacesPath } from "./paths";
|
|
7
|
+
|
|
8
|
+
let home: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-ghostty-store-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(home, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("GhosttyThreadSurfaceStore", () => {
|
|
19
|
+
test("persists terminal IDs outside Thread records and reloads them", () => {
|
|
20
|
+
const store = new GhosttyThreadSurfaceStore(home);
|
|
21
|
+
|
|
22
|
+
store.set("ses_one", { terminalId: "term-1" });
|
|
23
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toEqual({ terminalId: "term-1" });
|
|
24
|
+
expect(JSON.parse(readFileSync(ghosttyThreadSurfacesPath(home), "utf8"))).toEqual({
|
|
25
|
+
ses_one: { terminalId: "term-1" },
|
|
26
|
+
});
|
|
27
|
+
store.delete("ses_one");
|
|
28
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores malformed scratch state", () => {
|
|
32
|
+
writeFileSync(ghosttyThreadSurfacesPath(home), '{"ses_one":{"terminalId":3}}');
|
|
33
|
+
|
|
34
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("reserves a Thread before launch and fails closed across daemon restart", () => {
|
|
38
|
+
const store = new GhosttyThreadSurfaceStore(home);
|
|
39
|
+
|
|
40
|
+
expect(store.claim("ses_one")).toBe(true);
|
|
41
|
+
expect(store.claim("ses_one")).toBe(false);
|
|
42
|
+
expect(new GhosttyThreadSurfaceStore(home).claim("ses_one")).toBe(false);
|
|
43
|
+
store.set("ses_one", { terminalId: "term-1" });
|
|
44
|
+
expect(new GhosttyThreadSurfaceStore(home).claim("ses_one")).toBe(true);
|
|
45
|
+
store.release("ses_one");
|
|
46
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toEqual({ terminalId: "term-1" });
|
|
47
|
+
expect(new GhosttyThreadSurfaceStore(home).claim("ses_one")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
this.surfaces.set(threadId, { ...previous, opening: true });
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
this.persist();
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (previous) this.surfaces.set(threadId, previous);
|
|
76
|
+
else this.surfaces.delete(threadId);
|
|
77
|
+
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
release(threadId: string): void {
|
|
83
|
+
const record = this.surfaces.get(threadId);
|
|
84
|
+
|
|
85
|
+
if (!record?.opening) return;
|
|
86
|
+
if (record.terminalId) this.surfaces.set(threadId, { terminalId: record.terminalId });
|
|
87
|
+
else this.surfaces.delete(threadId);
|
|
88
|
+
this.persist();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
set(threadId: string, handle: GhosttyThreadSurfaceHandle): void {
|
|
92
|
+
const opening = this.surfaces.get(threadId)?.opening;
|
|
93
|
+
|
|
94
|
+
this.surfaces.set(threadId, opening ? { ...handle, opening } : handle);
|
|
95
|
+
this.persist();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
delete(threadId: string): void {
|
|
99
|
+
if (this.surfaces.delete(threadId)) this.persist();
|
|
100
|
+
}
|
|
101
|
+
}
|