@cueloop/daemon 0.1.0-alpha.3 → 0.1.0-alpha.30

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,14 +1,17 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.30",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
- "./client": "./src/client.ts"
8
+ "./client": "./src/client.ts",
9
+ "./review": "./src/review.ts",
10
+ "./herdr-pane": "./src/herdr-pane.ts",
11
+ "./share-blob": "./src/share-blob.ts"
9
12
  },
10
13
  "dependencies": {
11
- "@cueloop/schema": "0.1.0-alpha.3",
14
+ "@cueloop/schema": "0.1.0-alpha.30",
12
15
  "valibot": "^1.4.2"
13
16
  },
14
17
  "publishConfig": {
@@ -25,5 +28,10 @@
25
28
  "type": "git",
26
29
  "url": "git+https://github.com/mmurakaru/cueloop.git",
27
30
  "directory": "packages/daemon"
28
- }
29
- }
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
+ }
package/src/api.test.ts CHANGED
@@ -24,71 +24,138 @@ 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
+ // Act
28
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
29
+
30
+ // Assert
31
+ expect(session.status).toBe("pending");
32
+ expect(session.revisions.length).toBe(1);
33
+ expect(core.sessionGet(session.id).id).toBe(session.id);
31
34
  expect(core.sessionList({ status: "pending" }).length).toBe(1);
32
35
  });
33
36
 
34
37
  test("annotate upserts by id; remove deletes", () => {
35
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
- core.sessionAnnotate(s.id, {
38
+ // Arrange
39
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
40
+
41
+ // Act
42
+ core.sessionAnnotate(session.id, {
37
43
  id: "a1",
38
44
  kind: "comment",
39
45
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
40
46
  body: "How carefully?",
41
47
  });
42
- core.sessionAnnotate(s.id, {
48
+ core.sessionAnnotate(session.id, {
43
49
  id: "a1",
44
50
  kind: "comment",
45
51
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
46
52
  body: "Define carefully.",
47
53
  });
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);
54
+
55
+ // Assert
56
+ expect(core.sessionGet(session.id).annotations.length).toBe(1);
57
+ expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
58
+
59
+ // Act
60
+ core.sessionRemoveAnnotation(session.id, "a1");
61
+
62
+ // Assert
63
+ expect(core.sessionGet(session.id).annotations.length).toBe(0);
52
64
  });
53
65
 
54
66
  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, {
67
+ // Arrange
68
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
69
+ core.sessionAnnotate(session.id, {
57
70
  id: "a1",
58
71
  kind: "comment",
59
72
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
60
73
  body: "Spell out the steps.",
61
74
  });
62
- const resolved = core.sessionResolve(s.id, "request_changes", "Needs detail.");
75
+
76
+ // Act
77
+ const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
78
+
79
+ // Assert
63
80
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
81
  expect(resolved.verdict!.feedback).toContain("> carefully");
65
82
  expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
83
+
84
+ // Arrange
66
85
  const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
86
+
87
+ // Act
67
88
  core.sessionResolve(approved.id, "approve", "LGTM");
89
+
90
+ // Assert
68
91
  expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
69
92
  });
70
93
 
71
94
  test("mutating a resolved session throws", () => {
72
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
- core.sessionResolve(s.id, "approve", "");
95
+ // Arrange
96
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
97
+ core.sessionResolve(session.id, "approve", "");
98
+
99
+ // Assert
74
100
  expect(() =>
75
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
101
+ core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
76
102
  ).toThrow("already resolved");
77
103
  });
78
104
 
79
105
  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();
106
+ // Arrange
107
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
108
+
109
+ // Act
110
+ core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
111
+
112
+ // Assert
113
+ expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
114
+
115
+ // Act
116
+ core.sessionSetWorkingCopy(session.id, PLAN.content);
117
+
118
+ // Assert
119
+ expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
120
+ });
121
+
122
+ test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
123
+ // Arrange
124
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
125
+
126
+ // Act
127
+ core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
128
+
129
+ // Assert
130
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
131
+
132
+ // Act
133
+ // merge-additive: a stale client sending only its own new mark loses nothing
134
+ core.sessionSetViewed(session.id, ["src/c.ts"]);
135
+
136
+ // Assert
137
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
138
+ // a resumed review reads its progress back after a daemon restart
139
+ const reborn = new DaemonCore(home);
140
+ expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
141
+
142
+ // Act
143
+ core.sessionSetViewed(session.id, []);
144
+
145
+ // Assert
146
+ expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
85
147
  });
86
148
 
87
149
  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");
150
+ // Arrange
151
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
152
+ core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
153
+ core.sessionResolve(session.id, "request_changes", "redo");
154
+
155
+ // Act
156
+ const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
157
+
158
+ // Assert
92
159
  expect(revised.status).toBe("pending");
93
160
  expect(revised.verdict).toBeNull();
94
161
  expect(revised.workingCopy).toBeUndefined();
@@ -97,47 +164,154 @@ describe("session lifecycle", () => {
97
164
  });
98
165
  });
99
166
 
167
+ describe("revision marks addressed annotations", () => {
168
+ const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
169
+ core.sessionAnnotate(sessionId, { id, kind: "comment", anchor: { quote, prefix: "", suffix: "" }, body });
170
+
171
+ test("ids the agent reports are marked addressed by that revision", () => {
172
+ // Arrange
173
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
174
+ annotate(session.id, "a1", "carefully");
175
+ annotate(session.id, "a2", "Context");
176
+
177
+ // Act: the revision keeps both quoted texts, so only the reported id resolves
178
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
179
+
180
+ // Assert
181
+ const [first, second] = revised.annotations;
182
+ expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
183
+ expect(second!.resolution).toBeUndefined();
184
+ });
185
+
186
+ test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
187
+ // Arrange
188
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
189
+ annotate(session.id, "gone", "carefully");
190
+ annotate(session.id, "kept", "Context");
191
+
192
+ // 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");
194
+
195
+ // Assert
196
+ expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({ revision: 2, source: "drift" });
197
+ expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
198
+ });
199
+
200
+ test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
201
+ // Arrange
202
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
203
+ annotate(session.id, "a1", "carefully");
204
+ core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
205
+
206
+ // Act: a second revision reports a stale id and re-reports the settled one
207
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
208
+
209
+ // Assert
210
+ expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
211
+ expect(revised.revisions.length).toBe(3);
212
+ });
213
+
214
+ test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
215
+ // Arrange
216
+ const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
217
+ const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
218
+ annotate(session.id, "d1", "new line");
219
+
220
+ // Act: the revised patch no longer contains the quoted text
221
+ const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
222
+
223
+ // Assert
224
+ expect(revised.annotations[0]!.resolution).toBeUndefined();
225
+ });
226
+
227
+ test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
228
+ // Arrange
229
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
230
+ annotate(session.id, "settled", "carefully");
231
+ annotate(session.id, "open", "Context", "needs a diagram");
232
+ core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
233
+
234
+ // Act
235
+ const resolved = core.sessionResolve(session.id, "request_changes", "one left");
236
+
237
+ // Assert
238
+ expect(resolved.verdict!.feedback).not.toContain("settled");
239
+ expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
240
+ expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
241
+ expect(resolved.verdict!.feedback).toContain("--addressed");
242
+ });
243
+ });
244
+
100
245
  describe("the wait contract: verdicts outlive waits", () => {
101
246
  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", "");
247
+ // Arrange
248
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
249
+ const wait = core.sessionWait(session.id, 5_000);
250
+
251
+ // Act
252
+ core.sessionResolve(session.id, "approve", "");
105
253
  const resolved = await wait;
254
+
255
+ // Assert
106
256
  expect(resolved!.verdict!.kind).toBe("approve");
107
257
  });
108
258
 
109
259
  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);
260
+ // Arrange
261
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
262
+
263
+ // Act
264
+ const first = await core.sessionWait(session.id, 30);
265
+
266
+ // Assert
112
267
  expect(first).toBeNull();
113
- core.sessionResolve(s.id, "request_changes", "later");
114
- const second = await core.sessionWait(s.id, 30);
268
+
269
+ // Act
270
+ core.sessionResolve(session.id, "request_changes", "later");
271
+ const second = await core.sessionWait(session.id, 30);
272
+
273
+ // Assert
115
274
  expect(second!.verdict!.summary).toBe("later");
116
275
  });
117
276
 
118
277
  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", "");
278
+ // Arrange
279
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
280
+ const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
281
+
282
+ // Act
283
+ core.sessionResolve(session.id, "approve", "");
122
284
  const results = await Promise.all(waits);
123
- expect(results.every((r) => r?.status === "resolved")).toBe(true);
285
+
286
+ // Assert
287
+ expect(results.every((result) => result?.status === "resolved")).toBe(true);
124
288
  });
125
289
  });
126
290
 
127
291
  describe("persistence and recovery", () => {
128
292
  test("sessions survive a daemon restart", () => {
129
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
130
- core.sessionResolve(s.id, "approve", "done");
293
+ // Arrange
294
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
295
+ core.sessionResolve(session.id, "approve", "done");
296
+
297
+ // Act
131
298
  const reborn = new DaemonCore(home);
132
- const recovered = reborn.sessionGet(s.id);
299
+ const recovered = reborn.sessionGet(session.id);
300
+
301
+ // Assert
133
302
  expect(recovered.verdict!.summary).toBe("done");
134
303
  });
135
304
 
136
305
  test("corrupt records are skipped, not fatal, and never deleted", async () => {
306
+ // Arrange
137
307
  core.sessionCreate({ workspace: WS, artifact: PLAN });
138
308
  await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
139
309
  const store = new SessionStore(home);
310
+
311
+ // Act
140
312
  const report = store.recover();
313
+
314
+ // Assert
141
315
  expect(report.recovered.length).toBe(1);
142
316
  expect(report.skipped.length).toBe(1);
143
317
  expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
@@ -146,11 +320,16 @@ describe("persistence and recovery", () => {
146
320
 
147
321
  describe("events", () => {
148
322
  test("lifecycle events fire in order", () => {
323
+ // Arrange
149
324
  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", "");
325
+ core.onEvent((event) => seen.push(event.event));
326
+
327
+ // Act
328
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
329
+ core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
330
+ core.sessionResolve(session.id, "approve", "");
331
+
332
+ // Assert
154
333
  expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
155
334
  });
156
335
  });
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.
@@ -8,9 +8,14 @@
8
8
  import {
9
9
  SCHEMA_VERSION,
10
10
  feedbackForSession,
11
+ isAddressed,
12
+ isAgentNote,
13
+ parseBlocks,
14
+ resolveAnchor,
11
15
  verdictAllows,
12
16
  type Annotation,
13
17
  type Artifact,
18
+ type Identity,
14
19
  type ReviewSession,
15
20
  type Verdict,
16
21
  type VerdictKind,
@@ -31,7 +36,7 @@ export interface DaemonEvent {
31
36
  sessionId: string;
32
37
  }
33
38
 
34
- type EventListener = (e: DaemonEvent) => void;
39
+ type EventListener = (event: DaemonEvent) => void;
35
40
 
36
41
  export class DaemonCore {
37
42
  readonly store: SessionStore;
@@ -50,12 +55,12 @@ export class DaemonCore {
50
55
  }
51
56
 
52
57
  private emit(event: EventName, sessionId: string): void {
53
- for (const l of this.listeners) l({ event, sessionId });
58
+ for (const listener of this.listeners) listener({ event, sessionId });
54
59
  }
55
60
 
56
61
  /** True when nothing awaits a verdict - drives idle-exit. */
57
62
  hasPendingSessions(): boolean {
58
- return this.store.list().some((s) => s.status === "pending");
63
+ return this.store.list().some((session) => session.status === "pending");
59
64
  }
60
65
 
61
66
  sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
@@ -78,16 +83,16 @@ export class DaemonCore {
78
83
  }
79
84
 
80
85
  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;
86
+ const session = this.store.get(id);
87
+ if (!session) throw new DaemonError("not_found", `no session ${id}`);
88
+ return session;
84
89
  }
85
90
 
86
91
  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;
92
+ return this.store.list().filter((session) => {
93
+ 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;
91
96
  return true;
92
97
  });
93
98
  }
@@ -98,16 +103,16 @@ export class DaemonCore {
98
103
  * still pending - the caller re-polls later; the verdict is never lost).
99
104
  */
100
105
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
101
- const s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
106
+ const current = this.sessionGet(id);
107
+ if (current.status === "resolved") return Promise.resolve(current);
103
108
  return new Promise((resolve) => {
104
109
  const list = this.waiters.get(id) ?? [];
105
110
  let done = false;
106
111
  const timer = setTimeout(() => {
107
112
  if (done) return;
108
113
  done = true;
109
- const idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
114
+ const waiterIndex = list.indexOf(waiter);
115
+ if (waiterIndex !== -1) list.splice(waiterIndex, 1);
111
116
  resolve(null);
112
117
  }, timeoutMs);
113
118
  const waiter = (session: ReviewSession) => {
@@ -122,72 +127,145 @@ export class DaemonCore {
122
127
  }
123
128
 
124
129
  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);
130
+ const session = this.mutable(id);
131
+ const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
127
132
  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);
133
+ if (existing === -1) session.annotations.push(full);
134
+ else session.annotations[existing] = { ...full, createdAt: session.annotations[existing]!.createdAt };
135
+ this.store.upsert(session);
131
136
  this.emit("session.updated", id);
132
- return s;
137
+ return session;
133
138
  }
134
139
 
135
140
  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);
141
+ const session = this.mutable(id);
142
+ session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
143
+ this.store.upsert(session);
139
144
  this.emit("session.updated", id);
140
- return s;
145
+ return session;
141
146
  }
142
147
 
143
148
  /** The reviewer's working copy; undefined clears it (revert all edits). */
144
149
  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);
150
+ const session = this.mutable(id);
151
+ if (workingCopy === undefined || workingCopy === session.artifact.content) delete session.workingCopy;
152
+ else session.workingCopy = workingCopy;
153
+ this.store.upsert(session);
154
+ this.emit("session.updated", id);
155
+ return session;
156
+ }
157
+
158
+ /**
159
+ * The guided walk's viewed marks. Merge-additive: walking only ever adds
160
+ * marks, so concurrent or stale clients converge instead of overwriting
161
+ * each other. An empty array is the explicit reset.
162
+ */
163
+ sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
164
+ const session = this.mutable(id);
165
+ if (viewedPaths.length === 0) delete session.viewedPaths;
166
+ else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
167
+ this.store.upsert(session);
168
+ this.emit("session.updated", id);
169
+ return session;
170
+ }
171
+
172
+ sessionSetShareId(id: string, shareId: string): ReviewSession {
173
+ const session = this.mutable(id);
174
+ session.shareId = shareId;
175
+ this.store.upsert(session);
176
+ this.emit("session.updated", id);
177
+ return session;
178
+ }
179
+
180
+ /** Remove a session for good (inbox delete); resolved or pending, both go. */
181
+ sessionDelete(id: string): void {
182
+ if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
183
+ this.emit("inbox.changed", id);
184
+ }
185
+
186
+ /**
187
+ * Merge a share's collaborator state back into the local session: annotations
188
+ * union by id with existing ones (the planner's) winning, and the participant
189
+ * registry union by id with the incoming identity winning (a collaborator is
190
+ * the authority on their own name). This is how a teammate's name reaches the
191
+ * planner after a pull, alongside their notes.
192
+ */
193
+ sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): ReviewSession {
194
+ const session = this.mutable(id);
195
+ 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);
197
+ if (incoming.participants?.length) {
198
+ const registry = new Map((session.participants ?? []).map((participant) => [participant.id, participant]));
199
+ for (const participant of incoming.participants) registry.set(participant.id, participant);
200
+ session.participants = [...registry.values()];
201
+ }
202
+ this.store.upsert(session);
149
203
  this.emit("session.updated", id);
150
- return s;
204
+ return session;
151
205
  }
152
206
 
153
207
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
208
+ const session = this.mutable(id);
155
209
  const verdict: Verdict = {
156
210
  kind: verdictKind,
157
211
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
212
+ feedback: feedbackForSession(session, verdictKind, summary),
159
213
  resolvedAt: new Date().toISOString(),
160
214
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
215
+ session.verdict = verdict;
216
+ session.status = "resolved";
217
+ this.store.upsert(session);
164
218
  const parked = this.waiters.get(id) ?? [];
165
219
  this.waiters.delete(id);
166
- for (const w of parked) w(s);
220
+ for (const parkedWaiter of parked) parkedWaiter(session);
167
221
  this.emit("session.resolved", id);
168
222
  this.emit("inbox.changed", id);
169
- return s;
223
+ return session;
170
224
  }
171
225
 
172
- /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
- sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
226
+ /**
227
+ * Agent resubmits: new revision becomes the artifact, session reopens.
228
+ * Annotations the agent reports as acted on (by id) are marked addressed,
229
+ * and for plan revisions any still-open annotation whose quoted text no
230
+ * longer resolves is marked addressed too ("drift" - the line it pointed at
231
+ * was rewritten). Addressed is a marker, never a delete: the reviewer's
232
+ * rail hides them behind a count, and the next feedback document omits them.
233
+ * Unknown ids are ignored, so a stale id in the agent's list never fails
234
+ * the resubmit.
235
+ */
236
+ sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): ReviewSession {
237
+ const session = this.sessionGet(id);
175
238
  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);
239
+ const revisionNumber = session.revisions.length + 1;
240
+ session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
241
+ session.artifact = { ...session.artifact, content };
242
+ delete session.workingCopy;
243
+ session.verdict = null;
244
+ session.status = "pending";
245
+
246
+ const reportedIds = new Set(addressedAnnotationIds);
247
+ // drift assist applies to plans only: a diff revision is a whole new
248
+ // patch, where a vanished quote says nothing about the feedback
249
+ const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
250
+ for (const annotation of session.annotations) {
251
+ if (isAddressed(annotation) || isAgentNote(annotation)) continue;
252
+ if (reportedIds.has(annotation.id)) {
253
+ annotation.resolution = { revision: revisionNumber, source: "agent" };
254
+ } else if (revisedBlocks !== null && resolveAnchor(annotation.anchor, revisedBlocks) === null) {
255
+ annotation.resolution = { revision: revisionNumber, source: "drift" };
256
+ }
257
+ }
258
+
259
+ this.store.upsert(session);
182
260
  this.emit("session.revised", id);
183
261
  this.emit("inbox.changed", id);
184
- return s;
262
+ return session;
185
263
  }
186
264
 
187
265
  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;
266
+ const session = this.sessionGet(id);
267
+ if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
268
+ return session;
191
269
  }
192
270
  }
193
271