@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (56) hide show
  1. package/package.json +37 -22
  2. package/src/api.test.ts +940 -65
  3. package/src/api.ts +1095 -88
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/ghostty-thread-surface-store.test.ts +49 -0
  14. package/src/ghostty-thread-surface-store.ts +101 -0
  15. package/src/ghostty-thread-surface.test.ts +398 -0
  16. package/src/ghostty-thread-surface.ts +267 -0
  17. package/src/harness-delivery.test.ts +208 -0
  18. package/src/harness-state-store.test.ts +65 -0
  19. package/src/harness-state-store.ts +181 -0
  20. package/src/herdr-split.test.ts +106 -0
  21. package/src/herdr-split.ts +92 -0
  22. package/src/herdr-thread-surface-store.test.ts +81 -0
  23. package/src/herdr-thread-surface-store.ts +64 -0
  24. package/src/herdr-thread-surface.test.ts +411 -0
  25. package/src/herdr-thread-surface.ts +355 -0
  26. package/src/index.ts +8 -9
  27. package/src/interruptible-wait.test.ts +127 -0
  28. package/src/interruptible-wait.ts +66 -0
  29. package/src/main.ts +4 -1
  30. package/src/paths.ts +49 -0
  31. package/src/pr-poller.test.ts +76 -0
  32. package/src/pr-poller.ts +74 -0
  33. package/src/project-files.test.ts +124 -0
  34. package/src/project-files.ts +56 -0
  35. package/src/protocol.ts +79 -7
  36. package/src/retention.test.ts +191 -0
  37. package/src/retention.ts +123 -0
  38. package/src/server.test.ts +432 -42
  39. package/src/server.ts +423 -89
  40. package/src/share-blob.test.ts +112 -0
  41. package/src/share-blob.ts +55 -0
  42. package/src/single-instance.test.ts +162 -13
  43. package/src/store.conformance.test.ts +41 -0
  44. package/src/store.jsonl.test.ts +159 -0
  45. package/src/store.ts +264 -34
  46. package/src/testing/store-conformance.ts +194 -0
  47. package/src/thread-review.test.ts +394 -0
  48. package/src/thread-review.ts +314 -0
  49. package/src/thread-surface-config.test.ts +63 -0
  50. package/src/thread-surface-config.ts +61 -0
  51. package/src/validate.test.ts +214 -35
  52. package/src/validate.ts +306 -25
  53. package/src/version.ts +6 -0
  54. package/src/working-tree.ts +149 -0
  55. package/src/review.test.ts +0 -167
  56. package/src/review.ts +0 -174
@@ -0,0 +1,394 @@
1
+ /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitMessage shapes (one long-poll and the chunked loop with progress and abort). */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { Thread } from "@cueloop/schema";
8
+ import { DaemonServer } from "./server";
9
+ import { DaemonClient } from "./client";
10
+ import { awaitResolve, openReview, resolveWorkspace } from "./thread-review";
11
+
12
+ const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
13
+
14
+ let home: string;
15
+ let server: DaemonServer;
16
+ let client: DaemonClient;
17
+
18
+ beforeEach(async () => {
19
+ home = mkdtempSync(join(tmpdir(), "cueloop-review-core-"));
20
+ server = new DaemonServer({ home, idleExitMs: 0 });
21
+ server.start();
22
+ client = await DaemonClient.connect({ home });
23
+ });
24
+ afterEach(() => {
25
+ client.close();
26
+ server.stop();
27
+ rmSync(home, { recursive: true, force: true });
28
+ });
29
+
30
+ function sh(args: string[], cwd: string): void {
31
+ const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
32
+
33
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
34
+ }
35
+
36
+ describe("resolveWorkspace", () => {
37
+ test("a git repo resolves to its root and branch", async () => {
38
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
39
+
40
+ try {
41
+ // Arrange
42
+ sh(["git", "init", "-q", "-b", "main"], repo);
43
+ sh(["git", "config", "user.email", "t@t"], repo);
44
+ sh(["git", "config", "user.name", "t"], repo);
45
+ sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
46
+
47
+ // Act
48
+ const ws = await resolveWorkspace(repo);
49
+
50
+ // Assert
51
+ expect(ws.branch).toBe("main");
52
+ // macOS tmpdirs can resolve through /private; compare the tail
53
+ expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
54
+ } finally {
55
+ rmSync(repo, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("outside a repo the cwd itself is the workspace, branch detached", async () => {
60
+ // Act
61
+ const ws = await resolveWorkspace(home);
62
+
63
+ // Assert
64
+ expect(ws).toEqual({ repoRoot: home, branch: "detached" });
65
+ });
66
+ });
67
+
68
+ describe("openReview", () => {
69
+ test("shapes the artifact: agent, derived title, cwd", async () => {
70
+ // Act
71
+ const review = await openReview(client, {
72
+ type: "plan",
73
+ content: PLAN,
74
+ cwd: home,
75
+ agent: "test-agent",
76
+ });
77
+
78
+ // Assert
79
+ const session = review.session;
80
+
81
+ expect(session.status).toBe("pending");
82
+ expect(session.artifact.type).toBe("plan");
83
+ expect(session.artifact.meta.agent).toBe("test-agent");
84
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
85
+ expect(session.artifact.meta.cwd).toBe(home);
86
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
87
+ });
88
+
89
+ test("an explicit title wins; diffs never derive one from content", async () => {
90
+ // Act
91
+ const titled = await openReview(client, {
92
+ type: "plan",
93
+ content: PLAN,
94
+ cwd: home,
95
+ title: "Custom",
96
+ });
97
+
98
+ // Assert
99
+ expect(titled.session.artifact.meta.title).toBe("Custom");
100
+
101
+ // Act
102
+ const diff = await openReview(client, {
103
+ type: "diff",
104
+ content: "# not a heading\n",
105
+ cwd: home,
106
+ });
107
+
108
+ // Assert
109
+ expect(diff.session.artifact.meta.title).toBeUndefined();
110
+ });
111
+
112
+ test("a pre-resolved workspace skips git resolution", async () => {
113
+ // Arrange
114
+ const ws = { repoRoot: "/elsewhere", branch: "feature" };
115
+
116
+ // Act
117
+ const review = await openReview(client, {
118
+ type: "diff",
119
+ content: "d",
120
+ cwd: home,
121
+ workspace: ws,
122
+ });
123
+
124
+ // Assert
125
+ expect(review.session.workspace).toEqual(ws);
126
+ });
127
+
128
+ test("the same agentSessionId revises instead of opening a new session", async () => {
129
+ // Arrange
130
+ const first = await openReview(client, {
131
+ type: "plan",
132
+ content: PLAN,
133
+ cwd: home,
134
+ agentSessionId: "agent-1",
135
+ });
136
+
137
+ // Act
138
+ const second = await openReview(client, {
139
+ type: "plan",
140
+ content: PLAN + "\nStage three.\n",
141
+ cwd: home,
142
+ agentSessionId: "agent-1",
143
+ });
144
+
145
+ // Assert
146
+ expect(second.id).toBe(first.id);
147
+ expect(second.session.revisions.length).toBe(2);
148
+ expect(second.session.status).toBe("pending");
149
+
150
+ // Act
151
+ const other = await openReview(client, {
152
+ type: "plan",
153
+ content: PLAN,
154
+ cwd: home,
155
+ agentSessionId: "agent-2",
156
+ });
157
+
158
+ // Assert
159
+ expect(other.id).not.toBe(first.id);
160
+ });
161
+
162
+ test("the same agentSessionId with a different type opens a new session, never a type mismatch", async () => {
163
+ // Arrange - the agent session's plan review is open
164
+ const plan = await openReview(client, {
165
+ type: "plan",
166
+ content: PLAN,
167
+ cwd: home,
168
+ agentSessionId: "agent-cross",
169
+ });
170
+
171
+ // Act - the same agent session now submits a reply for review
172
+ const reply = await openReview(client, {
173
+ type: "reply",
174
+ content: "# Findings\n\nThe cache is fine.\n",
175
+ cwd: home,
176
+ agentSessionId: "agent-cross",
177
+ });
178
+
179
+ // Assert - a fresh reply session; the plan session keeps its type and history
180
+ expect(reply.id).not.toBe(plan.id);
181
+ expect(reply.session.artifact.type).toBe("reply");
182
+ expect(reply.session.revisions.length).toBe(1);
183
+ expect((await client.sessionGet(plan.id)).artifact.type).toBe("plan");
184
+ });
185
+
186
+ test("per-file notes land as note annotations anchored at the file path", async () => {
187
+ // Act
188
+ const review = await openReview(client, {
189
+ type: "diff",
190
+ content: "d",
191
+ cwd: home,
192
+ notes: [
193
+ { path: "src/a.ts", body: "Renames b and adds c." },
194
+ { path: "src/b.ts", body: "Swaps the line." },
195
+ ],
196
+ });
197
+
198
+ // Assert
199
+ const notes = review.session.annotations;
200
+
201
+ expect(notes.length).toBe(2);
202
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
203
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
204
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
205
+ });
206
+ });
207
+
208
+ describe("awaitMessage: one long-poll (the hook shape)", () => {
209
+ test("times out to pending; a later wait collects the stored message", async () => {
210
+ // Arrange
211
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
212
+
213
+ // Assert
214
+ expect(await review.awaitMessage({ timeoutMs: 50 })).toBe("pending");
215
+
216
+ // Act
217
+ await client.sessionSendMessage(review.id, "approved", "Fine.");
218
+ const message = await review.awaitMessage({ timeoutMs: 1_000 });
219
+
220
+ // Assert
221
+ expect(message).not.toBe("pending");
222
+ if (message === "pending") throw new Error("unreachable");
223
+ expect(message.allow).toBe(true);
224
+ expect(message.message.body).toContain("Fine.");
225
+ expect(message.session.message!.outcome).toBe("approved");
226
+ });
227
+
228
+ test("changes_requested maps to allow=false with feedback.md attached", async () => {
229
+ // Arrange
230
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
231
+ const waiting = review.awaitMessage({ timeoutMs: 5_000 });
232
+
233
+ // Act
234
+ await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
235
+ const message = await waiting;
236
+
237
+ // Assert
238
+ if (message === "pending") throw new Error("expected a message");
239
+ expect(message.allow).toBe(false);
240
+ expect(message.message.body).toContain("# Review: changes requested");
241
+ expect(message.message.body).toContain("One stage only.");
242
+ });
243
+ });
244
+
245
+ describe("awaitMessage: chunked loop (the pi shape)", () => {
246
+ test("onProgress sees fresh sessions between chunks; the message ends the loop", async () => {
247
+ // Arrange
248
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
249
+ const seen: Thread[] = [];
250
+ const waiting = review.awaitMessage({
251
+ timeoutMs: Infinity,
252
+ pollMs: 100,
253
+ onProgress: (progress) => seen.push(progress),
254
+ });
255
+
256
+ // Act
257
+ await client.sessionAnnotate(review.id, {
258
+ id: "a1",
259
+ kind: "comment",
260
+ anchor: { quote: "two stages", prefix: "in ", suffix: "." },
261
+ body: "Name them.",
262
+ });
263
+
264
+ // Assert
265
+ // give the loop a chunk to observe the annotation before resolving
266
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
267
+ await Bun.sleep(20);
268
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
269
+
270
+ // Act
271
+ await client.sessionSendMessage(review.id, "changes_requested", "Too vague.");
272
+ const message = await waiting;
273
+
274
+ // Assert
275
+ if (message === "pending") throw new Error("expected a message");
276
+ expect(message.allow).toBe(false);
277
+ expect(message.session.annotations.length).toBe(1);
278
+ }, 15_000);
279
+
280
+ test("abort surfaces as pending and leaves the session collectable", async () => {
281
+ // Arrange
282
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
283
+ const controller = new AbortController();
284
+ const waiting = review.awaitMessage({
285
+ timeoutMs: Infinity,
286
+ pollMs: 100,
287
+ signal: controller.signal,
288
+ });
289
+
290
+ // Act
291
+ controller.abort();
292
+
293
+ // Assert
294
+ expect(await waiting).toBe("pending");
295
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
296
+ });
297
+
298
+ test("a finite budget runs out to pending", async () => {
299
+ // Arrange
300
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
301
+
302
+ // Assert
303
+ expect(await review.awaitMessage({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
304
+ });
305
+ });
306
+
307
+ describe("awaitResolve: the adapter wake seam (session id only)", () => {
308
+ test("resolves to the outcome when the message lands during the wait", async () => {
309
+ // Arrange
310
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
311
+ const waiting = awaitResolve(client, review.id, { pollMs: 100 });
312
+
313
+ // Act
314
+ await client.sessionSendMessage(review.id, "approved", "Ship it.");
315
+ const message = await waiting;
316
+
317
+ // Assert
318
+ expect(message).not.toBeNull();
319
+ expect(message!.allow).toBe(true);
320
+ expect(message!.message.body).toContain("Ship it.");
321
+ expect(message!.session.message!.outcome).toBe("approved");
322
+ });
323
+
324
+ test("returns the stored message immediately when the session already resolved", async () => {
325
+ // Arrange - a detached waiter that only attaches after the human decided
326
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
327
+
328
+ await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
329
+
330
+ // Act
331
+ const message = await awaitResolve(client, review.id);
332
+
333
+ // Assert
334
+ expect(message).not.toBeNull();
335
+ expect(message!.allow).toBe(false);
336
+ expect(message!.message.body).toContain("One stage only.");
337
+ });
338
+
339
+ test("returns null when the signal aborts first", async () => {
340
+ // Arrange
341
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
342
+ const controller = new AbortController();
343
+ const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
344
+
345
+ // Act
346
+ controller.abort();
347
+
348
+ // Assert
349
+ expect(await waiting).toBeNull();
350
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
351
+ });
352
+ });
353
+
354
+ describe("awaitResolve: the held wait keeps the daemon alive", () => {
355
+ test("a pending session does not idle-exit while a waiter is parked, then the message lands", async () => {
356
+ // Arrange - a daemon with an aggressive idle timer and its own home
357
+ const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
358
+ let idleExits = 0;
359
+ const idleServer = new DaemonServer({
360
+ home: idleHome,
361
+ idleExitMs: 30,
362
+ onIdleExit: () => idleExits++,
363
+ });
364
+
365
+ idleServer.start();
366
+ const waiterClient = await DaemonClient.connect({ home: idleHome });
367
+
368
+ try {
369
+ const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
370
+ const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
371
+
372
+ // Act - wait well past the idle window while the review is still pending
373
+ await Bun.sleep(120);
374
+
375
+ // Assert - the parked waiter and the pending session both keep it alive
376
+ expect(idleExits).toBe(0);
377
+ expect((await waiterClient.ping()).pid).toBe(process.pid);
378
+
379
+ // Act - the human returns a message; the parked wait collects it
380
+ const resolver = await DaemonClient.connect({ home: idleHome });
381
+
382
+ await resolver.sessionSendMessage(review.id, "approved", "Good to go.");
383
+ resolver.close();
384
+ const message = await waiting;
385
+
386
+ // Assert
387
+ expect(message!.allow).toBe(true);
388
+ } finally {
389
+ waiterClient.close();
390
+ idleServer.stop();
391
+ rmSync(idleHome, { recursive: true, force: true });
392
+ }
393
+ }, 15_000);
394
+ });
@@ -0,0 +1,314 @@
1
+ import {
2
+ isMarkdownArtifact,
3
+ newAnnotationId,
4
+ type Annotation,
5
+ type Artifact,
6
+ type ArtifactType,
7
+ type DiffFileContents,
8
+ type Thread,
9
+ type WorkspaceKey,
10
+ type WorkflowKind,
11
+ } from "@cueloop/schema";
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { messageResponse } from "./api";
15
+ import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
16
+
17
+ // Adapters and CLI primitives reach the message mapping through this module too,
18
+ // so a session obtained outside a ReviewHandle maps the same way.
19
+ export { messageResponse };
20
+
21
+ async function git(args: string[], cwd: string): Promise<string | null> {
22
+ try {
23
+ const { stdout } = await promisify(execFile)("git", args, { cwd });
24
+
25
+ return stdout.trim();
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
32
+ function earliestRootCommit(revList: string | null): string | undefined {
33
+ if (!revList) return undefined;
34
+ const roots = revList.split("\n").filter((line) => line.length > 0);
35
+
36
+ // --date-order lists newest first, so the last root is the earliest
37
+ return roots.at(-1);
38
+ }
39
+
40
+ /** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
41
+ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
42
+ const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
43
+ const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
44
+ // a shallow clone's oldest commit is the graft boundary, not the true root, so
45
+ // it would key a different project than a full clone - leave it unset instead
46
+ const shallow = (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
47
+ const rootCommit = shallow
48
+ ? undefined
49
+ : earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
50
+ const remote = await git(["remote", "get-url", "origin"], cwd);
51
+
52
+ const workspace: WorkspaceKey = { repoRoot, branch };
53
+ // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
54
+ if (rootCommit) workspace.rootCommit = rootCommit;
55
+ if (remote) workspace.remote = remote;
56
+
57
+ return workspace;
58
+ }
59
+
60
+ function firstHeading(markdown: string): string | undefined {
61
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
62
+
63
+ return headingMatch?.[1]?.trim();
64
+ }
65
+
66
+ export interface OpenReviewOptions {
67
+ type: ArtifactType;
68
+ /** Workflow identity keeps plan/refine and diff/review Threads separate. */
69
+ workflow?: WorkflowKind;
70
+ content: string;
71
+ /** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
72
+ cwd?: string;
73
+ /** Pre-resolved workspace key; skips git resolution when the caller already has it. */
74
+ workspace?: WorkspaceKey;
75
+ agent?: string;
76
+ /** When set, a resubmit from the same agent session becomes a revision, not a new session. */
77
+ agentSessionId?: string;
78
+ planPath?: string;
79
+ prototypePath?: string;
80
+ pr?: string;
81
+ herdrPane?: string;
82
+ /**
83
+ * Full file contents per changed file for a working-tree diff, carried onto
84
+ * the artifact so hunk curation produces an exactly applyable patch.
85
+ */
86
+ files?: DiffFileContents[];
87
+ /** Defaults to a markdown artifact's first heading (plan, reply); diffs get no derived title. */
88
+ title?: string;
89
+ /**
90
+ * Per-file agent notes for diff sessions: the submitting agent's own
91
+ * explanation of each file's change, in dead prose. Stored as annotations
92
+ * with kind "note" anchored at the file path, so they render as regular
93
+ * rail cards and feed the guided walk's agent-note block.
94
+ */
95
+ notes?: ReviewNote[];
96
+ }
97
+
98
+ /** The note contract: one note per changed file, anchored by the file path. */
99
+ export interface ReviewNote {
100
+ path: string;
101
+ body: string;
102
+ }
103
+
104
+ /** The transport-neutral daemon operations used by the shared review lifecycle. */
105
+ export interface ThreadSessionClient {
106
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
107
+ sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
108
+ sessionSubmitRevision(
109
+ id: string,
110
+ content: string,
111
+ addressedAnnotationIds?: string[],
112
+ files?: DiffFileContents[],
113
+ ): Promise<Thread>;
114
+ sessionAnnotate(
115
+ id: string,
116
+ annotation: Omit<Annotation, "createdAt">,
117
+ authorName?: string,
118
+ ): Promise<Thread>;
119
+ sessionGet(id: string): Promise<Thread>;
120
+ sessionWait(id: string, timeoutMs: number): Promise<Thread | null>;
121
+ }
122
+
123
+ /** Anchor a note at its file: quote = the path, no context selectors. */
124
+ async function attachNotes(
125
+ client: ThreadSessionClient,
126
+ sessionId: string,
127
+ notes: ReviewNote[],
128
+ ): Promise<void> {
129
+ for (const note of notes) {
130
+ await client.sessionAnnotate(sessionId, {
131
+ id: newAnnotationId(),
132
+ kind: "note",
133
+ anchor: { quote: note.path, prefix: "", suffix: "" },
134
+ body: note.body,
135
+ });
136
+ }
137
+ }
138
+
139
+ export interface AwaitMessageOptions {
140
+ /** Total wait budget; Infinity keeps polling until resolved or aborted. */
141
+ timeoutMs: number;
142
+ /** Chunk length for the poll loop; between chunks the session is re-read for progress. */
143
+ pollMs?: number;
144
+ /** Called with the fresh session after each chunk that is still pending. */
145
+ onProgress?: (session: Thread) => void;
146
+ signal?: AbortSignal;
147
+ }
148
+
149
+ /** A resolved review mapped onto the agent contract, plus the full session. */
150
+ export interface MessageResult {
151
+ allow: boolean;
152
+ message: import("@cueloop/schema").Message;
153
+ session: Thread;
154
+ }
155
+
156
+ export class ReviewHandle {
157
+ constructor(
158
+ private readonly client: Pick<ThreadSessionClient, "sessionWait" | "sessionGet">,
159
+ readonly session: Thread,
160
+ ) {}
161
+
162
+ get id(): string {
163
+ return this.session.id;
164
+ }
165
+
166
+ /**
167
+ * Block on the message. "pending" means the budget ran out or the signal
168
+ * aborted - the session stays open and the message is collectable later
169
+ * (messages outlive waits). With only timeoutMs this is one long-poll;
170
+ * pollMs/onProgress/signal switch to the chunked loop.
171
+ */
172
+ async awaitMessage(options: AwaitMessageOptions): Promise<MessageResult | "pending"> {
173
+ const { timeoutMs, pollMs, onProgress, signal } = options;
174
+
175
+ if (pollMs === undefined && onProgress === undefined && signal === undefined) {
176
+ const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
177
+
178
+ return resolved === null ? "pending" : outcome(resolved);
179
+ }
180
+ const chunkMs = pollMs ?? 10_000;
181
+ const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
182
+
183
+ for (;;) {
184
+ const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
185
+
186
+ if (budget <= 0 || signal?.aborted) return "pending";
187
+ const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
188
+
189
+ if (resolved === ABORTED) return "pending";
190
+
191
+ if (resolved !== null) return outcome(resolved);
192
+ // Still pending after this chunk: re-read to surface reviewer progress.
193
+ const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
194
+
195
+ if (current === ABORTED) return "pending";
196
+
197
+ onProgress?.(current);
198
+ }
199
+ }
200
+ }
201
+
202
+ function outcome(session: Thread): MessageResult {
203
+ return { ...messageResponse(session), session };
204
+ }
205
+
206
+ export interface AwaitResolveOptions {
207
+ /** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
208
+ pollMs?: number;
209
+ /** Abort the wait (the harness session shut down); resolves to null. */
210
+ signal?: AbortSignal;
211
+ }
212
+
213
+ /**
214
+ * Park until a thread resolves, then return the message outcome; null
215
+ * when the signal aborts first. Where ReviewHandle.awaitMessage needs the handle
216
+ * that opened the review, this needs only a session id - so a background waiter
217
+ * that woke on a session it did not open (a detached Claude Code / Codex waiter,
218
+ * or pi's session_start listener) can collect the same message. This is the
219
+ * wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
220
+ * a message that lands between chunks is never missed and a session already
221
+ * resolved returns on the first chunk. The held connection also keeps the daemon
222
+ * off its idle-exit path for the whole wait.
223
+ */
224
+ export async function awaitResolve(
225
+ client: Pick<ThreadSessionClient, "sessionWait">,
226
+ sessionId: string,
227
+ options: AwaitResolveOptions = {},
228
+ ): Promise<MessageResult | null> {
229
+ const chunkMs = options.pollMs ?? 30_000;
230
+ const resolved = await pollUntilResolved(
231
+ () => client.sessionWait(sessionId, chunkMs),
232
+ options.signal,
233
+ );
234
+
235
+ return resolved === null ? null : outcome(resolved);
236
+ }
237
+
238
+ /** Find the Thread already submitted by this agent in the same workspace and primitive. */
239
+ export async function findExistingReview(
240
+ client: ThreadSessionClient,
241
+ options: OpenReviewOptions,
242
+ ): Promise<Thread | undefined> {
243
+ const cwd = options.cwd ?? process.cwd();
244
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
245
+
246
+ if (options.agentSessionId === undefined) return undefined;
247
+
248
+ return (await client.sessionList()).find(
249
+ (candidate) =>
250
+ candidate.artifact.meta.agentSessionId === options.agentSessionId &&
251
+ candidate.artifact.meta.agent === options.agent &&
252
+ candidate.artifact.type === options.type &&
253
+ (candidate.artifact.meta.workflow ??
254
+ (candidate.artifact.meta.pr ? "review" : candidate.artifact.type)) ===
255
+ (options.workflow ?? (options.pr ? "review" : options.type)) &&
256
+ candidate.artifact.meta.pr === options.pr &&
257
+ candidate.workspace.repoRoot === workspace.repoRoot &&
258
+ candidate.workspace.branch === workspace.branch,
259
+ );
260
+ }
261
+
262
+ /** Open a thread (or revise the agent session's existing one) and hand back the wait surface. */
263
+ export async function openReview(
264
+ client: ThreadSessionClient,
265
+ options: OpenReviewOptions,
266
+ ): Promise<ReviewHandle> {
267
+ const cwd = options.cwd ?? process.cwd();
268
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
269
+ const existing = await findExistingReview(client, options);
270
+
271
+ if (existing !== undefined) {
272
+ // PR diffs have no full file contents. An absent local-diff capture clears
273
+ // old curatable files, while a PR revision must keep curation disabled.
274
+ const revisionFiles = options.type !== "diff" || options.pr ? undefined : (options.files ?? []);
275
+ let revised = await client.sessionSubmitRevision(
276
+ existing.id,
277
+ options.content,
278
+ [],
279
+ revisionFiles,
280
+ );
281
+
282
+ if (options.notes?.length) {
283
+ await attachNotes(client, revised.id, options.notes);
284
+ revised = await client.sessionGet(revised.id);
285
+ }
286
+
287
+ return new ReviewHandle(client, revised);
288
+ }
289
+ let session = await client.sessionCreate(workspace, {
290
+ type: options.type,
291
+ content: options.content,
292
+ files: options.files,
293
+ meta: {
294
+ workflow: options.workflow,
295
+ agent: options.agent,
296
+ agentSessionId: options.agentSessionId,
297
+ planPath: options.planPath,
298
+ prototypePath: options.prototypePath,
299
+ pr: options.pr,
300
+ herdrPane: options.herdrPane,
301
+ title:
302
+ options.title ??
303
+ (isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
304
+ cwd,
305
+ },
306
+ });
307
+
308
+ if (options.notes?.length) {
309
+ await attachNotes(client, session.id, options.notes);
310
+ session = await client.sessionGet(session.id);
311
+ }
312
+
313
+ return new ReviewHandle(client, session);
314
+ }