@cueloop/daemon 0.1.0-alpha.5 → 0.1.0-alpha.51

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.
@@ -0,0 +1,362 @@
1
+ /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitVerdict 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 { ReviewSession } from "@cueloop/schema";
8
+ import { DaemonServer } from "./server";
9
+ import { DaemonClient } from "./client";
10
+ import { awaitResolve, openReview, resolveWorkspace } from "./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
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
33
+ }
34
+
35
+ describe("resolveWorkspace", () => {
36
+ test("a git repo resolves to its root and branch", async () => {
37
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
38
+ try {
39
+ // Arrange
40
+ sh(["git", "init", "-q", "-b", "main"], repo);
41
+ sh(["git", "config", "user.email", "t@t"], repo);
42
+ sh(["git", "config", "user.name", "t"], repo);
43
+ sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
44
+
45
+ // Act
46
+ const ws = await resolveWorkspace(repo);
47
+
48
+ // Assert
49
+ expect(ws.branch).toBe("main");
50
+ // macOS tmpdirs can resolve through /private; compare the tail
51
+ expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
52
+ } finally {
53
+ rmSync(repo, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("outside a repo the cwd itself is the workspace, branch detached", async () => {
58
+ // Act
59
+ const ws = await resolveWorkspace(home);
60
+
61
+ // Assert
62
+ expect(ws).toEqual({ repoRoot: home, branch: "detached" });
63
+ });
64
+ });
65
+
66
+ describe("openReview", () => {
67
+ test("shapes the artifact: agent, derived title, cwd", async () => {
68
+ // Act
69
+ const review = await openReview(client, {
70
+ type: "plan",
71
+ content: PLAN,
72
+ cwd: home,
73
+ agent: "test-agent",
74
+ });
75
+
76
+ // Assert
77
+ const session = review.session;
78
+ expect(session.status).toBe("pending");
79
+ expect(session.artifact.type).toBe("plan");
80
+ expect(session.artifact.meta.agent).toBe("test-agent");
81
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
82
+ expect(session.artifact.meta.cwd).toBe(home);
83
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
84
+ });
85
+
86
+ test("an explicit title wins; diffs never derive one from content", async () => {
87
+ // Act
88
+ const titled = await openReview(client, {
89
+ type: "plan",
90
+ content: PLAN,
91
+ cwd: home,
92
+ title: "Custom",
93
+ });
94
+
95
+ // Assert
96
+ expect(titled.session.artifact.meta.title).toBe("Custom");
97
+
98
+ // Act
99
+ const diff = await openReview(client, {
100
+ type: "diff",
101
+ content: "# not a heading\n",
102
+ cwd: home,
103
+ });
104
+
105
+ // Assert
106
+ expect(diff.session.artifact.meta.title).toBeUndefined();
107
+ });
108
+
109
+ test("a pre-resolved workspace skips git resolution", async () => {
110
+ // Arrange
111
+ const ws = { repoRoot: "/elsewhere", branch: "feature" };
112
+
113
+ // Act
114
+ const review = await openReview(client, {
115
+ type: "diff",
116
+ content: "d",
117
+ cwd: home,
118
+ workspace: ws,
119
+ });
120
+
121
+ // Assert
122
+ expect(review.session.workspace).toEqual(ws);
123
+ });
124
+
125
+ test("the same agentSessionId revises instead of opening a new session", async () => {
126
+ // Arrange
127
+ const first = await openReview(client, {
128
+ type: "plan",
129
+ content: PLAN,
130
+ cwd: home,
131
+ agentSessionId: "agent-1",
132
+ });
133
+
134
+ // Act
135
+ const second = await openReview(client, {
136
+ type: "plan",
137
+ content: PLAN + "\nStage three.\n",
138
+ cwd: home,
139
+ agentSessionId: "agent-1",
140
+ });
141
+
142
+ // Assert
143
+ expect(second.id).toBe(first.id);
144
+ expect(second.session.revisions.length).toBe(2);
145
+ expect(second.session.status).toBe("pending");
146
+
147
+ // Act
148
+ const other = await openReview(client, {
149
+ type: "plan",
150
+ content: PLAN,
151
+ cwd: home,
152
+ agentSessionId: "agent-2",
153
+ });
154
+
155
+ // Assert
156
+ expect(other.id).not.toBe(first.id);
157
+ });
158
+
159
+ test("per-file notes land as note annotations anchored at the file path", async () => {
160
+ // Act
161
+ const review = await openReview(client, {
162
+ type: "diff",
163
+ content: "d",
164
+ cwd: home,
165
+ notes: [
166
+ { path: "src/a.ts", body: "Renames b and adds c." },
167
+ { path: "src/b.ts", body: "Swaps the line." },
168
+ ],
169
+ });
170
+
171
+ // Assert
172
+ const notes = review.session.annotations;
173
+ expect(notes.length).toBe(2);
174
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
175
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
176
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
177
+ });
178
+ });
179
+
180
+ describe("awaitVerdict: one long-poll (the hook shape)", () => {
181
+ test("times out to pending; a later wait collects the stored verdict", async () => {
182
+ // Arrange
183
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
184
+
185
+ // Assert
186
+ expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
187
+
188
+ // Act
189
+ await client.sessionResolve(review.id, "approve", "Fine.");
190
+ const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
191
+
192
+ // Assert
193
+ expect(verdict).not.toBe("pending");
194
+ if (verdict === "pending") throw new Error("unreachable");
195
+ expect(verdict.allow).toBe(true);
196
+ expect(verdict.feedback).toContain("Fine.");
197
+ expect(verdict.session.verdict!.kind).toBe("approve");
198
+ });
199
+
200
+ test("request_changes maps to allow=false with feedback.md attached", async () => {
201
+ // Arrange
202
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
203
+ const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
204
+
205
+ // Act
206
+ await client.sessionResolve(review.id, "request_changes", "One stage only.");
207
+ const verdict = await waiting;
208
+
209
+ // Assert
210
+ if (verdict === "pending") throw new Error("expected a verdict");
211
+ expect(verdict.allow).toBe(false);
212
+ expect(verdict.feedback).toContain("# Review: request changes");
213
+ expect(verdict.feedback).toContain("One stage only.");
214
+ });
215
+ });
216
+
217
+ describe("awaitVerdict: chunked loop (the pi shape)", () => {
218
+ test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
219
+ // Arrange
220
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
221
+ const seen: ReviewSession[] = [];
222
+ const waiting = review.awaitVerdict({
223
+ timeoutMs: Infinity,
224
+ pollMs: 100,
225
+ onProgress: (progress) => seen.push(progress),
226
+ });
227
+
228
+ // Act
229
+ await client.sessionAnnotate(review.id, {
230
+ id: "a1",
231
+ kind: "comment",
232
+ anchor: { quote: "two stages", prefix: "in ", suffix: "." },
233
+ body: "Name them.",
234
+ });
235
+
236
+ // Assert
237
+ // give the loop a chunk to observe the annotation before resolving
238
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
239
+ await Bun.sleep(20);
240
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
241
+
242
+ // Act
243
+ await client.sessionResolve(review.id, "request_changes", "Too vague.");
244
+ const verdict = await waiting;
245
+
246
+ // Assert
247
+ if (verdict === "pending") throw new Error("expected a verdict");
248
+ expect(verdict.allow).toBe(false);
249
+ expect(verdict.session.annotations.length).toBe(1);
250
+ }, 15_000);
251
+
252
+ test("abort surfaces as pending and leaves the session collectable", async () => {
253
+ // Arrange
254
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
255
+ const controller = new AbortController();
256
+ const waiting = review.awaitVerdict({
257
+ timeoutMs: Infinity,
258
+ pollMs: 100,
259
+ signal: controller.signal,
260
+ });
261
+
262
+ // Act
263
+ controller.abort();
264
+
265
+ // Assert
266
+ expect(await waiting).toBe("pending");
267
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
268
+ });
269
+
270
+ test("a finite budget runs out to pending", async () => {
271
+ // Arrange
272
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
273
+
274
+ // Assert
275
+ expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
276
+ });
277
+ });
278
+
279
+ describe("awaitResolve: the adapter wake seam (session id only)", () => {
280
+ test("resolves to the outcome when the verdict lands during the wait", async () => {
281
+ // Arrange
282
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
283
+ const waiting = awaitResolve(client, review.id, { pollMs: 100 });
284
+
285
+ // Act
286
+ await client.sessionResolve(review.id, "approve", "Ship it.");
287
+ const verdict = await waiting;
288
+
289
+ // Assert
290
+ expect(verdict).not.toBeNull();
291
+ expect(verdict!.allow).toBe(true);
292
+ expect(verdict!.feedback).toContain("Ship it.");
293
+ expect(verdict!.session.verdict!.kind).toBe("approve");
294
+ });
295
+
296
+ test("returns the stored verdict immediately when the session already resolved", async () => {
297
+ // Arrange - a detached waiter that only attaches after the human decided
298
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
299
+ await client.sessionResolve(review.id, "request_changes", "One stage only.");
300
+
301
+ // Act
302
+ const verdict = await awaitResolve(client, review.id);
303
+
304
+ // Assert
305
+ expect(verdict).not.toBeNull();
306
+ expect(verdict!.allow).toBe(false);
307
+ expect(verdict!.feedback).toContain("One stage only.");
308
+ });
309
+
310
+ test("returns null when the signal aborts first", async () => {
311
+ // Arrange
312
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
313
+ const controller = new AbortController();
314
+ const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
315
+
316
+ // Act
317
+ controller.abort();
318
+
319
+ // Assert
320
+ expect(await waiting).toBeNull();
321
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
322
+ });
323
+ });
324
+
325
+ describe("awaitResolve: the held wait keeps the daemon alive", () => {
326
+ test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
327
+ // Arrange - a daemon with an aggressive idle timer and its own home
328
+ const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
329
+ let idleExits = 0;
330
+ const idleServer = new DaemonServer({
331
+ home: idleHome,
332
+ idleExitMs: 30,
333
+ onIdleExit: () => idleExits++,
334
+ });
335
+ idleServer.start();
336
+ const waiterClient = await DaemonClient.connect({ home: idleHome });
337
+ try {
338
+ const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
339
+ const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
340
+
341
+ // Act - wait well past the idle window while the review is still pending
342
+ await Bun.sleep(120);
343
+
344
+ // Assert - the parked waiter and the pending session both keep it alive
345
+ expect(idleExits).toBe(0);
346
+ expect((await waiterClient.ping()).pid).toBe(process.pid);
347
+
348
+ // Act - the human returns a verdict; the parked wait collects it
349
+ const resolver = await DaemonClient.connect({ home: idleHome });
350
+ await resolver.sessionResolve(review.id, "approve", "Good to go.");
351
+ resolver.close();
352
+ const verdict = await waiting;
353
+
354
+ // Assert
355
+ expect(verdict!.allow).toBe(true);
356
+ } finally {
357
+ waiterClient.close();
358
+ idleServer.stop();
359
+ rmSync(idleHome, { recursive: true, force: true });
360
+ }
361
+ }, 15_000);
362
+ });
package/src/review.ts ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The shared review core: one open/wait/verdict path for every consumer -
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
6
+ * adapter keeps only two bespoke parts: parsing its host's event shape and
7
+ * serializing the decision in its host's contract.
8
+ */
9
+
10
+ import {
11
+ newAnnotationId,
12
+ type ArtifactType,
13
+ type DiffFileContents,
14
+ type ReviewSession,
15
+ type WorkspaceKey,
16
+ } from "@cueloop/schema";
17
+ import { verdictResponse } from "./api";
18
+ import type { DaemonClient } from "./client";
19
+
20
+ // Adapters and CLI verbs reach the verdict mapping through this module too,
21
+ // so a session obtained outside a ReviewHandle maps the same way.
22
+ export { verdictResponse };
23
+
24
+ async function git(args: string[], cwd: string): Promise<string | null> {
25
+ try {
26
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
27
+ const out = await new Response(proc.stdout).text();
28
+ if ((await proc.exited) !== 0) return null;
29
+ return out.trim();
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** Workspace key resolution: repo root + branch from the cwd. */
36
+ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
37
+ const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
38
+ const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
39
+ return { repoRoot, branch };
40
+ }
41
+
42
+ function firstHeading(markdown: string): string | undefined {
43
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
44
+ return headingMatch?.[1]?.trim();
45
+ }
46
+
47
+ export interface OpenReviewOptions {
48
+ type: ArtifactType;
49
+ content: string;
50
+ /** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
51
+ cwd?: string;
52
+ /** Pre-resolved workspace key; skips git resolution when the caller already has it. */
53
+ workspace?: WorkspaceKey;
54
+ agent?: string;
55
+ /** When set, a resubmit from the same agent session becomes a revision, not a new session. */
56
+ agentSessionId?: string;
57
+ planPath?: string;
58
+ prototypePath?: string;
59
+ pr?: string;
60
+ herdrPane?: string;
61
+ /**
62
+ * Full file contents per changed file for a working-tree diff, carried onto
63
+ * the artifact so hunk curation produces an exactly applyable patch.
64
+ */
65
+ files?: DiffFileContents[];
66
+ /** Defaults to the plan's first markdown heading; diffs get no derived title. */
67
+ title?: string;
68
+ /**
69
+ * Per-file agent notes for diff sessions: the submitting agent's own
70
+ * explanation of each file's change, in dead prose. Stored as annotations
71
+ * with kind "note" anchored at the file path, so they render as regular
72
+ * rail cards and feed the guided walk's agent-note block.
73
+ */
74
+ notes?: ReviewNote[];
75
+ }
76
+
77
+ /** The note contract: one note per changed file, anchored by the file path. */
78
+ export interface ReviewNote {
79
+ path: string;
80
+ body: string;
81
+ }
82
+
83
+ /** Anchor a note at its file: quote = the path, no context selectors. */
84
+ async function attachNotes(
85
+ client: DaemonClient,
86
+ sessionId: string,
87
+ notes: ReviewNote[],
88
+ ): Promise<void> {
89
+ for (const note of notes) {
90
+ await client.sessionAnnotate(sessionId, {
91
+ id: newAnnotationId(),
92
+ kind: "note",
93
+ anchor: { quote: note.path, prefix: "", suffix: "" },
94
+ body: note.body,
95
+ });
96
+ }
97
+ }
98
+
99
+ export interface AwaitVerdictOptions {
100
+ /** Total wait budget; Infinity keeps polling until resolved or aborted. */
101
+ timeoutMs: number;
102
+ /** Chunk length for the poll loop; between chunks the session is re-read for progress. */
103
+ pollMs?: number;
104
+ /** Called with the fresh session after each chunk that is still pending. */
105
+ onProgress?: (session: ReviewSession) => void;
106
+ signal?: AbortSignal;
107
+ }
108
+
109
+ /** A resolved review mapped onto the agent contract, plus the full session. */
110
+ export interface VerdictOutcome {
111
+ allow: boolean;
112
+ feedback: string;
113
+ session: ReviewSession;
114
+ }
115
+
116
+ /** Sentinel distinguishing an abort from any daemon response. */
117
+ const ABORTED = Symbol("aborted");
118
+
119
+ function raceAbort<T>(
120
+ promise: Promise<T>,
121
+ signal: AbortSignal | undefined,
122
+ ): Promise<T | typeof ABORTED> {
123
+ if (!signal) return promise;
124
+ if (signal.aborted) {
125
+ promise.catch(() => {});
126
+ return Promise.resolve(ABORTED);
127
+ }
128
+ return new Promise<T | typeof ABORTED>((resolve, reject) => {
129
+ const onAbort = () => {
130
+ // The daemon request keeps running until the client closes; swallow its
131
+ // eventual rejection so the abort path never leaks an unhandled error.
132
+ promise.catch(() => {});
133
+ resolve(ABORTED);
134
+ };
135
+ signal.addEventListener("abort", onAbort, { once: true });
136
+ promise.then(
137
+ (value) => {
138
+ signal.removeEventListener("abort", onAbort);
139
+ resolve(value);
140
+ },
141
+ (error) => {
142
+ signal.removeEventListener("abort", onAbort);
143
+ reject(error);
144
+ },
145
+ );
146
+ });
147
+ }
148
+
149
+ export class ReviewHandle {
150
+ constructor(
151
+ private readonly client: DaemonClient,
152
+ readonly session: ReviewSession,
153
+ ) {}
154
+
155
+ get id(): string {
156
+ return this.session.id;
157
+ }
158
+
159
+ /**
160
+ * Block on the verdict. "pending" means the budget ran out or the signal
161
+ * aborted - the session stays open and the verdict is collectable later
162
+ * (verdicts outlive waits). With only timeoutMs this is one long-poll;
163
+ * pollMs/onProgress/signal switch to the chunked loop.
164
+ */
165
+ async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
166
+ const { timeoutMs, pollMs, onProgress, signal } = options;
167
+ if (pollMs === undefined && onProgress === undefined && signal === undefined) {
168
+ const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
169
+ return resolved === null ? "pending" : outcome(resolved);
170
+ }
171
+ const chunkMs = pollMs ?? 10_000;
172
+ const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
173
+ for (;;) {
174
+ const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
175
+ if (budget <= 0 || signal?.aborted) return "pending";
176
+ const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
177
+ if (resolved === ABORTED) return "pending";
178
+ if (resolved !== null) return outcome(resolved);
179
+ // Still pending after this chunk: re-read to surface reviewer progress.
180
+ const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
181
+ if (current === ABORTED) return "pending";
182
+ onProgress?.(current);
183
+ }
184
+ }
185
+ }
186
+
187
+ function outcome(session: ReviewSession): VerdictOutcome {
188
+ return { ...verdictResponse(session), session };
189
+ }
190
+
191
+ export interface AwaitResolveOptions {
192
+ /** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
193
+ pollMs?: number;
194
+ /** Abort the wait (the harness session shut down); resolves to null. */
195
+ signal?: AbortSignal;
196
+ }
197
+
198
+ /**
199
+ * Park until a review session resolves, then return the verdict outcome; null
200
+ * when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
201
+ * that opened the review, this needs only a session id - so a background waiter
202
+ * 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
204
+ * 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
206
+ * resolved returns on the first chunk. The held connection also keeps the daemon
207
+ * off its idle-exit path for the whole wait.
208
+ */
209
+ export async function awaitResolve(
210
+ client: DaemonClient,
211
+ sessionId: string,
212
+ options: AwaitResolveOptions = {},
213
+ ): Promise<VerdictOutcome | null> {
214
+ const chunkMs = options.pollMs ?? 30_000;
215
+ const { signal } = options;
216
+ for (;;) {
217
+ if (signal?.aborted) return null;
218
+ const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
219
+ if (resolved === ABORTED) return null;
220
+ if (resolved !== null) return outcome(resolved);
221
+ }
222
+ }
223
+
224
+ /** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
225
+ export async function openReview(
226
+ client: DaemonClient,
227
+ options: OpenReviewOptions,
228
+ ): Promise<ReviewHandle> {
229
+ const cwd = options.cwd ?? process.cwd();
230
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
231
+ // Resubmits from the same agent session become revisions, not new sessions.
232
+ if (options.agentSessionId !== undefined) {
233
+ const existing = (await client.sessionList()).find(
234
+ (candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
235
+ );
236
+ if (existing !== undefined) {
237
+ let revised = await client.sessionSubmitRevision(existing.id, options.content);
238
+ if (options.notes?.length) {
239
+ await attachNotes(client, revised.id, options.notes);
240
+ revised = await client.sessionGet(revised.id);
241
+ }
242
+ return new ReviewHandle(client, revised);
243
+ }
244
+ }
245
+ let session = await client.sessionCreate(workspace, {
246
+ type: options.type,
247
+ content: options.content,
248
+ files: options.files,
249
+ meta: {
250
+ agent: options.agent,
251
+ agentSessionId: options.agentSessionId,
252
+ planPath: options.planPath,
253
+ prototypePath: options.prototypePath,
254
+ pr: options.pr,
255
+ herdrPane: options.herdrPane,
256
+ title: options.title ?? (options.type === "plan" ? firstHeading(options.content) : undefined),
257
+ cwd,
258
+ },
259
+ });
260
+ if (options.notes?.length) {
261
+ await attachNotes(client, session.id, options.notes);
262
+ session = await client.sessionGet(session.id);
263
+ }
264
+ return new ReviewHandle(client, session);
265
+ }