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

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.101",
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.101",
48
+ "@cueloop/schema": "0.1.0-alpha.101",
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,12 @@ 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,
160
- );
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
- };
151
+ const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void =>
152
+ this.enqueue(state, revision, input, promptMirrorIdentity(params));
181
153
 
182
- if (params.retry) {
154
+ if (params.commentId) {
155
+ this.enqueueComment(state, thread, params.commentId, enqueue, params);
156
+ } else if (params.retry) {
183
157
  const submission = submissions.find((entry) => entry.id === params.retry);
184
158
 
185
159
  if (!submission || submission.status !== "failed")
@@ -189,41 +163,10 @@ export class ThreadAgentManager {
189
163
  } else {
190
164
  const accepted = new Set(submissions.map((entry) => entry.commentId));
191
165
 
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
- }
166
+ for (const annotation of pendingAgentAnnotations(thread, accepted, params.inputOnly))
167
+ this.enqueueComment(state, thread, annotation.id, enqueue);
168
+ for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
169
+ this.enqueueComment(state, thread, comment.id, enqueue);
227
170
  if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
228
171
  }
229
172
  recordPromptOperation(state, before, params);
@@ -267,6 +210,7 @@ export class ThreadAgentManager {
267
210
 
268
211
  if (current.phase.kind !== "running" && current.phase.kind !== "permission")
269
212
  active.turn = stepAgentTurn(active.turn, "finished");
213
+ this.drain(state);
270
214
 
271
215
  return structuredClone(state);
272
216
  }
@@ -278,12 +222,79 @@ export class ThreadAgentManager {
278
222
  return structuredClone(state);
279
223
  }
280
224
 
225
+ private enqueueComment(
226
+ state: ThreadAgentState,
227
+ thread: Thread,
228
+ commentId: string,
229
+ enqueue: (input: Omit<AgentSubmission, "id" | "status">) => void,
230
+ frozen?: AgentPromptRequest,
231
+ ): void {
232
+ const annotation = thread.annotations.find((entry) => entry.id === commentId);
233
+ const comment = state.comments.find((entry) => entry.id === commentId);
234
+ const input = annotation ?? comment;
235
+
236
+ if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
237
+ throw new Error("Shared agent comment is unavailable or already sent");
238
+ enqueue({
239
+ commentId,
240
+ prompt: frozen?.text ?? input.body,
241
+ quote: frozen?.context ?? input.anchor.quote,
242
+ messageId: comment?.messageId,
243
+ context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
244
+ });
245
+ if (comment) comment.sent = true;
246
+ }
247
+
248
+ private enqueue(
249
+ state: ThreadAgentState,
250
+ revision: number,
251
+ input: Omit<AgentSubmission, "id" | "status">,
252
+ operationId?: string,
253
+ ): void {
254
+ const submissions = state.submissions!;
255
+
256
+ if (
257
+ state.messages.length +
258
+ submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
259
+ .length +
260
+ 2 >
261
+ 128
262
+ )
263
+ throw new Error("Thread agent prototype reached its 128-message limit");
264
+ if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
265
+ throw new Error("Thread agent input exceeds 320 KiB");
266
+ const used = state.messages.reduce(
267
+ (total, message) => total + Buffer.byteLength(message.text),
268
+ 0,
269
+ );
270
+
271
+ if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
272
+ throw new Error("Thread agent input exceeds transcript limit");
273
+ const submission: AgentSubmission = {
274
+ ...input,
275
+ id: operationId ?? randomUUID(),
276
+ status: stepAgentSubmission("draft", "submit", true),
277
+ };
278
+
279
+ submission.commentId ??= submission.id;
280
+ submissions.push(submission);
281
+ state.messages.push({
282
+ id: submission.id,
283
+ submissionId: submission.id,
284
+ role: "user",
285
+ text: submission.prompt,
286
+ complete: true,
287
+ revision,
288
+ });
289
+ }
290
+
281
291
  private drain(state: ThreadAgentState): void {
282
292
  if (
283
293
  this.states.get(state.threadId) !== state ||
284
294
  state.phase.kind === "running" ||
285
295
  state.phase.kind === "permission" ||
286
- this.disposed
296
+ this.disposed ||
297
+ this.retiring.has(state.threadId)
287
298
  )
288
299
  return;
289
300
  const submission = state.submissions?.find((entry) => entry.status === "queued");
@@ -295,21 +306,28 @@ export class ThreadAgentManager {
295
306
  state.phase = { kind: "running" };
296
307
  const thread = this.options.getThread(state.threadId);
297
308
  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.
309
+ const prompt =
310
+ submission.harnessPrompt ??
311
+ `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
312
  Reviewed artifact:
300
313
  ${thread.artifact.content.slice(0, 32768)}
301
314
  Conversation:
302
- ${state.messages
303
- .filter((entry) => entry.submissionId !== submission.id)
304
- .map((entry) => `${entry.role}: ${entry.text}`)
305
- .join("\n")
306
- .slice(-65536)}
315
+ ${
316
+ this.adapter(state).id === "pi"
317
+ ? "Continued in the durable conversation."
318
+ : state.messages
319
+ .filter((entry) => entry.submissionId !== submission.id)
320
+ .map((entry) => `${entry.role}: ${entry.text}`)
321
+ .join("\n")
322
+ .slice(-65536)
323
+ }
307
324
  Comment ID: ${submission.commentId ?? "none"}
308
325
  Quote: ${submission.quote ?? ""}
309
326
  Discussion context: ${submission.context ?? submission.messageId ?? ""}
310
327
  Input: ${submission.prompt}`;
311
328
 
312
329
  try {
330
+ submission.harnessPrompt = prompt;
313
331
  this.save(state);
314
332
  } catch (error) {
315
333
  // Acceptance is already durable; a retry can resume the queue before any harness request.
@@ -423,22 +441,53 @@ Input: ${submission.prompt}`;
423
441
  }
424
442
 
425
443
  /** Daemon shutdown owns process cleanup; closing a client does not stop a turn. */
426
- dispose(): void {
444
+ dispose(): Promise<void> {
427
445
  this.disposed = true;
428
- for (const active of this.active.values()) active.connection.close();
429
- this.active.clear();
446
+ this.closing ??= Promise.all(
447
+ [...this.active.values()]
448
+ .map((active) => active.connection.close())
449
+ .concat([...this.retiring.values()]),
450
+ ).then(() => {
451
+ this.active.clear();
452
+ });
453
+
454
+ return this.closing;
430
455
  }
431
456
 
432
457
  /** Deleting a Thread also stops its process and removes its private transcript. */
433
- remove(id: string): void {
434
- const active = this.active.get(id);
458
+ async remove(id: string): Promise<void> {
459
+ const state = this.mutable(id);
435
460
 
436
- this.active.delete(id);
461
+ this.deleting.add(id);
437
462
  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 });
463
+ try {
464
+ await this.retiring.get(id);
465
+ const active = this.active.get(id);
466
+
467
+ this.active.delete(id);
468
+ active?.connection.cancel();
469
+ await active?.connection.close();
470
+ if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
471
+ rmSync(this.path(id), { force: true });
472
+ rmSync(this.path(id) + ".tmp", { force: true });
473
+ rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
474
+ } finally {
475
+ this.deleting.delete(id);
476
+ }
477
+ }
478
+
479
+ private adapter(state: ThreadAgentState): AgentHarnessAdapter {
480
+ const adapter =
481
+ (state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
482
+
483
+ if (!adapter) throw new Error("Thread agent harness is not configured");
484
+
485
+ return adapter;
486
+ }
487
+
488
+ private assertReadable(id: string): void {
489
+ if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
490
+ this.options.getThread(id);
442
491
  }
443
492
 
444
493
  private mutable(id: string): ThreadAgentState {
@@ -478,6 +527,7 @@ Input: ${submission.prompt}`;
478
527
 
479
528
  try {
480
529
  active = await this.connect(state, thread, turnId, revision);
530
+ if (this.states.get(state.threadId) !== state) return;
481
531
  active.turnId = turnId;
482
532
  active.revision = revision;
483
533
  // Cancellation during initialization must not submit a new model request.
@@ -489,8 +539,9 @@ Input: ${submission.prompt}`;
489
539
 
490
540
  return;
491
541
  }
492
- const result = await active.connection.prompt(prompt);
542
+ const result = await active.connection.prompt(prompt, turnId);
493
543
 
544
+ if (this.states.get(state.threadId) !== state) return;
494
545
  finalizeAgentMessages(state, messageStart, result.outcome);
495
546
  if (submission && result.outcome === "cancelled") submission.cancelled = true;
496
547
  if (submission)
@@ -513,21 +564,41 @@ Input: ${submission.prompt}`;
513
564
  kind: "failed",
514
565
  error: error instanceof Error ? error.message : "Thread agent request failed",
515
566
  };
516
- this.active.delete(state.threadId);
517
- active?.connection.close();
518
- if (!this.disposed) {
567
+ await this.retire(state.threadId, active);
568
+ if (!this.disposed && this.states.get(state.threadId) === state) {
519
569
  this.save(state);
520
570
  this.drain(state);
521
571
  }
522
572
  }
523
573
  }
524
574
 
575
+ private retire(id: string, active = this.active.get(id)): Promise<void> {
576
+ const existing = this.retiring.get(id);
577
+
578
+ if (existing) return existing;
579
+ this.active.delete(id);
580
+ const closing = Promise.resolve()
581
+ .then(() => active?.connection.close())
582
+ .catch((error) => console.error("[agent cleanup]", error))
583
+ .finally(() => {
584
+ this.retiring.delete(id);
585
+ });
586
+
587
+ this.retiring.set(id, closing);
588
+
589
+ return closing;
590
+ }
591
+
525
592
  private async connect(
526
593
  state: ThreadAgentState,
527
594
  thread: Thread,
528
595
  turnId: string,
529
596
  revision: number,
530
597
  ): Promise<ActiveAgent> {
598
+ const retiring = this.retiring.get(state.threadId);
599
+
600
+ if (retiring) await retiring;
601
+ if (this.disposed) throw new Error("Thread agent is shutting down");
531
602
  const pending = this.initializing.get(state.threadId);
532
603
 
533
604
  if (pending) return pending;
@@ -550,7 +621,8 @@ Input: ${submission.prompt}`;
550
621
  let active = this.active.get(state.threadId);
551
622
 
552
623
  if (!active) {
553
- const connection = this.options.adapter!.connect({
624
+ const adapter = this.adapter(state);
625
+ const connection = adapter.connect({
554
626
  cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
555
627
  sessionId: state.harness?.sessionId,
556
628
  tools: this.options.tools?.(thread),
@@ -569,9 +641,10 @@ Input: ${submission.prompt}`;
569
641
  this.active.set(state.threadId, active);
570
642
  const sessionId = await connection.start();
571
643
 
644
+ if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
572
645
  state.harness = {
573
- id: this.options.adapter!.id,
574
- label: this.options.adapter!.label,
646
+ id: adapter.id,
647
+ label: adapter.label,
575
648
  sessionId,
576
649
  };
577
650
  active.turn = stepAgentTurn(active.turn, "ready");
@@ -646,7 +719,17 @@ Input: ${submission.prompt}`;
646
719
  Math.min(320_000 - Buffer.byteLength(message.text), 2 * 1024 * 1024 - used),
647
720
  );
648
721
 
649
- message.text += Buffer.from(update.text).subarray(0, budget).toString("utf8");
722
+ message.text = update.replace
723
+ ? Buffer.from(update.text)
724
+ .subarray(
725
+ 0,
726
+ Math.max(
727
+ 0,
728
+ Math.min(320_000, 2 * 1024 * 1024 - used + Buffer.byteLength(message.text)),
729
+ ),
730
+ )
731
+ .toString("utf8")
732
+ : message.text + Buffer.from(update.text).subarray(0, budget).toString("utf8");
650
733
  } else {
651
734
  let tool = state.tools.find((tool) => tool.id === update.id && tool.turnId === active.turnId);
652
735
 
@@ -711,3 +794,42 @@ function assertHarnessIdentity(state: ThreadAgentState, adapter: AgentHarnessAda
711
794
  if (state.harness && state.harness.id !== adapter.id)
712
795
  throw new Error("Thread agent belongs to a different harness; create a new Thread");
713
796
  }
797
+
798
+ function promptMirrorIdentity(params: AgentPromptRequest): string | undefined {
799
+ return params.inputOnly ? params.operationId : undefined;
800
+ }
801
+
802
+ function pendingAgentAnnotations(
803
+ thread: Thread,
804
+ accepted: Set<string | undefined>,
805
+ inputOnly?: boolean,
806
+ ) {
807
+ return inputOnly
808
+ ? []
809
+ : thread.annotations.filter((annotation) => isPendingAgentInput(annotation, accepted));
810
+ }
811
+
812
+ function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: string): string {
813
+ const annotation = thread.annotations.find((entry) => entry.id === commentId);
814
+
815
+ if (annotation) {
816
+ const root =
817
+ thread.annotations.find((entry) => entry.id === (annotation.replyTo ?? annotation.id)) ??
818
+ annotation;
819
+
820
+ return JSON.stringify({
821
+ target: root.target,
822
+ discussion: thread.annotations
823
+ .filter((entry) => entry.id === root.id || entry.replyTo === root.id)
824
+ .map((entry) => ({ id: entry.id, body: entry.body })),
825
+ });
826
+ }
827
+ const comment = agentCommentRoot(state, commentId)!;
828
+ const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
829
+
830
+ return JSON.stringify({
831
+ discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
832
+ (entry) => ({ id: entry.id, body: entry.body }),
833
+ ),
834
+ });
835
+ }
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({