@cueloop/daemon 0.1.0-alpha.33 → 0.1.0-alpha.34

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.
package/package.json CHANGED
@@ -1,7 +1,21 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.33",
3
+ "version": "0.1.0-alpha.34",
4
+ "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/mmurakaru/cueloop/issues"
8
+ },
4
9
  "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mmurakaru/cueloop.git",
13
+ "directory": "packages/daemon"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
5
19
  "type": "module",
6
20
  "exports": {
7
21
  ".": "./src/index.ts",
@@ -10,28 +24,14 @@
10
24
  "./herdr-pane": "./src/herdr-pane.ts",
11
25
  "./share-blob": "./src/share-blob.ts"
12
26
  },
13
- "dependencies": {
14
- "@cueloop/schema": "0.1.0-alpha.33",
15
- "valibot": "^1.4.2"
16
- },
17
27
  "publishConfig": {
18
28
  "access": "public"
19
29
  },
20
- "files": [
21
- "src",
22
- "README.md"
23
- ],
30
+ "dependencies": {
31
+ "@cueloop/schema": "0.1.0-alpha.34",
32
+ "valibot": "^1.4.2"
33
+ },
24
34
  "engines": {
25
35
  "bun": ">=1.3.0"
26
- },
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/mmurakaru/cueloop.git",
30
- "directory": "packages/daemon"
31
- },
32
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
33
- "bugs": {
34
- "url": "https://github.com/mmurakaru/cueloop/issues"
35
- },
36
- "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits"
37
- }
36
+ }
37
+ }
package/src/api.test.ts CHANGED
@@ -79,7 +79,10 @@ describe("session lifecycle", () => {
79
79
  // Assert
80
80
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
81
81
  expect(resolved.verdict!.feedback).toContain("> carefully");
82
- expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
82
+ expect(verdictResponse(resolved)).toEqual({
83
+ allow: false,
84
+ feedback: resolved.verdict!.feedback,
85
+ });
83
86
 
84
87
  // Arrange
85
88
  const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
@@ -98,7 +101,12 @@ describe("session lifecycle", () => {
98
101
 
99
102
  // Assert
100
103
  expect(() =>
101
- core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
104
+ core.sessionAnnotate(session.id, {
105
+ id: "a1",
106
+ kind: "comment",
107
+ anchor: { quote: "x", prefix: "", suffix: "" },
108
+ body: "b",
109
+ }),
102
110
  ).toThrow("already resolved");
103
111
  });
104
112
 
@@ -166,7 +174,12 @@ describe("session lifecycle", () => {
166
174
 
167
175
  describe("revision marks addressed annotations", () => {
168
176
  const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
169
- core.sessionAnnotate(sessionId, { id, kind: "comment", anchor: { quote, prefix: "", suffix: "" }, body });
177
+ core.sessionAnnotate(sessionId, {
178
+ id,
179
+ kind: "comment",
180
+ anchor: { quote, prefix: "", suffix: "" },
181
+ body,
182
+ });
170
183
 
171
184
  test("ids the agent reports are marked addressed by that revision", () => {
172
185
  // Arrange
@@ -190,10 +203,16 @@ describe("revision marks addressed annotations", () => {
190
203
  annotate(session.id, "kept", "Context");
191
204
 
192
205
  // Act: the revision rewrites the "carefully" sentence but keeps the Context heading
193
- const revised = core.sessionSubmitRevision(session.id, "# Plan\n\n## Context\n\nDo the thing with tests.\n");
206
+ const revised = core.sessionSubmitRevision(
207
+ session.id,
208
+ "# Plan\n\n## Context\n\nDo the thing with tests.\n",
209
+ );
194
210
 
195
211
  // Assert
196
- expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({ revision: 2, source: "drift" });
212
+ expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({
213
+ revision: 2,
214
+ source: "drift",
215
+ });
197
216
  expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
198
217
  });
199
218
 
@@ -326,10 +345,21 @@ describe("events", () => {
326
345
 
327
346
  // Act
328
347
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
329
- core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
348
+ core.sessionAnnotate(session.id, {
349
+ id: "a1",
350
+ kind: "comment",
351
+ anchor: { quote: "thing", prefix: "", suffix: "" },
352
+ body: "b",
353
+ });
330
354
  core.sessionResolve(session.id, "approve", "");
331
355
 
332
356
  // Assert
333
- expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
357
+ expect(seen).toEqual([
358
+ "session.created",
359
+ "inbox.changed",
360
+ "session.updated",
361
+ "session.resolved",
362
+ "inbox.changed",
363
+ ]);
334
364
  });
335
365
  });
package/src/api.ts CHANGED
@@ -88,11 +88,16 @@ export class DaemonCore {
88
88
  return session;
89
89
  }
90
90
 
91
- sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
91
+ sessionList(filter?: {
92
+ status?: "pending" | "resolved";
93
+ workspace?: Partial<WorkspaceKey>;
94
+ }): ReviewSession[] {
92
95
  return this.store.list().filter((session) => {
93
96
  if (filter?.status && session.status !== filter.status) return false;
94
- if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot) return false;
95
- if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch) return false;
97
+ if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
98
+ return false;
99
+ if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
100
+ return false;
96
101
  return true;
97
102
  });
98
103
  }
@@ -131,7 +136,11 @@ export class DaemonCore {
131
136
  const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
132
137
  const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
133
138
  if (existing === -1) session.annotations.push(full);
134
- else session.annotations[existing] = { ...full, createdAt: session.annotations[existing]!.createdAt };
139
+ else
140
+ session.annotations[existing] = {
141
+ ...full,
142
+ createdAt: session.annotations[existing]!.createdAt,
143
+ };
135
144
  this.store.upsert(session);
136
145
  this.emit("session.updated", id);
137
146
  return session;
@@ -148,7 +157,8 @@ export class DaemonCore {
148
157
  /** The reviewer's working copy; undefined clears it (revert all edits). */
149
158
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
150
159
  const session = this.mutable(id);
151
- if (workingCopy === undefined || workingCopy === session.artifact.content) delete session.workingCopy;
160
+ if (workingCopy === undefined || workingCopy === session.artifact.content)
161
+ delete session.workingCopy;
152
162
  else session.workingCopy = workingCopy;
153
163
  this.store.upsert(session);
154
164
  this.emit("session.updated", id);
@@ -190,12 +200,18 @@ export class DaemonCore {
190
200
  * the authority on their own name). This is how a teammate's name reaches the
191
201
  * planner after a pull, alongside their notes.
192
202
  */
193
- sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): ReviewSession {
203
+ sessionMergeShared(
204
+ id: string,
205
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
206
+ ): ReviewSession {
194
207
  const session = this.mutable(id);
195
208
  const known = new Set(session.annotations.map((annotation) => annotation.id));
196
- for (const annotation of incoming.annotations) if (!known.has(annotation.id)) session.annotations.push(annotation);
209
+ for (const annotation of incoming.annotations)
210
+ if (!known.has(annotation.id)) session.annotations.push(annotation);
197
211
  if (incoming.participants?.length) {
198
- const registry = new Map((session.participants ?? []).map((participant) => [participant.id, participant]));
212
+ const registry = new Map(
213
+ (session.participants ?? []).map((participant) => [participant.id, participant]),
214
+ );
199
215
  for (const participant of incoming.participants) registry.set(participant.id, participant);
200
216
  session.participants = [...registry.values()];
201
217
  }
@@ -233,7 +249,11 @@ export class DaemonCore {
233
249
  * Unknown ids are ignored, so a stale id in the agent's list never fails
234
250
  * the resubmit.
235
251
  */
236
- sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): ReviewSession {
252
+ sessionSubmitRevision(
253
+ id: string,
254
+ content: string,
255
+ addressedAnnotationIds: string[] = [],
256
+ ): ReviewSession {
237
257
  const session = this.sessionGet(id);
238
258
  const now = new Date().toISOString();
239
259
  const revisionNumber = session.revisions.length + 1;
@@ -251,7 +271,10 @@ export class DaemonCore {
251
271
  if (isAddressed(annotation) || isAgentNote(annotation)) continue;
252
272
  if (reportedIds.has(annotation.id)) {
253
273
  annotation.resolution = { revision: revisionNumber, source: "agent" };
254
- } else if (revisedBlocks !== null && resolveAnchor(annotation.anchor, revisedBlocks) === null) {
274
+ } else if (
275
+ revisedBlocks !== null &&
276
+ resolveAnchor(annotation.anchor, revisedBlocks) === null
277
+ ) {
255
278
  annotation.resolution = { revision: revisionNumber, source: "drift" };
256
279
  }
257
280
  }
@@ -264,7 +287,8 @@ export class DaemonCore {
264
287
 
265
288
  private mutable(id: string): ReviewSession {
266
289
  const session = this.sessionGet(id);
267
- if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
290
+ if (session.status === "resolved")
291
+ throw new DaemonError("resolved", `session ${id} is already resolved`);
268
292
  return session;
269
293
  }
270
294
  }
package/src/client.ts CHANGED
@@ -5,7 +5,14 @@
5
5
  */
6
6
 
7
7
  import { existsSync, rmSync } from "node:fs";
8
- import type { Annotation, Artifact, Identity, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
8
+ import type {
9
+ Annotation,
10
+ Artifact,
11
+ Identity,
12
+ ReviewSession,
13
+ VerdictKind,
14
+ WorkspaceKey,
15
+ } from "@cueloop/schema";
9
16
  import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
10
17
  import { cueloopHome, socketPath } from "./paths";
11
18
 
@@ -35,7 +42,10 @@ export interface SessionClient {
35
42
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
36
43
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
37
44
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
38
- sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession>;
45
+ sessionMergeShared(
46
+ id: string,
47
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
48
+ ): Promise<ReviewSession>;
39
49
  sessionDelete(id: string): Promise<void>;
40
50
  /** Record the caller's own identity name (collaborator self-naming on a share). */
41
51
  sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
@@ -82,20 +92,20 @@ export class DaemonClient implements SessionClient {
82
92
 
83
93
  private async dial(path: string): Promise<void> {
84
94
  const buffer = new LineBuffer();
85
- const self = this;
86
95
  this.socket = await Bun.connect({
87
96
  unix: path,
88
97
  socket: {
89
- data(_socket, data) {
90
- buffer.push(data.toString(), (line) => self.routeInboundFrame(line));
98
+ data: (_socket, data) => {
99
+ buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
91
100
  },
92
- drain() {
93
- self.writer?.drain();
101
+ drain: () => {
102
+ this.writer?.drain();
94
103
  },
95
- close() {
96
- self.closed = true;
97
- for (const pendingRequest of self.pending.values()) pendingRequest.reject(new Error("daemon connection closed"));
98
- self.pending.clear();
104
+ close: () => {
105
+ this.closed = true;
106
+ for (const pendingRequest of this.pending.values())
107
+ pendingRequest.reject(new Error("daemon connection closed"));
108
+ this.pending.clear();
99
109
  },
100
110
  error() {},
101
111
  },
@@ -125,7 +135,8 @@ export class DaemonClient implements SessionClient {
125
135
  const pendingRequest = this.pending.get(frame.id);
126
136
  if (!pendingRequest) return;
127
137
  this.pending.delete(frame.id);
128
- if (frame.error) pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
138
+ if (frame.error)
139
+ pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
129
140
  else pendingRequest.resolve(frame.result);
130
141
  }
131
142
 
@@ -191,7 +202,10 @@ export class DaemonClient implements SessionClient {
191
202
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
192
203
  return this.request("session.setShareId", { id, shareId });
193
204
  }
194
- sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession> {
205
+ sessionMergeShared(
206
+ id: string,
207
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
208
+ ): Promise<ReviewSession> {
195
209
  return this.request("session.mergeShared", { id, ...incoming });
196
210
  }
197
211
  sessionDelete(id: string): Promise<void> {
@@ -204,7 +218,11 @@ export class DaemonClient implements SessionClient {
204
218
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
205
219
  return this.request("session.resolve", { id, verdictKind, summary });
206
220
  }
207
- sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): Promise<ReviewSession> {
221
+ sessionSubmitRevision(
222
+ id: string,
223
+ content: string,
224
+ addressedAnnotationIds: string[] = [],
225
+ ): Promise<ReviewSession> {
208
226
  return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
209
227
  }
210
228
  shutdown(): Promise<void> {
@@ -54,7 +54,12 @@ describe("openHerdrPane", () => {
54
54
  const stub = makeStub("open");
55
55
 
56
56
  // Act
57
- const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath: stub.binPath, label: "Rollout Plan" });
57
+ const ok = openHerdrPane({
58
+ sessionId: "ses_abc",
59
+ cwd: "/repo/work",
60
+ binPath: stub.binPath,
61
+ label: "Rollout Plan",
62
+ });
58
63
 
59
64
  // Assert
60
65
  expect(ok).toBeTrue();
@@ -82,7 +87,10 @@ describe("openHerdrPane", () => {
82
87
  // Arrange
83
88
  const logPath = join(dir, "nopane.log");
84
89
  const binPath = join(dir, "nopane.sh");
85
- writeFileSync(binPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`);
90
+ writeFileSync(
91
+ binPath,
92
+ `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
93
+ );
86
94
  chmodSync(binPath, 0o755);
87
95
 
88
96
  // Act
package/src/herdr-pane.ts CHANGED
@@ -47,13 +47,18 @@ export interface OpenHerdrPaneOptions {
47
47
  export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
48
48
  const { sessionId, cwd, binPath, label } = options;
49
49
  try {
50
- const created = Bun.spawnSync([binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"], {
51
- stdout: "pipe",
52
- stderr: "ignore",
53
- timeout: HERDR_SPAWN_TIMEOUT_MS,
54
- });
50
+ const created = Bun.spawnSync(
51
+ [binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
52
+ {
53
+ stdout: "pipe",
54
+ stderr: "ignore",
55
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
56
+ },
57
+ );
55
58
  if (created.exitCode !== 0) return false;
56
- const parsed = JSON.parse(created.stdout.toString()) as { result?: { root_pane?: { pane_id?: string } } };
59
+ const parsed = JSON.parse(created.stdout.toString()) as {
60
+ result?: { root_pane?: { pane_id?: string } };
61
+ };
57
62
  const paneId = parsed.result?.root_pane?.pane_id;
58
63
  if (!paneId) return false;
59
64
  // A fresh tab hosts a plain shell, so the review is typed in like a human.
package/src/main.ts CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { DaemonServer } from "./server";
4
4
 
5
- const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS ? Number(process.env.CUELOOP_IDLE_EXIT_MS) : undefined;
5
+ const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
6
+ ? Number(process.env.CUELOOP_IDLE_EXIT_MS)
7
+ : undefined;
6
8
  const server = new DaemonServer({ idleExitMs });
7
9
  const path = server.start();
8
10
  if (path === null) {
@@ -66,7 +66,12 @@ describe("resolveWorkspace", () => {
66
66
  describe("openReview", () => {
67
67
  test("shapes the artifact: agent, derived title, cwd", async () => {
68
68
  // Act
69
- const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
69
+ const review = await openReview(client, {
70
+ type: "plan",
71
+ content: PLAN,
72
+ cwd: home,
73
+ agent: "test-agent",
74
+ });
70
75
 
71
76
  // Assert
72
77
  const session = review.session;
@@ -80,13 +85,22 @@ describe("openReview", () => {
80
85
 
81
86
  test("an explicit title wins; diffs never derive one from content", async () => {
82
87
  // Act
83
- const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
88
+ const titled = await openReview(client, {
89
+ type: "plan",
90
+ content: PLAN,
91
+ cwd: home,
92
+ title: "Custom",
93
+ });
84
94
 
85
95
  // Assert
86
96
  expect(titled.session.artifact.meta.title).toBe("Custom");
87
97
 
88
98
  // Act
89
- const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
99
+ const diff = await openReview(client, {
100
+ type: "diff",
101
+ content: "# not a heading\n",
102
+ cwd: home,
103
+ });
90
104
 
91
105
  // Assert
92
106
  expect(diff.session.artifact.meta.title).toBeUndefined();
@@ -97,7 +111,12 @@ describe("openReview", () => {
97
111
  const ws = { repoRoot: "/elsewhere", branch: "feature" };
98
112
 
99
113
  // Act
100
- const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
114
+ const review = await openReview(client, {
115
+ type: "diff",
116
+ content: "d",
117
+ cwd: home,
118
+ workspace: ws,
119
+ });
101
120
 
102
121
  // Assert
103
122
  expect(review.session.workspace).toEqual(ws);
@@ -105,7 +124,12 @@ describe("openReview", () => {
105
124
 
106
125
  test("the same agentSessionId revises instead of opening a new session", async () => {
107
126
  // Arrange
108
- const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
127
+ const first = await openReview(client, {
128
+ type: "plan",
129
+ content: PLAN,
130
+ cwd: home,
131
+ agentSessionId: "agent-1",
132
+ });
109
133
 
110
134
  // Act
111
135
  const second = await openReview(client, {
@@ -121,7 +145,12 @@ describe("openReview", () => {
121
145
  expect(second.session.status).toBe("pending");
122
146
 
123
147
  // Act
124
- const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
148
+ const other = await openReview(client, {
149
+ type: "plan",
150
+ content: PLAN,
151
+ cwd: home,
152
+ agentSessionId: "agent-2",
153
+ });
125
154
 
126
155
  // Assert
127
156
  expect(other.id).not.toBe(first.id);
@@ -206,7 +235,8 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
206
235
 
207
236
  // Assert
208
237
  // give the loop a chunk to observe the annotation before resolving
209
- for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
238
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
239
+ await Bun.sleep(20);
210
240
  expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
211
241
 
212
242
  // Act
@@ -223,7 +253,11 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
223
253
  // Arrange
224
254
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
225
255
  const controller = new AbortController();
226
- const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
256
+ const waiting = review.awaitVerdict({
257
+ timeoutMs: Infinity,
258
+ pollMs: 100,
259
+ signal: controller.signal,
260
+ });
227
261
 
228
262
  // Act
229
263
  controller.abort();
package/src/review.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  * serializing the decision in its host's contract.
8
8
  */
9
9
 
10
- import { newAnnotationId, type ArtifactType, type ReviewSession, type WorkspaceKey } from "@cueloop/schema";
10
+ import {
11
+ newAnnotationId,
12
+ type ArtifactType,
13
+ type DiffFileContents,
14
+ type ReviewSession,
15
+ type WorkspaceKey,
16
+ } from "@cueloop/schema";
11
17
  import { verdictResponse } from "./api";
12
18
  import type { DaemonClient } from "./client";
13
19
 
@@ -51,6 +57,11 @@ export interface OpenReviewOptions {
51
57
  planPath?: string;
52
58
  pr?: string;
53
59
  herdrPane?: string;
60
+ /**
61
+ * Full file contents per changed file for a working-tree diff, carried onto
62
+ * the artifact so hunk curation produces an exactly applyable patch.
63
+ */
64
+ files?: DiffFileContents[];
54
65
  /** Defaults to the plan's first markdown heading; diffs get no derived title. */
55
66
  title?: string;
56
67
  /**
@@ -69,7 +80,11 @@ export interface ReviewNote {
69
80
  }
70
81
 
71
82
  /** Anchor a note at its file: quote = the path, no context selectors. */
72
- async function attachNotes(client: DaemonClient, sessionId: string, notes: ReviewNote[]): Promise<void> {
83
+ async function attachNotes(
84
+ client: DaemonClient,
85
+ sessionId: string,
86
+ notes: ReviewNote[],
87
+ ): Promise<void> {
73
88
  for (const note of notes) {
74
89
  await client.sessionAnnotate(sessionId, {
75
90
  id: newAnnotationId(),
@@ -100,7 +115,10 @@ export interface VerdictOutcome {
100
115
  /** Sentinel distinguishing an abort from any daemon response. */
101
116
  const ABORTED = Symbol("aborted");
102
117
 
103
- function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof ABORTED> {
118
+ function raceAbort<T>(
119
+ promise: Promise<T>,
120
+ signal: AbortSignal | undefined,
121
+ ): Promise<T | typeof ABORTED> {
104
122
  if (!signal) return promise;
105
123
  if (signal.aborted) {
106
124
  promise.catch(() => {});
@@ -170,7 +188,10 @@ function outcome(session: ReviewSession): VerdictOutcome {
170
188
  }
171
189
 
172
190
  /** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
173
- export async function openReview(client: DaemonClient, options: OpenReviewOptions): Promise<ReviewHandle> {
191
+ export async function openReview(
192
+ client: DaemonClient,
193
+ options: OpenReviewOptions,
194
+ ): Promise<ReviewHandle> {
174
195
  const cwd = options.cwd ?? process.cwd();
175
196
  const workspace = options.workspace ?? (await resolveWorkspace(cwd));
176
197
  // Resubmits from the same agent session become revisions, not new sessions.
@@ -190,6 +211,7 @@ export async function openReview(client: DaemonClient, options: OpenReviewOption
190
211
  let session = await client.sessionCreate(workspace, {
191
212
  type: options.type,
192
213
  content: options.content,
214
+ files: options.files,
193
215
  meta: {
194
216
  agent: options.agent,
195
217
  agentSessionId: options.agentSessionId,
@@ -132,8 +132,21 @@ describe("socket round-trip", () => {
132
132
  // When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
133
133
  const merged = await client.sessionMergeShared(session.id, {
134
134
  annotations: [
135
- { id: "a1", kind: "comment", anchor, body: "should not overwrite", createdAt: "2026-01-01T00:00:00.000Z" },
136
- { id: "a2", kind: "comment", anchor, body: "theirs", author: "SHA256:mate", createdAt: "2026-01-01T00:00:00.000Z" },
135
+ {
136
+ id: "a1",
137
+ kind: "comment",
138
+ anchor,
139
+ body: "should not overwrite",
140
+ createdAt: "2026-01-01T00:00:00.000Z",
141
+ },
142
+ {
143
+ id: "a2",
144
+ kind: "comment",
145
+ anchor,
146
+ body: "theirs",
147
+ author: "SHA256:mate",
148
+ createdAt: "2026-01-01T00:00:00.000Z",
149
+ },
137
150
  ],
138
151
  participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
139
152
  });
@@ -141,7 +154,9 @@ describe("socket round-trip", () => {
141
154
  // Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
142
155
  expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
143
156
  expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
144
- expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe("SHA256:mate");
157
+ expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
158
+ "SHA256:mate",
159
+ );
145
160
  expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
146
161
  });
147
162
 
@@ -196,7 +211,8 @@ describe("large payloads survive socket backpressure", () => {
196
211
  // mid-line because the write return value was ignored, so the client
197
212
  // never saw the frame's newline and every later request wedged too.
198
213
  // Arrange
199
- const bigContent = "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
214
+ const bigContent =
215
+ "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
200
216
 
201
217
  // Act
202
218
  const created = await client.sessionCreate(
package/src/server.ts CHANGED
@@ -4,7 +4,16 @@
4
4
  * permissions are the local auth: the socket and state dir are 0700/0600.
5
5
  */
6
6
 
7
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import {
8
+ chmodSync,
9
+ closeSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
8
17
  import { DaemonCore, type DaemonEvent } from "./api";
9
18
  import { DaemonError } from "./errors";
10
19
  import { isKnownMethod, parseParams } from "./validate";
@@ -118,20 +127,23 @@ export class DaemonServer {
118
127
  // safe now: holding the lock means no live daemon owns this home, so any
119
128
  // socket file left behind is stale
120
129
  if (existsSync(path)) rmSync(path, { force: true });
121
- const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; connection: Connection; writer: BackpressureWriter }>({
130
+ this.server = Bun.listen<{
131
+ buffer: LineBuffer;
132
+ connection: Connection;
133
+ writer: BackpressureWriter;
134
+ }>({
123
135
  unix: path,
124
136
  socket: {
125
- open(socket) {
137
+ open: (socket) => {
126
138
  const writer = new BackpressureWriter(socket);
127
139
  const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
128
140
  socket.data = { buffer: new LineBuffer(), connection, writer };
129
- self.connections.add(connection);
130
- self.scheduleIdleCheck();
141
+ this.connections.add(connection);
142
+ this.scheduleIdleCheck();
131
143
  },
132
- data(socket, data) {
144
+ data: (socket, data) => {
133
145
  socket.data.buffer.push(data.toString(), (line) => {
134
- void self.respondToRequestLine(socket.data.connection, line);
146
+ void this.respondToRequestLine(socket.data.connection, line);
135
147
  });
136
148
  },
137
149
  drain(socket) {
@@ -139,9 +151,9 @@ export class DaemonServer {
139
151
  // a throw here would take the whole daemon down with the socket
140
152
  socket.data?.writer.drain();
141
153
  },
142
- close(socket) {
143
- self.connections.delete(socket.data.connection);
144
- self.scheduleIdleCheck();
154
+ close: (socket) => {
155
+ this.connections.delete(socket.data.connection);
156
+ this.scheduleIdleCheck();
145
157
  },
146
158
  error() {},
147
159
  },
@@ -186,7 +198,10 @@ export class DaemonServer {
186
198
  try {
187
199
  request = JSON.parse(line) as Request;
188
200
  } catch {
189
- connection.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
201
+ connection.write(
202
+ JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
203
+ "\n",
204
+ );
190
205
  return;
191
206
  }
192
207
  try {
@@ -255,7 +270,10 @@ export class DaemonServer {
255
270
  }
256
271
  case "session.mergeShared": {
257
272
  const params = parseParams("session.mergeShared", request.params);
258
- return core.sessionMergeShared(params.id, { annotations: params.annotations, participants: params.participants });
273
+ return core.sessionMergeShared(params.id, {
274
+ annotations: params.annotations,
275
+ participants: params.participants,
276
+ });
259
277
  }
260
278
  case "session.resolve": {
261
279
  const params = parseParams("session.resolve", request.params);
package/src/share-blob.ts CHANGED
@@ -34,7 +34,10 @@ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
34
34
  try {
35
35
  json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
36
36
  } catch (err) {
37
- throw new Error(`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`);
37
+ throw new Error(
38
+ `blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
39
+ { cause: err },
40
+ );
38
41
  }
39
42
  let raw: unknown;
40
43
  try {
package/src/store.ts CHANGED
@@ -49,7 +49,9 @@ export class SessionStore {
49
49
  }
50
50
 
51
51
  list(): ReviewSession[] {
52
- return [...this.sessions.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
52
+ return [...this.sessions.values()].sort((left, right) =>
53
+ left.createdAt.localeCompare(right.createdAt),
54
+ );
53
55
  }
54
56
 
55
57
  upsert(session: ReviewSession): void {
@@ -77,12 +77,16 @@ describe("parseParams", () => {
77
77
  test("defaults and clamps the wait timeout", () => {
78
78
  expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
79
79
  expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
80
- expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 })).toThrow();
80
+ expect(() =>
81
+ parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
82
+ ).toThrow();
81
83
  });
82
84
 
83
85
  test("verdict kinds are closed", () => {
84
86
  expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
85
- expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(/verdictKind/);
87
+ expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
88
+ /verdictKind/,
89
+ );
86
90
  });
87
91
 
88
92
  test("annotation kinds stay open (extension kinds are allowed)", () => {
@@ -98,12 +102,14 @@ describe("parseParams", () => {
98
102
  });
99
103
 
100
104
  test("setViewed takes a full path list and rejects non-string entries", () => {
101
- expect(parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] }).viewedPaths).toEqual([
102
- "src/a.ts",
103
- "src/b.ts",
104
- ]);
105
+ expect(
106
+ parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
107
+ .viewedPaths,
108
+ ).toEqual(["src/a.ts", "src/b.ts"]);
105
109
  expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
106
- expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(/viewedPaths/);
110
+ expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
111
+ /viewedPaths/,
112
+ );
107
113
  expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
108
114
  });
109
115
 
@@ -166,7 +172,14 @@ describe("wire pins", () => {
166
172
  herdrPane: "%7",
167
173
  title: "Plan",
168
174
  };
169
- const fullAnchor: Required<Anchor> = { quote: "q", prefix: "p", suffix: "s", blockIndex: 0, start: 0, end: 1 };
175
+ const fullAnchor: Required<Anchor> = {
176
+ quote: "q",
177
+ prefix: "p",
178
+ suffix: "s",
179
+ blockIndex: 0,
180
+ start: 0,
181
+ end: 1,
182
+ };
170
183
  const fullAnnotation: Required<Annotation> = {
171
184
  id: "a1",
172
185
  kind: "comment",
@@ -177,11 +190,26 @@ describe("wire pins", () => {
177
190
  resolution: { revision: 2, source: "agent" },
178
191
  createdAt: "now",
179
192
  };
180
- const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
181
- const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
193
+ const fullArtifact: Required<Artifact> = {
194
+ type: "plan",
195
+ content: "# P",
196
+ meta: fullMeta,
197
+ files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
198
+ };
199
+ const fullVerdict: Required<Verdict> = {
200
+ kind: "approve",
201
+ summary: "",
202
+ feedback: "",
203
+ resolvedAt: "now",
204
+ };
182
205
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
183
206
  const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
184
- const fullIdentity: Required<Identity> = { id: "SHA256:abc", provider: "ssh", name: "Al", handle: "abc" };
207
+ const fullIdentity: Required<Identity> = {
208
+ id: "SHA256:abc",
209
+ provider: "ssh",
210
+ name: "Al",
211
+ handle: "abc",
212
+ };
185
213
  const fullSession: Required<ReviewSession> = {
186
214
  schemaVersion: SCHEMA_VERSION,
187
215
  id: "ses_1",
package/src/validate.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type Annotation,
17
17
  type Artifact,
18
18
  type ArtifactMeta,
19
+ type DiffFileContents,
19
20
  type Identity,
20
21
  type ReviewSession,
21
22
  type Revision,
@@ -50,10 +51,18 @@ export const ArtifactMetaSchema = v.object({
50
51
  title: v.optional(v.string()),
51
52
  } satisfies EntriesOf<ArtifactMeta>);
52
53
 
54
+ export const DiffFileContentsSchema = v.object({
55
+ path: NonEmpty,
56
+ oldContents: v.string(),
57
+ newContents: v.string(),
58
+ status: v.picklist(["added", "modified", "deleted"]),
59
+ } satisfies EntriesOf<DiffFileContents>);
60
+
53
61
  export const ArtifactSchema = v.object({
54
62
  type: v.picklist(["plan", "diff"]),
55
63
  content: v.string(),
56
64
  meta: v.optional(ArtifactMetaSchema, {}),
65
+ files: v.optional(v.array(DiffFileContentsSchema)),
57
66
  } satisfies EntriesOf<Artifact>);
58
67
 
59
68
  export const AnchorSchema = v.object({
@@ -68,7 +77,7 @@ export const AnchorSchema = v.object({
68
77
  /** Wire annotations arrive without createdAt - the daemon stamps it. */
69
78
  export const AnnotationSchema = v.object({
70
79
  id: NonEmpty,
71
- /** Open kind set: built-ins are comment and suggestion. */
80
+ /** Open kind set: the built-in is comment. */
72
81
  kind: NonEmpty,
73
82
  anchor: AnchorSchema,
74
83
  body: v.string(),
@@ -85,7 +94,10 @@ export const AnnotationSchema = v.object({
85
94
  const SessionId = NonEmpty;
86
95
 
87
96
  /** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
88
- export const FullAnnotationSchema = v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>);
97
+ export const FullAnnotationSchema = v.object({
98
+ ...AnnotationSchema.entries,
99
+ createdAt: v.string(),
100
+ } satisfies EntriesOf<Annotation>);
89
101
 
90
102
  export const IdentitySchema = v.object({
91
103
  id: NonEmpty,
@@ -108,7 +120,10 @@ export const Params = {
108
120
  "session.wait": v.object({
109
121
  id: SessionId,
110
122
  // clamped: a negative or absurd timeout is a client bug, not a daemon one
111
- timeoutMs: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)), 60_000),
123
+ timeoutMs: v.optional(
124
+ v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
125
+ 60_000,
126
+ ),
112
127
  }),
113
128
  "session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
114
129
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
@@ -144,12 +159,18 @@ export function isKnownMethod(method: string): method is MethodName {
144
159
  }
145
160
 
146
161
  /** Validate params for a method, or throw a DaemonError the client can read. */
147
- export function parseParams<M extends MethodName>(method: M, params: unknown): v.InferOutput<(typeof Params)[M]> {
162
+ export function parseParams<M extends MethodName>(
163
+ method: M,
164
+ params: unknown,
165
+ ): v.InferOutput<(typeof Params)[M]> {
148
166
  const result = v.safeParse(Params[method], params ?? {});
149
167
  if (!result.success) {
150
168
  const issue = result.issues[0]!;
151
169
  const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
152
- throw new DaemonError("invalid_params", `${method}: ${path ? path + " - " : ""}${issue.message}`);
170
+ throw new DaemonError(
171
+ "invalid_params",
172
+ `${method}: ${path ? path + " - " : ""}${issue.message}`,
173
+ );
153
174
  }
154
175
  return result.output;
155
176
  }
@@ -185,7 +206,9 @@ export const SessionRecordSchema = v.object({
185
206
  participants: v.optional(v.array(IdentitySchema)),
186
207
  } satisfies EntriesOf<ReviewSession>);
187
208
 
188
- export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
209
+ export function validateSessionRecord(
210
+ raw: unknown,
211
+ ): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
189
212
  const result = v.safeParse(SessionRecordSchema, raw);
190
213
  if (result.success) return { ok: true, value: result.output };
191
214
  const issue = result.issues[0]!;