@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61

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,243 @@
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 { ReviewSession } 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): { binPath: string; logPath: string } {
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
+ persistence: HerdrTabPersistence;
49
+ saved: () => HerdrTabHandle | null;
50
+ } {
51
+ let stored = initial;
52
+
53
+ return {
54
+ persistence: {
55
+ herdrGetTab: async () => stored,
56
+ herdrSetTab: async (_sessionId, handle) => {
57
+ stored = handle;
58
+ },
59
+ },
60
+ saved: () => stored,
61
+ };
62
+ }
63
+
64
+ function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
65
+ return {
66
+ schemaVersion: "1",
67
+ id: "ses_new1",
68
+ workspace: { repoRoot: "/repo", branch: "main" },
69
+ artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
70
+ revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
71
+ annotations: [],
72
+ verdict: null,
73
+ status: "pending",
74
+ createdAt: "now",
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ describe("openHerdrPane", () => {
80
+ test("creates a focused tab, launches the review, and returns the handle", () => {
81
+ // Arrange
82
+ const stub = makeStub("open");
83
+
84
+ // Act
85
+ const handle = openHerdrPane({
86
+ sessionId: "ses_abc",
87
+ cwd: "/repo/work",
88
+ binPath: stub.binPath,
89
+ label: "Rollout Plan",
90
+ });
91
+
92
+ // Assert
93
+ expect(handle).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
94
+ expect(readLines(stub.logPath)).toEqual([
95
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
96
+ "pane send-text w1:p2 cueloop ses_abc",
97
+ "pane send-keys w1:p2 enter",
98
+ ]);
99
+ });
100
+
101
+ test("returns null and never throws on a broken binary", () => {
102
+ expect(
103
+ openHerdrPane({
104
+ sessionId: "ses_abc",
105
+ cwd: "/repo/work",
106
+ binPath: join(dir, "missing-bin"),
107
+ label: "x",
108
+ }),
109
+ ).toBeNull();
110
+ });
111
+
112
+ test("returns null when tab create yields no ids - no send-text, no send-keys", () => {
113
+ // Arrange
114
+ const logPath = join(dir, "nopane.log");
115
+ const binPath = join(dir, "nopane.sh");
116
+
117
+ writeFileSync(
118
+ binPath,
119
+ `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
120
+ );
121
+ chmodSync(binPath, 0o755);
122
+
123
+ // Act
124
+ const handle = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
125
+
126
+ // Assert
127
+ expect(handle).toBeNull();
128
+ expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
129
+ });
130
+ });
131
+
132
+ describe("openHerdrPaneForReview", () => {
133
+ test("opens and records a tab for a review with no recorded tab", async () => {
134
+ // Arrange
135
+ const stub = makeStub("gated-new");
136
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
137
+ const store = fakePersistence(null);
138
+
139
+ // Act
140
+ await openHerdrPaneForReview(newSession({ id: "ses_xyz" }), store.persistence, env);
141
+
142
+ // Assert
143
+ expect(readLines(stub.logPath)).toEqual([
144
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
145
+ "pane send-text w1:p2 cueloop ses_xyz",
146
+ "pane send-keys w1:p2 enter",
147
+ ]);
148
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
149
+ });
150
+
151
+ test("no-op outside herdr - no herdr process is spawned", async () => {
152
+ // Arrange
153
+ const stub = makeStub("gated-outside");
154
+ const store = fakePersistence(null);
155
+
156
+ // Act
157
+ await openHerdrPaneForReview(newSession(), store.persistence, {
158
+ HERDR_PANE_ID: "w1:p1",
159
+ HERDR_BIN_PATH: stub.binPath,
160
+ });
161
+
162
+ // Assert
163
+ expect(existsSync(stub.logPath)).toBeFalse();
164
+ expect(store.saved()).toBeNull();
165
+ });
166
+
167
+ test("focuses the recorded tab when its pane is still alive, without reopening", async () => {
168
+ // Arrange
169
+ const stub = makeStub("gated-alive", true);
170
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
171
+ const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
172
+
173
+ // Act
174
+ await openHerdrPaneForReview(newSession(), store.persistence, env);
175
+
176
+ // Assert
177
+ const lines = readLines(stub.logPath);
178
+
179
+ expect(lines).toContain("pane get w9:p9");
180
+ expect(lines).toContain("tab focus w9:t9");
181
+ expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
182
+ expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
183
+ });
184
+
185
+ test("a persistence failure never escapes - review creation stays best-effort", async () => {
186
+ // Arrange - the daemon store rejects; the opener must still resolve quietly
187
+ const stub = makeStub("gated-persist-fail");
188
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
189
+ const persistence: HerdrTabPersistence = {
190
+ herdrGetTab: async () => null,
191
+ herdrSetTab: async () => {
192
+ throw new Error("disk full");
193
+ },
194
+ };
195
+
196
+ // Act & Assert - no throw
197
+ await openHerdrPaneForReview(newSession(), persistence, env);
198
+ expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
199
+ });
200
+
201
+ test("a recall failure (stale daemon) still opens a fresh tab", async () => {
202
+ // Arrange - a daemon predating the herdr-tab verbs rejects the recall
203
+ const stub = makeStub("gated-recall-fail");
204
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
205
+ const persistence: HerdrTabPersistence = {
206
+ herdrGetTab: async () => {
207
+ throw new Error("unknown method herdr.getTab");
208
+ },
209
+ herdrSetTab: async () => {},
210
+ };
211
+
212
+ // Act
213
+ await openHerdrPaneForReview(newSession({ id: "ses_stale" }), persistence, env);
214
+
215
+ // Assert - the recall throwing must not abort the open
216
+ expect(readLines(stub.logPath)).toEqual([
217
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
218
+ "pane send-text w1:p2 cueloop ses_stale",
219
+ "pane send-keys w1:p2 enter",
220
+ ]);
221
+ });
222
+
223
+ test("reopens and re-records when the recorded pane is dead", async () => {
224
+ // Arrange
225
+ const stub = makeStub("gated-dead", false);
226
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
227
+ const store = fakePersistence({ tabId: "old:t", paneId: "old:p" });
228
+
229
+ // Act
230
+ await openHerdrPaneForReview(newSession({ id: "ses_re" }), store.persistence, env);
231
+
232
+ // Assert
233
+ const lines = readLines(stub.logPath);
234
+
235
+ expect(lines).toContain("pane get old:p");
236
+ expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
237
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
238
+ });
239
+ });
240
+
241
+ afterAll(() => {
242
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
243
+ });
@@ -0,0 +1,154 @@
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 ReviewSession } from "@cueloop/schema";
10
+ import type { HerdrTabHandle } from "./herdr-tab-store";
11
+
12
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
13
+
14
+ export interface OpenHerdrPaneOptions {
15
+ sessionId: string;
16
+ cwd: string;
17
+ binPath: string;
18
+ label: string;
19
+ }
20
+
21
+ /** The daemon-side handle store, as the two methods this module needs. DaemonClient satisfies it. */
22
+ export interface HerdrTabPersistence {
23
+ herdrGetTab(sessionId: string): Promise<HerdrTabHandle | null>;
24
+ herdrSetTab(sessionId: string, handle: HerdrTabHandle): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Open a focused tab and launch the review in it. `tab create` prints
29
+ * `{ result: { root_pane: { pane_id, tab_id } } }` (herdr 0.8.2); both ids are
30
+ * returned so a later resubmit can liveness-check and focus. Null on any
31
+ * best-effort bail-out.
32
+ */
33
+ export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | null {
34
+ const { sessionId, cwd, binPath, label } = options;
35
+
36
+ try {
37
+ const created = Bun.spawnSync(
38
+ [binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
39
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
40
+ );
41
+
42
+ if (created.exitCode !== 0) return null;
43
+ const parsed = JSON.parse(created.stdout.toString()) as {
44
+ result?: { root_pane?: { pane_id?: string; tab_id?: string } };
45
+ };
46
+ const paneId = parsed.result?.root_pane?.pane_id;
47
+ const tabId = parsed.result?.root_pane?.tab_id;
48
+
49
+ if (!paneId || !tabId) return null;
50
+ // A fresh tab hosts a plain shell, so the review is typed in like a human.
51
+ const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
52
+ stdout: "ignore",
53
+ stderr: "ignore",
54
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
55
+ });
56
+
57
+ if (typed.exitCode !== 0) return null;
58
+ const entered = Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
59
+ stdout: "ignore",
60
+ stderr: "ignore",
61
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
62
+ });
63
+
64
+ return entered.exitCode === 0 ? { tabId, paneId } : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** True when the pane still exists (`herdr pane get` returns it). */
71
+ function herdrPaneAlive(binPath: string, paneId: string): boolean {
72
+ try {
73
+ const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
74
+ stdout: "pipe",
75
+ stderr: "ignore",
76
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
77
+ });
78
+
79
+ if (got.exitCode !== 0) return false;
80
+ const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: unknown } };
81
+
82
+ return parsed.result?.pane != null;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /** Bring an existing review tab to the front; best-effort. */
89
+ function focusHerdrTab(binPath: string, tabId: string): void {
90
+ try {
91
+ Bun.spawnSync([binPath, "tab", "focus", tabId], {
92
+ stdout: "ignore",
93
+ stderr: "ignore",
94
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
95
+ });
96
+ } catch {
97
+ // best-effort
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Ensure a herdr tab is rendering this review. Inside herdr only: focus the
103
+ * recorded tab if its pane is still alive, otherwise open a fresh one and record
104
+ * its handle. No-op outside herdr.
105
+ */
106
+ export async function openHerdrPaneForReview(
107
+ session: ReviewSession,
108
+ persistence: HerdrTabPersistence,
109
+ env: HerdrEnv = process.env,
110
+ ): Promise<void> {
111
+ const herdr = detectHerdr(env);
112
+
113
+ if (!herdr) return;
114
+ const recorded = await recallHerdrTab(persistence, session.id);
115
+
116
+ if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
117
+ focusHerdrTab(herdr.binPath, recorded.tabId);
118
+
119
+ return;
120
+ }
121
+ const opened = openHerdrPane({
122
+ sessionId: session.id,
123
+ cwd: session.artifact.meta.cwd ?? process.cwd(),
124
+ binPath: herdr.binPath,
125
+ label: session.artifact.meta.title ?? session.id,
126
+ });
127
+
128
+ if (opened) await rememberHerdrTab(persistence, session.id, opened);
129
+ }
130
+
131
+ /** The recorded handle, or null on any recall failure - a stale daemon must still open a fresh tab, not abort. */
132
+ async function recallHerdrTab(
133
+ persistence: HerdrTabPersistence,
134
+ sessionId: string,
135
+ ): Promise<HerdrTabHandle | null> {
136
+ try {
137
+ return await persistence.herdrGetTab(sessionId);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** Record the handle for later liveness checks; a store failure loses dedup, never the already-open tab. */
144
+ async function rememberHerdrTab(
145
+ persistence: HerdrTabPersistence,
146
+ sessionId: string,
147
+ handle: HerdrTabHandle,
148
+ ): Promise<void> {
149
+ try {
150
+ await persistence.herdrSetTab(sessionId, handle);
151
+ } catch {
152
+ // stale daemon: the tab is already shown; only the liveness-dedup handle is lost
153
+ }
154
+ }
@@ -0,0 +1,106 @@
1
+ /** launchHarnessInSplit: a stubbed herdr binary logs its argv, and the launcher splits the current pane, runs the harness in the new pane, and optionally types the plan-context seed - a no-op outside herdr. */
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 { launchHarnessInSplit } from "./herdr-split";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
10
+
11
+ /** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
12
+ function makeStub(name: string, splitOk = true): { binPath: string; logPath: string } {
13
+ const logPath = join(dir, `${name}.log`);
14
+ const binPath = join(dir, `${name}.sh`);
15
+ const splitResult = splitOk
16
+ ? `printf '{"result":{"pane":{"pane_id":"w1:p9"}}}'`
17
+ : `printf '{"result":{}}'`;
18
+
19
+ writeFileSync(
20
+ binPath,
21
+ `#!/bin/sh
22
+ printf '%s\\n' "$*" >> "${logPath}"
23
+ if [ "$1" = "pane" ] && [ "$2" = "split" ]; then ${splitResult}; fi
24
+ exit 0
25
+ `,
26
+ );
27
+ chmodSync(binPath, 0o755);
28
+
29
+ return { binPath, logPath };
30
+ }
31
+
32
+ function readLines(logPath: string): string[] {
33
+ if (!existsSync(logPath)) return [];
34
+
35
+ return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
36
+ }
37
+
38
+ describe("launchHarnessInSplit", () => {
39
+ test("splits the current pane and runs the harness in the new pane", () => {
40
+ // Arrange
41
+ const stub = makeStub("run");
42
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
43
+
44
+ // Act
45
+ const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo/work" }, env);
46
+
47
+ // Assert
48
+ expect(launched).toBeTrue();
49
+ expect(readLines(stub.logPath)).toEqual([
50
+ "pane split w1:p1 --direction right --ratio 0.4 --cwd /repo/work",
51
+ "pane send-text w1:p9 cc",
52
+ "pane send-keys w1:p9 enter",
53
+ ]);
54
+ });
55
+
56
+ test("types the plan-context seed into the split after launching", () => {
57
+ // Arrange
58
+ const stub = makeStub("seeded");
59
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
60
+
61
+ // Act
62
+ launchHarnessInSplit({ command: "pi", cwd: "/repo", seedText: "read plan ses_1" }, env);
63
+
64
+ // Assert
65
+ expect(readLines(stub.logPath)).toEqual([
66
+ "pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
67
+ "pane send-text w1:p9 pi",
68
+ "pane send-keys w1:p9 enter",
69
+ "pane send-text w1:p9 read plan ses_1",
70
+ ]);
71
+ });
72
+
73
+ test("no-op outside herdr - returns false and spawns nothing", () => {
74
+ // Arrange
75
+ const stub = makeStub("outside");
76
+
77
+ // Act
78
+ const launched = launchHarnessInSplit(
79
+ { command: "cc", cwd: "/repo" },
80
+ { HERDR_BIN_PATH: stub.binPath },
81
+ );
82
+
83
+ // Assert
84
+ expect(launched).toBeFalse();
85
+ expect(existsSync(stub.logPath)).toBeFalse();
86
+ });
87
+
88
+ test("returns false when the split yields no pane id", () => {
89
+ // Arrange
90
+ const stub = makeStub("nopane", false);
91
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
92
+
93
+ // Act
94
+ const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo" }, env);
95
+
96
+ // Assert - the split ran but no harness was launched into a missing pane
97
+ expect(launched).toBeFalse();
98
+ expect(readLines(stub.logPath)).toEqual([
99
+ "pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
100
+ ]);
101
+ });
102
+ });
103
+
104
+ afterAll(() => {
105
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
106
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Launches a bring-your-own review harness (claude code / pi / codex) in a herdr
3
+ * split beside the current pane - the Agent rail's "run it right here" spawn, so
4
+ * a reviewer chats with an agent about the plan without leaving the review tab.
5
+ * Best-effort and herdr-only: a no-op returning false outside herdr or on any
6
+ * spawn failure, each spawn bounded by a timeout.
7
+ */
8
+
9
+ import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
10
+
11
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
12
+
13
+ /** How much of the current pane the launched harness split takes (herdr --ratio). */
14
+ const HARNESS_SPLIT_RATIO = "0.4";
15
+
16
+ export interface LaunchHarnessInSplitOptions {
17
+ /** The harness launch command run in the split, e.g. "cc", "pi", "codex". */
18
+ command: string;
19
+ /** Working directory for the split - the reviewed session's cwd. */
20
+ cwd: string;
21
+ /**
22
+ * A plan-context briefing typed into the split after the harness command, left
23
+ * unsubmitted for the reviewer to send. Best-effort: on a slow harness start it
24
+ * may reach the shell instead, so the launch never depends on it.
25
+ */
26
+ seedText?: string;
27
+ }
28
+
29
+ /**
30
+ * Split the current pane and run the harness in it. Returns true when the split
31
+ * opened, false on any best-effort bail-out. No-op outside herdr.
32
+ */
33
+ export function launchHarnessInSplit(
34
+ options: LaunchHarnessInSplitOptions,
35
+ env: HerdrEnv = process.env,
36
+ ): boolean {
37
+ const herdr = detectHerdr(env);
38
+
39
+ if (!herdr) return false;
40
+ try {
41
+ const split = Bun.spawnSync(
42
+ [
43
+ herdr.binPath,
44
+ "pane",
45
+ "split",
46
+ herdr.paneId,
47
+ "--direction",
48
+ "right",
49
+ "--ratio",
50
+ HARNESS_SPLIT_RATIO,
51
+ "--cwd",
52
+ options.cwd,
53
+ ],
54
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
55
+ );
56
+
57
+ if (split.exitCode !== 0) return false;
58
+ const parsed = JSON.parse(split.stdout.toString()) as {
59
+ result?: { pane?: { pane_id?: string } };
60
+ };
61
+ const paneId = parsed.result?.pane?.pane_id;
62
+
63
+ if (!paneId) return false;
64
+ sendText(herdr.binPath, paneId, options.command);
65
+ sendKeys(herdr.binPath, paneId, "enter");
66
+ if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
67
+
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function sendText(binPath: string, paneId: string, text: string): void {
75
+ Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
76
+ stdout: "ignore",
77
+ stderr: "ignore",
78
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
79
+ });
80
+ }
81
+
82
+ function sendKeys(binPath: string, paneId: string, keys: string): void {
83
+ Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
84
+ stdout: "ignore",
85
+ stderr: "ignore",
86
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
87
+ });
88
+ }
@@ -0,0 +1,96 @@
1
+ /** herdr tab side-store: set/get/delete round-trips, survives a reload from disk, and ignores a corrupt file. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { HerdrTabStore } from "./herdr-tab-store";
8
+ import { herdrTabsPath } from "./paths";
9
+
10
+ let home: string;
11
+
12
+ beforeEach(() => {
13
+ home = mkdtempSync(join(tmpdir(), "cueloop-herdr-store-"));
14
+ });
15
+ afterEach(() => {
16
+ rmSync(home, { recursive: true, force: true });
17
+ });
18
+
19
+ describe("HerdrTabStore", () => {
20
+ test("set then get returns the handle; get for an unknown session is null", () => {
21
+ // Arrange
22
+ const store = new HerdrTabStore(home);
23
+
24
+ // Act
25
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
26
+
27
+ // Assert
28
+ expect(store.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
29
+ expect(store.get("ses_missing")).toBeNull();
30
+ });
31
+
32
+ test("handles survive a reload from disk", () => {
33
+ // Arrange
34
+ new HerdrTabStore(home).set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
35
+
36
+ // Act - a fresh store over the same home loads the persisted file
37
+ const reloaded = new HerdrTabStore(home);
38
+
39
+ // Assert
40
+ expect(reloaded.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
41
+ });
42
+
43
+ test("delete removes the handle", () => {
44
+ // Arrange
45
+ const store = new HerdrTabStore(home);
46
+
47
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
48
+
49
+ // Act
50
+ store.delete("ses_1");
51
+
52
+ // Assert
53
+ expect(store.get("ses_1")).toBeNull();
54
+ });
55
+
56
+ test("a corrupt file starts empty instead of throwing", () => {
57
+ // Arrange
58
+ writeFileSync(herdrTabsPath(home), "not json");
59
+
60
+ // Act
61
+ const store = new HerdrTabStore(home);
62
+
63
+ // Assert
64
+ expect(store.get("ses_1")).toBeNull();
65
+ });
66
+
67
+ test("malformed entries are dropped, well-formed ones survive", () => {
68
+ // Arrange - valid JSON, but entries with the wrong shape
69
+ writeFileSync(
70
+ herdrTabsPath(home),
71
+ JSON.stringify({
72
+ ses_ok: { tabId: "w1:t2", paneId: "w1:p2" },
73
+ ses_partial: { tabId: "w1:t2" },
74
+ ses_typed: { tabId: 1, paneId: 2 },
75
+ ses_null: null,
76
+ }),
77
+ );
78
+
79
+ // Act
80
+ const store = new HerdrTabStore(home);
81
+
82
+ // Assert
83
+ expect(store.get("ses_ok")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
84
+ expect(store.get("ses_partial")).toBeNull();
85
+ expect(store.get("ses_typed")).toBeNull();
86
+ expect(store.get("ses_null")).toBeNull();
87
+ });
88
+
89
+ test("a non-object top level starts empty", () => {
90
+ // Arrange
91
+ writeFileSync(herdrTabsPath(home), JSON.stringify(["not", "a", "map"]));
92
+
93
+ // Act & Assert
94
+ expect(new HerdrTabStore(home).get("ses_1")).toBeNull();
95
+ });
96
+ });