@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +33 -22
  2. package/src/api.test.ts +891 -50
  3. package/src/api.ts +849 -72
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +77 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +459 -69
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +186 -0
  11. package/src/diff-watcher.ts +102 -0
  12. package/src/herdr-pane.test.ts +240 -0
  13. package/src/herdr-pane.ts +167 -0
  14. package/src/herdr-split.test.ts +106 -0
  15. package/src/herdr-split.ts +92 -0
  16. package/src/herdr-tab-store.test.ts +96 -0
  17. package/src/herdr-tab-store.ts +66 -0
  18. package/src/index.ts +8 -9
  19. package/src/interruptible-wait.test.ts +127 -0
  20. package/src/interruptible-wait.ts +66 -0
  21. package/src/main.ts +4 -1
  22. package/src/paths.ts +39 -0
  23. package/src/project-files.test.ts +124 -0
  24. package/src/project-files.ts +56 -0
  25. package/src/protocol.ts +79 -7
  26. package/src/retention.test.ts +182 -0
  27. package/src/retention.ts +122 -0
  28. package/src/review.test.ts +255 -28
  29. package/src/review.ts +164 -62
  30. package/src/server.test.ts +381 -39
  31. package/src/server.ts +322 -84
  32. package/src/share-blob.test.ts +112 -0
  33. package/src/share-blob.ts +55 -0
  34. package/src/single-instance.test.ts +38 -11
  35. package/src/store.conformance.test.ts +41 -0
  36. package/src/store.jsonl.test.ts +159 -0
  37. package/src/store.ts +261 -34
  38. package/src/testing/store-conformance.ts +194 -0
  39. package/src/validate.test.ts +174 -28
  40. package/src/validate.ts +226 -16
  41. package/src/version.ts +6 -0
  42. package/src/working-tree.ts +149 -0
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.
@@ -7,18 +7,60 @@
7
7
 
8
8
  import {
9
9
  SCHEMA_VERSION,
10
+ appendEntry,
11
+ applyPathView,
12
+ createBranch,
13
+ cutBlock,
10
14
  feedbackForSession,
15
+ forkHistory,
16
+ historyFromLinear,
17
+ HistoryError,
18
+ labelTip,
19
+ navigateTo,
20
+ recaptureMainHead,
21
+ viewOfPath,
22
+ isAddressed,
23
+ isAgentNote,
24
+ annotationTarget,
25
+ isMarkdownArtifact,
26
+ MAIN_BRANCH,
27
+ parseBlocks,
28
+ registerParticipant,
29
+ resolveAnchor,
30
+ isBlockCut,
31
+ restoreBlock,
32
+ switchBranch,
11
33
  verdictAllows,
12
34
  type Annotation,
35
+ type DiffFileStatus,
36
+ type HunkRejection,
37
+ type NewEntry,
13
38
  type Artifact,
14
- type ReviewSession,
39
+ type Identity,
40
+ type Thread,
41
+ type SessionHistory,
15
42
  type Verdict,
16
43
  type VerdictKind,
17
44
  type WorkspaceKey,
18
45
  } from "@cueloop/schema";
19
- import { SessionStore } from "./store";
46
+ import { curateDiff } from "./curate";
47
+ import { ThreadStore, withHistory } from "./store";
48
+ import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
49
+ import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
50
+ import { DiffWatcher } from "./diff-watcher";
51
+ import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
52
+ import { listProjectFiles, readProjectFile } from "./project-files";
53
+ import { resolveWorkspace } from "./review";
20
54
  import { DaemonError } from "./errors";
21
55
 
56
+ /** What a share hands back: the notes and names it collected, and the removals it recorded. */
57
+ export interface SharedMerge {
58
+ annotations: Annotation[];
59
+ participants?: Identity[];
60
+ /** Removal entries by id; a merge applies each once and never rewrites what it already holds. */
61
+ removals?: Array<{ id: string; annotationId: string; createdAt: string }>;
62
+ }
63
+
22
64
  export type EventName =
23
65
  | "session.created"
24
66
  | "session.updated"
@@ -29,40 +71,76 @@ export type EventName =
29
71
  export interface DaemonEvent {
30
72
  event: EventName;
31
73
  sessionId: string;
74
+ /** The history entry the change appended, when it appended one. */
75
+ entryId?: string;
32
76
  }
33
77
 
34
- type EventListener = (e: DaemonEvent) => void;
78
+ type EventListener = (event: DaemonEvent) => void;
35
79
 
36
80
  export class DaemonCore {
37
- readonly store: SessionStore;
38
- private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
81
+ readonly store: ThreadStore;
82
+ readonly herdrTabs: HerdrTabStore;
83
+ private waiters = new Map<string, ((session: Thread) => void)[]>();
39
84
  private listeners = new Set<EventListener>();
40
85
  private seq = 0;
86
+ /** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
87
+ private readonly diffWatcher: DiffWatcher;
88
+ /**
89
+ * Per-diff-session capture generation. Bumped when a refresh begins; a
90
+ * capture whose generation is stale by the time it finishes discards its
91
+ * result, so overlapping captures never write an older patch over a newer one.
92
+ */
93
+ private readonly diffRefreshGenerations = new Map<string, number>();
94
+ /** In-flight workbench creations keyed by project, so concurrent bare launches share one thread. */
95
+ private readonly workbenchCreation = new Map<string, Promise<Thread>>();
41
96
 
42
97
  constructor(home: string) {
43
- this.store = new SessionStore(home);
98
+ this.store = new ThreadStore(home);
44
99
  this.store.recover();
100
+ pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
101
+ this.herdrTabs = new HerdrTabStore(home);
102
+ this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
103
+ // resume hot-reload for diff sessions that survived a daemon restart
104
+ for (const session of this.store.list()) this.watchIfDiffSession(session);
105
+ }
106
+
107
+ /** Release the fs watchers behind diff hot-reload; call on daemon shutdown. */
108
+ dispose(): void {
109
+ this.diffWatcher.close();
110
+ }
111
+
112
+ /** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
113
+ herdrGetTab(sessionId: string): HerdrTabHandle | null {
114
+ return this.herdrTabs.get(sessionId);
115
+ }
116
+
117
+ herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
118
+ this.herdrTabs.set(sessionId, handle);
45
119
  }
46
120
 
47
121
  onEvent(listener: EventListener): () => void {
48
122
  this.listeners.add(listener);
123
+
49
124
  return () => this.listeners.delete(listener);
50
125
  }
51
126
 
52
- private emit(event: EventName, sessionId: string): void {
53
- for (const l of this.listeners) l({ event, sessionId });
127
+ private emit(event: EventName, sessionId: string, entryId?: string): void {
128
+ const frame: DaemonEvent =
129
+ entryId === undefined ? { event, sessionId } : { event, sessionId, entryId };
130
+
131
+ for (const listener of this.listeners) listener(frame);
54
132
  }
55
133
 
56
134
  /** True when nothing awaits a verdict - drives idle-exit. */
57
135
  hasPendingSessions(): boolean {
58
- return this.store.list().some((s) => s.status === "pending");
136
+ return this.store.list().some((session) => session.status === "pending");
59
137
  }
60
138
 
61
- sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
139
+ sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): Thread {
62
140
  const now = new Date().toISOString();
63
- const session: ReviewSession = {
141
+ const session: Thread = {
64
142
  schemaVersion: SCHEMA_VERSION,
65
- id: `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`,
143
+ id: this.newSessionId(now),
66
144
  workspace: params.workspace,
67
145
  artifact: params.artifact,
68
146
  revisions: [{ revision: 1, content: params.artifact.content, submittedAt: now }],
@@ -71,23 +149,39 @@ export class DaemonCore {
71
149
  status: "pending",
72
150
  createdAt: now,
73
151
  };
152
+
153
+ session.history = historyFromLinear(session);
74
154
  this.store.upsert(session);
155
+ this.watchIfDiffSession(session);
75
156
  this.emit("session.created", session.id);
76
157
  this.emit("inbox.changed", session.id);
158
+
77
159
  return session;
78
160
  }
79
161
 
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;
162
+ private newSessionId(now: string): string {
163
+ return `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
164
+ }
165
+
166
+ sessionGet(id: string): Thread {
167
+ const session = this.store.get(id);
168
+
169
+ if (!session) throw new DaemonError("not_found", `no session ${id}`);
170
+
171
+ return session;
84
172
  }
85
173
 
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;
174
+ sessionList(filter?: {
175
+ status?: "pending" | "resolved";
176
+ workspace?: Partial<WorkspaceKey>;
177
+ }): Thread[] {
178
+ return this.store.list().filter((session) => {
179
+ if (filter?.status && session.status !== filter.status) return false;
180
+ if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
181
+ return false;
182
+ if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
183
+ return false;
184
+
91
185
  return true;
92
186
  });
93
187
  }
@@ -97,104 +191,787 @@ export class DaemonCore {
97
191
  * otherwise parks until sessionResolve fires or timeoutMs elapses (null =
98
192
  * still pending - the caller re-polls later; the verdict is never lost).
99
193
  */
100
- sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
101
- const s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
194
+ sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
195
+ const current = this.sessionGet(id);
196
+
197
+ if (current.status === "resolved") return Promise.resolve(current);
198
+
103
199
  return new Promise((resolve) => {
104
200
  const list = this.waiters.get(id) ?? [];
105
201
  let done = false;
106
202
  const timer = setTimeout(() => {
107
203
  if (done) return;
108
204
  done = true;
109
- const idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
205
+ const waiterIndex = list.indexOf(waiter);
206
+
207
+ if (waiterIndex !== -1) list.splice(waiterIndex, 1);
111
208
  resolve(null);
112
209
  }, timeoutMs);
113
- const waiter = (session: ReviewSession) => {
210
+ const waiter = (session: Thread) => {
114
211
  if (done) return;
115
212
  done = true;
116
213
  clearTimeout(timer);
117
214
  resolve(session);
118
215
  };
216
+
119
217
  list.push(waiter);
120
218
  this.waiters.set(id, list);
121
219
  });
122
220
  }
123
221
 
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);
222
+ /**
223
+ * `authorName` registers the annotation's author in the participant registry;
224
+ * a bare author id (no name) keeps its short-handle fallback instead.
225
+ */
226
+ sessionAnnotate(
227
+ id: string,
228
+ annotation: Omit<Annotation, "createdAt">,
229
+ authorName?: string,
230
+ ): Thread {
231
+ const session = this.mutable(id);
232
+ // a welcome-playground note is ephemeral by design: never persisted, so never fed back
233
+ if (annotationTarget(annotation).kind === "welcome") return session;
234
+ const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
127
235
  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);
131
- this.emit("session.updated", id);
132
- return s;
236
+
237
+ let entryId: string | undefined;
238
+
239
+ if (existing === -1) {
240
+ session.annotations.push(full);
241
+ entryId = this.record(session, {
242
+ type: "comment",
243
+ annotationId: full.id,
244
+ createdAt: full.createdAt,
245
+ });
246
+ } else
247
+ session.annotations[existing] = {
248
+ ...full,
249
+ createdAt: session.annotations[existing]!.createdAt,
250
+ };
251
+ if (annotation.author && authorName)
252
+ session.participants = registerParticipant(
253
+ session,
254
+ annotation.author,
255
+ authorName,
256
+ ).participants;
257
+ this.store.upsert(session);
258
+ this.emit("session.updated", id, entryId);
259
+
260
+ return session;
261
+ }
262
+
263
+ /**
264
+ * Remove a comment. With `onBehalfOf`, the caller is a collaborator or an
265
+ * agent acting as that author and may remove only that author's comments;
266
+ * the owner (no `onBehalfOf`) may remove any.
267
+ */
268
+ sessionRemoveAnnotation(id: string, annotationId: string, onBehalfOf?: string): Thread {
269
+ const session = this.mutable(id);
270
+ const target = session.annotations.find((candidate) => candidate.id === annotationId);
271
+
272
+ if (onBehalfOf !== undefined && target !== undefined && target.author !== onBehalfOf) {
273
+ throw new DaemonError("forbidden", `${onBehalfOf} cannot remove another author's comment`);
274
+ }
275
+ session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
276
+ let entryId: string | undefined;
277
+
278
+ if (target !== undefined) {
279
+ // nothing is deleted: a navigate back before the removal shows it again
280
+ session.shelvedAnnotations = [...(session.shelvedAnnotations ?? []), target];
281
+ entryId = this.record(session, {
282
+ type: "comment-removed",
283
+ annotationId,
284
+ createdAt: new Date().toISOString(),
285
+ });
286
+ }
287
+
288
+ this.store.upsert(session);
289
+ this.emit("session.updated", id, entryId);
290
+
291
+ return session;
133
292
  }
134
293
 
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);
294
+ /** Register a display name for a participant; how a collaborator or an agent names itself. */
295
+ sessionSetParticipantName(id: string, author: string, name: string): Thread {
296
+ const session = this.mutable(id);
297
+
298
+ session.participants = registerParticipant(session, author, name).participants;
299
+ this.store.upsert(session);
139
300
  this.emit("session.updated", id);
140
- return s;
301
+
302
+ return session;
141
303
  }
142
304
 
143
305
  /** The reviewer's working copy; undefined clears it (revert all edits). */
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);
306
+ sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Thread {
307
+ const session = this.mutable(id);
308
+ const entryId = this.applyWorkingCopy(session, workingCopy);
309
+
310
+ this.store.upsert(session);
311
+ this.emit("session.updated", id, entryId);
312
+
313
+ return session;
314
+ }
315
+
316
+ /**
317
+ * The guided walk's viewed marks. Merge-additive: walking only ever adds
318
+ * marks, so concurrent or stale clients converge instead of overwriting
319
+ * each other. An empty array is the explicit reset.
320
+ */
321
+ sessionSetViewed(id: string, viewedPaths: string[]): Thread {
322
+ const session = this.mutable(id);
323
+
324
+ if (viewedPaths.length === 0) delete session.viewedPaths;
325
+ else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
326
+ this.store.upsert(session);
327
+ this.emit("session.updated", id);
328
+
329
+ return session;
330
+ }
331
+
332
+ /** Rename a session's display title; an empty title clears it back to the derived default. */
333
+ sessionSetTitle(id: string, title: string): Thread {
334
+ const session = this.mutable(id);
335
+ const trimmed = title.trim();
336
+
337
+ if (trimmed.length === 0) delete session.artifact.meta.title;
338
+ else session.artifact.meta.title = trimmed;
339
+ this.store.upsert(session);
340
+ // the title shows in the Threads sidebar, so a rename is an inbox change, not just a content edit
341
+ this.emit("inbox.changed", id);
342
+
343
+ return session;
344
+ }
345
+
346
+ /** Tracked, repo-relative file paths for the session's workspace; [] when it has no repo. */
347
+ projectFiles(id: string): Promise<string[]> {
348
+ return listProjectFiles(this.sessionGet(id).workspace.repoRoot);
349
+ }
350
+
351
+ /** UTF-8 contents of a repo-relative file in the session's workspace, or null when it cannot be read safely. */
352
+ fileContents(id: string, path: string): Promise<string | null> {
353
+ return readProjectFile(this.sessionGet(id).workspace.repoRoot, path);
354
+ }
355
+
356
+ /** Tracked, repo-relative file paths for the git repo containing `cwd`; [] when it is not a repo. Owner-only. */
357
+ async repoFiles(cwd: string): Promise<string[]> {
358
+ return listProjectFiles((await resolveWorkspace(cwd)).repoRoot);
359
+ }
360
+
361
+ /** UTF-8 contents of a repo-relative file in the repo containing `cwd`, or null when unreadable. Owner-only. */
362
+ async repoFileContents(cwd: string, path: string): Promise<string | null> {
363
+ return readProjectFile((await resolveWorkspace(cwd)).repoRoot, path);
364
+ }
365
+
366
+ /** Changed files (repo-relative path plus git status) in the working tree at `cwd`. Owner-only. */
367
+ repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
368
+ return workingChangeList(cwd);
369
+ }
370
+
371
+ /**
372
+ * The live working-tree diff (HEAD vs working tree, untracked included) at `cwd`:
373
+ * the same unified patch plus full per-file contents a `cueloop diff` captures,
374
+ * so the Changes navigator renders a real diff for any thread. Owner-only.
375
+ */
376
+ repoDiff(cwd: string): Promise<WorkingTreeDiff> {
377
+ return workingTreeDiff(cwd);
378
+ }
379
+
380
+ /**
381
+ * The per-repo workbench thread for `cwd`: a self-initiated review of the current checkout, keyed by
382
+ * the repo's root-commit identity (a standalone bucket when there is none). Returns the existing one
383
+ * or lazily creates it with the working-tree diff as its artifact, so a bare launch persists its
384
+ * first comment without an agent submission. Owner-only.
385
+ */
386
+ async workbenchSession(cwd: string): Promise<Thread> {
387
+ const workspace = await resolveWorkspace(cwd);
388
+ const key = workspace.rootCommit ?? "_standalone";
389
+ // an open workbench is reused; a resolved one is immutable and would reject the note, so a fresh
390
+ // one is created past it
391
+ const open = this.store
392
+ .list()
393
+ .find(
394
+ (session) =>
395
+ session.artifact.meta.workbench === true &&
396
+ session.status !== "resolved" &&
397
+ (session.workspace.rootCommit ?? "_standalone") === key,
398
+ );
399
+
400
+ if (open) return open;
401
+ // serialize concurrent bare launches for the same repo onto one creation, so they share a thread
402
+ const inFlight = this.workbenchCreation.get(key);
403
+
404
+ if (inFlight) return inFlight;
405
+ const creation = workingTreeDiff(cwd).then((diff) =>
406
+ this.sessionCreate({
407
+ workspace,
408
+ artifact: {
409
+ type: "diff",
410
+ content: diff.patch,
411
+ files: diff.files,
412
+ meta: { workbench: true, title: "Workbench", cwd },
413
+ },
414
+ }),
415
+ );
416
+
417
+ this.workbenchCreation.set(key, creation);
418
+ try {
419
+ return await creation;
420
+ } finally {
421
+ this.workbenchCreation.delete(key);
422
+ }
423
+ }
424
+
425
+ sessionSetShareId(id: string, shareId: string): Thread {
426
+ const session = this.mutable(id);
427
+
428
+ session.shareId = shareId;
429
+ this.store.upsert(session);
430
+ this.emit("session.updated", id);
431
+
432
+ return session;
433
+ }
434
+
435
+ /** Remove a session for good (inbox delete); resolved or pending, both go. */
436
+ sessionDelete(id: string): void {
437
+ const session = this.store.get(id);
438
+
439
+ if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
440
+ if (session) this.unwatchIfDiffSession(session);
441
+ this.diffRefreshGenerations.delete(id);
442
+ this.herdrTabs.delete(id);
443
+ this.emit("inbox.changed", id);
444
+ }
445
+
446
+ /**
447
+ * Cut one block of the reviewer's working copy - the `blockIndex`-th block
448
+ * of the working text - so it serializes into the diff the agent receives.
449
+ */
450
+ sessionCutBlock(id: string, blockIndex: number): Thread {
451
+ const session = this.mutable(id);
452
+ const working = session.workingCopy ?? session.artifact.content;
453
+ const block = parseBlocks(working)[blockIndex];
454
+
455
+ if (!block)
456
+ throw new DaemonError("invalid_params", `no block ${blockIndex} in the working copy`);
457
+ const entryId = this.applyWorkingCopy(session, cutBlock(working, block));
458
+
459
+ this.store.upsert(session);
460
+ this.emit("session.updated", id, entryId);
461
+
462
+ return session;
463
+ }
464
+
465
+ /**
466
+ * Re-insert a cut block - the `baseBlockIndex`-th block of the submitted
467
+ * revision - into the working copy before `line` (default: the end). A copy
468
+ * that reads as the submitted revision again is dropped, not stored.
469
+ */
470
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Thread {
471
+ const session = this.mutable(id);
472
+ const base = session.artifact.content;
473
+ const block = parseBlocks(base)[baseBlockIndex];
474
+
475
+ if (!block) {
476
+ throw new DaemonError(
477
+ "invalid_params",
478
+ `no block ${baseBlockIndex} in the submitted revision`,
479
+ );
480
+ }
481
+ const working = session.workingCopy ?? base;
482
+
483
+ if (!isBlockCut(base, working, block)) {
484
+ throw new DaemonError(
485
+ "invalid_params",
486
+ `block ${baseBlockIndex} of the submitted revision is present in the working copy`,
487
+ );
488
+ }
489
+ const beforeLine = Math.min(line ?? working.split("\n").length, working.split("\n").length);
490
+ const entryId = this.applyWorkingCopy(session, restoreBlock(base, working, block, beforeLine));
491
+
492
+ this.store.upsert(session);
493
+ this.emit("session.updated", id, entryId);
494
+
495
+ return session;
496
+ }
497
+
498
+ /**
499
+ * Replace a diff review's reject decisions; the working copy becomes the
500
+ * patch they leave, or clears when nothing is rejected. Needs the full file
501
+ * contents a working-tree diff carries; a PR diff cannot be curated.
502
+ */
503
+ sessionCurate(id: string, rejections: HunkRejection[]): Thread {
504
+ const session = this.mutable(id);
505
+
506
+ if (session.artifact.type !== "diff") {
507
+ throw new DaemonError("invalid_params", "only a diff review is curated by hunk");
508
+ }
509
+ if (!session.artifact.files) {
510
+ throw new DaemonError("invalid_params", "hunk curation needs full file contents");
511
+ }
512
+ if (rejections.length === 0) delete session.curation;
513
+ else session.curation = rejections;
514
+ const entryId = this.applyWorkingCopy(
515
+ session,
516
+ rejections.length === 0 ? undefined : curateDiff(session.artifact.files, rejections),
517
+ );
518
+
519
+ this.store.upsert(session);
520
+ this.emit("session.updated", id, entryId);
521
+
522
+ return session;
523
+ }
524
+
525
+ /**
526
+ * Merge a share's state back into the local session. Comments union by
527
+ * annotation id with existing ones (the planner's) winning; removals union by
528
+ * entry id, each shelving its comment once; the participant registry unions
529
+ * by id with the incoming identity winning (a collaborator is the authority
530
+ * on their own name). Everything lands on the branch the share follows, and
531
+ * the record then shows its current path again, wherever the owner stands.
532
+ */
533
+ sessionMergeShared(id: string, incoming: SharedMerge): Thread {
534
+ const session = this.mutable(id);
535
+ const branch = this.shareBranchOf(session);
536
+ const known = new Set(
537
+ [...session.annotations, ...(session.shelvedAnnotations ?? [])].map(
538
+ (annotation) => annotation.id,
539
+ ),
540
+ );
541
+ let changed = false;
542
+
543
+ for (const annotation of incoming.annotations) {
544
+ if (known.has(annotation.id)) continue;
545
+ known.add(annotation.id);
546
+ session.annotations.push(annotation);
547
+ this.recordOn(session, branch, {
548
+ type: "comment",
549
+ annotationId: annotation.id,
550
+ createdAt: annotation.createdAt,
551
+ });
552
+ changed = true;
553
+ }
554
+ const recorded = new Set((session.history?.entries ?? []).map((entry) => entry.id));
555
+
556
+ for (const removal of incoming.removals ?? []) {
557
+ if (recorded.has(removal.id) || !known.has(removal.annotationId)) continue;
558
+ this.recordOn(session, branch, {
559
+ id: removal.id,
560
+ type: "comment-removed",
561
+ annotationId: removal.annotationId,
562
+ createdAt: removal.createdAt,
563
+ });
564
+ changed = true;
565
+ }
566
+ if (incoming.participants?.length) {
567
+ const registry = new Map(
568
+ (session.participants ?? []).map((participant) => [participant.id, participant]),
569
+ );
570
+
571
+ for (const participant of incoming.participants) registry.set(participant.id, participant);
572
+ session.participants = [...registry.values()];
573
+ }
574
+ if (changed && session.history) this.refreshView(session, session.history);
575
+ else this.store.upsert(session);
149
576
  this.emit("session.updated", id);
150
- return s;
577
+
578
+ return session;
151
579
  }
152
580
 
153
- sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
581
+ sessionResolve(
582
+ id: string,
583
+ verdictKind: VerdictKind,
584
+ summary: string,
585
+ actionBodies?: Record<string, string>,
586
+ ): Thread {
587
+ const session = this.mutable(id);
155
588
  const verdict: Verdict = {
156
589
  kind: verdictKind,
157
590
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
591
+ feedback: feedbackForSession(session, verdictKind, summary, actionBodies),
159
592
  resolvedAt: new Date().toISOString(),
160
593
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
594
+
595
+ session.verdict = verdict;
596
+ session.status = "resolved";
597
+ const entryId = this.record(session, {
598
+ type: "verdict",
599
+ verdict,
600
+ createdAt: verdict.resolvedAt,
601
+ });
602
+ this.store.upsert(session);
603
+ // a resolved diff review is frozen; stop hot-reloading its working tree
604
+ this.unwatchIfDiffSession(session);
164
605
  const parked = this.waiters.get(id) ?? [];
606
+
165
607
  this.waiters.delete(id);
166
- for (const w of parked) w(s);
167
- this.emit("session.resolved", id);
608
+ for (const parkedWaiter of parked) parkedWaiter(session);
609
+ this.emit("session.resolved", id, entryId);
168
610
  this.emit("inbox.changed", id);
169
- return s;
611
+
612
+ return session;
170
613
  }
171
614
 
172
- /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
- sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
615
+ /**
616
+ * Agent resubmits: new revision becomes the artifact, session reopens.
617
+ * Annotations the agent reports as acted on (by id) are marked addressed,
618
+ * and for plan revisions any still-open annotation whose quoted text no
619
+ * longer resolves is marked addressed too ("drift" - the line it pointed at
620
+ * was rewritten). Addressed is a marker, never a delete: the reviewer's
621
+ * rail hides them behind a count, and the next feedback document omits them.
622
+ * Unknown ids are ignored, so a stale id in the agent's list never fails
623
+ * the resubmit.
624
+ */
625
+ sessionSubmitRevision(
626
+ id: string,
627
+ content: string,
628
+ addressedAnnotationIds: string[] = [],
629
+ ): Thread {
630
+ const session = this.sessionGet(id);
175
631
  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);
182
- this.emit("session.revised", id);
632
+ const revisionNumber = session.revisions.length + 1;
633
+
634
+ session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
635
+ session.artifact = { ...session.artifact, content };
636
+ // the agent's revision lands on main wherever its tip sits; the artifact
637
+ // shows the head of the branch the reviewer is on
638
+ const entryId = this.recordOnMain(session, {
639
+ type: "revision",
640
+ by: "agent",
641
+ content,
642
+ createdAt: now,
643
+ });
644
+ delete session.workingCopy;
645
+ session.verdict = null;
646
+ session.status = "pending";
647
+
648
+ // a reported root comment addresses its whole discussion: replies are
649
+ // never listed on their own in the feedback document
650
+ const reportedIds = new Set(addressedAnnotationIds);
651
+
652
+ for (const annotation of session.annotations) {
653
+ if (annotation.replyTo !== undefined && reportedIds.has(annotation.replyTo)) {
654
+ reportedIds.add(annotation.id);
655
+ }
656
+ }
657
+ // drift assist applies to markdown artifacts (plan, reply) only: a diff
658
+ // revision is a whole new patch, where a vanished quote says nothing about
659
+ // the feedback
660
+ const revisedBlocks = isMarkdownArtifact(session.artifact.type) ? parseBlocks(content) : null;
661
+
662
+ for (const annotation of session.annotations) {
663
+ if (isAddressed(annotation) || isAgentNote(annotation)) continue;
664
+ if (reportedIds.has(annotation.id)) {
665
+ annotation.resolution = { revision: revisionNumber, source: "agent" };
666
+ } else if (
667
+ revisedBlocks !== null &&
668
+ // only a note on the artifact drifts against the artifact's revision; a selector-anchored
669
+ // pixel element or a file-targeted Changes-diff note anchors elsewhere
670
+ annotationTarget(annotation).kind === "artifact" &&
671
+ !annotation.anchor.selector &&
672
+ resolveAnchor(annotation.anchor, revisedBlocks) === null
673
+ ) {
674
+ annotation.resolution = { revision: revisionNumber, source: "drift" };
675
+ }
676
+ }
677
+
678
+ this.store.upsert(session);
679
+ this.emit("session.revised", id, entryId);
183
680
  this.emit("inbox.changed", id);
184
- return s;
681
+
682
+ return session;
683
+ }
684
+
685
+ /**
686
+ * Move a branch's tip back to an entry on its path - the current branch, or
687
+ * `branch` after switching to it. The entries after it stay in the tree; a
688
+ * summary records them as a branch-summary entry at the target. The view
689
+ * follows the path: on `main`, the agent's next revision lands there.
690
+ */
691
+ sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Thread {
692
+ const session = this.mutable(id);
693
+ const moved = this.tree(id, () => {
694
+ const history = this.historyOf(session);
695
+ const standing = branch === undefined ? history : switchBranch(history, branch);
696
+
697
+ return navigateTo(standing, entryId, summary === undefined ? {} : { summary });
698
+ });
699
+
700
+ this.refreshView(session, moved);
701
+ const tip = moved.tips[moved.branch];
702
+
703
+ this.emit("session.updated", id, tip === entryId ? undefined : tip);
704
+
705
+ return session;
706
+ }
707
+
708
+ /** Start a branch at the current tip and switch to it; `main` stays where it is. */
709
+ sessionBranch(id: string, name: string): Thread {
710
+ const session = this.mutable(id);
711
+
712
+ this.refreshView(
713
+ session,
714
+ this.tree(id, () => createBranch(this.historyOf(session), name)),
715
+ );
716
+ this.emit("session.updated", id);
717
+
718
+ return session;
719
+ }
720
+
721
+ sessionSwitch(id: string, branch: string): Thread {
722
+ const session = this.mutable(id);
723
+
724
+ this.refreshView(
725
+ session,
726
+ this.tree(id, () => switchBranch(this.historyOf(session), branch)),
727
+ );
728
+ this.emit("session.updated", id);
729
+
730
+ return session;
731
+ }
732
+
733
+ /** Name the current tip as a checkpoint to navigate back to. */
734
+ sessionLabel(id: string, label: string): Thread {
735
+ const session = this.mutable(id);
736
+
737
+ session.history = labelTip(this.historyOf(session), label);
738
+ this.store.upsert(session);
739
+ this.emit("session.updated", id);
740
+
741
+ return session;
742
+ }
743
+
744
+ /**
745
+ * Copy the current path into a new pending session: its revisions, open
746
+ * comments, labels, and participant names travel; verdicts, edits, and the
747
+ * share do not. A resolved session can be forked.
748
+ */
749
+ sessionFork(id: string): Thread {
750
+ const source = this.sessionGet(id);
751
+ const history = this.tree(id, () => forkHistory(this.historyOf(source)));
752
+ const now = new Date().toISOString();
753
+ const known = [...source.annotations, ...(source.shelvedAnnotations ?? [])];
754
+ const view = viewOfPath(history, known);
755
+ const fork: Thread = {
756
+ schemaVersion: SCHEMA_VERSION,
757
+ id: this.newSessionId(now),
758
+ workspace: source.workspace,
759
+ artifact: { ...source.artifact, content: view.content },
760
+ revisions: history.entries
761
+ .filter((entry) => entry.type === "revision")
762
+ .map((entry, index) => ({
763
+ revision: index + 1,
764
+ content: entry.content,
765
+ submittedAt: entry.createdAt,
766
+ })),
767
+ annotations: [],
768
+ history,
769
+ verdict: null,
770
+ status: "pending",
771
+ createdAt: now,
772
+ parentSessionId: id,
773
+ };
774
+
775
+ fork.annotations = view.annotations.map((annotation) =>
776
+ forkedAnnotation(annotation, source, fork),
777
+ );
778
+ if (source.participants)
779
+ fork.participants = source.participants.map((identity) => ({ ...identity }));
780
+ this.store.upsert(fork);
781
+ this.watchIfDiffSession(fork);
782
+ this.emit("session.created", fork.id);
783
+ this.emit("inbox.changed", fork.id);
784
+
785
+ return fork;
786
+ }
787
+
788
+ /** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
789
+ async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
790
+ const session = this.mutable(id);
791
+
792
+ if (session.artifact.type !== "diff") return { changed: false };
793
+ const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
794
+
795
+ this.diffRefreshGenerations.set(id, generation);
796
+ const diff = await workingTreeDiff(session.workspace.repoRoot);
797
+
798
+ // The capture yields the event loop: a newer refresh may have started, or a
799
+ // concurrent resolve/delete may have closed the session. Discard a stale
800
+ // capture, and re-read so a resolved review is never mutated and a deleted
801
+ // one is never revived by this upsert.
802
+ if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
803
+ const current = this.store.get(id);
804
+
805
+ if (!current || current.status !== "pending" || current.artifact.type !== "diff")
806
+ return { changed: false };
807
+ if (diff.patch === current.artifact.content) return { changed: false };
808
+ current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
809
+ // the history keeps showing the same revision, re-captured, so a later tree move derives this patch
810
+ const history = withHistory(current).history;
811
+
812
+ if (history) current.history = recaptureMainHead(history, diff.patch);
813
+ this.store.upsert(current);
814
+ this.emit("session.updated", id);
815
+
816
+ return { changed: true };
817
+ }
818
+
819
+ /** Re-capture every live diff session sharing a repo root (one debounced fs change). */
820
+ private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
821
+ const live = this.store
822
+ .list()
823
+ .filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
824
+
825
+ for (const session of live) {
826
+ // a session resolved or deleted between the change and this tick just skips
827
+ try {
828
+ await this.sessionRefreshDiff(session.id);
829
+ } catch {
830
+ // no-op: the session is gone or already resolved
831
+ }
832
+ }
833
+ }
834
+
835
+ private watchIfDiffSession(session: Thread): void {
836
+ if (isLiveDiffSession(session))
837
+ this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
838
+ }
839
+
840
+ private unwatchIfDiffSession(session: Thread): void {
841
+ if (session.artifact.type === "diff")
842
+ this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
843
+ }
844
+
845
+ /**
846
+ * Set or clear the working copy and, when the reviewer's text changed, record
847
+ * it as a reviewer revision on the current branch. Returns that entry's id.
848
+ */
849
+ private applyWorkingCopy(session: Thread, workingCopy: string | undefined): string | undefined {
850
+ const before = session.workingCopy ?? session.artifact.content;
851
+ const next =
852
+ workingCopy === undefined || workingCopy === session.artifact.content
853
+ ? undefined
854
+ : workingCopy;
855
+
856
+ if (next === undefined) delete session.workingCopy;
857
+ else session.workingCopy = next;
858
+ const after = next ?? session.artifact.content;
859
+
860
+ if (after === before) return undefined;
861
+
862
+ return this.record(session, {
863
+ type: "revision",
864
+ by: "reviewer",
865
+ content: after,
866
+ createdAt: new Date().toISOString(),
867
+ });
868
+ }
869
+
870
+ /** The session's history; a record without a revision has none and cannot be moved through. */
871
+ private historyOf(session: Thread): SessionHistory {
872
+ const history = withHistory(session).history;
873
+
874
+ if (!history) throw new DaemonError("invalid_params", `session ${session.id} has no history`);
875
+
876
+ return history;
185
877
  }
186
878
 
187
- 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;
879
+ /** Run a tree operation; a refused move is the caller's mistake, not the daemon's. */
880
+ private tree(id: string, operation: () => SessionHistory): SessionHistory {
881
+ try {
882
+ return operation();
883
+ } catch (error) {
884
+ if (error instanceof HistoryError) throw new DaemonError("invalid_params", error.message);
885
+ throw error;
886
+ }
887
+ }
888
+
889
+ /** Store a moved history and make the record show its active path. */
890
+ private refreshView(session: Thread, history: SessionHistory): void {
891
+ session.history = history;
892
+ applyPathView(
893
+ session,
894
+ viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
895
+ );
896
+ this.store.upsert(session);
897
+ }
898
+
899
+ /** Append an entry on the session's current branch; a session without a head has no history to extend. */
900
+ private record(session: Thread, entry: NewEntry): string | undefined {
901
+ const history = withHistory(session).history;
902
+
903
+ if (!history) return undefined;
904
+ const appended = appendEntry(history, entry);
905
+
906
+ session.history = appended.history;
907
+
908
+ return appended.entry.id;
909
+ }
910
+
911
+ /** Append an entry on main, leaving the reviewer's current branch where it is. */
912
+ private recordOnMain(session: Thread, entry: NewEntry): string | undefined {
913
+ return this.recordOn(session, MAIN_BRANCH, entry);
914
+ }
915
+
916
+ /** Append an entry on a named branch, leaving the reviewer's current branch where it is. */
917
+ private recordOn(session: Thread, branch: string, entry: NewEntry): string | undefined {
918
+ const history = withHistory(session).history;
919
+
920
+ if (!history) return undefined;
921
+ const appended = appendEntry(switchBranch(history, branch), entry);
922
+
923
+ session.history = { ...appended.history, branch: history.branch };
924
+
925
+ return appended.entry.id;
926
+ }
927
+
928
+ /** The branch a share follows; a share made before branches existed follows main. */
929
+ private shareBranchOf(session: Thread): string {
930
+ const branch = session.shareBranch ?? MAIN_BRANCH;
931
+
932
+ return session.history?.tips[branch] === undefined ? MAIN_BRANCH : branch;
933
+ }
934
+
935
+ private mutable(id: string): Thread {
936
+ const session = this.sessionGet(id);
937
+
938
+ if (session.status === "resolved")
939
+ throw new DaemonError("resolved", `session ${id} is already resolved`);
940
+
941
+ return session;
191
942
  }
192
943
  }
193
944
 
194
945
  export { DaemonError };
195
946
 
947
+ /**
948
+ * A comment as the fork carries it. An addressed mark names a revision by the
949
+ * source's numbering; the fork numbers its own, so the mark follows the
950
+ * revision's text, and a comment addressed by a revision off the fork's path
951
+ * is open again.
952
+ */
953
+ function forkedAnnotation(annotation: Annotation, source: Thread, fork: Thread): Annotation {
954
+ const { resolution, ...open } = annotation;
955
+
956
+ if (resolution === undefined) return { ...annotation };
957
+ const addressedBy = source.revisions.find(
958
+ (revision) => revision.revision === resolution.revision,
959
+ );
960
+ const inFork = fork.revisions.find((revision) => revision.content === addressedBy?.content);
961
+
962
+ return inFork === undefined
963
+ ? open
964
+ : { ...annotation, resolution: { ...resolution, revision: inFork.revision } };
965
+ }
966
+
967
+ /** A pending diff session: the state that warrants hot-reload watching of its working tree. */
968
+ function isLiveDiffSession(session: Thread): boolean {
969
+ return session.status === "pending" && session.artifact.type === "diff";
970
+ }
971
+
196
972
  /** Convenience for adapters: map a resolved session to the agent contract. */
197
- export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
973
+ export function verdictResponse(session: Thread) {
198
974
  if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
975
+
199
976
  return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
200
977
  }