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

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,273 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, statSync } from "node:fs";
3
+ import { basename, delimiter, isAbsolute, join } from "node:path";
4
+ import type { Thread, ThreadSurfaceOpenStatus } from "@cueloop/schema";
5
+ import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
6
+ import { loadGhosttyThreadSurface, type GhosttyThreadSurface } from "./thread-surface-config";
7
+
8
+ const APPLESCRIPT_TIMEOUT_MS = 10000;
9
+ const VERSION_SCRIPT = 'tell application "Ghostty" to get version';
10
+ export const GHOSTTY_OPEN_APPLESCRIPT = `on run argv
11
+ set placement to item 1 of argv
12
+ set threadDirectory to item 2 of argv
13
+ set shellCommand to item 3 of argv
14
+ tell application "Ghostty"
15
+ if placement is "tab" then
16
+ set targetWindow to front window
17
+ set beforeCount to count of tabs of targetWindow
18
+ set beforeIds to id of every tab of targetWindow
19
+ set sourceTerminal to focused terminal of selected tab of targetWindow
20
+ if not (perform action "new_tab" on sourceTerminal) then error "Ghostty rejected new_tab"
21
+ repeat 50 times
22
+ if (count of tabs of targetWindow) > beforeCount then exit repeat
23
+ delay 0.1
24
+ end repeat
25
+ if (count of tabs of targetWindow) <= beforeCount then error "Ghostty did not create a tab"
26
+ set newTabs to {}
27
+ repeat with candidateTab in tabs of targetWindow
28
+ if (id of candidateTab) is not in beforeIds then set end of newTabs to candidateTab
29
+ end repeat
30
+ if (count of newTabs) is not 1 then error "Ghostty tab identity is ambiguous"
31
+ set createdTab to item 1 of newTabs
32
+ set createdTerminal to focused terminal of createdTab
33
+ else
34
+ set surfaceConfiguration to new surface configuration
35
+ set initial working directory of surfaceConfiguration to threadDirectory
36
+ if placement is "window" then
37
+ set createdWindow to new window with configuration surfaceConfiguration
38
+ set createdTerminal to focused terminal of selected tab of createdWindow
39
+ else
40
+ set targetWindow to front window
41
+ set sourceTerminal to focused terminal of selected tab of targetWindow
42
+ set createdTerminal to split sourceTerminal direction right with configuration surfaceConfiguration
43
+ end if
44
+ end if
45
+ try
46
+ repeat 50 times
47
+ if (name of createdTerminal) is not "👻" and (name of createdTerminal) is not "" then exit repeat
48
+ delay 0.1
49
+ end repeat
50
+ if (name of createdTerminal) is "👻" or (name of createdTerminal) is "" then error "Ghostty surface has no shell"
51
+ input text shellCommand to createdTerminal
52
+ send key "enter" to createdTerminal
53
+ focus createdTerminal
54
+ return id of createdTerminal
55
+ on error
56
+ if placement is "tab" then
57
+ close tab createdTab
58
+ else if placement is "window" then
59
+ close window createdWindow
60
+ else
61
+ close createdTerminal
62
+ end if
63
+ error "Ghostty surface did not launch the Thread"
64
+ end try
65
+ end tell
66
+ end run`;
67
+ const FOCUS_SCRIPT = `on run argv
68
+ set targetId to item 1 of argv
69
+ tell application "Ghostty"
70
+ repeat with candidate in terminals
71
+ if (id of candidate) is targetId then
72
+ focus candidate
73
+ return "focused"
74
+ end if
75
+ end repeat
76
+ end tell
77
+ return "closed"
78
+ end run`;
79
+ const CLOSE_SCRIPT = `on run argv
80
+ set targetId to item 1 of argv
81
+ tell application "Ghostty"
82
+ repeat with candidate in terminals
83
+ if (id of candidate) is targetId then
84
+ close candidate
85
+ return "closed"
86
+ end if
87
+ end repeat
88
+ end tell
89
+ return "absent"
90
+ end run`;
91
+
92
+ export interface GhosttyThreadSurfacePersistence {
93
+ ghosttyClaimThreadSurface(threadId: string): Promise<boolean>;
94
+ ghosttyReleaseThreadSurface(threadId: string): Promise<void>;
95
+ ghosttyGetThreadSurface(threadId: string): Promise<GhosttyThreadSurfaceHandle | null>;
96
+ ghosttySetThreadSurface(threadId: string, handle: GhosttyThreadSurfaceHandle): Promise<void>;
97
+ }
98
+
99
+ export interface GhosttyEnv {
100
+ [name: string]: string | undefined;
101
+ TERM_PROGRAM?: string;
102
+ GHOSTTY_RESOURCES_DIR?: string;
103
+ }
104
+
105
+ interface GhosttyLaunchResult {
106
+ status: ThreadSurfaceOpenStatus;
107
+ retainClaim: boolean;
108
+ }
109
+
110
+ function runAppleScript(binPath: string, script: string, args: string[] = []): string | null {
111
+ try {
112
+ const result = spawnSync(binPath, ["-e", script, ...args], {
113
+ encoding: "utf8",
114
+ stdio: ["ignore", "pipe", "ignore"],
115
+ timeout: APPLESCRIPT_TIMEOUT_MS,
116
+ });
117
+
118
+ return result.status === 0 ? result.stdout.trim() : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ export function resolveCueloopLaunchCommand(
125
+ executable = process.execPath,
126
+ entry = process.argv[1],
127
+ path = process.env.PATH,
128
+ ): string | null {
129
+ if (basename(executable) === "cueloop") return shellQuote(executable);
130
+
131
+ if (
132
+ entry &&
133
+ isAbsolute(entry) &&
134
+ entry.endsWith(join("packages", "cli", "src", "main.ts")) &&
135
+ existsSync(entry)
136
+ ) {
137
+ return `${shellQuote(executable)} run ${shellQuote(entry)}`;
138
+ }
139
+
140
+ for (const directory of path?.split(delimiter) ?? []) {
141
+ if (!isAbsolute(directory)) continue;
142
+
143
+ const candidate = join(directory, "cueloop");
144
+
145
+ try {
146
+ accessSync(candidate, constants.X_OK);
147
+
148
+ if (statSync(candidate).isFile()) return shellQuote(candidate);
149
+ } catch {
150
+ // A PATH entry without an executable cannot launch the Thread.
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ function shellQuote(value: string): string {
158
+ return `'${value.replaceAll("'", "'\\''")}'`;
159
+ }
160
+
161
+ /** Ghostty 1.3.0 introduced the native AppleScript commands used here. */
162
+ export function supportsGhosttyAppleScript(version: string | null): boolean {
163
+ const parsed = version?.match(/^(\d+)\.(\d+)\.(\d+)/);
164
+
165
+ if (!parsed) return false;
166
+
167
+ const major = Number(parsed[1]);
168
+ const minor = Number(parsed[2]);
169
+
170
+ return major > 1 || (major === 1 && minor >= 3);
171
+ }
172
+
173
+ /** Herdr selection happens before this adapter, even when it runs inside Ghostty. */
174
+ export function insideGhostty(env: GhosttyEnv = process.env): boolean {
175
+ return env.TERM_PROGRAM?.toLowerCase() === "ghostty" || Boolean(env.GHOSTTY_RESOURCES_DIR);
176
+ }
177
+
178
+ /** Reuse a live Ghostty terminal, reopen a closed one, or leave the Thread pending. */
179
+ export async function openGhosttyThreadSurface(
180
+ thread: Thread,
181
+ persistence: GhosttyThreadSurfacePersistence,
182
+ env: GhosttyEnv = process.env,
183
+ mode: GhosttyThreadSurface = loadGhosttyThreadSurface(),
184
+ binPath = "osascript",
185
+ platform: NodeJS.Platform = process.platform,
186
+ cueloopBinPath?: string | null,
187
+ ): Promise<ThreadSurfaceOpenStatus> {
188
+ if (platform !== "darwin" || !insideGhostty(env)) return "unavailable";
189
+
190
+ if (mode === "none") return "disabled";
191
+
192
+ if (!supportsGhosttyAppleScript(runAppleScript(binPath, VERSION_SCRIPT))) return "failed";
193
+
194
+ try {
195
+ if (!(await persistence.ghosttyClaimThreadSurface(thread.id))) return "failed";
196
+ } catch {
197
+ return "failed";
198
+ }
199
+
200
+ let result: GhosttyLaunchResult;
201
+
202
+ try {
203
+ result = await openClaimedGhosttyThreadSurface(
204
+ thread,
205
+ persistence,
206
+ mode,
207
+ binPath,
208
+ cueloopBinPath,
209
+ );
210
+ } catch {
211
+ result = { status: "failed", retainClaim: true };
212
+ }
213
+
214
+ if (result.retainClaim) return "failed";
215
+
216
+ try {
217
+ await persistence.ghosttyReleaseThreadSurface(thread.id);
218
+ } catch {
219
+ return "failed";
220
+ }
221
+
222
+ return result.status;
223
+ }
224
+
225
+ async function openClaimedGhosttyThreadSurface(
226
+ thread: Thread,
227
+ persistence: GhosttyThreadSurfacePersistence,
228
+ mode: Exclude<GhosttyThreadSurface, "none">,
229
+ binPath: string,
230
+ cueloopBinPath?: string | null,
231
+ ): Promise<GhosttyLaunchResult> {
232
+ let recorded: GhosttyThreadSurfaceHandle | null;
233
+
234
+ try {
235
+ recorded = await persistence.ghosttyGetThreadSurface(thread.id);
236
+ } catch {
237
+ return { status: "failed", retainClaim: false };
238
+ }
239
+
240
+ if (recorded) {
241
+ const result = runAppleScript(binPath, FOCUS_SCRIPT, [recorded.terminalId]);
242
+
243
+ if (result === "focused") return { status: "focused", retainClaim: false };
244
+
245
+ if (result !== "closed") return { status: "failed", retainClaim: false };
246
+ }
247
+
248
+ const launchCommand =
249
+ cueloopBinPath === undefined
250
+ ? resolveCueloopLaunchCommand()
251
+ : cueloopBinPath && shellQuote(cueloopBinPath);
252
+
253
+ if (!launchCommand) return { status: "failed", retainClaim: false };
254
+
255
+ const cwd = thread.artifact.meta.cwd ?? thread.workspace.repoRoot;
256
+ const terminalId = runAppleScript(binPath, GHOSTTY_OPEN_APPLESCRIPT, [
257
+ mode,
258
+ cwd,
259
+ `cd -- ${shellQuote(cwd)} && ${launchCommand} ${shellQuote(thread.id)}`,
260
+ ]);
261
+
262
+ if (!terminalId) return { status: "failed", retainClaim: false };
263
+
264
+ try {
265
+ await persistence.ghosttySetThreadSurface(thread.id, { terminalId });
266
+ } catch {
267
+ const closed = runAppleScript(binPath, CLOSE_SCRIPT, [terminalId]);
268
+
269
+ return { status: "failed", retainClaim: closed !== "closed" && closed !== "absent" };
270
+ }
271
+
272
+ return { status: "opened", retainClaim: false };
273
+ }
@@ -0,0 +1,269 @@
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 { DaemonCore } from "./api";
6
+ import { harnessStatePath } from "./paths";
7
+
8
+ let home: string;
9
+ let cores: DaemonCore[];
10
+
11
+ beforeEach(() => {
12
+ home = mkdtempSync(join(tmpdir(), "cueloop-harness-delivery-"));
13
+ cores = [];
14
+ });
15
+
16
+ afterEach(() => {
17
+ for (const core of cores) core.dispose();
18
+ rmSync(home, { recursive: true, force: true });
19
+ });
20
+
21
+ function core(): DaemonCore {
22
+ const instance = new DaemonCore(home);
23
+
24
+ cores.push(instance);
25
+
26
+ return instance;
27
+ }
28
+
29
+ function createPlan(instance: DaemonCore) {
30
+ return instance.sessionCreate({
31
+ workspace: { repoRoot: "/repo", branch: "main" },
32
+ artifact: { type: "plan", content: "# Plan", meta: {} },
33
+ });
34
+ }
35
+
36
+ describe("durable harness delivery", () => {
37
+ test("delivers Comment without resolving the Thread", () => {
38
+ const instance = core();
39
+ const thread = createPlan(instance);
40
+ const binding = instance.harnessBind({
41
+ threadId: thread.id,
42
+ harness: "fake",
43
+ harnessSessionId: "fake_1",
44
+ });
45
+
46
+ const sent = instance.sessionSendMessage(thread.id, "comment", "Interim note.");
47
+ const pending = instance.deliveryPending(binding.id);
48
+
49
+ expect(sent.status).toBe("pending");
50
+ expect(pending).toHaveLength(1);
51
+ expect(pending[0]!.message.outcome).toBe("comment");
52
+ });
53
+
54
+ test("reload finds only bindings for the active harness conversation", () => {
55
+ const first = core();
56
+ const thread = createPlan(first);
57
+ const current = first.harnessBind({
58
+ threadId: thread.id,
59
+ harness: "pi",
60
+ harnessSessionId: "pi-current",
61
+ });
62
+
63
+ first.harnessBind({ threadId: thread.id, harness: "pi", harnessSessionId: "pi-other" });
64
+ first.harnessBind({ threadId: thread.id, harness: "codex", harnessSessionId: "pi-current" });
65
+
66
+ expect(core().harnessBindingsForSession("pi", "pi-current")).toEqual([current]);
67
+ });
68
+
69
+ test("Thread deletion removes pi, Codex, and Claude harness state", () => {
70
+ const instance = core();
71
+ const thread = createPlan(instance);
72
+ const bindings = ["pi", "codex", "claude-code"].map((harness) =>
73
+ instance.harnessBind({
74
+ threadId: thread.id,
75
+ harness,
76
+ harnessSessionId: `${harness}-session`,
77
+ }),
78
+ );
79
+
80
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
81
+ const deliveryId = instance.deliveryPending(bindings[0]!.id)[0]!.delivery.id;
82
+
83
+ instance.sessionDelete(thread.id);
84
+
85
+ for (const [index, harness] of ["pi", "codex", "claude-code"].entries()) {
86
+ expect(instance.harnessBindingsForSession(harness, `${harness}-session`)).toEqual([]);
87
+ expect(() => instance.harnessGetBinding(bindings[index]!.id)).toThrow("no harness binding");
88
+ }
89
+ expect(() => instance.deliveryAcknowledge(deliveryId)).toThrow("no delivery");
90
+ });
91
+
92
+ test("startup removes harness state orphaned by an older deletion", () => {
93
+ const first = core();
94
+ const thread = createPlan(first);
95
+ const binding = first.harnessBind({
96
+ threadId: thread.id,
97
+ harness: "codex",
98
+ harnessSessionId: "codex-orphan",
99
+ });
100
+
101
+ first.sessionSendMessage(thread.id, "approved", "Ready.");
102
+ const deliveryId = first.deliveryPending(binding.id)[0]!.delivery.id;
103
+
104
+ first.store.delete(thread.id);
105
+ const restarted = core();
106
+
107
+ expect(restarted.harnessBindingsForSession("codex", "codex-orphan")).toEqual([]);
108
+ expect(() => restarted.harnessGetBinding(binding.id)).toThrow("no harness binding");
109
+ expect(() => restarted.deliveryAcknowledge(deliveryId)).toThrow("no delivery");
110
+ });
111
+
112
+ test("redelivers after reload until the harness acknowledges the Message", () => {
113
+ const first = core();
114
+ const thread = createPlan(first);
115
+ const binding = first.harnessBind({
116
+ threadId: thread.id,
117
+ harness: "fake",
118
+ harnessSessionId: "fake_1",
119
+ });
120
+ const sent = first.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
121
+ const initial = first.deliveryPending(binding.id);
122
+
123
+ expect(initial).toHaveLength(1);
124
+ expect(initial[0]!.message).toEqual(sent.message!);
125
+
126
+ const reloaded = core();
127
+ const redelivered = reloaded.deliveryPending(binding.id);
128
+
129
+ expect(redelivered).toEqual(initial);
130
+ reloaded.deliveryAcknowledge(redelivered[0]!.delivery.id);
131
+
132
+ expect(core().deliveryPending(binding.id)).toEqual([]);
133
+ });
134
+
135
+ test("retains an expired Thread until its pending Message is acknowledged", () => {
136
+ const first = core();
137
+ const thread = createPlan(first);
138
+ const binding = first.harnessBind({
139
+ threadId: thread.id,
140
+ harness: "fake",
141
+ harnessSessionId: "fake_1",
142
+ });
143
+
144
+ first.sessionSendMessage(thread.id, "approved", "Ready.");
145
+ const oldThread = first.sessionGet(thread.id);
146
+
147
+ first.store.upsert({ ...oldThread, createdAt: "2020-01-01T00:00:00.000Z" });
148
+
149
+ const reloaded = core();
150
+ const delivery = reloaded.deliveryPending(binding.id)[0];
151
+
152
+ expect(delivery?.message.body).toContain("Ready.");
153
+ reloaded.deliveryAcknowledge(delivery!.delivery.id);
154
+
155
+ expect(core().store.get(thread.id)).toBeUndefined();
156
+ });
157
+
158
+ test("rebuilds a delivery if the Thread saved its Message before enqueue", () => {
159
+ const first = core();
160
+ const thread = createPlan(first);
161
+ const binding = first.harnessBind({
162
+ threadId: thread.id,
163
+ harness: "fake",
164
+ harnessSessionId: "fake_1",
165
+ });
166
+ const sent = first.sessionSendMessage(thread.id, "approved", "Ready.");
167
+
168
+ first.store.upsert({
169
+ ...first.sessionGet(thread.id),
170
+ createdAt: "2020-01-01T00:00:00.000Z",
171
+ });
172
+
173
+ writeFileSync(harnessStatePath(home), JSON.stringify({ bindings: [binding], deliveries: [] }));
174
+
175
+ const recovered = core().deliveryPending(binding.id);
176
+
177
+ expect(recovered).toHaveLength(1);
178
+ expect(recovered[0]!.message).toEqual(sent.message!);
179
+ });
180
+
181
+ test("routes an approved retry as a new Message", () => {
182
+ const instance = core();
183
+ const thread = createPlan(instance);
184
+ const binding = instance.harnessBind({
185
+ threadId: thread.id,
186
+ harness: "fake",
187
+ harnessSessionId: "fake_1",
188
+ });
189
+
190
+ instance.sessionSendMessage(thread.id, "changes_requested", "Try once more.");
191
+ const first = instance.deliveryPending(binding.id)[0]!;
192
+
193
+ instance.deliveryAcknowledge(first.delivery.id);
194
+ instance.sessionSubmitRevision(thread.id, "# Plan");
195
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
196
+ const retry = instance.deliveryPending(binding.id);
197
+
198
+ expect(retry).toHaveLength(1);
199
+ expect(retry[0]!.message.outcome).toBe("approved");
200
+ expect(retry[0]!.message.id).not.toBe(first.message.id);
201
+ });
202
+
203
+ test("keeps an older pending Message available after a Thread revision", () => {
204
+ const instance = core();
205
+ const thread = createPlan(instance);
206
+ const binding = instance.harnessBind({
207
+ threadId: thread.id,
208
+ harness: "fake",
209
+ harnessSessionId: "fake_1",
210
+ });
211
+
212
+ instance.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
213
+ const first = instance.deliveryPending(binding.id)[0]!;
214
+
215
+ instance.sessionSubmitRevision(thread.id, "# Revised plan");
216
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
217
+
218
+ const pending = core().deliveryPending(binding.id);
219
+
220
+ expect(pending.map((item) => item.message.id)).toEqual([first.message.id, expect.any(String)]);
221
+ expect(pending[0]!.message.body).toBe(first.message.body);
222
+ });
223
+
224
+ test("defaults delivery to the submitting binding only", () => {
225
+ const instance = core();
226
+ const thread = createPlan(instance);
227
+ const submitter = instance.harnessBind({
228
+ threadId: thread.id,
229
+ harness: "fake",
230
+ harnessSessionId: "fake_1",
231
+ });
232
+ const other = instance.harnessBind({
233
+ threadId: thread.id,
234
+ harness: "other",
235
+ harnessSessionId: "other_1",
236
+ });
237
+
238
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
239
+
240
+ expect(instance.deliveryPending(submitter.id)).toHaveLength(1);
241
+ expect(instance.deliveryPending(other.id)).toEqual([]);
242
+ });
243
+
244
+ test("consumes the unchanged approved retry once across daemon reload", () => {
245
+ const instance = core();
246
+ const thread = createPlan(instance);
247
+ const binding = instance.harnessBind({
248
+ threadId: thread.id,
249
+ harness: "fake",
250
+ harnessSessionId: "fake_1",
251
+ });
252
+ const sent = instance.sessionSendMessage(thread.id, "approved", "Ready.");
253
+ const messageId = sent.message!.id;
254
+
255
+ expect(() => instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toThrow(
256
+ "no unchanged approved plan",
257
+ );
258
+
259
+ const delivery = instance.deliveryPending(binding.id)[0]!.delivery;
260
+
261
+ instance.deliveryAcknowledge(delivery.id);
262
+
263
+ expect(instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(true);
264
+ expect(core().harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(false);
265
+ expect(() => core().harnessConsumeApprovedRetry(binding.id, messageId, "# Changed")).toThrow(
266
+ "no unchanged approved plan",
267
+ );
268
+ });
269
+ });
@@ -0,0 +1,26 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { writeHarnessDiagnostic } from "./harness-diagnostics";
6
+
7
+ test("diagnostic logs are private and bounded across repeated notices", () => {
8
+ const directory = mkdtempSync(join(tmpdir(), "cueloop-diagnostics-"));
9
+ const path = join(directory, "thread.diagnostics.ndjson");
10
+
11
+ try {
12
+ for (let i = 0; i < 100; i++)
13
+ writeHarnessDiagnostic(directory, "thread", {
14
+ kind: "diagnostic",
15
+ severity: "error",
16
+ source: "protocol",
17
+ title: "Advisory",
18
+ text: "x".repeat(32768),
19
+ });
20
+ expect(statSync(path).size).toBeLessThanOrEqual(256 * 1024);
21
+ expect(statSync(path).mode & 0o777).toBe(0o600);
22
+ expect(JSON.parse(readFileSync(path, "utf8").trim().split("\n")[0]!).text).toHaveLength(16384);
23
+ } finally {
24
+ rmSync(directory, { recursive: true, force: true });
25
+ }
26
+ });
@@ -0,0 +1,31 @@
1
+ import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AgentHarnessDiagnostic } from "@cueloop/schema";
4
+
5
+ /** Store harness diagnostics in a private, bounded log, outside Thread history and model context. */
6
+ export function writeHarnessDiagnostic(
7
+ directory: string,
8
+ threadId: string,
9
+ diagnostic: AgentHarnessDiagnostic,
10
+ ): void {
11
+ const path = join(directory, `${encodeURIComponent(threadId)}.diagnostics.ndjson`);
12
+ const record =
13
+ JSON.stringify({
14
+ at: new Date().toISOString(),
15
+ ...diagnostic,
16
+ severity: diagnostic.severity.slice(0, 128),
17
+ title: diagnostic.title.slice(0, 512),
18
+ text: diagnostic.text.slice(0, 16384),
19
+ }) + "\n";
20
+
21
+ try {
22
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
23
+
24
+ if (existsSync(path) && statSync(path).size + Buffer.byteLength(record) > 256 * 1024)
25
+ writeFileSync(path, record, { mode: 0o600 });
26
+ else appendFileSync(path, record, { mode: 0o600 });
27
+ } catch {
28
+ // A diagnostic log failure must not change the agent turn outcome.
29
+ console.error("Thread agent diagnostic log could not be written");
30
+ }
31
+ }