@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,92 @@
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
+ import * as v from "valibot";
11
+
12
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
13
+ const SplitPaneSchema = v.object({
14
+ result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.optional(v.string()) })) })),
15
+ });
16
+
17
+ /** How much of the current pane the launched harness split takes (herdr --ratio). */
18
+ const HARNESS_SPLIT_RATIO = "0.4";
19
+
20
+ export interface LaunchHarnessInSplitOptions {
21
+ /** The harness launch command run in the split, e.g. "cc", "pi", "codex". */
22
+ command: string;
23
+ /** Working directory for the split - the reviewed session's cwd. */
24
+ cwd: string;
25
+ /**
26
+ * A plan-context briefing typed into the split after the harness command, left
27
+ * unsubmitted for the reviewer to send. Best-effort: on a slow harness start it
28
+ * may reach the shell instead, so the launch never depends on it.
29
+ */
30
+ seedText?: string;
31
+ }
32
+
33
+ /**
34
+ * Split the current pane and run the harness in it. Returns true when the split
35
+ * opened, false on any best-effort bail-out. No-op outside herdr.
36
+ */
37
+ export function launchHarnessInSplit(
38
+ options: LaunchHarnessInSplitOptions,
39
+ env: HerdrEnv = process.env,
40
+ ): boolean {
41
+ const herdr = detectHerdr(env);
42
+
43
+ if (!herdr) return false;
44
+ try {
45
+ const split = Bun.spawnSync(
46
+ [
47
+ herdr.binPath,
48
+ "pane",
49
+ "split",
50
+ herdr.paneId,
51
+ "--direction",
52
+ "right",
53
+ "--ratio",
54
+ HARNESS_SPLIT_RATIO,
55
+ "--cwd",
56
+ options.cwd,
57
+ ],
58
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
59
+ );
60
+
61
+ if (split.exitCode !== 0) return false;
62
+ const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
63
+
64
+ if (!parsed.success) return false;
65
+ const paneId = parsed.output.result?.pane?.pane_id;
66
+
67
+ if (!paneId) return false;
68
+ sendText(herdr.binPath, paneId, options.command);
69
+ sendKeys(herdr.binPath, paneId, "enter");
70
+ if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
71
+
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function sendText(binPath: string, paneId: string, text: string): void {
79
+ Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
80
+ stdout: "ignore",
81
+ stderr: "ignore",
82
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
83
+ });
84
+ }
85
+
86
+ function sendKeys(binPath: string, paneId: string, keys: string): void {
87
+ Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
88
+ stdout: "ignore",
89
+ stderr: "ignore",
90
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
91
+ });
92
+ }
@@ -0,0 +1,81 @@
1
+ /** Herdr Thread-surface handles survive reload and reject corrupt records. */
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 { HerdrThreadSurfaceStore } from "./herdr-thread-surface-store";
8
+ import { herdrThreadSurfacesPath } 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("HerdrThreadSurfaceStore", () => {
20
+ test("set then get returns the handle; get for an unknown session is null", () => {
21
+ const store = new HerdrThreadSurfaceStore(home);
22
+
23
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
24
+
25
+ expect(store.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
26
+ expect(store.get("ses_missing")).toBeNull();
27
+ });
28
+
29
+ test("handles survive a reload from disk", () => {
30
+ new HerdrThreadSurfaceStore(home).set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
31
+
32
+ const reloaded = new HerdrThreadSurfaceStore(home);
33
+
34
+ expect(reloaded.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
35
+ });
36
+
37
+ test("delete removes the handle", () => {
38
+ const store = new HerdrThreadSurfaceStore(home);
39
+
40
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
41
+
42
+ store.delete("ses_1");
43
+
44
+ expect(store.get("ses_1")).toBeNull();
45
+ });
46
+
47
+ test("a corrupt file starts empty instead of throwing", () => {
48
+ writeFileSync(herdrThreadSurfacesPath(home), "not json");
49
+
50
+ const store = new HerdrThreadSurfaceStore(home);
51
+
52
+ expect(store.get("ses_1")).toBeNull();
53
+ });
54
+
55
+ test("malformed entries are dropped, well-formed ones survive", () => {
56
+ writeFileSync(
57
+ herdrThreadSurfacesPath(home),
58
+ JSON.stringify({
59
+ ses_ok: { tabId: "w1:t2", paneId: "w1:p2" },
60
+ ses_partial: { tabId: "w1:t2" },
61
+ ses_typed: { tabId: 1, paneId: 2 },
62
+ ses_invalid_mode: { tabId: "w1:t2", paneId: "w1:p3", mode: "window" },
63
+ ses_null: null,
64
+ }),
65
+ );
66
+
67
+ const store = new HerdrThreadSurfaceStore(home);
68
+
69
+ expect(store.get("ses_ok")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
70
+ expect(store.get("ses_partial")).toBeNull();
71
+ expect(store.get("ses_typed")).toBeNull();
72
+ expect(store.get("ses_invalid_mode")).toBeNull();
73
+ expect(store.get("ses_null")).toBeNull();
74
+ });
75
+
76
+ test("a non-object top level starts empty", () => {
77
+ writeFileSync(herdrThreadSurfacesPath(home), JSON.stringify(["not", "a", "map"]));
78
+
79
+ expect(new HerdrThreadSurfaceStore(home).get("ses_1")).toBeNull();
80
+ });
81
+ });
@@ -0,0 +1,64 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import * as v from "valibot";
3
+ import { herdrThreadSurfacesPath } from "./paths";
4
+
5
+ /** Herdr-native handle, stored outside the canonical Thread. */
6
+ export interface HerdrThreadSurfaceHandle {
7
+ mode?: "tab" | "pane";
8
+ tabId: string;
9
+ paneId: string;
10
+ }
11
+
12
+ const HerdrThreadSurfaceHandleSchema = v.object({
13
+ tabId: v.string(),
14
+ paneId: v.string(),
15
+ mode: v.optional(v.picklist(["tab", "pane"])),
16
+ });
17
+ const HerdrThreadSurfaceMapSchema = v.record(v.string(), v.unknown());
18
+
19
+ export class HerdrThreadSurfaceStore {
20
+ private surfaces = new Map<string, HerdrThreadSurfaceHandle>();
21
+ private readonly path: string;
22
+
23
+ constructor(home: string) {
24
+ this.path = herdrThreadSurfacesPath(home);
25
+ this.load();
26
+ }
27
+
28
+ private load(): void {
29
+ try {
30
+ const parsed = v.parse(
31
+ HerdrThreadSurfaceMapSchema,
32
+ JSON.parse(readFileSync(this.path, "utf8")),
33
+ );
34
+
35
+ for (const [sessionId, value] of Object.entries(parsed)) {
36
+ const handle = v.safeParse(HerdrThreadSurfaceHandleSchema, value);
37
+
38
+ if (handle.success) this.surfaces.set(sessionId, handle.output);
39
+ }
40
+ } catch {
41
+ // A missing or corrupt side-store cannot block a pending Thread.
42
+ }
43
+ }
44
+
45
+ private persist(): void {
46
+ const tempPath = this.path + ".tmp";
47
+
48
+ writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.surfaces), null, 2));
49
+ renameSync(tempPath, this.path);
50
+ }
51
+
52
+ get(sessionId: string): HerdrThreadSurfaceHandle | null {
53
+ return this.surfaces.get(sessionId) ?? null;
54
+ }
55
+
56
+ set(sessionId: string, handle: HerdrThreadSurfaceHandle): void {
57
+ this.surfaces.set(sessionId, handle);
58
+ this.persist();
59
+ }
60
+
61
+ delete(sessionId: string): void {
62
+ if (this.surfaces.delete(sessionId)) this.persist();
63
+ }
64
+ }
@@ -0,0 +1,411 @@
1
+ /** Herdr Thread launching with a stub CLI, including tab and pane reuse. */
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 {
9
+ openHerdrThreadTab,
10
+ openHerdrThreadPane,
11
+ openHerdrThreadSurface,
12
+ } from "./herdr-thread-surface";
13
+ import type { HerdrThreadSurfacePersistence } from "./herdr-thread-surface";
14
+ import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
15
+
16
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-thread-surface-"));
17
+
18
+ /**
19
+ * A stub herdr binary. It logs argv, prints the tab-create result carrying both
20
+ * pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
21
+ * per `paneAlive` so the liveness branch can be exercised.
22
+ */
23
+ function makeStub(
24
+ name: string,
25
+ paneAlive = false,
26
+ leftNeighborPaneId = "w1:p1",
27
+ failedCommand?: "send-text" | "send-keys",
28
+ ) {
29
+ const logPath = join(dir, `${name}.log`);
30
+ const binPath = join(dir, `${name}.sh`);
31
+ const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
32
+
33
+ writeFileSync(
34
+ binPath,
35
+ `#!/bin/sh
36
+ printf '%s\\n' "$*" >> "${logPath}"
37
+ if [ "$1" = "pane" ] && [ "$2" = "${failedCommand ?? "never"}" ]; then exit 1; fi
38
+ if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
39
+ printf '{"result":{"root_pane":{"pane_id":"w1:p2","tab_id":"w1:t2"}}}'
40
+ fi
41
+ if [ "$1" = "pane" ] && [ "$2" = "split" ]; then
42
+ printf '{"result":{"pane":{"pane_id":"w1:p3"}}}'
43
+ fi
44
+ if [ "$1" = "pane" ] && [ "$2" = "neighbor" ]; then
45
+ if [ "$4" = "w1:p3" ] && [ "$6" = "left" ]; then
46
+ printf '{"result":{"neighbor":{"neighbor_pane_id":"${leftNeighborPaneId}"}}}'
47
+ elif [ "$4" = "${leftNeighborPaneId}" ] && [ "$6" = "right" ]; then
48
+ printf '{"result":{"neighbor":{"neighbor_pane_id":"w1:p3"}}}'
49
+ else
50
+ printf '{"result":{"neighbor":{"neighbor_pane_id":null}}}'
51
+ fi
52
+ fi
53
+ if [ "$1" = "pane" ] && [ "$2" = "focus" ]; then
54
+ printf '{"result":{"focus":{"focused_pane_id":"w1:p3"}}}'
55
+ fi
56
+ if [ "$1" = "pane" ] && [ "$2" = "get" ]; then ${paneGet}; fi
57
+ exit 0
58
+ `,
59
+ );
60
+ chmodSync(binPath, 0o755);
61
+
62
+ return { binPath, logPath };
63
+ }
64
+
65
+ function readLines(logPath: string): string[] {
66
+ if (!existsSync(logPath)) return [];
67
+
68
+ return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
69
+ }
70
+
71
+ /** In-memory persistence double for native Thread handles. */
72
+ function fakePersistence(initial: HerdrThreadSurfaceHandle | null = null) {
73
+ let stored = initial;
74
+
75
+ return {
76
+ persistence: {
77
+ herdrGetThreadSurface: async () => stored,
78
+ herdrSetThreadSurface: async (_sessionId: string, handle: HerdrThreadSurfaceHandle) => {
79
+ stored = handle;
80
+ },
81
+ },
82
+ saved: () => stored,
83
+ };
84
+ }
85
+
86
+ function newSession(overrides: Partial<Thread> = {}): Thread {
87
+ return {
88
+ schemaVersion: "1",
89
+ id: "ses_new1",
90
+ workspace: { repoRoot: "/repo", branch: "main" },
91
+ artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
92
+ revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
93
+ annotations: [],
94
+ message: null,
95
+ status: "pending",
96
+ createdAt: "now",
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ describe("openHerdrThreadTab", () => {
102
+ test("creates a focused tab, launches the review, and returns the handle", () => {
103
+ const stub = makeStub("open");
104
+
105
+ const handle = openHerdrThreadTab({
106
+ sessionId: "ses_abc",
107
+ cwd: "/repo/work",
108
+ binPath: stub.binPath,
109
+ label: "Rollout Plan",
110
+ });
111
+
112
+ expect(handle).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
113
+ expect(readLines(stub.logPath)).toEqual([
114
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
115
+ "pane send-text w1:p2 cueloop ses_abc",
116
+ "pane send-keys w1:p2 enter",
117
+ ]);
118
+ });
119
+
120
+ test("returns null and never throws on a broken binary", () => {
121
+ expect(
122
+ openHerdrThreadTab({
123
+ sessionId: "ses_abc",
124
+ cwd: "/repo/work",
125
+ binPath: join(dir, "missing-bin"),
126
+ label: "x",
127
+ }),
128
+ ).toBeNull();
129
+ });
130
+
131
+ test("returns null when tab create yields no ids - no send-text, no send-keys", () => {
132
+ const logPath = join(dir, "nopane.log");
133
+ const binPath = join(dir, "nopane.sh");
134
+
135
+ writeFileSync(
136
+ binPath,
137
+ `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
138
+ );
139
+ chmodSync(binPath, 0o755);
140
+
141
+ const handle = openHerdrThreadTab({
142
+ sessionId: "ses_abc",
143
+ cwd: "/repo/work",
144
+ binPath,
145
+ label: "x",
146
+ });
147
+
148
+ expect(handle).toBeNull();
149
+ expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
150
+ });
151
+
152
+ test("closes a tab if the Thread command cannot be sent", () => {
153
+ const stub = makeStub("tab-send-failed", false, "w1:p1", "send-text");
154
+
155
+ expect(
156
+ openHerdrThreadTab({
157
+ sessionId: "ses_abc",
158
+ cwd: "/repo/work",
159
+ binPath: stub.binPath,
160
+ label: "Rollout Plan",
161
+ }),
162
+ ).toBeNull();
163
+ expect(readLines(stub.logPath)).toEqual([
164
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
165
+ "pane send-text w1:p2 cueloop ses_abc",
166
+ "tab close w1:t2",
167
+ ]);
168
+ });
169
+ });
170
+
171
+ describe("openHerdrThreadPane", () => {
172
+ test("creates a focused right-hand pane at 50 percent and launches cueloop", () => {
173
+ const stub = makeStub("split");
174
+ const handle = openHerdrThreadPane({
175
+ sessionId: "ses_abc",
176
+ cwd: "/repo/work",
177
+ binPath: stub.binPath,
178
+ sourcePaneId: "w1:p1",
179
+ tabId: "w1:t1",
180
+ });
181
+
182
+ expect(handle).toEqual({
183
+ mode: "pane",
184
+ tabId: "w1:t1",
185
+ paneId: "w1:p3",
186
+ });
187
+ expect(readLines(stub.logPath)).toEqual([
188
+ "pane split w1:p1 --direction right --ratio 0.5 --cwd /repo/work --focus",
189
+ "pane send-text w1:p3 cueloop ses_abc",
190
+ "pane send-keys w1:p3 enter",
191
+ ]);
192
+ });
193
+
194
+ test("closes a pane if the Thread command cannot be entered", () => {
195
+ const stub = makeStub("pane-enter-failed", false, "w1:p1", "send-keys");
196
+
197
+ expect(
198
+ openHerdrThreadPane({
199
+ sessionId: "ses_abc",
200
+ cwd: "/repo/work",
201
+ binPath: stub.binPath,
202
+ sourcePaneId: "w1:p1",
203
+ tabId: "w1:t1",
204
+ }),
205
+ ).toBeNull();
206
+ expect(readLines(stub.logPath)).toEqual([
207
+ "pane split w1:p1 --direction right --ratio 0.5 --cwd /repo/work --focus",
208
+ "pane send-text w1:p3 cueloop ses_abc",
209
+ "pane send-keys w1:p3 enter",
210
+ "pane close w1:p3",
211
+ ]);
212
+ });
213
+ });
214
+
215
+ describe("openHerdrThreadSurface", () => {
216
+ test("tab mode targets the calling Herdr workspace", async () => {
217
+ const stub = makeStub("workspace");
218
+ const store = fakePersistence();
219
+ const env = {
220
+ HERDR_ENV: "1",
221
+ HERDR_PANE_ID: "w1:p1",
222
+ HERDR_WORKSPACE_ID: "w1",
223
+ HERDR_BIN_PATH: stub.binPath,
224
+ };
225
+
226
+ expect(await openHerdrThreadSurface(newSession(), store.persistence, env, "tab")).toBe(
227
+ "opened",
228
+ );
229
+ expect(readLines(stub.logPath)[0]).toBe(
230
+ "tab create --workspace w1 --cwd /repo/work --label Rollout Plan --focus",
231
+ );
232
+ });
233
+
234
+ test("pane mode focuses a live remembered pane and reopens a closed one", async () => {
235
+ const stub = makeStub("pane-reopen", false);
236
+ const env = {
237
+ HERDR_ENV: "1",
238
+ HERDR_PANE_ID: "w1:p1",
239
+ HERDR_TAB_ID: "w1:t1",
240
+ HERDR_BIN_PATH: stub.binPath,
241
+ };
242
+ const store = fakePersistence({
243
+ mode: "pane",
244
+ tabId: "w1:t1",
245
+ paneId: "w1:p9",
246
+ });
247
+
248
+ expect(await openHerdrThreadSurface(newSession(), store.persistence, env, "pane")).toBe(
249
+ "opened",
250
+ );
251
+ expect(readLines(stub.logPath)).toContain("pane get w1:p9");
252
+ expect(store.saved()).toEqual({
253
+ mode: "pane",
254
+ tabId: "w1:t1",
255
+ paneId: "w1:p3",
256
+ });
257
+
258
+ const live = makeStub("pane-focus", true);
259
+ const liveEnv = { ...env, HERDR_BIN_PATH: live.binPath };
260
+
261
+ expect(await openHerdrThreadSurface(newSession(), store.persistence, liveEnv, "pane")).toBe(
262
+ "focused",
263
+ );
264
+ expect(readLines(live.logPath)).toEqual([
265
+ "pane get w1:p3",
266
+ "tab focus w1:t1",
267
+ "pane neighbor --pane w1:p3 --direction left",
268
+ "pane neighbor --pane w1:p1 --direction right",
269
+ "pane focus --pane w1:p1 --direction right",
270
+ ]);
271
+ });
272
+
273
+ test("a moved live pane focuses through its new neighbor without opening a duplicate", async () => {
274
+ const stub = makeStub("pane-moved", true, "w1:p4");
275
+ const store = fakePersistence({
276
+ mode: "pane",
277
+ tabId: "w1:t1",
278
+ paneId: "w1:p3",
279
+ });
280
+ const env = {
281
+ HERDR_ENV: "1",
282
+ HERDR_PANE_ID: "w1:p1",
283
+ HERDR_TAB_ID: "w1:t1",
284
+ HERDR_BIN_PATH: stub.binPath,
285
+ };
286
+
287
+ expect(await openHerdrThreadSurface(newSession(), store.persistence, env, "pane")).toBe(
288
+ "focused",
289
+ );
290
+ expect(readLines(stub.logPath)).toEqual([
291
+ "pane get w1:p3",
292
+ "tab focus w1:t1",
293
+ "pane neighbor --pane w1:p3 --direction left",
294
+ "pane neighbor --pane w1:p4 --direction right",
295
+ "pane focus --pane w1:p4 --direction right",
296
+ ]);
297
+ });
298
+
299
+ test("none never launches, and a launch failure reports failure without resolving the Thread", async () => {
300
+ const stub = makeStub("disabled");
301
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
302
+ const store = fakePersistence();
303
+
304
+ expect(await openHerdrThreadSurface(newSession(), store.persistence, env, "none")).toBe(
305
+ "disabled",
306
+ );
307
+ expect(readLines(stub.logPath)).toEqual([]);
308
+ expect(
309
+ await openHerdrThreadSurface(
310
+ newSession(),
311
+ store.persistence,
312
+ { ...env, HERDR_BIN_PATH: join(dir, "missing-binary") },
313
+ "tab",
314
+ ),
315
+ ).toBe("failed");
316
+ expect(store.saved()).toBeNull();
317
+ });
318
+ test("opens and records a tab for a review with no recorded tab", async () => {
319
+ const stub = makeStub("gated-new");
320
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
321
+ const store = fakePersistence(null);
322
+
323
+ await openHerdrThreadSurface(newSession({ id: "ses_xyz" }), store.persistence, env);
324
+
325
+ expect(readLines(stub.logPath)).toEqual([
326
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
327
+ "pane send-text w1:p2 cueloop ses_xyz",
328
+ "pane send-keys w1:p2 enter",
329
+ ]);
330
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
331
+ });
332
+
333
+ test("no-op outside herdr - no herdr process is spawned", async () => {
334
+ const stub = makeStub("gated-outside");
335
+ const store = fakePersistence(null);
336
+
337
+ await openHerdrThreadSurface(newSession(), store.persistence, {
338
+ HERDR_PANE_ID: "w1:p1",
339
+ HERDR_BIN_PATH: stub.binPath,
340
+ });
341
+
342
+ expect(existsSync(stub.logPath)).toBeFalse();
343
+ expect(store.saved()).toBeNull();
344
+ });
345
+
346
+ test("focuses the recorded tab when its pane is still alive, without reopening", async () => {
347
+ const stub = makeStub("gated-alive", true);
348
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
349
+ const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
350
+
351
+ await openHerdrThreadSurface(newSession(), store.persistence, env);
352
+
353
+ const lines = readLines(stub.logPath);
354
+
355
+ expect(lines).toContain("pane get w9:p9");
356
+ expect(lines).toContain("tab focus w9:t9");
357
+ expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
358
+ expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
359
+ });
360
+
361
+ test("a persistence failure never escapes - review creation stays best-effort", async () => {
362
+ const stub = makeStub("gated-persist-fail");
363
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
364
+ const persistence: HerdrThreadSurfacePersistence = {
365
+ herdrGetThreadSurface: async () => null,
366
+ herdrSetThreadSurface: async () => {
367
+ throw new Error("disk full");
368
+ },
369
+ };
370
+
371
+ await openHerdrThreadSurface(newSession(), persistence, env);
372
+ expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
373
+ });
374
+
375
+ test("a recall failure (stale daemon) still opens a fresh tab", async () => {
376
+ const stub = makeStub("gated-recall-fail");
377
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
378
+ const persistence: HerdrThreadSurfacePersistence = {
379
+ herdrGetThreadSurface: async () => {
380
+ throw new Error("unknown method herdr.getThreadSurface");
381
+ },
382
+ herdrSetThreadSurface: async () => {},
383
+ };
384
+
385
+ await openHerdrThreadSurface(newSession({ id: "ses_stale" }), persistence, env);
386
+
387
+ expect(readLines(stub.logPath)).toEqual([
388
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
389
+ "pane send-text w1:p2 cueloop ses_stale",
390
+ "pane send-keys w1:p2 enter",
391
+ ]);
392
+ });
393
+
394
+ test("reopens and re-records when the recorded pane is dead", async () => {
395
+ const stub = makeStub("gated-dead", false);
396
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
397
+ const store = fakePersistence({ tabId: "old:t", paneId: "old:p" });
398
+
399
+ await openHerdrThreadSurface(newSession({ id: "ses_re" }), store.persistence, env);
400
+
401
+ const lines = readLines(stub.logPath);
402
+
403
+ expect(lines).toContain("pane get old:p");
404
+ expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
405
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
406
+ });
407
+ });
408
+
409
+ afterAll(() => {
410
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
411
+ });