@adecore/agents 0.23.1 → 0.24.1

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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/chat/attachment-store.js +4 -5
  3. package/dist/chat/backend.d.ts +6 -1
  4. package/dist/chat/background-work.d.ts +1 -1
  5. package/dist/chat/background-work.js +2 -1
  6. package/dist/chat/chat-core.d.ts +53 -161
  7. package/dist/chat/chat-core.js +146 -112
  8. package/dist/chat/chat-log.js +3 -3
  9. package/dist/chat/chat-process.d.ts +21 -0
  10. package/dist/chat/chat-process.js +109 -9
  11. package/dist/chat/chat-session.d.ts +10 -17
  12. package/dist/chat/chat-session.js +141 -182
  13. package/dist/chat/chat-store.d.ts +1 -0
  14. package/dist/chat/chat-store.js +105 -1
  15. package/dist/chat/claude-backend.d.ts +4 -0
  16. package/dist/chat/claude-backend.js +51 -27
  17. package/dist/chat/claude-protocol.d.ts +12 -0
  18. package/dist/chat/claude-protocol.js +137 -7
  19. package/dist/chat/claude-title.d.ts +1 -3
  20. package/dist/chat/claude-title.js +11 -39
  21. package/dist/chat/claude-transcript.d.ts +1 -0
  22. package/dist/chat/claude-transcript.js +36 -17
  23. package/dist/chat/codex-backend.d.ts +21 -1
  24. package/dist/chat/codex-backend.js +168 -10
  25. package/dist/chat/codex-protocol.d.ts +2 -0
  26. package/dist/chat/codex-protocol.js +37 -3
  27. package/dist/chat/codex-thread.js +1 -3
  28. package/dist/chat/codex-transport.d.ts +3 -0
  29. package/dist/chat/codex-transport.js +38 -35
  30. package/dist/chat/context-breakdown.js +2 -2
  31. package/dist/chat/errors.d.ts +1 -1
  32. package/dist/chat/external-sessions.js +2 -2
  33. package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
  34. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  35. package/dist/chat/index.d.ts +29 -0
  36. package/dist/chat/index.js +23 -0
  37. package/dist/chat/projector.d.ts +2 -1
  38. package/dist/chat/projector.js +12 -3
  39. package/dist/chat/request-summary.js +1 -4
  40. package/dist/chat/subagent-settlement.js +1 -3
  41. package/dist/chat/thread.d.ts +3 -0
  42. package/dist/chat/thread.js +20 -3
  43. package/dist/chat/ui-queries.d.ts +2 -0
  44. package/dist/chat/ui-queries.js +40 -5
  45. package/dist/chat/ui-replies.d.ts +33 -0
  46. package/dist/chat/ui-replies.js +152 -0
  47. package/dist/context/index.d.ts +2 -0
  48. package/dist/context/index.js +3 -0
  49. package/dist/coordination/index.d.ts +15 -0
  50. package/dist/coordination/index.js +16 -0
  51. package/dist/host/handlers.js +8 -0
  52. package/dist/host/index.d.ts +7 -0
  53. package/dist/host/index.js +7 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.js +10 -0
  56. package/dist/providers/accounts/status.js +1 -3
  57. package/dist/providers/claude.js +3 -1
  58. package/dist/providers/codex.js +3 -1
  59. package/dist/providers/index.d.ts +14 -0
  60. package/dist/providers/index.js +12 -0
  61. package/dist/run-process.js +54 -14
  62. package/dist/skills.d.ts +1 -0
  63. package/dist/skills.js +4 -0
  64. package/dist/tasks/task-coordinator.d.ts +4 -0
  65. package/dist/tasks/task-coordinator.js +16 -2
  66. package/dist/tasks/wiring.js +7 -1
  67. package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
  68. package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
  69. package/dist/testing/bin/fake-claude.d.ts +1 -0
  70. package/dist/testing/bin/fake-claude.js +6 -0
  71. package/dist/testing/bin/fake-codex.d.ts +1 -0
  72. package/dist/testing/bin/fake-codex.js +6 -0
  73. package/dist/{chat → testing}/fake-claude.d.ts +1 -1
  74. package/dist/{chat → testing}/fake-claude.js +0 -4
  75. package/dist/{chat → testing}/fake-cli.d.ts +1 -1
  76. package/dist/{chat → testing}/fake-codex.d.ts +1 -1
  77. package/dist/{chat → testing}/fake-codex.js +70 -8
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.js +7 -0
  80. package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
  81. package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
  82. package/dist/usage/exchange.d.ts +5 -3
  83. package/dist/usage/exchange.js +25 -16
  84. package/dist/usage/index-file.d.ts +1 -0
  85. package/dist/usage/index-file.js +20 -2
  86. package/dist/usage/index.d.ts +9 -0
  87. package/dist/usage/index.js +6 -0
  88. package/dist/usage/limits/normalize.js +1 -3
  89. package/dist/usage/pricing.d.ts +5 -3
  90. package/dist/usage/pricing.js +28 -19
  91. package/dist/usage/scanner.js +39 -11
  92. package/dist/usage/usage-service.js +8 -3
  93. package/package.json +72 -11
  94. package/src/chat/attachment-store.ts +4 -6
  95. package/src/chat/backend.ts +8 -0
  96. package/src/chat/background-work.ts +2 -2
  97. package/src/chat/chat-core.ts +221 -143
  98. package/src/chat/chat-log.ts +3 -3
  99. package/src/chat/chat-process.ts +128 -8
  100. package/src/chat/chat-session.ts +153 -204
  101. package/src/chat/chat-store.ts +104 -1
  102. package/src/chat/claude-backend.ts +58 -26
  103. package/src/chat/claude-protocol.ts +147 -8
  104. package/src/chat/claude-title.ts +11 -39
  105. package/src/chat/claude-transcript.ts +38 -18
  106. package/src/chat/codex-backend.ts +182 -20
  107. package/src/chat/codex-protocol.ts +40 -5
  108. package/src/chat/codex-thread.ts +1 -4
  109. package/src/chat/codex-transport.ts +45 -35
  110. package/src/chat/context-breakdown.ts +2 -2
  111. package/src/chat/errors.ts +1 -0
  112. package/src/chat/external-sessions.ts +2 -2
  113. package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
  114. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  115. package/src/chat/index.ts +49 -0
  116. package/src/chat/projector.ts +13 -4
  117. package/src/chat/request-summary.ts +8 -5
  118. package/src/chat/subagent-settlement.ts +1 -4
  119. package/src/chat/thread.ts +22 -3
  120. package/src/chat/ui-queries.ts +46 -5
  121. package/src/chat/ui-replies.ts +187 -0
  122. package/src/context/index.ts +26 -0
  123. package/src/coordination/index.ts +67 -0
  124. package/src/host/handlers.ts +8 -0
  125. package/src/host/index.ts +8 -0
  126. package/src/index.ts +20 -0
  127. package/src/providers/accounts/status.ts +1 -4
  128. package/src/providers/claude.ts +3 -1
  129. package/src/providers/codex.ts +3 -1
  130. package/src/providers/index.ts +15 -0
  131. package/src/run-process.ts +56 -16
  132. package/src/skills.ts +5 -0
  133. package/src/tasks/task-coordinator.ts +22 -2
  134. package/src/tasks/wiring.ts +7 -1
  135. package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
  136. package/src/testing/bin/fake-claude.ts +7 -0
  137. package/src/testing/bin/fake-codex.ts +7 -0
  138. package/src/{chat → testing}/fake-claude.ts +1 -5
  139. package/src/{chat → testing}/fake-cli.ts +1 -1
  140. package/src/{chat → testing}/fake-codex.ts +75 -6
  141. package/src/testing/index.ts +7 -0
  142. package/src/{outbox → testing}/manual-clock.ts +1 -1
  143. package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
  144. package/src/usage/exchange.ts +31 -20
  145. package/src/usage/index-file.ts +23 -2
  146. package/src/usage/index.ts +10 -0
  147. package/src/usage/limits/normalize.ts +1 -4
  148. package/src/usage/pricing.ts +34 -23
  149. package/src/usage/scanner.ts +48 -11
  150. package/src/usage/usage-service.ts +8 -3
  151. /package/dist/{chat → testing}/fake-cli.js +0 -0
  152. /package/dist/{outbox → testing}/manual-clock.js +0 -0
  153. /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
@@ -1,15 +1,16 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import type {
3
3
  ChatAttachment,
4
- ChatAssistantItem,
5
4
  ChatCheckpointDiff,
6
5
  ChatEvent,
6
+ ChatGoalAction,
7
7
  ChatInfo,
8
8
  ChatItem,
9
9
  ChatQueuedMessage,
10
10
  ChatQuoteSource,
11
11
  ChatSkill,
12
12
  ChatSubagentItem,
13
+ ChatToolItem,
13
14
  ChatTurnItem,
14
15
  ChatUiAnswer,
15
16
  ChatUiChoiceOrigin,
@@ -18,10 +19,8 @@ import type {
18
19
  ModelSelection,
19
20
  RuntimeMode
20
21
  } from '@adecore/agent-contracts';
21
- import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
22
- import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
23
- import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
24
- import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
22
+ import { HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
23
+ import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure } from '@adecore/intelligent-ui';
25
24
  import type { ChatProvider } from '../providers/provider.ts';
26
25
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
27
26
  import type { BackendEvent, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
@@ -34,6 +33,7 @@ import { isMainAgentOutput, ThreadProjector } from './projector.ts';
34
33
  import { readableAssistantText } from './readable-text.ts';
35
34
  import type { SubagentSettlement } from './subagent-settlement.ts';
36
35
  import { ChatThread } from './thread.ts';
36
+ import { UiReplies, type UiReplyHost } from './ui-replies.ts';
37
37
  import { errorText } from '../error-text.ts';
38
38
  import { systemClock, type OutboxClock } from '../outbox/outbox-worker.ts';
39
39
 
@@ -84,10 +84,7 @@ export interface ChatSessionOptions {
84
84
  checkpoints?: TurnCheckpoints;
85
85
  // The info string of the host's UI fences; `UI_FENCE_LANGUAGE` without one.
86
86
  uiFenceLanguage?: string;
87
- ui?: {
88
- schemas: UiCompileOptions['querySchemas'];
89
- observe(item: ChatAssistantItem, final: boolean): void;
90
- };
87
+ ui?: UiReplyHost;
91
88
  emit(event: ChatEvent): void;
92
89
  /* What a turn said about the plan it runs on. It belongs to the machine, so it leaves the chat. */
93
90
  onLimits?(update: LimitsUpdate): void;
@@ -117,8 +114,6 @@ export interface LimitResumeHooks {
117
114
 
118
115
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
119
116
  const TITLE_RECHECK_MS = 10_000;
120
- // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
121
- const UI_SCAN_OVERLAP = 64;
122
117
 
123
118
  export interface ChatSendExtras {
124
119
  uiChoice?: ChatUiChoiceOrigin;
@@ -206,40 +201,35 @@ export class ChatSession {
206
201
  private pendingPreambles: readonly string[];
207
202
  private submission: { turnId: string; input: TurnInput } | null = null;
208
203
  private readonly steering = new Set<string>();
209
- private readonly uiStreams = new Map<string, UiStream>();
210
- private readonly uiCompiled = new Map<string, UiStreamPreview>();
211
204
  private readonly capturedUiAccess: Map<string, unknown>;
212
- // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
213
- private readonly uiScanned = new Map<string, number>();
214
- private readonly uiReferencing = new Set<string>();
215
- private readonly uiAttachments = new Map<string | null, string | undefined>();
216
- private readonly uiFenceLanguage: string;
205
+ private readonly uiReplies: UiReplies;
217
206
 
218
207
  constructor(options: ChatSessionOptions) {
219
208
  this.options = options;
220
- this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
221
209
  this.pendingPreambles = options.preambles ?? [];
222
210
  this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
223
211
  this.thread = new ChatThread(options.info, options.items);
224
212
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
213
+ this.uiReplies = new UiReplies({
214
+ thread: this.thread,
215
+ fenceLanguage: options.uiFenceLanguage ?? UI_FENCE_LANGUAGE,
216
+ clock: options.clock ?? systemClock,
217
+ ...(options.ui ? { host: options.ui } : {}),
218
+ emit: (event) => options.emit(event),
219
+ repair: (note) => {
220
+ if (!this.pendingPreambles.includes(note)) {
221
+ this.pendingPreambles = [...this.pendingPreambles, note];
222
+ }
223
+ }
224
+ });
225
225
  }
226
226
 
227
227
  get uiPreviews(): ChatEvent[] {
228
- const events: ChatEvent[] = [];
229
- for (const [itemId, preview] of this.uiCompiled) {
230
- const item = this.thread.get(itemId);
231
- if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
232
- events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
233
- }
234
- }
235
- return events;
228
+ return this.uiReplies.previews();
236
229
  }
237
230
 
238
231
  previewItems(items: ChatItem[]): ChatItem[] {
239
- return items.map((item) => {
240
- const preview = this.uiCompiled.get(item.id);
241
- return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
242
- });
232
+ return this.uiReplies.previewItems(items);
243
233
  }
244
234
 
245
235
  get id(): string {
@@ -267,7 +257,37 @@ export class ChatSession {
267
257
  get busy(): boolean {
268
258
  const turnId = this.thread.info.activeTurnId;
269
259
  const turn = turnId ? this.thread.get(turnId) : undefined;
270
- return turnId !== null && (turn?.kind !== 'turn' || turn.origin !== 'agent');
260
+ // 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.
261
+ return turnId !== null && (turn?.kind !== 'turn' || turn.origin !== 'agent' || this.goalWorks());
262
+ }
263
+
264
+ /* Whether the CLI works toward a goal, which goes on in turns of its own until the goal is met, paused or cleared. */
265
+ private goalWorks(): boolean {
266
+ return this.thread.info.goal?.status === 'active';
267
+ }
268
+
269
+ /*
270
+ * Pauses, resumes or clears the chat's goal. A CLI with calls for it takes them at once, even while a goal
271
+ * turn runs; resuming an idle goal and clearing a goal of a CLI without them go out as `/goal` in a turn of
272
+ * their own, which a goal turn still running is in the way of.
273
+ */
274
+ async goal(action: ChatGoalAction): Promise<void> {
275
+ const supported = this.options.provider.capabilities.goals;
276
+ if (supported === undefined || (supported === 'command' && action !== 'clear')) {
277
+ throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot ${action} a goal`);
278
+ }
279
+ if (this.thread.info.goal === undefined) {
280
+ throw new ChatError('goal-not-found', 'This chat has no goal');
281
+ }
282
+ const backend = this.backend;
283
+ if (supported === 'native' && backend?.goal && backend.running && (action !== 'resume' || this.busy)) {
284
+ await backend.goal(action);
285
+ return;
286
+ }
287
+ if (this.busy) {
288
+ throw new ChatError('chat-busy', 'Stop the turn that works toward the goal first');
289
+ }
290
+ this.send(`/goal ${action}`);
271
291
  }
272
292
 
273
293
  /* The next process uses these settings; live work keeps the process it started in. */
@@ -456,6 +476,8 @@ export class ChatSession {
456
476
  extras.delivery !== 'steer' ||
457
477
  this.info.queuePaused ||
458
478
  text.startsWith('/') ||
479
+ // A goal runs as turns of its own; a message waits for the one running instead of joining it.
480
+ this.goalWorks() ||
459
481
  !this.busy ||
460
482
  !turnId ||
461
483
  !backend?.steerTurn ||
@@ -599,6 +621,10 @@ export class ChatSession {
599
621
  private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
600
622
  this.pauseQueue(false);
601
623
  this.settleAgentTurn();
624
+ // A goal that was met has said what it had to; the next message of the person leaves it behind.
625
+ if (this.thread.info.goal?.status === 'complete') {
626
+ this.emit([this.thread.patchInfo({ goal: undefined })]);
627
+ }
602
628
  const { preamble, note } = this.contextNote(text);
603
629
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
604
630
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
@@ -723,6 +749,22 @@ export class ChatSession {
723
749
  return true;
724
750
  }
725
751
 
752
+ /*
753
+ * Marks the chat as waiting on a person for something the host asks beside the CLI, such as a
754
+ * card a verb holds its call for. The chat reads `needs-you` until every key is released.
755
+ */
756
+ holdForPerson(key: string): void {
757
+ if (this.thread.hold(key)) {
758
+ this.emit([this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
759
+ }
760
+ }
761
+
762
+ releaseHold(key: string): void {
763
+ if (this.thread.release(key) && this.thread.info.status === 'needs-you') {
764
+ this.emit([this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
765
+ }
766
+ }
767
+
726
768
  /*
727
769
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
728
770
  * The thread resets at once; the returned promise lets the host wait for pending attachment
@@ -759,6 +801,7 @@ export class ChatSession {
759
801
  queuePaused: false,
760
802
  background: [],
761
803
  delegating: undefined,
804
+ goal: undefined,
762
805
  effectiveRuntimeMode: undefined,
763
806
  permissionMode: undefined,
764
807
  slashCommands: [],
@@ -1149,7 +1192,7 @@ export class ChatSession {
1149
1192
  const settled =
1150
1193
  item.kind === 'turn' && item.state === 'running'
1151
1194
  ? { ...item, state: 'aborted' as const, endedAt: now }
1152
- : item.kind === 'subagent' && item.origin !== 'ruimte' && item.status === 'running'
1195
+ : item.kind === 'subagent' && item.origin !== HOST_TASK_ORIGIN && item.status === 'running'
1153
1196
  ? { ...item, status: 'failed' as const, finishedAt: now }
1154
1197
  : settleStoredItem(item, null);
1155
1198
  if (settled !== item) {
@@ -1174,9 +1217,30 @@ export class ChatSession {
1174
1217
  this.options.persist();
1175
1218
  }
1176
1219
 
1220
+ /*
1221
+ * Ends the CLI and keeps the conversation: the next message starts a fresh process that resumes it,
1222
+ * and with it the skills, plugins and servers the CLI reads at its start. Refused while a turn runs or
1223
+ * the process still holds work, a request or a background command, since those would end with it.
1224
+ */
1225
+ restart(): Promise<void> {
1226
+ if (this.thread.info.activeTurnId !== null || this.processInUse()) {
1227
+ throw new ChatError('chat-busy', `Chat ${this.id} is still working; stop it before restarting its agent session`);
1228
+ }
1229
+ this.clearStartWarning();
1230
+ this.clearWorkflowWarnings();
1231
+ const backend = this.backend;
1232
+ this.backend = null;
1233
+ this.starting = null;
1234
+ this.launchedSelection = null;
1235
+ // What the init frame of the process that goes announced; a skill added since would be filtered out by it.
1236
+ this.emit([this.thread.patchInfo({ running: false, effectiveRuntimeMode: undefined, permissionMode: undefined, skills: undefined })]);
1237
+ this.options.persist();
1238
+ return backend?.dispose() ?? Promise.resolve();
1239
+ }
1240
+
1177
1241
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
1178
1242
  freeze(): void {
1179
- this.clearUi();
1243
+ this.uiReplies.clear();
1180
1244
  this.clearStartWarning();
1181
1245
  this.clearWorkflowWarnings();
1182
1246
  this.frozen = true;
@@ -1185,7 +1249,7 @@ export class ChatSession {
1185
1249
 
1186
1250
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
1187
1251
  dispose(): Promise<void> {
1188
- this.clearUi();
1252
+ this.uiReplies.clear();
1189
1253
  this.clearStartWarning();
1190
1254
  this.clearWorkflowWarnings();
1191
1255
  if (this.titleTimer !== null) {
@@ -1351,7 +1415,9 @@ export class ChatSession {
1351
1415
  /* Marks every running subagent of the CLI's own as stopped, for a person stopping the turn together with them. */
1352
1416
  markSubagentsStopped(): void {
1353
1417
  this.markStopped(
1354
- this.thread.list().filter((item): item is ChatSubagentItem => item.kind === 'subagent' && item.status === 'running' && item.origin !== 'ruimte')
1418
+ this.thread
1419
+ .list()
1420
+ .filter((item): item is ChatSubagentItem => item.kind === 'subagent' && item.status === 'running' && item.origin !== HOST_TASK_ORIGIN)
1355
1421
  );
1356
1422
  }
1357
1423
 
@@ -1650,7 +1716,7 @@ export class ChatSession {
1650
1716
  }
1651
1717
 
1652
1718
  private processInUse(): boolean {
1653
- return this.hasNativeWork() || this.thread.pending().length > 0 || this.awaitingBackgroundReport;
1719
+ return this.hasNativeWork() || this.thread.pending().length > 0 || this.awaitingBackgroundReport || this.goalWorks();
1654
1720
  }
1655
1721
 
1656
1722
  private replacementWaits(): boolean {
@@ -1696,6 +1762,7 @@ export class ChatSession {
1696
1762
  saveGeneratedImage: this.options.saveGeneratedImage,
1697
1763
  instructions: this.options.instructions?.() ?? null,
1698
1764
  resumeNote: this.options.resumeNote?.() ?? null,
1765
+ ...(info.goal === undefined ? {} : { goal: info.goal }),
1699
1766
  ...(folders.length > 0 ? { folders } : {}),
1700
1767
  ...(this.options.spawn ? { spawn: this.options.spawn } : {})
1701
1768
  };
@@ -1822,7 +1889,7 @@ export class ChatSession {
1822
1889
  }
1823
1890
  this.emit(projected);
1824
1891
  this.trackSubagentOwner(generation, event);
1825
- this.watchWorkflows(event);
1892
+ this.watchWorkflows(generation, event);
1826
1893
  if (isMainAgentOutput(event)) {
1827
1894
  this.spokenTurnId = this.thread.info.activeTurnId;
1828
1895
  }
@@ -1892,56 +1959,58 @@ export class ChatSession {
1892
1959
  this.workflowWarnings.clear();
1893
1960
  }
1894
1961
 
1895
- private watchWorkflows(event: BackendEvent): void {
1896
- if (
1897
- !['tool.started', 'tool.progress', 'tool.output', 'tool.done', 'workflow.progress', 'task.started', 'task.done', 'exit', 'failed'].includes(
1898
- event.type
1899
- )
1900
- ) {
1962
+ private watchWorkflows(generation: number, event: BackendEvent): void {
1963
+ if (event.type === 'tool.output' || event.type === 'tool.progress') {
1964
+ // These come by the thousand, and only the call they name can be a workflow they move.
1965
+ const item = this.projector.itemOf(generation, event.ref);
1966
+ if (item?.kind === 'tool' && item.name === 'Workflow') {
1967
+ this.watchWorkflow(item, true);
1968
+ }
1969
+ return;
1970
+ }
1971
+ if (!['tool.started', 'tool.done', 'workflow.progress', 'task.started', 'task.done', 'exit', 'failed'].includes(event.type)) {
1901
1972
  return;
1902
1973
  }
1903
- const clock = this.options.clock ?? systemClock;
1904
1974
  const ref = 'ref' in event ? event.ref : null;
1905
1975
  for (const item of this.thread.list()) {
1906
- if (item.kind !== 'tool' || item.name !== 'Workflow') {
1907
- continue;
1908
- }
1909
- const existing = this.workflowWarnings.get(item.id);
1910
- const progress =
1911
- ref === item.toolUseId &&
1912
- ['workflow.progress', 'tool.started', 'tool.progress', 'tool.output', 'tool.done', 'task.started'].includes(event.type);
1913
- if (item.state !== 'running' || progress) {
1914
- if (existing !== undefined) {
1915
- clock.clearTimeout(existing);
1916
- this.workflowWarnings.delete(item.id);
1917
- }
1918
- }
1919
- if (item.state !== 'running' || (!progress && existing !== undefined) || (!progress && item.workflow?.stalledAt !== undefined)) {
1920
- continue;
1976
+ if (item.kind === 'tool' && item.name === 'Workflow') {
1977
+ this.watchWorkflow(item, ref === item.toolUseId && ['workflow.progress', 'tool.started', 'tool.done', 'task.started'].includes(event.type));
1921
1978
  }
1922
- if (progress) {
1923
- this.emit([
1924
- this.thread.upsert({
1925
- ...item,
1926
- workflow: { name: null, phases: [], agents: [], ...item.workflow, lastProgressAt: clock.now(), stalledAt: undefined }
1927
- })
1928
- ]);
1979
+ }
1980
+ }
1981
+
1982
+ private watchWorkflow(item: ChatToolItem, progress: boolean): void {
1983
+ const clock = this.options.clock ?? systemClock;
1984
+ const existing = this.workflowWarnings.get(item.id);
1985
+ if (item.state !== 'running' || progress) {
1986
+ if (existing !== undefined) {
1987
+ clock.clearTimeout(existing);
1988
+ this.workflowWarnings.delete(item.id);
1929
1989
  }
1930
- this.workflowWarnings.set(
1931
- item.id,
1932
- clock.setTimeout(() => {
1933
- this.workflowWarnings.delete(item.id);
1934
- const current = this.thread.get(item.id);
1935
- if (this.frozen || current?.kind !== 'tool' || current.state !== 'running') {
1936
- return;
1937
- }
1938
- this.emit([
1939
- this.thread.upsert({ ...current, workflow: { name: null, phases: [], agents: [], ...current.workflow, stalledAt: clock.now() } })
1940
- ]);
1941
- this.options.persist();
1942
- }, 5 * 60_000)
1943
- );
1944
1990
  }
1991
+ if (item.state !== 'running' || (!progress && existing !== undefined) || (!progress && item.workflow?.stalledAt !== undefined)) {
1992
+ return;
1993
+ }
1994
+ if (progress) {
1995
+ this.emit([
1996
+ this.thread.upsert({
1997
+ ...item,
1998
+ workflow: { name: null, phases: [], agents: [], ...item.workflow, lastProgressAt: clock.now(), stalledAt: undefined }
1999
+ })
2000
+ ]);
2001
+ }
2002
+ this.workflowWarnings.set(
2003
+ item.id,
2004
+ clock.setTimeout(() => {
2005
+ this.workflowWarnings.delete(item.id);
2006
+ const current = this.thread.get(item.id);
2007
+ if (this.frozen || current?.kind !== 'tool' || current.state !== 'running') {
2008
+ return;
2009
+ }
2010
+ this.emit([this.thread.upsert({ ...current, workflow: { name: null, phases: [], agents: [], ...current.workflow, stalledAt: clock.now() } })]);
2011
+ this.options.persist();
2012
+ }, 5 * 60_000)
2013
+ );
1945
2014
  }
1946
2015
 
1947
2016
  /* Whether the CLI that reports was started on a pick standing for the same window as the chat's own. */
@@ -2050,129 +2119,9 @@ export class ChatSession {
2050
2119
 
2051
2120
  private emit(events: ChatEvent[]): void {
2052
2121
  const delegating = this.delegatingChange(events);
2053
- for (let event of delegating === null ? events : [...events, delegating]) {
2054
- if (event.type === 'reset') {
2055
- this.clearUi();
2056
- }
2057
- if (event.type === 'item' && event.item.kind === 'tool') {
2058
- this.uiAttachments.delete(event.item.turnId);
2059
- }
2060
- const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
2061
- if (item?.kind !== 'assistant' || item.parentToolUseId) {
2062
- this.options.emit(event);
2063
- continue;
2064
- }
2065
- const tail = this.unscannedUiText(item, event.type === 'item');
2066
- if (uiMayReferenceHost(tail)) {
2067
- this.uiReferencing.add(item.id);
2068
- }
2069
- let stream = this.uiStreams.get(item.id);
2070
- if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
2071
- this.forgetUiScan(item);
2072
- this.options.emit(event);
2073
- continue;
2074
- }
2075
- if (!stream) {
2076
- stream = new UiStream({
2077
- querySchemas: this.options.ui?.schemas,
2078
- fenceLanguage: this.uiFenceLanguage,
2079
- id: item.id,
2080
- clock: this.options.clock ?? systemClock,
2081
- emit: (preview) => {
2082
- this.uiCompiled.set(item.id, preview);
2083
- this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
2084
- }
2085
- });
2086
- this.uiStreams.set(item.id, stream);
2087
- }
2088
- if (this.uiReferencing.has(item.id)) {
2089
- this.options.ui?.observe(item, false);
2090
- }
2091
- this.forgetUiScan(item);
2092
- const latestAttachment = this.latestUiAttachment(item.turnId);
2093
- if (event.type === 'item' && !item.streaming) {
2094
- const ui = stream
2095
- .finish(item.text, latestAttachment)
2096
- .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
2097
- this.uiStreams.delete(item.id);
2098
- this.uiCompiled.delete(item.id);
2099
- const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
2100
- if (diagnostics.length > 0) {
2101
- const repair =
2102
- `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
2103
- 0,
2104
- 1200
2105
- );
2106
- if (!this.pendingPreambles.includes(repair)) {
2107
- this.pendingPreambles = [...this.pendingPreambles, repair];
2108
- }
2109
- }
2110
- const current = this.thread.get(item.id);
2111
- const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
2112
- event = this.thread.upsert(completed);
2113
- this.options.emit(event);
2114
- this.options.ui?.observe(completed, true);
2115
- } else {
2116
- this.options.emit(event);
2117
- stream.update(item.text, latestAttachment);
2118
- }
2119
- }
2120
- }
2121
-
2122
- /*
2123
- * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
2124
- * is one line; a long line is cut short, behind a character no fence or tag can follow.
2125
- */
2126
- private unscannedUiText(item: ChatAssistantItem, whole: boolean): string {
2127
- const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
2128
- this.uiScanned.set(item.id, item.text.length);
2129
- if (scanned === 0 || scanned > item.text.length) {
2130
- return item.text;
2131
- }
2132
- const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
2133
- const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
2134
- return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
2135
- }
2136
-
2137
- private forgetUiScan(item: ChatAssistantItem): void {
2138
- if (!item.streaming) {
2139
- this.uiScanned.delete(item.id);
2140
- this.uiReferencing.delete(item.id);
2141
- }
2142
- }
2143
-
2144
- /* Looked up once per turn, and again only after one of its tool calls changed. */
2145
- private latestUiAttachment(turnId: string | null): string | undefined {
2146
- if (this.uiAttachments.has(turnId)) {
2147
- return this.uiAttachments.get(turnId);
2148
- }
2149
- const attachment = this.findUiAttachment(turnId);
2150
- this.uiAttachments.set(turnId, attachment);
2151
- return attachment;
2152
- }
2153
-
2154
- private findUiAttachment(turnId: string | null): string | undefined {
2155
- for (const item of this.thread.list().toReversed()) {
2156
- if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
2157
- continue;
2158
- }
2159
- const input = GeneratedImageInputSchema.safeParse(item.input);
2160
- if (input.success && input.data.attachment?.mime.startsWith('image/')) {
2161
- return input.data.attachment.id;
2162
- }
2163
- }
2164
- return undefined;
2165
- }
2166
-
2167
- private clearUi(): void {
2168
- for (const stream of this.uiStreams.values()) {
2169
- stream.dispose();
2122
+ for (const event of delegating === null ? events : [...events, delegating]) {
2123
+ this.uiReplies.pass(event);
2170
2124
  }
2171
- this.uiStreams.clear();
2172
- this.uiCompiled.clear();
2173
- this.uiScanned.clear();
2174
- this.uiReferencing.clear();
2175
- this.uiAttachments.clear();
2176
2125
  }
2177
2126
 
2178
2127
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync } from 'node:fs';
2
- import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
2
+ import { mkdir, open, readdir, readFile, rm, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { ChatInfoSchema, ChatItemSchema, type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
5
5
  import { z } from 'zod';
@@ -57,6 +57,7 @@ function logName(chatId: string): string {
57
57
  return `${encodeURIComponent(chatId)}.log`;
58
58
  }
59
59
 
60
+ // `settled` reads the info from the head of the record, so it stays the first key.
60
61
  function recordBody(
61
62
  info: ChatInfo,
62
63
  items: ChatItem[],
@@ -223,6 +224,19 @@ export class ChatStore {
223
224
  return (await this.readSnapshot(chatId)) === UNREADABLE;
224
225
  }
225
226
 
227
+ /*
228
+ * Whether the chat has neither a turn in flight nor a resume after a limit, by the info its record
229
+ * starts with and the last one its log wrote, without parsing the thread. False whenever that
230
+ * cannot be told so cheaply, which leaves the decision to `read`.
231
+ */
232
+ async settled(chatId: string): Promise<boolean> {
233
+ if (!settledInfo(await leadingInfo(join(this.dir, recordFileName(chatId))))) {
234
+ return false;
235
+ }
236
+ const last = lastLoggedInfo((await readOrNull(this.logPath(chatId))) ?? '');
237
+ return last === undefined || settledInfo(last);
238
+ }
239
+
226
240
  private async readSnapshot(chatId: string): Promise<z.infer<typeof RecordSchema> | typeof UNREADABLE | null> {
227
241
  const raw = await readOrNull(join(this.dir, recordFileName(chatId)));
228
242
  if (raw === null) {
@@ -253,6 +267,95 @@ export class ChatStore {
253
267
 
254
268
  const UNREADABLE = Symbol('unreadable');
255
269
 
270
+ const RECORD_HEAD = Buffer.from('{"info":{');
271
+ const HEAD_CHUNK_BYTES = 16384;
272
+ const QUOTE = 0x22;
273
+ const BACKSLASH = 0x5c;
274
+ const OPEN_BRACE = 0x7b;
275
+ const CLOSE_BRACE = 0x7d;
276
+
277
+ /*
278
+ * The info a record starts with, read only as far as it goes; undefined for a record that is missing,
279
+ * starts otherwise (an older or hand-edited one) or ends before its info does. Scanning bytes is safe
280
+ * in UTF-8, where no byte of a multibyte character equals a quote, a backslash or a brace.
281
+ */
282
+ async function leadingInfo(path: string): Promise<unknown> {
283
+ let handle: Awaited<ReturnType<typeof open>>;
284
+ try {
285
+ handle = await open(path, 'r');
286
+ } catch (e) {
287
+ if (isNotFound(e)) {
288
+ return undefined;
289
+ }
290
+ throw e;
291
+ }
292
+ try {
293
+ let head = Buffer.alloc(0);
294
+ let at = RECORD_HEAD.length;
295
+ let depth = 1;
296
+ let inString = false;
297
+ let escaped = false;
298
+ const chunk = Buffer.alloc(HEAD_CHUNK_BYTES);
299
+ let { bytesRead } = await handle.read(chunk, 0, chunk.length, 0);
300
+ while (bytesRead > 0) {
301
+ head = Buffer.concat([head, chunk.subarray(0, bytesRead)]);
302
+ if (!head.subarray(0, RECORD_HEAD.length).equals(RECORD_HEAD.subarray(0, Math.min(head.length, RECORD_HEAD.length)))) {
303
+ return undefined;
304
+ }
305
+ for (; at < head.length; at++) {
306
+ const byte = head[at]!;
307
+ if (escaped) {
308
+ escaped = false;
309
+ } else if (inString) {
310
+ escaped = byte === BACKSLASH;
311
+ inString = byte !== QUOTE;
312
+ } else if (byte === QUOTE) {
313
+ inString = true;
314
+ } else if (byte === OPEN_BRACE) {
315
+ depth += 1;
316
+ } else if (byte === CLOSE_BRACE) {
317
+ depth -= 1;
318
+ if (depth === 0) {
319
+ return parseOrUndefined(head.subarray(RECORD_HEAD.length - 1, at + 1).toString('utf8'));
320
+ }
321
+ }
322
+ }
323
+ ({ bytesRead } = await handle.read(chunk, 0, chunk.length, head.length));
324
+ }
325
+ return undefined;
326
+ } finally {
327
+ await handle.close();
328
+ }
329
+ }
330
+
331
+ /* The info of the last info or reset line in a log, unvalidated; undefined when no line carries one. */
332
+ function lastLoggedInfo(raw: string): unknown {
333
+ const lines = raw.split('\n');
334
+ for (let i = lines.length - 1; i >= 0; i--) {
335
+ // Most lines are items and deltas, which are larger and never carry this key.
336
+ if (!lines[i]!.includes('"info"')) {
337
+ continue;
338
+ }
339
+ const event = (parseOrUndefined(lines[i]!) as { event?: { type?: unknown; info?: unknown } } | null | undefined)?.event;
340
+ if (event?.type === 'info' || event?.type === 'reset') {
341
+ return event.info;
342
+ }
343
+ }
344
+ return undefined;
345
+ }
346
+
347
+ function settledInfo(info: unknown): boolean {
348
+ return typeof info === 'object' && info !== null && (info as { activeTurnId?: unknown }).activeTurnId === null && !('resumeAt' in info);
349
+ }
350
+
351
+ function parseOrUndefined(text: string): unknown {
352
+ try {
353
+ return JSON.parse(text);
354
+ } catch {
355
+ return undefined;
356
+ }
357
+ }
358
+
256
359
  async function exists(path: string): Promise<boolean> {
257
360
  try {
258
361
  await stat(path);