@cueloop/daemon 0.1.0-alpha.79 → 0.1.0-alpha.80

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.79",
3
+ "version": "0.1.0-alpha.80",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -20,7 +20,8 @@
20
20
  "exports": {
21
21
  ".": "./src/index.ts",
22
22
  "./client": "./src/client.ts",
23
- "./review": "./src/review.ts",
23
+ "./thread-review": "./src/thread-review.ts",
24
+ "./api": "./src/api.ts",
24
25
  "./working-tree": "./src/working-tree.ts",
25
26
  "./store": "./src/store.ts",
26
27
  "./validate": "./src/validate.ts",
@@ -35,7 +36,7 @@
35
36
  "access": "public"
36
37
  },
37
38
  "dependencies": {
38
- "@cueloop/schema": "0.1.0-alpha.79",
39
+ "@cueloop/schema": "0.1.0-alpha.80",
39
40
  "@pierre/diffs": "^1.4.1",
40
41
  "effect": "^4.0.0-rc.115",
41
42
  "valibot": "^1.4.2"
package/src/api.test.ts CHANGED
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { DaemonCore, verdictResponse } from "./api";
5
+ import { DaemonCore, messageResponse } from "./api";
6
6
  import { ThreadStore } from "./store";
7
7
  import { derivePath, tipOf, type Artifact, type WorkspaceKey } from "@cueloop/schema";
8
8
  import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
@@ -117,31 +117,31 @@ describe("session lifecycle", () => {
117
117
  });
118
118
 
119
119
  // Act
120
- const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
120
+ const resolved = core.sessionSendMessage(session.id, "changes_requested", "Needs detail.");
121
121
 
122
122
  // Assert
123
- expect(resolved.verdict!.feedback).toContain("# Review: request changes");
124
- expect(resolved.verdict!.feedback).toContain("> carefully");
125
- expect(verdictResponse(resolved)).toEqual({
123
+ expect(resolved.message!.body).toContain("# Review: changes requested");
124
+ expect(resolved.message!.body).toContain("> carefully");
125
+ expect(messageResponse(resolved)).toEqual({
126
126
  allow: false,
127
- feedback: resolved.verdict!.feedback,
127
+ message: resolved.message!,
128
128
  });
129
129
 
130
130
  // Arrange
131
131
  const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
132
132
 
133
133
  // Act
134
- core.sessionResolve(approved.id, "approve", "LGTM");
134
+ core.sessionSendMessage(approved.id, "approved", "LGTM");
135
135
 
136
136
  // Assert
137
- expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
137
+ expect(messageResponse(core.sessionGet(approved.id)).allow).toBe(true);
138
138
  });
139
139
 
140
140
  test("mutating a resolved session throws", () => {
141
141
  // Arrange
142
142
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
143
143
 
144
- core.sessionResolve(session.id, "approve", "");
144
+ core.sessionSendMessage(session.id, "approved", "");
145
145
 
146
146
  // Assert
147
147
  expect(() =>
@@ -217,19 +217,19 @@ describe("session lifecycle", () => {
217
217
  expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
218
218
  });
219
219
 
220
- test("revision reopens the session and resets working copy + verdict", () => {
220
+ test("revision reopens the session and resets working copy + message", () => {
221
221
  // Arrange
222
222
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
223
223
 
224
224
  core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
225
- core.sessionResolve(session.id, "request_changes", "redo");
225
+ core.sessionSendMessage(session.id, "changes_requested", "redo");
226
226
 
227
227
  // Act
228
228
  const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
229
229
 
230
230
  // Assert
231
231
  expect(revised.status).toBe("pending");
232
- expect(revised.verdict).toBeNull();
232
+ expect(revised.message).toBeNull();
233
233
  expect(revised.workingCopy).toBeUndefined();
234
234
  expect(revised.revisions.length).toBe(2);
235
235
  expect(revised.artifact.content).toBe("# Plan v2\n");
@@ -345,31 +345,31 @@ describe("revision marks addressed annotations", () => {
345
345
  core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
346
346
 
347
347
  // Act
348
- const resolved = core.sessionResolve(session.id, "request_changes", "one left");
348
+ const resolved = core.sessionSendMessage(session.id, "changes_requested", "one left");
349
349
 
350
350
  // Assert
351
- expect(resolved.verdict!.feedback).not.toContain("settled");
352
- expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
353
- expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
354
- expect(resolved.verdict!.feedback).toContain("--addressed");
351
+ expect(resolved.message!.body).not.toContain("settled");
352
+ expect(resolved.message!.body).toContain("annotation id: `open`");
353
+ expect(resolved.message!.body).toContain(`submit-revision ${session.id}`);
354
+ expect(resolved.message!.body).toContain("--addressed");
355
355
  });
356
356
  });
357
357
 
358
- describe("the wait contract: verdicts outlive waits", () => {
359
- test("wait resolves when the verdict arrives", async () => {
358
+ describe("the wait contract: messages outlive waits", () => {
359
+ test("wait resolves when the message arrives", async () => {
360
360
  // Arrange
361
361
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
362
362
  const wait = core.sessionWait(session.id, 5_000);
363
363
 
364
364
  // Act
365
- core.sessionResolve(session.id, "approve", "");
365
+ core.sessionSendMessage(session.id, "approved", "");
366
366
  const resolved = await wait;
367
367
 
368
368
  // Assert
369
- expect(resolved!.verdict!.kind).toBe("approve");
369
+ expect(resolved!.message!.outcome).toBe("approved");
370
370
  });
371
371
 
372
- test("wait times out to null; a later wait collects the stored verdict", async () => {
372
+ test("wait times out to null; a later wait collects the stored message", async () => {
373
373
  // Arrange
374
374
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
375
375
 
@@ -380,11 +380,11 @@ describe("the wait contract: verdicts outlive waits", () => {
380
380
  expect(first).toBeNull();
381
381
 
382
382
  // Act
383
- core.sessionResolve(session.id, "request_changes", "later");
383
+ core.sessionSendMessage(session.id, "changes_requested", "later");
384
384
  const second = await core.sessionWait(session.id, 30);
385
385
 
386
386
  // Assert
387
- expect(second!.verdict!.summary).toBe("later");
387
+ expect(second!.message!.summary).toBe("later");
388
388
  });
389
389
 
390
390
  test("multiple waiters all resolve", async () => {
@@ -393,7 +393,7 @@ describe("the wait contract: verdicts outlive waits", () => {
393
393
  const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
394
394
 
395
395
  // Act
396
- core.sessionResolve(session.id, "approve", "");
396
+ core.sessionSendMessage(session.id, "approved", "");
397
397
  const results = await Promise.all(waits);
398
398
 
399
399
  // Assert
@@ -406,14 +406,14 @@ describe("persistence and recovery", () => {
406
406
  // Arrange
407
407
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
408
408
 
409
- core.sessionResolve(session.id, "approve", "done");
409
+ core.sessionSendMessage(session.id, "approved", "done");
410
410
 
411
411
  // Act
412
412
  const reborn = new DaemonCore(home);
413
413
  const recovered = reborn.sessionGet(session.id);
414
414
 
415
415
  // Assert
416
- expect(recovered.verdict!.summary).toBe("done");
416
+ expect(recovered.message!.summary).toBe("done");
417
417
  });
418
418
 
419
419
  test("corrupt records are skipped, not fatal, and never deleted", async () => {
@@ -448,14 +448,14 @@ describe("events", () => {
448
448
  anchor: { quote: "thing", prefix: "", suffix: "" },
449
449
  body: "b",
450
450
  });
451
- core.sessionResolve(session.id, "approve", "");
451
+ core.sessionSendMessage(session.id, "approved", "");
452
452
 
453
453
  // Assert
454
454
  expect(seen).toEqual([
455
455
  "session.created",
456
456
  "inbox.changed",
457
457
  "session.updated",
458
- "session.resolved",
458
+ "message.sent",
459
459
  "inbox.changed",
460
460
  ]);
461
461
  });
@@ -484,7 +484,7 @@ describe("the session history records every write as an entry", () => {
484
484
  annotate(created.id, "a2", "Context");
485
485
  core.sessionRemoveAnnotation(created.id, "a2");
486
486
  core.sessionRemoveAnnotation(created.id, "never-existed");
487
- core.sessionResolve(created.id, "request_changes", "tighten");
487
+ core.sessionSendMessage(created.id, "changes_requested", "tighten");
488
488
  const revised = core.sessionSubmitRevision(created.id, PLAN.content + "\nMore.\n", ["a1"]);
489
489
 
490
490
  // Assert: an edit of an existing comment and a removal of an unknown id leave no entry
@@ -495,7 +495,7 @@ describe("the session history records every write as an entry", () => {
495
495
  "comment",
496
496
  "comment",
497
497
  "comment-removed",
498
- "verdict",
498
+ "message",
499
499
  "revision",
500
500
  ]);
501
501
  expect(history.branch).toBe("main");
@@ -653,6 +653,25 @@ describe("curation primitives", () => {
653
653
  // Assert
654
654
  expect(() => core.sessionCurate(created.id, [])).toThrow(/only a diff review/);
655
655
  });
656
+
657
+ test("rejects hunk curation after a patch-only diff revision clears file snapshots", () => {
658
+ const created = core.sessionCreate({
659
+ workspace: WS,
660
+ artifact: {
661
+ type: "diff",
662
+ content: "diff --git a/a.ts b/a.ts\n",
663
+ meta: {},
664
+ files: [{ path: "a.ts", status: "modified", oldContents: "old", newContents: "new" }],
665
+ },
666
+ });
667
+
668
+ core.sessionSubmitRevision(created.id, "diff --git a/b.ts b/b.ts\n", [], []);
669
+
670
+ expect(() => core.sessionCurate(created.id, [{ path: "b.ts", hunkIndex: 0 }])).toThrow(
671
+ /hunk curation needs full file contents/,
672
+ );
673
+ expect(core.sessionGet(created.id).workingCopy).toBeUndefined();
674
+ });
656
675
  });
657
676
 
658
677
  describe("tree primitives", () => {
@@ -709,11 +728,11 @@ describe("tree primitives", () => {
709
728
  core.sessionNavigate(created.id, between);
710
729
 
711
730
  // Act
712
- const resolved = core.sessionResolve(created.id, "request_changes", "one note");
731
+ const resolved = core.sessionSendMessage(created.id, "changes_requested", "one note");
713
732
 
714
733
  // Assert
715
- expect(resolved.verdict!.feedback).toContain("keep this one");
716
- expect(resolved.verdict!.feedback).not.toContain("not this one");
734
+ expect(resolved.message!.body).toContain("keep this one");
735
+ expect(resolved.message!.body).not.toContain("not this one");
717
736
  });
718
737
 
719
738
  test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
@@ -846,13 +865,13 @@ describe("tree primitives", () => {
846
865
  ).toEqual(["c1"]);
847
866
  });
848
867
 
849
- test("a fork is a new pending session on the copied path, without verdict, edits, or share", () => {
868
+ test("a fork is a new pending session on the copied path, without message, edits, or share", () => {
850
869
  // Arrange: comment, edit, resolve, then a second round
851
870
  const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
852
871
 
853
872
  core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
854
873
  core.sessionSetShareId(created.id, "share-1");
855
- core.sessionResolve(created.id, "request_changes", "again");
874
+ core.sessionSendMessage(created.id, "changes_requested", "again");
856
875
  core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
857
876
  core.sessionLabel(created.id, "round two");
858
877
  core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
@@ -864,7 +883,7 @@ describe("tree primitives", () => {
864
883
  expect(fork.id).not.toBe(created.id);
865
884
  expect(fork.parentSessionId).toBe(created.id);
866
885
  expect(fork.status).toBe("pending");
867
- expect(fork.verdict).toBeNull();
886
+ expect(fork.message).toBeNull();
868
887
  expect(fork.shareId).toBeUndefined();
869
888
  expect(fork.workingCopy).toBeUndefined();
870
889
  expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
@@ -956,7 +975,7 @@ describe("workbench session", () => {
956
975
  try {
957
976
  const first = await core.workbenchSession(repo);
958
977
 
959
- core.sessionResolve(first.id, "comment", "");
978
+ core.sessionSendMessage(first.id, "changes_requested", "");
960
979
  const second = await core.workbenchSession(repo);
961
980
 
962
981
  expect(second.id).not.toBe(first.id);
package/src/api.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The daemon's method surface. Transport-independent: the socket
3
3
  * server and the in-process test harness both call these handlers.
4
- * The wait contract: verdicts outlive waits - session.wait long-polls,
5
- * and a verdict resolved while nobody waited is delivered on next contact.
4
+ * The wait contract: messages outlive waits - session.wait long-polls,
5
+ * and a message resolved while nobody waited is delivered on next contact.
6
6
  */
7
7
 
8
8
  import {
@@ -27,33 +27,39 @@ import {
27
27
  parseBlocks,
28
28
  registerParticipant,
29
29
  resolveAnchor,
30
+ newMessageId,
30
31
  isBlockCut,
31
32
  restoreBlock,
32
33
  switchBranch,
33
- verdictAllows,
34
+ messageAllows,
34
35
  type Annotation,
35
36
  type DiffFileStatus,
37
+ type DiffFileContents,
36
38
  type HunkRejection,
37
39
  type NewEntry,
38
40
  type Artifact,
39
41
  type ShareLink,
40
42
  type Identity,
43
+ type HarnessBinding,
44
+ type Delivery,
45
+ type PendingDelivery,
41
46
  type Thread,
42
47
  type SessionHistory,
43
- type Verdict,
44
- type VerdictKind,
48
+ type Message,
49
+ type MessageOutcome,
45
50
  type WorkspaceKey,
46
51
  } from "@cueloop/schema";
47
52
  import { curateDiff } from "./curate";
48
53
  import { ThreadStore, withHistory } from "./store";
49
54
  import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
50
55
  import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
56
+ import { HarnessStateStore } from "./harness-state-store";
51
57
  import { DiffWatcher } from "./diff-watcher";
52
58
  import { PrReviewPoller } from "./pr-poller";
53
59
  import { prDiff } from "./gh";
54
60
  import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
55
61
  import { listProjectFiles, readProjectFile } from "./project-files";
56
- import { resolveWorkspace } from "./review";
62
+ import { resolveWorkspace } from "./thread-review";
57
63
  import { DaemonError } from "./errors";
58
64
 
59
65
  /** What a share hands back: the notes and names it collected, and the removals it recorded. */
@@ -67,7 +73,7 @@ export interface SharedMerge {
67
73
  export type EventName =
68
74
  | "session.created"
69
75
  | "session.updated"
70
- | "session.resolved"
76
+ | "message.sent"
71
77
  | "session.revised"
72
78
  | "inbox.changed";
73
79
 
@@ -83,6 +89,7 @@ type EventListener = (event: DaemonEvent) => void;
83
89
  export class DaemonCore {
84
90
  readonly store: ThreadStore;
85
91
  readonly herdrTabs: HerdrTabStore;
92
+ readonly harnessState: HarnessStateStore;
86
93
  private waiters = new Map<string, ((session: Thread) => void)[]>();
87
94
  private listeners = new Set<EventListener>();
88
95
  private seq = 0;
@@ -102,12 +109,21 @@ export class DaemonCore {
102
109
  constructor(home: string) {
103
110
  this.store = new ThreadStore(home);
104
111
  this.store.recover();
105
- pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
112
+ this.harnessState = new HarnessStateStore(home);
113
+ for (const session of this.store.list()) this.reconcileDeliveries(session);
114
+ pruneExpiredSessions(
115
+ this.store,
116
+ resolveCleanupPeriodDays(),
117
+ Date.now(),
118
+ this.harnessState.pendingThreadIds(),
119
+ );
106
120
  this.herdrTabs = new HerdrTabStore(home);
107
121
  this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
108
122
  this.prPoller = new PrReviewPoller((sessionId) => void this.sessionRefreshPrDiff(sessionId));
109
123
  // resume hot-reload for diff sessions that survived a daemon restart
110
- for (const session of this.store.list()) this.trackLiveDiffSession(session);
124
+ for (const session of this.store.list()) {
125
+ this.trackLiveDiffSession(session);
126
+ }
111
127
  }
112
128
 
113
129
  /** Release the watchers and pollers behind diff hot-reload; call on daemon shutdown. */
@@ -125,6 +141,81 @@ export class DaemonCore {
125
141
  this.herdrTabs.set(sessionId, handle);
126
142
  }
127
143
 
144
+ harnessBind(input: {
145
+ threadId: string;
146
+ harness: string;
147
+ harnessSessionId: string;
148
+ }): HarnessBinding {
149
+ this.sessionGet(input.threadId);
150
+ const binding = this.harnessState.bind(input);
151
+
152
+ this.reconcileDeliveries(this.sessionGet(input.threadId));
153
+
154
+ return binding;
155
+ }
156
+
157
+ harnessGetBinding(bindingId: string): HarnessBinding {
158
+ const binding = this.harnessState.binding(bindingId);
159
+
160
+ if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
161
+
162
+ return binding;
163
+ }
164
+
165
+ harnessConsumeApprovedRetry(bindingId: string, messageId: string, content: string): boolean {
166
+ const binding = this.harnessState.binding(bindingId);
167
+
168
+ if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
169
+ const session = this.sessionGet(binding.threadId);
170
+
171
+ if (
172
+ session.artifact.type !== "plan" ||
173
+ session.status !== "resolved" ||
174
+ session.message?.id !== messageId ||
175
+ session.message.outcome !== "approved" ||
176
+ session.artifact.content !== content ||
177
+ this.harnessState.submittingBinding(session.id)?.id !== bindingId ||
178
+ !this.harnessState.acknowledged(bindingId, messageId)
179
+ ) {
180
+ throw new DaemonError("invalid_state", `no unchanged approved plan for ${bindingId}`);
181
+ }
182
+
183
+ return this.harnessState.consumeApprovedRetry(bindingId, messageId);
184
+ }
185
+
186
+ deliveryPending(bindingId: string): PendingDelivery[] {
187
+ const binding = this.harnessState.binding(bindingId);
188
+
189
+ if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
190
+ const session = this.sessionGet(binding.threadId);
191
+
192
+ return this.harnessState.pending(bindingId).map((delivery) => {
193
+ const historical = session.history?.entries.find(
194
+ (entry) => entry.type === "message" && entry.message.id === delivery.messageId,
195
+ );
196
+ const message =
197
+ session.message?.id === delivery.messageId
198
+ ? session.message
199
+ : historical?.type === "message"
200
+ ? historical.message
201
+ : null;
202
+
203
+ if (!message || message.id !== delivery.messageId) {
204
+ throw new DaemonError("not_found", `no message ${delivery.messageId}`);
205
+ }
206
+
207
+ return { delivery, message };
208
+ });
209
+ }
210
+
211
+ deliveryAcknowledge(deliveryId: string): Delivery {
212
+ if (!this.harnessState.delivery(deliveryId)) {
213
+ throw new DaemonError("not_found", `no delivery ${deliveryId}`);
214
+ }
215
+
216
+ return this.harnessState.acknowledge(deliveryId);
217
+ }
218
+
128
219
  onEvent(listener: EventListener): () => void {
129
220
  this.listeners.add(listener);
130
221
 
@@ -138,7 +229,7 @@ export class DaemonCore {
138
229
  for (const listener of this.listeners) listener(frame);
139
230
  }
140
231
 
141
- /** True when nothing awaits a verdict - drives idle-exit. */
232
+ /** True when nothing awaits a message - drives idle-exit. */
142
233
  hasPendingSessions(): boolean {
143
234
  return this.store.list().some((session) => session.status === "pending");
144
235
  }
@@ -152,13 +243,14 @@ export class DaemonCore {
152
243
  artifact: params.artifact,
153
244
  revisions: [{ revision: 1, content: params.artifact.content, submittedAt: now }],
154
245
  annotations: [],
155
- verdict: null,
246
+ message: null,
156
247
  status: "pending",
157
248
  createdAt: now,
158
249
  };
159
250
 
160
251
  session.history = historyFromLinear(session);
161
252
  this.store.upsert(session);
253
+ this.reconcileDeliveries(session);
162
254
  this.trackLiveDiffSession(session);
163
255
  this.emit("session.created", session.id);
164
256
  this.emit("inbox.changed", session.id);
@@ -166,6 +258,15 @@ export class DaemonCore {
166
258
  return session;
167
259
  }
168
260
 
261
+ private reconcileDeliveries(session: Thread): void {
262
+ if (!session.message) return;
263
+
264
+ const binding = this.harnessState.submittingBinding(session.id);
265
+
266
+ if (binding)
267
+ this.harnessState.enqueue({ bindingId: binding.id, messageId: session.message.id });
268
+ }
269
+
169
270
  private newSessionId(now: string): string {
170
271
  return `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
171
272
  }
@@ -194,9 +295,9 @@ export class DaemonCore {
194
295
  }
195
296
 
196
297
  /**
197
- * Long-poll for the verdict. Resolves immediately when already resolved;
198
- * otherwise parks until sessionResolve fires or timeoutMs elapses (null =
199
- * still pending - the caller re-polls later; the verdict is never lost).
298
+ * Long-poll for the message. Resolves immediately when already resolved;
299
+ * otherwise parks until sessionSendMessage fires or timeoutMs elapses (null =
300
+ * still pending - the caller re-polls later; the message is never lost).
200
301
  */
201
302
  sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
202
303
  const current = this.sessionGet(id);
@@ -539,7 +640,7 @@ export class DaemonCore {
539
640
  if (session.artifact.type !== "diff") {
540
641
  throw new DaemonError("invalid_params", "only a diff review is curated by hunk");
541
642
  }
542
- if (!session.artifact.files) {
643
+ if (!session.artifact.files?.length) {
543
644
  throw new DaemonError("invalid_params", "hunk curation needs full file contents");
544
645
  }
545
646
  if (rejections.length === 0) delete session.curation;
@@ -611,35 +712,38 @@ export class DaemonCore {
611
712
  return session;
612
713
  }
613
714
 
614
- sessionResolve(
715
+ sessionSendMessage(
615
716
  id: string,
616
- verdictKind: VerdictKind,
717
+ outcome: MessageOutcome,
617
718
  summary: string,
618
719
  actionBodies?: Record<string, string>,
619
720
  ): Thread {
620
721
  const session = this.mutable(id);
621
- const verdict: Verdict = {
622
- kind: verdictKind,
722
+ const sentAt = new Date().toISOString();
723
+ const message: Message = {
724
+ id: newMessageId(),
725
+ outcome,
623
726
  summary,
624
- feedback: feedbackForSession(session, verdictKind, summary, actionBodies),
625
- resolvedAt: new Date().toISOString(),
727
+ body: feedbackForSession(session, outcome, summary, actionBodies),
728
+ sentAt,
626
729
  };
627
730
 
628
- session.verdict = verdict;
731
+ session.message = message;
629
732
  session.status = "resolved";
630
733
  const entryId = this.record(session, {
631
- type: "verdict",
632
- verdict,
633
- createdAt: verdict.resolvedAt,
734
+ type: "message",
735
+ message,
736
+ createdAt: message.sentAt,
634
737
  });
635
738
  this.store.upsert(session);
739
+ this.reconcileDeliveries(session);
636
740
  // a resolved diff review is frozen; stop hot-reloading its working tree
637
741
  this.untrackLiveDiffSession(session);
638
742
  const parked = this.waiters.get(id) ?? [];
639
743
 
640
744
  this.waiters.delete(id);
641
745
  for (const parkedWaiter of parked) parkedWaiter(session);
642
- this.emit("session.resolved", id, entryId);
746
+ this.emit("message.sent", id, entryId);
643
747
  this.emit("inbox.changed", id);
644
748
 
645
749
  return session;
@@ -659,13 +763,14 @@ export class DaemonCore {
659
763
  id: string,
660
764
  content: string,
661
765
  addressedAnnotationIds: string[] = [],
766
+ files?: DiffFileContents[],
662
767
  ): Thread {
663
768
  const session = this.sessionGet(id);
664
769
  const now = new Date().toISOString();
665
770
  const revisionNumber = session.revisions.length + 1;
666
771
 
667
772
  session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
668
- session.artifact = { ...session.artifact, content };
773
+ session.artifact = { ...session.artifact, content, files: files ?? session.artifact.files };
669
774
  // the agent's revision lands on main wherever its tip sits; the artifact
670
775
  // shows the head of the branch the reviewer is on
671
776
  const entryId = this.recordOnMain(session, {
@@ -675,7 +780,7 @@ export class DaemonCore {
675
780
  createdAt: now,
676
781
  });
677
782
  delete session.workingCopy;
678
- session.verdict = null;
783
+ session.message = null;
679
784
  session.status = "pending";
680
785
 
681
786
  // a reported root comment addresses its whole discussion: replies are
@@ -776,7 +881,7 @@ export class DaemonCore {
776
881
 
777
882
  /**
778
883
  * Copy the current path into a new pending session: its revisions, open
779
- * comments, labels, and participant names travel; verdicts, edits, and the
884
+ * comments, labels, and participant names travel; messages, edits, and the
780
885
  * share do not. A resolved session can be forked.
781
886
  */
782
887
  sessionFork(id: string): Thread {
@@ -799,7 +904,7 @@ export class DaemonCore {
799
904
  })),
800
905
  annotations: [],
801
906
  history,
802
- verdict: null,
907
+ message: null,
803
908
  status: "pending",
804
909
  createdAt: now,
805
910
  parentSessionId: id,
@@ -1060,8 +1165,8 @@ function isPrReviewSession(session: Thread): boolean {
1060
1165
  }
1061
1166
 
1062
1167
  /** Convenience for adapters: map a resolved session to the agent contract. */
1063
- export function verdictResponse(session: Thread) {
1064
- if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
1168
+ export function messageResponse(session: Thread) {
1169
+ if (!session.message) throw new DaemonError("pending", "session has no message");
1065
1170
 
1066
- return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
1171
+ return { allow: messageAllows(session.message.outcome), message: session.message };
1067
1172
  }
@@ -5,7 +5,7 @@ import type { MethodName } from "./validate";
5
5
  describe("roleAllowsMethod", () => {
6
6
  test("the owner may call every primitive", () => {
7
7
  // Assert
8
- const owned: MethodName[] = ["session.resolve", "session.submitRevision", "session.delete"];
8
+ const owned: MethodName[] = ["session.sendMessage", "session.submitRevision", "session.delete"];
9
9
 
10
10
  for (const method of owned) expect(roleAllowsMethod("owner", method)).toBe(true);
11
11
  });
@@ -17,7 +17,7 @@ describe("roleAllowsMethod", () => {
17
17
  for (const method of open) expect(roleAllowsMethod("agent", method)).toBe(true);
18
18
  // Assert - denied
19
19
  const owned: MethodName[] = [
20
- "session.resolve",
20
+ "session.sendMessage",
21
21
  "session.submitRevision",
22
22
  "session.setWorkingCopy",
23
23
  "session.delete",
@@ -32,7 +32,7 @@ describe("roleAllowsMethod", () => {
32
32
  ];
33
33
 
34
34
  for (const method of owned) expect(roleAllowsMethod("agent", method)).toBe(false);
35
- expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
35
+ expect(roleAllowsMethod("collaborator", "session.sendMessage")).toBe(false);
36
36
  // removal and naming are open to every role; the primitive itself scopes a
37
37
  // non-owner to the author it acts as
38
38
  expect(roleAllowsMethod("agent", "session.removeAnnotation")).toBe(true);
@@ -58,7 +58,12 @@ const PRIMITIVE_ROLES = {
58
58
  "session.setShares": "owner",
59
59
  "session.delete": "owner",
60
60
  "session.mergeShared": "owner",
61
- "session.resolve": "owner",
61
+ "session.sendMessage": "owner",
62
+ "harness.bind": "owner",
63
+ "harness.getBinding": "owner",
64
+ "harness.consumeApprovedRetry": "owner",
65
+ "delivery.pending": "owner",
66
+ "delivery.acknowledge": "owner",
62
67
  "session.submitRevision": "owner",
63
68
  "herdr.getTab": "owner",
64
69
  "herdr.setTab": "owner",