@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (56) hide show
  1. package/package.json +37 -22
  2. package/src/api.test.ts +940 -65
  3. package/src/api.ts +1095 -88
  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 +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/ghostty-thread-surface-store.test.ts +49 -0
  14. package/src/ghostty-thread-surface-store.ts +101 -0
  15. package/src/ghostty-thread-surface.test.ts +398 -0
  16. package/src/ghostty-thread-surface.ts +267 -0
  17. package/src/harness-delivery.test.ts +208 -0
  18. package/src/harness-state-store.test.ts +65 -0
  19. package/src/harness-state-store.ts +181 -0
  20. package/src/herdr-split.test.ts +106 -0
  21. package/src/herdr-split.ts +92 -0
  22. package/src/herdr-thread-surface-store.test.ts +81 -0
  23. package/src/herdr-thread-surface-store.ts +64 -0
  24. package/src/herdr-thread-surface.test.ts +411 -0
  25. package/src/herdr-thread-surface.ts +355 -0
  26. package/src/index.ts +8 -9
  27. package/src/interruptible-wait.test.ts +127 -0
  28. package/src/interruptible-wait.ts +66 -0
  29. package/src/main.ts +4 -1
  30. package/src/paths.ts +49 -0
  31. package/src/pr-poller.test.ts +76 -0
  32. package/src/pr-poller.ts +74 -0
  33. package/src/project-files.test.ts +124 -0
  34. package/src/project-files.ts +56 -0
  35. package/src/protocol.ts +79 -7
  36. package/src/retention.test.ts +191 -0
  37. package/src/retention.ts +123 -0
  38. package/src/server.test.ts +432 -42
  39. package/src/server.ts +423 -89
  40. package/src/share-blob.test.ts +112 -0
  41. package/src/share-blob.ts +55 -0
  42. package/src/single-instance.test.ts +162 -13
  43. package/src/store.conformance.test.ts +41 -0
  44. package/src/store.jsonl.test.ts +159 -0
  45. package/src/store.ts +264 -34
  46. package/src/testing/store-conformance.ts +194 -0
  47. package/src/thread-review.test.ts +394 -0
  48. package/src/thread-review.ts +314 -0
  49. package/src/thread-surface-config.test.ts +63 -0
  50. package/src/thread-surface-config.ts +61 -0
  51. package/src/validate.test.ts +214 -35
  52. package/src/validate.ts +306 -25
  53. package/src/version.ts +6 -0
  54. package/src/working-tree.ts +149 -0
  55. package/src/review.test.ts +0 -167
  56. package/src/review.ts +0 -174
@@ -0,0 +1,123 @@
1
+ import { readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import * as v from "valibot";
5
+ import type { ThreadStore } from "./store";
6
+
7
+ export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
8
+ export const LATEST_REPORT_FILENAME = "report.md";
9
+ export const TIMESTAMPED_REPORT_PREFIX = "refine-";
10
+
11
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
12
+
13
+ const CleanupConfigSchema = v.object({
14
+ cleanup: v.optional(v.object({ period_days: v.optional(v.number()) })),
15
+ });
16
+
17
+ const RefineStateSchema = v.object({
18
+ analyzed: v.optional(v.record(v.string(), v.string()), {}),
19
+ });
20
+
21
+ export type RefineState = v.InferOutput<typeof RefineStateSchema>;
22
+
23
+ export function parseRefineState(raw: Parameters<typeof v.safeParse>[1]): Map<string, string> {
24
+ const parsed = v.safeParse(RefineStateSchema, raw);
25
+
26
+ if (!parsed.success) return new Map();
27
+
28
+ return new Map(Object.entries(parsed.output.analyzed));
29
+ }
30
+
31
+ function userConfigPath(env: NodeJS.ProcessEnv): string {
32
+ const explicit = env.CUELOOP_CONFIG;
33
+
34
+ if (explicit && explicit.trim()) return explicit;
35
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
36
+ const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
37
+
38
+ return join(base, "cueloop", "config.toml");
39
+ }
40
+
41
+ function readConfiguredPeriodDays(path: string): number | undefined {
42
+ let raw: unknown;
43
+
44
+ try {
45
+ raw = Bun.TOML.parse(readFileSync(path, "utf8"));
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ const parsed = v.safeParse(CleanupConfigSchema, raw);
50
+
51
+ if (!parsed.success) return undefined;
52
+ const value = parsed.output.cleanup?.period_days;
53
+
54
+ return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
55
+ }
56
+
57
+ export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env): number {
58
+ return readConfiguredPeriodDays(userConfigPath(env)) ?? DEFAULT_CLEANUP_PERIOD_DAYS;
59
+ }
60
+
61
+ export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
62
+ if (periodDays <= 0) return false;
63
+ const createdMs = Date.parse(createdAt);
64
+
65
+ if (Number.isNaN(createdMs)) return false;
66
+
67
+ return nowMs - createdMs > periodDays * MILLISECONDS_PER_DAY;
68
+ }
69
+
70
+ export function pruneExpiredSessions(
71
+ store: ThreadStore,
72
+ periodDays: number,
73
+ nowMs: number,
74
+ protectedThreadIds: ReadonlySet<string> = new Set(),
75
+ ): string[] {
76
+ if (periodDays <= 0) return [];
77
+ const pruned: string[] = [];
78
+
79
+ for (const session of store.list()) {
80
+ if (session.status !== "resolved" || protectedThreadIds.has(session.id)) continue;
81
+ if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
82
+ pruned.push(session.id);
83
+ }
84
+ }
85
+
86
+ return pruned;
87
+ }
88
+
89
+ export function timestampedReportFilename(nowMs: number): string {
90
+ const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, "-");
91
+
92
+ return `${TIMESTAMPED_REPORT_PREFIX}${stamp}.md`;
93
+ }
94
+
95
+ export function pruneExpiredReports(
96
+ reportsDirectory: string,
97
+ periodDays: number,
98
+ nowMs: number,
99
+ ): string[] {
100
+ if (periodDays <= 0) return [];
101
+ let entries: string[];
102
+
103
+ try {
104
+ entries = readdirSync(reportsDirectory);
105
+ } catch {
106
+ return [];
107
+ }
108
+ const cutoffMs = nowMs - periodDays * MILLISECONDS_PER_DAY;
109
+ const pruned: string[] = [];
110
+
111
+ for (const entry of entries) {
112
+ if (entry === LATEST_REPORT_FILENAME) continue;
113
+ if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
114
+ const path = join(reportsDirectory, entry);
115
+
116
+ if (statSync(path).mtimeMs < cutoffMs) {
117
+ rmSync(path, { force: true });
118
+ pruned.push(entry);
119
+ }
120
+ }
121
+
122
+ return pruned;
123
+ }
@@ -1,7 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import * as v from "valibot";
5
6
  import { DaemonServer } from "./server";
6
7
  import { DaemonClient, DaemonClientError } from "./client";
7
8
  import type { Artifact, WorkspaceKey } from "@cueloop/schema";
@@ -27,88 +28,477 @@ afterEach(() => {
27
28
 
28
29
  describe("socket round-trip", () => {
29
30
  test("ping and full session flow over the wire", async () => {
31
+ // Assert
30
32
  expect((await client.ping()).pid).toBe(process.pid);
31
- const s = await client.sessionCreate(WS, PLAN);
32
- expect(s.status).toBe("pending");
33
- await client.sessionAnnotate(s.id, {
33
+
34
+ // Act
35
+ const session = await client.sessionCreate(WS, PLAN);
36
+
37
+ // Assert
38
+ expect(session.status).toBe("pending");
39
+
40
+ // Act
41
+ await client.sessionAnnotate(session.id, {
34
42
  id: "a1",
35
43
  kind: "comment",
36
44
  anchor: { quote: "Body text", prefix: "", suffix: "." },
37
45
  body: "More detail please.",
38
46
  });
39
- const wait = client.sessionWait(s.id, 5_000);
40
- await client.sessionResolve(s.id, "request_changes", "Expand it.");
47
+ const wait = client.sessionWait(session.id, 5_000);
48
+
49
+ await client.sessionSendMessage(session.id, "changes_requested", "Expand it.");
41
50
  const resolved = (await wait)!;
42
- expect(resolved.verdict!.kind).toBe("request_changes");
43
- expect(resolved.verdict!.feedback).toContain("More detail please.");
51
+
52
+ // Assert
53
+ expect(resolved.message!.outcome).toBe("changes_requested");
54
+ expect(resolved.message!.body).toContain("More detail please.");
55
+ });
56
+
57
+ test("harness binding and Message delivery round-trip over the wire", async () => {
58
+ const thread = await client.sessionCreate(WS, PLAN);
59
+ const binding = await client.harnessBind(thread.id, "fake", "fake_1");
60
+
61
+ expect(await client.harnessGetBinding(binding.id)).toEqual(binding);
62
+ expect(await client.harnessBindingsForSession("fake", "fake_1")).toEqual([binding]);
63
+ expect(await client.harnessBindingsForSession("fake", "different")).toEqual([]);
64
+
65
+ const sent = await client.sessionSendMessage(thread.id, "approved", "Ready.");
66
+ const pending = await client.deliveryPending(binding.id);
67
+
68
+ expect(pending).toHaveLength(1);
69
+ expect(pending[0]!.message).toEqual(sent.message!);
70
+ expect(pending[0]!.delivery.status).toBe("pending");
71
+
72
+ const acknowledged = await client.deliveryAcknowledge(pending[0]!.delivery.id);
73
+
74
+ expect(acknowledged.status).toBe("acknowledged");
75
+ expect(await client.deliveryPending(binding.id)).toEqual([]);
76
+ expect(
77
+ await client.harnessConsumeApprovedRetry(binding.id, sent.message!.id, PLAN.content),
78
+ ).toBe(true);
79
+ expect(
80
+ await client.harnessConsumeApprovedRetry(binding.id, sent.message!.id, PLAN.content),
81
+ ).toBe(false);
82
+ });
83
+
84
+ test("diff revision replaces full changed-file contents over the wire", async () => {
85
+ const files = [
86
+ { path: "a.ts", status: "modified" as const, oldContents: "old", newContents: "first" },
87
+ ];
88
+ const thread = await client.sessionCreate(WS, {
89
+ type: "diff",
90
+ content: "first patch",
91
+ files,
92
+ meta: {},
93
+ });
94
+ const revised = await client.sessionSubmitRevision(
95
+ thread.id,
96
+ "second patch",
97
+ [],
98
+ [{ ...files[0]!, newContents: "second" }],
99
+ );
100
+
101
+ expect(revised.artifact.files?.[0]?.newContents).toBe("second");
102
+ expect((await client.sessionGet(thread.id)).artifact.files?.[0]?.newContents).toBe("second");
44
103
  });
45
104
 
46
105
  test("errors carry codes across the wire", async () => {
47
- expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
48
- try {
49
- await client.sessionGet("nope");
50
- } catch (e) {
51
- expect((e as DaemonClientError).code).toBe("not_found");
52
- }
106
+ await expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
107
+ await expect(client.sessionGet("nope")).rejects.toHaveProperty("code", "not_found");
53
108
  });
54
109
 
55
110
  test("events push to subscribed connections only", async () => {
111
+ // Arrange
56
112
  const observer = await DaemonClient.connect({ home });
57
113
  const seen: string[] = [];
58
- observer.onEvent((e) => seen.push(e.event));
114
+
115
+ observer.onEvent((event) => seen.push(event.event));
59
116
  await observer.subscribe();
60
- const s = await client.sessionCreate(WS, PLAN);
61
- await client.sessionResolve(s.id, "approve", "");
117
+
118
+ // Act
119
+ const session = await client.sessionCreate(WS, PLAN);
120
+
121
+ await client.sessionSendMessage(session.id, "approved", "");
122
+
123
+ // Assert
62
124
  // events are pushed async over the socket; give the loop a beat
63
125
  await Bun.sleep(50);
64
126
  expect(seen).toContain("session.created");
65
- expect(seen).toContain("session.resolved");
127
+ expect(seen).toContain("message.sent");
128
+ observer.close();
129
+ });
130
+
131
+ test("an event names the history entry its change appended", async () => {
132
+ // Arrange
133
+ const observer = await DaemonClient.connect({ home });
134
+ const frames: Array<{ event: string; entryId?: string }> = [];
135
+
136
+ observer.onEvent((event) => frames.push(event));
137
+ await observer.subscribe();
138
+ const session = await client.sessionCreate(WS, PLAN);
139
+
140
+ // Act
141
+ const annotated = await client.sessionAnnotate(session.id, {
142
+ id: "a_evt",
143
+ kind: "comment",
144
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
145
+ body: "hello",
146
+ });
147
+
148
+ await Bun.sleep(50);
149
+
150
+ // Assert: the update frame carries the comment entry's id
151
+ const updated = frames.find((frame) => frame.event === "session.updated");
152
+
153
+ expect(updated?.entryId).toBe(annotated.history!.entries.at(-1)!.id);
66
154
  observer.close();
67
155
  });
68
156
 
157
+ test("a non-owner removes only the comments of the author it acts as, and names itself", async () => {
158
+ // Arrange: the owner's and Ana's comments; an agent connection acting as Ana
159
+ const session = await client.sessionCreate(WS, PLAN);
160
+
161
+ await client.sessionAnnotate(session.id, {
162
+ id: "own",
163
+ kind: "comment",
164
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
165
+ body: "mine",
166
+ });
167
+ await client.sessionAnnotate(session.id, {
168
+ id: "anas",
169
+ kind: "comment",
170
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
171
+ body: "hers",
172
+ author: "SHA256:ana",
173
+ });
174
+ const anonymous = await DaemonClient.connect({ home, role: "agent" });
175
+ const agent = await DaemonClient.connect({ home, role: "agent", author: "SHA256:ana" });
176
+
177
+ // Act + Assert: an unbound connection removes nothing; a bound one never another author's
178
+ await expect(anonymous.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
179
+ "code",
180
+ "forbidden",
181
+ );
182
+ await expect(agent.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
183
+ "code",
184
+ "forbidden",
185
+ );
186
+ await expect(
187
+ agent.sessionSetParticipantName(session.id, "SHA256:bob", "Not Bob"),
188
+ ).rejects.toHaveProperty("code", "forbidden");
189
+
190
+ // Act: her own comment goes, and she names herself
191
+ const removed = await agent.sessionRemoveAnnotation(session.id, "anas");
192
+ const named = await agent.sessionSetParticipantName(session.id, "SHA256:ana", "Ana");
193
+
194
+ // Assert
195
+ expect(removed.annotations.map((annotation) => annotation.id)).toEqual(["own"]);
196
+ expect(removed.history!.entries.at(-1)).toMatchObject({
197
+ type: "comment-removed",
198
+ annotationId: "anas",
199
+ });
200
+ expect(named.participants).toContainEqual({ id: "SHA256:ana", provider: "ssh", name: "Ana" });
201
+ anonymous.close();
202
+ agent.close();
203
+ });
204
+
69
205
  test("malformed requests get structured errors and never wedge the daemon", async () => {
70
206
  // missing required params
71
- try {
72
- await client.request("session.create", { artifact: { type: "plan", content: "x" } });
73
- throw new Error("should have thrown");
74
- } catch (e) {
75
- expect((e as DaemonClientError).code).toBe("invalid_params");
76
- }
207
+ await expect(
208
+ client.request("session.create", { artifact: { type: "plan", content: "x" } }, v.object({})),
209
+ ).rejects.toHaveProperty("code", "invalid_params");
77
210
  // wrong types
78
- try {
79
- await client.request("session.wait", { id: 42 });
80
- throw new Error("should have thrown");
81
- } catch (e) {
82
- expect((e as DaemonClientError).code).toBe("invalid_params");
83
- }
211
+ await expect(client.request("session.wait", { id: 42 }, v.object({}))).rejects.toHaveProperty(
212
+ "code",
213
+ "invalid_params",
214
+ );
84
215
  // unknown method
85
- try {
86
- await client.request("session.nuke", {});
87
- throw new Error("should have thrown");
88
- } catch (e) {
89
- expect((e as DaemonClientError).code).toBe("unknown_method");
90
- }
216
+ await expect(client.request("session.nuke", {}, v.object({}))).rejects.toHaveProperty(
217
+ "code",
218
+ "unknown_method",
219
+ );
220
+ await Promise.all(
221
+ ["__proto__", "toString", "constructor"].map((method) =>
222
+ expect(client.request(method, {}, v.object({}))).rejects.toHaveProperty(
223
+ "code",
224
+ "unknown_method",
225
+ ),
226
+ ),
227
+ );
91
228
  // the daemon is still fully alive afterwards
92
- const s = await client.sessionCreate(WS, PLAN);
93
- expect((await client.sessionGet(s.id)).id).toBe(s.id);
229
+ const session = await client.sessionCreate(WS, PLAN);
230
+
231
+ expect((await client.sessionGet(session.id)).id).toBe(session.id);
94
232
  });
95
233
 
96
234
  test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
97
- const s = await client.sessionCreate(WS, {
235
+ // Act
236
+ const session = await client.sessionCreate(WS, {
98
237
  type: "plan",
99
238
  content: "# P",
100
239
  meta: { agent: "claude-code", herdrPane: "%7" },
101
240
  });
102
- const got = await client.sessionGet(s.id);
241
+ const got = await client.sessionGet(session.id);
242
+
243
+ // Assert
103
244
  expect(got.artifact.meta.herdrPane).toBe("%7");
104
245
  expect(got.artifact.meta.agent).toBe("claude-code");
105
246
  });
106
247
 
248
+ test("share-sync primitives round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
249
+ // Given a shared session with one local note
250
+ const session = await client.sessionCreate(WS, PLAN);
251
+ const anchor = { quote: "Body text", prefix: "", suffix: "." };
252
+
253
+ await client.sessionSetShareId(session.id, "p_abc123xy");
254
+ await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
255
+ expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
256
+
257
+ // When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
258
+ const merged = await client.sessionMergeShared(session.id, {
259
+ annotations: [
260
+ {
261
+ id: "a1",
262
+ kind: "comment",
263
+ anchor,
264
+ body: "should not overwrite",
265
+ createdAt: "2026-01-01T00:00:00.000Z",
266
+ },
267
+ {
268
+ id: "a2",
269
+ kind: "comment",
270
+ anchor,
271
+ body: "theirs",
272
+ author: "SHA256:mate",
273
+ createdAt: "2026-01-01T00:00:00.000Z",
274
+ },
275
+ ],
276
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
277
+ });
278
+
279
+ // Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
280
+ expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
281
+ expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
282
+ expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
283
+ "SHA256:mate",
284
+ );
285
+ expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
286
+ });
287
+
288
+ test("mergeShared validates each annotation at the socket boundary", async () => {
289
+ // Given a session
290
+ const session = await client.sessionCreate(WS, PLAN);
291
+
292
+ // When a malformed annotation is merged, it is rejected at the wire boundary
293
+ // @ts-expect-error intentionally malformed annotation (empty id, missing fields)
294
+ const merge = client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
295
+
296
+ await expect(merge).rejects.toHaveProperty("code", "invalid_params");
297
+ });
298
+
299
+ test("session.delete removes a session over the wire", async () => {
300
+ // Given a created session
301
+ const session = await client.sessionCreate(WS, PLAN);
302
+
303
+ // When it is deleted
304
+ await client.sessionDelete(session.id);
305
+
306
+ // Then it is gone, and deleting an unknown id is a not_found error
307
+ await expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
308
+ await expect(client.sessionDelete("ses_missing")).rejects.toHaveProperty("code", "not_found");
309
+ });
310
+
107
311
  test("two clients see the same state (thin-renderer model)", async () => {
312
+ // Arrange
108
313
  const second = await DaemonClient.connect({ home });
109
- const s = await client.sessionCreate(WS, PLAN);
110
- const fromSecond = await second.sessionGet(s.id);
314
+
315
+ // Act
316
+ const session = await client.sessionCreate(WS, PLAN);
317
+ const fromSecond = await second.sessionGet(session.id);
318
+
319
+ // Assert
111
320
  expect(fromSecond.artifact.content).toBe(PLAN.content);
112
321
  second.close();
113
322
  });
114
323
  });
324
+
325
+ describe("ownership is proven, never declared", () => {
326
+ /** A wire frame as the daemon answers it: a result or a coded error. */
327
+ interface WireReply {
328
+ result?: unknown;
329
+ error?: { code: string };
330
+ }
331
+
332
+ /** The request body a bare socket sends: the primitive's params as the wire carries them. */
333
+ type WireParams = Parameters<typeof JSON.stringify>[0];
334
+
335
+ interface RawConnection {
336
+ call: (method: string, params: WireParams) => Promise<WireReply>;
337
+ close: () => void;
338
+ }
339
+
340
+ /** A bare socket that speaks the wire protocol without the client's handshake. */
341
+ async function rawConnection(): Promise<RawConnection> {
342
+ const pending = new Map<number, (frame: WireReply) => void>();
343
+ let buffered = "";
344
+ let nextId = 1;
345
+ const socket = await Bun.connect({
346
+ unix: join(home, "cueloop.sock"),
347
+ socket: {
348
+ data: (_socket, data) => {
349
+ buffered += data.toString();
350
+ const lines = buffered.split("\n");
351
+
352
+ buffered = lines.pop() ?? "";
353
+ for (const line of lines) {
354
+ const frame = JSON.parse(line);
355
+
356
+ if ("id" in frame) pending.get(frame.id)?.(frame);
357
+ }
358
+ },
359
+ },
360
+ });
361
+
362
+ return {
363
+ call: (method, params) =>
364
+ new Promise((resolve) => {
365
+ const id = nextId++;
366
+
367
+ pending.set(id, resolve);
368
+ socket.write(JSON.stringify({ id, method, params }) + "\n");
369
+ }),
370
+ close: () => socket.end(),
371
+ };
372
+ }
373
+
374
+ test("a connection that never proves ownership is a collaborator: it reads and comments, nothing more", async () => {
375
+ // Arrange
376
+ const session = await client.sessionCreate(WS, PLAN);
377
+ const raw = await rawConnection();
378
+
379
+ // Act + Assert
380
+ expect((await raw.call("session.get", { id: session.id })).error).toBeUndefined();
381
+ expect(
382
+ (
383
+ await raw.call("session.annotate", {
384
+ id: session.id,
385
+ annotation: {
386
+ id: "a_raw",
387
+ kind: "comment",
388
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
389
+ body: "from a bare socket",
390
+ },
391
+ })
392
+ ).error,
393
+ ).toBeUndefined();
394
+ expect(
395
+ (await raw.call("session.sendMessage", { id: session.id, outcome: "approved", summary: "" }))
396
+ .error?.code,
397
+ ).toBe("forbidden");
398
+ expect((await raw.call("session.delete", { id: session.id })).error?.code).toBe("forbidden");
399
+ raw.close();
400
+ });
401
+
402
+ test("claiming ownership without the token is refused; the token grants it", async () => {
403
+ // Arrange
404
+ const raw = await rawConnection();
405
+
406
+ // Act + Assert: a bare claim, then a wrong token, both refused
407
+ expect((await raw.call("daemon.hello", { role: "owner" })).error?.code).toBe("forbidden");
408
+ expect((await raw.call("daemon.hello", { role: "owner", token: "nope" })).error?.code).toBe(
409
+ "forbidden",
410
+ );
411
+ expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
412
+
413
+ // Act: the token the daemon wrote into its home
414
+ const token = readFileSync(join(home, "owner.token"), "utf8").trim();
415
+
416
+ expect((await raw.call("daemon.hello", { role: "owner", token })).error).toBeUndefined();
417
+
418
+ // Assert: owner primitives open up
419
+ const created = await raw.call("session.create", { workspace: WS, artifact: PLAN });
420
+
421
+ expect(created.error).toBeUndefined();
422
+ raw.close();
423
+ });
424
+
425
+ test("a refused handshake reaches the caller and leaves the live daemon's socket alone", async () => {
426
+ // Arrange: a token file that is not what the daemon minted
427
+ const tokenPath = join(home, "owner.token");
428
+ const minted = readFileSync(tokenPath, "utf8");
429
+
430
+ writeFileSync(tokenPath, "0".repeat(64));
431
+
432
+ // Act + Assert: the client hears the refusal; autostart does not replace the daemon
433
+ await expect(DaemonClient.connect({ home, autostart: true })).rejects.toHaveProperty(
434
+ "code",
435
+ "forbidden",
436
+ );
437
+ expect(existsSync(join(home, "cueloop.sock"))).toBe(true);
438
+ expect((await client.sessionList()).length).toBe(0);
439
+
440
+ // Arrange: a token file that is not a token at all
441
+ writeFileSync(tokenPath, "not-a-token");
442
+
443
+ // Act + Assert
444
+ await expect(DaemonClient.connect({ home })).rejects.toHaveProperty(
445
+ "code",
446
+ "invalid_owner_token",
447
+ );
448
+ writeFileSync(tokenPath, minted);
449
+ });
450
+
451
+ test("the token exists before the socket does", () => {
452
+ // Arrange: a second daemon in another home
453
+ const other = mkdtempSync(join(tmpdir(), "cueloop-token-"));
454
+ const daemon = new DaemonServer({ home: other, idleExitMs: 0 });
455
+
456
+ try {
457
+ // Act
458
+ daemon.start();
459
+
460
+ // Assert: the token is on disk the moment the socket is
461
+ expect(existsSync(join(other, "owner.token"))).toBe(true);
462
+ expect(existsSync(join(other, "cueloop.sock"))).toBe(true);
463
+ } finally {
464
+ daemon.stop();
465
+ rmSync(other, { recursive: true, force: true });
466
+ }
467
+ });
468
+
469
+ test("the daemon client proves ownership on connect and the token is private to the home", async () => {
470
+ // Assert: the client in beforeEach connected as the owner through the token
471
+ const session = await client.sessionCreate(WS, PLAN);
472
+
473
+ expect((await client.sessionSendMessage(session.id, "approved", "")).status).toBe("resolved");
474
+ expect(statSync(join(home, "owner.token")).mode & 0o777).toBe(0o600);
475
+ });
476
+ });
477
+
478
+ describe("large payloads survive socket backpressure", () => {
479
+ test("a session bigger than the kernel socket buffer round-trips", async () => {
480
+ // Regression: responses larger than one socket write used to truncate
481
+ // mid-line because the write return value was ignored, so the client
482
+ // never saw the frame's newline and every later request wedged too.
483
+ // Arrange
484
+ const bigContent =
485
+ "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
486
+
487
+ // Act
488
+ const created = await client.sessionCreate(
489
+ { repoRoot: "/repo", branch: "main" },
490
+ { type: "plan", content: bigContent, meta: { title: "big" } },
491
+ );
492
+ const fetched = await client.sessionGet(created.id);
493
+
494
+ // Assert
495
+ expect(fetched.artifact.content).toBe(bigContent);
496
+
497
+ // Act
498
+ // the connection must still be usable for the next frame
499
+ const listed = await client.sessionList();
500
+
501
+ // Assert
502
+ expect(listed.some((session) => session.id === created.id)).toBe(true);
503
+ }, 15_000);
504
+ });