@cueloop/daemon 0.1.0-alpha.102 → 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 +353 -24
  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
 
@@ -165,7 +179,9 @@ export class ThreadAgentManager {
165
179
 
166
180
  if (!submission || submission.status !== "failed")
167
181
  throw new Error("Thread agent retry requires a failed submission");
182
+
168
183
  delete submission.cancelled;
184
+ submission.attemptId = randomUUID();
169
185
  submission.status = stepAgentSubmission(submission.status, "retry", true);
170
186
  } else {
171
187
  const accepted = new Set(submissions.map((entry) => entry.commentId));
@@ -174,8 +190,10 @@ export class ThreadAgentManager {
174
190
  this.enqueueComment(state, thread, annotation.id, enqueue);
175
191
  for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
176
192
  this.enqueueComment(state, thread, comment.id, enqueue);
193
+
177
194
  if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
178
195
  }
196
+
179
197
  recordPromptOperation(state, before, params);
180
198
  this.save(state);
181
199
  } catch (error) {
@@ -201,15 +219,28 @@ export class ThreadAgentManager {
201
219
  value?: string;
202
220
  }): Promise<ThreadAgentState> {
203
221
  this.assertEnabled(params.id);
222
+
204
223
  if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
224
+
205
225
  const state = this.mutable(params.id);
206
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
+
207
236
  if (state.phase.kind === "running" || state.phase.kind === "permission") {
208
237
  // Discovery can read existing choices without reconfiguring an active turn.
209
238
  if (params.configId === undefined || params.value === undefined)
210
239
  return structuredClone(state);
240
+
211
241
  throw new Error("Thread agent configuration waits for the current turn");
212
242
  }
243
+
213
244
  const active = await this.connect(state, this.options.getThread(params.id), "", 1);
214
245
 
215
246
  if (params.configId === undefined || params.value === undefined) {
@@ -217,18 +248,188 @@ export class ThreadAgentManager {
217
248
 
218
249
  if (current.phase.kind !== "running" && current.phase.kind !== "permission")
219
250
  active.turn = stepAgentTurn(active.turn, "finished");
251
+
220
252
  this.drain(state);
221
253
 
222
254
  return structuredClone(state);
223
255
  }
256
+
224
257
  if (!active.connection.configure)
225
258
  throw new Error("Thread agent harness does not advertise configuration");
259
+
226
260
  await active.connection.configure(params.configId, params.value);
227
261
  this.save(state);
228
262
 
229
263
  return structuredClone(state);
230
264
  }
231
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
+
232
433
  private enqueueComment(
233
434
  state: ThreadAgentState,
234
435
  thread: Thread,
@@ -242,6 +443,7 @@ export class ThreadAgentManager {
242
443
 
243
444
  if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
244
445
  throw new Error("Shared agent comment is unavailable or already sent");
446
+
245
447
  enqueue({
246
448
  commentId,
247
449
  prompt: frozen?.text ?? input.body,
@@ -249,6 +451,7 @@ export class ThreadAgentManager {
249
451
  messageId: comment?.messageId,
250
452
  context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
251
453
  });
454
+
252
455
  if (comment) comment.sent = true;
253
456
  }
254
457
 
@@ -268,8 +471,10 @@ export class ThreadAgentManager {
268
471
  128
269
472
  )
270
473
  throw new Error("Thread agent prototype reached its 128-message limit");
474
+
271
475
  if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
272
476
  throw new Error("Thread agent input exceeds 320 KiB");
477
+
273
478
  const used = state.messages.reduce(
274
479
  (total, message) => total + Buffer.byteLength(message.text),
275
480
  0,
@@ -277,6 +482,7 @@ export class ThreadAgentManager {
277
482
 
278
483
  if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
279
484
  throw new Error("Thread agent input exceeds transcript limit");
485
+
280
486
  const submission: AgentSubmission = {
281
487
  ...input,
282
488
  id: operationId ?? randomUUID(),
@@ -304,9 +510,17 @@ export class ThreadAgentManager {
304
510
  this.retiring.has(state.threadId)
305
511
  )
306
512
  return;
513
+
514
+ if (state.handoff) {
515
+ void this.performHandoff(state);
516
+
517
+ return;
518
+ }
519
+
307
520
  const submission = state.submissions?.find((entry) => entry.status === "queued");
308
521
 
309
522
  if (!submission) return;
523
+
310
524
  const before = structuredClone(state);
311
525
 
312
526
  submission.status = stepAgentSubmission(submission.status, "start", true);
@@ -334,14 +548,17 @@ Discussion context: ${submission.context ?? submission.messageId ?? ""}
334
548
  Input: ${submission.prompt}`;
335
549
 
336
550
  try {
337
- submission.harnessPrompt = prompt;
551
+ submission.harnessPrompt = state.continuation
552
+ ? harnessContinuationPrompt(state.continuation, prompt)
553
+ : prompt;
554
+ state.continuation = undefined;
338
555
  this.save(state);
339
556
  } catch (error) {
340
557
  // Acceptance is already durable; a retry can resume the queue before any harness request.
341
558
  restoreAgentState(state, before);
342
559
  throw error;
343
560
  }
344
- void this.run(state, thread, submission.id, revision, prompt, [], submission);
561
+ void this.run(state, thread, submission.id, revision, submission.harnessPrompt!, submission);
345
562
  }
346
563
 
347
564
  /** Attach quote-primary feedback only to a finalized agent message. */
@@ -351,8 +568,10 @@ Input: ${submission.prompt}`;
351
568
 
352
569
  if (!message || !message.complete)
353
570
  throw new Error("Thread agent comment requires a completed answer");
571
+
354
572
  if (!resolveAnchor(params.comment.anchor, parseBlocks(message.text)))
355
573
  throw new Error("Thread agent comment quote does not resolve");
574
+
356
575
  const index = state.comments.findIndex((comment) => comment.id === params.comment.id);
357
576
  const previous = state.comments[index];
358
577
  const root = params.comment.replyTo
@@ -364,19 +583,24 @@ Input: ${submission.prompt}`;
364
583
  (!root || root.messageId !== message.id || root.id === params.comment.id)
365
584
  )
366
585
  throw new Error("Thread agent reply requires a comment on the same answer");
586
+
367
587
  if (
368
588
  this.isReadOnly(params.id, params.comment.id) ||
369
589
  (previous && (previous.sent || previous.messageId !== params.comment.messageId))
370
590
  )
371
591
  throw new Error("Thread agent delivered comments are immutable");
592
+
372
593
  if (Buffer.byteLength(JSON.stringify(params.comment)) > 32_768)
373
594
  throw new Error("Thread agent comment exceeds 32 KiB");
595
+
374
596
  if (index >= 0) state.comments[index] = { ...params.comment, sent: false };
375
597
  else {
376
598
  if (state.comments.length >= 128)
377
599
  throw new Error("Thread agent prototype reached its comment limit");
600
+
378
601
  state.comments.push({ ...params.comment, sent: false });
379
602
  }
603
+
380
604
  this.save(state);
381
605
 
382
606
  return structuredClone(state);
@@ -388,10 +612,12 @@ Input: ${submission.prompt}`;
388
612
  const comment = agentCommentRoot(state, commentId);
389
613
 
390
614
  if (!comment) throw new Error("Thread agent reply comment does not exist");
615
+
391
616
  const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
392
617
 
393
618
  if (state.comments.length >= 128)
394
619
  throw new Error("Thread agent prototype reached its comment limit");
620
+
395
621
  state.comments.push({
396
622
  ...root,
397
623
  id: randomUUID(),
@@ -415,6 +641,7 @@ Input: ${submission.prompt}`;
415
641
  const submission = state.submissions?.find((entry) => entry.status === "running");
416
642
 
417
643
  if (submission) submission.cancelled = true;
644
+
418
645
  for (const tool of state.tools) {
419
646
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
420
647
  }
@@ -438,8 +665,10 @@ Input: ${submission.prompt}`;
438
665
  ) {
439
666
  throw new Error("Thread agent permission request is no longer pending");
440
667
  }
668
+
441
669
  if (!state.phase.permission.options.some((option) => option.optionId === params.optionId))
442
670
  throw new Error("Thread agent permission option was not advertised");
671
+
443
672
  active.connection.permission(params.requestId, params.optionId);
444
673
  state.phase = { kind: "running" };
445
674
  this.save(state);
@@ -474,7 +703,9 @@ Input: ${submission.prompt}`;
474
703
  this.active.delete(id);
475
704
  active?.connection.cancel();
476
705
  await active?.connection.close();
706
+
477
707
  if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
708
+
478
709
  rmSync(this.path(id), { force: true });
479
710
  rmSync(this.path(id) + ".tmp", { force: true });
480
711
  rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
@@ -483,17 +714,39 @@ Input: ${submission.prompt}`;
483
714
  }
484
715
  }
485
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
+
486
734
  private adapter(state: ThreadAgentState): AgentHarnessAdapter {
487
- const adapter =
488
- (state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
735
+ const adapter = this.availableAdapter(state);
489
736
 
490
- 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
+ );
491
743
 
492
744
  return adapter;
493
745
  }
494
746
 
495
747
  private assertReadable(id: string): void {
496
748
  if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
749
+
497
750
  this.options.getThread(id);
498
751
  }
499
752
 
@@ -509,11 +762,14 @@ Input: ${submission.prompt}`;
509
762
 
510
763
  private save(state: ThreadAgentState): void {
511
764
  settlePromptOperations(state);
765
+
512
766
  if (this.states.get(state.threadId) !== state) return;
767
+
513
768
  const data = JSON.stringify(state);
514
769
 
515
770
  if (Buffer.byteLength(data) > 8 * 1024 * 1024)
516
771
  throw new Error("Thread agent transcript exceeds 8 MiB");
772
+
517
773
  mkdirSync(this.directory, { recursive: true, mode: 0o700 });
518
774
  writeFileSync(this.path(state.threadId) + ".tmp", data, { mode: 0o600 });
519
775
  renameSync(this.path(state.threadId) + ".tmp", this.path(state.threadId));
@@ -526,38 +782,45 @@ Input: ${submission.prompt}`;
526
782
  turnId: string,
527
783
  revision: number,
528
784
  prompt: string,
529
- comments: AgentComment[],
530
785
  submission?: AgentSubmission,
531
786
  ): Promise<void> {
532
787
  let active = this.active.get(state.threadId);
533
- const messageStart = state.messages.length;
534
788
 
535
789
  try {
536
790
  active = await this.connect(state, thread, turnId, revision);
791
+
537
792
  if (this.states.get(state.threadId) !== state) return;
793
+
538
794
  active.turnId = turnId;
539
795
  active.revision = revision;
796
+ active.attemptId = submission?.attemptId;
797
+
540
798
  // Cancellation during initialization must not submit a new model request.
541
799
  if (active.turn.kind === "idle") {
542
800
  if (submission) submission.status = "failed";
801
+
543
802
  state.phase = { kind: "idle" };
544
803
  this.save(state);
545
804
  this.drain(state);
546
805
 
547
806
  return;
548
807
  }
549
- const result = await active.connection.prompt(prompt, turnId);
808
+
809
+ const result = await active.connection.prompt(prompt, submission?.attemptId ?? turnId);
550
810
 
551
811
  if (this.states.get(state.threadId) !== state) return;
552
- finalizeAgentMessages(state, messageStart, result.outcome);
812
+
813
+ finalizeAgentMessages(state, active, result.outcome);
814
+
553
815
  if (submission && result.outcome === "cancelled") submission.cancelled = true;
816
+
554
817
  if (submission)
555
818
  submission.status = stepAgentSubmission(
556
819
  submission.status,
557
820
  result.outcome === "completed" ? "complete" : "fail",
558
821
  true,
559
822
  );
560
- if (result.outcome === "completed") for (const comment of comments) comment.sent = true;
823
+
561
824
  for (const tool of state.tools) {
562
825
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
563
826
  }
@@ -567,11 +830,13 @@ Input: ${submission.prompt}`;
567
830
  this.drain(state);
568
831
  } catch (error) {
569
832
  if (submission) submission.status = "failed";
833
+
570
834
  state.phase = {
571
835
  kind: "failed",
572
836
  error: error instanceof Error ? error.message : "Thread agent request failed",
573
837
  };
574
838
  await this.retire(state.threadId, active);
839
+
575
840
  if (!this.disposed && this.states.get(state.threadId) === state) {
576
841
  this.save(state);
577
842
  this.drain(state);
@@ -583,6 +848,7 @@ Input: ${submission.prompt}`;
583
848
  const existing = this.retiring.get(id);
584
849
 
585
850
  if (existing) return existing;
851
+
586
852
  this.active.delete(id);
587
853
  const closing = Promise.resolve()
588
854
  .then(() => active?.connection.close())
@@ -605,10 +871,13 @@ Input: ${submission.prompt}`;
605
871
  const retiring = this.retiring.get(state.threadId);
606
872
 
607
873
  if (retiring) await retiring;
874
+
608
875
  if (this.disposed) throw new Error("Thread agent is shutting down");
876
+
609
877
  const pending = this.initializing.get(state.threadId);
610
878
 
611
879
  if (pending) return pending;
880
+
612
881
  const promise = this.startConnection(state, thread, turnId, revision);
613
882
 
614
883
  this.initializing.set(state.threadId, promise);
@@ -619,6 +888,22 @@ Input: ${submission.prompt}`;
619
888
  }
620
889
  }
621
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
+
622
907
  private async startConnection(
623
908
  state: ThreadAgentState,
624
909
  thread: Thread,
@@ -632,11 +917,13 @@ Input: ${submission.prompt}`;
632
917
  const connection = adapter.connect({
633
918
  cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
634
919
  sessionId: state.harness?.sessionId,
635
- tools: this.options.tools?.(thread),
920
+ tools: this.handoffTools(state, thread),
636
921
  onEvent: (event) => this.receive(state, event),
637
922
  onExit: (error) => {
638
923
  if (this.active.get(state.threadId)?.connection !== connection) return;
924
+
639
925
  this.active.delete(state.threadId);
926
+
640
927
  if (!this.disposed) {
641
928
  state.phase = { kind: "failed", error: error.message };
642
929
  this.save(state);
@@ -649,6 +936,7 @@ Input: ${submission.prompt}`;
649
936
  const sessionId = await connection.start();
650
937
 
651
938
  if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
939
+
652
940
  state.harness = {
653
941
  id: adapter.id,
654
942
  label: adapter.label,
@@ -664,7 +952,20 @@ Input: ${submission.prompt}`;
664
952
  }
665
953
 
666
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
+
667
967
  if (this.states.get(state.threadId) !== state) return;
968
+
668
969
  const routed = routeHarnessOutput(event);
669
970
 
670
971
  if (routed.destination === "diagnostics") {
@@ -672,27 +973,33 @@ Input: ${submission.prompt}`;
672
973
 
673
974
  return;
674
975
  }
976
+
675
977
  event = routed.event;
978
+
676
979
  if (event.kind === "config") {
677
980
  state.configOptions = event.options;
678
981
  this.save(state);
679
982
 
680
983
  return;
681
984
  }
985
+
682
986
  const active = this.active.get(state.threadId);
683
987
 
684
988
  if (!active || active.turn.kind === "starting") return;
989
+
685
990
  if (event.kind === "permission") {
686
991
  if (agentTurnCancelled(active.turn)) {
687
992
  active.connection.permission(event.permission.id);
688
993
 
689
994
  return;
690
995
  }
996
+
691
997
  state.phase = { kind: "permission", permission: event.permission };
692
998
  this.save(state);
693
999
 
694
1000
  return;
695
1001
  }
1002
+
696
1003
  this.applyUpdate(state, active, event);
697
1004
  }
698
1005
 
@@ -702,7 +1009,9 @@ Input: ${submission.prompt}`;
702
1009
  update: Extract<AgentHarnessEvent, { kind: "message" | "tool" }>,
703
1010
  ): void {
704
1011
  if (update.kind === "message") {
705
- const messageId = update.id ?? `answer-${active.turnId}`;
1012
+ const messageId = active.attemptId
1013
+ ? `${active.attemptId}:${update.id ?? "answer"}`
1014
+ : (update.id ?? `answer-${active.turnId}`);
706
1015
  let message = state.messages.find((message) => message.id === messageId);
707
1016
 
708
1017
  if (!message) {
@@ -716,7 +1025,9 @@ Input: ${submission.prompt}`;
716
1025
  };
717
1026
  state.messages.push(message);
718
1027
  }
1028
+
719
1029
  if (message.complete) return;
1030
+
720
1031
  const used = state.messages.reduce(
721
1032
  (total, entry) => total + Buffer.byteLength(entry.text),
722
1033
  0,
@@ -743,6 +1054,7 @@ Input: ${submission.prompt}`;
743
1054
  if (!tool) {
744
1055
  if (state.tools.length >= 128)
745
1056
  throw new Error("Thread agent reached its tool activity limit");
1057
+
746
1058
  tool = {
747
1059
  id: update.id,
748
1060
  turnId: active.turnId,
@@ -754,16 +1066,22 @@ Input: ${submission.prompt}`;
754
1066
  };
755
1067
  state.tools.push(tool);
756
1068
  }
1069
+
757
1070
  if (update.title) tool.title = update.title.slice(0, 256);
1071
+
758
1072
  if (update.toolKind) tool.kind = update.toolKind;
1073
+
759
1074
  if (update.status)
760
1075
  tool.status = agentTurnCancelled(active.turn) ? "cancelled" : update.status;
1076
+
761
1077
  if (update.locations)
762
1078
  tool.locations = update.locations
763
1079
  .slice(0, 8)
764
1080
  .map((location) => ({ ...location, path: location.path.slice(0, 512) }));
1081
+
765
1082
  if (update.output !== undefined) tool.output = update.output.slice(0, 8192);
766
1083
  }
1084
+
767
1085
  this.save(state);
768
1086
  }
769
1087
  }
@@ -771,11 +1089,18 @@ Input: ${submission.prompt}`;
771
1089
  /** Only this turn can finalize its answers; stopped and historical partials stay incomplete. */
772
1090
  function finalizeAgentMessages(
773
1091
  state: ThreadAgentState,
774
- start: number,
1092
+ active: ActiveAgent,
775
1093
  outcome: AgentHarnessResult["outcome"],
776
1094
  ): void {
777
1095
  if (outcome !== "completed") return;
778
- 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
+ }
779
1104
  }
780
1105
 
781
1106
  function isPendingAgentInput(
@@ -792,8 +1117,11 @@ function isPendingAgentInput(
792
1117
 
793
1118
  function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
794
1119
  if (before.submissions === undefined) delete state.submissions;
1120
+
795
1121
  if (before.promptOperations === undefined) delete state.promptOperations;
1122
+
796
1123
  if (before.harness === undefined) delete state.harness;
1124
+
797
1125
  Object.assign(state, before);
798
1126
  }
799
1127
 
@@ -831,6 +1159,7 @@ function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: s
831
1159
  .map((entry) => ({ id: entry.id, body: entry.body })),
832
1160
  });
833
1161
  }
1162
+
834
1163
  const comment = agentCommentRoot(state, commentId)!;
835
1164
  const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
836
1165