@cueloop/daemon 0.1.0-alpha.100 → 0.1.0-alpha.102

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.100",
3
+ "version": "0.1.0-alpha.102",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -37,14 +37,15 @@
37
37
  "./curate": "./src/curate.ts",
38
38
  "./sdk": "./src/daemon-sdk.ts",
39
39
  "./sdk-effect": "./src/daemon-sdk-effect.ts",
40
- "./thread-subscription": "./src/thread-subscription.ts"
40
+ "./thread-subscription": "./src/thread-subscription.ts",
41
+ "./shared-agent-protocol": "./src/shared-agent-protocol.ts"
41
42
  },
42
43
  "publishConfig": {
43
44
  "access": "public"
44
45
  },
45
46
  "dependencies": {
46
- "@cueloop/extension-api": "0.1.0-alpha.100",
47
- "@cueloop/schema": "0.1.0-alpha.100",
47
+ "@cueloop/extension-api": "0.1.0-alpha.102",
48
+ "@cueloop/schema": "0.1.0-alpha.102",
48
49
  "@pierre/diffs": "^1.5.1",
49
50
  "effect": "^4.0.0",
50
51
  "smol-toml": "^1.9.0",
@@ -87,5 +87,12 @@ export function findPromptOperationReceipt(
87
87
  }
88
88
 
89
89
  function promptOperationFingerprint(input: AgentPromptRequest): string {
90
- return operationFingerprint([input.text, input.context ?? null, input.retry ?? null]);
90
+ return operationFingerprint([
91
+ input.text,
92
+ input.context ?? null,
93
+ input.retry ?? null,
94
+ input.commentId ?? null,
95
+ input.inputOnly ? "input" : null,
96
+ input.discussion ?? null,
97
+ ]);
91
98
  }
@@ -526,3 +526,49 @@ describe("large payloads survive socket backpressure", () => {
526
526
  expect(listed.some((session) => session.id === created.id)).toBe(true);
527
527
  }, 15_000);
528
528
  });
529
+
530
+ test("explicit daemon shutdown waits for the harness to release durable storage before exit", async () => {
531
+ client.close();
532
+ server.stop();
533
+ const closeStarted = Promise.withResolvers<void>();
534
+ const release = Promise.withResolvers<void>();
535
+ const exited = Promise.withResolvers<void>();
536
+ let didExit = false;
537
+
538
+ server = new DaemonServer({
539
+ home,
540
+ idleExitMs: 0,
541
+ onIdleExit: () => {
542
+ didExit = true;
543
+ exited.resolve();
544
+ },
545
+ threadAgent: {
546
+ enabled: true,
547
+ adapter: {
548
+ id: "pi",
549
+ label: "pi",
550
+ connect: () => ({
551
+ start: async () => "session",
552
+ prompt: async () => ({ outcome: "completed" }),
553
+ cancel() {},
554
+ permission() {},
555
+ close: async () => {
556
+ closeStarted.resolve();
557
+ await release.promise;
558
+ },
559
+ }),
560
+ },
561
+ },
562
+ });
563
+ server.start();
564
+ client = await DaemonClient.connect({ home });
565
+ const thread = await client.sessionCreate(WS, PLAN);
566
+
567
+ await client.agentPrompt({ id: thread.id, text: "Persist this" });
568
+ await client.shutdown();
569
+ await closeStarted.promise;
570
+ expect(didExit).toBe(false);
571
+ release.resolve();
572
+ await exited.promise;
573
+ expect(didExit).toBe(true);
574
+ });
package/src/server.ts CHANGED
@@ -45,8 +45,9 @@ interface Connection {
45
45
  type MethodHandler = (connection: Connection, request: Request) => Response["result"];
46
46
 
47
47
  export interface DaemonOptions {
48
- threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter">;
48
+ threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter" | "adapters">;
49
49
  home?: string;
50
+ onEvent?: (event: import("./client").EventFrame) => void;
50
51
  idleExitMs?: number;
51
52
  onIdleExit?: () => void;
52
53
  version?: string;
@@ -73,8 +74,10 @@ export class DaemonServer {
73
74
  private readonly idleExitMs: number;
74
75
  private readonly onIdleExit: () => void;
75
76
  private readonly version: string;
77
+ private readonly onEvent?: (event: import("./client").EventFrame) => void;
76
78
 
77
79
  constructor(options: DaemonOptions = {}) {
80
+ this.onEvent = options.onEvent;
78
81
  this.home = options.home ?? cueloopHome();
79
82
  this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
80
83
  this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
@@ -86,6 +89,7 @@ export class DaemonServer {
86
89
  enabled: options.threadAgent?.enabled ?? false,
87
90
  enabledForThread: options.threadAgent?.enabledForThread,
88
91
  adapter: options.threadAgent?.adapter,
92
+ adapters: options.threadAgent?.adapters,
89
93
  getThread: (id) => this.core.sessionGet(id),
90
94
  tools: (thread): AgentHarnessTools => ({
91
95
  definitions: [
@@ -131,6 +135,7 @@ export class DaemonServer {
131
135
  call: async (name, input) => this.callAgentTool(thread.id, name, input),
132
136
  }),
133
137
  onChange: (id) => {
138
+ this.onEvent?.({ event: "agent.updated", sessionId: id });
134
139
  for (const connection of this.connections) {
135
140
  if (connection.subscribed && connection.role === "owner")
136
141
  connection.write(JSON.stringify({ event: "agent.updated", sessionId: id }) + "\n");
@@ -286,8 +291,28 @@ export class DaemonServer {
286
291
  return path;
287
292
  }
288
293
 
294
+ resumeAgents(): void {
295
+ for (const thread of this.core.sessionList({ status: "pending" })) {
296
+ if (!this.threadAgent.isEnabled(thread.id)) continue;
297
+ const state = this.threadAgent.get(thread.id);
298
+
299
+ if (
300
+ state.harness?.id === "pi" &&
301
+ state.submissions?.some((submission) => submission.status === "queued")
302
+ )
303
+ void this.threadAgent
304
+ .configure({ id: thread.id })
305
+ .catch((error) => console.error("[agent recovery]", error));
306
+ }
307
+ }
308
+
309
+ async shutdown(): Promise<void> {
310
+ await this.threadAgent.dispose();
311
+ this.stop();
312
+ }
313
+
289
314
  stop(): void {
290
- this.threadAgent.dispose();
315
+ void this.threadAgent.dispose().catch((error) => console.error("[agent shutdown]", error));
291
316
  this.core.dispose();
292
317
  if (this.lockFd === null) return;
293
318
  this.server?.stop(true);
@@ -300,6 +325,7 @@ export class DaemonServer {
300
325
  }
301
326
 
302
327
  private broadcast(event: DaemonEvent): void {
328
+ this.onEvent?.(event);
303
329
  const frame = JSON.stringify(event) + "\n";
304
330
 
305
331
  for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
@@ -473,8 +499,9 @@ export class DaemonServer {
473
499
  },
474
500
  "daemon.shutdown": () => {
475
501
  setTimeout(() => {
476
- this.stop();
477
- this.onIdleExit();
502
+ void this.shutdown()
503
+ .then(() => this.onIdleExit())
504
+ .catch((error) => console.error("[agent shutdown]", error));
478
505
  }, 10);
479
506
 
480
507
  return {};
@@ -632,13 +659,16 @@ export class DaemonServer {
632
659
  "session.setShares": (_connection, request) => {
633
660
  const params = parseParams("session.setShares", request.params);
634
661
 
662
+ if (params.shares.some((link) => link.agentEnabled))
663
+ this.threadAgent.assertEnabled(params.id);
664
+
635
665
  return this.core.sessionSetShares(params.id, params.shares);
636
666
  },
637
- "session.delete": (_connection, request) => {
667
+ "session.delete": async (_connection, request) => {
638
668
  const { id } = parseParams("session.delete", request.params);
639
669
 
670
+ await this.threadAgent.remove(id);
640
671
  this.core.sessionDelete(id);
641
- this.threadAgent.remove(id);
642
672
 
643
673
  return {};
644
674
  },
@@ -0,0 +1,30 @@
1
+ import * as v from "valibot";
2
+ import { ThreadRecordSchema, Params } from "./validate";
3
+ import { AgentCommentSchema, ThreadAgentSchema } from "./thread-agent-validation";
4
+
5
+ /** Both SSH endpoints validate relay frames before using owner or collaborator data. */
6
+ export const SharedAgentRequestSchema = v.object({
7
+ author: v.string(),
8
+ params: v.object({
9
+ ...Params["agent.prompt"].entries,
10
+ operationId: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
11
+ }),
12
+ comment: v.optional(AgentCommentSchema),
13
+ });
14
+
15
+ /** A relay cannot carry executable configuration, credentials, or arbitrary daemon methods. */
16
+ export const SharedAgentFrameSchema = v.variant("type", [
17
+ v.object({ type: v.literal("hello"), shareId: v.string() }),
18
+ v.object({
19
+ type: v.literal("requests"),
20
+ thread: ThreadRecordSchema,
21
+ requests: v.array(SharedAgentRequestSchema),
22
+ }),
23
+ v.object({
24
+ type: v.literal("state"),
25
+ state: ThreadAgentSchema,
26
+ accepted: v.optional(v.array(v.string())),
27
+ }),
28
+ ]);
29
+
30
+ export { ThreadAgentSchema, AgentCommentSchema } from "./thread-agent-validation";
@@ -32,6 +32,7 @@ const StoredAgentSchema = v.object({
32
32
  phase: v.variant("kind", [
33
33
  v.object({ kind: v.literal("idle") }),
34
34
  v.object({ kind: v.literal("running") }),
35
+ v.object({ kind: v.literal("offline") }),
35
36
  v.object({ kind: v.literal("failed"), error: v.string() }),
36
37
  v.object({
37
38
  kind: v.literal("permission"),
@@ -77,6 +78,7 @@ const StoredAgentSchema = v.object({
77
78
  commentId: v.optional(v.string()),
78
79
  messageId: v.optional(v.string()),
79
80
  prompt: v.string(),
81
+ harnessPrompt: v.optional(v.string()),
80
82
  quote: v.optional(v.string()),
81
83
  context: v.optional(v.string()),
82
84
  status: v.picklist(["queued", "running", "completed", "failed"]),
@@ -410,3 +410,310 @@ test("retrying accepted work after a failed running-state save starts the harnes
410
410
  rmSync(home, { recursive: true, force: true });
411
411
  }
412
412
  });
413
+
414
+ test("a shared plain input does not consume the owner's pending comments", () => {
415
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-input-only-"));
416
+ const thread = createTestThread(home);
417
+ const adapter: AgentHarnessAdapter = {
418
+ id: "pi",
419
+ label: "pi",
420
+ connect: () => ({
421
+ start: () => new Promise(() => {}),
422
+ prompt: () => new Promise(() => {}),
423
+ cancel() {},
424
+ permission() {},
425
+ close() {},
426
+ }),
427
+ };
428
+ const manager = new ThreadAgentManager({
429
+ home,
430
+ enabled: true,
431
+ adapter,
432
+ getThread: () => thread,
433
+ onChange() {},
434
+ });
435
+
436
+ thread.annotations = [
437
+ {
438
+ id: "owner-note",
439
+ author: "owner",
440
+ kind: "comment",
441
+ body: "Private draft",
442
+ anchor: { quote: "Review", prefix: "", suffix: " the retry" },
443
+ createdAt: "now",
444
+ },
445
+ ];
446
+ try {
447
+ const state = manager.prompt({
448
+ id: thread.id,
449
+ text: "Shared question",
450
+ operationId: "shared-input",
451
+ inputOnly: true,
452
+ });
453
+
454
+ expect(state.messages.map((entry) => entry.text)).toEqual(["Shared question"]);
455
+ expect(manager.isReadOnly(thread.id, "owner-note")).toBe(false);
456
+ expect(
457
+ manager.prompt({
458
+ id: thread.id,
459
+ text: "Shared question",
460
+ operationId: "shared-input",
461
+ inputOnly: true,
462
+ }).messages,
463
+ ).toHaveLength(1);
464
+ } finally {
465
+ manager.dispose();
466
+ rmSync(home, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ test("Pi recovery keeps the frozen harness input and submission identity after interruption", async () => {
471
+ const home = mkdtempSync(join(tmpdir(), "cueloop-pi-recovery-"));
472
+ const thread = createTestThread(home);
473
+ const prompts: Array<{ text: string; id?: string }> = [];
474
+ let resumed = false;
475
+ const adapter: AgentHarnessAdapter = {
476
+ id: "pi",
477
+ label: "pi",
478
+ connect: ({ onEvent }) => ({
479
+ start: async () => "durable-session",
480
+ prompt: async (text, id) => {
481
+ prompts.push({ text, id });
482
+ if (!resumed) return new Promise(() => {});
483
+ onEvent({ kind: "message", id: "durable-answer", text: "Recovered answer", replace: true });
484
+
485
+ return { outcome: "completed" as const };
486
+ },
487
+ cancel() {},
488
+ permission() {},
489
+ close() {},
490
+ }),
491
+ };
492
+ const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
493
+ let manager = new ThreadAgentManager(options);
494
+
495
+ try {
496
+ manager.prompt({
497
+ id: thread.id,
498
+ text: "Recover",
499
+ operationId: "recover-input",
500
+ inputOnly: true,
501
+ });
502
+ for (let turn = 0; !prompts.length && turn < 100; turn++) await Bun.sleep(1);
503
+ await manager.dispose();
504
+ thread.artifact.content = "Changed artifact after admission";
505
+ resumed = true;
506
+ manager = new ThreadAgentManager(options);
507
+ expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
508
+ manager.prompt({
509
+ id: thread.id,
510
+ text: "Recover",
511
+ operationId: "recover-input",
512
+ inputOnly: true,
513
+ });
514
+ for (let turn = 0; manager.get(thread.id).phase.kind !== "idle" && turn < 100; turn++)
515
+ await Bun.sleep(1);
516
+ expect(prompts).toHaveLength(2);
517
+ expect(prompts[1]).toEqual(prompts[0]);
518
+ expect(manager.get(thread.id).messages.map((message) => message.text)).toEqual([
519
+ "Recover",
520
+ "Recovered answer",
521
+ ]);
522
+ } finally {
523
+ await manager.dispose();
524
+ rmSync(home, { recursive: true, force: true });
525
+ }
526
+ });
527
+
528
+ test("explicit shared comments execute the frozen payload and retain the original discussion", async () => {
529
+ const home = mkdtempSync(join(tmpdir(), "cueloop-shared-frozen-"));
530
+ const thread = createTestThread(home);
531
+ const original = {
532
+ id: "root",
533
+ kind: "comment" as const,
534
+ body: "Original discussion",
535
+ anchor: { quote: "retry", prefix: "Review the ", suffix: "" },
536
+ createdAt: "now",
537
+ };
538
+
539
+ thread.annotations = [
540
+ original,
541
+ { ...original, id: "reply", replyTo: "root", body: "Later edited value" },
542
+ ];
543
+ const manager = new ThreadAgentManager({
544
+ home,
545
+ enabled: true,
546
+ adapter: {
547
+ id: "pi",
548
+ label: "pi",
549
+ connect: () => ({
550
+ start: async () => "session",
551
+ prompt: async () => ({ outcome: "completed" }),
552
+ cancel() {},
553
+ close() {},
554
+ permission() {},
555
+ }),
556
+ },
557
+ getThread: () => thread,
558
+ onChange() {},
559
+ });
560
+
561
+ try {
562
+ const state = manager.prompt({
563
+ id: thread.id,
564
+ commentId: "reply",
565
+ operationId: "shared-frozen",
566
+ text: "Accepted reply",
567
+ context: "retry",
568
+ discussion: JSON.stringify({
569
+ discussion: [{ body: "Original discussion" }, { body: "Accepted reply" }],
570
+ }),
571
+ });
572
+
573
+ expect(state.submissions?.[0]?.prompt).toBe("Accepted reply");
574
+ expect(state.submissions?.[0]?.context).toContain("Original discussion");
575
+ expect(state.submissions?.[0]?.context).not.toContain("Later edited value");
576
+ } finally {
577
+ await manager.dispose();
578
+ rmSync(home, { recursive: true, force: true });
579
+ }
580
+ });
581
+
582
+ test("a failed connection finishes asynchronous close before the next queued turn opens storage", async () => {
583
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-close-"));
584
+ const thread = createTestThread(home);
585
+ const closeStarted = Promise.withResolvers<void>();
586
+ const release = Promise.withResolvers<void>();
587
+ let connects = 0;
588
+ const manager = new ThreadAgentManager({
589
+ home,
590
+ enabled: true,
591
+ getThread: () => thread,
592
+ onChange() {},
593
+ adapter: {
594
+ id: "pi",
595
+ label: "pi",
596
+ connect: () => {
597
+ const first = ++connects === 1;
598
+
599
+ return {
600
+ start: async () => "session",
601
+ prompt: async () => {
602
+ if (first) throw new Error("Connection failed");
603
+
604
+ return { outcome: "completed" };
605
+ },
606
+ cancel() {},
607
+ permission() {},
608
+ close: async () => {
609
+ if (first) {
610
+ closeStarted.resolve();
611
+ await release.promise;
612
+ }
613
+ },
614
+ };
615
+ },
616
+ },
617
+ });
618
+
619
+ try {
620
+ manager.prompt({ id: thread.id, text: "First" });
621
+ manager.prompt({ id: thread.id, text: "Second" });
622
+ await closeStarted.promise;
623
+ expect(connects).toBe(1);
624
+ release.resolve();
625
+ for (
626
+ let attempt = 0;
627
+ manager.get(thread.id).submissions?.[1]?.status !== "completed" && attempt < 100;
628
+ attempt++
629
+ )
630
+ await Bun.sleep(1);
631
+ expect(connects).toBe(2);
632
+ expect(manager.get(thread.id).submissions?.[1]?.status).toBe("completed");
633
+ } finally {
634
+ release.resolve();
635
+ await manager.dispose();
636
+ rmSync(home, { recursive: true, force: true });
637
+ }
638
+ });
639
+
640
+ test("shutdown joins a failed turn that is already retiring its durable writer", async () => {
641
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-retiring-"));
642
+ const thread = createTestThread(home);
643
+ const closeStarted = Promise.withResolvers<void>();
644
+ const release = Promise.withResolvers<void>();
645
+ let closed = false;
646
+ const manager = new ThreadAgentManager({
647
+ home,
648
+ enabled: true,
649
+ getThread: () => thread,
650
+ onChange() {},
651
+ adapter: {
652
+ id: "pi",
653
+ label: "pi",
654
+ connect: () => ({
655
+ start: async () => "session",
656
+ prompt: async () => {
657
+ throw new Error("Failed turn");
658
+ },
659
+ cancel() {},
660
+ permission() {},
661
+ close: async () => {
662
+ closeStarted.resolve();
663
+ await release.promise;
664
+ closed = true;
665
+ },
666
+ }),
667
+ },
668
+ });
669
+
670
+ try {
671
+ manager.prompt({ id: thread.id, text: "First" });
672
+ await closeStarted.promise;
673
+ const shutdown = manager.dispose();
674
+ let finished = false;
675
+
676
+ void shutdown.then(() => {
677
+ finished = true;
678
+ });
679
+ await Promise.resolve();
680
+ expect(finished).toBe(false);
681
+ release.resolve();
682
+ await shutdown;
683
+ expect(closed).toBe(true);
684
+ } finally {
685
+ release.resolve();
686
+ await manager.dispose();
687
+ rmSync(home, { recursive: true, force: true });
688
+ }
689
+ });
690
+
691
+ test("shutdown seals admission so a later configuration cannot open a new harness", async () => {
692
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-sealed-"));
693
+ const thread = createTestThread(home);
694
+ let connects = 0;
695
+ const manager = new ThreadAgentManager({
696
+ home,
697
+ enabled: true,
698
+ getThread: () => thread,
699
+ onChange() {},
700
+ adapter: {
701
+ id: "pi",
702
+ label: "pi",
703
+ connect: () => {
704
+ connects++;
705
+
706
+ throw new Error("Must not connect after shutdown");
707
+ },
708
+ },
709
+ });
710
+
711
+ try {
712
+ await manager.dispose();
713
+ await expect(manager.configure({ id: thread.id })).rejects.toThrow("shutting down");
714
+ expect(() => manager.prompt({ id: thread.id, text: "Late input" })).toThrow("shutting down");
715
+ expect(connects).toBe(0);
716
+ } finally {
717
+ rmSync(home, { recursive: true, force: true });
718
+ }
719
+ });
@@ -35,6 +35,7 @@ export interface ThreadAgentOptions {
35
35
  enabled: boolean;
36
36
  enabledForThread?: (thread: Thread) => boolean;
37
37
  adapter?: AgentHarnessAdapter;
38
+ adapters?: Record<string, AgentHarnessAdapter>;
38
39
  getThread: (id: string) => Thread;
39
40
  onChange: (id: string) => void;
40
41
  tools?: (thread: Thread) => AgentHarnessTools;
@@ -49,10 +50,13 @@ interface ActiveAgent {
49
50
 
50
51
  /** Own harness processes and durable agent transcripts without mutating reviewed artifacts. */
51
52
  export class ThreadAgentManager {
53
+ private readonly deleting = new Set<string>();
54
+ private readonly retiring = new Map<string, Promise<void>>();
52
55
  private states = new Map<string, ThreadAgentState>();
53
56
  private active = new Map<string, ActiveAgent>();
54
57
  private readonly directory: string;
55
58
  private disposed = false;
59
+ private closing?: Promise<void>;
56
60
  private initializing = new Map<string, Promise<ActiveAgent>>();
57
61
 
58
62
  constructor(private readonly options: ThreadAgentOptions) {
@@ -68,6 +72,7 @@ export class ThreadAgentManager {
68
72
 
69
73
  /** Refuse agent API calls while its experimental flag is disabled. */
70
74
  assertEnabled(id: string): void {
75
+ if (this.disposed) throw new Error("Thread agent is shutting down");
71
76
  if (!this.isEnabled(id))
72
77
  throw new Error(
73
78
  "Thread agent is disabled; enable [experimental] thread_agent in config.toml",
@@ -76,7 +81,7 @@ export class ThreadAgentManager {
76
81
 
77
82
  /** Read a transcript without starting an agent or contacting a model. */
78
83
  get(id: string): ThreadAgentState {
79
- this.options.getThread(id);
84
+ this.assertReadable(id);
80
85
  const cached = this.states.get(id);
81
86
 
82
87
  if (cached) return structuredClone(cached);
@@ -100,13 +105,16 @@ export class ThreadAgentManager {
100
105
  state.phase.kind === "permission" ||
101
106
  state.submissions?.some((entry) => entry.status === "queued" || entry.status === "running")
102
107
  ) {
103
- state.phase = {
104
- kind: "failed",
105
- error: "Agent was interrupted by a daemon restart. Send a message to resume.",
106
- };
108
+ state.phase =
109
+ state.harness?.id === "pi"
110
+ ? { kind: "idle" }
111
+ : {
112
+ kind: "failed",
113
+ error: "Agent was interrupted by a daemon restart. Send a message to resume.",
114
+ };
107
115
  for (const submission of state.submissions ?? []) {
108
116
  if (submission.status === "running" || submission.status === "queued")
109
- submission.status = "failed";
117
+ submission.status = state.harness?.id === "pi" ? "queued" : "failed";
110
118
  }
111
119
  for (const tool of state.tools) {
112
120
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
@@ -132,7 +140,7 @@ export class ThreadAgentManager {
132
140
  }
133
141
  if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
134
142
  if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
135
- assertHarnessIdentity(state, this.options.adapter);
143
+ assertHarnessIdentity(state, this.adapter(state));
136
144
  const before = structuredClone(state);
137
145
 
138
146
  try {
@@ -140,46 +148,19 @@ export class ThreadAgentManager {
140
148
  state.submissions ??= [];
141
149
  const submissions = state.submissions;
142
150
  const revision = thread.revisions.at(-1)?.revision ?? 1;
143
- const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void => {
144
- if (
145
- state.messages.length +
146
- submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
147
- .length +
148
- 2 >
149
- 128
150
- )
151
- throw new Error("Thread agent prototype reached its 128-message limit");
152
- if (
153
- Buffer.byteLength(input.prompt) > 320_000 ||
154
- Buffer.byteLength(input.quote ?? "") > 320_000
155
- )
156
- throw new Error("Thread agent input exceeds 320 KiB");
157
- const used = state.messages.reduce(
158
- (total, message) => total + Buffer.byteLength(message.text),
159
- 0,
151
+ const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void =>
152
+ this.enqueue(state, revision, input, promptMirrorIdentity(params));
153
+
154
+ if (params.commentId) {
155
+ // Shared requests carry an accepted discussion snapshot; local requests read the saved note.
156
+ this.enqueueComment(
157
+ state,
158
+ thread,
159
+ params.commentId,
160
+ enqueue,
161
+ params.discussion === undefined ? undefined : params,
160
162
  );
161
-
162
- if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
163
- throw new Error("Thread agent input exceeds transcript limit");
164
- const submission: AgentSubmission = {
165
- ...input,
166
- id: randomUUID(),
167
- status: stepAgentSubmission("draft", "submit", true),
168
- };
169
-
170
- submission.commentId ??= submission.id;
171
- submissions.push(submission);
172
- state.messages.push({
173
- id: submission.id,
174
- submissionId: submission.id,
175
- role: "user",
176
- text: submission.prompt,
177
- complete: true,
178
- revision,
179
- });
180
- };
181
-
182
- if (params.retry) {
163
+ } else if (params.retry) {
183
164
  const submission = submissions.find((entry) => entry.id === params.retry);
184
165
 
185
166
  if (!submission || submission.status !== "failed")
@@ -189,41 +170,10 @@ export class ThreadAgentManager {
189
170
  } else {
190
171
  const accepted = new Set(submissions.map((entry) => entry.commentId));
191
172
 
192
- for (const annotation of thread.annotations) {
193
- if (!isPendingAgentInput(annotation, accepted)) continue;
194
- const root =
195
- thread.annotations.find(
196
- (entry) => entry.id === (annotation.replyTo ?? annotation.id),
197
- ) ?? annotation;
198
- const discussion = thread.annotations.filter(
199
- (entry) => entry.id === root.id || entry.replyTo === root.id,
200
- );
201
-
202
- enqueue({ commentId: annotation.id, prompt: annotation.body, quote: root.anchor.quote });
203
- // The discussion snapshot is passed as context, while the mirror shows only the new comment.
204
- const submission = submissions.at(-1)!;
205
-
206
- submission.context = JSON.stringify({
207
- target: root.target,
208
- discussion: discussion.map((entry) => ({ id: entry.id, body: entry.body })),
209
- });
210
- }
211
- for (const comment of state.comments.filter((entry) => !entry.sent)) {
212
- enqueue({
213
- commentId: comment.id,
214
- prompt: comment.body,
215
- quote: comment.anchor.quote,
216
- messageId: comment.messageId,
217
- });
218
- const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
219
-
220
- submissions.at(-1)!.context = JSON.stringify({
221
- discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
222
- (entry) => ({ id: entry.id, body: entry.body }),
223
- ),
224
- });
225
- comment.sent = true;
226
- }
173
+ for (const annotation of pendingAgentAnnotations(thread, accepted, params.inputOnly))
174
+ this.enqueueComment(state, thread, annotation.id, enqueue);
175
+ for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
176
+ this.enqueueComment(state, thread, comment.id, enqueue);
227
177
  if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
228
178
  }
229
179
  recordPromptOperation(state, before, params);
@@ -267,6 +217,7 @@ export class ThreadAgentManager {
267
217
 
268
218
  if (current.phase.kind !== "running" && current.phase.kind !== "permission")
269
219
  active.turn = stepAgentTurn(active.turn, "finished");
220
+ this.drain(state);
270
221
 
271
222
  return structuredClone(state);
272
223
  }
@@ -278,12 +229,79 @@ export class ThreadAgentManager {
278
229
  return structuredClone(state);
279
230
  }
280
231
 
232
+ private enqueueComment(
233
+ state: ThreadAgentState,
234
+ thread: Thread,
235
+ commentId: string,
236
+ enqueue: (input: Omit<AgentSubmission, "id" | "status">) => void,
237
+ frozen?: AgentPromptRequest,
238
+ ): void {
239
+ const annotation = thread.annotations.find((entry) => entry.id === commentId);
240
+ const comment = state.comments.find((entry) => entry.id === commentId);
241
+ const input = annotation ?? comment;
242
+
243
+ if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
244
+ throw new Error("Shared agent comment is unavailable or already sent");
245
+ enqueue({
246
+ commentId,
247
+ prompt: frozen?.text ?? input.body,
248
+ quote: frozen?.context ?? input.anchor.quote,
249
+ messageId: comment?.messageId,
250
+ context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
251
+ });
252
+ if (comment) comment.sent = true;
253
+ }
254
+
255
+ private enqueue(
256
+ state: ThreadAgentState,
257
+ revision: number,
258
+ input: Omit<AgentSubmission, "id" | "status">,
259
+ operationId?: string,
260
+ ): void {
261
+ const submissions = state.submissions!;
262
+
263
+ if (
264
+ state.messages.length +
265
+ submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
266
+ .length +
267
+ 2 >
268
+ 128
269
+ )
270
+ throw new Error("Thread agent prototype reached its 128-message limit");
271
+ if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
272
+ throw new Error("Thread agent input exceeds 320 KiB");
273
+ const used = state.messages.reduce(
274
+ (total, message) => total + Buffer.byteLength(message.text),
275
+ 0,
276
+ );
277
+
278
+ if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
279
+ throw new Error("Thread agent input exceeds transcript limit");
280
+ const submission: AgentSubmission = {
281
+ ...input,
282
+ id: operationId ?? randomUUID(),
283
+ status: stepAgentSubmission("draft", "submit", true),
284
+ };
285
+
286
+ submission.commentId ??= submission.id;
287
+ submissions.push(submission);
288
+ state.messages.push({
289
+ id: submission.id,
290
+ submissionId: submission.id,
291
+ role: "user",
292
+ text: submission.prompt,
293
+ complete: true,
294
+ revision,
295
+ });
296
+ }
297
+
281
298
  private drain(state: ThreadAgentState): void {
282
299
  if (
283
300
  this.states.get(state.threadId) !== state ||
284
301
  state.phase.kind === "running" ||
285
302
  state.phase.kind === "permission" ||
286
- this.disposed
303
+ this.disposed ||
304
+ this.retiring.has(state.threadId)
287
305
  )
288
306
  return;
289
307
  const submission = state.submissions?.find((entry) => entry.status === "queued");
@@ -295,21 +313,28 @@ export class ThreadAgentManager {
295
313
  state.phase = { kind: "running" };
296
314
  const thread = this.options.getThread(state.threadId);
297
315
  const revision = thread.revisions.at(-1)?.revision ?? 1;
298
- const prompt = `You are assisting cueloop Thread ${thread.id}. Answer this input individually. Use cueloop tools when the user asks to reply to a comment or return feedback to the main session. A reply to a Changes comment must use its original annotation ID.
316
+ const prompt =
317
+ submission.harnessPrompt ??
318
+ `You are assisting cueloop Thread ${thread.id}. Answer this input individually. Use cueloop tools when the user asks to reply to a comment or return feedback to the main session. A reply to a Changes comment must use its original annotation ID.
299
319
  Reviewed artifact:
300
320
  ${thread.artifact.content.slice(0, 32768)}
301
321
  Conversation:
302
- ${state.messages
303
- .filter((entry) => entry.submissionId !== submission.id)
304
- .map((entry) => `${entry.role}: ${entry.text}`)
305
- .join("\n")
306
- .slice(-65536)}
322
+ ${
323
+ this.adapter(state).id === "pi"
324
+ ? "Continued in the durable conversation."
325
+ : state.messages
326
+ .filter((entry) => entry.submissionId !== submission.id)
327
+ .map((entry) => `${entry.role}: ${entry.text}`)
328
+ .join("\n")
329
+ .slice(-65536)
330
+ }
307
331
  Comment ID: ${submission.commentId ?? "none"}
308
332
  Quote: ${submission.quote ?? ""}
309
333
  Discussion context: ${submission.context ?? submission.messageId ?? ""}
310
334
  Input: ${submission.prompt}`;
311
335
 
312
336
  try {
337
+ submission.harnessPrompt = prompt;
313
338
  this.save(state);
314
339
  } catch (error) {
315
340
  // Acceptance is already durable; a retry can resume the queue before any harness request.
@@ -423,22 +448,53 @@ Input: ${submission.prompt}`;
423
448
  }
424
449
 
425
450
  /** Daemon shutdown owns process cleanup; closing a client does not stop a turn. */
426
- dispose(): void {
451
+ dispose(): Promise<void> {
427
452
  this.disposed = true;
428
- for (const active of this.active.values()) active.connection.close();
429
- this.active.clear();
453
+ this.closing ??= Promise.all(
454
+ [...this.active.values()]
455
+ .map((active) => active.connection.close())
456
+ .concat([...this.retiring.values()]),
457
+ ).then(() => {
458
+ this.active.clear();
459
+ });
460
+
461
+ return this.closing;
430
462
  }
431
463
 
432
464
  /** Deleting a Thread also stops its process and removes its private transcript. */
433
- remove(id: string): void {
434
- const active = this.active.get(id);
465
+ async remove(id: string): Promise<void> {
466
+ const state = this.mutable(id);
435
467
 
436
- this.active.delete(id);
468
+ this.deleting.add(id);
437
469
  this.states.delete(id);
438
- active?.connection.close();
439
- rmSync(this.path(id), { force: true });
440
- rmSync(this.path(id) + ".tmp", { force: true });
441
- rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
470
+ try {
471
+ await this.retiring.get(id);
472
+ const active = this.active.get(id);
473
+
474
+ this.active.delete(id);
475
+ active?.connection.cancel();
476
+ await active?.connection.close();
477
+ if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
478
+ rmSync(this.path(id), { force: true });
479
+ rmSync(this.path(id) + ".tmp", { force: true });
480
+ rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
481
+ } finally {
482
+ this.deleting.delete(id);
483
+ }
484
+ }
485
+
486
+ private adapter(state: ThreadAgentState): AgentHarnessAdapter {
487
+ const adapter =
488
+ (state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
489
+
490
+ if (!adapter) throw new Error("Thread agent harness is not configured");
491
+
492
+ return adapter;
493
+ }
494
+
495
+ private assertReadable(id: string): void {
496
+ if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
497
+ this.options.getThread(id);
442
498
  }
443
499
 
444
500
  private mutable(id: string): ThreadAgentState {
@@ -478,6 +534,7 @@ Input: ${submission.prompt}`;
478
534
 
479
535
  try {
480
536
  active = await this.connect(state, thread, turnId, revision);
537
+ if (this.states.get(state.threadId) !== state) return;
481
538
  active.turnId = turnId;
482
539
  active.revision = revision;
483
540
  // Cancellation during initialization must not submit a new model request.
@@ -489,8 +546,9 @@ Input: ${submission.prompt}`;
489
546
 
490
547
  return;
491
548
  }
492
- const result = await active.connection.prompt(prompt);
549
+ const result = await active.connection.prompt(prompt, turnId);
493
550
 
551
+ if (this.states.get(state.threadId) !== state) return;
494
552
  finalizeAgentMessages(state, messageStart, result.outcome);
495
553
  if (submission && result.outcome === "cancelled") submission.cancelled = true;
496
554
  if (submission)
@@ -513,21 +571,41 @@ Input: ${submission.prompt}`;
513
571
  kind: "failed",
514
572
  error: error instanceof Error ? error.message : "Thread agent request failed",
515
573
  };
516
- this.active.delete(state.threadId);
517
- active?.connection.close();
518
- if (!this.disposed) {
574
+ await this.retire(state.threadId, active);
575
+ if (!this.disposed && this.states.get(state.threadId) === state) {
519
576
  this.save(state);
520
577
  this.drain(state);
521
578
  }
522
579
  }
523
580
  }
524
581
 
582
+ private retire(id: string, active = this.active.get(id)): Promise<void> {
583
+ const existing = this.retiring.get(id);
584
+
585
+ if (existing) return existing;
586
+ this.active.delete(id);
587
+ const closing = Promise.resolve()
588
+ .then(() => active?.connection.close())
589
+ .catch((error) => console.error("[agent cleanup]", error))
590
+ .finally(() => {
591
+ this.retiring.delete(id);
592
+ });
593
+
594
+ this.retiring.set(id, closing);
595
+
596
+ return closing;
597
+ }
598
+
525
599
  private async connect(
526
600
  state: ThreadAgentState,
527
601
  thread: Thread,
528
602
  turnId: string,
529
603
  revision: number,
530
604
  ): Promise<ActiveAgent> {
605
+ const retiring = this.retiring.get(state.threadId);
606
+
607
+ if (retiring) await retiring;
608
+ if (this.disposed) throw new Error("Thread agent is shutting down");
531
609
  const pending = this.initializing.get(state.threadId);
532
610
 
533
611
  if (pending) return pending;
@@ -550,7 +628,8 @@ Input: ${submission.prompt}`;
550
628
  let active = this.active.get(state.threadId);
551
629
 
552
630
  if (!active) {
553
- const connection = this.options.adapter!.connect({
631
+ const adapter = this.adapter(state);
632
+ const connection = adapter.connect({
554
633
  cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
555
634
  sessionId: state.harness?.sessionId,
556
635
  tools: this.options.tools?.(thread),
@@ -569,9 +648,10 @@ Input: ${submission.prompt}`;
569
648
  this.active.set(state.threadId, active);
570
649
  const sessionId = await connection.start();
571
650
 
651
+ if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
572
652
  state.harness = {
573
- id: this.options.adapter!.id,
574
- label: this.options.adapter!.label,
653
+ id: adapter.id,
654
+ label: adapter.label,
575
655
  sessionId,
576
656
  };
577
657
  active.turn = stepAgentTurn(active.turn, "ready");
@@ -646,7 +726,17 @@ Input: ${submission.prompt}`;
646
726
  Math.min(320_000 - Buffer.byteLength(message.text), 2 * 1024 * 1024 - used),
647
727
  );
648
728
 
649
- message.text += Buffer.from(update.text).subarray(0, budget).toString("utf8");
729
+ message.text = update.replace
730
+ ? Buffer.from(update.text)
731
+ .subarray(
732
+ 0,
733
+ Math.max(
734
+ 0,
735
+ Math.min(320_000, 2 * 1024 * 1024 - used + Buffer.byteLength(message.text)),
736
+ ),
737
+ )
738
+ .toString("utf8")
739
+ : message.text + Buffer.from(update.text).subarray(0, budget).toString("utf8");
650
740
  } else {
651
741
  let tool = state.tools.find((tool) => tool.id === update.id && tool.turnId === active.turnId);
652
742
 
@@ -711,3 +801,42 @@ function assertHarnessIdentity(state: ThreadAgentState, adapter: AgentHarnessAda
711
801
  if (state.harness && state.harness.id !== adapter.id)
712
802
  throw new Error("Thread agent belongs to a different harness; create a new Thread");
713
803
  }
804
+
805
+ function promptMirrorIdentity(params: AgentPromptRequest): string | undefined {
806
+ return params.inputOnly ? params.operationId : undefined;
807
+ }
808
+
809
+ function pendingAgentAnnotations(
810
+ thread: Thread,
811
+ accepted: Set<string | undefined>,
812
+ inputOnly?: boolean,
813
+ ) {
814
+ return inputOnly
815
+ ? []
816
+ : thread.annotations.filter((annotation) => isPendingAgentInput(annotation, accepted));
817
+ }
818
+
819
+ function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: string): string {
820
+ const annotation = thread.annotations.find((entry) => entry.id === commentId);
821
+
822
+ if (annotation) {
823
+ const root =
824
+ thread.annotations.find((entry) => entry.id === (annotation.replyTo ?? annotation.id)) ??
825
+ annotation;
826
+
827
+ return JSON.stringify({
828
+ target: root.target,
829
+ discussion: thread.annotations
830
+ .filter((entry) => entry.id === root.id || entry.replyTo === root.id)
831
+ .map((entry) => ({ id: entry.id, body: entry.body })),
832
+ });
833
+ }
834
+ const comment = agentCommentRoot(state, commentId)!;
835
+ const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
836
+
837
+ return JSON.stringify({
838
+ discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
839
+ (entry) => ({ id: entry.id, body: entry.body }),
840
+ ),
841
+ });
842
+ }
package/src/validate.ts CHANGED
@@ -188,6 +188,7 @@ export const ShareLinkSchema = v.object({
188
188
  name: v.optional(v.string()),
189
189
  requireAuth: v.boolean(),
190
190
  allowlist: v.array(NonEmpty),
191
+ agentEnabled: v.optional(v.boolean()),
191
192
  owner: v.optional(v.string()),
192
193
  shareBranch: v.optional(v.string()),
193
194
  } satisfies EntriesOf<ShareLink>);
@@ -222,6 +223,9 @@ export const Params = {
222
223
  text: v.pipe(v.string(), v.maxLength(65_536)),
223
224
  context: v.optional(v.pipe(v.string(), v.maxLength(32_768))),
224
225
  retry: v.optional(NonEmpty),
226
+ commentId: v.optional(NonEmpty),
227
+ inputOnly: v.optional(v.boolean()),
228
+ discussion: v.optional(v.pipe(v.string(), v.maxLength(320_000))),
225
229
  operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
226
230
  }),
227
231
  "agent.reply": v.object({