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

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.
Files changed (44) hide show
  1. package/package.json +3 -3
  2. package/src/agent-tools.ts +6 -0
  3. package/src/api.ts +63 -0
  4. package/src/client-reliability.test.ts +2 -0
  5. package/src/client.ts +24 -2
  6. package/src/curate.ts +2 -0
  7. package/src/daemon-sdk.ts +8 -0
  8. package/src/diff-hot-reload.test.ts +4 -0
  9. package/src/diff-watcher.test.ts +2 -0
  10. package/src/diff-watcher.ts +33 -0
  11. package/src/examples/daemon-sdk.ts +2 -0
  12. package/src/gh.ts +4 -0
  13. package/src/ghostty-thread-surface-store.ts +3 -0
  14. package/src/ghostty-thread-surface.test.ts +2 -0
  15. package/src/ghostty-thread-surface.ts +6 -0
  16. package/src/harness-diagnostics.ts +1 -0
  17. package/src/harness-handoff.test.ts +725 -0
  18. package/src/harness-handoff.ts +27 -0
  19. package/src/harness-state-store.ts +5 -0
  20. package/src/herdr-split.ts +5 -0
  21. package/src/herdr-thread-surface.ts +14 -0
  22. package/src/interruptible-wait.ts +3 -0
  23. package/src/jj-working-tree.ts +9 -0
  24. package/src/main.ts +1 -0
  25. package/src/operation-receipts.ts +6 -0
  26. package/src/pr-poller.ts +7 -0
  27. package/src/project-files.ts +4 -0
  28. package/src/protocol.ts +4 -0
  29. package/src/retention.ts +8 -0
  30. package/src/server.ts +36 -7
  31. package/src/share-blob.test.ts +2 -0
  32. package/src/single-instance.test.ts +3 -0
  33. package/src/store.ts +11 -0
  34. package/src/thread-agent-validation.ts +20 -0
  35. package/src/thread-agent.test.ts +14 -1
  36. package/src/thread-agent.ts +361 -25
  37. package/src/thread-review.test.ts +4 -0
  38. package/src/thread-review.ts +8 -0
  39. package/src/thread-subscription.test.ts +3 -0
  40. package/src/thread-subscription.ts +9 -0
  41. package/src/validate.test.ts +4 -0
  42. package/src/validate.ts +3 -0
  43. package/src/vcs-source.ts +6 -0
  44. package/src/working-tree.ts +4 -0
@@ -25,6 +25,7 @@ import {
25
25
  recordPromptOperation,
26
26
  settlePromptOperations,
27
27
  } from "./operation-receipts";
28
+ import { harnessHandoffPrompt, harnessContinuationPrompt } from "./harness-handoff";
28
29
  import { writeHarnessDiagnostic } from "./harness-diagnostics";
29
30
  import { stepAgentTurn, agentTurnCancelled, type AgentTurn } from "./agent-turn";
30
31
  import { ThreadAgentSchema } from "./thread-agent-validation";
@@ -39,6 +40,7 @@ export interface ThreadAgentOptions {
39
40
  getThread: (id: string) => Thread;
40
41
  onChange: (id: string) => void;
41
42
  tools?: (thread: Thread) => AgentHarnessTools;
43
+ defaultHarnessForThread?: (thread: Thread) => AgentHarnessAdapter;
42
44
  }
43
45
 
44
46
  interface ActiveAgent {
@@ -46,6 +48,8 @@ interface ActiveAgent {
46
48
  turn: AgentTurn;
47
49
  turnId: string;
48
50
  revision: number;
51
+ attemptId?: string;
52
+ handoffText?: Map<string, string>;
49
53
  }
50
54
 
51
55
  /** Own harness processes and durable agent transcripts without mutating reviewed artifacts. */
@@ -73,6 +77,7 @@ export class ThreadAgentManager {
73
77
  /** Refuse agent API calls while its experimental flag is disabled. */
74
78
  assertEnabled(id: string): void {
75
79
  if (this.disposed) throw new Error("Thread agent is shutting down");
80
+
76
81
  if (!this.isEnabled(id))
77
82
  throw new Error(
78
83
  "Thread agent is disabled; enable [experimental] thread_agent in config.toml",
@@ -85,13 +90,16 @@ export class ThreadAgentManager {
85
90
  const cached = this.states.get(id);
86
91
 
87
92
  if (cached) return structuredClone(cached);
93
+
88
94
  const path = this.path(id);
95
+ const initialAdapter =
96
+ this.options.defaultHarnessForThread?.(this.options.getThread(id)) ?? this.options.adapter;
89
97
  const state = existsSync(path)
90
98
  ? v.parse(ThreadAgentSchema, JSON.parse(readFileSync(path, "utf8")))
91
99
  : {
92
100
  threadId: id,
93
- harness: this.options.adapter
94
- ? { id: this.options.adapter.id, label: this.options.adapter.label }
101
+ harness: initialAdapter
102
+ ? { id: initialAdapter.id, label: initialAdapter.label }
95
103
  : undefined,
96
104
  phase: { kind: "idle" as const },
97
105
  messages: [],
@@ -100,26 +108,29 @@ export class ThreadAgentManager {
100
108
  };
101
109
 
102
110
  if (state.threadId !== id) throw new Error("Thread agent record has the wrong thread identity");
111
+
112
+ const recoverable = this.canRecover(state);
113
+
103
114
  if (
104
115
  state.phase.kind === "running" ||
105
116
  state.phase.kind === "permission" ||
106
117
  state.submissions?.some((entry) => entry.status === "queued" || entry.status === "running")
107
118
  ) {
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
- };
119
+ state.phase = recoverable
120
+ ? { kind: "idle" }
121
+ : {
122
+ kind: "failed",
123
+ error: "Agent was interrupted by a daemon restart. Send a message to resume.",
124
+ };
115
125
  for (const submission of state.submissions ?? []) {
116
126
  if (submission.status === "running" || submission.status === "queued")
117
- submission.status = state.harness?.id === "pi" ? "queued" : "failed";
127
+ submission.status = recoverable ? "queued" : "failed";
118
128
  }
119
129
  for (const tool of state.tools) {
120
130
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
121
131
  }
122
132
  }
133
+
123
134
  settlePromptOperations(state);
124
135
  this.states.set(id, state);
125
136
 
@@ -138,8 +149,11 @@ export class ThreadAgentManager {
138
149
 
139
150
  return structuredClone(state);
140
151
  }
152
+
141
153
  if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
154
+
142
155
  if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
156
+
143
157
  assertHarnessIdentity(state, this.adapter(state));
144
158
  const before = structuredClone(state);
145
159
 
@@ -152,13 +166,22 @@ export class ThreadAgentManager {
152
166
  this.enqueue(state, revision, input, promptMirrorIdentity(params));
153
167
 
154
168
  if (params.commentId) {
155
- this.enqueueComment(state, thread, params.commentId, enqueue, params);
169
+ // Shared requests carry an accepted discussion snapshot; local requests read the saved note.
170
+ this.enqueueComment(
171
+ state,
172
+ thread,
173
+ params.commentId,
174
+ enqueue,
175
+ params.discussion === undefined ? undefined : params,
176
+ );
156
177
  } else if (params.retry) {
157
178
  const submission = submissions.find((entry) => entry.id === params.retry);
158
179
 
159
180
  if (!submission || submission.status !== "failed")
160
181
  throw new Error("Thread agent retry requires a failed submission");
182
+
161
183
  delete submission.cancelled;
184
+ submission.attemptId = randomUUID();
162
185
  submission.status = stepAgentSubmission(submission.status, "retry", true);
163
186
  } else {
164
187
  const accepted = new Set(submissions.map((entry) => entry.commentId));
@@ -167,8 +190,10 @@ export class ThreadAgentManager {
167
190
  this.enqueueComment(state, thread, annotation.id, enqueue);
168
191
  for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
169
192
  this.enqueueComment(state, thread, comment.id, enqueue);
193
+
170
194
  if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
171
195
  }
196
+
172
197
  recordPromptOperation(state, before, params);
173
198
  this.save(state);
174
199
  } catch (error) {
@@ -194,15 +219,28 @@ export class ThreadAgentManager {
194
219
  value?: string;
195
220
  }): Promise<ThreadAgentState> {
196
221
  this.assertEnabled(params.id);
222
+
197
223
  if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
224
+
198
225
  const state = this.mutable(params.id);
199
226
 
227
+ if (params.configId === "harness" && params.value !== undefined)
228
+ return this.switchHarness(state, params.value);
229
+
230
+ if (state.handoff && params.configId === undefined) {
231
+ this.drain(state);
232
+
233
+ return structuredClone(state);
234
+ }
235
+
200
236
  if (state.phase.kind === "running" || state.phase.kind === "permission") {
201
237
  // Discovery can read existing choices without reconfiguring an active turn.
202
238
  if (params.configId === undefined || params.value === undefined)
203
239
  return structuredClone(state);
240
+
204
241
  throw new Error("Thread agent configuration waits for the current turn");
205
242
  }
243
+
206
244
  const active = await this.connect(state, this.options.getThread(params.id), "", 1);
207
245
 
208
246
  if (params.configId === undefined || params.value === undefined) {
@@ -210,18 +248,188 @@ export class ThreadAgentManager {
210
248
 
211
249
  if (current.phase.kind !== "running" && current.phase.kind !== "permission")
212
250
  active.turn = stepAgentTurn(active.turn, "finished");
251
+
213
252
  this.drain(state);
214
253
 
215
254
  return structuredClone(state);
216
255
  }
256
+
217
257
  if (!active.connection.configure)
218
258
  throw new Error("Thread agent harness does not advertise configuration");
259
+
219
260
  await active.connection.configure(params.configId, params.value);
220
261
  this.save(state);
221
262
 
222
263
  return structuredClone(state);
223
264
  }
224
265
 
266
+ private switchHarness(state: ThreadAgentState, target: string): ThreadAgentState {
267
+ const adapter = this.options.adapters?.[target];
268
+
269
+ if (!adapter || adapter.recovery !== "durable")
270
+ throw new Error("Thread harness is unavailable for durable handoff");
271
+
272
+ if (state.handoff && (state.handoff.status !== "queued" || state.phase.kind === "running")) {
273
+ state.handoff.requestedTarget = target;
274
+ this.save(state);
275
+
276
+ return structuredClone(state);
277
+ }
278
+
279
+ if (this.adapter(state).id === target) state.handoff = undefined;
280
+ else
281
+ state.handoff = {
282
+ target,
283
+ operationId: randomUUID(),
284
+ status: "queued",
285
+ source: state.harness ? { ...state.harness } : undefined,
286
+ };
287
+
288
+ this.save(state);
289
+ this.drain(state);
290
+
291
+ return structuredClone(state);
292
+ }
293
+
294
+ private handoffCurrent(state: ThreadAgentState): boolean {
295
+ return !this.disposed && this.states.get(state.threadId) === state;
296
+ }
297
+
298
+ private coalesceQueuedHandoff(
299
+ state: ThreadAgentState,
300
+ handoff: NonNullable<ThreadAgentState["handoff"]>,
301
+ ): ThreadAgentState["handoff"] {
302
+ if (handoff.status !== "queued" || !handoff.requestedTarget) return handoff;
303
+
304
+ const latest = { ...handoff, target: handoff.requestedTarget, requestedTarget: undefined };
305
+
306
+ if (this.adapter(state).id === latest.target) {
307
+ state.handoff = undefined;
308
+ this.save(state);
309
+ this.drain(state);
310
+
311
+ return undefined;
312
+ }
313
+
314
+ state.handoff = latest;
315
+
316
+ return latest;
317
+ }
318
+
319
+ private async performHandoff(state: ThreadAgentState): Promise<void> {
320
+ let handoff = state.handoff;
321
+
322
+ if (!this.handoffCurrent(state)) return;
323
+
324
+ if (!handoff || state.phase.kind === "running" || state.phase.kind === "permission") return;
325
+
326
+ handoff = this.coalesceQueuedHandoff(state, handoff);
327
+
328
+ if (!handoff) return;
329
+
330
+ state.phase = { kind: "running" };
331
+ this.save(state);
332
+ let active: ActiveAgent | undefined;
333
+ const original = handoff.status === "queued" ? state.harness : handoff.source;
334
+ const intent = { ...handoff, source: original ? { ...original } : undefined };
335
+
336
+ try {
337
+ const thread = this.options.getThread(state.threadId);
338
+ const text = await this.prepareHandoff(state, thread, intent);
339
+
340
+ if (!this.handoffCurrent(state)) return;
341
+
342
+ const sessionId = state.harness?.sessionId;
343
+
344
+ state.handoff = {
345
+ ...intent,
346
+ requestedTarget: state.handoff?.requestedTarget,
347
+ status: "prepared",
348
+ text,
349
+ };
350
+ this.save(state);
351
+ await this.retire(state.threadId);
352
+
353
+ if (!this.handoffCurrent(state)) return;
354
+
355
+ const target = this.options.adapters![handoff.target]!;
356
+
357
+ state.harness = { id: target.id, label: target.label, sessionId };
358
+ state.configOptions = [];
359
+ active = await this.connect(state, thread, "", 1);
360
+
361
+ if (!this.handoffCurrent(state)) return;
362
+
363
+ active.turn = stepAgentTurn(active.turn, "finished");
364
+ state.continuation = text || undefined;
365
+ const requestedTarget = state.handoff?.requestedTarget;
366
+
367
+ state.handoff =
368
+ requestedTarget && requestedTarget !== target.id
369
+ ? {
370
+ target: requestedTarget,
371
+ operationId: randomUUID(),
372
+ status: "queued",
373
+ source: { ...state.harness },
374
+ }
375
+ : undefined;
376
+ state.phase = { kind: "idle" };
377
+ this.save(state);
378
+ this.drain(state);
379
+ } catch (error) {
380
+ await this.retire(state.threadId, active);
381
+
382
+ if (!this.handoffCurrent(state)) return;
383
+
384
+ state.harness = original;
385
+ state.handoff = undefined;
386
+ state.phase = {
387
+ kind: "failed",
388
+ error: error instanceof Error ? error.message : "Thread harness handoff failed",
389
+ };
390
+ this.save(state);
391
+ }
392
+ }
393
+
394
+ private async prepareHandoff(
395
+ state: ThreadAgentState,
396
+ thread: Thread,
397
+ handoff: NonNullable<ThreadAgentState["handoff"]>,
398
+ ): Promise<string> {
399
+ if (handoff.status === "prepared") return handoff.text;
400
+
401
+ if (!state.messages.length) return "";
402
+
403
+ const active = await this.connect(
404
+ state,
405
+ thread,
406
+ handoff.operationId,
407
+ thread.revisions.at(-1)?.revision ?? 1,
408
+ );
409
+
410
+ if (!this.handoffCurrent(state)) throw new Error("Thread handoff was closed");
411
+
412
+ active.turnId = handoff.operationId;
413
+ active.attemptId = undefined;
414
+ active.handoffText = new Map();
415
+ state.handoff = {
416
+ ...handoff,
417
+ requestedTarget: state.handoff?.requestedTarget,
418
+ status: "summarizing",
419
+ prompt: handoff.prompt ?? harnessHandoffPrompt(thread, state),
420
+ };
421
+ this.save(state);
422
+ const result = await active.connection.prompt(state.handoff.prompt!, handoff.operationId);
423
+
424
+ if (result.outcome !== "completed") throw new Error("Thread harness handoff did not complete");
425
+
426
+ const text = [...active.handoffText.values()].join("\n").trim();
427
+
428
+ if (!text) throw new Error("Thread harness handoff returned no continuation context");
429
+
430
+ return text;
431
+ }
432
+
225
433
  private enqueueComment(
226
434
  state: ThreadAgentState,
227
435
  thread: Thread,
@@ -235,6 +443,7 @@ export class ThreadAgentManager {
235
443
 
236
444
  if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
237
445
  throw new Error("Shared agent comment is unavailable or already sent");
446
+
238
447
  enqueue({
239
448
  commentId,
240
449
  prompt: frozen?.text ?? input.body,
@@ -242,6 +451,7 @@ export class ThreadAgentManager {
242
451
  messageId: comment?.messageId,
243
452
  context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
244
453
  });
454
+
245
455
  if (comment) comment.sent = true;
246
456
  }
247
457
 
@@ -261,8 +471,10 @@ export class ThreadAgentManager {
261
471
  128
262
472
  )
263
473
  throw new Error("Thread agent prototype reached its 128-message limit");
474
+
264
475
  if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
265
476
  throw new Error("Thread agent input exceeds 320 KiB");
477
+
266
478
  const used = state.messages.reduce(
267
479
  (total, message) => total + Buffer.byteLength(message.text),
268
480
  0,
@@ -270,6 +482,7 @@ export class ThreadAgentManager {
270
482
 
271
483
  if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
272
484
  throw new Error("Thread agent input exceeds transcript limit");
485
+
273
486
  const submission: AgentSubmission = {
274
487
  ...input,
275
488
  id: operationId ?? randomUUID(),
@@ -297,9 +510,17 @@ export class ThreadAgentManager {
297
510
  this.retiring.has(state.threadId)
298
511
  )
299
512
  return;
513
+
514
+ if (state.handoff) {
515
+ void this.performHandoff(state);
516
+
517
+ return;
518
+ }
519
+
300
520
  const submission = state.submissions?.find((entry) => entry.status === "queued");
301
521
 
302
522
  if (!submission) return;
523
+
303
524
  const before = structuredClone(state);
304
525
 
305
526
  submission.status = stepAgentSubmission(submission.status, "start", true);
@@ -327,14 +548,17 @@ Discussion context: ${submission.context ?? submission.messageId ?? ""}
327
548
  Input: ${submission.prompt}`;
328
549
 
329
550
  try {
330
- submission.harnessPrompt = prompt;
551
+ submission.harnessPrompt = state.continuation
552
+ ? harnessContinuationPrompt(state.continuation, prompt)
553
+ : prompt;
554
+ state.continuation = undefined;
331
555
  this.save(state);
332
556
  } catch (error) {
333
557
  // Acceptance is already durable; a retry can resume the queue before any harness request.
334
558
  restoreAgentState(state, before);
335
559
  throw error;
336
560
  }
337
- void this.run(state, thread, submission.id, revision, prompt, [], submission);
561
+ void this.run(state, thread, submission.id, revision, submission.harnessPrompt!, submission);
338
562
  }
339
563
 
340
564
  /** Attach quote-primary feedback only to a finalized agent message. */
@@ -344,8 +568,10 @@ Input: ${submission.prompt}`;
344
568
 
345
569
  if (!message || !message.complete)
346
570
  throw new Error("Thread agent comment requires a completed answer");
571
+
347
572
  if (!resolveAnchor(params.comment.anchor, parseBlocks(message.text)))
348
573
  throw new Error("Thread agent comment quote does not resolve");
574
+
349
575
  const index = state.comments.findIndex((comment) => comment.id === params.comment.id);
350
576
  const previous = state.comments[index];
351
577
  const root = params.comment.replyTo
@@ -357,19 +583,24 @@ Input: ${submission.prompt}`;
357
583
  (!root || root.messageId !== message.id || root.id === params.comment.id)
358
584
  )
359
585
  throw new Error("Thread agent reply requires a comment on the same answer");
586
+
360
587
  if (
361
588
  this.isReadOnly(params.id, params.comment.id) ||
362
589
  (previous && (previous.sent || previous.messageId !== params.comment.messageId))
363
590
  )
364
591
  throw new Error("Thread agent delivered comments are immutable");
592
+
365
593
  if (Buffer.byteLength(JSON.stringify(params.comment)) > 32_768)
366
594
  throw new Error("Thread agent comment exceeds 32 KiB");
595
+
367
596
  if (index >= 0) state.comments[index] = { ...params.comment, sent: false };
368
597
  else {
369
598
  if (state.comments.length >= 128)
370
599
  throw new Error("Thread agent prototype reached its comment limit");
600
+
371
601
  state.comments.push({ ...params.comment, sent: false });
372
602
  }
603
+
373
604
  this.save(state);
374
605
 
375
606
  return structuredClone(state);
@@ -381,10 +612,12 @@ Input: ${submission.prompt}`;
381
612
  const comment = agentCommentRoot(state, commentId);
382
613
 
383
614
  if (!comment) throw new Error("Thread agent reply comment does not exist");
615
+
384
616
  const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
385
617
 
386
618
  if (state.comments.length >= 128)
387
619
  throw new Error("Thread agent prototype reached its comment limit");
620
+
388
621
  state.comments.push({
389
622
  ...root,
390
623
  id: randomUUID(),
@@ -408,6 +641,7 @@ Input: ${submission.prompt}`;
408
641
  const submission = state.submissions?.find((entry) => entry.status === "running");
409
642
 
410
643
  if (submission) submission.cancelled = true;
644
+
411
645
  for (const tool of state.tools) {
412
646
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
413
647
  }
@@ -431,8 +665,10 @@ Input: ${submission.prompt}`;
431
665
  ) {
432
666
  throw new Error("Thread agent permission request is no longer pending");
433
667
  }
668
+
434
669
  if (!state.phase.permission.options.some((option) => option.optionId === params.optionId))
435
670
  throw new Error("Thread agent permission option was not advertised");
671
+
436
672
  active.connection.permission(params.requestId, params.optionId);
437
673
  state.phase = { kind: "running" };
438
674
  this.save(state);
@@ -467,7 +703,9 @@ Input: ${submission.prompt}`;
467
703
  this.active.delete(id);
468
704
  active?.connection.cancel();
469
705
  await active?.connection.close();
706
+
470
707
  if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
708
+
471
709
  rmSync(this.path(id), { force: true });
472
710
  rmSync(this.path(id) + ".tmp", { force: true });
473
711
  rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
@@ -476,17 +714,39 @@ Input: ${submission.prompt}`;
476
714
  }
477
715
  }
478
716
 
717
+ private canRecover(state: ThreadAgentState): boolean {
718
+ return this.availableAdapter(state)?.recovery === "durable";
719
+ }
720
+
721
+ private availableAdapter(state: ThreadAgentState): AgentHarnessAdapter | undefined {
722
+ const preferred =
723
+ this.options.defaultHarnessForThread?.(this.options.getThread(state.threadId)) ??
724
+ this.options.adapter;
725
+
726
+ if (!state.harness) return preferred;
727
+
728
+ return (
729
+ this.options.adapters?.[state.harness.id] ??
730
+ (preferred?.id === state.harness.id ? preferred : undefined)
731
+ );
732
+ }
733
+
479
734
  private adapter(state: ThreadAgentState): AgentHarnessAdapter {
480
- const adapter =
481
- (state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
735
+ const adapter = this.availableAdapter(state);
482
736
 
483
- if (!adapter) throw new Error("Thread agent harness is not configured");
737
+ if (!adapter)
738
+ throw new Error(
739
+ state.harness && this.options.adapter
740
+ ? "Thread is bound to a different harness"
741
+ : "Thread agent harness is not configured",
742
+ );
484
743
 
485
744
  return adapter;
486
745
  }
487
746
 
488
747
  private assertReadable(id: string): void {
489
748
  if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
749
+
490
750
  this.options.getThread(id);
491
751
  }
492
752
 
@@ -502,11 +762,14 @@ Input: ${submission.prompt}`;
502
762
 
503
763
  private save(state: ThreadAgentState): void {
504
764
  settlePromptOperations(state);
765
+
505
766
  if (this.states.get(state.threadId) !== state) return;
767
+
506
768
  const data = JSON.stringify(state);
507
769
 
508
770
  if (Buffer.byteLength(data) > 8 * 1024 * 1024)
509
771
  throw new Error("Thread agent transcript exceeds 8 MiB");
772
+
510
773
  mkdirSync(this.directory, { recursive: true, mode: 0o700 });
511
774
  writeFileSync(this.path(state.threadId) + ".tmp", data, { mode: 0o600 });
512
775
  renameSync(this.path(state.threadId) + ".tmp", this.path(state.threadId));
@@ -519,38 +782,45 @@ Input: ${submission.prompt}`;
519
782
  turnId: string,
520
783
  revision: number,
521
784
  prompt: string,
522
- comments: AgentComment[],
523
785
  submission?: AgentSubmission,
524
786
  ): Promise<void> {
525
787
  let active = this.active.get(state.threadId);
526
- const messageStart = state.messages.length;
527
788
 
528
789
  try {
529
790
  active = await this.connect(state, thread, turnId, revision);
791
+
530
792
  if (this.states.get(state.threadId) !== state) return;
793
+
531
794
  active.turnId = turnId;
532
795
  active.revision = revision;
796
+ active.attemptId = submission?.attemptId;
797
+
533
798
  // Cancellation during initialization must not submit a new model request.
534
799
  if (active.turn.kind === "idle") {
535
800
  if (submission) submission.status = "failed";
801
+
536
802
  state.phase = { kind: "idle" };
537
803
  this.save(state);
538
804
  this.drain(state);
539
805
 
540
806
  return;
541
807
  }
542
- const result = await active.connection.prompt(prompt, turnId);
808
+
809
+ const result = await active.connection.prompt(prompt, submission?.attemptId ?? turnId);
543
810
 
544
811
  if (this.states.get(state.threadId) !== state) return;
545
- finalizeAgentMessages(state, messageStart, result.outcome);
812
+
813
+ finalizeAgentMessages(state, active, result.outcome);
814
+
546
815
  if (submission && result.outcome === "cancelled") submission.cancelled = true;
816
+
547
817
  if (submission)
548
818
  submission.status = stepAgentSubmission(
549
819
  submission.status,
550
820
  result.outcome === "completed" ? "complete" : "fail",
551
821
  true,
552
822
  );
553
- if (result.outcome === "completed") for (const comment of comments) comment.sent = true;
823
+
554
824
  for (const tool of state.tools) {
555
825
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
556
826
  }
@@ -560,11 +830,13 @@ Input: ${submission.prompt}`;
560
830
  this.drain(state);
561
831
  } catch (error) {
562
832
  if (submission) submission.status = "failed";
833
+
563
834
  state.phase = {
564
835
  kind: "failed",
565
836
  error: error instanceof Error ? error.message : "Thread agent request failed",
566
837
  };
567
838
  await this.retire(state.threadId, active);
839
+
568
840
  if (!this.disposed && this.states.get(state.threadId) === state) {
569
841
  this.save(state);
570
842
  this.drain(state);
@@ -576,6 +848,7 @@ Input: ${submission.prompt}`;
576
848
  const existing = this.retiring.get(id);
577
849
 
578
850
  if (existing) return existing;
851
+
579
852
  this.active.delete(id);
580
853
  const closing = Promise.resolve()
581
854
  .then(() => active?.connection.close())
@@ -598,10 +871,13 @@ Input: ${submission.prompt}`;
598
871
  const retiring = this.retiring.get(state.threadId);
599
872
 
600
873
  if (retiring) await retiring;
874
+
601
875
  if (this.disposed) throw new Error("Thread agent is shutting down");
876
+
602
877
  const pending = this.initializing.get(state.threadId);
603
878
 
604
879
  if (pending) return pending;
880
+
605
881
  const promise = this.startConnection(state, thread, turnId, revision);
606
882
 
607
883
  this.initializing.set(state.threadId, promise);
@@ -612,6 +888,22 @@ Input: ${submission.prompt}`;
612
888
  }
613
889
  }
614
890
 
891
+ private handoffTools(state: ThreadAgentState, thread: Thread): AgentHarnessTools | undefined {
892
+ const tools = this.options.tools?.(thread);
893
+
894
+ if (!tools) return undefined;
895
+
896
+ return {
897
+ ...tools,
898
+ call: (name, input) => {
899
+ if (state.handoff?.status === "summarizing")
900
+ throw new Error("Thread tools are unavailable while preparing a harness handoff");
901
+
902
+ return tools.call(name, input);
903
+ },
904
+ };
905
+ }
906
+
615
907
  private async startConnection(
616
908
  state: ThreadAgentState,
617
909
  thread: Thread,
@@ -625,11 +917,13 @@ Input: ${submission.prompt}`;
625
917
  const connection = adapter.connect({
626
918
  cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
627
919
  sessionId: state.harness?.sessionId,
628
- tools: this.options.tools?.(thread),
920
+ tools: this.handoffTools(state, thread),
629
921
  onEvent: (event) => this.receive(state, event),
630
922
  onExit: (error) => {
631
923
  if (this.active.get(state.threadId)?.connection !== connection) return;
924
+
632
925
  this.active.delete(state.threadId);
926
+
633
927
  if (!this.disposed) {
634
928
  state.phase = { kind: "failed", error: error.message };
635
929
  this.save(state);
@@ -642,6 +936,7 @@ Input: ${submission.prompt}`;
642
936
  const sessionId = await connection.start();
643
937
 
644
938
  if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
939
+
645
940
  state.harness = {
646
941
  id: adapter.id,
647
942
  label: adapter.label,
@@ -657,7 +952,20 @@ Input: ${submission.prompt}`;
657
952
  }
658
953
 
659
954
  private receive(state: ThreadAgentState, event: AgentHarnessEvent): void {
955
+ const handoff = this.active.get(state.threadId)?.handoffText;
956
+
957
+ if (handoff && event.kind === "message") {
958
+ const id = event.id ?? "summary";
959
+
960
+ handoff.set(id, event.replace ? event.text : (handoff.get(id) ?? "") + event.text);
961
+
962
+ return;
963
+ }
964
+
965
+ if (handoff && event.kind === "tool") return;
966
+
660
967
  if (this.states.get(state.threadId) !== state) return;
968
+
661
969
  const routed = routeHarnessOutput(event);
662
970
 
663
971
  if (routed.destination === "diagnostics") {
@@ -665,27 +973,33 @@ Input: ${submission.prompt}`;
665
973
 
666
974
  return;
667
975
  }
976
+
668
977
  event = routed.event;
978
+
669
979
  if (event.kind === "config") {
670
980
  state.configOptions = event.options;
671
981
  this.save(state);
672
982
 
673
983
  return;
674
984
  }
985
+
675
986
  const active = this.active.get(state.threadId);
676
987
 
677
988
  if (!active || active.turn.kind === "starting") return;
989
+
678
990
  if (event.kind === "permission") {
679
991
  if (agentTurnCancelled(active.turn)) {
680
992
  active.connection.permission(event.permission.id);
681
993
 
682
994
  return;
683
995
  }
996
+
684
997
  state.phase = { kind: "permission", permission: event.permission };
685
998
  this.save(state);
686
999
 
687
1000
  return;
688
1001
  }
1002
+
689
1003
  this.applyUpdate(state, active, event);
690
1004
  }
691
1005
 
@@ -695,7 +1009,9 @@ Input: ${submission.prompt}`;
695
1009
  update: Extract<AgentHarnessEvent, { kind: "message" | "tool" }>,
696
1010
  ): void {
697
1011
  if (update.kind === "message") {
698
- const messageId = update.id ?? `answer-${active.turnId}`;
1012
+ const messageId = active.attemptId
1013
+ ? `${active.attemptId}:${update.id ?? "answer"}`
1014
+ : (update.id ?? `answer-${active.turnId}`);
699
1015
  let message = state.messages.find((message) => message.id === messageId);
700
1016
 
701
1017
  if (!message) {
@@ -709,7 +1025,9 @@ Input: ${submission.prompt}`;
709
1025
  };
710
1026
  state.messages.push(message);
711
1027
  }
1028
+
712
1029
  if (message.complete) return;
1030
+
713
1031
  const used = state.messages.reduce(
714
1032
  (total, entry) => total + Buffer.byteLength(entry.text),
715
1033
  0,
@@ -736,6 +1054,7 @@ Input: ${submission.prompt}`;
736
1054
  if (!tool) {
737
1055
  if (state.tools.length >= 128)
738
1056
  throw new Error("Thread agent reached its tool activity limit");
1057
+
739
1058
  tool = {
740
1059
  id: update.id,
741
1060
  turnId: active.turnId,
@@ -747,16 +1066,22 @@ Input: ${submission.prompt}`;
747
1066
  };
748
1067
  state.tools.push(tool);
749
1068
  }
1069
+
750
1070
  if (update.title) tool.title = update.title.slice(0, 256);
1071
+
751
1072
  if (update.toolKind) tool.kind = update.toolKind;
1073
+
752
1074
  if (update.status)
753
1075
  tool.status = agentTurnCancelled(active.turn) ? "cancelled" : update.status;
1076
+
754
1077
  if (update.locations)
755
1078
  tool.locations = update.locations
756
1079
  .slice(0, 8)
757
1080
  .map((location) => ({ ...location, path: location.path.slice(0, 512) }));
1081
+
758
1082
  if (update.output !== undefined) tool.output = update.output.slice(0, 8192);
759
1083
  }
1084
+
760
1085
  this.save(state);
761
1086
  }
762
1087
  }
@@ -764,11 +1089,18 @@ Input: ${submission.prompt}`;
764
1089
  /** Only this turn can finalize its answers; stopped and historical partials stay incomplete. */
765
1090
  function finalizeAgentMessages(
766
1091
  state: ThreadAgentState,
767
- start: number,
1092
+ active: ActiveAgent,
768
1093
  outcome: AgentHarnessResult["outcome"],
769
1094
  ): void {
770
1095
  if (outcome !== "completed") return;
771
- for (const message of state.messages.slice(start)) message.complete = true;
1096
+
1097
+ for (const message of state.messages) {
1098
+ if (message.role !== "agent" || message.submissionId !== active.turnId) continue;
1099
+
1100
+ if (active.attemptId && !message.id.startsWith(`${active.attemptId}:`)) continue;
1101
+
1102
+ message.complete = true;
1103
+ }
772
1104
  }
773
1105
 
774
1106
  function isPendingAgentInput(
@@ -785,8 +1117,11 @@ function isPendingAgentInput(
785
1117
 
786
1118
  function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
787
1119
  if (before.submissions === undefined) delete state.submissions;
1120
+
788
1121
  if (before.promptOperations === undefined) delete state.promptOperations;
1122
+
789
1123
  if (before.harness === undefined) delete state.harness;
1124
+
790
1125
  Object.assign(state, before);
791
1126
  }
792
1127
 
@@ -824,6 +1159,7 @@ function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: s
824
1159
  .map((entry) => ({ id: entry.id, body: entry.body })),
825
1160
  });
826
1161
  }
1162
+
827
1163
  const comment = agentCommentRoot(state, commentId)!;
828
1164
  const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
829
1165