@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.80",
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-pane": "./src/herdr-pane.ts",
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.80",
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 { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
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 herdrTabs: HerdrTabStore;
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
- pruneExpiredSessions(
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
- this.herdrTabs = new HerdrTabStore(home);
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 herdr tab opened for a review, if any (adapter scratch, not on the session). */
136
- herdrGetTab(sessionId: string): HerdrTabHandle | null {
137
- return this.herdrTabs.get(sessionId);
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
- herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
141
- this.herdrTabs.set(sessionId, handle);
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.herdrTabs.delete(id);
610
+ this.herdrThreadSurfaces.delete(id);
611
+ this.ghosttyThreadSurfaces.delete(id);
577
612
  this.emit("inbox.changed", id);
578
613
  }
579
614
 
@@ -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.getTab": "owner",
69
- "herdr.setTab": "owner",
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 { existsSync, readFileSync } from "node:fs";
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 { HerdrTabHandle } from "./herdr-tab-store";
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 HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
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: Awaited<ReturnType<typeof Bun.connect>> | null = null;
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 Bun.sleep(50);
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
- while (Date.now() < deadline) {
277
- try {
278
- await this.dial(path);
279
-
280
- return this;
281
- } catch (err) {
282
- lastError = err;
283
- await Bun.sleep(50);
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
- this.socket = await Bun.connect({
293
- unix: path,
294
- socket: {
295
- data: (_socket, data) => {
296
- buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
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
- drain: () => {
299
- this.writer?.drain();
300
- },
301
- close: () => {
302
- this.closed = true;
303
- for (const pendingRequest of this.pending.values())
304
- pendingRequest.reject(new Error("daemon connection closed"));
305
- this.pending.clear();
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
- error() {},
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
- /** herdr adapter scratch: the tab opened for a review; local-only, off the ThreadClient contract. */
595
- herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
596
- return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
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
- async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
599
- await this.request("herdr.setTab", { id, ...handle }, EmptyResultSchema);
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): void {
635
- Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
636
- env: { ...process.env, CUELOOP_HOME: home },
637
- stdio: ["ignore", "ignore", "ignore"],
638
- }).unref();
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
+ }