@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.
package/src/client.ts CHANGED
@@ -57,7 +57,11 @@ import {
57
57
  PendingDeliverySchema,
58
58
  } from "./validate";
59
59
  import type { WorkingTreeDiff } from "./working-tree";
60
+ import { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
61
+ export { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
60
62
  import { DAEMON_VERSION } from "./version";
63
+ import { ThreadAgentSchema } from "./thread-agent-validation";
64
+ import type { AgentComment, ThreadAgentState, AgentPromptRequest } from "@cueloop/schema";
61
65
 
62
66
  export type { EventFrame } from "./protocol";
63
67
 
@@ -101,6 +105,8 @@ export interface ThreadClient {
101
105
  onEvent(listener: (event: EventFrame) => void): () => void;
102
106
  subscribe(): Promise<void>;
103
107
  sessionGet(id: string): Promise<Thread>;
108
+ /** Create a local owner Thread; shared clients omit this capability. */
109
+ sessionCreate?(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
104
110
  sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
105
111
  /** Add a comment; the primary annotate method. `sessionAnnotate` is the retained alias. */
106
112
  sessionComment(
@@ -222,7 +228,8 @@ export class DaemonClient implements ThreadClient {
222
228
  private daemonVersion: string | undefined;
223
229
  private nextId = 1;
224
230
  private eventListeners = new Set<(event: EventFrame) => void>();
225
- private disconnectListeners = new Set<() => void>();
231
+ private disconnectListeners = new Set<(reason: DaemonTransportError) => void>();
232
+ private disconnectReason: DaemonTransportError | undefined;
226
233
  private closed = false;
227
234
  private role: DaemonRole = "owner";
228
235
  private author: string | undefined;
@@ -251,7 +258,15 @@ export class DaemonClient implements ThreadClient {
251
258
  if (!options.autostart || err instanceof DaemonClientError) {
252
259
  client.close();
253
260
 
254
- throw err;
261
+ throw err instanceof DaemonClientError || err instanceof DaemonTransportError
262
+ ? err
263
+ : new DaemonTransportError(
264
+ "connection",
265
+ "Daemon connection failed",
266
+ "not_sent",
267
+ undefined,
268
+ { cause: err },
269
+ );
255
270
  }
256
271
  }
257
272
 
@@ -266,7 +281,9 @@ export class DaemonClient implements ThreadClient {
266
281
  this.writer = null;
267
282
  this.closed = false;
268
283
  for (const pendingRequest of this.pending.values())
269
- pendingRequest.reject(new Error("daemon connection replaced"));
284
+ pendingRequest.reject(
285
+ new DaemonTransportError("connection", "daemon connection replaced", "unknown"),
286
+ );
270
287
  this.pending.clear();
271
288
  }
272
289
 
@@ -306,7 +323,11 @@ export class DaemonClient implements ThreadClient {
306
323
  const output = readFileSync(startupLogPath, "utf8").trim();
307
324
  const detail = output ? `\ndaemon startup output:\n${output.slice(-4_096)}` : "";
308
325
 
309
- throw new Error(`daemon did not come up at ${path}: ${String(lastError)}${detail}`);
326
+ throw new DaemonTransportError(
327
+ "connection",
328
+ `Daemon startup failed at ${path}: ${String(lastError)}${detail}`,
329
+ "not_sent",
330
+ );
310
331
  } finally {
311
332
  rmSync(startupLogPath, { force: true });
312
333
  }
@@ -314,15 +335,23 @@ export class DaemonClient implements ThreadClient {
314
335
 
315
336
  private async dial(path: string): Promise<void> {
316
337
  const buffer = new LineBuffer();
338
+
339
+ this.disconnectReason = undefined;
317
340
  const epoch = ++this.connectionEpoch;
318
341
 
319
342
  const close = () => {
320
343
  if (this.connectionEpoch !== epoch) return;
321
344
  this.closed = true;
322
345
  for (const pendingRequest of this.pending.values())
323
- pendingRequest.reject(new Error("daemon connection closed"));
346
+ pendingRequest.reject(
347
+ new DaemonTransportError("connection", "daemon connection closed", "unknown"),
348
+ );
324
349
  this.pending.clear();
325
- for (const listener of this.disconnectListeners) listener();
350
+ const reason =
351
+ this.disconnectReason ??
352
+ new DaemonTransportError("connection", "daemon connection closed", "unknown");
353
+
354
+ for (const listener of this.disconnectListeners) listener(reason);
326
355
  };
327
356
 
328
357
  if (typeof Bun !== "undefined") {
@@ -395,7 +424,7 @@ export class DaemonClient implements ThreadClient {
395
424
  }
396
425
 
397
426
  /** Notify an adapter when its daemon socket closes so it can reconnect and replay Messages. */
398
- onDisconnect(listener: () => void): () => void {
427
+ onDisconnect(listener: (reason: DaemonTransportError) => void): () => void {
399
428
  this.disconnectListeners.add(listener);
400
429
 
401
430
  return () => this.disconnectListeners.delete(listener);
@@ -406,7 +435,27 @@ export class DaemonClient implements ThreadClient {
406
435
 
407
436
  try {
408
437
  frame = parseInboundFrame(line);
409
- } catch {
438
+ } catch (cause) {
439
+ this.disconnectReason = new DaemonTransportError(
440
+ "protocol",
441
+ "daemon sent a malformed frame",
442
+ "unknown",
443
+ undefined,
444
+ { cause },
445
+ );
446
+ for (const pendingRequest of this.pending.values())
447
+ pendingRequest.reject(
448
+ new DaemonTransportError(
449
+ "protocol",
450
+ "daemon sent a malformed frame",
451
+ "unknown",
452
+ undefined,
453
+ { cause },
454
+ ),
455
+ );
456
+ this.pending.clear();
457
+ this.close();
458
+
410
459
  return;
411
460
  }
412
461
  if ("event" in frame) {
@@ -427,37 +476,135 @@ export class DaemonClient implements ThreadClient {
427
476
  method: string,
428
477
  params: Request["params"],
429
478
  resultSchema: v.GenericSchema<unknown, TOutput>,
430
- timeoutMs = 30_000,
479
+ options: number | DaemonRequestOptions = 30_000,
431
480
  ): Promise<TOutput> {
432
- if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
481
+ const { timeoutMs = 30_000, signal } = v.is(v.number(), options)
482
+ ? { timeoutMs: options }
483
+ : options;
484
+
485
+ if (signal?.aborted)
486
+ return Promise.reject(
487
+ new DaemonTransportError("cancelled", `request ${method} cancelled`, "not_sent", method),
488
+ );
489
+ if (this.closed || !this.socket)
490
+ return Promise.reject(
491
+ new DaemonTransportError("connection", "not connected", "not_sent", method),
492
+ );
433
493
  const id = this.nextId++;
434
494
 
435
495
  return new Promise<TOutput>((resolve, reject) => {
436
- const timer = setTimeout(() => {
496
+ const cleanup = () => {
497
+ clearTimeout(timer);
498
+ signal?.removeEventListener("abort", abort);
437
499
  this.pending.delete(id);
438
- reject(new Error(`request ${method} timed out`));
500
+ };
501
+ const fail = (error: Error) => {
502
+ cleanup();
503
+ reject(error);
504
+ };
505
+ const abort = () =>
506
+ fail(
507
+ new DaemonTransportError("cancelled", `request ${method} cancelled`, "unknown", method),
508
+ );
509
+ const timer = setTimeout(() => {
510
+ fail(new DaemonTransportError("timeout", `request ${method} timed out`, "unknown", method));
439
511
  }, timeoutMs);
440
512
 
441
513
  this.pending.set(id, {
442
514
  resolve: (value) => {
443
- clearTimeout(timer);
444
- resolve(v.parse(resultSchema, value));
445
- },
446
- reject: (error) => {
447
- clearTimeout(timer);
448
- reject(error);
515
+ cleanup();
516
+ try {
517
+ const parsed = v.safeParse(resultSchema, value);
518
+
519
+ if (!parsed.success)
520
+ throw new DaemonTransportError(
521
+ "protocol",
522
+ `request ${method} returned an invalid result`,
523
+ "unknown",
524
+ method,
525
+ { cause: parsed.issues },
526
+ );
527
+ resolve(parsed.output);
528
+ } catch (cause) {
529
+ reject(
530
+ cause instanceof DaemonTransportError
531
+ ? cause
532
+ : new DaemonTransportError(
533
+ "protocol",
534
+ `request ${method} returned an invalid result`,
535
+ "unknown",
536
+ method,
537
+ { cause },
538
+ ),
539
+ );
540
+ }
449
541
  },
542
+ reject: fail,
450
543
  });
451
- this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
544
+ signal?.addEventListener("abort", abort, { once: true });
545
+ try {
546
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
547
+ } catch (cause) {
548
+ fail(
549
+ new DaemonTransportError(
550
+ "connection",
551
+ `request ${method} could not be written`,
552
+ "unknown",
553
+ method,
554
+ { cause },
555
+ ),
556
+ );
557
+ }
452
558
  });
453
559
  }
454
560
 
455
561
  close(): void {
456
562
  this.closed = true;
563
+ for (const pendingRequest of this.pending.values())
564
+ pendingRequest.reject(
565
+ new DaemonTransportError("connection", "daemon connection closed", "unknown"),
566
+ );
567
+ this.pending.clear();
457
568
  this.socket?.end();
458
569
  }
459
570
 
460
571
  // ── typed primitives ─────────────────────────────
572
+ /** Read the agent transcript without starting a model request. */
573
+ agentGet(id: string, options?: DaemonRequestOptions): Promise<ThreadAgentState> {
574
+ return this.request("agent.get", { id }, ThreadAgentSchema, options);
575
+ }
576
+ /** Submit a question and optional selected passage to the daemon-owned agent. */
577
+ agentPrompt(
578
+ params: AgentPromptRequest,
579
+ options?: DaemonRequestOptions,
580
+ ): Promise<ThreadAgentState> {
581
+ return this.request("agent.prompt", params, ThreadAgentSchema, options);
582
+ }
583
+ /** Cancel the active turn; the daemon retains partial output. */
584
+ agentCancel(id: string): Promise<ThreadAgentState> {
585
+ return this.request("agent.cancel", { id }, ThreadAgentSchema);
586
+ }
587
+ /** Save quote-primary feedback on a finalized agent answer. */
588
+ agentComment(params: { id: string; comment: AgentComment }): Promise<ThreadAgentState> {
589
+ return this.request("agent.comment", params, ThreadAgentSchema);
590
+ }
591
+ /** Configure only an option advertised by the current harness. */
592
+ agentConfigure(params: {
593
+ id: string;
594
+ configId?: string;
595
+ value?: string;
596
+ }): Promise<ThreadAgentState> {
597
+ return this.request("agent.configure", params, ThreadAgentSchema);
598
+ }
599
+
600
+ /** Select an option from the active agent permission card. */
601
+ agentPermission(params: {
602
+ id: string;
603
+ requestId: string;
604
+ optionId: string;
605
+ }): Promise<ThreadAgentState> {
606
+ return this.request("agent.permission", params, ThreadAgentSchema);
607
+ }
461
608
  ping(): Promise<{ pid: number }> {
462
609
  return this.request("daemon.ping", {}, PingResultSchema);
463
610
  }
@@ -639,11 +786,14 @@ export class DaemonClient implements ThreadClient {
639
786
  outcome: MessageOutcome,
640
787
  summary: string,
641
788
  actionBodies?: Record<string, string>,
789
+ operationId?: string,
790
+ options?: DaemonRequestOptions,
642
791
  ): Promise<Thread> {
643
792
  return this.request(
644
793
  "session.sendMessage",
645
- { id, outcome, summary, actionBodies },
794
+ { id, outcome, summary, actionBodies, operationId },
646
795
  ThreadRecordSchema,
796
+ options,
647
797
  );
648
798
  }
649
799
  harnessBind(
@@ -722,6 +872,7 @@ export class DaemonClient implements ThreadClient {
722
872
  }
723
873
 
724
874
  export class DaemonClientError extends Error {
875
+ readonly _tag = "DaemonClientError";
725
876
  constructor(
726
877
  readonly code: string,
727
878
  message: string,
@@ -731,7 +882,7 @@ export class DaemonClientError extends Error {
731
882
  }
732
883
 
733
884
  // A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
734
- // unlike the never-exiting foreground daemon); from source, bun runs main.ts. In
885
+ // unlike the never-exiting foreground daemon); source also uses the CLI composition root. In
735
886
  // dev (CUELOOP_DEV_WATCH=1, source only) it runs under --watch so daemon-code edits
736
887
  // reload the daemon without a manual restart - the version handshake only catches
737
888
  // release upgrades, not same-version source changes.
@@ -739,14 +890,19 @@ export function daemonSpawnCommand(
739
890
  execPath: string,
740
891
  moduleUrl: string,
741
892
  devWatch = process.env.CUELOOP_DEV_WATCH === "1",
893
+ entry = process.env.CUELOOP_DAEMON_ENTRY,
742
894
  ): string[] {
743
895
  const compiled =
744
896
  moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
745
897
 
746
898
  if (compiled) return [execPath, "daemon", "--autostart"];
747
- const mainPath = new URL("./main.ts", moduleUrl).pathname;
899
+ const mainPath = entry ?? new URL("./main.ts", moduleUrl).pathname;
900
+
901
+ const args = entry ? ["daemon", "--autostart"] : [];
748
902
 
749
- return devWatch ? [execPath, "--watch", "run", mainPath] : [execPath, "run", mainPath];
903
+ return devWatch
904
+ ? [execPath, "--watch", "run", mainPath, ...args]
905
+ : [execPath, "run", mainPath, ...args];
750
906
  }
751
907
 
752
908
  function spawnDaemon(home: string): string {
@@ -0,0 +1,113 @@
1
+ import { Context, Effect, Layer, Stream, Queue, Cause, type Scope } from "effect";
2
+ import {
3
+ connectOwnerSdk,
4
+ normalizeSdkError,
5
+ type OwnerDaemonSdk,
6
+ type DaemonSdkError,
7
+ type SdkThread,
8
+ type SdkThreadId,
9
+ } from "./daemon-sdk";
10
+ import type { ConnectOptions } from "./client";
11
+ import type { DaemonRequestOptions } from "./client-errors";
12
+
13
+ type EffectMethods<Methods> = {
14
+ readonly [Key in keyof Methods]: Methods[Key] extends (
15
+ ...args: infer Arguments
16
+ ) => Promise<infer Result>
17
+ ? (...args: Arguments) => Effect.Effect<Result, DaemonSdkError>
18
+ : never;
19
+ };
20
+
21
+ /** The Effect service owns a scoped connection while daemon-owned work outlives local waits. */
22
+ export class CueloopSdk extends Context.Service<
23
+ CueloopSdk,
24
+ {
25
+ readonly ids: OwnerDaemonSdk["ids"];
26
+ readonly threads: EffectMethods<OwnerDaemonSdk["threads"]> & {
27
+ watch(threadId: SdkThreadId): Stream.Stream<SdkThread, DaemonSdkError>;
28
+ };
29
+ readonly comments: EffectMethods<OwnerDaemonSdk["comments"]>;
30
+ readonly sessions: EffectMethods<OwnerDaemonSdk["sessions"]>;
31
+ readonly agents: EffectMethods<OwnerDaemonSdk["agents"]>;
32
+ }
33
+ >()("@cueloop/daemon/CueloopSdk") {
34
+ static ownerLayer(
35
+ options: Omit<ConnectOptions, "role"> = {},
36
+ ): Layer.Layer<CueloopSdk, DaemonSdkError> {
37
+ return Layer.effect(CueloopSdk, acquireOwnerSdk(options).pipe(Effect.map(effectSdkService)));
38
+ }
39
+ }
40
+
41
+ function acquireOwnerSdk(
42
+ options: Omit<ConnectOptions, "role">,
43
+ ): Effect.Effect<OwnerDaemonSdk, DaemonSdkError, Scope.Scope> {
44
+ return Effect.acquireRelease(
45
+ Effect.tryPromise({ try: () => connectOwnerSdk(options), catch: normalizeSdkError }),
46
+ (sdk) => Effect.sync(() => sdk.close()),
47
+ );
48
+ }
49
+
50
+ function sdkEffect<Result>(
51
+ request: (options: DaemonRequestOptions) => Promise<Result>,
52
+ options?: DaemonRequestOptions,
53
+ ): Effect.Effect<Result, DaemonSdkError> {
54
+ return Effect.tryPromise({
55
+ try: (signal) =>
56
+ request({
57
+ ...options,
58
+ signal: options?.signal ? AbortSignal.any([signal, options.signal]) : signal,
59
+ }),
60
+ catch: normalizeSdkError,
61
+ });
62
+ }
63
+
64
+ function effectSdkService(sdk: OwnerDaemonSdk): CueloopSdk["Service"] {
65
+ return {
66
+ ids: sdk.ids,
67
+ threads: {
68
+ watch: (threadId) =>
69
+ Stream.callback<SdkThread, DaemonSdkError>(
70
+ Effect.fn(function* (queue) {
71
+ yield* Effect.acquireRelease(
72
+ Effect.sync(() =>
73
+ sdk.observeThread({
74
+ threadId,
75
+ onValue: (thread) => {
76
+ Queue.offerUnsafe(queue, thread);
77
+ },
78
+ onError: (error) => {
79
+ if (error._tag === "DaemonClientError" || error.kind === "protocol")
80
+ Queue.failCauseUnsafe(queue, Cause.fail(error));
81
+ },
82
+ }),
83
+ ),
84
+ (stop) => Effect.sync(stop),
85
+ );
86
+ }),
87
+ { bufferSize: 1, strategy: "sliding" },
88
+ ),
89
+ get: (id, options) => sdkEffect((request) => sdk.threads.get(id, request), options),
90
+ create: (input, options) =>
91
+ sdkEffect((request) => sdk.threads.create(input, request), options),
92
+ },
93
+ comments: {
94
+ add: (input, options) => sdkEffect((request) => sdk.comments.add(input, request), options),
95
+ list: (id, options) => sdkEffect((request) => sdk.comments.list(id, request), options),
96
+ reply: (input, options) =>
97
+ sdkEffect((request) => sdk.comments.reply(input, request), options),
98
+ },
99
+ sessions: {
100
+ sendMessage: (input, options) =>
101
+ sdkEffect((request) => sdk.sessions.sendMessage(input, request), options),
102
+ },
103
+ agents: {
104
+ get: (id, options) => sdkEffect((request) => sdk.agents.get(id, request), options),
105
+ prompt: (input, options) =>
106
+ sdkEffect((request) => sdk.agents.prompt(input, request), options),
107
+ replyToComment: (input, options) =>
108
+ sdkEffect((request) => sdk.agents.replyToComment(input, request), options),
109
+ wait: (input, options) => sdkEffect((request) => sdk.agents.wait(input, request), options),
110
+ cancel: (id, options) => sdkEffect((request) => sdk.agents.cancel(id, request), options),
111
+ },
112
+ };
113
+ }