@cueloop/daemon 0.1.0-alpha.15 → 0.1.0-alpha.17

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.17",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,7 +9,7 @@
9
9
  "./review": "./src/review.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@cueloop/schema": "0.1.0-alpha.15",
12
+ "@cueloop/schema": "0.1.0-alpha.17",
13
13
  "valibot": "^1.4.2"
14
14
  },
15
15
  "publishConfig": {
package/src/api.test.ts CHANGED
@@ -24,42 +24,42 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
24
24
 
25
25
  describe("session lifecycle", () => {
26
26
  test("create → get → list", () => {
27
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
28
- expect(s.status).toBe("pending");
29
- expect(s.revisions.length).toBe(1);
30
- expect(core.sessionGet(s.id).id).toBe(s.id);
27
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
28
+ expect(session.status).toBe("pending");
29
+ expect(session.revisions.length).toBe(1);
30
+ expect(core.sessionGet(session.id).id).toBe(session.id);
31
31
  expect(core.sessionList({ status: "pending" }).length).toBe(1);
32
32
  });
33
33
 
34
34
  test("annotate upserts by id; remove deletes", () => {
35
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
- core.sessionAnnotate(s.id, {
35
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
+ core.sessionAnnotate(session.id, {
37
37
  id: "a1",
38
38
  kind: "comment",
39
39
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
40
40
  body: "How carefully?",
41
41
  });
42
- core.sessionAnnotate(s.id, {
42
+ core.sessionAnnotate(session.id, {
43
43
  id: "a1",
44
44
  kind: "comment",
45
45
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
46
46
  body: "Define carefully.",
47
47
  });
48
- expect(core.sessionGet(s.id).annotations.length).toBe(1);
49
- expect(core.sessionGet(s.id).annotations[0]!.body).toBe("Define carefully.");
50
- core.sessionRemoveAnnotation(s.id, "a1");
51
- expect(core.sessionGet(s.id).annotations.length).toBe(0);
48
+ expect(core.sessionGet(session.id).annotations.length).toBe(1);
49
+ expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
50
+ core.sessionRemoveAnnotation(session.id, "a1");
51
+ expect(core.sessionGet(session.id).annotations.length).toBe(0);
52
52
  });
53
53
 
54
54
  test("resolve produces feedback.md and maps to the agent contract", () => {
55
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
- core.sessionAnnotate(s.id, {
55
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
+ core.sessionAnnotate(session.id, {
57
57
  id: "a1",
58
58
  kind: "comment",
59
59
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
60
60
  body: "Spell out the steps.",
61
61
  });
62
- const resolved = core.sessionResolve(s.id, "request_changes", "Needs detail.");
62
+ const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
63
63
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
64
  expect(resolved.verdict!.feedback).toContain("> carefully");
65
65
  expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
@@ -69,26 +69,40 @@ describe("session lifecycle", () => {
69
69
  });
70
70
 
71
71
  test("mutating a resolved session throws", () => {
72
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
- core.sessionResolve(s.id, "approve", "");
72
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
+ core.sessionResolve(session.id, "approve", "");
74
74
  expect(() =>
75
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
75
+ core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
76
76
  ).toThrow("already resolved");
77
77
  });
78
78
 
79
79
  test("working copy stores edits and clears on revert or no-op", () => {
80
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
81
- core.sessionSetWorkingCopy(s.id, PLAN.content.replace("carefully", "very carefully"));
82
- expect(core.sessionGet(s.id).workingCopy).toContain("very carefully");
83
- core.sessionSetWorkingCopy(s.id, PLAN.content);
84
- expect(core.sessionGet(s.id).workingCopy).toBeUndefined();
80
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
81
+ core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
82
+ expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
83
+ core.sessionSetWorkingCopy(session.id, PLAN.content);
84
+ expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
85
+ });
86
+
87
+ test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
88
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
+ core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
90
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
91
+ // merge-additive: a stale client sending only its own new mark loses nothing
92
+ core.sessionSetViewed(session.id, ["src/c.ts"]);
93
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
94
+ // a resumed review reads its progress back after a daemon restart
95
+ const reborn = new DaemonCore(home);
96
+ expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
97
+ core.sessionSetViewed(session.id, []);
98
+ expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
85
99
  });
86
100
 
87
101
  test("revision reopens the session and resets working copy + verdict", () => {
88
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
- core.sessionSetWorkingCopy(s.id, PLAN.content + "\nedit");
90
- core.sessionResolve(s.id, "request_changes", "redo");
91
- const revised = core.sessionSubmitRevision(s.id, "# Plan v2\n");
102
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
+ core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
104
+ core.sessionResolve(session.id, "request_changes", "redo");
105
+ const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
92
106
  expect(revised.status).toBe("pending");
93
107
  expect(revised.verdict).toBeNull();
94
108
  expect(revised.workingCopy).toBeUndefined();
@@ -99,37 +113,37 @@ describe("session lifecycle", () => {
99
113
 
100
114
  describe("the wait contract: verdicts outlive waits", () => {
101
115
  test("wait resolves when the verdict arrives", async () => {
102
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
- const wait = core.sessionWait(s.id, 5_000);
104
- core.sessionResolve(s.id, "approve", "");
116
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
117
+ const wait = core.sessionWait(session.id, 5_000);
118
+ core.sessionResolve(session.id, "approve", "");
105
119
  const resolved = await wait;
106
120
  expect(resolved!.verdict!.kind).toBe("approve");
107
121
  });
108
122
 
109
123
  test("wait times out to null; a later wait collects the stored verdict", async () => {
110
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
111
- const first = await core.sessionWait(s.id, 30);
124
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
125
+ const first = await core.sessionWait(session.id, 30);
112
126
  expect(first).toBeNull();
113
- core.sessionResolve(s.id, "request_changes", "later");
114
- const second = await core.sessionWait(s.id, 30);
127
+ core.sessionResolve(session.id, "request_changes", "later");
128
+ const second = await core.sessionWait(session.id, 30);
115
129
  expect(second!.verdict!.summary).toBe("later");
116
130
  });
117
131
 
118
132
  test("multiple waiters all resolve", async () => {
119
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
120
- const waits = [core.sessionWait(s.id, 5_000), core.sessionWait(s.id, 5_000)];
121
- core.sessionResolve(s.id, "approve", "");
133
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
134
+ const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
135
+ core.sessionResolve(session.id, "approve", "");
122
136
  const results = await Promise.all(waits);
123
- expect(results.every((r) => r?.status === "resolved")).toBe(true);
137
+ expect(results.every((result) => result?.status === "resolved")).toBe(true);
124
138
  });
125
139
  });
126
140
 
127
141
  describe("persistence and recovery", () => {
128
142
  test("sessions survive a daemon restart", () => {
129
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
130
- core.sessionResolve(s.id, "approve", "done");
143
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
144
+ core.sessionResolve(session.id, "approve", "done");
131
145
  const reborn = new DaemonCore(home);
132
- const recovered = reborn.sessionGet(s.id);
146
+ const recovered = reborn.sessionGet(session.id);
133
147
  expect(recovered.verdict!.summary).toBe("done");
134
148
  });
135
149
 
@@ -147,10 +161,10 @@ describe("persistence and recovery", () => {
147
161
  describe("events", () => {
148
162
  test("lifecycle events fire in order", () => {
149
163
  const seen: string[] = [];
150
- core.onEvent((e) => seen.push(e.event));
151
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
152
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
153
- core.sessionResolve(s.id, "approve", "");
164
+ core.onEvent((event) => seen.push(event.event));
165
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
166
+ core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
167
+ core.sessionResolve(session.id, "approve", "");
154
168
  expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
155
169
  });
156
170
  });
package/src/api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The daemon's method surface (#14). Transport-independent: the socket
2
+ * The daemon's method surface. Transport-independent: the socket
3
3
  * server and the in-process test harness both call these handlers.
4
4
  * The wait contract: verdicts outlive waits - session.wait long-polls,
5
5
  * and a verdict resolved while nobody waited is delivered on next contact.
@@ -31,7 +31,7 @@ export interface DaemonEvent {
31
31
  sessionId: string;
32
32
  }
33
33
 
34
- type EventListener = (e: DaemonEvent) => void;
34
+ type EventListener = (event: DaemonEvent) => void;
35
35
 
36
36
  export class DaemonCore {
37
37
  readonly store: SessionStore;
@@ -50,12 +50,12 @@ export class DaemonCore {
50
50
  }
51
51
 
52
52
  private emit(event: EventName, sessionId: string): void {
53
- for (const l of this.listeners) l({ event, sessionId });
53
+ for (const listener of this.listeners) listener({ event, sessionId });
54
54
  }
55
55
 
56
56
  /** True when nothing awaits a verdict - drives idle-exit. */
57
57
  hasPendingSessions(): boolean {
58
- return this.store.list().some((s) => s.status === "pending");
58
+ return this.store.list().some((session) => session.status === "pending");
59
59
  }
60
60
 
61
61
  sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
@@ -78,16 +78,16 @@ export class DaemonCore {
78
78
  }
79
79
 
80
80
  sessionGet(id: string): ReviewSession {
81
- const s = this.store.get(id);
82
- if (!s) throw new DaemonError("not_found", `no session ${id}`);
83
- return s;
81
+ const session = this.store.get(id);
82
+ if (!session) throw new DaemonError("not_found", `no session ${id}`);
83
+ return session;
84
84
  }
85
85
 
86
86
  sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
87
- return this.store.list().filter((s) => {
88
- if (filter?.status && s.status !== filter.status) return false;
89
- if (filter?.workspace?.repoRoot && s.workspace.repoRoot !== filter.workspace.repoRoot) return false;
90
- if (filter?.workspace?.branch && s.workspace.branch !== filter.workspace.branch) return false;
87
+ return this.store.list().filter((session) => {
88
+ if (filter?.status && session.status !== filter.status) return false;
89
+ if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot) return false;
90
+ if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch) return false;
91
91
  return true;
92
92
  });
93
93
  }
@@ -98,16 +98,16 @@ export class DaemonCore {
98
98
  * still pending - the caller re-polls later; the verdict is never lost).
99
99
  */
100
100
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
101
- const s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
101
+ const current = this.sessionGet(id);
102
+ if (current.status === "resolved") return Promise.resolve(current);
103
103
  return new Promise((resolve) => {
104
104
  const list = this.waiters.get(id) ?? [];
105
105
  let done = false;
106
106
  const timer = setTimeout(() => {
107
107
  if (done) return;
108
108
  done = true;
109
- const idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
109
+ const waiterIndex = list.indexOf(waiter);
110
+ if (waiterIndex !== -1) list.splice(waiterIndex, 1);
111
111
  resolve(null);
112
112
  }, timeoutMs);
113
113
  const waiter = (session: ReviewSession) => {
@@ -122,72 +122,86 @@ export class DaemonCore {
122
122
  }
123
123
 
124
124
  sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
125
- const s = this.mutable(id);
126
- const existing = s.annotations.findIndex((a) => a.id === annotation.id);
125
+ const session = this.mutable(id);
126
+ const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
127
127
  const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
128
- if (existing === -1) s.annotations.push(full);
129
- else s.annotations[existing] = { ...full, createdAt: s.annotations[existing]!.createdAt };
130
- this.store.upsert(s);
128
+ if (existing === -1) session.annotations.push(full);
129
+ else session.annotations[existing] = { ...full, createdAt: session.annotations[existing]!.createdAt };
130
+ this.store.upsert(session);
131
131
  this.emit("session.updated", id);
132
- return s;
132
+ return session;
133
133
  }
134
134
 
135
135
  sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
136
- const s = this.mutable(id);
137
- s.annotations = s.annotations.filter((a) => a.id !== annotationId);
138
- this.store.upsert(s);
136
+ const session = this.mutable(id);
137
+ session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
138
+ this.store.upsert(session);
139
139
  this.emit("session.updated", id);
140
- return s;
140
+ return session;
141
141
  }
142
142
 
143
143
  /** The reviewer's working copy; undefined clears it (revert all edits). */
144
144
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
145
- const s = this.mutable(id);
146
- if (workingCopy === undefined || workingCopy === s.artifact.content) delete s.workingCopy;
147
- else s.workingCopy = workingCopy;
148
- this.store.upsert(s);
145
+ const session = this.mutable(id);
146
+ if (workingCopy === undefined || workingCopy === session.artifact.content) delete session.workingCopy;
147
+ else session.workingCopy = workingCopy;
148
+ this.store.upsert(session);
149
149
  this.emit("session.updated", id);
150
- return s;
150
+ return session;
151
+ }
152
+
153
+ /**
154
+ * The guided walk's viewed marks. Merge-additive: walking only ever adds
155
+ * marks, so concurrent or stale clients converge instead of overwriting
156
+ * each other. An empty array is the explicit reset.
157
+ */
158
+ sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
159
+ const session = this.mutable(id);
160
+ if (viewedPaths.length === 0) delete session.viewedPaths;
161
+ else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
162
+ this.store.upsert(session);
163
+ this.emit("session.updated", id);
164
+ return session;
151
165
  }
152
166
 
153
167
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
168
+ const session = this.mutable(id);
155
169
  const verdict: Verdict = {
156
170
  kind: verdictKind,
157
171
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
172
+ feedback: feedbackForSession(session, verdictKind, summary),
159
173
  resolvedAt: new Date().toISOString(),
160
174
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
175
+ session.verdict = verdict;
176
+ session.status = "resolved";
177
+ this.store.upsert(session);
164
178
  const parked = this.waiters.get(id) ?? [];
165
179
  this.waiters.delete(id);
166
- for (const w of parked) w(s);
180
+ for (const parkedWaiter of parked) parkedWaiter(session);
167
181
  this.emit("session.resolved", id);
168
182
  this.emit("inbox.changed", id);
169
- return s;
183
+ return session;
170
184
  }
171
185
 
172
186
  /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
187
  sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
188
+ const session = this.sessionGet(id);
175
189
  const now = new Date().toISOString();
176
- s.revisions.push({ revision: s.revisions.length + 1, content, submittedAt: now });
177
- s.artifact = { ...s.artifact, content };
178
- delete s.workingCopy;
179
- s.verdict = null;
180
- s.status = "pending";
181
- this.store.upsert(s);
190
+ session.revisions.push({ revision: session.revisions.length + 1, content, submittedAt: now });
191
+ session.artifact = { ...session.artifact, content };
192
+ delete session.workingCopy;
193
+ session.verdict = null;
194
+ session.status = "pending";
195
+ this.store.upsert(session);
182
196
  this.emit("session.revised", id);
183
197
  this.emit("inbox.changed", id);
184
- return s;
198
+ return session;
185
199
  }
186
200
 
187
201
  private mutable(id: string): ReviewSession {
188
- const s = this.sessionGet(id);
189
- if (s.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
190
- return s;
202
+ const session = this.sessionGet(id);
203
+ if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
204
+ return session;
191
205
  }
192
206
  }
193
207
 
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Daemon client: the one library every consumer shares - CLI verbs, the TUI,
3
- * adapters, and tests. Also owns the lazy-launch story (#14): connect() with
3
+ * adapters, and tests. Also owns the lazy-launch story: connect() with
4
4
  * autostart spawns a detached daemon when the socket is dead, then attaches.
5
5
  */
6
6
 
@@ -15,25 +15,25 @@ export interface ConnectOptions {
15
15
  autostart?: boolean;
16
16
  }
17
17
 
18
- type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
18
+ type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
19
19
 
20
20
  export class DaemonClient {
21
21
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
22
  private writer: BackpressureWriter | null = null;
23
- private pending = new Map<number, Pending>();
23
+ private pending = new Map<number, PendingRequest>();
24
24
  private nextId = 1;
25
- private eventListeners = new Set<(e: EventFrame) => void>();
25
+ private eventListeners = new Set<(event: EventFrame) => void>();
26
26
  private closed = false;
27
27
 
28
- static async connect(opts: ConnectOptions = {}): Promise<DaemonClient> {
29
- const home = opts.home ?? cueloopHome();
28
+ static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
29
+ const home = options.home ?? cueloopHome();
30
30
  const path = socketPath(home);
31
31
  const client = new DaemonClient();
32
32
  try {
33
33
  await client.dial(path);
34
34
  return client;
35
35
  } catch (err) {
36
- if (!opts.autostart) throw err;
36
+ if (!options.autostart) throw err;
37
37
  }
38
38
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
39
39
  if (existsSync(path)) rmSync(path, { force: true });
@@ -41,17 +41,17 @@ export class DaemonClient {
41
41
  // Generous: a cold or loaded machine pays for a runtime start before the
42
42
  // socket exists, and giving up early looks to callers like a broken daemon.
43
43
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
44
- let lastErr: unknown;
44
+ let lastError: unknown;
45
45
  while (Date.now() < deadline) {
46
46
  try {
47
47
  await client.dial(path);
48
48
  return client;
49
49
  } catch (err) {
50
- lastErr = err;
50
+ lastError = err;
51
51
  await Bun.sleep(50);
52
52
  }
53
53
  }
54
- throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
54
+ throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
55
55
  }
56
56
 
57
57
  private async dial(path: string): Promise<void> {
@@ -61,14 +61,14 @@ export class DaemonClient {
61
61
  unix: path,
62
62
  socket: {
63
63
  data(_socket, data) {
64
- buffer.push(data.toString(), (line) => self.handleFrame(line));
64
+ buffer.push(data.toString(), (line) => self.routeInboundFrame(line));
65
65
  },
66
66
  drain() {
67
67
  self.writer?.drain();
68
68
  },
69
69
  close() {
70
70
  self.closed = true;
71
- for (const p of self.pending.values()) p.reject(new Error("daemon connection closed"));
71
+ for (const pendingRequest of self.pending.values()) pendingRequest.reject(new Error("daemon connection closed"));
72
72
  self.pending.clear();
73
73
  },
74
74
  error() {},
@@ -80,12 +80,12 @@ export class DaemonClient {
80
80
  await this.request("daemon.ping", {}, 2_000);
81
81
  }
82
82
 
83
- onEvent(listener: (e: EventFrame) => void): () => void {
83
+ onEvent(listener: (event: EventFrame) => void): () => void {
84
84
  this.eventListeners.add(listener);
85
85
  return () => this.eventListeners.delete(listener);
86
86
  }
87
87
 
88
- private handleFrame(line: string): void {
88
+ private routeInboundFrame(line: string): void {
89
89
  let frame: Response | EventFrame;
90
90
  try {
91
91
  frame = JSON.parse(line) as Response | EventFrame;
@@ -93,14 +93,14 @@ export class DaemonClient {
93
93
  return;
94
94
  }
95
95
  if ("event" in frame) {
96
- for (const l of this.eventListeners) l(frame);
96
+ for (const listener of this.eventListeners) listener(frame);
97
97
  return;
98
98
  }
99
- const p = this.pending.get(frame.id);
100
- if (!p) return;
99
+ const pendingRequest = this.pending.get(frame.id);
100
+ if (!pendingRequest) return;
101
101
  this.pending.delete(frame.id);
102
- if (frame.error) p.reject(new DaemonClientError(frame.error.code, frame.error.message));
103
- else p.resolve(frame.result);
102
+ if (frame.error) pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
103
+ else pendingRequest.resolve(frame.result);
104
104
  }
105
105
 
106
106
  request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
@@ -112,13 +112,13 @@ export class DaemonClient {
112
112
  reject(new Error(`request ${method} timed out`));
113
113
  }, timeoutMs);
114
114
  this.pending.set(id, {
115
- resolve: (v) => {
115
+ resolve: (value) => {
116
116
  clearTimeout(timer);
117
- resolve(v as T);
117
+ resolve(value as T);
118
118
  },
119
- reject: (e) => {
119
+ reject: (error) => {
120
120
  clearTimeout(timer);
121
- reject(e);
121
+ reject(error);
122
122
  },
123
123
  });
124
124
  this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
@@ -159,6 +159,9 @@ export class DaemonClient {
159
159
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
160
160
  return this.request("session.setWorkingCopy", { id, workingCopy });
161
161
  }
162
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
163
+ return this.request("session.setViewed", { id, viewedPaths });
164
+ }
162
165
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
163
166
  return this.request("session.resolve", { id, verdictKind, summary });
164
167
  }
package/src/protocol.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Wire protocol (#14): newline-delimited JSON over the unix socket.
2
+ * Wire protocol: newline-delimited JSON over the unix socket.
3
3
  * Requests: { id, method, params } Responses: { id, result } | { id, error }
4
4
  * Events (push, after events.subscribe): { event, sessionId }
5
5
  */
@@ -25,15 +25,15 @@ export type Frame = Response | EventFrame;
25
25
 
26
26
  /** Incremental NDJSON splitter; tolerates partial writes. */
27
27
  export class LineBuffer {
28
- private buf = "";
28
+ private buffered = "";
29
29
 
30
30
  push(chunk: string, onLine: (line: string) => void): void {
31
- this.buf += chunk;
31
+ this.buffered += chunk;
32
32
  for (;;) {
33
- const nl = this.buf.indexOf("\n");
34
- if (nl === -1) return;
35
- const line = this.buf.slice(0, nl).trim();
36
- this.buf = this.buf.slice(nl + 1);
33
+ const newlineIndex = this.buffered.indexOf("\n");
34
+ if (newlineIndex === -1) return;
35
+ const line = this.buffered.slice(0, newlineIndex).trim();
36
+ this.buffered = this.buffered.slice(newlineIndex + 1);
37
37
  if (line) onLine(line);
38
38
  }
39
39
  }
@@ -33,8 +33,8 @@ afterEach(() => {
33
33
  });
34
34
 
35
35
  function sh(args: string[], cwd: string): void {
36
- const p = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
37
- if (p.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
36
+ const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
37
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
38
38
  }
39
39
 
40
40
  describe("resolveWorkspace", () => {
@@ -63,13 +63,13 @@ describe("resolveWorkspace", () => {
63
63
  describe("openReview", () => {
64
64
  test("shapes the artifact: agent, derived title, cwd", async () => {
65
65
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
66
- const s = review.session;
67
- expect(s.status).toBe("pending");
68
- expect(s.artifact.type).toBe("plan");
69
- expect(s.artifact.meta.agent).toBe("test-agent");
70
- expect(s.artifact.meta.title).toBe("Rollout Plan");
71
- expect(s.artifact.meta.cwd).toBe(home);
72
- expect(s.workspace).toEqual({ repoRoot: home, branch: "detached" });
66
+ const session = review.session;
67
+ expect(session.status).toBe("pending");
68
+ expect(session.artifact.type).toBe("plan");
69
+ expect(session.artifact.meta.agent).toBe("test-agent");
70
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
71
+ expect(session.artifact.meta.cwd).toBe(home);
72
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
73
73
  });
74
74
 
75
75
  test("an explicit title wins; diffs never derive one from content", async () => {
@@ -99,6 +99,23 @@ describe("openReview", () => {
99
99
  const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
100
100
  expect(other.id).not.toBe(first.id);
101
101
  });
102
+
103
+ test("per-file notes land as note annotations anchored at the file path", async () => {
104
+ const review = await openReview(client, {
105
+ type: "diff",
106
+ content: "d",
107
+ cwd: home,
108
+ notes: [
109
+ { path: "src/a.ts", body: "Renames b and adds c." },
110
+ { path: "src/b.ts", body: "Swaps the line." },
111
+ ],
112
+ });
113
+ const notes = review.session.annotations;
114
+ expect(notes.length).toBe(2);
115
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
116
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
117
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
118
+ });
102
119
  });
103
120
 
104
121
  describe("awaitVerdict: one long-poll (the hook shape)", () => {
@@ -133,7 +150,7 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
133
150
  const waiting = review.awaitVerdict({
134
151
  timeoutMs: Infinity,
135
152
  pollMs: 100,
136
- onProgress: (s) => seen.push(s),
153
+ onProgress: (progress) => seen.push(progress),
137
154
  });
138
155
  await client.sessionAnnotate(review.id, {
139
156
  id: "a1",
@@ -142,8 +159,8 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
142
159
  body: "Name them.",
143
160
  });
144
161
  // give the loop a chunk to observe the annotation before resolving
145
- for (let i = 0; i < 100 && !seen.some((s) => s.annotations.length === 1); i++) await Bun.sleep(20);
146
- expect(seen.some((s) => s.annotations.length === 1)).toBe(true);
162
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
163
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
147
164
  await client.sessionResolve(review.id, "request_changes", "Too vague.");
148
165
  const verdict = await waiting;
149
166
  if (verdict === "pending") throw new Error("expected a verdict");