@cueloop/daemon 0.1.0-alpha.97 → 0.1.0-alpha.99

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,306 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Effect, Stream } from "effect";
6
+ import { DaemonServer } from "./server";
7
+ import { DaemonClient } from "./client";
8
+ import { connectOwnerSdk, connectReviewSdk } from "./daemon-sdk";
9
+ import { CueloopSdk } from "./daemon-sdk-effect";
10
+
11
+ import { createTestSdkHarness } from "../../../test/helpers/sdk-harness";
12
+
13
+ test("SDK accepts once, waits for its own submission and cancelling a wait preserves work", async () => {
14
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-"));
15
+ const harness = createTestSdkHarness();
16
+ const server = new DaemonServer({
17
+ home,
18
+ idleExitMs: 0,
19
+ threadAgent: { enabled: true, adapter: harness.adapter },
20
+ });
21
+
22
+ server.start();
23
+ const sdk = await connectOwnerSdk({ home });
24
+
25
+ try {
26
+ const thread = await sdk.threads.create({
27
+ workspace: { repoRoot: home, branch: "main" },
28
+ artifact: { type: "plan", content: "Explain this", meta: {} },
29
+ });
30
+ const input = {
31
+ threadId: thread.id,
32
+ operationId: sdk.ids.operation("ask-once"),
33
+ text: "Explain",
34
+ };
35
+ const accepted = await sdk.agents.prompt(input);
36
+ const duplicate = await sdk.agents.prompt(input);
37
+
38
+ expect(duplicate).toEqual(accepted);
39
+ expect((await sdk.agents.get(thread.id)).submissions).toHaveLength(1);
40
+ await expect(sdk.agents.prompt({ ...input, text: "Different" })).rejects.toMatchObject({
41
+ code: "operation_conflict",
42
+ });
43
+ const cancel = new AbortController();
44
+ const abandonedWait = sdk.agents.wait(accepted, { signal: cancel.signal });
45
+
46
+ cancel.abort();
47
+ await expect(abandonedWait).rejects.toMatchObject({ kind: "cancelled" });
48
+ expect(harness.cancelled()).toBe(0);
49
+ const completed = sdk.agents.wait(accepted);
50
+
51
+ harness.complete();
52
+ expect((await completed).outcome).toBe("completed");
53
+ const message = {
54
+ threadId: thread.id,
55
+ operationId: sdk.ids.operation("approve-once"),
56
+ outcome: "approved" as const,
57
+ summary: "Good",
58
+ };
59
+ const sent = await sdk.sessions.sendMessage(message);
60
+
61
+ expect(await sdk.sessions.sendMessage(message)).toEqual(sent);
62
+ } finally {
63
+ sdk.close();
64
+ server.stop();
65
+ rmSync(home, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("Effect scope supports review comments and typed owner-only capabilities", async () => {
70
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-effect-"));
71
+ const server = new DaemonServer({ home, idleExitMs: 0 });
72
+
73
+ server.start();
74
+
75
+ try {
76
+ const program = Effect.gen(function* () {
77
+ const sdk = yield* CueloopSdk;
78
+ const thread = yield* sdk.threads.create({
79
+ workspace: { repoRoot: home, branch: "main" },
80
+ artifact: { type: "plan", content: "Discuss this", meta: {} },
81
+ });
82
+ const snapshots = yield* sdk.threads.watch(thread.id).pipe(Stream.take(1), Stream.runCollect);
83
+
84
+ expect(snapshots[0]?.id).toBe(thread.id);
85
+ const client = yield* Effect.promise(() => DaemonClient.connect({ home }));
86
+
87
+ try {
88
+ yield* Effect.promise(() =>
89
+ client.sessionComment(thread.id, {
90
+ id: "comment",
91
+ kind: "comment",
92
+ anchor: { quote: "Discuss", prefix: "", suffix: " this" },
93
+ body: "Why?",
94
+ }),
95
+ );
96
+ } finally {
97
+ client.close();
98
+ }
99
+ yield* sdk.comments.reply({
100
+ threadId: thread.id,
101
+ commentId: "comment",
102
+ body: "Because",
103
+ replyId: "reply",
104
+ });
105
+ const comments = yield* sdk.comments.list(thread.id);
106
+
107
+ expect(comments.at(-1)).toMatchObject({ id: "reply", replyTo: "comment", body: "Because" });
108
+
109
+ return thread.id;
110
+ });
111
+ const id = await Effect.runPromise(
112
+ program.pipe(Effect.provide(CueloopSdk.ownerLayer({ home }))),
113
+ );
114
+ const review = await connectReviewSdk({ home, role: "agent", author: "review-agent" });
115
+
116
+ try {
117
+ expect((await review.threads.get(id)).id).toBe(id);
118
+ expect("agents" in review).toBe(false);
119
+ const capped = await DaemonClient.connect({ home, role: "agent", author: "review-agent" });
120
+
121
+ try {
122
+ await expect(capped.agentPrompt({ id, text: "Forbidden" })).rejects.toMatchObject({
123
+ code: "forbidden",
124
+ });
125
+ } finally {
126
+ capped.close();
127
+ }
128
+ } finally {
129
+ review.close();
130
+ }
131
+ } finally {
132
+ server.stop();
133
+ rmSync(home, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ test("lost response after message commit is uncertain and an explicit retry survives server restart", async () => {
138
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-lost-response-"));
139
+ let server = new DaemonServer({ home, idleExitMs: 0 });
140
+
141
+ server.start();
142
+ const connection = await DaemonClient.connect({ home });
143
+
144
+ try {
145
+ const thread = await connection.sessionCreate(
146
+ { repoRoot: home, branch: "main" },
147
+ { type: "plan", content: "Approve this", meta: {} },
148
+ );
149
+ const send = server.core.sessionSendMessage.bind(server.core);
150
+
151
+ server.core.sessionSendMessage = (...args) => {
152
+ const result = send(...args);
153
+
154
+ connection.close();
155
+
156
+ return result;
157
+ };
158
+ await expect(
159
+ connection.sessionSendMessage(thread.id, "approved", "Done", undefined, "lost-ack"),
160
+ ).rejects.toMatchObject({ kind: "connection", certainty: "unknown" });
161
+ const original = server.core.sessionGet(thread.id).message;
162
+
163
+ server.stop();
164
+ server = new DaemonServer({ home, idleExitMs: 0 });
165
+ server.start();
166
+ const sdk = await connectOwnerSdk({ home });
167
+
168
+ try {
169
+ expect(
170
+ await sdk.sessions.sendMessage({
171
+ threadId: sdk.ids.thread(thread.id),
172
+ operationId: sdk.ids.operation("lost-ack"),
173
+ outcome: "approved",
174
+ summary: "Done",
175
+ }),
176
+ ).toEqual(original!);
177
+ expect(
178
+ (await sdk.threads.get(sdk.ids.thread(thread.id))).history?.entries.filter(
179
+ (entry) => entry.type === "message",
180
+ ),
181
+ ).toHaveLength(1);
182
+ } finally {
183
+ sdk.close();
184
+ }
185
+ } finally {
186
+ connection.close();
187
+ server.stop();
188
+ rmSync(home, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test("Effect interruption aborts the completion wait without stopping the harness", async () => {
193
+ const { Fiber } = await import("effect");
194
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-interrupt-"));
195
+ const harness = createTestSdkHarness();
196
+ const server = new DaemonServer({
197
+ home,
198
+ idleExitMs: 0,
199
+ threadAgent: { enabled: true, adapter: harness.adapter },
200
+ });
201
+
202
+ server.start();
203
+
204
+ try {
205
+ const program = Effect.gen(function* () {
206
+ const sdk = yield* CueloopSdk;
207
+ const thread = yield* sdk.threads.create({
208
+ workspace: { repoRoot: home, branch: "main" },
209
+ artifact: { type: "plan", content: "Explain", meta: {} },
210
+ });
211
+ const accepted = yield* sdk.agents.prompt({
212
+ threadId: thread.id,
213
+ operationId: sdk.ids.operation("interrupt"),
214
+ text: "Explain",
215
+ });
216
+ const fiber = yield* Effect.forkChild(sdk.agents.wait(accepted));
217
+
218
+ yield* Effect.yieldNow;
219
+ yield* Fiber.interrupt(fiber);
220
+ expect(harness.cancelled()).toBe(0);
221
+ yield* Effect.sync(() => harness.complete());
222
+ const completed = yield* sdk.agents.wait(accepted);
223
+
224
+ expect(completed.outcome).toBe("completed");
225
+ });
226
+
227
+ await Effect.runPromise(program.pipe(Effect.provide(CueloopSdk.ownerLayer({ home }))));
228
+ } finally {
229
+ server.stop();
230
+ rmSync(home, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ test("lost prompt acknowledgement replays original acceptance after restart without a harness", async () => {
235
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-lost-prompt-"));
236
+ const harness = createTestSdkHarness();
237
+ let server = new DaemonServer({
238
+ home,
239
+ idleExitMs: 0,
240
+ threadAgent: { enabled: true, adapter: harness.adapter },
241
+ });
242
+
243
+ server.start();
244
+ const connection = await DaemonClient.connect({ home });
245
+
246
+ try {
247
+ const thread = await connection.sessionCreate(
248
+ { repoRoot: home, branch: "main" },
249
+ { type: "plan", content: "Explain", meta: {} },
250
+ );
251
+
252
+ await connection.subscribe();
253
+ connection.onEvent((event) => {
254
+ if (event.event === "agent.updated") connection.close();
255
+ });
256
+ await expect(
257
+ connection.agentPrompt({ id: thread.id, text: "Once", operationId: "lost-prompt" }),
258
+ ).rejects.toMatchObject({ kind: "connection", certainty: "unknown" });
259
+ const inspect = await DaemonClient.connect({ home });
260
+ const accepted = await inspect.agentGet(thread.id);
261
+
262
+ inspect.close();
263
+ server.stop();
264
+ server = new DaemonServer({ home, idleExitMs: 0, threadAgent: { enabled: true } });
265
+ server.start();
266
+ const sdk = await connectOwnerSdk({ home });
267
+
268
+ try {
269
+ const replay = await sdk.agents.prompt({
270
+ threadId: sdk.ids.thread(thread.id),
271
+ operationId: sdk.ids.operation("lost-prompt"),
272
+ text: "Once",
273
+ });
274
+
275
+ expect(replay.submissionIds).toEqual(
276
+ accepted.promptOperations![0]!.result.map(sdk.ids.submission),
277
+ );
278
+ expect((await sdk.agents.get(sdk.ids.thread(thread.id))).submissions).toHaveLength(1);
279
+ expect((await sdk.agents.wait(replay)).outcome).toBe("failed");
280
+ } finally {
281
+ sdk.close();
282
+ }
283
+ } finally {
284
+ connection.close();
285
+ server.stop();
286
+ rmSync(home, { recursive: true, force: true });
287
+ }
288
+ });
289
+
290
+ test("SDK identity types distinguish Thread, operation, and submission capabilities", () => {
291
+ type ThreadId = import("./daemon-sdk").SdkThreadId;
292
+ type OperationId = import("./daemon-sdk").SdkOperationId;
293
+ type SubmissionId = import("./daemon-sdk").SdkSubmissionId;
294
+ type Review = import("./daemon-sdk").ReviewDaemonSdk;
295
+ const operationIsThread: OperationId extends ThreadId ? true : false = false;
296
+ const submissionIsOperation: SubmissionId extends OperationId ? true : false = false;
297
+ const reviewHasAgent: "agents" extends keyof Review ? true : false = false;
298
+ const reviewCreatesThread: "create" extends keyof Review["threads"] ? true : false = false;
299
+
300
+ expect([operationIsThread, submissionIsOperation, reviewHasAgent, reviewCreatesThread]).toEqual([
301
+ false,
302
+ false,
303
+ false,
304
+ false,
305
+ ]);
306
+ });
@@ -0,0 +1,397 @@
1
+ import * as v from "valibot";
2
+ import {
3
+ newAnnotationId,
4
+ type Annotation,
5
+ type Artifact,
6
+ type Message,
7
+ type MessageOutcome,
8
+ type Thread,
9
+ type ThreadAgentState,
10
+ type WorkspaceKey,
11
+ } from "@cueloop/schema";
12
+ import { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
13
+ import { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
14
+ import {
15
+ createSdkThreadId,
16
+ createSdkOperationId,
17
+ createSdkSubmissionId,
18
+ type SdkThreadId,
19
+ type SdkOperationId,
20
+ type SdkSubmissionId,
21
+ } from "./sdk-identities";
22
+ export {
23
+ createSdkThreadId,
24
+ createSdkOperationId,
25
+ createSdkSubmissionId,
26
+ type SdkThreadId,
27
+ type SdkOperationId,
28
+ type SdkSubmissionId,
29
+ } from "./sdk-identities";
30
+ import { ThreadRecordSchema } from "./validate";
31
+ import { ThreadAgentSchema } from "./thread-agent-validation";
32
+ import { connectThreadObserver, subscribeThreadState } from "./thread-subscription";
33
+
34
+ /** Validated SDK Thread reads carry a distinct identity without changing the wire model. */
35
+ export type SdkThread = Thread & { id: SdkThreadId };
36
+ const SdkThreadRecordSchema = v.pipe(
37
+ ThreadRecordSchema,
38
+ v.transform((thread): SdkThread => ({ ...thread, id: createSdkThreadId(thread.id) })),
39
+ );
40
+
41
+ /** These errors preserve daemon rejection codes and uncertainty after a lost response. */
42
+ export type DaemonSdkError = DaemonClientError | DaemonTransportError;
43
+
44
+ /** Acceptance identifies the exact batch; idle Thread state alone does not prove completion. */
45
+ export interface AgentAcceptance {
46
+ threadId: SdkThreadId;
47
+ operationId: SdkOperationId;
48
+ submissionIds: readonly SdkSubmissionId[];
49
+ }
50
+
51
+ /** Explicit cancellation and failed execution remain distinct terminal outcomes. */
52
+ export interface AgentCompletion extends AgentAcceptance {
53
+ outcome: "completed" | "failed" | "cancelled";
54
+ state: ThreadAgentState;
55
+ }
56
+
57
+ /** A review capability cannot create Threads, submit agents, or resolve reviews. */
58
+ export class ReviewDaemonSdk {
59
+ readonly ids = {
60
+ thread: createSdkThreadId,
61
+ operation: createSdkOperationId,
62
+ submission: createSdkSubmissionId,
63
+ };
64
+
65
+ protected closed = false;
66
+ private readonly observers = new Set<() => void>();
67
+
68
+ constructor(
69
+ protected readonly client: DaemonClient,
70
+ protected readonly connectionOptions: ConnectOptions = {},
71
+ ) {}
72
+
73
+ /** Snapshot observation owns a separate connection and never replays mutations. */
74
+ observeThread(input: {
75
+ threadId: SdkThreadId;
76
+ onValue: (thread: SdkThread) => void;
77
+ onError: (error: DaemonSdkError) => void;
78
+ }): () => void {
79
+ if (this.closed) {
80
+ input.onError(new DaemonTransportError("connection", "SDK connection is closed", "not_sent"));
81
+
82
+ return () => {};
83
+ }
84
+ const dispose = subscribeThreadState({
85
+ connect: connectThreadObserver(this.connectionOptions),
86
+ matches: (event) => event.sessionId === input.threadId,
87
+ read: (client, signal) =>
88
+ client.request("session.get", { id: input.threadId }, SdkThreadRecordSchema, { signal }),
89
+ onValue: input.onValue,
90
+ onError: (error) => input.onError(normalizeSdkError(error)),
91
+ });
92
+ const stop = () => {
93
+ dispose();
94
+ this.observers.delete(stop);
95
+ };
96
+
97
+ this.observers.add(stop);
98
+
99
+ return stop;
100
+ }
101
+
102
+ get threads() {
103
+ return {
104
+ get: (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<SdkThread> =>
105
+ this.client.request("session.get", { id: threadId }, SdkThreadRecordSchema, options),
106
+ };
107
+ }
108
+
109
+ readonly comments = {
110
+ add: (
111
+ input: { threadId: SdkThreadId; annotation: Parameters<DaemonClient["sessionComment"]>[1] },
112
+ options?: DaemonRequestOptions,
113
+ ): Promise<SdkThread> =>
114
+ this.client.request(
115
+ "session.comment",
116
+ { id: input.threadId, annotation: input.annotation },
117
+ SdkThreadRecordSchema,
118
+ options,
119
+ ),
120
+ list: async (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<Annotation[]> =>
121
+ (await this.threads.get(threadId, options)).annotations,
122
+ reply: async (
123
+ input: { threadId: SdkThreadId; commentId: string; body: string; replyId?: string },
124
+ options?: DaemonRequestOptions,
125
+ ): Promise<SdkThread> => {
126
+ const thread = await this.threads.get(input.threadId, options);
127
+ const comment = thread.annotations.find((entry) => entry.id === input.commentId);
128
+
129
+ if (!comment)
130
+ throw new DaemonClientError("not_found", "SDK comment reply target does not exist");
131
+ const rootId = comment.replyTo ?? comment.id;
132
+ const root = thread.annotations.find((entry) => entry.id === rootId);
133
+
134
+ if (!root) throw new DaemonClientError("not_found", "SDK comment reply root does not exist");
135
+
136
+ return this.client.request(
137
+ "session.comment",
138
+ {
139
+ id: input.threadId,
140
+ annotation: {
141
+ id: input.replyId ?? newAnnotationId(),
142
+ kind: "comment",
143
+ anchor: root.anchor,
144
+ target: root.target,
145
+ replyTo: root.id,
146
+ body: input.body,
147
+ },
148
+ },
149
+ SdkThreadRecordSchema,
150
+ options,
151
+ );
152
+ },
153
+ };
154
+
155
+ close(): void {
156
+ this.closed = true;
157
+ for (const stop of this.observers) stop();
158
+ this.client.close();
159
+ }
160
+ }
161
+
162
+ /** Owner methods still require daemon token authorization and the experimental agent flag. */
163
+ export class OwnerDaemonSdk extends ReviewDaemonSdk {
164
+ get threads() {
165
+ return {
166
+ ...super.threads,
167
+ create: (
168
+ input: { workspace: WorkspaceKey; artifact: Artifact },
169
+ options?: DaemonRequestOptions,
170
+ ): Promise<SdkThread> =>
171
+ this.client.request("session.create", input, SdkThreadRecordSchema, options),
172
+ };
173
+ }
174
+
175
+ readonly sessions = {
176
+ sendMessage: async (
177
+ input: {
178
+ threadId: SdkThreadId;
179
+ operationId: SdkOperationId;
180
+ outcome: MessageOutcome;
181
+ summary: string;
182
+ actionBodies?: Record<string, string>;
183
+ },
184
+ options?: DaemonRequestOptions,
185
+ ): Promise<Message> => {
186
+ const thread = await this.client.sessionSendMessage(
187
+ input.threadId,
188
+ input.outcome,
189
+ input.summary,
190
+ input.actionBodies,
191
+ input.operationId,
192
+ options,
193
+ );
194
+
195
+ if (!thread.message)
196
+ throw new DaemonTransportError(
197
+ "protocol",
198
+ "SDK accepted review message is missing",
199
+ "unknown",
200
+ "session.sendMessage",
201
+ );
202
+
203
+ return thread.message;
204
+ },
205
+ };
206
+
207
+ readonly agents = {
208
+ get: (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<ThreadAgentState> =>
209
+ this.client.request("agent.get", { id: threadId }, ThreadAgentSchema, options),
210
+ prompt: async (
211
+ input: {
212
+ threadId: SdkThreadId;
213
+ operationId: SdkOperationId;
214
+ text: string;
215
+ context?: string;
216
+ retry?: SdkSubmissionId;
217
+ },
218
+ options?: DaemonRequestOptions,
219
+ ): Promise<AgentAcceptance> => {
220
+ const state = await this.client.agentPrompt(
221
+ {
222
+ id: input.threadId,
223
+ operationId: input.operationId,
224
+ text: input.text,
225
+ context: input.context,
226
+ retry: input.retry,
227
+ },
228
+ options,
229
+ );
230
+ const receipt = state.promptOperations?.find(
231
+ (entry) => entry.operationId === input.operationId,
232
+ );
233
+
234
+ if (!receipt)
235
+ throw new DaemonTransportError(
236
+ "protocol",
237
+ "SDK accepted agent operation receipt is missing",
238
+ "unknown",
239
+ "agent.prompt",
240
+ );
241
+
242
+ return {
243
+ threadId: input.threadId,
244
+ operationId: input.operationId,
245
+ submissionIds: receipt.result.map(createSdkSubmissionId),
246
+ };
247
+ },
248
+ replyToComment: (
249
+ input: { threadId: SdkThreadId; commentId: string; body: string },
250
+ options?: DaemonRequestOptions,
251
+ ): Promise<ThreadAgentState> =>
252
+ this.client.request(
253
+ "agent.reply",
254
+ { id: input.threadId, commentId: input.commentId, body: input.body },
255
+ ThreadAgentSchema,
256
+ options,
257
+ ),
258
+ wait: (accepted: AgentAcceptance, options?: DaemonRequestOptions): Promise<AgentCompletion> =>
259
+ this.waitAgentCompletion(accepted, options),
260
+ cancel: (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<ThreadAgentState> =>
261
+ this.client.request("agent.cancel", { id: threadId }, ThreadAgentSchema, options),
262
+ };
263
+
264
+ private readonly waits = new Set<AbortController>();
265
+
266
+ override close(): void {
267
+ this.closed = true;
268
+ for (const wait of this.waits) wait.abort();
269
+ super.close();
270
+ }
271
+
272
+ private waitAgentCompletion(
273
+ accepted: AgentAcceptance,
274
+ options: DaemonRequestOptions = {},
275
+ ): Promise<AgentCompletion> {
276
+ if (this.closed)
277
+ return Promise.reject(
278
+ new DaemonTransportError("connection", "SDK connection is closed", "not_sent"),
279
+ );
280
+ if (!accepted.submissionIds.length)
281
+ return Promise.reject(
282
+ new DaemonClientError("invalid_params", "SDK wait requires accepted submissions"),
283
+ );
284
+
285
+ const controller = new AbortController();
286
+
287
+ this.waits.add(controller);
288
+ const signal = options.signal
289
+ ? AbortSignal.any([controller.signal, options.signal])
290
+ : controller.signal;
291
+
292
+ return new Promise((resolve, reject) => {
293
+ let done = false;
294
+ let detach: (() => void) | undefined;
295
+ const finish = (result?: AgentCompletion, error?: DaemonSdkError) => {
296
+ if (done) return;
297
+ done = true;
298
+ clearTimeout(timer);
299
+ signal.removeEventListener("abort", abort);
300
+ this.waits.delete(controller);
301
+ detach?.();
302
+ if (result) resolve(result);
303
+ else reject(error);
304
+ };
305
+ const abort = () =>
306
+ finish(
307
+ undefined,
308
+ new DaemonTransportError("cancelled", "SDK agent wait cancelled locally", "not_sent"),
309
+ );
310
+ const timer = setTimeout(
311
+ () =>
312
+ finish(
313
+ undefined,
314
+ new DaemonTransportError(
315
+ "timeout",
316
+ "SDK agent completion deadline elapsed",
317
+ "not_sent",
318
+ ),
319
+ ),
320
+ options.timeoutMs ?? 30_000,
321
+ );
322
+
323
+ signal.addEventListener("abort", abort, { once: true });
324
+ if (signal.aborted) {
325
+ abort();
326
+
327
+ return;
328
+ }
329
+ detach = subscribeThreadState({
330
+ connect: connectThreadObserver(this.connectionOptions),
331
+ matches: (event) =>
332
+ event.event === "agent.updated" && event.sessionId === accepted.threadId,
333
+ read: (client, readSignal) =>
334
+ client.request("agent.get", { id: accepted.threadId }, ThreadAgentSchema, {
335
+ signal: readSignal,
336
+ }),
337
+ onValue: (state) => {
338
+ const receipt = state.promptOperations?.find(
339
+ (entry) => entry.operationId === accepted.operationId,
340
+ );
341
+
342
+ if (!receipt || receipt.result.join("\n") !== accepted.submissionIds.join("\n")) {
343
+ finish(
344
+ undefined,
345
+ new DaemonClientError("not_found", "SDK accepted agent operation no longer exists"),
346
+ );
347
+
348
+ return;
349
+ }
350
+ if (receipt.outcome) finish({ ...accepted, outcome: receipt.outcome, state });
351
+ },
352
+ onError: (error) => {
353
+ if (
354
+ error instanceof DaemonClientError ||
355
+ (error instanceof DaemonTransportError && error.kind === "protocol")
356
+ )
357
+ finish(undefined, normalizeSdkError(error));
358
+ },
359
+ });
360
+ });
361
+ }
362
+
363
+ constructor(client: DaemonClient, connectionOptions: ConnectOptions) {
364
+ super(client, connectionOptions);
365
+ }
366
+ }
367
+
368
+ /** Choose capabilities at connection time; the daemon remains the authority. */
369
+ export async function connectOwnerSdk(
370
+ options: Omit<ConnectOptions, "role"> = {},
371
+ ): Promise<OwnerDaemonSdk> {
372
+ return new OwnerDaemonSdk(await DaemonClient.connect({ ...options, role: "owner" }), {
373
+ ...options,
374
+ role: "owner",
375
+ });
376
+ }
377
+
378
+ /** Collaborators and review agents expose only shared review operations. */
379
+ export async function connectReviewSdk(
380
+ options: ConnectOptions & { role: "collaborator" | "agent" },
381
+ ): Promise<ReviewDaemonSdk> {
382
+ return new ReviewDaemonSdk(await DaemonClient.connect(options), options);
383
+ }
384
+
385
+ /** Preserve typed failures; unexpected transport exceptions carry their original cause. */
386
+ // eslint-disable-next-line type-evidence/no-unknown-parameters -- Promise rejection is an untyped transport boundary.
387
+ export function normalizeSdkError(error: unknown): DaemonSdkError {
388
+ if (error instanceof DaemonClientError || error instanceof DaemonTransportError) return error;
389
+
390
+ return new DaemonTransportError(
391
+ "connection",
392
+ "SDK daemon operation failed",
393
+ "unknown",
394
+ undefined,
395
+ { cause: error },
396
+ );
397
+ }