@cueloop/daemon 0.1.0-alpha.80 → 0.1.0-alpha.82

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.
@@ -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
+ }
package/src/validate.ts CHANGED
@@ -262,6 +262,7 @@ export const Params = {
262
262
  harnessSessionId: NonEmpty,
263
263
  }),
264
264
  "harness.getBinding": v.object({ bindingId: NonEmpty }),
265
+ "harness.bindingsForSession": v.object({ harness: NonEmpty, harnessSessionId: NonEmpty }),
265
266
  "harness.consumeApprovedRetry": v.object({
266
267
  bindingId: NonEmpty,
267
268
  messageId: NonEmpty,
@@ -286,9 +287,18 @@ export const Params = {
286
287
  author: v.optional(NonEmpty),
287
288
  }),
288
289
  "daemon.shutdown": v.object({}),
289
- // herdr adapter scratch: the review's opened tab, kept off the session record.
290
- "herdr.getTab": v.object({ id: SessionId }),
291
- "herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
290
+ // Herdr terminal handles stay outside canonical Thread records.
291
+ "herdr.getThreadSurface": v.object({ id: SessionId }),
292
+ "herdr.setThreadSurface": v.object({
293
+ id: SessionId,
294
+ tabId: NonEmpty,
295
+ paneId: NonEmpty,
296
+ mode: v.optional(v.picklist(["tab", "pane"])),
297
+ }),
298
+ "ghostty.getThreadSurface": v.object({ id: SessionId }),
299
+ "ghostty.setThreadSurface": v.object({ id: SessionId, terminalId: NonEmpty }),
300
+ "ghostty.claimThreadSurface": v.object({ id: SessionId }),
301
+ "ghostty.releaseThreadSurface": v.object({ id: SessionId }),
292
302
  } as const;
293
303
 
294
304
  export type MethodName = keyof typeof Params;
@@ -1,240 +0,0 @@
1
- /** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, openHerdrPane returns the tab handle, and openHerdrPaneForReview opens+records for a new review, focuses a still-alive recorded tab, and reopens a dead one - collision-free by pane id. */
2
-
3
- import { afterAll, describe, expect, test } from "bun:test";
4
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import type { Thread } from "@cueloop/schema";
8
- import { openHerdrPane, openHerdrPaneForReview } from "./herdr-pane";
9
- import type { HerdrTabPersistence } from "./herdr-pane";
10
- import type { HerdrTabHandle } from "./herdr-tab-store";
11
-
12
- const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
13
-
14
- /**
15
- * A stub herdr binary. It logs argv, prints the tab-create result carrying both
16
- * pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
17
- * per `paneAlive` so the liveness branch can be exercised.
18
- */
19
- function makeStub(name: string, paneAlive = false) {
20
- const logPath = join(dir, `${name}.log`);
21
- const binPath = join(dir, `${name}.sh`);
22
- const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
23
-
24
- writeFileSync(
25
- binPath,
26
- `#!/bin/sh
27
- printf '%s\\n' "$*" >> "${logPath}"
28
- if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
29
- printf '{"result":{"root_pane":{"pane_id":"w1:p2","tab_id":"w1:t2"}}}'
30
- fi
31
- if [ "$1" = "pane" ] && [ "$2" = "get" ]; then ${paneGet}; fi
32
- exit 0
33
- `,
34
- );
35
- chmodSync(binPath, 0o755);
36
-
37
- return { binPath, logPath };
38
- }
39
-
40
- function readLines(logPath: string): string[] {
41
- if (!existsSync(logPath)) return [];
42
-
43
- return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
44
- }
45
-
46
- /** In-memory persistence double: records what openHerdrPaneForReview saves. */
47
- function fakePersistence(initial: HerdrTabHandle | null = null) {
48
- let stored = initial;
49
-
50
- return {
51
- persistence: {
52
- herdrGetTab: async () => stored,
53
- herdrSetTab: async (_sessionId: string, handle: HerdrTabHandle) => {
54
- stored = handle;
55
- },
56
- },
57
- saved: () => stored,
58
- };
59
- }
60
-
61
- function newSession(overrides: Partial<Thread> = {}): Thread {
62
- return {
63
- schemaVersion: "1",
64
- id: "ses_new1",
65
- workspace: { repoRoot: "/repo", branch: "main" },
66
- artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
67
- revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
68
- annotations: [],
69
- message: null,
70
- status: "pending",
71
- createdAt: "now",
72
- ...overrides,
73
- };
74
- }
75
-
76
- describe("openHerdrPane", () => {
77
- test("creates a focused tab, launches the review, and returns the handle", () => {
78
- // Arrange
79
- const stub = makeStub("open");
80
-
81
- // Act
82
- const handle = openHerdrPane({
83
- sessionId: "ses_abc",
84
- cwd: "/repo/work",
85
- binPath: stub.binPath,
86
- label: "Rollout Plan",
87
- });
88
-
89
- // Assert
90
- expect(handle).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
91
- expect(readLines(stub.logPath)).toEqual([
92
- "tab create --cwd /repo/work --label Rollout Plan --focus",
93
- "pane send-text w1:p2 cueloop ses_abc",
94
- "pane send-keys w1:p2 enter",
95
- ]);
96
- });
97
-
98
- test("returns null and never throws on a broken binary", () => {
99
- expect(
100
- openHerdrPane({
101
- sessionId: "ses_abc",
102
- cwd: "/repo/work",
103
- binPath: join(dir, "missing-bin"),
104
- label: "x",
105
- }),
106
- ).toBeNull();
107
- });
108
-
109
- test("returns null when tab create yields no ids - no send-text, no send-keys", () => {
110
- // Arrange
111
- const logPath = join(dir, "nopane.log");
112
- const binPath = join(dir, "nopane.sh");
113
-
114
- writeFileSync(
115
- binPath,
116
- `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
117
- );
118
- chmodSync(binPath, 0o755);
119
-
120
- // Act
121
- const handle = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
122
-
123
- // Assert
124
- expect(handle).toBeNull();
125
- expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
126
- });
127
- });
128
-
129
- describe("openHerdrPaneForReview", () => {
130
- test("opens and records a tab for a review with no recorded tab", async () => {
131
- // Arrange
132
- const stub = makeStub("gated-new");
133
- const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
134
- const store = fakePersistence(null);
135
-
136
- // Act
137
- await openHerdrPaneForReview(newSession({ id: "ses_xyz" }), store.persistence, env);
138
-
139
- // Assert
140
- expect(readLines(stub.logPath)).toEqual([
141
- "tab create --cwd /repo/work --label Rollout Plan --focus",
142
- "pane send-text w1:p2 cueloop ses_xyz",
143
- "pane send-keys w1:p2 enter",
144
- ]);
145
- expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
146
- });
147
-
148
- test("no-op outside herdr - no herdr process is spawned", async () => {
149
- // Arrange
150
- const stub = makeStub("gated-outside");
151
- const store = fakePersistence(null);
152
-
153
- // Act
154
- await openHerdrPaneForReview(newSession(), store.persistence, {
155
- HERDR_PANE_ID: "w1:p1",
156
- HERDR_BIN_PATH: stub.binPath,
157
- });
158
-
159
- // Assert
160
- expect(existsSync(stub.logPath)).toBeFalse();
161
- expect(store.saved()).toBeNull();
162
- });
163
-
164
- test("focuses the recorded tab when its pane is still alive, without reopening", async () => {
165
- // Arrange
166
- const stub = makeStub("gated-alive", true);
167
- const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
168
- const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
169
-
170
- // Act
171
- await openHerdrPaneForReview(newSession(), store.persistence, env);
172
-
173
- // Assert
174
- const lines = readLines(stub.logPath);
175
-
176
- expect(lines).toContain("pane get w9:p9");
177
- expect(lines).toContain("tab focus w9:t9");
178
- expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
179
- expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
180
- });
181
-
182
- test("a persistence failure never escapes - review creation stays best-effort", async () => {
183
- // Arrange - the daemon store rejects; the opener must still resolve quietly
184
- const stub = makeStub("gated-persist-fail");
185
- const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
186
- const persistence: HerdrTabPersistence = {
187
- herdrGetTab: async () => null,
188
- herdrSetTab: async () => {
189
- throw new Error("disk full");
190
- },
191
- };
192
-
193
- // Act & Assert - no throw
194
- await openHerdrPaneForReview(newSession(), persistence, env);
195
- expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
196
- });
197
-
198
- test("a recall failure (stale daemon) still opens a fresh tab", async () => {
199
- // Arrange - a daemon predating the herdr-tab primitives rejects the recall
200
- const stub = makeStub("gated-recall-fail");
201
- const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
202
- const persistence: HerdrTabPersistence = {
203
- herdrGetTab: async () => {
204
- throw new Error("unknown method herdr.getTab");
205
- },
206
- herdrSetTab: async () => {},
207
- };
208
-
209
- // Act
210
- await openHerdrPaneForReview(newSession({ id: "ses_stale" }), persistence, env);
211
-
212
- // Assert - the recall throwing must not abort the open
213
- expect(readLines(stub.logPath)).toEqual([
214
- "tab create --cwd /repo/work --label Rollout Plan --focus",
215
- "pane send-text w1:p2 cueloop ses_stale",
216
- "pane send-keys w1:p2 enter",
217
- ]);
218
- });
219
-
220
- test("reopens and re-records when the recorded pane is dead", async () => {
221
- // Arrange
222
- const stub = makeStub("gated-dead", false);
223
- const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
224
- const store = fakePersistence({ tabId: "old:t", paneId: "old:p" });
225
-
226
- // Act
227
- await openHerdrPaneForReview(newSession({ id: "ses_re" }), store.persistence, env);
228
-
229
- // Assert
230
- const lines = readLines(stub.logPath);
231
-
232
- expect(lines).toContain("pane get old:p");
233
- expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
234
- expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
235
- });
236
- });
237
-
238
- afterAll(() => {
239
- rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
240
- });
package/src/herdr-pane.ts DELETED
@@ -1,167 +0,0 @@
1
- /**
2
- * Opens a herdr tab that renders a review, from the creation sites (the Claude
3
- * Code hook and `cueloop session create`) that run inside a herdr pane. On a
4
- * resubmit it reopens only when the recorded pane is dead, so a live tab is
5
- * focused rather than duplicated. Best-effort: any spawn failure is swallowed
6
- * and never blocks review creation; each spawn is bounded by a timeout.
7
- */
8
-
9
- import { detectHerdr, type HerdrEnv, type Thread } from "@cueloop/schema";
10
- import * as v from "valibot";
11
- import type { HerdrTabHandle } from "./herdr-tab-store";
12
-
13
- const HERDR_SPAWN_TIMEOUT_MS = 2000;
14
- const CreatedTabSchema = v.object({
15
- result: v.optional(
16
- v.object({
17
- root_pane: v.optional(
18
- v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
19
- ),
20
- }),
21
- ),
22
- });
23
- const PaneResultSchema = v.object({
24
- result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
25
- });
26
-
27
- export interface OpenHerdrPaneOptions {
28
- sessionId: string;
29
- cwd: string;
30
- binPath: string;
31
- label: string;
32
- }
33
-
34
- /** The daemon-side handle store, as the two methods this module needs. DaemonClient satisfies it. */
35
- export interface HerdrTabPersistence {
36
- herdrGetTab(sessionId: string): Promise<HerdrTabHandle | null>;
37
- herdrSetTab(sessionId: string, handle: HerdrTabHandle): Promise<void>;
38
- }
39
-
40
- /**
41
- * Open a focused tab and launch the review in it. `tab create` prints
42
- * `{ result: { root_pane: { pane_id, tab_id } } }` (herdr 0.8.2); both ids are
43
- * returned so a later resubmit can liveness-check and focus. Null on any
44
- * best-effort bail-out.
45
- */
46
- export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | null {
47
- const { sessionId, cwd, binPath, label } = options;
48
-
49
- try {
50
- const created = Bun.spawnSync(
51
- [binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
52
- { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
53
- );
54
-
55
- if (created.exitCode !== 0) return null;
56
- const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
57
-
58
- if (!parsed.success) return null;
59
- const paneId = parsed.output.result?.root_pane?.pane_id;
60
- const tabId = parsed.output.result?.root_pane?.tab_id;
61
-
62
- if (!paneId || !tabId) return null;
63
- // A fresh tab hosts a plain shell, so the review is typed in like a human.
64
- const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
65
- stdout: "ignore",
66
- stderr: "ignore",
67
- timeout: HERDR_SPAWN_TIMEOUT_MS,
68
- });
69
-
70
- if (typed.exitCode !== 0) return null;
71
- const entered = Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
72
- stdout: "ignore",
73
- stderr: "ignore",
74
- timeout: HERDR_SPAWN_TIMEOUT_MS,
75
- });
76
-
77
- return entered.exitCode === 0 ? { tabId, paneId } : null;
78
- } catch {
79
- return null;
80
- }
81
- }
82
-
83
- /** True when the pane still exists (`herdr pane get` returns it). */
84
- function herdrPaneAlive(binPath: string, paneId: string): boolean {
85
- try {
86
- const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
87
- stdout: "pipe",
88
- stderr: "ignore",
89
- timeout: HERDR_SPAWN_TIMEOUT_MS,
90
- });
91
-
92
- if (got.exitCode !== 0) return false;
93
- const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
94
-
95
- return parsed.success && parsed.output.result?.pane != null;
96
- } catch {
97
- return false;
98
- }
99
- }
100
-
101
- /** Bring an existing review tab to the front; best-effort. */
102
- function focusHerdrTab(binPath: string, tabId: string): void {
103
- try {
104
- Bun.spawnSync([binPath, "tab", "focus", tabId], {
105
- stdout: "ignore",
106
- stderr: "ignore",
107
- timeout: HERDR_SPAWN_TIMEOUT_MS,
108
- });
109
- } catch {
110
- // best-effort
111
- }
112
- }
113
-
114
- /**
115
- * Ensure a herdr tab is rendering this review. Inside herdr only: focus the
116
- * recorded tab if its pane is still alive, otherwise open a fresh one and record
117
- * its handle. No-op outside herdr.
118
- */
119
- export async function openHerdrPaneForReview(
120
- session: Thread,
121
- persistence: HerdrTabPersistence,
122
- env: HerdrEnv = process.env,
123
- ): Promise<void> {
124
- const herdr = detectHerdr(env);
125
-
126
- if (!herdr) return;
127
- const recorded = await recallHerdrTab(persistence, session.id);
128
-
129
- if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
130
- focusHerdrTab(herdr.binPath, recorded.tabId);
131
-
132
- return;
133
- }
134
- const opened = openHerdrPane({
135
- sessionId: session.id,
136
- cwd: session.artifact.meta.cwd ?? process.cwd(),
137
- binPath: herdr.binPath,
138
- label: session.artifact.meta.title ?? session.id,
139
- });
140
-
141
- if (opened) await rememberHerdrTab(persistence, session.id, opened);
142
- }
143
-
144
- /** The recorded handle, or null on any recall failure - a stale daemon must still open a fresh tab, not abort. */
145
- async function recallHerdrTab(
146
- persistence: HerdrTabPersistence,
147
- sessionId: string,
148
- ): Promise<HerdrTabHandle | null> {
149
- try {
150
- return await persistence.herdrGetTab(sessionId);
151
- } catch {
152
- return null;
153
- }
154
- }
155
-
156
- /** Record the handle for later liveness checks; a store failure loses dedup, never the already-open tab. */
157
- async function rememberHerdrTab(
158
- persistence: HerdrTabPersistence,
159
- sessionId: string,
160
- handle: HerdrTabHandle,
161
- ): Promise<void> {
162
- try {
163
- await persistence.herdrSetTab(sessionId, handle);
164
- } catch {
165
- // stale daemon: the tab is already shown; only the liveness-dedup handle is lost
166
- }
167
- }
@@ -1,66 +0,0 @@
1
- /**
2
- * herdr adapter scratch, kept out of the core session record: the tab cueloop
3
- * opened to render each review, keyed by session id. One JSON map, atomic write.
4
- * The core `Thread` never mentions herdr; this side-store is the whole
5
- * leak boundary.
6
- */
7
-
8
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
9
- import * as v from "valibot";
10
- import { herdrTabsPath } from "./paths";
11
-
12
- /** The tab and its root pane: pane id checks liveness, tab id focuses it. */
13
- export interface HerdrTabHandle {
14
- tabId: string;
15
- paneId: string;
16
- }
17
-
18
- const HerdrTabHandleSchema = v.object({
19
- tabId: v.string(),
20
- paneId: v.string(),
21
- });
22
- const HerdrTabsSchema = v.record(v.string(), v.unknown());
23
-
24
- export class HerdrTabStore {
25
- private tabs = new Map<string, HerdrTabHandle>();
26
- private readonly path: string;
27
-
28
- constructor(home: string) {
29
- this.path = herdrTabsPath(home);
30
- this.load();
31
- }
32
-
33
- private load(): void {
34
- try {
35
- const parsed = v.parse(HerdrTabsSchema, JSON.parse(readFileSync(this.path, "utf8")));
36
-
37
- for (const [sessionId, value] of Object.entries(parsed)) {
38
- const handle = v.safeParse(HerdrTabHandleSchema, value);
39
-
40
- if (handle.success) this.tabs.set(sessionId, handle.output);
41
- }
42
- } catch {
43
- // absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
44
- }
45
- }
46
-
47
- private persist(): void {
48
- const tempPath = this.path + ".tmp";
49
-
50
- writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
51
- renameSync(tempPath, this.path);
52
- }
53
-
54
- get(sessionId: string): HerdrTabHandle | null {
55
- return this.tabs.get(sessionId) ?? null;
56
- }
57
-
58
- set(sessionId: string, handle: HerdrTabHandle): void {
59
- this.tabs.set(sessionId, handle);
60
- this.persist();
61
- }
62
-
63
- delete(sessionId: string): void {
64
- if (this.tabs.delete(sessionId)) this.persist();
65
- }
66
- }