@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.111

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 (85) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +104 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1483 -90
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +191 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +844 -95
  15. package/src/curate.ts +111 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +405 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +423 -0
  21. package/src/diff-watcher.test.ts +165 -0
  22. package/src/diff-watcher.ts +363 -0
  23. package/src/examples/daemon-sdk.ts +57 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +78 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +104 -0
  28. package/src/ghostty-thread-surface.test.ts +400 -0
  29. package/src/ghostty-thread-surface.ts +273 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +31 -0
  33. package/src/harness-handoff.test.ts +725 -0
  34. package/src/harness-handoff.ts +27 -0
  35. package/src/harness-state-store.test.ts +93 -0
  36. package/src/harness-state-store.ts +218 -0
  37. package/src/herdr-split.test.ts +106 -0
  38. package/src/herdr-split.ts +90 -0
  39. package/src/herdr-thread-surface-store.test.ts +81 -0
  40. package/src/herdr-thread-surface-store.ts +66 -0
  41. package/src/herdr-thread-surface.test.ts +465 -0
  42. package/src/herdr-thread-surface.ts +392 -0
  43. package/src/index.ts +8 -9
  44. package/src/interruptible-wait.test.ts +127 -0
  45. package/src/interruptible-wait.ts +69 -0
  46. package/src/jj-working-tree.ts +140 -0
  47. package/src/main.ts +5 -1
  48. package/src/operation-receipts.test.ts +111 -0
  49. package/src/operation-receipts.ts +104 -0
  50. package/src/paths.ts +49 -0
  51. package/src/pr-poller.test.ts +102 -0
  52. package/src/pr-poller.ts +80 -0
  53. package/src/project-files.test.ts +134 -0
  54. package/src/project-files.ts +72 -0
  55. package/src/protocol.ts +82 -7
  56. package/src/retention.test.ts +191 -0
  57. package/src/retention.ts +131 -0
  58. package/src/sdk-identities.ts +35 -0
  59. package/src/server.test.ts +502 -42
  60. package/src/server.ts +660 -88
  61. package/src/share-blob.test.ts +142 -0
  62. package/src/share-blob.ts +57 -0
  63. package/src/shared-agent-protocol.ts +30 -0
  64. package/src/single-instance.test.ts +165 -13
  65. package/src/store.conformance.test.ts +41 -0
  66. package/src/store.jsonl.test.ts +173 -0
  67. package/src/store.ts +278 -34
  68. package/src/testing/store-conformance.ts +192 -0
  69. package/src/thread-agent-validation.ts +141 -0
  70. package/src/thread-agent.test.ts +732 -0
  71. package/src/thread-agent.ts +1171 -0
  72. package/src/thread-review.test.ts +487 -0
  73. package/src/thread-review.ts +394 -0
  74. package/src/thread-subscription.test.ts +177 -0
  75. package/src/thread-subscription.ts +138 -0
  76. package/src/thread-surface-config.test.ts +63 -0
  77. package/src/thread-surface-config.ts +61 -0
  78. package/src/validate.test.ts +290 -37
  79. package/src/validate.ts +427 -33
  80. package/src/vcs-source.test.ts +180 -0
  81. package/src/vcs-source.ts +235 -0
  82. package/src/version.ts +6 -0
  83. package/src/working-tree.ts +154 -0
  84. package/src/review.test.ts +0 -167
  85. package/src/review.ts +0 -174
@@ -0,0 +1,27 @@
1
+ import type { Thread, ThreadAgentState } from "@cueloop/schema";
2
+
3
+ /** Handoff transfers continuation context, never provider credentials or private session files. */
4
+ export function harnessHandoffPrompt(thread: Thread, state: ThreadAgentState): string {
5
+ return `Prepare a continuation document for another agent taking over this Thread.
6
+ Do not execute tools or change files. Return only the handoff text.
7
+ Include the current goal, decisions, completed work, outstanding work, constraints, and suggested skills.
8
+ Reference existing artifacts by path or URL instead of duplicating them.
9
+ Redact credentials, passwords, and personal information.
10
+ Thread: ${thread.id}
11
+ Workspace: ${thread.workspace.repoRoot}
12
+ Reviewed artifact:
13
+ ${thread.artifact.content.slice(0, 32768)}
14
+ Pending continuation context:
15
+ ${state.continuation ?? ""}
16
+ Recent conversation:
17
+ ${state.messages
18
+ .filter((message) => message.complete)
19
+ .map((message) => `${message.role}: ${message.text}`)
20
+ .join("\n")
21
+ .slice(-65536)}`;
22
+ }
23
+
24
+ /** The receiving agent treats the handoff as context rather than a fresh user instruction. */
25
+ export function harnessContinuationPrompt(handoff: string, prompt: string): string {
26
+ return `Continuation context from the previous harness:\n${handoff}\n\nCurrent request:\n${prompt}`;
27
+ }
@@ -0,0 +1,93 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { HarnessStateStore } from "./harness-state-store";
6
+ import { harnessStatePath } from "./paths";
7
+
8
+ let home: string;
9
+
10
+ beforeEach(() => {
11
+ home = mkdtempSync(join(tmpdir(), "cueloop-harness-state-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(home, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("HarnessStateStore", () => {
19
+ test("redelivers an unacknowledged Message after reload", () => {
20
+ const store = new HarnessStateStore(home);
21
+ const binding = store.bind({
22
+ threadId: "ses_1",
23
+ harness: "fake",
24
+ harnessSessionId: "fake_1",
25
+ });
26
+ const delivery = store.enqueue({ bindingId: binding.id, messageId: "msg_1" });
27
+
28
+ expect(store.pending(binding.id)).toEqual([delivery]);
29
+
30
+ const reloaded = new HarnessStateStore(home);
31
+
32
+ expect(reloaded.pending(binding.id)).toEqual([delivery]);
33
+ });
34
+
35
+ test("does not redeliver an acknowledged Message", () => {
36
+ const store = new HarnessStateStore(home);
37
+ const binding = store.bind({
38
+ threadId: "ses_1",
39
+ harness: "fake",
40
+ harnessSessionId: "fake_1",
41
+ });
42
+ const delivery = store.enqueue({ bindingId: binding.id, messageId: "msg_1" });
43
+
44
+ store.acknowledge(delivery.id, "2026-09-20T20:00:00.000Z");
45
+
46
+ expect(new HarnessStateStore(home).pending(binding.id)).toEqual([]);
47
+ });
48
+
49
+ test("reuses the binding for the same harness session and thread", () => {
50
+ const store = new HarnessStateStore(home);
51
+ const input = {
52
+ threadId: "ses_1",
53
+ harness: "fake",
54
+ harnessSessionId: "fake_1",
55
+ };
56
+
57
+ expect(store.bind(input)).toEqual(store.bind(input));
58
+ });
59
+
60
+ test("deletes every harness binding and delivery for a Thread", () => {
61
+ const store = new HarnessStateStore(home);
62
+ const bindings = ["pi", "codex", "claude-code"].map((harness) =>
63
+ store.bind({ threadId: "ses_1", harness, harnessSessionId: `${harness}_1` }),
64
+ );
65
+ const deliveries = bindings.map((binding, index) =>
66
+ store.enqueue({ bindingId: binding.id, messageId: `msg_${index}` }),
67
+ );
68
+
69
+ expect(store.deleteThreadState("ses_1")).toBeTrue();
70
+ expect(store.deleteThreadState("ses_1")).toBeFalse();
71
+ const reloaded = new HarnessStateStore(home);
72
+
73
+ for (const binding of bindings) expect(reloaded.binding(binding.id)).toBeNull();
74
+ for (const delivery of deliveries) expect(reloaded.delivery(delivery.id)).toBeNull();
75
+ });
76
+
77
+ test("deletes state for Threads missing at startup", () => {
78
+ const store = new HarnessStateStore(home);
79
+
80
+ store.bind({ threadId: "ses_kept", harness: "pi", harnessSessionId: "pi_1" });
81
+ store.bind({ threadId: "ses_orphan", harness: "codex", harnessSessionId: "codex_1" });
82
+
83
+ expect(store.deleteOrphanedThreadState(new Set(["ses_kept"]))).toEqual(["ses_orphan"]);
84
+ expect(store.bindingsForSession("pi", "pi_1")).toHaveLength(1);
85
+ expect(store.bindingsForSession("codex", "codex_1")).toEqual([]);
86
+ });
87
+
88
+ test("does not discard pending deliveries when persisted state is corrupt", () => {
89
+ writeFileSync(harnessStatePath(home), "{broken");
90
+
91
+ expect(() => new HarnessStateStore(home)).toThrow();
92
+ });
93
+ });
@@ -0,0 +1,218 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import type { Delivery, HarnessBinding } from "@cueloop/schema";
3
+ import * as v from "valibot";
4
+ import { harnessStatePath } from "./paths";
5
+ import { DeliverySchema, HarnessBindingSchema } from "./validate";
6
+
7
+ type BindInput = Pick<HarnessBinding, "threadId" | "harness" | "harnessSessionId">;
8
+ type EnqueueInput = Pick<Delivery, "bindingId" | "messageId">;
9
+
10
+ const HarnessStateSchema = v.object({
11
+ bindings: v.array(HarnessBindingSchema),
12
+ deliveries: v.array(DeliverySchema),
13
+ });
14
+ const FileErrorSchema = v.object({ code: v.string() });
15
+
16
+ export class HarnessStateStore {
17
+ private readonly path: string;
18
+ private bindings = new Map<string, HarnessBinding>();
19
+ private deliveries = new Map<string, Delivery>();
20
+
21
+ constructor(home: string) {
22
+ this.path = harnessStatePath(home);
23
+ this.load();
24
+ }
25
+
26
+ bind(input: BindInput): HarnessBinding {
27
+ const existing = [...this.bindings.values()].find(
28
+ (binding) =>
29
+ binding.threadId === input.threadId &&
30
+ binding.harness === input.harness &&
31
+ binding.harnessSessionId === input.harnessSessionId,
32
+ );
33
+
34
+ if (existing) return existing;
35
+
36
+ const binding: HarnessBinding = {
37
+ ...input,
38
+ id: newRoutingId("bind"),
39
+ createdAt: new Date().toISOString(),
40
+ };
41
+
42
+ this.bindings.set(binding.id, binding);
43
+ this.persist();
44
+
45
+ return binding;
46
+ }
47
+
48
+ binding(id: string): HarnessBinding | null {
49
+ return this.bindings.get(id) ?? null;
50
+ }
51
+
52
+ bindingsForSession(harness: string, harnessSessionId: string): HarnessBinding[] {
53
+ return [...this.bindings.values()].filter(
54
+ (binding) => binding.harness === harness && binding.harnessSessionId === harnessSessionId,
55
+ );
56
+ }
57
+
58
+ deleteThreadState(threadId: string): boolean {
59
+ const bindingIds = new Set(
60
+ [...this.bindings.values()]
61
+ .filter((binding) => binding.threadId === threadId)
62
+ .map((binding) => binding.id),
63
+ );
64
+
65
+ return this.deleteBindingsAndDeliveries(bindingIds);
66
+ }
67
+
68
+ deleteOrphanedThreadState(existingThreadIds: ReadonlySet<string>): string[] {
69
+ const orphanedBindings = [...this.bindings.values()].filter(
70
+ (binding) => !existingThreadIds.has(binding.threadId),
71
+ );
72
+ const orphanedThreadIds = [...new Set(orphanedBindings.map((binding) => binding.threadId))];
73
+
74
+ this.deleteBindingsAndDeliveries(new Set(orphanedBindings.map((binding) => binding.id)));
75
+
76
+ return orphanedThreadIds;
77
+ }
78
+
79
+ private deleteBindingsAndDeliveries(bindingIds: ReadonlySet<string>): boolean {
80
+ if (bindingIds.size === 0) return false;
81
+
82
+ for (const bindingId of bindingIds) this.bindings.delete(bindingId);
83
+ for (const delivery of this.deliveries.values()) {
84
+ if (bindingIds.has(delivery.bindingId)) this.deliveries.delete(delivery.id);
85
+ }
86
+ this.persist();
87
+
88
+ return true;
89
+ }
90
+
91
+ consumeApprovedRetry(bindingId: string, messageId: string): boolean {
92
+ const binding = this.bindings.get(bindingId);
93
+
94
+ if (!binding) throw new Error(`no harness binding ${bindingId}`);
95
+
96
+ if (binding.approvedRetryMessageId === messageId) return false;
97
+
98
+ this.bindings.set(bindingId, { ...binding, approvedRetryMessageId: messageId });
99
+ this.persist();
100
+
101
+ return true;
102
+ }
103
+
104
+ /** The first binding is the submitter; other bindings need explicit future routing. */
105
+ submittingBinding(threadId: string): HarnessBinding | null {
106
+ return [...this.bindings.values()].find((binding) => binding.threadId === threadId) ?? null;
107
+ }
108
+
109
+ enqueue(input: EnqueueInput): Delivery {
110
+ const existing = [...this.deliveries.values()].find(
111
+ (delivery) =>
112
+ delivery.bindingId === input.bindingId && delivery.messageId === input.messageId,
113
+ );
114
+
115
+ if (existing) return existing;
116
+
117
+ const delivery: Delivery = {
118
+ ...input,
119
+ id: newRoutingId("del"),
120
+ status: "pending",
121
+ createdAt: new Date().toISOString(),
122
+ };
123
+
124
+ this.deliveries.set(delivery.id, delivery);
125
+ this.persist();
126
+
127
+ return delivery;
128
+ }
129
+
130
+ pending(bindingId: string): Delivery[] {
131
+ return [...this.deliveries.values()].filter(
132
+ (delivery) => delivery.bindingId === bindingId && delivery.status === "pending",
133
+ );
134
+ }
135
+
136
+ /** A pending delivery keeps its Thread's immutable Message body available for redelivery. */
137
+ pendingThreadIds(): Set<string> {
138
+ const ids = new Set<string>();
139
+
140
+ for (const delivery of this.deliveries.values()) {
141
+ if (delivery.status !== "pending") continue;
142
+
143
+ const binding = this.bindings.get(delivery.bindingId);
144
+
145
+ if (binding) ids.add(binding.threadId);
146
+ }
147
+
148
+ return ids;
149
+ }
150
+
151
+ acknowledged(bindingId: string, messageId: string): boolean {
152
+ return [...this.deliveries.values()].some(
153
+ (delivery) =>
154
+ delivery.bindingId === bindingId &&
155
+ delivery.messageId === messageId &&
156
+ delivery.status === "acknowledged",
157
+ );
158
+ }
159
+
160
+ delivery(deliveryId: string): Delivery | null {
161
+ return this.deliveries.get(deliveryId) ?? null;
162
+ }
163
+
164
+ acknowledge(deliveryId: string, acknowledgedAt = new Date().toISOString()): Delivery {
165
+ const delivery = this.deliveries.get(deliveryId);
166
+
167
+ if (!delivery) throw new Error(`no delivery ${deliveryId}`);
168
+
169
+ if (delivery.status === "acknowledged") return delivery;
170
+
171
+ const acknowledged: Delivery = {
172
+ ...delivery,
173
+ status: "acknowledged",
174
+ acknowledgedAt,
175
+ };
176
+
177
+ this.deliveries.set(deliveryId, acknowledged);
178
+ this.persist();
179
+
180
+ return acknowledged;
181
+ }
182
+
183
+ private load(): void {
184
+ let serialized: string;
185
+
186
+ try {
187
+ serialized = readFileSync(this.path, "utf8");
188
+ } catch (error) {
189
+ const parsed = v.safeParse(FileErrorSchema, error);
190
+
191
+ if (parsed.success && parsed.output.code === "ENOENT") {
192
+ return;
193
+ }
194
+
195
+ throw error;
196
+ }
197
+
198
+ const state = v.parse(HarnessStateSchema, JSON.parse(serialized));
199
+
200
+ this.bindings = new Map(state.bindings.map((binding) => [binding.id, binding]));
201
+ this.deliveries = new Map(state.deliveries.map((delivery) => [delivery.id, delivery]));
202
+ }
203
+
204
+ private persist(): void {
205
+ const temporaryPath = this.path + ".tmp";
206
+ const state = {
207
+ bindings: [...this.bindings.values()],
208
+ deliveries: [...this.deliveries.values()],
209
+ };
210
+
211
+ writeFileSync(temporaryPath, JSON.stringify(state, null, 2));
212
+ renameSync(temporaryPath, this.path);
213
+ }
214
+ }
215
+
216
+ function newRoutingId(prefix: "bind" | "del"): string {
217
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
218
+ }
@@ -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) {
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,90 @@
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
+ command: string;
22
+ cwd: string;
23
+ seedText?: string;
24
+ }
25
+
26
+ /**
27
+ * Split the current pane and run the harness in it. Returns true when the split
28
+ * opened, false on any best-effort bail-out. No-op outside herdr.
29
+ */
30
+ export function launchHarnessInSplit(
31
+ options: LaunchHarnessInSplitOptions,
32
+ env: HerdrEnv = process.env,
33
+ ): boolean {
34
+ const herdr = detectHerdr(env);
35
+
36
+ if (!herdr) return false;
37
+
38
+ try {
39
+ const split = Bun.spawnSync(
40
+ [
41
+ herdr.binPath,
42
+ "pane",
43
+ "split",
44
+ herdr.paneId,
45
+ "--direction",
46
+ "right",
47
+ "--ratio",
48
+ HARNESS_SPLIT_RATIO,
49
+ "--cwd",
50
+ options.cwd,
51
+ ],
52
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
53
+ );
54
+
55
+ if (split.exitCode !== 0) return false;
56
+
57
+ const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
58
+
59
+ if (!parsed.success) return false;
60
+
61
+ const paneId = parsed.output.result?.pane?.pane_id;
62
+
63
+ if (!paneId) return false;
64
+
65
+ sendText(herdr.binPath, paneId, options.command);
66
+ sendKeys(herdr.binPath, paneId, "enter");
67
+
68
+ if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
69
+
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function sendText(binPath: string, paneId: string, text: string): void {
77
+ Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
78
+ stdout: "ignore",
79
+ stderr: "ignore",
80
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
81
+ });
82
+ }
83
+
84
+ function sendKeys(binPath: string, paneId: string, keys: string): void {
85
+ Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
86
+ stdout: "ignore",
87
+ stderr: "ignore",
88
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
89
+ });
90
+ }
@@ -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,66 @@
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
+ layout?: "pair";
9
+ tabId: string;
10
+ paneId: string;
11
+ }
12
+
13
+ const HerdrThreadSurfaceHandleSchema = v.object({
14
+ tabId: v.string(),
15
+ paneId: v.string(),
16
+ mode: v.optional(v.picklist(["tab", "pane"])),
17
+ layout: v.optional(v.literal("pair")),
18
+ });
19
+ const HerdrThreadSurfaceMapSchema = v.record(v.string(), v.unknown());
20
+
21
+ export class HerdrThreadSurfaceStore {
22
+ private surfaces = new Map<string, HerdrThreadSurfaceHandle>();
23
+ private readonly path: string;
24
+
25
+ constructor(home: string) {
26
+ this.path = herdrThreadSurfacesPath(home);
27
+ this.load();
28
+ }
29
+
30
+ private load(): void {
31
+ try {
32
+ const parsed = v.parse(
33
+ HerdrThreadSurfaceMapSchema,
34
+ JSON.parse(readFileSync(this.path, "utf8")),
35
+ );
36
+
37
+ for (const [sessionId, value] of Object.entries(parsed)) {
38
+ const handle = v.safeParse(HerdrThreadSurfaceHandleSchema, value);
39
+
40
+ if (handle.success) this.surfaces.set(sessionId, handle.output);
41
+ }
42
+ } catch {
43
+ // A missing or corrupt side-store cannot block a pending Thread.
44
+ }
45
+ }
46
+
47
+ private persist(): void {
48
+ const tempPath = this.path + ".tmp";
49
+
50
+ writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.surfaces), null, 2));
51
+ renameSync(tempPath, this.path);
52
+ }
53
+
54
+ get(sessionId: string): HerdrThreadSurfaceHandle | null {
55
+ return this.surfaces.get(sessionId) ?? null;
56
+ }
57
+
58
+ set(sessionId: string, handle: HerdrThreadSurfaceHandle): void {
59
+ this.surfaces.set(sessionId, handle);
60
+ this.persist();
61
+ }
62
+
63
+ delete(sessionId: string): void {
64
+ if (this.surfaces.delete(sessionId)) this.persist();
65
+ }
66
+ }