@adecore/agents 0.39.0 → 0.40.0

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 (168) hide show
  1. package/dist/async.d.ts +5 -0
  2. package/dist/async.js +13 -5
  3. package/dist/chat/acp-protocol.js +3 -5
  4. package/dist/chat/chat-core.js +0 -2
  5. package/dist/chat/chat-process.d.ts +1 -0
  6. package/dist/chat/chat-process.js +1 -1
  7. package/dist/chat/chat-session.js +6 -8
  8. package/dist/chat/claude-protocol.js +1 -6
  9. package/dist/chat/claude-transcript.js +1 -3
  10. package/dist/chat/codex-protocol.js +1 -6
  11. package/dist/chat/projector.js +1 -3
  12. package/dist/chat/request-summary.js +1 -3
  13. package/dist/coded-error.js +1 -5
  14. package/dist/context/argv.js +1 -2
  15. package/dist/context/verb.js +1 -3
  16. package/dist/error-text.d.ts +1 -0
  17. package/dist/error-text.js +4 -0
  18. package/dist/fields.d.ts +3 -0
  19. package/dist/fields.js +12 -0
  20. package/dist/fs.d.ts +1 -0
  21. package/dist/fs.js +8 -4
  22. package/dist/lineage.js +11 -5
  23. package/dist/messages/notice-store.js +3 -4
  24. package/dist/providers/accounts/acp-account.js +3 -20
  25. package/dist/providers/accounts/service.d.ts +1 -2
  26. package/dist/providers/accounts/service.js +5 -9
  27. package/dist/providers/accounts/status.js +1 -3
  28. package/dist/providers/acp-agent-state.js +1 -3
  29. package/dist/providers/claude.js +0 -1
  30. package/dist/providers/codex.js +0 -1
  31. package/dist/providers/detect.js +1 -1
  32. package/dist/providers/shell-commands.js +1 -2
  33. package/dist/run-process.js +1 -3
  34. package/dist/skills.d.ts +1 -1
  35. package/dist/skills.js +5 -8
  36. package/dist/tasks/background-limit.js +3 -2
  37. package/dist/tasks/task-coordinator.js +11 -17
  38. package/dist/testing/watch.js +0 -1
  39. package/dist/usage/limits/monitor.js +1 -2
  40. package/dist/usage/readers/claude.js +3 -10
  41. package/dist/usage/readers/codex.js +2 -9
  42. package/dist/usage/readers/grok.js +3 -10
  43. package/dist/usage/readers/host-log.js +3 -10
  44. package/dist/usage/record.d.ts +2 -0
  45. package/dist/usage/record.js +13 -0
  46. package/package.json +6 -6
  47. package/src/async.ts +23 -5
  48. package/src/chat/acp-backend.ts +121 -0
  49. package/src/chat/acp-protocol.ts +172 -6
  50. package/src/chat/attachment-store.ts +21 -0
  51. package/src/chat/backend.ts +0 -2
  52. package/src/chat/bookmark-store.ts +23 -0
  53. package/src/chat/chat-core.ts +208 -7
  54. package/src/chat/chat-log.ts +19 -0
  55. package/src/chat/chat-process.ts +52 -1
  56. package/src/chat/chat-session.ts +401 -10
  57. package/src/chat/chat-store.ts +50 -0
  58. package/src/chat/chat-title.ts +9 -0
  59. package/src/chat/claude-backend.ts +50 -0
  60. package/src/chat/claude-protocol.ts +155 -8
  61. package/src/chat/claude-title.ts +10 -0
  62. package/src/chat/claude-transcript.ts +47 -4
  63. package/src/chat/codex-backend.ts +131 -0
  64. package/src/chat/codex-protocol.ts +180 -8
  65. package/src/chat/codex-thread.ts +23 -0
  66. package/src/chat/composer-preferences.ts +6 -0
  67. package/src/chat/context-breakdown.ts +11 -0
  68. package/src/chat/delta-coalescer.ts +8 -0
  69. package/src/chat/external-sessions.ts +28 -0
  70. package/src/chat/generated-image.ts +25 -0
  71. package/src/chat/index.ts +5 -0
  72. package/src/chat/input.ts +13 -0
  73. package/src/chat/json-rpc-transport.ts +28 -0
  74. package/src/chat/limit-resume.ts +13 -0
  75. package/src/chat/projector.ts +160 -4
  76. package/src/chat/readable-text.ts +3 -0
  77. package/src/chat/request-summary.ts +18 -4
  78. package/src/chat/subagent-projection.ts +3 -0
  79. package/src/chat/subagent-reader.ts +76 -0
  80. package/src/chat/subagent-settlement.ts +24 -0
  81. package/src/chat/thread-search.ts +13 -0
  82. package/src/chat/thread.ts +31 -0
  83. package/src/chat/ui-queries.ts +122 -0
  84. package/src/chat/ui-replies.ts +34 -0
  85. package/src/chat/visual-store.ts +49 -0
  86. package/src/chat/wake-chat.ts +3 -0
  87. package/src/client-sinks.ts +2 -0
  88. package/src/coded-error.ts +1 -5
  89. package/src/context/argv.ts +18 -2
  90. package/src/context/index.ts +1 -0
  91. package/src/context/refusal.ts +3 -0
  92. package/src/context/verb.ts +32 -4
  93. package/src/coordination/index.ts +10 -0
  94. package/src/error-text.ts +7 -0
  95. package/src/fields.ts +14 -0
  96. package/src/fs.ts +20 -4
  97. package/src/host/agent-host.ts +20 -0
  98. package/src/host/environment.ts +6 -0
  99. package/src/host/handlers.ts +18 -0
  100. package/src/host/memory-port.ts +4 -0
  101. package/src/host/wiring.ts +10 -0
  102. package/src/index.ts +1 -0
  103. package/src/lineage.ts +24 -5
  104. package/src/messages/deliver-message.ts +6 -0
  105. package/src/messages/deliver-notice.ts +13 -0
  106. package/src/messages/notice-store.ts +16 -4
  107. package/src/modes.ts +1 -0
  108. package/src/outbox/outbox-worker.ts +31 -0
  109. package/src/outbox/outbox.ts +5 -0
  110. package/src/providers/accounts/accounts.ts +36 -0
  111. package/src/providers/accounts/acp-account.ts +22 -27
  112. package/src/providers/accounts/launch.ts +4 -0
  113. package/src/providers/accounts/service.ts +110 -12
  114. package/src/providers/accounts/shadow-home.ts +10 -0
  115. package/src/providers/accounts/status.ts +13 -4
  116. package/src/providers/accounts/store.ts +25 -0
  117. package/src/providers/accounts/variables.ts +11 -1
  118. package/src/providers/acp-agent-state.ts +24 -3
  119. package/src/providers/acp-provider.ts +5 -0
  120. package/src/providers/acp-usage-log.ts +2 -0
  121. package/src/providers/catalog.ts +17 -0
  122. package/src/providers/claude.ts +10 -1
  123. package/src/providers/codex-rules.ts +3 -0
  124. package/src/providers/codex.ts +2 -1
  125. package/src/providers/detect.ts +6 -1
  126. package/src/providers/provider.ts +1 -1
  127. package/src/providers/registry.ts +18 -2
  128. package/src/providers/shell-commands.ts +37 -2
  129. package/src/record-directory.ts +19 -0
  130. package/src/run-process.ts +24 -3
  131. package/src/serializer.ts +3 -0
  132. package/src/skills.ts +73 -10
  133. package/src/tasks/background-limit.ts +9 -2
  134. package/src/tasks/end-children.ts +9 -0
  135. package/src/tasks/give-task.ts +8 -0
  136. package/src/tasks/task-coordinator.ts +81 -17
  137. package/src/tasks/task-store.ts +28 -0
  138. package/src/tasks/waiting-child.ts +21 -0
  139. package/src/tasks/wake-parent.ts +26 -0
  140. package/src/tasks/wiring.ts +22 -0
  141. package/src/testing/accounts.ts +2 -0
  142. package/src/testing/fake-acp-capture.ts +16 -0
  143. package/src/testing/fake-acp.ts +66 -0
  144. package/src/testing/fake-claude.ts +177 -0
  145. package/src/testing/fake-cli.ts +29 -0
  146. package/src/testing/fake-codex.ts +122 -0
  147. package/src/testing/manual-clock.ts +2 -0
  148. package/src/testing/watch.ts +7 -1
  149. package/src/title-file.ts +12 -0
  150. package/src/usage/accounts.ts +6 -0
  151. package/src/usage/aggregate.ts +19 -0
  152. package/src/usage/exchange.ts +12 -0
  153. package/src/usage/index-file.ts +27 -0
  154. package/src/usage/limits/monitor.ts +31 -3
  155. package/src/usage/limits/normalize.ts +30 -0
  156. package/src/usage/limits/probe.ts +17 -0
  157. package/src/usage/pricing.ts +35 -0
  158. package/src/usage/projects.ts +8 -0
  159. package/src/usage/readers/claude.ts +12 -9
  160. package/src/usage/readers/codex.ts +40 -8
  161. package/src/usage/readers/grok.ts +17 -9
  162. package/src/usage/readers/host-log.ts +9 -9
  163. package/src/usage/record.ts +22 -0
  164. package/src/usage/roots.ts +6 -0
  165. package/src/usage/scanner.ts +61 -0
  166. package/src/usage/usage-service.ts +14 -0
  167. package/src/watch-seam.ts +8 -0
  168. package/src/windows-command.ts +12 -0
@@ -34,7 +34,7 @@ import { readableAssistantText } from './readable-text.ts';
34
34
  import type { SubagentSettlement } from './subagent-settlement.ts';
35
35
  import { ChatThread } from './thread.ts';
36
36
  import { UiReplies, type UiReplyHost } from './ui-replies.ts';
37
- import { errorText } from '../error-text.ts';
37
+ import { asError, errorText } from '../error-text.ts';
38
38
  import { systemClock, type OutboxClock } from '../outbox/outbox-worker.ts';
39
39
  import { cleanTitle } from '../title-file.ts';
40
40
 
@@ -56,13 +56,13 @@ export interface PromptNotes {
56
56
  reset(): void;
57
57
  }
58
58
 
59
- /* Chats a person attached to a message: the ones the host knows, and what the agent is told about them. */
60
59
  /* What a message pointed at: other chats with `@`, and views and nodes with `#`, by id. */
61
60
  export interface ChatAttached {
62
61
  chats: string[];
63
62
  references: string[];
64
63
  }
65
64
 
65
+ /* Chats a person attached to a message: the ones the host knows, and what the agent is told about them. */
66
66
  export type ChatReferences = (attached: {
67
67
  readonly chats?: readonly string[];
68
68
  readonly references?: readonly string[];
@@ -77,7 +77,6 @@ export interface ChatSessionOptions {
77
77
  // The access a UI host captured per reply, from the record; never on the wire.
78
78
  uiAccess?: Readonly<Record<string, unknown>>;
79
79
  provider: ChatProvider;
80
- // The executable and leading arguments; a test points this at a fake CLI.
81
80
  command: string[];
82
81
  /* The environment of the CLI under the chat's account, asked at every start; throws for an account that cannot start. */
83
82
  env(account: string | undefined): Record<string, string>;
@@ -163,8 +162,10 @@ function newId(prefix: string): string {
163
162
  * the ones before it, as long as it handed out fewer than one per millisecond it ran.
164
163
  */
165
164
  let lastGeneration = Date.now();
165
+
166
166
  function nextGeneration(): number {
167
167
  lastGeneration = Math.max(lastGeneration + 1, Date.now());
168
+
168
169
  return lastGeneration;
169
170
  }
170
171
 
@@ -235,6 +236,7 @@ export class ChatSession {
235
236
  this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
236
237
  this.thread = new ChatThread(options.info, options.items);
237
238
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
239
+
238
240
  this.uiReplies = new UiReplies({
239
241
  thread: this.thread,
240
242
  fenceLanguage: options.uiFenceLanguage ?? UI_FENCE_LANGUAGE,
@@ -286,6 +288,7 @@ export class ChatSession {
286
288
  get busy(): boolean {
287
289
  const turnId = this.thread.info.activeTurnId;
288
290
  const turn = turnId ? this.thread.get(turnId) : undefined;
291
+
289
292
  // A turn the CLI opened toward a goal is the goal's own: a message waits for it like for any turn of the person's.
290
293
  return turnId !== null && (turn?.kind !== 'turn' || turn.origin !== 'agent' || this.goalWorks());
291
294
  }
@@ -302,20 +305,27 @@ export class ChatSession {
302
305
  */
303
306
  async goal(action: ChatGoalAction): Promise<void> {
304
307
  const supported = this.options.provider.capabilities.goals;
308
+
305
309
  if (supported === undefined || (supported === 'command' && action !== 'clear')) {
306
310
  throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot ${action} a goal`);
307
311
  }
312
+
308
313
  if (this.thread.info.goal === undefined) {
309
314
  throw new ChatError('goal-not-found', 'This chat has no goal');
310
315
  }
316
+
311
317
  const backend = this.backend;
318
+
312
319
  if (supported === 'native' && backend?.goal && backend.running && (action !== 'resume' || this.busy)) {
313
320
  await backend.goal(action);
321
+
314
322
  return;
315
323
  }
324
+
316
325
  if (this.busy) {
317
326
  throw new ChatError('chat-busy', 'Stop the turn that works toward the goal first');
318
327
  }
328
+
319
329
  this.send(`/goal ${action}`);
320
330
  }
321
331
 
@@ -326,22 +336,29 @@ export class ChatSession {
326
336
  this.options.persist();
327
337
  this.resumeSettingChanged();
328
338
  }
339
+
329
340
  const catalog = this.options.provider.catalog;
330
341
  const selection = patch.selection ? catalog.normalize(patch.selection) : this.thread.info.selection;
342
+
331
343
  const next: Partial<ChatInfo> = {
332
344
  selection,
333
345
  runtimeMode: patch.runtimeMode ?? this.thread.info.runtimeMode
334
346
  };
347
+
335
348
  const changed = JSON.stringify(next.selection) !== JSON.stringify(this.thread.info.selection) || next.runtimeMode !== this.thread.info.runtimeMode;
349
+
336
350
  if (!changed) {
337
351
  return this.thread.info;
338
352
  }
353
+
339
354
  if (patch.selection) {
340
355
  next.usage = { ...this.thread.info.usage, contextWindow: catalog.contextWindowFor(selection) };
341
356
  }
357
+
342
358
  this.emit([this.thread.patchInfo(next)]);
343
359
  this.options.persist();
344
360
  this.drainQueue();
361
+
345
362
  return this.thread.info;
346
363
  }
347
364
 
@@ -350,9 +367,11 @@ export class ChatSession {
350
367
  if (account === this.thread.info.account) {
351
368
  return this.thread.info;
352
369
  }
370
+
353
371
  this.emit([this.thread.patchInfo({ account })]);
354
372
  this.options.persist();
355
373
  this.drainQueue();
374
+
356
375
  return this.thread.info;
357
376
  }
358
377
 
@@ -361,8 +380,10 @@ export class ChatSession {
361
380
  if (cwd === this.thread.info.cwd) {
362
381
  return this.thread.info;
363
382
  }
383
+
364
384
  this.emit([this.thread.patchInfo({ cwd })]);
365
385
  this.options.persist();
386
+
366
387
  return this.thread.info;
367
388
  }
368
389
 
@@ -396,17 +417,17 @@ export class ChatSession {
396
417
  this.emit([this.thread.patchInfo({})]);
397
418
  }
398
419
 
399
- /*
400
- * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
401
- */
420
+ /* Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work. */
402
421
  send(text: string, sent: ChatSendExtras = {}): { queued: boolean; turnId: string } {
403
422
  const extras = this.vetted(sent);
404
423
  const turnId = newId('turn');
424
+
405
425
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
406
426
  if (this.busy || this.queue.length > 0 || this.replacementWaits() || this.relocating) {
407
427
  if (this.busy && this.info.queuePaused) {
408
428
  this.sendAfterStop = true;
409
429
  }
430
+
410
431
  const message: ChatQueuedMessage = {
411
432
  id: newId('queued'),
412
433
  turnId,
@@ -420,12 +441,16 @@ export class ChatSession {
420
441
  ...(extras.attachments?.length ? { attachments: extras.attachments } : {}),
421
442
  ...(extras.references?.length ? { references: extras.references } : {})
422
443
  };
444
+
423
445
  this.setQueue([...this.queue, message]);
424
446
  this.lapseResume();
425
447
  this.drainQueue(true);
448
+
426
449
  return { queued: true, turnId };
427
450
  }
451
+
428
452
  this.dispatch(text, extras, turnId);
453
+
429
454
  return { queued: false, turnId };
430
455
  }
431
456
 
@@ -438,6 +463,7 @@ export class ChatSession {
438
463
  if (this.frozen) {
439
464
  return;
440
465
  }
466
+
441
467
  this.capturedUiAccess.set(itemId, access);
442
468
  this.options.persistSoon();
443
469
  }
@@ -449,9 +475,11 @@ export class ChatSession {
449
475
 
450
476
  updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
451
477
  const item = this.thread.get(itemId);
478
+
452
479
  if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
453
480
  return;
454
481
  }
482
+
455
483
  this.thread.upsert({ ...item, uiQueries });
456
484
  this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
457
485
  this.options.persistSoon();
@@ -459,35 +487,46 @@ export class ChatSession {
459
487
 
460
488
  choose(payload: ChatUiChoicePayload, queries: Readonly<Record<string, unknown>> = {}): { queued: boolean; turnId: string } {
461
489
  const item = this.thread.get(payload.itemId);
490
+
462
491
  const block =
463
492
  item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
493
+
464
494
  if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
465
495
  throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
466
496
  }
497
+
467
498
  const sameBlock = (origin: ChatUiChoiceOrigin | undefined) =>
468
499
  origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
500
+
469
501
  let previous = item.uiAnswers?.[block.id];
502
+
470
503
  if (previous?.revision !== block.revision) {
471
504
  previous = undefined;
472
505
  }
506
+
473
507
  // The message is logged first: a crash before its answer marker must not send it a second time.
474
508
  if (!previous) {
475
509
  const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
476
510
  const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
477
511
  const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
478
512
  const turnId = sent?.turnId ?? queued?.turnId;
513
+
479
514
  if (origin && turnId) {
480
515
  previous = { ...origin, turnId, queued: !sent };
481
516
  this.recordUiAnswer(previous);
482
517
  }
483
518
  }
519
+
484
520
  if (previous) {
485
521
  if (previous.choiceId !== payload.choiceId) {
486
522
  throw new ChatError('ui-already-answered', 'This UI block was already answered');
487
523
  }
524
+
488
525
  return { queued: previous.queued, turnId: previous.turnId };
489
526
  }
527
+
490
528
  let selected;
529
+
491
530
  try {
492
531
  selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
493
532
  } catch (error) {
@@ -496,6 +535,7 @@ export class ChatSession {
496
535
  error instanceof Error ? error.message : 'This choice could not be read'
497
536
  );
498
537
  }
538
+
499
539
  const origin: ChatUiChoiceOrigin = {
500
540
  itemId: item.id,
501
541
  blockId: block.id,
@@ -507,19 +547,24 @@ export class ChatSession {
507
547
  values: selected.values,
508
548
  at: Date.now()
509
549
  };
550
+
510
551
  const result = this.send(selected.context, { uiChoice: origin });
511
552
  this.recordUiAnswer({ ...origin, ...result });
553
+
512
554
  return result;
513
555
  }
514
556
 
515
557
  private recordUiAnswer(answer: ChatUiAnswer): void {
516
558
  const item = this.thread.get(answer.itemId);
559
+
517
560
  if (item?.kind !== 'assistant') {
518
561
  return;
519
562
  }
563
+
520
564
  if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
521
565
  return;
522
566
  }
567
+
523
568
  this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
524
569
  this.options.persist();
525
570
  }
@@ -529,8 +574,10 @@ export class ChatSession {
529
574
  if (extras.quoteSource === undefined || this.thread.get(extras.quoteSource.itemId)?.kind === 'assistant') {
530
575
  return extras;
531
576
  }
577
+
532
578
  const plain = { ...extras };
533
579
  delete plain.quoteSource;
580
+
534
581
  return plain;
535
582
  }
536
583
 
@@ -540,6 +587,7 @@ export class ChatSession {
540
587
  if (text.startsWith('/') || !this.options.references) {
541
588
  return { chats: [], references: [], note: null };
542
589
  }
590
+
543
591
  return this.options.references({ chats: extras.chats, references: extras.references });
544
592
  }
545
593
 
@@ -551,6 +599,7 @@ export class ChatSession {
551
599
  const extras = this.vetted(sent);
552
600
  const backend = this.backend;
553
601
  const turnId = this.info.activeTurnId;
602
+
554
603
  if (
555
604
  extras.delivery !== 'steer' ||
556
605
  this.info.queuePaused ||
@@ -565,26 +614,34 @@ export class ChatSession {
565
614
  ) {
566
615
  return this.send(text, extras);
567
616
  }
617
+
568
618
  const queued = this.send(text, extras);
569
619
  const message = this.queue.find((entry) => entry.turnId === queued.turnId);
620
+
570
621
  if (!message) {
571
622
  return queued;
572
623
  }
624
+
573
625
  this.steering.add(message.id);
574
626
  const steer = this.steer(message, text, extras, backend.steerTurn.bind(backend), turnId, queued);
575
627
  // Claude takes a steer only between tool calls, so a long command would outlast the client's request.
576
628
  const clock = this.options.clock ?? systemClock;
577
629
  let timer: unknown = null;
630
+
578
631
  const waited = new Promise<null>((resolve) => {
579
632
  timer = clock.setTimeout(() => resolve(null), STEER_ANSWER_MS);
580
633
  });
634
+
581
635
  try {
582
636
  const answer = await Promise.race([steer, waited]);
637
+
583
638
  if (answer !== null) {
584
639
  return answer;
585
640
  }
641
+
586
642
  // Still in the queue until it is taken; a later failure pauses the queue as it does now.
587
643
  steer.catch(() => undefined);
644
+
588
645
  return queued;
589
646
  } finally {
590
647
  clock.clearTimeout(timer);
@@ -601,6 +658,7 @@ export class ChatSession {
601
658
  ): Promise<{ queued: boolean; turnId: string }> {
602
659
  try {
603
660
  const attached = this.attached(text, extras);
661
+
604
662
  const accepted = await steerTurn({
605
663
  promptId: message.id,
606
664
  text,
@@ -609,9 +667,11 @@ export class ChatSession {
609
667
  mentions: extras.mentions ?? [],
610
668
  skills: extras.skills ?? []
611
669
  });
670
+
612
671
  if (!accepted) {
613
672
  return queued;
614
673
  }
674
+
615
675
  this.emit([
616
676
  this.thread.upsert({
617
677
  id: `user-${message.id}`,
@@ -628,12 +688,15 @@ export class ChatSession {
628
688
  }),
629
689
  this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
630
690
  ]);
691
+
631
692
  this.options.persist();
693
+
632
694
  return { queued: false, turnId };
633
695
  } catch (error) {
634
696
  // A lost acknowledgement does not prove rejection. Keep the message for an explicit retry.
635
697
  this.pauseQueue(true);
636
698
  this.options.persist();
699
+
637
700
  throw error;
638
701
  } finally {
639
702
  this.steering.delete(message.id);
@@ -645,10 +708,13 @@ export class ChatSession {
645
708
  unqueue(messageId: string): ChatQueuedMessage | null {
646
709
  const queue = this.queue;
647
710
  const message = queue.find((entry) => entry.id === messageId);
711
+
648
712
  if (!message || this.steering.has(message.id)) {
649
713
  return null;
650
714
  }
715
+
651
716
  this.setQueue(queue.filter((entry) => entry.id !== messageId));
717
+
652
718
  return message;
653
719
  }
654
720
 
@@ -656,16 +722,22 @@ export class ChatSession {
656
722
  sendNow(messageId: string): boolean {
657
723
  const queue = this.queue;
658
724
  const message = queue.find((entry) => entry.id === messageId);
725
+
659
726
  if (!message || this.steering.has(message.id)) {
660
727
  return false;
661
728
  }
729
+
662
730
  this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
731
+
663
732
  if (this.busy) {
664
733
  this.cancel(true);
734
+
665
735
  return true;
666
736
  }
737
+
667
738
  this.lapseResume();
668
739
  this.drainQueue(true);
740
+
669
741
  return true;
670
742
  }
671
743
 
@@ -683,6 +755,7 @@ export class ChatSession {
683
755
  const [next, ...rest] = this.queue;
684
756
  const limited = limitedTurn(this.thread.list());
685
757
  const explicit = asked || this.sendAfterReplacement;
758
+
686
759
  if (
687
760
  !next ||
688
761
  this.steering.size > 0 ||
@@ -692,9 +765,11 @@ export class ChatSession {
692
765
  ) {
693
766
  return;
694
767
  }
768
+
695
769
  if (this.replacementWaits()) {
696
770
  this.sendAfterReplacement = explicit;
697
771
  const id = `settings-wait-${this.generation}`;
772
+
698
773
  if (!this.thread.get(id)) {
699
774
  this.emit([
700
775
  this.thread.upsert({
@@ -706,13 +781,17 @@ export class ChatSession {
706
781
  text: 'Your message is queued until the current work and open requests finish. The changes to this chat will apply then.'
707
782
  })
708
783
  ]);
784
+
709
785
  this.options.persist();
710
786
  }
787
+
711
788
  return;
712
789
  }
790
+
713
791
  this.sendAfterReplacement = false;
714
792
  this.pauseQueue(false);
715
793
  this.setQueue(rest);
794
+
716
795
  this.dispatch(
717
796
  next.text,
718
797
  {
@@ -731,17 +810,22 @@ export class ChatSession {
731
810
  private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
732
811
  this.pauseQueue(false);
733
812
  this.settleAgentTurn();
813
+
734
814
  // A goal that was met has said what it had to; the next message of the person leaves it behind.
735
815
  if (this.thread.info.goal?.status === 'complete') {
736
816
  this.emit([this.thread.patchInfo({ goal: undefined })]);
737
817
  }
818
+
738
819
  const { preamble, note } = this.contextNote(text);
739
820
  const attached = this.attached(text, extras);
740
821
  const turnId = this.openTurn(text, note, { ...extras, chats: attached.chats, references: attached.references }, requestedTurnId);
822
+
741
823
  const choiceNote = extras.uiChoice
742
824
  ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
743
825
  : null;
826
+
744
827
  const said = [preamble, attached.note, choiceNote].filter((part): part is string => part !== null);
828
+
745
829
  const input = {
746
830
  text,
747
831
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -749,6 +833,7 @@ export class ChatSession {
749
833
  mentions: extras.mentions ?? [],
750
834
  skills: extras.skills ?? []
751
835
  };
836
+
752
837
  // The prompt waits for the checkpoint, so the tree is the folder as it was before the agent edited it.
753
838
  this.turnReady = this.checkpoint(turnId);
754
839
  this.submit(turnId, input);
@@ -757,16 +842,21 @@ export class ChatSession {
757
842
  /* Asks the CLI to fold its context: a call of its own, or the slash command as a turn. */
758
843
  compact(): void {
759
844
  const compaction = this.options.provider.capabilities.compaction;
845
+
760
846
  if (compaction === 'none') {
761
847
  throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot compact its context`);
762
848
  }
849
+
763
850
  if (compaction === 'prompt') {
764
851
  this.send('/compact');
852
+
765
853
  return;
766
854
  }
855
+
767
856
  if (this.replacementWaits()) {
768
857
  throw new ChatError('chat-busy', 'The current work and open requests must finish before the changes to this chat can apply');
769
858
  }
859
+
770
860
  // A native compaction has no message of the person in front of it, only a turn to fold behind.
771
861
  this.openTurn(null, null, {});
772
862
  // Folding the context changes no file, so this turn needs no checkpoint.
@@ -780,12 +870,15 @@ export class ChatSession {
780
870
  this.pauseQueue(true);
781
871
  this.lapseResume();
782
872
  const turnId = this.thread.info.activeTurnId;
873
+
783
874
  // A turn still waiting to be resumed has no process to interrupt; stopping it is ending it here.
784
875
  if (!this.backend && turnId !== null) {
785
876
  this.clearStartWarning();
786
877
  this.abandon(turnId, null);
878
+
787
879
  return;
788
880
  }
881
+
789
882
  if (!this.backend || turnId === null) {
790
883
  if (turnId === null && this.backend) {
791
884
  for (const item of this.thread.pending()) {
@@ -793,10 +886,13 @@ export class ChatSession {
793
886
  this.receive(this.generation, { type: 'request.withdrawn', requestId: item.requestId });
794
887
  }
795
888
  }
889
+
796
890
  this.options.persist();
797
891
  }
892
+
798
893
  return;
799
894
  }
895
+
800
896
  if (this.pendingStart?.turnId === turnId) {
801
897
  this.clearStartWarning();
802
898
  const backend = this.backend;
@@ -805,8 +901,10 @@ export class ChatSession {
805
901
  backend.stop();
806
902
  this.emit([this.thread.patchInfo({ running: false })]);
807
903
  this.receive(this.generation, { type: 'turn.done', state: 'aborted', costUsd: 0 });
904
+
808
905
  return;
809
906
  }
907
+
810
908
  this.backend.interrupt();
811
909
  }
812
910
 
@@ -815,32 +913,41 @@ export class ChatSession {
815
913
  const workflow = this.thread
816
914
  .list()
817
915
  .some((item) => item.kind === 'tool' && item.name === 'Workflow' && item.state === 'running' && item.workflow?.taskId === taskId);
916
+
818
917
  if (!workflow && !this.thread.info.background?.some((task) => task.id === taskId)) {
819
918
  throw new ChatError('task-not-found', 'This chat runs no such task');
820
919
  }
920
+
821
921
  if (!this.backend?.stopTask) {
822
922
  throw new ChatError('chat-unsupported', 'This CLI cannot stop a task on its own');
823
923
  }
924
+
824
925
  this.backend.stopTask(taskId);
825
926
  }
826
927
 
827
928
  /* Answers a pending approval; false when nothing waits under that id. */
828
929
  approve(requestId: string, decision: 'allow' | 'allow-always' | 'deny', message?: string): boolean {
829
930
  const item = this.thread.get(`approval-${requestId}`);
931
+
830
932
  if (item?.kind !== 'approval' || item.decision !== 'pending' || this.backend?.respondApproval(requestId, decision, message) !== true) {
831
933
  return false;
832
934
  }
935
+
833
936
  this.emit([this.thread.upsert({ ...item, decision }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
937
+
834
938
  return true;
835
939
  }
836
940
 
837
941
  /* Answers a pending question; false when nothing waits under that id. */
838
942
  answer(requestId: string, answers: Record<string, string>): boolean {
839
943
  const item = this.thread.get(`question-${requestId}`);
944
+
840
945
  if (item?.kind !== 'question' || item.state !== 'pending' || this.backend?.respondQuestion(requestId, answers) !== true) {
841
946
  return false;
842
947
  }
948
+
843
949
  this.emit([this.thread.upsert({ ...item, answers, state: 'answered' }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
950
+
844
951
  return true;
845
952
  }
846
953
 
@@ -850,11 +957,14 @@ export class ChatSession {
850
957
  */
851
958
  dismiss(itemId: string): boolean {
852
959
  const item = this.thread.get(itemId);
960
+
853
961
  if (item?.kind !== 'question' || item.state !== 'pending' || item.async !== true) {
854
962
  return false;
855
963
  }
964
+
856
965
  this.backend?.dismissRequest?.(item.requestId);
857
966
  this.emit([this.thread.upsert({ ...item, state: 'dismissed' }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
967
+
858
968
  return true;
859
969
  }
860
970
 
@@ -883,6 +993,7 @@ export class ChatSession {
883
993
  if (this.busy && !force) {
884
994
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
885
995
  }
996
+
886
997
  const ended = this.dispose();
887
998
  this.generation = nextGeneration();
888
999
  this.projector.reset();
@@ -900,6 +1011,7 @@ export class ChatSession {
900
1011
  this.turnReady = Promise.resolve();
901
1012
  this.lapseResume();
902
1013
  const usage = this.thread.info.usage;
1014
+
903
1015
  this.emit([
904
1016
  this.thread.reset({
905
1017
  agentSessionId: null,
@@ -926,6 +1038,7 @@ export class ChatSession {
926
1038
  }
927
1039
  })
928
1040
  ]);
1041
+
929
1042
  return ended;
930
1043
  }
931
1044
 
@@ -938,23 +1051,31 @@ export class ChatSession {
938
1051
  const events: ChatEvent[] = [];
939
1052
  const resumeTurnId = resume.resumeTurnId;
940
1053
  const now = Date.now();
1054
+
941
1055
  for (const item of this.thread.list()) {
942
1056
  const settled = settleStoredItem(item, resumeTurnId);
1057
+
943
1058
  if (settled === item) {
944
1059
  continue;
945
1060
  }
1061
+
946
1062
  if (isBackgroundWork(item)) {
947
1063
  this.recordLostBackground(item);
948
1064
  }
1065
+
949
1066
  events.push(this.thread.upsert(settled));
1067
+
950
1068
  if (item.kind === 'turn' && settled.kind === 'turn' && settled.state === 'aborted') {
951
1069
  const reason = item.id === this.thread.info.activeTurnId && resume.reason !== null ? resume.reason : 'it was not the turn the chat was running';
1070
+
952
1071
  events.push(
953
1072
  this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId: item.id, level: 'warning', text: notResumedNote(reason) })
954
1073
  );
955
1074
  }
956
1075
  }
1076
+
957
1077
  const info = this.thread.info;
1078
+
958
1079
  const patch: Partial<ChatInfo> = {
959
1080
  selection,
960
1081
  running: false,
@@ -963,19 +1084,24 @@ export class ChatSession {
963
1084
  effectiveRuntimeMode: undefined,
964
1085
  permissionMode: undefined
965
1086
  };
1087
+
966
1088
  // No process of the host that went down is left to run what it kept in the background.
967
1089
  if (info.background?.length) {
968
1090
  for (const command of info.background) {
969
1091
  const text = `The machine restarted while background command "${commandLabel(command)}" was running. It will not send a result. Do not wait for it.`;
970
1092
  this.deliverNote({ noteId: `lost-command-${command.id}`, note: text, preamble: text });
971
1093
  }
1094
+
972
1095
  patch.background = [];
973
1096
  }
1097
+
974
1098
  // The window stored is whatever the session that went left behind; the next CLI starts on the pick.
975
1099
  const contextWindow = this.options.provider.catalog.contextWindowFor(selection);
1100
+
976
1101
  if (contextWindow !== info.usage.contextWindow) {
977
1102
  patch.usage = { ...info.usage, contextWindow };
978
1103
  }
1104
+
979
1105
  if (
980
1106
  JSON.stringify(selection) !== JSON.stringify(info.selection) ||
981
1107
  patch.usage !== undefined ||
@@ -988,6 +1114,7 @@ export class ChatSession {
988
1114
  ) {
989
1115
  events.push(this.thread.patchInfo(patch));
990
1116
  }
1117
+
991
1118
  this.emit(events);
992
1119
  }
993
1120
 
@@ -1001,55 +1128,75 @@ export class ChatSession {
1001
1128
  if (!this.awaitsResume(turnId, attempt)) {
1002
1129
  return;
1003
1130
  }
1131
+
1004
1132
  const original = this.thread.get(turnId);
1133
+
1005
1134
  if (original?.kind !== 'turn') {
1006
1135
  return;
1007
1136
  }
1137
+
1008
1138
  const now = Date.now();
1139
+
1009
1140
  this.emit([
1010
1141
  this.thread.upsert({ ...original, attempt, resumePending: true }),
1011
1142
  this.thread.upsert({ id: `resume-${turnId}-${attempt}`, kind: 'note', createdAt: now, turnId, level: 'info', text: words.note })
1012
1143
  ]);
1144
+
1013
1145
  this.options.persist();
1014
1146
  const pending = this.watchStart(turnId);
1015
1147
  let backend: ChatBackend;
1148
+
1016
1149
  try {
1017
1150
  await this.options.save?.();
1151
+
1018
1152
  if (this.pendingStart !== pending || !this.awaitsResume(turnId, attempt)) {
1019
1153
  return;
1020
1154
  }
1155
+
1021
1156
  backend = await this.ensureBackend();
1022
1157
  } catch (e) {
1023
1158
  if (this.pendingStart !== pending) {
1024
1159
  return;
1025
1160
  }
1161
+
1026
1162
  this.clearStartWarning();
1163
+
1027
1164
  if (this.backend === null) {
1028
1165
  this.emit([this.thread.patchInfo({ running: false })]);
1029
1166
  }
1167
+
1030
1168
  this.addNote('warning', `The resume attempt could not start: ${errorText(e)}`);
1169
+
1031
1170
  throw e;
1032
1171
  }
1172
+
1033
1173
  const turn = this.thread.get(turnId);
1174
+
1034
1175
  if (this.pendingStart !== pending || !this.awaitsResume(turnId, attempt) || this.backend !== backend || turn?.kind !== 'turn') {
1035
1176
  return;
1036
1177
  }
1178
+
1037
1179
  this.clearStartWarning();
1038
1180
  // The turn keeps the checkpoint it started with, so its card still shows everything it changed.
1039
1181
  this.turnReady = Promise.resolve();
1040
1182
  const context = this.contextNote(words.prompt);
1041
1183
  const preamble = [words.preamble, context.preamble].filter(Boolean).join('\n\n') || null;
1184
+
1042
1185
  if (context.note) {
1043
1186
  this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: context.note })]);
1044
1187
  }
1188
+
1045
1189
  const input: TurnInput = { promptId: randomUUID(), text: words.prompt, preamble, attachments: [], mentions: [], skills: [] };
1046
1190
  this.submission = { turnId, input };
1047
1191
  this.options.persist();
1048
1192
  await this.options.save?.();
1193
+
1049
1194
  if (this.backend !== backend || this.info.activeTurnId !== turnId || this.frozen) {
1050
1195
  return;
1051
1196
  }
1197
+
1052
1198
  backend.sendTurn(input);
1199
+
1053
1200
  if (!backend.acknowledgesTurns) {
1054
1201
  this.acceptPrompt(input.promptId!);
1055
1202
  }
@@ -1058,16 +1205,20 @@ export class ChatSession {
1058
1205
  /* Ends a running turn nobody is working on and keeps queued messages for a person's next send. */
1059
1206
  abandon(turnId: string, reason: string | null): void {
1060
1207
  const turn = this.thread.get(turnId);
1208
+
1061
1209
  if (turn?.kind !== 'turn' || turn.state !== 'running' || this.thread.info.activeTurnId !== turnId || this.running) {
1062
1210
  return;
1063
1211
  }
1212
+
1064
1213
  const now = Date.now();
1065
1214
  this.pauseQueue(true);
1215
+
1066
1216
  this.emit([
1067
1217
  this.thread.upsert({ ...turn, state: 'aborted', endedAt: now }),
1068
1218
  ...(reason === null ? [] : [this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason })]),
1069
1219
  this.thread.patchInfo({ status: 'idle', activeTurnId: null })
1070
1220
  ]);
1221
+
1071
1222
  this.options.persist();
1072
1223
  this.drainQueue(this.sendAfterStop);
1073
1224
  this.sendAfterStop = false;
@@ -1094,10 +1245,12 @@ export class ChatSession {
1094
1245
  if (this.frozen || this.relocating || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
1095
1246
  return null;
1096
1247
  }
1248
+
1097
1249
  this.turnOpened();
1098
1250
  const { preamble, note } = this.contextNote(wake.text);
1099
1251
  const turnId = newId('turn');
1100
1252
  const now = Date.now();
1253
+
1101
1254
  this.emit([
1102
1255
  this.thread.upsert({
1103
1256
  id: turnId,
@@ -1120,9 +1273,11 @@ export class ChatSession {
1120
1273
  ...(note === null ? [] : [this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: note })]),
1121
1274
  this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId })
1122
1275
  ]);
1276
+
1123
1277
  this.options.persist();
1124
1278
  this.turnReady = this.checkpoint(turnId);
1125
1279
  this.submit(turnId, { text: wake.text, preamble, attachments: [], mentions: wake.mentions ?? [], skills: wake.skills ?? [] });
1280
+
1126
1281
  return turnId;
1127
1282
  }
1128
1283
 
@@ -1143,20 +1298,27 @@ export class ChatSession {
1143
1298
  */
1144
1299
  takeUpAfterLimit(turnId: string): 'opened' | 'wait' | 'lapsed' {
1145
1300
  const turn = limitedTurn(this.thread.list());
1301
+
1146
1302
  if (turn === null || turn.id !== turnId || !this.resumeAllowed()) {
1147
1303
  this.lapseResume();
1304
+
1148
1305
  return 'lapsed';
1149
1306
  }
1307
+
1150
1308
  const wake = limitResumeWake(turn.limit.kind);
1309
+
1151
1310
  const opened = this.wake({
1152
1311
  ...wake,
1153
1312
  taskIds: turn.taskIds ?? [],
1154
1313
  ...(turn.messageFrom === undefined ? {} : { messageFrom: turn.messageFrom })
1155
1314
  });
1315
+
1156
1316
  if (opened === null) {
1157
1317
  return 'wait';
1158
1318
  }
1319
+
1159
1320
  this.pauseQueue(false);
1321
+
1160
1322
  return 'opened';
1161
1323
  }
1162
1324
 
@@ -1182,17 +1344,23 @@ export class ChatSession {
1182
1344
  private oweResume(fresh: boolean): void {
1183
1345
  const hooks = this.options.limitResume;
1184
1346
  const turn = limitedTurn(this.thread.list());
1347
+
1185
1348
  if (!hooks || turn === null || this.thread.info.activeTurnId !== null || this.frozen || !this.resumeAllowed() || this.wentOn(turn)) {
1186
1349
  return;
1187
1350
  }
1351
+
1188
1352
  const now = hooks.now();
1353
+
1189
1354
  if (!fresh && (turn.limit.kind !== 'usage' || (turn.limit.resetsAt ?? 0) <= now)) {
1190
1355
  return;
1191
1356
  }
1357
+
1192
1358
  const at = limitResumeAt(this.thread.list(), turn, now);
1359
+
1193
1360
  if (at === null || at === this.thread.info.resumeAt) {
1194
1361
  return;
1195
1362
  }
1363
+
1196
1364
  this.emit([this.thread.patchInfo({ resumeAt: at })]);
1197
1365
  this.options.persist();
1198
1366
  void hooks.owe(turn.id, at).catch((e: unknown) => console.error(`Owing a resume of chat ${this.id} failed:`, errorText(e)));
@@ -1205,15 +1373,20 @@ export class ChatSession {
1205
1373
  settleOwedResume(): void {
1206
1374
  const hooks = this.options.limitResume;
1207
1375
  const at = this.thread.info.resumeAt;
1376
+
1208
1377
  if (at === undefined || hooks?.owed() === true) {
1209
1378
  return;
1210
1379
  }
1380
+
1211
1381
  const turn = limitedTurn(this.thread.list());
1382
+
1212
1383
  if (!hooks || turn === null || this.thread.info.activeTurnId !== null || !this.resumeAllowed()) {
1213
1384
  this.emit([this.thread.patchInfo({ resumeAt: undefined })]);
1214
1385
  this.options.persist();
1386
+
1215
1387
  return;
1216
1388
  }
1389
+
1217
1390
  void hooks.owe(turn.id, at).catch((e: unknown) => console.error(`Owing a resume of chat ${this.id} failed:`, errorText(e)));
1218
1391
  }
1219
1392
 
@@ -1222,6 +1395,7 @@ export class ChatSession {
1222
1395
  if (this.thread.info.limit !== undefined) {
1223
1396
  this.emit([this.thread.patchInfo({ limit: undefined })]);
1224
1397
  }
1398
+
1225
1399
  this.lapseResume();
1226
1400
  }
1227
1401
 
@@ -1232,9 +1406,11 @@ export class ChatSession {
1232
1406
  continuedElsewhere(note: string): void {
1233
1407
  this.lapseResume();
1234
1408
  const turn = limitedTurn(this.thread.list());
1409
+
1235
1410
  if (turn === null || this.wentOn(turn)) {
1236
1411
  return;
1237
1412
  }
1413
+
1238
1414
  this.emit([this.thread.upsert({ id: continuedNoteId(turn.id), kind: 'note', createdAt: Date.now(), turnId: turn.id, level: 'info', text: note })]);
1239
1415
  this.options.persist();
1240
1416
  }
@@ -1242,9 +1418,11 @@ export class ChatSession {
1242
1418
  /* Drops the resume owed after a limit, the time the thread shows for it included. */
1243
1419
  lapseResume(): void {
1244
1420
  const hooks = this.options.limitResume;
1421
+
1245
1422
  if (this.thread.info.resumeAt === undefined || !hooks) {
1246
1423
  return;
1247
1424
  }
1425
+
1248
1426
  this.emit([this.thread.patchInfo({ resumeAt: undefined })]);
1249
1427
  this.options.persist();
1250
1428
  void hooks.lapse().catch((e: unknown) => console.error(`Dropping the resume of chat ${this.id} failed:`, errorText(e)));
@@ -1259,7 +1437,9 @@ export class ChatSession {
1259
1437
  if (this.thread.get(delivery.noteId) !== undefined) {
1260
1438
  return false;
1261
1439
  }
1440
+
1262
1441
  this.pendingPreambles = [...this.pendingPreambles, delivery.preamble];
1442
+
1263
1443
  this.emit([
1264
1444
  this.thread.upsert({
1265
1445
  id: delivery.noteId,
@@ -1271,16 +1451,20 @@ export class ChatSession {
1271
1451
  ...(delivery.from === undefined ? {} : { from: delivery.from })
1272
1452
  })
1273
1453
  ]);
1454
+
1274
1455
  this.options.persist();
1456
+
1275
1457
  return true;
1276
1458
  }
1277
1459
 
1278
1460
  /* Writes items a host draws in the thread itself (a row for work it delegated), leaving the ones that did not change alone. */
1279
1461
  upsertItems(items: readonly ChatItem[]): void {
1280
1462
  const changed = items.filter((item) => JSON.stringify(this.thread.get(item.id)) !== JSON.stringify(item));
1463
+
1281
1464
  if (changed.length === 0) {
1282
1465
  return;
1283
1466
  }
1467
+
1284
1468
  this.emit(changed.map((item) => this.thread.upsert(item)));
1285
1469
  this.options.persistSoon();
1286
1470
  }
@@ -1310,6 +1494,7 @@ export class ChatSession {
1310
1494
  const now = Date.now();
1311
1495
  const turnId = this.thread.info.activeTurnId;
1312
1496
  const events: ChatEvent[] = [];
1497
+
1313
1498
  for (const item of this.thread.list()) {
1314
1499
  const settled =
1315
1500
  item.kind === 'turn' && item.state === 'running'
@@ -1317,13 +1502,16 @@ export class ChatSession {
1317
1502
  : item.kind === 'subagent' && item.origin !== HOST_TASK_ORIGIN && item.status === 'running'
1318
1503
  ? { ...item, status: 'failed' as const, finishedAt: now }
1319
1504
  : settleStoredItem(item, null);
1505
+
1320
1506
  if (settled !== item) {
1321
1507
  events.push(this.thread.upsert(settled));
1322
1508
  }
1323
1509
  }
1510
+
1324
1511
  if (turnId !== null) {
1325
1512
  events.push(this.thread.upsert({ id: newId(`${ENDED_NOTE_PREFIX}note`), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
1326
1513
  }
1514
+
1327
1515
  events.push(
1328
1516
  this.thread.patchInfo({
1329
1517
  running: false,
@@ -1335,6 +1523,7 @@ export class ChatSession {
1335
1523
  ...(this.thread.info.background?.length ? { background: [] } : {})
1336
1524
  })
1337
1525
  );
1526
+
1338
1527
  this.emit(events);
1339
1528
  this.options.persist();
1340
1529
  }
@@ -1348,6 +1537,7 @@ export class ChatSession {
1348
1537
  if (this.thread.info.activeTurnId !== null || this.processInUse()) {
1349
1538
  throw new ChatError('chat-busy', `Chat ${this.id} is still working; stop it before restarting its agent session`);
1350
1539
  }
1540
+
1351
1541
  return this.dropProcess();
1352
1542
  }
1353
1543
 
@@ -1365,6 +1555,7 @@ export class ChatSession {
1365
1555
  // What the init frame of the process that goes announced; a skill added since would be filtered out by it.
1366
1556
  this.emit([this.thread.patchInfo({ running: false, effectiveRuntimeMode: undefined, permissionMode: undefined, skills: undefined })]);
1367
1557
  this.options.persist();
1558
+
1368
1559
  return backend?.dispose() ?? Promise.resolve();
1369
1560
  }
1370
1561
 
@@ -1382,50 +1573,63 @@ export class ChatSession {
1382
1573
  this.uiReplies.clear();
1383
1574
  this.clearStartWarning();
1384
1575
  this.clearWorkflowWarnings();
1576
+
1385
1577
  if (this.titleTimer !== null) {
1386
1578
  clearTimeout(this.titleTimer);
1387
1579
  this.titleTimer = null;
1388
1580
  }
1581
+
1389
1582
  const ended = this.backend?.dispose() ?? Promise.resolve();
1390
1583
  this.backend = null;
1391
1584
  this.starting = null;
1585
+
1392
1586
  return Promise.all([ended, ...this.checkpointWork]).then(() => undefined);
1393
1587
  }
1394
1588
 
1395
1589
  /* What a turn changed against its checkpoint: the stored answer, or one taken now while it runs. */
1396
1590
  async turnDiff(turnId: string): Promise<ChatCheckpointDiff | null> {
1397
1591
  const turn = this.thread.get(turnId);
1592
+
1398
1593
  if (turn?.kind !== 'turn') {
1399
1594
  throw new ChatError('request-not-found', `No turn ${turnId} in chat ${this.id}`);
1400
1595
  }
1596
+
1401
1597
  if (turn.checkpointDiff) {
1402
1598
  return turn.checkpointDiff;
1403
1599
  }
1600
+
1404
1601
  if (turn.checkpoint === undefined || !this.options.checkpoints) {
1405
1602
  return null;
1406
1603
  }
1604
+
1407
1605
  return await this.options.checkpoints.diff(this.thread.info.cwd, turn.checkpoint);
1408
1606
  }
1409
1607
 
1410
1608
  /* A page of a thread the CLI keeps, through the process that runs now; null when none runs that can answer. */
1411
1609
  listThreadItems(params: { threadId: string; cursor?: string; limit: number; sortDirection: 'asc' | 'desc' }): Promise<unknown> | null {
1412
1610
  const backend = this.backend;
1611
+
1413
1612
  return backend?.running === true && backend.listThreadItems ? backend.listThreadItems(params) : null;
1414
1613
  }
1415
1614
 
1416
1615
  /* A completed transcript survives the process; unfinished work cannot send a result after a restart. */
1417
1616
  async settleOrphanedSubagents(): Promise<void> {
1418
1617
  const rows = this.runningBackgroundRows();
1618
+
1419
1619
  if (this.info.provider === 'claude') {
1420
1620
  for (const row of rows) {
1421
1621
  this.orphans.add(row.toolUseId);
1422
1622
  }
1623
+
1423
1624
  await this.settleFromTranscripts(rows);
1424
1625
  }
1626
+
1425
1627
  const lost = this.runningBackgroundRows();
1628
+
1426
1629
  for (const row of lost) {
1427
1630
  this.recordLostBackground(row);
1428
1631
  }
1632
+
1429
1633
  this.settleRows(lost.map((row) => ({ ...row, status: 'failed', finishedAt: Date.now() })));
1430
1634
  }
1431
1635
 
@@ -1444,15 +1648,20 @@ export class ChatSession {
1444
1648
  const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1445
1649
  const agentId = row?.native?.agentId;
1446
1650
  const owned = this.subagentOwners.get(toolUseId) === this.generation;
1651
+
1447
1652
  if (agentId !== undefined && owned && this.backend?.stopTask) {
1448
1653
  this.stopping.set(agentId, toolUseId);
1449
1654
  this.backend.stopTask(agentId);
1655
+
1450
1656
  return 'stopping';
1451
1657
  }
1658
+
1452
1659
  if (agentId !== undefined && !owned && this.thread.info.activeTurnId !== null) {
1453
1660
  throw new ChatError('subagent-stale', 'This sub-agent belongs to a process that has ended');
1454
1661
  }
1662
+
1455
1663
  this.markSubagentStopped(toolUseId);
1664
+
1456
1665
  return 'marked';
1457
1666
  }
1458
1667
 
@@ -1461,6 +1670,7 @@ export class ChatSession {
1461
1670
  this.subagentOwners.set(event.ref, generation);
1462
1671
  } else if (event.type === 'task.done') {
1463
1672
  this.noteStopped(event);
1673
+
1464
1674
  if (event.ref !== null) {
1465
1675
  this.subagentOwners.delete(event.ref);
1466
1676
  }
@@ -1472,18 +1682,24 @@ export class ChatSession {
1472
1682
  /* The CLI would not stop it: with no turn running the row is only marked, and while one runs a note says it still goes on. */
1473
1683
  private refuseStop(agentId: string, message: string): void {
1474
1684
  const toolUseId = this.stopping.get(agentId);
1685
+
1475
1686
  if (toolUseId === undefined) {
1476
1687
  return;
1477
1688
  }
1689
+
1478
1690
  this.stopping.delete(agentId);
1479
1691
  const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1692
+
1480
1693
  if (row?.status !== 'running') {
1481
1694
  return;
1482
1695
  }
1696
+
1483
1697
  if (this.thread.info.activeTurnId === null) {
1484
1698
  this.markSubagentStopped(toolUseId);
1699
+
1485
1700
  return;
1486
1701
  }
1702
+
1487
1703
  this.emit([
1488
1704
  this.thread.upsert({
1489
1705
  id: `stop-refused-${toolUseId}-${Date.now()}`,
@@ -1494,25 +1710,33 @@ export class ChatSession {
1494
1710
  text: `"${row.description || row.subagentType || 'Sub-agent'}" could not be stopped: ${message}`
1495
1711
  })
1496
1712
  ]);
1713
+
1497
1714
  this.options.persist();
1498
1715
  }
1499
1716
 
1500
1717
  /* The line under a subagent that `stop_task` ended, once the CLI reported it settled. */
1501
1718
  private noteStopped(event: Extract<BackendEvent, { type: 'task.done' }>): void {
1502
1719
  const stopped = [...this.stopping].find(([agentId, toolUseId]) => agentId === event.taskId || toolUseId === event.ref);
1720
+
1503
1721
  if (stopped === undefined) {
1504
1722
  return;
1505
1723
  }
1724
+
1506
1725
  this.stopping.delete(stopped[0]);
1726
+
1507
1727
  // It finished on its own just before the stop arrived; the row already says so.
1508
1728
  if (event.ok) {
1509
1729
  return;
1510
1730
  }
1731
+
1511
1732
  const row = this.thread.find('subagent', (item) => item.toolUseId === stopped[1]);
1733
+
1512
1734
  if (!row) {
1513
1735
  return;
1514
1736
  }
1737
+
1515
1738
  const now = Date.now();
1739
+
1516
1740
  this.emit([
1517
1741
  ...(row.status === 'running' ? [this.thread.upsert({ ...row, status: 'failed' as const, finishedAt: now })] : []),
1518
1742
  this.thread.upsert({
@@ -1524,6 +1748,7 @@ export class ChatSession {
1524
1748
  text: `"${row.description || row.subagentType || 'Sub-agent'}" was stopped.`
1525
1749
  })
1526
1750
  ]);
1751
+
1527
1752
  this.options.persist();
1528
1753
  }
1529
1754
 
@@ -1535,10 +1760,13 @@ export class ChatSession {
1535
1760
  if (this.thread.info.activeTurnId !== null) {
1536
1761
  throw new ChatError('chat-busy', 'A turn is running; stop the turn instead');
1537
1762
  }
1763
+
1538
1764
  const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1765
+
1539
1766
  if (row?.status !== 'running') {
1540
1767
  return;
1541
1768
  }
1769
+
1542
1770
  this.markStopped([row]);
1543
1771
  }
1544
1772
 
@@ -1555,11 +1783,15 @@ export class ChatSession {
1555
1783
  if (rows.length === 0) {
1556
1784
  return;
1557
1785
  }
1786
+
1558
1787
  const now = Date.now();
1788
+
1559
1789
  for (const row of rows) {
1560
1790
  this.orphans.delete(row.toolUseId);
1561
1791
  }
1792
+
1562
1793
  const names = rows.map((row) => `"${row.description || row.subagentType || 'Sub-agent'}"`).join(', ');
1794
+
1563
1795
  this.emit([
1564
1796
  ...rows.map((row) => this.thread.upsert({ ...row, status: 'failed', finishedAt: now })),
1565
1797
  this.thread.upsert({
@@ -1571,12 +1803,14 @@ export class ChatSession {
1571
1803
  text: `${names} ${rows.length === 1 ? 'was' : 'were'} marked as stopped. ${this.options.provider.name} cannot stop one sub-agent on its own, so ${rows.length === 1 ? 'it' : 'they'} may keep working until the chat's process ends.`
1572
1804
  })
1573
1805
  ]);
1806
+
1574
1807
  this.options.persist();
1575
1808
  }
1576
1809
 
1577
1810
  /* Asks the transcript again for a row a load left running, when somebody looks at it. */
1578
1811
  async recheckOrphan(toolUseId: string): Promise<void> {
1579
1812
  const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1813
+
1580
1814
  if (this.orphans.has(toolUseId) && (row?.status === 'running' || row?.status === 'failed')) {
1581
1815
  await this.settleFromTranscripts([row]);
1582
1816
  }
@@ -1585,9 +1819,11 @@ export class ChatSession {
1585
1819
  /* Writes down where a subagent's conversation was found, so the next question about it reads no folder. */
1586
1820
  noteSubagentNative(toolUseId: string, native: { agentId?: string; threadId?: string }): void {
1587
1821
  const item = this.thread.find('subagent', (candidate) => candidate.toolUseId === toolUseId);
1822
+
1588
1823
  if (!item || (item.native?.agentId === native.agentId && item.native?.threadId === native.threadId)) {
1589
1824
  return;
1590
1825
  }
1826
+
1591
1827
  this.emit([this.thread.upsert({ ...item, native: { ...item.native, ...native } })]);
1592
1828
  this.options.persistSoon();
1593
1829
  }
@@ -1599,9 +1835,11 @@ export class ChatSession {
1599
1835
  */
1600
1836
  async skills(scan: () => Promise<ChatSkill[]>): Promise<ChatSkill[]> {
1601
1837
  const backend = this.backend;
1838
+
1602
1839
  if (backend?.running === true && backend.listSkills) {
1603
1840
  try {
1604
1841
  const own = await backend.listSkills();
1842
+
1605
1843
  if (own.length > 0) {
1606
1844
  return own;
1607
1845
  }
@@ -1609,12 +1847,16 @@ export class ChatSession {
1609
1847
  // The CLI could not answer; the scan below is what is left.
1610
1848
  }
1611
1849
  }
1850
+
1612
1851
  const scanned = await scan();
1613
1852
  const announced = this.thread.info.skills;
1853
+
1614
1854
  if (!announced?.length) {
1615
1855
  return scanned;
1616
1856
  }
1857
+
1617
1858
  const known = new Set(announced);
1859
+
1618
1860
  return scanned.filter((skill) => known.has(skill.name));
1619
1861
  }
1620
1862
 
@@ -1623,14 +1865,17 @@ export class ChatSession {
1623
1865
  const turnId = requestedTurnId ?? newId('turn');
1624
1866
  const now = Date.now();
1625
1867
  const events = [this.thread.upsert({ id: turnId, kind: 'turn', createdAt: now, turnId, state: 'running', origin: 'user', endedAt: null, costUsd: 0 })];
1868
+
1626
1869
  if (note !== null) {
1627
1870
  events.push(this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: note }));
1628
1871
  }
1872
+
1629
1873
  if (text !== null) {
1630
1874
  const mentions = extras.mentions?.length ? extras.mentions : undefined;
1631
1875
  const skills = extras.skills?.length ? extras.skills : undefined;
1632
1876
  const chats = extras.chats?.length ? extras.chats : undefined;
1633
1877
  const attachments = extras.attachments?.length ? extras.attachments : undefined;
1878
+
1634
1879
  events.push(
1635
1880
  this.thread.upsert({
1636
1881
  id: newId('user'),
@@ -1648,13 +1893,17 @@ export class ChatSession {
1648
1893
  })
1649
1894
  );
1650
1895
  }
1896
+
1651
1897
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
1652
1898
  this.emit(events);
1899
+
1653
1900
  if (extras.uiChoice) {
1654
1901
  this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1655
1902
  }
1903
+
1656
1904
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
1657
1905
  this.options.persist();
1906
+
1658
1907
  return turnId;
1659
1908
  }
1660
1909
 
@@ -1665,12 +1914,15 @@ export class ChatSession {
1665
1914
  private settleAgentTurn(): void {
1666
1915
  const turnId = this.thread.info.activeTurnId;
1667
1916
  const turn = turnId ? this.thread.get(turnId) : undefined;
1917
+
1668
1918
  if (turn?.kind !== 'turn' || turn.origin !== 'agent') {
1669
1919
  return;
1670
1920
  }
1921
+
1671
1922
  if (this.spokenTurnId === turn.id) {
1672
1923
  this.staleResults += 1;
1673
1924
  }
1925
+
1674
1926
  this.emit([this.thread.upsert({ ...turn, state: 'done', endedAt: Date.now() }), this.thread.patchInfo({ status: 'idle', activeTurnId: null })]);
1675
1927
  this.settleCheckpoint(turn.id);
1676
1928
  }
@@ -1678,16 +1930,21 @@ export class ChatSession {
1678
1930
  /* Records the folder's tree on the turn; a folder without git leaves the turn as it is. */
1679
1931
  private checkpoint(turnId: string): Promise<void> {
1680
1932
  const checkpoints = this.options.checkpoints;
1933
+
1681
1934
  if (!checkpoints) {
1682
1935
  return Promise.resolve();
1683
1936
  }
1937
+
1684
1938
  const cwd = this.thread.info.cwd;
1939
+
1685
1940
  return this.trackCheckpoint(
1686
1941
  Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null]).then(([tree, head]) => {
1687
1942
  const turn = this.thread.get(turnId);
1943
+
1688
1944
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1689
1945
  return;
1690
1946
  }
1947
+
1691
1948
  this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1692
1949
  })
1693
1950
  );
@@ -1696,17 +1953,22 @@ export class ChatSession {
1696
1953
  /* The diff of a turn that just ended, so a reload shows it without asking git again, and the tree a fork after it starts from. */
1697
1954
  private settleCheckpoint(turnId: string): void {
1698
1955
  const turn = this.thread.get(turnId);
1956
+
1699
1957
  if (turn?.kind !== 'turn' || turn.checkpoint === undefined || turn.checkpointDiff || !this.options.checkpoints) {
1700
1958
  return;
1701
1959
  }
1960
+
1702
1961
  const { checkpoints } = this.options;
1703
1962
  const cwd = this.thread.info.cwd;
1963
+
1704
1964
  void this.trackCheckpoint(
1705
1965
  Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null]).then(([answer, head]) => {
1706
1966
  const settled = this.thread.get(turnId);
1967
+
1707
1968
  if (answer === null || settled?.kind !== 'turn') {
1708
1969
  return;
1709
1970
  }
1971
+
1710
1972
  this.emit([
1711
1973
  this.thread.upsert({
1712
1974
  ...settled,
@@ -1715,6 +1977,7 @@ export class ChatSession {
1715
1977
  ...(head === null ? {} : { checkpointHeadAfter: head })
1716
1978
  })
1717
1979
  ]);
1980
+
1718
1981
  this.options.persist();
1719
1982
  })
1720
1983
  );
@@ -1724,6 +1987,7 @@ export class ChatSession {
1724
1987
  private trackCheckpoint(work: Promise<void>): Promise<void> {
1725
1988
  const tracked = work.catch(() => undefined).finally(() => this.checkpointWork.delete(tracked));
1726
1989
  this.checkpointWork.add(tracked);
1990
+
1727
1991
  return tracked;
1728
1992
  }
1729
1993
 
@@ -1736,6 +2000,7 @@ export class ChatSession {
1736
2000
  if (text.startsWith('/')) {
1737
2001
  return { preamble: null, note: null };
1738
2002
  }
2003
+
1739
2004
  const host = this.options.promptNotes?.next() ?? { shown: [], heard: [] };
1740
2005
  // Keep consumed notes in the submission until the CLI acknowledges them.
1741
2006
  const preambles = this.preambles;
@@ -1744,7 +2009,9 @@ export class ChatSession {
1744
2009
  const hints = this.pendingHints;
1745
2010
  this.pendingHints = [];
1746
2011
  const read = [...preambles, ...host.shown];
2012
+
1747
2013
  const joined = (parts: string[]): string | null => (parts.length === 0 ? null : parts.join('\n\n'));
2014
+
1748
2015
  return { preamble: joined([...read, ...host.heard, ...hints]), note: joined(read) };
1749
2016
  }
1750
2017
 
@@ -1753,8 +2020,10 @@ export class ChatSession {
1753
2020
  this.submission = { turnId, input: { ...input, promptId: randomUUID() } };
1754
2021
  const submission = this.submission;
1755
2022
  this.options.persist();
2023
+
1756
2024
  this.run((backend) => {
1757
2025
  backend.sendTurn(submission.input);
2026
+
1758
2027
  if (!backend.acknowledgesTurns) {
1759
2028
  this.acceptPrompt(submission.input.promptId!);
1760
2029
  }
@@ -1763,43 +2032,54 @@ export class ChatSession {
1763
2032
 
1764
2033
  private acceptPrompt(promptId: string): void {
1765
2034
  const submission = this.submission;
2035
+
1766
2036
  if (submission?.input.promptId !== promptId) {
1767
2037
  return;
1768
2038
  }
2039
+
1769
2040
  this.submission = null;
1770
2041
  const turn = this.thread.get(submission.turnId);
2042
+
1771
2043
  if (turn?.kind === 'turn' && (turn.deliveryPending || turn.resumePending)) {
1772
2044
  this.emit([this.thread.upsert({ ...turn, deliveryPending: undefined, resumePending: undefined })]);
1773
2045
  }
2046
+
1774
2047
  this.options.persist();
1775
2048
  }
1776
2049
 
1777
2050
  /* Runs one turn against the backend; a backend that will not start ends the turn with the reason. */
1778
2051
  private run(work: (backend: ChatBackend) => void): void {
1779
2052
  const turnId = this.info.activeTurnId;
2053
+
1780
2054
  if (turnId === null) {
1781
2055
  return;
1782
2056
  }
2057
+
1783
2058
  const pending = this.watchStart(turnId);
1784
2059
  let backend: Promise<ChatBackend>;
2060
+
1785
2061
  try {
1786
2062
  backend = this.ensureBackend();
1787
2063
  } catch (error) {
1788
2064
  // What the host prepares for a launch (its folders, its instructions) can throw too; the turn already opened, so it fails.
1789
- backend = Promise.reject(error instanceof Error ? error : new Error(String(error)));
2065
+ backend = Promise.reject(asError(error));
1790
2066
  }
2067
+
1791
2068
  const generation = this.generation;
2069
+
1792
2070
  void Promise.all([backend, this.turnReady, this.options.save?.()])
1793
2071
  .then(([started]) => {
1794
2072
  if (this.pendingStart !== pending || this.backend !== started || this.info.activeTurnId !== turnId || this.frozen) {
1795
2073
  return;
1796
2074
  }
2075
+
1797
2076
  this.clearStartWarning();
1798
2077
  work(started);
1799
2078
  })
1800
2079
  .catch((error: unknown) => {
1801
2080
  if (this.pendingStart === pending || (this.backend !== null && this.generation === generation && this.info.activeTurnId === turnId)) {
1802
2081
  this.clearStartWarning();
2082
+
1803
2083
  if (error instanceof ChatError && error.code === 'chat-busy') {
1804
2084
  this.pendingPreambles = this.preambles;
1805
2085
  this.submission = null;
@@ -1807,8 +2087,10 @@ export class ChatSession {
1807
2087
  this.emit(this.projector.project(generation, { type: 'turn.done', state: 'error', costUsd: 0, error: error.message }));
1808
2088
  this.settleCheckpoint(turnId);
1809
2089
  this.options.persist();
2090
+
1810
2091
  return;
1811
2092
  }
2093
+
1812
2094
  this.receive(generation, { type: 'failed', message: errorText(error), processAlive: this.running });
1813
2095
  }
1814
2096
  });
@@ -1817,6 +2099,7 @@ export class ChatSession {
1817
2099
  private watchStart(turnId: string): { turnId: string; warning: unknown } {
1818
2100
  this.clearStartWarning();
1819
2101
  const clock = this.options.clock ?? systemClock;
2102
+
1820
2103
  const pending = {
1821
2104
  turnId,
1822
2105
  warning: clock.setTimeout(() => {
@@ -1831,11 +2114,14 @@ export class ChatSession {
1831
2114
  text: 'The agent is still starting. You can stop this turn and try again.'
1832
2115
  })
1833
2116
  ]);
2117
+
1834
2118
  this.options.persist();
1835
2119
  }
1836
2120
  }, 30_000)
1837
2121
  };
2122
+
1838
2123
  this.pendingStart = pending;
2124
+
1839
2125
  return pending;
1840
2126
  }
1841
2127
 
@@ -1843,6 +2129,7 @@ export class ChatSession {
1843
2129
  if (this.pendingStart === null) {
1844
2130
  return;
1845
2131
  }
2132
+
1846
2133
  (this.options.clock ?? systemClock).clearTimeout(this.pendingStart.warning);
1847
2134
  this.pendingStart = null;
1848
2135
  }
@@ -1881,27 +2168,34 @@ export class ChatSession {
1881
2168
  if (this.processInUse()) {
1882
2169
  return Promise.reject(new ChatError('chat-busy', 'The current work and open requests must finish before the changes to this chat can apply'));
1883
2170
  }
2171
+
1884
2172
  // Let the old one go quietly; its events are dropped and its exit must not end the chat.
1885
2173
  this.backend.stop();
1886
2174
  this.backend = null;
1887
2175
  this.starting = null;
1888
2176
  }
2177
+
1889
2178
  if (this.backend && this.starting) {
1890
2179
  return this.starting;
1891
2180
  }
2181
+
1892
2182
  const projectConfig = this.loadsProjectConfig();
2183
+
1893
2184
  if (!projectConfig && !this.options.provider.ignoresProjectConfig) {
1894
2185
  return Promise.reject(
1895
2186
  new ChatError('project-config-unsupported', `${this.options.provider.name} cannot run without the configuration of its folder`)
1896
2187
  );
1897
2188
  }
2189
+
1898
2190
  let env: Record<string, string>;
2191
+
1899
2192
  try {
1900
2193
  env = this.options.env(this.thread.info.account);
1901
2194
  this.launchedAccount = this.thread.info.account;
1902
2195
  } catch (error) {
1903
- return Promise.reject(error instanceof Error ? error : new Error(String(error)));
2196
+ return Promise.reject(asError(error));
1904
2197
  }
2198
+
1905
2199
  this.generation = nextGeneration();
1906
2200
  const generation = this.generation;
1907
2201
  const info = this.thread.info;
@@ -1911,6 +2205,7 @@ export class ChatSession {
1911
2205
  const folders = this.options.folders?.() ?? [];
1912
2206
  this.launchedFolders = folders;
1913
2207
  this.launchedProjectConfig = projectConfig;
2208
+
1914
2209
  const launch: BackendLaunch = {
1915
2210
  command: this.options.command,
1916
2211
  cwd: info.cwd,
@@ -1931,7 +2226,9 @@ export class ChatSession {
1931
2226
  ...(projectConfig ? {} : { projectConfig }),
1932
2227
  ...(this.options.spawn ? { spawn: this.options.spawn } : {})
1933
2228
  };
2229
+
1934
2230
  const made: { backend: ChatBackend | null } = { backend: null };
2231
+
1935
2232
  try {
1936
2233
  made.backend = this.options.provider.createBackend(launch, {
1937
2234
  onEvent: (event) => {
@@ -1941,19 +2238,22 @@ export class ChatSession {
1941
2238
  }
1942
2239
  });
1943
2240
  } catch (error) {
1944
- return Promise.reject(error instanceof Error ? error : new Error(String(error)));
2241
+ return Promise.reject(asError(error));
1945
2242
  }
2243
+
1946
2244
  const backend = made.backend;
1947
2245
  this.backend = backend;
1948
2246
  this.replaceProcess = false;
1949
2247
  this.emit([this.thread.patchInfo({ running: true, effectiveRuntimeMode: undefined, permissionMode: undefined })]);
1950
2248
  let started: Promise<void>;
2249
+
1951
2250
  try {
1952
2251
  started = backend.start();
1953
2252
  } catch (error) {
1954
2253
  // A spawn that throws on the spot (no such executable) is a start that failed like any other.
1955
- started = Promise.reject(error instanceof Error ? error : new Error(String(error)));
2254
+ started = Promise.reject(asError(error));
1956
2255
  }
2256
+
1957
2257
  this.starting = started
1958
2258
  .then(() => backend)
1959
2259
  .catch((error: unknown) => {
@@ -1962,13 +2262,16 @@ export class ChatSession {
1962
2262
  this.backend = null;
1963
2263
  this.starting = null;
1964
2264
  }
2265
+
1965
2266
  throw error;
1966
2267
  });
2268
+
1967
2269
  return this.starting;
1968
2270
  }
1969
2271
 
1970
2272
  private awaitsResume(turnId: string, attempt: number): boolean {
1971
2273
  const turn = this.thread.get(turnId);
2274
+
1972
2275
  return (
1973
2276
  !this.frozen &&
1974
2277
  turn?.kind === 'turn' &&
@@ -1982,33 +2285,45 @@ export class ChatSession {
1982
2285
  if (this.frozen) {
1983
2286
  return;
1984
2287
  }
2288
+
1985
2289
  if (event.type === 'turn.accepted') {
1986
2290
  this.acceptPrompt(event.promptId);
2291
+
1987
2292
  return;
1988
2293
  }
2294
+
1989
2295
  if (event.type === 'limits') {
1990
2296
  this.options.onLimits?.(this.launchedAccount === undefined ? event.update : { ...event.update, account: this.launchedAccount });
2297
+
1991
2298
  return;
1992
2299
  }
2300
+
1993
2301
  if (event.type === 'title') {
1994
2302
  this.applyTitle(event.title);
2303
+
1995
2304
  return;
1996
2305
  }
2306
+
1997
2307
  if (event.type === 'stop.refused') {
1998
2308
  this.refuseStop(event.taskId, event.message);
2309
+
1999
2310
  return;
2000
2311
  }
2312
+
2001
2313
  /*
2002
2314
  * A CLI that counts its tokens but names no window, in a chat that has none yet: the catalog may have learned the
2003
2315
  * model's window since the chat began, as an ACP agent's catalog does from the first session that reports its models.
2004
2316
  */
2005
2317
  if (event.type === 'usage' && event.contextWindow === undefined && event.contextTokens !== undefined && this.thread.info.usage.contextWindow === null) {
2006
2318
  const contextWindow = this.options.provider.catalog.contextWindowFor(this.launchedSelection ?? this.thread.info.selection);
2319
+
2007
2320
  if (contextWindow !== null) {
2008
2321
  this.emit(this.projector.project(generation, { ...event, contextWindow }));
2322
+
2009
2323
  return;
2010
2324
  }
2011
2325
  }
2326
+
2012
2327
  /*
2013
2328
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
2014
2329
  * send does, so its report may not put the old size back under a meter that already reads the new one.
@@ -2017,66 +2332,88 @@ export class ChatSession {
2017
2332
  if (event.contextTokens === undefined) {
2018
2333
  return;
2019
2334
  }
2335
+
2020
2336
  this.emit(this.projector.project(generation, { type: 'usage', contextTokens: event.contextTokens }));
2337
+
2021
2338
  return;
2022
2339
  }
2340
+
2023
2341
  if (event.type === 'exit') {
2024
2342
  this.backend = null;
2025
2343
  this.starting = null;
2026
2344
  this.awaitingBackgroundReport = false;
2027
2345
  }
2346
+
2028
2347
  // A turn we settled ourselves still has its own `result` coming; it may not close the turn after it.
2029
2348
  if (event.type === 'turn.done' && event.promptId === undefined && this.staleResults > 0) {
2030
2349
  this.staleResults -= 1;
2350
+
2031
2351
  return;
2032
2352
  }
2353
+
2033
2354
  if (event.type === 'exit' || event.type === 'failed') {
2034
2355
  this.staleResults = 0;
2035
2356
  }
2357
+
2036
2358
  if (event.type === 'turn.done') {
2037
2359
  if (event.promptId !== undefined && this.submission !== null && this.submission.input.promptId !== event.promptId) {
2038
2360
  return;
2039
2361
  }
2362
+
2040
2363
  if (event.promptId !== undefined) {
2041
2364
  this.staleResults = 0;
2042
2365
  }
2366
+
2043
2367
  if (event.replaceProcess === true) {
2044
2368
  this.replaceProcess = true;
2045
2369
  }
2370
+
2046
2371
  const promptId = event.promptId ?? this.submission?.input.promptId;
2372
+
2047
2373
  if (promptId !== undefined) {
2048
2374
  this.acceptPrompt(promptId);
2049
2375
  }
2050
2376
  }
2377
+
2051
2378
  const openTurnId = this.thread.info.activeTurnId;
2379
+
2052
2380
  if ((event.type === 'turn.done' && event.state !== 'done') || ((event.type === 'failed' || event.type === 'exit') && openTurnId !== null)) {
2053
2381
  this.pauseQueue(true);
2054
2382
  }
2383
+
2055
2384
  // Settled as failed by the exit below; the transcript may still show that they finished first.
2056
2385
  const orphaned = event.type === 'exit' && this.info.provider === 'claude' ? this.runningBackgroundRows() : [];
2386
+
2057
2387
  if (event.type === 'turn.done' && event.state === 'aborted') {
2058
2388
  // A request of work beside the turn outlives the interrupt; turned down, that work goes on instead of waiting forever.
2059
2389
  for (const item of this.thread.pending()) {
2060
2390
  this.backend?.declineRequest?.(item.requestId, 'The user stopped the turn');
2061
2391
  }
2062
2392
  }
2393
+
2063
2394
  const hadNativeWork = (event.type === 'task.done' || event.type === 'tool.done' || event.type === 'background.ended') && this.hasNativeWork();
2064
2395
  const projected = this.projector.project(generation, event);
2396
+
2065
2397
  if (event.type === 'turn.done' || (event.type === 'failed' && event.processAlive !== true)) {
2066
2398
  this.awaitingBackgroundReport = false;
2067
2399
  } else if (event.type !== 'exit' && event.type !== 'failed' && hadNativeWork && !this.hasNativeWork() && reportsOnBackgroundWork(this.info.provider)) {
2068
2400
  this.awaitingBackgroundReport = true;
2069
2401
  }
2402
+
2070
2403
  this.emit(projected);
2071
2404
  this.trackSubagentOwner(generation, event);
2072
2405
  this.watchWorkflows(generation, event);
2406
+
2073
2407
  if (isMainAgentOutput(event)) {
2074
2408
  this.spokenTurnId = this.thread.info.activeTurnId;
2075
2409
  }
2410
+
2076
2411
  if (orphaned.length > 0) {
2077
2412
  void this.settleFromTranscripts(orphaned.flatMap((row) => this.thread.get(row.id) ?? []).filter((item) => item.kind === 'subagent'));
2078
2413
  }
2414
+
2079
2415
  const activeTurnId = this.thread.info.activeTurnId;
2416
+
2080
2417
  if (openTurnId === null && activeTurnId !== null) {
2081
2418
  // The CLI opened this turn itself; it takes a checkpoint like any other, so the card can
2082
2419
  // show what the agent changed while nobody was watching.
@@ -2084,20 +2421,26 @@ export class ChatSession {
2084
2421
  void this.checkpoint(activeTurnId);
2085
2422
  this.options.persist();
2086
2423
  }
2424
+
2087
2425
  if (event.type === 'turn.done' || event.type === 'exit' || event.type === 'failed') {
2088
2426
  this.options.persist();
2089
2427
  }
2428
+
2090
2429
  if (event.type === 'permissions' || event.type === 'session') {
2091
2430
  this.options.persist();
2092
2431
  }
2432
+
2093
2433
  if (openTurnId !== null && activeTurnId === null) {
2094
2434
  this.settleCheckpoint(openTurnId);
2095
2435
  }
2436
+
2096
2437
  if (event.type === 'session' && typeof event.title === 'string') {
2097
2438
  this.applyTitle(event.title);
2098
2439
  }
2440
+
2099
2441
  if (event.type === 'session' && this.thread.info.agentSessionId !== null) {
2100
2442
  this.refreshTitle();
2443
+
2101
2444
  if (this.titleTimer === null && this.thread.info.suggestedTitle === undefined) {
2102
2445
  this.titleTimer = setTimeout(() => {
2103
2446
  this.titleTimer = null;
@@ -2105,15 +2448,19 @@ export class ChatSession {
2105
2448
  }, TITLE_RECHECK_MS);
2106
2449
  }
2107
2450
  }
2451
+
2108
2452
  // The turn is over and the CLI is still there, so whatever waited behind it can go out now.
2109
2453
  if (event.type === 'turn.done') {
2110
2454
  this.refreshTitle();
2455
+
2111
2456
  if (event.state === 'done') {
2112
2457
  this.nameThread();
2113
2458
  }
2459
+
2114
2460
  if (event.limit !== undefined) {
2115
2461
  this.oweResume(true);
2116
2462
  }
2463
+
2117
2464
  this.drainQueue(event.state === 'aborted' && this.sendAfterStop);
2118
2465
  this.sendAfterStop = false;
2119
2466
  } else if (
@@ -2133,9 +2480,11 @@ export class ChatSession {
2133
2480
 
2134
2481
  private clearWorkflowWarnings(): void {
2135
2482
  const clock = this.options.clock ?? systemClock;
2483
+
2136
2484
  for (const timer of this.workflowWarnings.values()) {
2137
2485
  clock.clearTimeout(timer);
2138
2486
  }
2487
+
2139
2488
  this.workflowWarnings.clear();
2140
2489
  }
2141
2490
 
@@ -2143,15 +2492,20 @@ export class ChatSession {
2143
2492
  if (event.type === 'tool.output' || event.type === 'tool.progress') {
2144
2493
  // These come by the thousand, and only the call they name can be a workflow they move.
2145
2494
  const item = this.projector.itemOf(generation, event.ref);
2495
+
2146
2496
  if (item?.kind === 'tool' && item.name === 'Workflow') {
2147
2497
  this.watchWorkflow(item, true);
2148
2498
  }
2499
+
2149
2500
  return;
2150
2501
  }
2502
+
2151
2503
  if (!['tool.started', 'tool.done', 'workflow.progress', 'task.started', 'task.done', 'exit', 'failed'].includes(event.type)) {
2152
2504
  return;
2153
2505
  }
2506
+
2154
2507
  const ref = 'ref' in event ? event.ref : null;
2508
+
2155
2509
  for (const item of this.thread.list()) {
2156
2510
  if (item.kind === 'tool' && item.name === 'Workflow') {
2157
2511
  this.watchWorkflow(item, ref === item.toolUseId && ['workflow.progress', 'tool.started', 'tool.done', 'task.started'].includes(event.type));
@@ -2162,15 +2516,18 @@ export class ChatSession {
2162
2516
  private watchWorkflow(item: ChatToolItem, progress: boolean): void {
2163
2517
  const clock = this.options.clock ?? systemClock;
2164
2518
  const existing = this.workflowWarnings.get(item.id);
2519
+
2165
2520
  if (item.state !== 'running' || progress) {
2166
2521
  if (existing !== undefined) {
2167
2522
  clock.clearTimeout(existing);
2168
2523
  this.workflowWarnings.delete(item.id);
2169
2524
  }
2170
2525
  }
2526
+
2171
2527
  if (item.state !== 'running' || (!progress && existing !== undefined) || (!progress && item.workflow?.stalledAt !== undefined)) {
2172
2528
  return;
2173
2529
  }
2530
+
2174
2531
  if (progress) {
2175
2532
  this.emit([
2176
2533
  this.thread.upsert({
@@ -2179,14 +2536,17 @@ export class ChatSession {
2179
2536
  })
2180
2537
  ]);
2181
2538
  }
2539
+
2182
2540
  this.workflowWarnings.set(
2183
2541
  item.id,
2184
2542
  clock.setTimeout(() => {
2185
2543
  this.workflowWarnings.delete(item.id);
2186
2544
  const current = this.thread.get(item.id);
2545
+
2187
2546
  if (this.frozen || current?.kind !== 'tool' || current.state !== 'running') {
2188
2547
  return;
2189
2548
  }
2549
+
2190
2550
  this.emit([this.thread.upsert({ ...current, workflow: { name: null, phases: [], agents: [], ...current.workflow, stalledAt: clock.now() } })]);
2191
2551
  this.options.persist();
2192
2552
  }, 5 * 60_000)
@@ -2196,15 +2556,18 @@ export class ChatSession {
2196
2556
  /* Whether the CLI that reports was started on a pick standing for the same window as the chat's own. */
2197
2557
  private reportsCurrentWindow(): boolean {
2198
2558
  const catalog = this.options.provider.catalog;
2559
+
2199
2560
  return this.launchedSelection === null || catalog.contextWindowFor(this.launchedSelection) === catalog.contextWindowFor(this.thread.info.selection);
2200
2561
  }
2201
2562
 
2202
2563
  /* Rides on the info patch: a client that has the chat open hears it, and a reload finds it stored. */
2203
2564
  private refreshTitle(): void {
2204
2565
  const agentSessionId = this.thread.info.agentSessionId;
2566
+
2205
2567
  if (!this.options.readTitle || agentSessionId === null) {
2206
2568
  return;
2207
2569
  }
2570
+
2208
2571
  void this.options
2209
2572
  .readTitle(agentSessionId)
2210
2573
  .then((title) => {
@@ -2212,6 +2575,7 @@ export class ChatSession {
2212
2575
  if (title === null || title === this.thread.info.suggestedTitle || this.thread.info.agentSessionId !== agentSessionId) {
2213
2576
  return;
2214
2577
  }
2578
+
2215
2579
  this.emit([this.thread.patchInfo({ suggestedTitle: title })]);
2216
2580
  this.options.persistSoon();
2217
2581
  })
@@ -2221,9 +2585,11 @@ export class ChatSession {
2221
2585
  private applyTitle(raw: string): void {
2222
2586
  // Every info a client reads carries the title, so one it would refuse would leave the chat stuck there.
2223
2587
  const title = cleanTitle(raw);
2588
+
2224
2589
  if (title === null || title === this.thread.info.suggestedTitle) {
2225
2590
  return;
2226
2591
  }
2592
+
2227
2593
  this.emit([this.thread.patchInfo({ suggestedTitle: title })]);
2228
2594
  this.options.persistSoon();
2229
2595
  }
@@ -2235,28 +2601,36 @@ export class ChatSession {
2235
2601
  */
2236
2602
  private nameThread(): void {
2237
2603
  const name = this.options.nameThread;
2604
+
2238
2605
  if (!name || this.naming || this.thread.info.suggestedTitle !== undefined) {
2239
2606
  return;
2240
2607
  }
2608
+
2241
2609
  const items = this.thread.list();
2242
2610
  const done = items.filter((item) => item.kind === 'turn' && item.state === 'done');
2243
2611
  const prompt = items.find((item) => item.kind === 'user');
2612
+
2244
2613
  if (done.length !== 1 || prompt?.kind !== 'user' || prompt.text.trim() === '') {
2245
2614
  return;
2246
2615
  }
2616
+
2247
2617
  this.naming = true;
2248
2618
  const turnId = done[0]!.id;
2619
+
2249
2620
  const answer = items
2250
2621
  .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
2251
2622
  .filter((text) => text !== '')
2252
2623
  .join('\n\n');
2624
+
2253
2625
  const agentSessionId = this.thread.info.agentSessionId;
2626
+
2254
2627
  void name({ cwd: this.thread.info.cwd, prompt: prompt.text, answer })
2255
2628
  .then((title) => {
2256
2629
  // A clear in between started another conversation, and a name that came meanwhile is the CLI's own.
2257
2630
  if (title === null || this.thread.info.agentSessionId !== agentSessionId || this.thread.info.suggestedTitle !== undefined) {
2258
2631
  return;
2259
2632
  }
2633
+
2260
2634
  this.applyTitle(title);
2261
2635
  this.backend?.setTitle?.(title);
2262
2636
  })
@@ -2270,17 +2644,23 @@ export class ChatSession {
2270
2644
  /* Done for every row whose transcript shows the end, unless something else settled the row while it was read. */
2271
2645
  private async settleFromTranscripts(rows: ChatSubagentItem[]): Promise<void> {
2272
2646
  const lookup = this.options.subagentSettlement;
2647
+
2273
2648
  if (!lookup || rows.length === 0) {
2274
2649
  return;
2275
2650
  }
2651
+
2276
2652
  const settled: ChatSubagentItem[] = [];
2653
+
2277
2654
  for (const row of rows) {
2278
2655
  const settlement = await lookup(row.toolUseId);
2279
2656
  const current = this.thread.get(row.id);
2657
+
2280
2658
  if (settlement === null || current?.kind !== 'subagent' || current.status !== row.status || current.finishedAt !== row.finishedAt) {
2281
2659
  continue;
2282
2660
  }
2661
+
2283
2662
  this.orphans.delete(row.toolUseId);
2663
+
2284
2664
  settled.push({
2285
2665
  ...current,
2286
2666
  status: 'done',
@@ -2288,6 +2668,7 @@ export class ChatSession {
2288
2668
  result: settlement.report ?? current.result
2289
2669
  });
2290
2670
  }
2671
+
2291
2672
  this.settleRows(settled);
2292
2673
  }
2293
2674
 
@@ -2295,12 +2676,14 @@ export class ChatSession {
2295
2676
  if (this.frozen || rows.length === 0) {
2296
2677
  return;
2297
2678
  }
2679
+
2298
2680
  this.emit(rows.map((row) => this.thread.upsert(row)));
2299
2681
  this.options.persist();
2300
2682
  }
2301
2683
 
2302
2684
  private emit(events: ChatEvent[]): void {
2303
2685
  const delegating = this.delegatingChange(events);
2686
+
2304
2687
  for (const event of delegating === null ? events : [...events, delegating]) {
2305
2688
  this.uiReplies.pass(event);
2306
2689
  }
@@ -2311,10 +2694,13 @@ export class ChatSession {
2311
2694
  if (!events.some((event) => event.type === 'item' && isBackgroundWork(event.item))) {
2312
2695
  return null;
2313
2696
  }
2697
+
2314
2698
  const delegating = runningInBackground(this.thread.list()).length > 0;
2699
+
2315
2700
  if (delegating === (this.thread.info.delegating ?? false)) {
2316
2701
  return null;
2317
2702
  }
2703
+
2318
2704
  return this.thread.patchInfo({ delegating: delegating || undefined });
2319
2705
  }
2320
2706
  }
@@ -2330,18 +2716,23 @@ function settleStoredItem(item: ChatItem, resumeTurnId: string | null): ChatItem
2330
2716
  if (item.kind === 'assistant' && item.streaming) {
2331
2717
  return { ...item, streaming: false };
2332
2718
  }
2719
+
2333
2720
  if (item.kind === 'approval' && item.decision === 'pending') {
2334
2721
  return { ...item, decision: 'cancelled' };
2335
2722
  }
2723
+
2336
2724
  if (item.kind === 'question' && item.state === 'pending') {
2337
2725
  return { ...item, state: 'cancelled' };
2338
2726
  }
2727
+
2339
2728
  if (item.kind === 'tool' && item.state === 'running') {
2340
2729
  return { ...item, state: 'error' };
2341
2730
  }
2731
+
2342
2732
  // Aborted rather than error: the turn did not fail, the machine went down under it.
2343
2733
  if (item.kind === 'turn' && item.state === 'running' && item.id !== resumeTurnId) {
2344
2734
  return { ...item, state: 'aborted', endedAt: item.endedAt ?? item.createdAt };
2345
2735
  }
2736
+
2346
2737
  return item;
2347
2738
  }