@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.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The shared review core: one open/wait/verdict path for every consumer -
2
+ * The shared Thread review core: one open/wait/message path for every consumer -
3
3
  * CLI commands and agent adapters. openReview resolves the workspace, shapes
4
- * the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
5
- * wait contract onto the agent contract through verdictResponse. An
4
+ * the artifact, and opens-or-revises by agentSessionId; awaitMessage maps the
5
+ * wait contract onto the agent contract through messageResponse. An
6
6
  * adapter keeps only two bespoke parts: parsing its host's event shape and
7
7
  * serializing the decision in its host's contract.
8
8
  */
@@ -10,18 +10,20 @@
10
10
  import {
11
11
  isMarkdownArtifact,
12
12
  newAnnotationId,
13
+ type Annotation,
14
+ type Artifact,
13
15
  type ArtifactType,
14
16
  type DiffFileContents,
15
17
  type Thread,
16
18
  type WorkspaceKey,
19
+ type WorkflowKind,
17
20
  } from "@cueloop/schema";
18
- import { verdictResponse } from "./api";
19
- import type { DaemonClient } from "./client";
21
+ import { messageResponse } from "./api";
20
22
  import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
21
23
 
22
- // Adapters and CLI primitives reach the verdict mapping through this module too,
24
+ // Adapters and CLI primitives reach the message mapping through this module too,
23
25
  // so a session obtained outside a ReviewHandle maps the same way.
24
- export { verdictResponse };
26
+ export { messageResponse };
25
27
 
26
28
  async function git(args: string[], cwd: string): Promise<string | null> {
27
29
  try {
@@ -73,6 +75,8 @@ function firstHeading(markdown: string): string | undefined {
73
75
 
74
76
  export interface OpenReviewOptions {
75
77
  type: ArtifactType;
78
+ /** Workflow identity keeps plan/refine and diff/review Threads separate. */
79
+ workflow?: WorkflowKind;
76
80
  content: string;
77
81
  /** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
78
82
  cwd?: string;
@@ -107,9 +111,28 @@ export interface ReviewNote {
107
111
  body: string;
108
112
  }
109
113
 
114
+ /** The transport-neutral daemon operations used by the shared review lifecycle. */
115
+ export interface ThreadSessionClient {
116
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
117
+ sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
118
+ sessionSubmitRevision(
119
+ id: string,
120
+ content: string,
121
+ addressedAnnotationIds?: string[],
122
+ files?: DiffFileContents[],
123
+ ): Promise<Thread>;
124
+ sessionAnnotate(
125
+ id: string,
126
+ annotation: Omit<Annotation, "createdAt">,
127
+ authorName?: string,
128
+ ): Promise<Thread>;
129
+ sessionGet(id: string): Promise<Thread>;
130
+ sessionWait(id: string, timeoutMs: number): Promise<Thread | null>;
131
+ }
132
+
110
133
  /** Anchor a note at its file: quote = the path, no context selectors. */
111
134
  async function attachNotes(
112
- client: DaemonClient,
135
+ client: ThreadSessionClient,
113
136
  sessionId: string,
114
137
  notes: ReviewNote[],
115
138
  ): Promise<void> {
@@ -123,7 +146,7 @@ async function attachNotes(
123
146
  }
124
147
  }
125
148
 
126
- export interface AwaitVerdictOptions {
149
+ export interface AwaitMessageOptions {
127
150
  /** Total wait budget; Infinity keeps polling until resolved or aborted. */
128
151
  timeoutMs: number;
129
152
  /** Chunk length for the poll loop; between chunks the session is re-read for progress. */
@@ -134,15 +157,15 @@ export interface AwaitVerdictOptions {
134
157
  }
135
158
 
136
159
  /** A resolved review mapped onto the agent contract, plus the full session. */
137
- export interface VerdictOutcome {
160
+ export interface MessageResult {
138
161
  allow: boolean;
139
- feedback: string;
162
+ message: import("@cueloop/schema").Message;
140
163
  session: Thread;
141
164
  }
142
165
 
143
166
  export class ReviewHandle {
144
167
  constructor(
145
- private readonly client: DaemonClient,
168
+ private readonly client: Pick<ThreadSessionClient, "sessionWait" | "sessionGet">,
146
169
  readonly session: Thread,
147
170
  ) {}
148
171
 
@@ -151,12 +174,12 @@ export class ReviewHandle {
151
174
  }
152
175
 
153
176
  /**
154
- * Block on the verdict. "pending" means the budget ran out or the signal
155
- * aborted - the session stays open and the verdict is collectable later
156
- * (verdicts outlive waits). With only timeoutMs this is one long-poll;
177
+ * Block on the message. "pending" means the budget ran out or the signal
178
+ * aborted - the session stays open and the message is collectable later
179
+ * (messages outlive waits). With only timeoutMs this is one long-poll;
157
180
  * pollMs/onProgress/signal switch to the chunked loop.
158
181
  */
159
- async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
182
+ async awaitMessage(options: AwaitMessageOptions): Promise<MessageResult | "pending"> {
160
183
  const { timeoutMs, pollMs, onProgress, signal } = options;
161
184
 
162
185
  if (pollMs === undefined && onProgress === undefined && signal === undefined) {
@@ -174,18 +197,20 @@ export class ReviewHandle {
174
197
  const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
175
198
 
176
199
  if (resolved === ABORTED) return "pending";
200
+
177
201
  if (resolved !== null) return outcome(resolved);
178
202
  // Still pending after this chunk: re-read to surface reviewer progress.
179
203
  const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
180
204
 
181
205
  if (current === ABORTED) return "pending";
206
+
182
207
  onProgress?.(current);
183
208
  }
184
209
  }
185
210
  }
186
211
 
187
- function outcome(session: Thread): VerdictOutcome {
188
- return { ...verdictResponse(session), session };
212
+ function outcome(session: Thread): MessageResult {
213
+ return { ...messageResponse(session), session };
189
214
  }
190
215
 
191
216
  export interface AwaitResolveOptions {
@@ -196,21 +221,21 @@ export interface AwaitResolveOptions {
196
221
  }
197
222
 
198
223
  /**
199
- * Park until a thread resolves, then return the verdict outcome; null
200
- * when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
224
+ * Park until a thread resolves, then return the message outcome; null
225
+ * when the signal aborts first. Where ReviewHandle.awaitMessage needs the handle
201
226
  * that opened the review, this needs only a session id - so a background waiter
202
227
  * that woke on a session it did not open (a detached Claude Code / Codex waiter,
203
- * or pi's session_start listener) can collect the same verdict. This is the
228
+ * or pi's session_start listener) can collect the same message. This is the
204
229
  * wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
205
- * a verdict that lands between chunks is never missed and a session already
230
+ * a message that lands between chunks is never missed and a session already
206
231
  * resolved returns on the first chunk. The held connection also keeps the daemon
207
232
  * off its idle-exit path for the whole wait.
208
233
  */
209
234
  export async function awaitResolve(
210
- client: DaemonClient,
235
+ client: Pick<ThreadSessionClient, "sessionWait">,
211
236
  sessionId: string,
212
237
  options: AwaitResolveOptions = {},
213
- ): Promise<VerdictOutcome | null> {
238
+ ): Promise<MessageResult | null> {
214
239
  const chunkMs = options.pollMs ?? 30_000;
215
240
  const resolved = await pollUntilResolved(
216
241
  () => client.sessionWait(sessionId, chunkMs),
@@ -220,40 +245,63 @@ export async function awaitResolve(
220
245
  return resolved === null ? null : outcome(resolved);
221
246
  }
222
247
 
248
+ /** Find the Thread already submitted by this agent in the same workspace and primitive. */
249
+ export async function findExistingReview(
250
+ client: ThreadSessionClient,
251
+ options: OpenReviewOptions,
252
+ ): Promise<Thread | undefined> {
253
+ const cwd = options.cwd ?? process.cwd();
254
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
255
+
256
+ if (options.agentSessionId === undefined) return undefined;
257
+
258
+ return (await client.sessionList()).find(
259
+ (candidate) =>
260
+ candidate.artifact.meta.agentSessionId === options.agentSessionId &&
261
+ candidate.artifact.meta.agent === options.agent &&
262
+ candidate.artifact.type === options.type &&
263
+ (candidate.artifact.meta.workflow ??
264
+ (candidate.artifact.meta.pr ? "review" : candidate.artifact.type)) ===
265
+ (options.workflow ?? (options.pr ? "review" : options.type)) &&
266
+ candidate.artifact.meta.pr === options.pr &&
267
+ candidate.workspace.repoRoot === workspace.repoRoot &&
268
+ candidate.workspace.branch === workspace.branch,
269
+ );
270
+ }
271
+
223
272
  /** Open a thread (or revise the agent session's existing one) and hand back the wait surface. */
224
273
  export async function openReview(
225
- client: DaemonClient,
274
+ client: ThreadSessionClient,
226
275
  options: OpenReviewOptions,
227
276
  ): Promise<ReviewHandle> {
228
277
  const cwd = options.cwd ?? process.cwd();
229
278
  const workspace = options.workspace ?? (await resolveWorkspace(cwd));
230
-
231
- // Resubmits from the same agent session become revisions, not new sessions -
232
- // but only within the same primitive: a different artifact type is a new
233
- // review, never a silent type mismatch on the old session.
234
- if (options.agentSessionId !== undefined) {
235
- const existing = (await client.sessionList()).find(
236
- (candidate) =>
237
- candidate.artifact.meta.agentSessionId === options.agentSessionId &&
238
- candidate.artifact.type === options.type,
279
+ const existing = await findExistingReview(client, options);
280
+
281
+ if (existing !== undefined) {
282
+ // PR diffs have no full file contents. An absent local-diff capture clears
283
+ // old curatable files, while a PR revision must keep curation disabled.
284
+ const revisionFiles = options.type !== "diff" || options.pr ? undefined : (options.files ?? []);
285
+ let revised = await client.sessionSubmitRevision(
286
+ existing.id,
287
+ options.content,
288
+ [],
289
+ revisionFiles,
239
290
  );
240
291
 
241
- if (existing !== undefined) {
242
- let revised = await client.sessionSubmitRevision(existing.id, options.content);
243
-
244
- if (options.notes?.length) {
245
- await attachNotes(client, revised.id, options.notes);
246
- revised = await client.sessionGet(revised.id);
247
- }
248
-
249
- return new ReviewHandle(client, revised);
292
+ if (options.notes?.length) {
293
+ await attachNotes(client, revised.id, options.notes);
294
+ revised = await client.sessionGet(revised.id);
250
295
  }
296
+
297
+ return new ReviewHandle(client, revised);
251
298
  }
252
299
  let session = await client.sessionCreate(workspace, {
253
300
  type: options.type,
254
301
  content: options.content,
255
302
  files: options.files,
256
303
  meta: {
304
+ workflow: options.workflow,
257
305
  agent: options.agent,
258
306
  agentSessionId: options.agentSessionId,
259
307
  planPath: options.planPath,
@@ -13,7 +13,7 @@ import {
13
13
  IdentitySchema,
14
14
  RevisionSchema,
15
15
  ThreadRecordSchema,
16
- VerdictSchema,
16
+ MessageSchema,
17
17
  WorkspaceSchema,
18
18
  isKnownMethod,
19
19
  parseParams,
@@ -29,7 +29,7 @@ import {
29
29
  type Identity,
30
30
  type Thread,
31
31
  type Revision,
32
- type Verdict,
32
+ type Message,
33
33
  type WorkspaceKey,
34
34
  } from "@cueloop/schema";
35
35
 
@@ -97,10 +97,10 @@ describe("parseParams", () => {
97
97
  ).toThrow();
98
98
  });
99
99
 
100
- test("verdict kinds are closed", () => {
101
- expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
102
- expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
103
- /verdictKind/,
100
+ test("message kinds are closed", () => {
101
+ expect(parseParams("session.sendMessage", { id: "s", outcome: "approved" }).summary).toBe("");
102
+ expect(() => parseParams("session.sendMessage", { id: "s", outcome: "lgtm" })).toThrow(
103
+ /outcome/,
104
104
  );
105
105
  });
106
106
 
@@ -141,7 +141,7 @@ describe("validateThreadRecord", () => {
141
141
  artifact: { type: "plan", content: "# P", meta: {} },
142
142
  revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
143
143
  annotations: [],
144
- verdict: null,
144
+ message: null,
145
145
  status: "pending",
146
146
  createdAt: "now",
147
147
  };
@@ -179,6 +179,7 @@ describe("wire pins", () => {
179
179
  const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
180
180
 
181
181
  const fullMeta: Required<ArtifactMeta> = {
182
+ workflow: "plan",
182
183
  cwd: "/repo",
183
184
  agent: "claude-code",
184
185
  agentSessionId: "sess-1",
@@ -218,11 +219,12 @@ describe("wire pins", () => {
218
219
  meta: fullMeta,
219
220
  files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
220
221
  };
221
- const fullVerdict: Required<Verdict> = {
222
- kind: "approve",
222
+ const fullMessage: Required<Message> = {
223
+ id: "msg_1",
224
+ outcome: "approved",
223
225
  summary: "",
224
- feedback: "",
225
- resolvedAt: "now",
226
+ body: "",
227
+ sentAt: "now",
226
228
  };
227
229
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
228
230
  const fullWorkspace: Required<WorkspaceKey> = {
@@ -256,7 +258,7 @@ describe("wire pins", () => {
256
258
  },
257
259
  { id: "e2", parentId: "e1", type: "comment", annotationId: "a1", createdAt: "now" },
258
260
  { id: "e3", parentId: "e2", type: "comment-removed", annotationId: "a1", createdAt: "now" },
259
- { id: "e4", parentId: "e3", type: "verdict", verdict: fullVerdict, createdAt: "now" },
261
+ { id: "e4", parentId: "e3", type: "message", message: fullMessage, createdAt: "now" },
260
262
  {
261
263
  id: "e5",
262
264
  parentId: "e4",
@@ -276,7 +278,7 @@ describe("wire pins", () => {
276
278
  shareBranch: "main",
277
279
  workingCopy: "# P edited",
278
280
  viewedPaths: ["src/a.ts"],
279
- verdict: fullVerdict,
281
+ message: fullMessage,
280
282
  status: "pending",
281
283
  createdAt: "now",
282
284
  shares: [
@@ -328,7 +330,7 @@ describe("wire pins", () => {
328
330
 
329
331
  expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
330
332
  expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
331
- expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
333
+ expect(entryKeys(MessageSchema)).toEqual(keys(fullMessage));
332
334
  expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
333
335
  expect(entryKeys(ThreadRecordSchema)).toEqual(keys(fullSession));
334
336
  // persisted annotations carry the stamped createdAt
package/src/validate.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import * as v from "valibot";
13
13
  import {
14
14
  ARTIFACT_TYPES,
15
+ WORKFLOW_KINDS,
15
16
  SCHEMA_VERSION,
16
17
  type Anchor,
17
18
  type Annotation,
@@ -22,12 +23,15 @@ import {
22
23
  type Identity,
23
24
  type Thread,
24
25
  type HunkRejection,
26
+ type HarnessBinding,
27
+ type Delivery,
28
+ type PendingDelivery,
25
29
  type Revision,
26
30
  type SessionHistory,
27
31
  type ShareAccess,
28
32
  type ShareLink,
29
33
  validateHistory,
30
- type Verdict,
34
+ type Message,
31
35
  type WorkspaceKey,
32
36
  } from "@cueloop/schema";
33
37
  import { DaemonError } from "./errors";
@@ -52,6 +56,7 @@ export const WorkspaceSchema = v.object({
52
56
  } satisfies EntriesOf<WorkspaceKey>);
53
57
 
54
58
  export const ArtifactMetaSchema = v.object({
59
+ workflow: v.optional(v.picklist(WORKFLOW_KINDS)),
55
60
  cwd: v.optional(v.string()),
56
61
  agent: v.optional(v.string()),
57
62
  agentSessionId: v.optional(v.string()),
@@ -141,6 +146,24 @@ export const ShareLinkSchema = v.object({
141
146
  shareBranch: v.optional(v.string()),
142
147
  } satisfies EntriesOf<ShareLink>);
143
148
 
149
+ export const HarnessBindingSchema = v.object({
150
+ id: NonEmpty,
151
+ threadId: NonEmpty,
152
+ harness: NonEmpty,
153
+ harnessSessionId: NonEmpty,
154
+ createdAt: NonEmpty,
155
+ approvedRetryMessageId: v.optional(NonEmpty),
156
+ } satisfies EntriesOf<HarnessBinding>);
157
+
158
+ export const DeliverySchema = v.object({
159
+ id: NonEmpty,
160
+ messageId: NonEmpty,
161
+ bindingId: NonEmpty,
162
+ status: v.picklist(["pending", "acknowledged"]),
163
+ createdAt: NonEmpty,
164
+ acknowledgedAt: v.optional(NonEmpty),
165
+ } satisfies EntriesOf<Delivery>);
166
+
144
167
  export const Params = {
145
168
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
146
169
  "session.get": v.object({ id: SessionId }),
@@ -227,15 +250,29 @@ export const Params = {
227
250
  v.array(v.object({ id: NonEmpty, annotationId: NonEmpty, createdAt: v.string() })),
228
251
  ),
229
252
  }),
230
- "session.resolve": v.object({
253
+ "session.sendMessage": v.object({
231
254
  id: SessionId,
232
- verdictKind: v.picklist(["comment", "approve", "request_changes"]),
255
+ outcome: v.picklist(["approved", "changes_requested"]),
233
256
  summary: v.optional(v.string(), ""),
234
257
  actionBodies: v.optional(v.record(v.string(), v.string())),
235
258
  }),
259
+ "harness.bind": v.object({
260
+ threadId: SessionId,
261
+ harness: NonEmpty,
262
+ harnessSessionId: NonEmpty,
263
+ }),
264
+ "harness.getBinding": v.object({ bindingId: NonEmpty }),
265
+ "harness.consumeApprovedRetry": v.object({
266
+ bindingId: NonEmpty,
267
+ messageId: NonEmpty,
268
+ content: v.string(),
269
+ }),
270
+ "delivery.pending": v.object({ bindingId: NonEmpty }),
271
+ "delivery.acknowledge": v.object({ deliveryId: NonEmpty }),
236
272
  "session.submitRevision": v.object({
237
273
  id: SessionId,
238
274
  content: v.string(),
275
+ files: v.optional(v.array(DiffFileContentsSchema)),
239
276
  /** Annotation ids the agent acted on; each is marked addressed. */
240
277
  addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
241
278
  }),
@@ -286,12 +323,18 @@ export const RevisionSchema = v.object({
286
323
  submittedAt: v.string(),
287
324
  } satisfies EntriesOf<Revision>);
288
325
 
289
- export const VerdictSchema = v.object({
290
- kind: v.picklist(["comment", "approve", "request_changes"]),
326
+ export const MessageSchema = v.object({
327
+ id: NonEmpty,
328
+ outcome: v.picklist(["approved", "changes_requested"]),
291
329
  summary: v.string(),
292
- feedback: v.string(),
293
- resolvedAt: v.string(),
294
- } satisfies EntriesOf<Verdict>);
330
+ body: v.string(),
331
+ sentAt: v.string(),
332
+ } satisfies EntriesOf<Message>);
333
+
334
+ export const PendingDeliverySchema = v.object({
335
+ delivery: DeliverySchema,
336
+ message: MessageSchema,
337
+ } satisfies EntriesOf<PendingDelivery>);
295
338
 
296
339
  const EntryBaseEntries = {
297
340
  id: NonEmpty,
@@ -309,7 +352,7 @@ export const SessionEntrySchema = v.variant("type", [
309
352
  }),
310
353
  v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
311
354
  v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
312
- v.object({ ...EntryBaseEntries, type: v.literal("verdict"), verdict: VerdictSchema }),
355
+ v.object({ ...EntryBaseEntries, type: v.literal("message"), message: MessageSchema }),
313
356
  v.object({
314
357
  ...EntryBaseEntries,
315
358
  type: v.literal("branch-summary"),
@@ -354,7 +397,7 @@ export const ThreadRecordSchema = v.object({
354
397
  ),
355
398
  workingCopy: v.optional(v.string()),
356
399
  viewedPaths: v.optional(v.array(v.string())),
357
- verdict: v.nullable(VerdictSchema),
400
+ message: v.nullable(MessageSchema),
358
401
  status: v.picklist(["pending", "resolved"]),
359
402
  createdAt: v.string(),
360
403
  shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),