@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.
Files changed (60) hide show
  1. package/package.json +39 -22
  2. package/src/api.test.ts +1060 -65
  3. package/src/api.ts +1398 -89
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +142 -0
  10. package/src/diff-hot-reload.test.ts +419 -0
  11. package/src/diff-watcher.ts +293 -0
  12. package/src/gh.test.ts +59 -0
  13. package/src/gh.ts +74 -0
  14. package/src/ghostty-thread-surface-store.test.ts +49 -0
  15. package/src/ghostty-thread-surface-store.ts +101 -0
  16. package/src/ghostty-thread-surface.test.ts +398 -0
  17. package/src/ghostty-thread-surface.ts +267 -0
  18. package/src/harness-delivery.test.ts +268 -0
  19. package/src/harness-state-store.test.ts +93 -0
  20. package/src/harness-state-store.ts +213 -0
  21. package/src/herdr-split.test.ts +106 -0
  22. package/src/herdr-split.ts +92 -0
  23. package/src/herdr-thread-surface-store.test.ts +81 -0
  24. package/src/herdr-thread-surface-store.ts +64 -0
  25. package/src/herdr-thread-surface.test.ts +411 -0
  26. package/src/herdr-thread-surface.ts +355 -0
  27. package/src/index.ts +8 -9
  28. package/src/interruptible-wait.test.ts +127 -0
  29. package/src/interruptible-wait.ts +66 -0
  30. package/src/jj-working-tree.ts +131 -0
  31. package/src/main.ts +4 -1
  32. package/src/paths.ts +49 -0
  33. package/src/pr-poller.test.ts +102 -0
  34. package/src/pr-poller.ts +73 -0
  35. package/src/project-files.test.ts +124 -0
  36. package/src/project-files.ts +56 -0
  37. package/src/protocol.ts +79 -7
  38. package/src/retention.test.ts +191 -0
  39. package/src/retention.ts +123 -0
  40. package/src/server.test.ts +453 -42
  41. package/src/server.ts +433 -88
  42. package/src/share-blob.test.ts +112 -0
  43. package/src/share-blob.ts +55 -0
  44. package/src/single-instance.test.ts +162 -13
  45. package/src/store.conformance.test.ts +41 -0
  46. package/src/store.jsonl.test.ts +159 -0
  47. package/src/store.ts +264 -34
  48. package/src/testing/store-conformance.ts +194 -0
  49. package/src/thread-review.test.ts +483 -0
  50. package/src/thread-review.ts +398 -0
  51. package/src/thread-surface-config.test.ts +63 -0
  52. package/src/thread-surface-config.ts +61 -0
  53. package/src/validate.test.ts +282 -37
  54. package/src/validate.ts +398 -33
  55. package/src/vcs-source.test.ts +180 -0
  56. package/src/vcs-source.ts +228 -0
  57. package/src/version.ts +6 -0
  58. package/src/working-tree.ts +149 -0
  59. package/src/review.test.ts +0 -167
  60. package/src/review.ts +0 -174
@@ -0,0 +1,398 @@
1
+ import {
2
+ isMarkdownArtifact,
3
+ newAnnotationId,
4
+ type Annotation,
5
+ type Artifact,
6
+ type ArtifactType,
7
+ type DiffFileContents,
8
+ type DiffSource,
9
+ type Thread,
10
+ type WorkspaceKey,
11
+ type WorkflowKind,
12
+ } from "@cueloop/schema";
13
+ import { execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ import { messageResponse } from "./api";
16
+ import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
17
+ import { VcsSourceManager } from "./vcs-source";
18
+
19
+ // Adapters and CLI primitives reach the message mapping through this module too,
20
+ // so a session obtained outside a ReviewHandle maps the same way.
21
+ export { messageResponse };
22
+
23
+ async function git(args: string[], cwd: string): Promise<string | null> {
24
+ try {
25
+ const { stdout } = await promisify(execFile)("git", args, { cwd });
26
+
27
+ return stdout.trim();
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ async function jjRoot(cwd: string): Promise<string | null> {
34
+ try {
35
+ const { stdout } = await promisify(execFile)("jj", ["--ignore-working-copy", "root"], { cwd });
36
+
37
+ return stdout.trim();
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
44
+ function earliestRootCommit(revList: string | null): string | undefined {
45
+ if (!revList) return undefined;
46
+ const roots = revList.split("\n").filter((line) => line.length > 0);
47
+
48
+ // --date-order lists newest first, so the last root is the earliest
49
+ return roots.at(-1);
50
+ }
51
+
52
+ /** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
53
+ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
54
+ const gitRepoRoot = await git(["rev-parse", "--show-toplevel"], cwd);
55
+ const jjRepoRoot = await jjRoot(cwd);
56
+ const nativeJjRoot =
57
+ jjRepoRoot && (!gitRepoRoot || jjRepoRoot.length > gitRepoRoot.length) ? jjRepoRoot : null;
58
+ const repoRoot = nativeJjRoot ?? gitRepoRoot ?? cwd;
59
+ const branch = nativeJjRoot
60
+ ? "jj"
61
+ : ((await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached");
62
+ // a shallow clone's oldest commit is the graft boundary, not the true root, so
63
+ // it would key a different project than a full clone - leave it unset instead
64
+ const shallow =
65
+ nativeJjRoot !== null || (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
66
+ const rootCommit = shallow
67
+ ? undefined
68
+ : earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
69
+ const remote = nativeJjRoot ? null : await git(["remote", "get-url", "origin"], cwd);
70
+
71
+ const workspace: WorkspaceKey = { repoRoot, branch };
72
+ // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
73
+ if (rootCommit) workspace.rootCommit = rootCommit;
74
+ if (remote) workspace.remote = remote;
75
+
76
+ return workspace;
77
+ }
78
+
79
+ function firstHeading(markdown: string): string | undefined {
80
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
81
+
82
+ return headingMatch?.[1]?.trim();
83
+ }
84
+
85
+ export interface OpenReviewOptions {
86
+ type: ArtifactType;
87
+ /** Workflow identity keeps plan/refine and diff/review Threads separate. */
88
+ workflow?: WorkflowKind;
89
+ content: string;
90
+ /** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
91
+ cwd?: string;
92
+ /** Optional explicit VCS selection for a diff that matches the checkout capture. */
93
+ vcs?: string;
94
+ /** The native logical change, set after a captured JJ diff is verified. */
95
+ vcsChangeId?: string;
96
+ /** Pre-resolved workspace key; skips git resolution when the caller already has it. */
97
+ workspace?: WorkspaceKey;
98
+ agent?: string;
99
+ /** When set, a resubmit from the same agent session becomes a revision, not a new session. */
100
+ agentSessionId?: string;
101
+ planPath?: string;
102
+ prototypePath?: string;
103
+ pr?: string;
104
+ prBrief?: string;
105
+ prBaseSha?: string;
106
+ prHeadSha?: string;
107
+ prUrl?: string;
108
+ herdrPane?: string;
109
+ /**
110
+ * Full file contents per changed file for a working-tree diff, carried onto
111
+ * the artifact so hunk curation produces an exactly applyable patch.
112
+ */
113
+ files?: DiffFileContents[];
114
+ /** Defaults to a markdown artifact's first heading (plan, reply); diffs get no derived title. */
115
+ title?: string;
116
+ /**
117
+ * Per-file agent notes for diff sessions: the submitting agent's own
118
+ * explanation of each file's change, in dead prose. Stored as annotations
119
+ * with kind "note" anchored at the file path, so they render as regular
120
+ * rail cards and feed the guided walk's agent-note block.
121
+ */
122
+ notes?: ReviewNote[];
123
+ }
124
+
125
+ /** The note contract: one note per changed file, anchored by the file path. */
126
+ export interface ReviewNote {
127
+ path: string;
128
+ body: string;
129
+ }
130
+
131
+ /** The transport-neutral daemon operations used by the shared review lifecycle. */
132
+ export interface ThreadSessionClient {
133
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
134
+ sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
135
+ sessionSubmitRevision(
136
+ id: string,
137
+ content: string,
138
+ addressedAnnotationIds?: string[],
139
+ files?: DiffFileContents[],
140
+ source?: DiffSource,
141
+ ): Promise<Thread>;
142
+ sessionAnnotate(
143
+ id: string,
144
+ annotation: Omit<Annotation, "createdAt">,
145
+ authorName?: string,
146
+ ): Promise<Thread>;
147
+ sessionGet(id: string): Promise<Thread>;
148
+ sessionWait(id: string, timeoutMs: number): Promise<Thread | null>;
149
+ }
150
+
151
+ /** Anchor a note at its file: quote = the path, no context selectors. */
152
+ async function attachNotes(
153
+ client: ThreadSessionClient,
154
+ sessionId: string,
155
+ notes: ReviewNote[],
156
+ ): Promise<void> {
157
+ for (const note of notes) {
158
+ await client.sessionAnnotate(sessionId, {
159
+ id: newAnnotationId(),
160
+ kind: "note",
161
+ anchor: { quote: note.path, prefix: "", suffix: "" },
162
+ body: note.body,
163
+ });
164
+ }
165
+ }
166
+
167
+ export interface AwaitMessageOptions {
168
+ /** Total wait budget; Infinity keeps polling until resolved or aborted. */
169
+ timeoutMs: number;
170
+ /** Chunk length for the poll loop; between chunks the session is re-read for progress. */
171
+ pollMs?: number;
172
+ /** Called with the fresh session after each chunk that is still pending. */
173
+ onProgress?: (session: Thread) => void;
174
+ signal?: AbortSignal;
175
+ }
176
+
177
+ /** A resolved review mapped onto the agent contract, plus the full session. */
178
+ export interface MessageResult {
179
+ allow: boolean;
180
+ message: import("@cueloop/schema").Message;
181
+ session: Thread;
182
+ }
183
+
184
+ export class ReviewHandle {
185
+ constructor(
186
+ private readonly client: Pick<ThreadSessionClient, "sessionWait" | "sessionGet">,
187
+ readonly session: Thread,
188
+ ) {}
189
+
190
+ get id(): string {
191
+ return this.session.id;
192
+ }
193
+
194
+ /**
195
+ * Block on the message. "pending" means the budget ran out or the signal
196
+ * aborted - the session stays open and the message is collectable later
197
+ * (messages outlive waits). With only timeoutMs this is one long-poll;
198
+ * pollMs/onProgress/signal switch to the chunked loop.
199
+ */
200
+ async awaitMessage(options: AwaitMessageOptions): Promise<MessageResult | "pending"> {
201
+ const { timeoutMs, pollMs, onProgress, signal } = options;
202
+
203
+ if (pollMs === undefined && onProgress === undefined && signal === undefined) {
204
+ const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
205
+
206
+ return resolved === null ? "pending" : outcome(resolved);
207
+ }
208
+ const chunkMs = pollMs ?? 10_000;
209
+ const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
210
+
211
+ for (;;) {
212
+ const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
213
+
214
+ if (budget <= 0 || signal?.aborted) return "pending";
215
+ const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
216
+
217
+ if (resolved === ABORTED) return "pending";
218
+
219
+ if (resolved !== null) return outcome(resolved);
220
+ // Still pending after this chunk: re-read to surface reviewer progress.
221
+ const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
222
+
223
+ if (current === ABORTED) return "pending";
224
+
225
+ onProgress?.(current);
226
+ }
227
+ }
228
+ }
229
+
230
+ function outcome(session: Thread): MessageResult {
231
+ return { ...messageResponse(session), session };
232
+ }
233
+
234
+ export interface AwaitResolveOptions {
235
+ /** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
236
+ pollMs?: number;
237
+ /** Abort the wait (the harness session shut down); resolves to null. */
238
+ signal?: AbortSignal;
239
+ }
240
+
241
+ /**
242
+ * Park until a thread resolves, then return the message outcome; null
243
+ * when the signal aborts first. Where ReviewHandle.awaitMessage needs the handle
244
+ * that opened the review, this needs only a session id - so a background waiter
245
+ * that woke on a session it did not open (a detached Claude Code / Codex waiter,
246
+ * or pi's session_start listener) can collect the same message. This is the
247
+ * wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
248
+ * a message that lands between chunks is never missed and a session already
249
+ * resolved returns on the first chunk. The held connection also keeps the daemon
250
+ * off its idle-exit path for the whole wait.
251
+ */
252
+ export async function awaitResolve(
253
+ client: Pick<ThreadSessionClient, "sessionWait">,
254
+ sessionId: string,
255
+ options: AwaitResolveOptions = {},
256
+ ): Promise<MessageResult | null> {
257
+ const chunkMs = options.pollMs ?? 30_000;
258
+ const resolved = await pollUntilResolved(
259
+ () => client.sessionWait(sessionId, chunkMs),
260
+ options.signal,
261
+ );
262
+
263
+ return resolved === null ? null : outcome(resolved);
264
+ }
265
+
266
+ /** Find the Thread already submitted by this agent in the same workspace and primitive. */
267
+ export async function findExistingReview(
268
+ client: ThreadSessionClient,
269
+ options: OpenReviewOptions,
270
+ ): Promise<Thread | undefined> {
271
+ const cwd = options.cwd ?? process.cwd();
272
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
273
+
274
+ if (options.agentSessionId === undefined) return undefined;
275
+
276
+ const isJjDiff = options.type === "diff" && options.vcs === "jj";
277
+ const hasJjProvenance = (candidate: Thread): boolean =>
278
+ candidate.artifact.meta.vcs === "jj" ||
279
+ candidate.revisions.some((revision) => revision.source?.vcs === "jj");
280
+ const candidates = (await client.sessionList()).filter(
281
+ (candidate) =>
282
+ candidate.artifact.meta.agentSessionId === options.agentSessionId &&
283
+ candidate.artifact.meta.agent === options.agent &&
284
+ candidate.artifact.type === options.type &&
285
+ (candidate.artifact.meta.workflow ??
286
+ (candidate.artifact.meta.pr ? "review" : candidate.artifact.type)) ===
287
+ (options.workflow ?? (options.pr ? "review" : options.type)) &&
288
+ candidate.artifact.meta.pr === options.pr &&
289
+ candidate.workspace.repoRoot === workspace.repoRoot &&
290
+ (candidate.workspace.branch === workspace.branch || (isJjDiff && hasJjProvenance(candidate))),
291
+ );
292
+
293
+ if (options.vcsChangeId !== undefined)
294
+ return candidates.find(
295
+ (candidate) => candidate.artifact.meta.vcsChangeId === options.vcsChangeId,
296
+ );
297
+ if (isJjDiff) {
298
+ const jjCandidates = candidates.filter(hasJjProvenance);
299
+
300
+ // An unmatched patch has no verified change ID. Reuse the Thread only when
301
+ // there is exactly one possible JJ change in this agent session.
302
+ return jjCandidates.length === 1 ? jjCandidates[0] : undefined;
303
+ }
304
+
305
+ return candidates.find((candidate) => candidate.artifact.meta.vcsChangeId === undefined);
306
+ }
307
+
308
+ /** Attach VCS provenance only when the submitted patch matches a native capture. */
309
+ async function matchingDiffCapture(
310
+ options: OpenReviewOptions,
311
+ cwd: string,
312
+ ): Promise<{ source?: DiffSource; files?: DiffFileContents[] }> {
313
+ if (options.type !== "diff" || options.pr || !options.content.trim()) return {};
314
+ try {
315
+ const captured = await new VcsSourceManager().capture(cwd, options.vcs);
316
+
317
+ if (captured.patch.replace(/\n+$/, "") !== options.content.replace(/\n+$/, "")) return {};
318
+
319
+ return {
320
+ source: {
321
+ vcs: captured.vcs,
322
+ changeId: captured.source?.changeId,
323
+ revisionId: captured.source?.revisionId,
324
+ },
325
+ files: captured.files,
326
+ };
327
+ } catch {
328
+ // A supplied patch stays reviewable even when the local source is unavailable.
329
+ return {};
330
+ }
331
+ }
332
+
333
+ /** Open a thread (or revise the agent session's existing one) and hand back the wait surface. */
334
+ export async function openReview(
335
+ client: ThreadSessionClient,
336
+ options: OpenReviewOptions,
337
+ ): Promise<ReviewHandle> {
338
+ const cwd = options.cwd ?? process.cwd();
339
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
340
+ const { source, files: capturedFiles } = await matchingDiffCapture(options, cwd);
341
+ const existing = await findExistingReview(client, {
342
+ ...options,
343
+ vcsChangeId: source?.changeId,
344
+ });
345
+
346
+ if (existing !== undefined) {
347
+ // PR diffs have no full file contents. An absent local-diff capture clears
348
+ // old curatable files, while a PR revision must keep curation disabled.
349
+ const revisionFiles =
350
+ options.type !== "diff" || options.pr ? undefined : (options.files ?? capturedFiles ?? []);
351
+ let revised = await client.sessionSubmitRevision(
352
+ existing.id,
353
+ options.content,
354
+ [],
355
+ revisionFiles,
356
+ source,
357
+ );
358
+
359
+ if (options.notes?.length) {
360
+ await attachNotes(client, revised.id, options.notes);
361
+ revised = await client.sessionGet(revised.id);
362
+ }
363
+
364
+ return new ReviewHandle(client, revised);
365
+ }
366
+ let session = await client.sessionCreate(workspace, {
367
+ type: options.type,
368
+ content: options.content,
369
+ files: options.files ?? capturedFiles,
370
+ meta: {
371
+ workflow: options.workflow,
372
+ agent: options.agent,
373
+ agentSessionId: options.agentSessionId,
374
+ planPath: options.planPath,
375
+ prototypePath: options.prototypePath,
376
+ pr: options.pr,
377
+ prBrief: options.prBrief,
378
+ prBaseSha: options.prBaseSha,
379
+ prHeadSha: options.prHeadSha,
380
+ prUrl: options.prUrl,
381
+ herdrPane: options.herdrPane,
382
+ title:
383
+ options.title ??
384
+ (isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
385
+ cwd,
386
+ vcs: source?.vcs,
387
+ vcsChangeId: source?.changeId,
388
+ vcsRevisionId: source?.revisionId,
389
+ },
390
+ });
391
+
392
+ if (options.notes?.length) {
393
+ await attachNotes(client, session.id, options.notes);
394
+ session = await client.sessionGet(session.id);
395
+ }
396
+
397
+ return new ReviewHandle(client, session);
398
+ }
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadGhosttyThreadSurface, loadHerdrThreadSurface } from "./thread-surface-config";
6
+
7
+ let dir: string;
8
+ let userPath: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-config-"));
12
+ userPath = join(dir, "user.toml");
13
+ });
14
+
15
+ describe("personal Ghostty Thread surface config", () => {
16
+ test("defaults to tab and accepts pane, window, or none", () => {
17
+ expect(loadGhosttyThreadSurface(userPath)).toBe("tab");
18
+
19
+ for (const mode of ["pane", "window", "none"] as const) {
20
+ writeFileSync(userPath, `[integrations.ghostty]\nthread_surface = "${mode}"\n`);
21
+ expect(loadGhosttyThreadSurface(userPath)).toBe(mode);
22
+ }
23
+
24
+ writeFileSync(userPath, '[integrations.ghostty]\nthread_surface = "invalid"\n');
25
+ expect(loadGhosttyThreadSurface(userPath)).toBe("tab");
26
+ });
27
+
28
+ test("ignores repository config", () => {
29
+ const repoConfig = join(dir, "repo", ".cueloop", "config.toml");
30
+
31
+ mkdirSync(join(dir, "repo", ".cueloop"), { recursive: true });
32
+ writeFileSync(userPath, '[integrations.ghostty]\nthread_surface = "window"\n');
33
+ writeFileSync(repoConfig, '[integrations.ghostty]\nthread_surface = "none"\n');
34
+
35
+ expect(loadGhosttyThreadSurface(userPath)).toBe("window");
36
+ });
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("personal Herdr Thread surface config", () => {
44
+ test("defaults to tab and accepts pane or none", () => {
45
+ expect(loadHerdrThreadSurface(userPath)).toBe("tab");
46
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "pane"\n');
47
+ expect(loadHerdrThreadSurface(userPath)).toBe("pane");
48
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "none"\n');
49
+ expect(loadHerdrThreadSurface(userPath)).toBe("none");
50
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "invalid"\n');
51
+ expect(loadHerdrThreadSurface(userPath)).toBe("tab");
52
+ });
53
+
54
+ test("never reads a repository's terminal automation setting", () => {
55
+ const repoConfig = join(dir, "repo", ".cueloop", "config.toml");
56
+
57
+ mkdirSync(join(dir, "repo", ".cueloop"), { recursive: true });
58
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "pane"\n');
59
+ writeFileSync(repoConfig, '[integrations.herdr]\nthread_surface = "none"\n');
60
+
61
+ expect(loadHerdrThreadSurface(userPath)).toBe("pane");
62
+ });
63
+ });
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parse as parseToml } from "smol-toml";
5
+ import * as v from "valibot";
6
+
7
+ /** Placement of a cueloop Thread inside Herdr. */
8
+ export type HerdrThreadSurface = "tab" | "pane" | "none";
9
+ /** Placement of a cueloop Thread inside Ghostty. */
10
+ export type GhosttyThreadSurface = "tab" | "pane" | "window" | "none";
11
+
12
+ const PersonalThreadSurfaceSchema = v.object({
13
+ integrations: v.optional(
14
+ v.object({
15
+ herdr: v.optional(
16
+ v.object({
17
+ thread_surface: v.fallback(v.optional(v.picklist(["tab", "pane", "none"])), undefined),
18
+ }),
19
+ ),
20
+ ghostty: v.optional(
21
+ v.object({
22
+ thread_surface: v.fallback(
23
+ v.optional(v.picklist(["tab", "pane", "window", "none"])),
24
+ undefined,
25
+ ),
26
+ }),
27
+ ),
28
+ }),
29
+ ),
30
+ });
31
+
32
+ /** Read Herdr placement from the personal TOML file only; invalid values use the tab default. */
33
+ export function loadHerdrThreadSurface(userConfigPath?: string): HerdrThreadSurface {
34
+ return (
35
+ loadPersonalThreadSurfaceConfig(userConfigPath)?.integrations?.herdr?.thread_surface ?? "tab"
36
+ );
37
+ }
38
+
39
+ /** Read Ghostty placement from the personal TOML file only. */
40
+ export function loadGhosttyThreadSurface(userConfigPath?: string): GhosttyThreadSurface {
41
+ return (
42
+ loadPersonalThreadSurfaceConfig(userConfigPath)?.integrations?.ghostty?.thread_surface ?? "tab"
43
+ );
44
+ }
45
+
46
+ function loadPersonalThreadSurfaceConfig(userConfigPath?: string) {
47
+ const path =
48
+ userConfigPath ??
49
+ process.env.CUELOOP_CONFIG ??
50
+ join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
51
+
52
+ if (!existsSync(path)) return null;
53
+
54
+ try {
55
+ const parsed = v.safeParse(PersonalThreadSurfaceSchema, parseToml(readFileSync(path, "utf8")));
56
+
57
+ return parsed.success ? parsed.output : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }